Add ESLint and Prettier, and close the node test gaps

Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
  measured against the existing style); Markdown excluded because table-cell
  padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
  "this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
  tests in one go.

Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.

Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
  when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
  checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
  events now; the DOM handlers are thin adapters.

Compare fell back to == without a configured operator although the schema
default is <; aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 10:02:22 +02:00
1 parent f140c2fd77
commit d54a620ebd
113 files changed
+5049 -666

No files matched your search

+1 -3
View File
@@ -62,9 +62,7 @@ export class FocusManager {
const list = collectFocusables(this.root);
if (list.length === 0) return this.focus(null);
const index = this.focusedWidget ? list.indexOf(this.focusedWidget) : -1;
const next = backwards
? (index <= 0 ? list.length : index) - 1
: (index + 1) % list.length;
const next = backwards ? (index <= 0 ? list.length : index) - 1 : (index + 1) % list.length;
this.focus(list[next] ?? null);
}
+6 -1
View File
@@ -66,7 +66,12 @@ export class KeyboardBridge {
}
/** Start editing: mirror the field's value into the input and grab DOM focus. */
attach(target: TextEditTarget, value: string, selectionStart = value.length, selectionEnd = selectionStart): void {
attach(
target: TextEditTarget,
value: string,
selectionStart = value.length,
selectionEnd = selectionStart,
): void {
this.suppressBlur = true;
this.target = target;
this.input.value = value;
+2 -6
View File
@@ -1,4 +1,4 @@
import type { Application, Container, FederatedPointerEvent } from 'pixi.js';
import type { Application, Container } from 'pixi.js';
import { FocusManager } from './FocusManager';
import { KeyboardBridge } from './KeyboardBridge';
@@ -21,11 +21,7 @@ export function initUI(app: Application, root: Container, overlay: Container): U
context = { app, focus: new FocusManager(root), keyboard: new KeyboardBridge(), overlay };
// Capture phase on the stage runs before any widget handler: a press outside the focused
// widget blurs it (committing text edits) before the press can change selection.
app.stage.addEventListener(
'pointerdown',
(e) => context?.focus.blurIfOutside((e as FederatedPointerEvent).target as Container),
{ capture: true },
);
app.stage.addEventListener('pointerdown', (e) => context?.focus.blurIfOutside(e.target), { capture: true });
// Clicking the canvas must not steal DOM focus from the keyboard bridge input.
app.canvas.addEventListener('mousedown', (e) => e.preventDefault());
app.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
+1 -1
View File
@@ -46,7 +46,7 @@ export function hsvToRgb({ h, s, v }: HSV): number {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
let r: number, g: number, b: number;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
+4 -1
View File
@@ -17,7 +17,10 @@ export abstract class Stack extends Widget {
padding: number;
align: NonNullable<StackOptions['align']>;
constructor(protected readonly axis: 'x' | 'y', options: StackOptions = {}) {
constructor(
protected readonly axis: 'x' | 'y',
options: StackOptions = {},
) {
super();
this.gap = options.gap ?? 0;
this.padding = options.padding ?? 0;
+19 -5
View File
@@ -34,7 +34,9 @@ export class Button extends Widget {
this.addChild(this.bg, this.labelText);
// Leaving while pressed cancels the click: a release elsewhere must not activate.
this.trackHover(() => { this.pressed = false; });
this.trackHover(() => {
this.pressed = false;
});
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button !== 0) return;
this.pressed = true;
@@ -89,10 +91,22 @@ export class Button extends Widget {
const flat = this.variant === 'default' || this.variant === 'ghost';
let fill: number | null = c.control;
let border: number | null = c.border;
if (this.variant === 'primary') { fill = c.accent; border = null; }
if (this.variant === 'danger') { fill = c.danger; border = null; }
if (this.variant === 'ghost') { fill = null; border = null; }
if (this.toggled) { fill = c.accent; border = null; }
if (this.variant === 'primary') {
fill = c.accent;
border = null;
}
if (this.variant === 'danger') {
fill = c.danger;
border = null;
}
if (this.variant === 'ghost') {
fill = null;
border = null;
}
if (this.toggled) {
fill = c.accent;
border = null;
}
if (this.hovered && !this.toggled && flat) fill = c.controlHover;
if (this.pressed && flat) fill = c.controlActive;
const stroke = this.focused ? c.accent : border;
+5 -1
View File
@@ -54,7 +54,11 @@ export class Checkbox extends Widget {
y,
});
if (this.checked) {
this.box.moveTo(4, y + 8.5).lineTo(7, y + 11.5).lineTo(12.5, y + 5).stroke({ width: 2, color: c.accentText });
this.box
.moveTo(4, y + 8.5)
.lineTo(7, y + 11.5)
.lineTo(12.5, y + 5)
.stroke({ width: 2, color: c.accentText });
}
}
}
+3 -1
View File
@@ -54,7 +54,9 @@ export class ColorPicker extends Popup {
this.applyPointer(local.x, local.y);
});
// The press starts on the body; its release bubbles up to the popup either way.
this.onPointerRelease(() => { this.dragging = null; });
this.onPointerRelease(() => {
this.dragging = null;
});
this.content.add(this.body);
this.renderSv();
}
+3 -1
View File
@@ -22,7 +22,9 @@ export class Dialog extends Popup {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.add(new Spacer(), 1); // pushes the buttons to the right
for (const b of buttons) {
const button = new Button(b.label, { variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default' });
const button = new Button(b.label, {
variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default',
});
button.onClick = this.closeThen(b.onClick);
row.add(button);
}
+28 -6
View File
@@ -20,7 +20,11 @@ export class ListRow extends Widget {
private selected = false;
private listFocused = false;
constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) {
constructor(
readonly item: ListItem,
height: number,
private readonly chevronSlot: boolean,
) {
super();
this.cursor = 'pointer';
this.minHeight = height;
@@ -59,12 +63,17 @@ export class ListRow extends Widget {
const depth = this.item.depth ?? 0;
const indent = this.indent;
this.bg.clear();
if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
if (this.selected)
this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover);
// Tree guide: a short "└" mark in front of nested rows.
if (depth > 0) {
const gx = indent - 8;
this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border });
this.bg
.moveTo(gx, 0)
.lineTo(gx, this.h / 2)
.lineTo(indent - 2, this.h / 2)
.stroke({ width: 1, color: c.border });
}
let x = indent;
if (this.chevronSlot) {
@@ -86,13 +95,26 @@ export class ListRow extends Widget {
const hintW = this.hint.text ? this.hint.width + Theme.padding : 0;
this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding);
this.labelText.position.set(x, centreY(this.h, this.labelText.height));
this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), centreY(this.h, this.hint.height));
this.hint.position.set(
Math.round(this.w - Theme.padding - this.hint.width),
centreY(this.h, this.hint.height),
);
}
/** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */
private drawChevron(x: number, y: number, expanded: boolean): void {
const color = this.selected ? Theme.colors.text : Theme.colors.textMuted;
if (expanded) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color);
else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color);
if (expanded)
this.bg
.moveTo(x - 3, y - 2)
.lineTo(x + 5, y - 2)
.lineTo(x + 1, y + 3)
.fill(color);
else
this.bg
.moveTo(x - 1, y - 4)
.lineTo(x + 4, y)
.lineTo(x - 1, y + 4)
.fill(color);
}
}
+2
View File
@@ -124,6 +124,8 @@ export class NumberField extends TextField {
/** Clamps to min/max; NaN/Infinity keep the current value (0 before the first assignment). */
private clampValue(value: number): number {
// `?? 0` is not dead despite the type: the constructor calls this before assigning `numeric`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!Number.isFinite(value)) return this.numeric ?? 0;
return clamp(value, this.min, this.max);
}
+6 -2
View File
@@ -39,7 +39,9 @@ export class ScrollContainer extends Widget {
this.scrollTo(this.dragScrollStart + (e.global.y - this.dragStart) * ratio);
});
// The press starts on the scrollbar graphics; its release bubbles up to us either way.
this.onPointerRelease(() => { this.draggingBar = false; });
this.onPointerRelease(() => {
this.draggingBar = false;
});
}
/** Replaces the scrolled widget. The previous content is detached, not destroyed. */
@@ -99,6 +101,8 @@ export class ScrollContainer extends Widget {
const x = this.w - trackW - 2;
const barH = Math.max(20, (this.h / this.contentHeight) * this.h);
const barY = (this.scrollY / this.maxScroll) * (this.h - barH);
this.scrollbar.roundRect(x, barY, trackW, barH, trackW / 2).fill({ color: Theme.colors.textMuted, alpha: 0.6 });
this.scrollbar
.roundRect(x, barY, trackW, barH, trackW / 2)
.fill({ color: Theme.colors.textMuted, alpha: 0.6 });
}
}
+22 -5
View File
@@ -61,7 +61,14 @@ export class TextField extends Widget implements TextEditTarget {
this.minWidth = options.width ?? 80;
this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') });
this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') });
this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, this.clipShape);
this.addChild(
this.bg,
this.selectionShape,
this.placeholderNode,
this.textNode,
this.caret,
this.clipShape,
);
this.mask = this.clipShape;
this.trackHover();
@@ -71,7 +78,9 @@ export class TextField extends Widget implements TextEditTarget {
const index = this.indexAt(this.toLocal(e.global).x);
this.setSelection(this.selStart, index, true);
});
this.onPointerRelease(() => { this.dragSelecting = false; });
this.onPointerRelease(() => {
this.dragSelecting = false;
});
}
/** Current text, including an edit in progress that has not been committed yet. */
@@ -216,7 +225,10 @@ export class TextField extends Widget implements TextEditTarget {
let bestDist = Infinity;
for (let i = 0; i <= this.valueText.length; i++) {
const dist = Math.abs(this.textWidth(this.valueText.slice(0, i)) - x);
if (dist < bestDist) { bestDist = dist; best = i; }
if (dist < bestDist) {
bestDist = dist;
best = i;
}
}
return best;
}
@@ -248,7 +260,10 @@ export class TextField extends Widget implements TextEditTarget {
fill: this.hovered && !this.editing ? c.controlHover : c.control,
border: this.focused ? c.accent : c.border,
});
this.clipShape.clear().rect(1, 1, this.w - 2, this.h - 2).fill(0xffffff);
this.clipShape
.clear()
.rect(1, 1, this.w - 2, this.h - 2)
.fill(0xffffff);
this.textNode.text = this.valueText;
this.placeholderNode.text = this.valueText ? '' : this.placeholder;
@@ -271,7 +286,9 @@ export class TextField extends Widget implements TextEditTarget {
if (hi > lo) {
const x1 = textX + this.textWidth(this.valueText.slice(0, lo));
const x2 = textX + this.textWidth(this.valueText.slice(0, hi));
this.selectionShape.rect(x1, textY, x2 - x1, this.textNode.height).fill({ color: c.accent, alpha: 0.45 });
this.selectionShape
.rect(x1, textY, x2 - x1, this.textNode.height)
.fill({ color: c.accent, alpha: 0.45 });
}
if (this.caretVisible) {
this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text);