Skip to content

fix(shell): honour reduced motion, announce answers, and test the resize geometry - #91

Merged
mrsibe merged 1 commit into
fix/restore-panel-gutterfrom
fix/shell-a11y-and-tests
Sep 24, 2026
Merged

mrsibe merged 1 commit into
fix/restore-panel-gutterfrom
fix/shell-a11y-and-tests

Conversation

@mrsibe

@mrsibe mrsibe commented Sep 24, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

Closes the four items from the second critique run that did not need a product decision: renderer coverage for the resize arithmetic, prefers-reduced-motion, the missing aria-live on the transcript, and the nested interactive element in the tab strip.

Stacked on #90 — it builds on that branch's code, so the base is fix/restore-panel-gutter. GitHub retargets it to main automatically when #90 merges. Merge #90 first.

Why: the resize arithmetic had no way to fail loudly

ResizableLayout's geometry was inline, so the only way to exercise it was to drag a mouse — and it was wrong in a way nothing could catch. ArrowLeft grew the left panel and shrank the right one, so the two seams told a screen-reader user the opposite of what they had pressed. Typecheck passed. Lint passed. The second critique run found it by reading, which is not a reliable place to catch arithmetic.

Everything pure moved to components/layouts/panelGeometry.ts: the constraint math, the golden-ratio split, the stored-layout parser, and the key-to-width mapping. test/panelGeometry.test.ts covers all of it — the arrow direction on both seams, the Shift step, Home/End, the clamps at both ends, and the parser against absent, corrupt, NaN, Infinity and zero inputs.

I verified the tests fail on the buggy version before keeping them. Reintroducing the inverted flag turns three red. One thing worth knowing, because it is a trap: the invariant "the two seams move oppositely" stays true when both are flipped together, so it cannot detect an inversion. Only assertions on the absolute direction can. That weak test is kept as documentation of intent, not as the guard.

The guard's own predicate is tested, because CI found that bug and a local run could not

isChartSurfaceAllowed is now exported (with an entrypoint guard, so importing the module does not run the scan), and test/designGuard.test.ts asserts a POSIX path, a Windows path, a relative Windows path, and that other files are still rejected on both platforms. Reintroducing the forward-slash-only version turns two tests red.

The same file asserts --list still names every rule — so one cannot be deleted silently — and that a plain run stays green.

prefers-reduced-motion is honoured

The app pulses rows and cursors while work is in flight and animates every popover and dialog, and none of it was conditional on the user's setting. Transitions and decorative animation now collapse and scroll behaviour goes instant.

Spinners keep turning, slowed rather than stopped: a spinner is a status indicator, not decoration, and collapsing it removes information rather than motion. The !important in that block is load-bearing and says so in a comment — a * selector cannot outrank Tailwind's utility classes, which is the whole point of a global preference override.

The transcript announces completion, never tokens

It previously announced nothing: an answer streamed into a plain div with no aria-live and no busy state, so the product's entire output arrived silently and had to be hunted by cursor. A role="status" region now reports when a turn ends.

It deliberately does not sit on the transcript. A live region there would re-announce on every token — worse than the silence it replaces. The node is keyed so an identical message still re-announces, since setting the same string twice is a no-op for a screen reader.

The tab close control is a sibling of its tab, not a child

It was a role="button" tabIndex={0} span nested inside a role="tab" — itself a <button> — with its own Enter/Space handling duplicating the trigger's. Because the tablist uses a roving tabindex, that inner tabIndex={0} also added a second focus stop per open notebook, so the tab order grew with the number of tabs for no reason.

It is now a real <button> with an accessible name and a focus ring, positioned beside the trigger rather than inside it. The chip still looks the same: the trigger gained pr-7 for the space the close control occupies.

Residual, stated rather than hidden: a focusable control inside role="tablist" is still not ideal — the fully correct structure needs the tab strip rebuilt so the close lives outside the list, which is a purely visual change I could not verify in this environment. Flagging it rather than pretending the structure is now exemplary.

How was this tested?

  • npm test — 73/73 (was 50; 23 added here).
  • Both new suites were checked against a deliberately reintroduced version of the bug they cover, and both go red. A test that passes on the buggy code too is worthless, so this was verified rather than assumed.
  • npm run typecheck — passes.
  • npm run lint — 0 errors, 112 warnings (unchanged).
  • npm run check:design — no violations.
  • npx electron-vite build — builds.
  • npx prettier --check — clean.
  • impeccable detect --json src — [].

Not verified on screen: the reduced-motion rendering, the completion announcement as a screen reader would hear it, and the tab chip after the close control moved. All three are behaviour I can only reason about from here. The geometry and the guard predicate are covered by tests; these are not.

Checklist

  • I have reviewed my own changes.
  • npm run typecheck passes.
  • npm run build passes.
  • I have tested the affected user workflow. (reduced motion / screen reader not run)
  • I have not included unrelated changes.
  • I have updated documentation when necessary.

Desktop / build changes

  • Not applicable

The remaining items from the second critique run that did not need a product
decision.

**Renderer coverage for the resize arithmetic.** `ResizableLayout`'s geometry was
inline, so the only way to exercise it was to drag a mouse — and it was wrong in a
way nothing could catch: `ArrowLeft` grew the left panel and shrank the right one,
so the two seams told a screen-reader user the opposite of what they had pressed.
Typecheck passed. Lint passed. The second critique run found it by reading.

Everything pure moved to `components/layouts/panelGeometry.ts`: the constraint
math, the golden-ratio split, the stored-layout parser, and the key-to-width
mapping. `test/panelGeometry.test.ts` covers all of it — including the arrow
direction on both seams, the `Shift` step, `Home`/`End`, the clamps, and the
parser against absent, corrupt, `NaN`, `Infinity` and zero inputs.

I checked that these tests actually fail on the buggy version before keeping them:
reintroducing the inverted flag turns three of them red. One thing that did *not*
catch it, and is worth knowing: the invariant "the two seams move oppositely" holds
when both are flipped together, so only assertions on the absolute direction detect
an inversion.

**The guard's allowlist predicate is tested too**, because CI found that bug and a
local run could not. `isChartSurfaceAllowed` is exported (with an entrypoint guard
so importing does not run the scan) and `test/designGuard.test.ts` asserts both
separator styles — reintroducing the forward-slash-only version turns two tests
red. The same file also asserts `--list` still names every rule, so one cannot be
deleted silently, and that a plain run stays green.

**`prefers-reduced-motion` is honoured.** The app pulses rows and cursors while work
is in flight and animates every popover, and none of it was conditional on the
setting. Transitions and decorative animation now collapse and scroll behaviour
goes instant; spinners keep turning, slowed rather than stopped, because a spinner
is a status indicator and removing it removes information rather than motion. The
`!important` in that block is load-bearing and says so in a comment: a `*` selector
cannot outrank Tailwind's utility classes.

**The transcript announces completion, never tokens.** It previously announced
nothing at all: an answer streamed into a plain div with no `aria-live`, so the
product's entire output arrived silently. A `role="status"` region now reports when
a turn ends, with the node keyed so an identical message re-announces. A live region
on the transcript itself would chatter on every token — worse than the silence it
replaces.

**The tab close control is a sibling of its tab, not a child.** It was a
`role="button" tabIndex={0}` span nested inside a `role="tab"` (itself a `<button>`),
with its own Enter/Space handling duplicating the trigger's. Because the tablist
uses a roving tabindex, that inner `tabIndex={0}` also added a second focus stop per
open notebook. It is now a real `<button>` with an accessible name, positioned
beside the trigger rather than inside it. (A focusable control inside `role="tablist"`
is still not ideal — the fully correct structure needs the tab strip rebuilt so the
close lives outside the list, which is a visual change I could not verify here.)

DESIGN.md records the geometry module and what the tests do and do not catch, the
motion and announcement rules, the tab-strip structure, and the guard's own test.
@github-actions github-actions Bot added the bug Something isn't working label Sep 24, 2026
@mrsibe
mrsibe merged commit 341e1cd into fix/restore-panel-gutter Sep 24, 2026
4 checks passed
@mrsibe
mrsibe deleted the fix/shell-a11y-and-tests branch September 24, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant