The console stops borrowing the browser's controls — and its preset tiles stop landing all at once #100

Merged
enricobuehler merged 2 commits from worktree-web-console-sweep into main 2026-08-07 21:08:48 +00:00
Owner

A pre-release sweep of the management console for the two things neither tsc nor a diff can catch: primitives that were never @unom/ui's, and animation that a nested motion parent quietly cancelled.

The preset tiles all landed on the same frame

@unom/ui's <Section> sets delayChildren: stagger(…), so a page whose cards are direct descendants of it staggers for free — which is why every page but one looked right. An <AnimatedCard> is also a motion element and sets no delayChildren, and the Virtual displays preset tiles are cards nested inside that page's config card, so that card became their timing group.

Measured in a headless browser, sampling each tile's computed opacity every frame:

spread between first and last tile
before 0.00 — six tiles in lockstep, the whole way
after 0.98 — a ~100 ms cascade, matching the rest of the console

The four hand-rolled copies of the stagger container collapse into one <Stagger> that carries the explanation.

Five files imported the wrong Button

@unom/ui/button exports both a plain Button and the AnimatedButton this console's wrapper re-exports under the same name. So import { Button } from "@unom/ui/button" compiles, renders, and silently opts out of the mount animation and the hover/tap response. Displays, SessionGame, GPU, Update and PendingDevices had dead buttons sitting next to live ones.

Three primitives had no wrapper, so nobody reached for them

@unom/ui ships form/select, form/textarea and form/checkbox; components/ui did not, and the gap was filled with browser-chrome <select>, <textarea> and <input type="checkbox"> in the add-hook modal and both library forms.

Select needs the same token correction Tabs needed: upstream text-secondary is a text colour, but here --secondary is a surface, so the trigger's chevron and placeholder were rendering at near-zero contrast against the card behind them.

Sixteen browser dialogs, gone

Every destructive action in an otherwise fully-branded console handed off to window.confirm — a grey OS box with the page's URL in it, no brand, no red on a delete, and untouchable by any story or screenshot, which is part of why it survived this long.

One promise-based surface (components/dialogs.tsx) replaces them, rather than a dialog per call site: the natives were expressions (if (!confirm(…)) return;) threaded through mutation handlers, and returning a promise keeps each handler the shape it already had. It is also what let the navigation guard come along — TanStack's shouldBlockFn accepts Promise<boolean>. beforeunload necessarily stays native: a reload is the browser's dialog to draw, and it will not wait on ours.

No warning copy was rewritten. Each message was split at its existing sentence boundary — question → title, consequence → body — with "Continue?" dropped where the affirmative button now carries the verb. 16 new keys; en and de in parity at 629.

Driven in a headless browser rather than reasoned about. All seven contract checks pass, including the two that would fail invisibly:

  • Escape settles the promise. An unsettled one hangs a mutation handler forever with no error.
  • A cancelled prompt resolves null, not "" — so a caller can still tell "backed out" from "cleared the field".

Numeric fields: four converted, three deliberately not

The hook timeout stops accepting a value the host rejects. On a controlled <input type="number">, min/max are decoration — form validation never runs — so 900 went into a field capped at 600 and failed later, at run time. InputNumber clamps on blur and lets the field be empty while you retype instead of snapping to the fallback.

The layout X/Y pair had a real defect too: a screen left of the origin has a negative coordinate, and Number("-") || 0 rewrote the lone minus sign to "0" before the digits could be typed.

Three fields correctly keep type="number", and now say why in place:

  • Grace seconds writes to the host on blur; InputNumber commits while you type, so its own blur-time clamp would race the apply, which still closes over the pre-clamp value.
  • Release year / players are optional metadata where empty means "don't send it". InputNumber's contract is value: number — it cannot express "unset", so adopting it would invent a year for every entry that hasn't got one.

Storybook

The Virtual displays page — the console's largest configuration surface — had no story at all, which is why the stagger regression shipped unseen. It has one now, and its <Card> wrapper is load-bearing: it reproduces the motion nesting that is the bug. Dropping it would make the story pass for the wrong reason.

That story earned its keep immediately: adding useDialogs to the page broke it in Storybook, because the provider was mounted in __root and nowhere else. It now sits beside the other app-level providers in .storybook/preview.tsx.

UI/Inputs gains the three non-text controls side by side with an Input — the comparison that matters, since the failure mode these wrappers prevent is a control that looks borrowed from another app.

Verification

tsc --noEmit clean · bun run build clean (i18n check: 629 messages, en + de) · bun test server/ 9/9 · build-storybook clean · biome check at the 4 pre-existing baseline findings, none introduced (one pre-existing format violation resolved in a file already being edited) · no raw <select> / <textarea> / <input type="checkbox"> left, no @unom/ui primitive imported outside components/ui/, and no window.confirm / window.prompt outside doc comments.

A pre-release sweep of the management console for the two things neither `tsc` nor a diff can catch: primitives that were never `@unom/ui`'s, and animation that a nested motion parent quietly cancelled. ## The preset tiles all landed on the same frame `@unom/ui`'s `<Section>` sets `delayChildren: stagger(…)`, so a page whose cards are direct descendants of it staggers for free — which is why every page **but one** looked right. An `<AnimatedCard>` is *also* a motion element and sets no `delayChildren`, and the Virtual displays preset tiles are cards nested **inside** that page's config card, so that card became their timing group. Measured in a headless browser, sampling each tile's computed opacity every frame: | | spread between first and last tile | |---|---| | before | **0.00** — six tiles in lockstep, the whole way | | after | **0.98** — a ~100 ms cascade, matching the rest of the console | The four hand-rolled copies of the stagger container collapse into one `<Stagger>` that carries the explanation. ## Five files imported the wrong Button `@unom/ui/button` exports **both** a plain `Button` and the `AnimatedButton` this console's wrapper re-exports *under the same name*. So `import { Button } from "@unom/ui/button"` compiles, renders, and silently opts out of the mount animation and the hover/tap response. Displays, SessionGame, GPU, Update and PendingDevices had dead buttons sitting next to live ones. ## Three primitives had no wrapper, so nobody reached for them `@unom/ui` ships `form/select`, `form/textarea` and `form/checkbox`; `components/ui` did not, and the gap was filled with browser-chrome `<select>`, `<textarea>` and `<input type="checkbox">` in the add-hook modal and both library forms. Select needs the same token correction Tabs needed: upstream `text-secondary` is a *text* colour, but here `--secondary` is a **surface**, so the trigger's chevron and placeholder were rendering at near-zero contrast against the card behind them. ## Sixteen browser dialogs, gone Every destructive action in an otherwise fully-branded console handed off to `window.confirm` — a grey OS box with the page's URL in it, no brand, no red on a delete, and untouchable by any story or screenshot, which is part of why it survived this long. One promise-based surface (`components/dialogs.tsx`) replaces them, rather than a dialog per call site: the natives were *expressions* (`if (!confirm(…)) return;`) threaded through mutation handlers, and returning a promise keeps each handler the shape it already had. It is also what let the navigation guard come along — TanStack's `shouldBlockFn` accepts `Promise<boolean>`. `beforeunload` necessarily stays native: a reload is the browser's dialog to draw, and it will not wait on ours. **No warning copy was rewritten.** Each message was *split* at its existing sentence boundary — question → title, consequence → body — with "Continue?" dropped where the affirmative button now carries the verb. 16 new keys; en and de in parity at 629. Driven in a headless browser rather than reasoned about. All seven contract checks pass, including the two that would fail invisibly: - **Escape settles the promise.** An unsettled one hangs a mutation handler forever with no error. - **A cancelled prompt resolves `null`, not `""`** — so a caller can still tell "backed out" from "cleared the field". ## Numeric fields: four converted, three deliberately not The hook timeout stops accepting a value the host rejects. On a *controlled* `<input type="number">`, `min`/`max` are decoration — form validation never runs — so 900 went into a field capped at 600 and failed later, at run time. `InputNumber` clamps on blur and lets the field be empty while you retype instead of snapping to the fallback. The layout X/Y pair had a real defect too: a screen left of the origin has a **negative** coordinate, and `Number("-") || 0` rewrote the lone minus sign to `"0"` before the digits could be typed. Three fields correctly keep `type="number"`, and now say why in place: - **Grace seconds** writes to the *host* on blur; `InputNumber` commits while you type, so its own blur-time clamp would race the apply, which still closes over the pre-clamp value. - **Release year / players** are *optional* metadata where empty means "don't send it". `InputNumber`'s contract is `value: number` — it cannot express "unset", so adopting it would invent a year for every entry that hasn't got one. ## Storybook The Virtual displays page — the console's largest configuration surface — had **no story at all**, which is why the stagger regression shipped unseen. It has one now, and its `<Card>` wrapper is load-bearing: it reproduces the motion nesting that *is* the bug. Dropping it would make the story pass for the wrong reason. That story earned its keep immediately: adding `useDialogs` to the page broke it in Storybook, because the provider was mounted in `__root` and nowhere else. It now sits beside the other app-level providers in `.storybook/preview.tsx`. `UI/Inputs` gains the three non-text controls side by side with an Input — the comparison that matters, since the failure mode these wrappers prevent is a control that looks borrowed from another app. ## Verification `tsc --noEmit` clean · `bun run build` clean (i18n check: 629 messages, en + de) · `bun test server/` 9/9 · `build-storybook` clean · `biome check` at the 4 pre-existing baseline findings, none introduced (one pre-existing format violation resolved in a file already being edited) · no raw `<select>` / `<textarea>` / `<input type="checkbox">` left, no `@unom/ui` primitive imported outside `components/ui/`, and no `window.confirm` / `window.prompt` outside doc comments.
enricobuehler added 2 commits 2026-08-07 21:00:33 +00:00
A pre-release sweep of the management console for two things that no type check
and no diff can catch: primitives that were never @unom/ui's, and animation
that a nested motion parent quietly cancelled.

THE PRESET TILES ALL LANDED ON THE SAME FRAME. @unom/ui's <Section> sets
`delayChildren: stagger(...)`, so a page whose cards are direct descendants of
it staggers for free — which is why every page but one looked right. An
<AnimatedCard> is ALSO a motion element and sets no `delayChildren`, and the
Virtual displays preset tiles are cards nested INSIDE that page's config card,
so that card became their timing group. Measured in a headless browser: the
opacity spread between the first and last tile was 0.00 across the whole
animation (six tiles in lockstep), and is 0.98 now — a ~100 ms cascade matching
the rest of the console. The four hand-rolled copies of the stagger container
collapse into one `<Stagger>` that carries the explanation.

FIVE FILES IMPORTED THE WRONG BUTTON. `@unom/ui/button` exports both a plain
`Button` and the `AnimatedButton` that this console's wrapper re-exports under
the same name — so `import { Button } from "@unom/ui/button"` compiles, renders,
and silently opts out of the mount animation and the hover/tap response.
Displays, SessionGame, GPU, Update and PendingDevices had dead buttons sitting
next to live ones.

THREE PRIMITIVES HAD NO WRAPPER, SO NOBODY REACHED FOR THEM. @unom/ui ships
form/select, form/textarea and form/checkbox; components/ui did not, and the
gap was filled with browser-chrome `<select>`, `<textarea>` and
`<input type="checkbox">` in the add-hook modal and both library forms. Select
needs the same token correction Tabs needed — upstream `text-secondary` is a
text colour, but here `--secondary` is a SURFACE, so the trigger's chevron and
placeholder rendered at near-zero contrast on the card behind them.

The hook timeout also stops accepting a value the host rejects: `min`/`max` on
a controlled `<input type="number">` are decoration (no form validation ever
runs), so 900 went into a field capped at 600 and failed later, at run time.
@unom/ui's InputNumber clamps on blur and lets the field be empty while you
retype instead of snapping to the fallback.

Storybook gains the page that had no story at all — the console's largest
config surface, and the reason this shipped unseen. Its <Card> wrapper is load
bearing: it reproduces the motion nesting that IS the bug.
feat(web): the console asks its own questions
ci / bun-nix (pull_request) Successful in 30s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m36s
ci / rust (pull_request) Successful in 6m32s
3be7d1d4f8
Follow-up to a85e8452, closing the three items that sweep flagged and left.

SIXTEEN BROWSER DIALOGS, GONE. Every destructive action in an otherwise fully
branded console handed off to `window.confirm` — a grey OS box with the page's
URL in it, no brand, no red on a delete, and untouchable by any story or
screenshot, which is part of why it survived this long.

They are replaced by one promise-based surface (components/dialogs.tsx) rather
than a dialog per call site. The native calls were EXPRESSIONS — `if
(!confirm(…)) return;` — threaded through mutation handlers; rewriting each into
"hold the pending action in state, render a dialog, run it from onConfirm" would
have put dialog machinery in every section file and turned each linear handler
inside out. Returning a promise keeps them the shape they already were, and it
is what let the navigation guard come along too: TanStack's `shouldBlockFn`
accepts `Promise<boolean>`. `beforeunload` necessarily stays native — a reload
is the browser's dialog to draw, and it will not wait on ours.

No warning copy was rewritten. Each message was SPLIT at its existing sentence
boundary: the question becomes the dialog's title, the consequence its body,
and "Continue?" is dropped where the affirmative button now carries the verb
("Delete", "Uninstall", "Unpair", "Stop every session"). 16 new keys, en and de
in parity at 629.

Verified by driving the real dialogs in a headless browser — all seven contract
checks pass, including the two that would be invisible until they bit: Escape
SETTLES the promise (an unsettled one would hang a mutation handler forever with
no error), and a cancelled prompt resolves null rather than "", so a caller can
still tell "backed out" from "cleared the field".

FOUR OF THE SEVEN NUMERIC FIELDS became InputNumber; three deliberately did not,
and now say why in place. The layout X/Y pair had a real defect: a screen left
of the origin has a negative coordinate, and `Number("-") || 0` rewrote the lone
minus sign to "0" before the digits could be typed. Measured on the built page:
the field can now be emptied to retype instead of snapping to its floor, and 900
in a 1..=16 field clamps to 16. The three left alone cannot take it — the grace
seconds field writes to the HOST on blur (InputNumber commits while typing, so
its clamp would race the apply), and the library's year/players are OPTIONAL,
where `value: number` has no way to say "unset" and would invent a year for
every entry without one.

The select's highlighted row moves off @unom/ui's neutral grey onto the brand
wash the nav and the preset cards already use.

The Displays story earned its keep immediately: adding `useDialogs` to that page
broke it in Storybook, because the provider was mounted in __root and nowhere
else. It belongs beside the other app-level providers in .storybook/preview.
enricobuehler merged commit de1bfdc0c5 into main 2026-08-07 21:08:48 +00:00
enricobuehler deleted branch worktree-web-console-sweep 2026-08-07 21:08:58 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#100