fix: give the Setup selection groups radio semantics - #74
Merged
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to the review finding on #73. Picking a character is one choice of six, not six independent switches —
aria-pressedwas toggle-button semantics, so assistive technology announced three separately-pressable buttons where it should announce one choice of three.What changed
Each
.card-gridis nowrole="radiogroup"labelled by its step heading; each card isrole="radio"witharia-checked. The substantive part is the keyboard contract that comes with it: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.
renderSetupnow re-focuses by id after each render, and the id is built by a sharedcardId(kind, itemId)so the builder and the restoration can't drift.Verified in the browser
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']:focusstyling and initially justified it by reporting that programmatic focus doesn't set:focus-visiblein Safari. That measurement was invalid — the automated Safari window runs backgrounded, sodocument.hasFocus()isfalseandvisibilityStateishidden, which means neither:focusnor:focus-visiblematches anything regardless of the CSS.document.activeElementwas 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:ciclean, 82 tests.