Skip to content

fix: give the Setup selection groups radio semantics - #74

Merged
philoserf merged 1 commit into
mainfrom
fix/setup-radio-semantics
Sep 16, 2026
Merged

philoserf merged 1 commit into
mainfrom
fix/setup-radio-semantics

Conversation

@philoserf

Copy link
Copy Markdown
Owner

Follow-up to the review finding on #73. Picking a character is one choice of six, not six independent switches — aria-pressed was toggle-button semantics, so assistive technology announced three separately-pressable buttons where it should announce one choice of three.

What changed

Each .card-grid is now role="radiogroup" labelled by its step heading; each card is role="radio" with aria-checked. The substantive part is the keyboard contract that comes with it:

  • One tab stop per group via roving tabindex, landing on the current choice — or the first option when nothing is chosen yet.
  • Arrows move and select, as the pattern requires: left/up and right/down, wrapping at both ends, plus Home and End.

The wrinkle: selecting re-renders the whole Setup screen, destroying the focused card. Without restoring focus, every arrow press would drop the user to the body. renderSetup now re-focuses by id after each render, and the id is built by a shared cardId(kind, itemId) so the builder and the restoration can't drift.

Verified in the browser

initial            monk:tab0  knight:tab-1  poet:tab-1  aristocrat:tab-1  scholar:tab-1  courtier:tab-1
ArrowRight         → character-knight, CHECKED, tab0 (monk drops to -1)
End                → character-courtier
ArrowRight (wrap)  → character-monk
ArrowLeft  (wrap)  → character-courtier
Home               → character-monk
ArrowDown          → character-knight

groups   [{I. The Character, 6 options, 1 tab stop, 1 checked},
          {II. The Skill,    3 options, 1 tab stop, 1 checked},
          {III. The Scenario, 4 options, 1 tab stop, 1 checked}]

aria-checked matches card--selected on every card: true

Mouse selection and the full setup-to-play flow still work — clicking through all three steps reaches the play screen with The Monk — Illumination unspent.

One thing I could not verify, and a correction

I added .card[role='radio']:focus styling and initially justified it by reporting that programmatic focus doesn't set :focus-visible in Safari. That measurement was invalid — the automated Safari window runs backgrounded, so document.hasFocus() is false and visibilityState is hidden, which means neither :focus nor :focus-visible matches anything regardless of the CSS. document.activeElement was correct throughout; only the pseudo-class matching was unobservable.

So the rule is belt-and-braces rather than a response to an observed failure. It still stands on its merits: the radiogroup pattern needs a visible focus indicator, whether a browser treats a programmatic re-focus as focus-visible is a per-browser heuristic, and a ring on a card the user has just selected costs nothing.

Worth a real look when you next have the app open with a focused window — tab into a group, then arrow through it, and confirm the focus ring is visible.

bun run check:ci clean, 82 tests.

Picking a character is one choice of six, not six independent switches.
`aria-pressed` on a mutually-exclusive group is toggle-button semantics, so
assistive technology announced three separately-pressable buttons where it
should announce one choice of three.

Each `.card-grid` is now `role="radiogroup"` labelled by its step heading, and
each card is `role="radio"` with `aria-checked`. That brings the keyboard
contract with it, which is the substantive part:

  - **One tab stop per group** via roving tabindex, landing on the current
    choice or the first option when nothing is chosen yet.
  - **Arrows move and select**, as the radio pattern requires — left/up and
    right/down, wrapping at both ends — plus Home and End.

Selecting re-renders the whole Setup screen, which destroys the focused card,
so `renderSetup` now restores focus by id after each render. Without that,
every arrow press would drop focus to the body. The card id is built by a
shared `cardId(kind, itemId)` so the builder and the restoration cannot drift.

Verified in the browser: one tab stop per group with the rest at -1; arrow
right from the last card wraps to the first and arrow left from the first wraps
to the last; Home and End reach the ends; focus follows the selection across
the re-render; `aria-checked` and `card--selected` stay in agreement on every
card; and all three groups end with exactly one checked option. Mouse selection
and the full setup-to-play flow still work.

Focus styling could **not** be verified here — the automated Safari window runs
backgrounded, so `document.hasFocus()` is false and neither `:focus` nor
`:focus-visible` matches anything regardless of the CSS. The added
`.card[role='radio']:focus` rule is therefore belt-and-braces rather than a
response to an observed failure: the pattern needs a visible focus indicator,
whether a given browser treats a programmatic re-focus as focus-visible is a
heuristic, and a ring on a card the user has just selected costs nothing.

Co-Authored-By: Claude
@philoserf
philoserf merged commit 387f26c into main Sep 16, 2026
3 checks passed
@philoserf
philoserf deleted the fix/setup-radio-semantics branch September 16, 2026 12:32
philoserf added a commit that referenced this pull request Sep 16, 2026
WALKTHROUGH.md had drifted badly since #67-#74. It documented a
`loadScenarios()` that fetched `public/scenarios/*.json` (now typed
constants in `src/scenarios.ts`), a `src/screens/letterhead.ts` (now
`fragments.ts`), and had no mention of `src/paragraph.ts` at all. Its
snippets were pinned with `sed -n 'N,Mp'` line ranges into a `play.ts`
that has since been rewritten, so `showboat verify` kept passing while
the prose described code that no longer existed.

Rebuilt from scratch in first-run execution order: page load, boot and
hydration, the store, the domain types, the content constants, Setup,
the paragraph machine, dice and rules, the Play screen, then scoring and
export. Every snippet is now anchored to content rather than line
numbers, so a command keeps pointing at the code it quotes.

Six blocks execute the game layer instead of quoting it -- the paragraph
machine driven through all seven phases, its wrong-phase guards, the
character-gated dice bonus on The Archduke, the full scoring matrix, the
tier boundaries, and a complete five-paragraph session through
`toMarkdown`.

Also removes an orphaned JSDoc block above `cardId`: a pre-#74 copy of
`renderChoiceStep`'s comment asserting "the `aria-label` below is the
name", contradicted by both the live comment eight lines below it and by
the code, which uses `aria-labelledby`.

Filed while tracing: #75 (a new `PhaseName` variant type-checks clean and
renders nothing in either play-screen switch) and #76 (the mount error
boundary covers only the first render).

Co-Authored-By: Claude
philoserf added a commit that referenced this pull request Sep 16, 2026
THEORY.md was written at #25 and describes a system the #59-#74 arc
dismantled: `validateScenario`, scenarios as fetched JSON, `TIER_NAMES`
keyed off per-scenario thresholds, a module-level `currentDraft` with
`ensureDraftFor`, a play screen with "no render authority" repainting
through the store, and the five-paragraph length "defined nowhere". All
of those were deleted or inverted. Its four indexed findings (#26, #33,
#35, #55) are closed. #63 called for regenerating rather than patching.

The new account is organized around what the arc was actually doing:
invariants move into types where types reach, and into tests where they
do not. The scenario validator went because Bun's HTML dev server answers
a sibling-JSON fetch with the SPA's own HTML, so the files were always
build-time imports and the boundary it guarded had already stopped
existing; `tests/scenarios.test.ts` is the residue of what the type
cannot state.

That framing is what produced both findings, by asking where a real
boundary remains. It is localStorage, and the checks there stop one level
short twice: `isSession` validates the session's fields but only
`Array.isArray` on `paragraphs`, and `hydrate` validates that ids resolve
but not that ink-pot indices still fit. Filed as #78 and #79, both
verified by probe rather than by reading -- a malformed paragraph element
throws in the render path and is then destroyed by the recovery button
the app offers, and reordering a shipped ink pot silently makes the game
record name a word the player never drew.

Also records which of the previous edition's uncertainties the arc
closed (the reroll is a penalty; `?? 1` is type appeasement) and which
stays open (the write-step indicator does not gate Finish paragraph).

Co-Authored-By: Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant