The shell's follow-ups: skip link, focus on navigation, a Contents that keeps up, the owner sees Settings (#780, D93) #789

Merged
claude-bot merged 4 commits from build/shell-followups into main 2026-09-24 01:51:43 +00:00
Collaborator

Five shell changes the owner decided on 2026-09-23 (B5, B7, B11, B12 and the bell alias, recorded on #729). Closes #780.

Whole journeys suite: 27 passed, 3 skipped (J2's lane-4 placeholder in each project), on desktop, phone and keyboard. a11y-baseline.js is still {}. vitest 1978/1978 (142 files); eslint 0 errors, warnings unchanged; ruff clean.

1. D93: the owner sees Settings even as a plain player

  • The campaign list's rows carry is_owner (additive, not the bot API).
  • The Contents builds Settings from settingsFor({ gm, owner }) and shows it whenever that list isn't empty:
    • a player-owner sees exactly Ownership and Archive, export & delete;
    • a GM who isn't the owner no longer sees Ownership. Before this, every GM saw every section, and a non-owner GM who follows the address still meets a clean "Not for you — this one is the owner's to decide".
  • Tested: the owner-GM, the player-owner and a plain player on the backend; the GM-owner, the non-owner GM and the player-owner in the Contents.

Skip link (IS-292, WCAG 2.4.1), a new SkipLink in the kit:

  • It's first in tab order, hidden until focused, then drawn over the app bar.
  • It focuses <main> directly rather than changing the URL fragment, so neither the router nor the leave guard sees it.

Focus on navigation (IS-296), shell/useRouteFocus.js. After a pathname change it focuses the fragment's section (the bell's alias included), else the page's h1, else <main> until the heading arrives. It deliberately does nothing:

  • on the first load, meaning until the first key or pointer press, so a redirect on arrival counts as part of loading;
  • on a hash-only or ?tab= change;
  • while a dialog is open;
  • when focus is already in a field inside the page.

It also stops the moment focus goes anywhere else, so it can't fight the reader. I read the hook in review for exactly that. The focused heading takes tabindex=-1 only while focused.

J1 now proves both by keyboard alone on every project: Tab reaches the skip link, which is actually drawn; Enter focuses main#main; the next Tab lands in the page. After entering This week through the Contents, focus is on the page's heading.

3. The Contents rail keeps up

  • The campaign list re-fetches on navigation, keeping the old list on screen meanwhile.
  • A write that changes the list (create, join, leave, rename, archive, restore, role change, ownership accepted) also triggers a re-fetch, through a small onApiWrite announcement in api/client.js, fired after the cache is cleared. A wiki or notes write doesn't match, so it doesn't re-fetch.

4. Home's buttons on a fresh campaign

  • "Schedule the first session" → the session form. "Invite players" → Settings → Invites.
  • One addition beyond the decision: both buttons now show only to a GM, since both destinations are the GM's. A player on a fresh campaign would otherwise follow them into a "not yours" stop, so they get Open instead. Easy to revert.
  • J1 now checks both addresses and enters the campaign through the Contents. The spec comment about the stale rail is gone.

5. The bell's old /profile#notifications

The leave prompt now honours the section alias, so a dirty Profile following the bell's old link isn't asked about leaving a page it isn't leaving.

Found, not fixed

  • Scroll position isn't reset on navigation. .book__main is the scroll container, so a new page can open part-way down. Focusing the heading scrolls it into view after a keyboard or pointer move, but there's no general reset or restoration.
  • There's no per-page document.title. #780 mentions announcing the new title; for now the focused h1 is what's read out.
  • A player-owner's Contents meta line still says "You're a player". Whether it should say they own the campaign is a wording call.
  • CHANGELOG.md's [Unreleased] has a second **Added**/**Fixed** pair further down, predating this branch. It gets fixed once the PRs in flight have landed, so it doesn't conflict with all of them.

🤖 Generated with Claude Code

Five shell changes the owner decided on 2026-09-23 (B5, B7, B11, B12 and the bell alias, recorded on #729). Closes #780. Whole journeys suite: **27 passed, 3 skipped** (J2's lane-4 placeholder in each project), on desktop, phone and keyboard. `a11y-baseline.js` is still `{}`. vitest 1978/1978 (142 files); eslint 0 errors, warnings unchanged; ruff clean. ## 1. D93: the owner sees Settings even as a plain player - The campaign list's rows carry `is_owner` (additive, not the bot API). - The Contents builds Settings from `settingsFor({ gm, owner })` and shows it whenever that list isn't empty: - a **player-owner** sees exactly Ownership and Archive, export & delete; - a **GM who isn't the owner** no longer sees Ownership. Before this, every GM saw every section, and a non-owner GM who follows the address still meets a clean "Not for you — this one is the owner's to decide". - Tested: the owner-GM, the player-owner and a plain player on the backend; the GM-owner, the non-owner GM and the player-owner in the Contents. ## 2. #780: a skip link, and focus that moves on navigation **Skip link** (`IS-292`, WCAG 2.4.1), a new `SkipLink` in the kit: - It's first in tab order, hidden until focused, then drawn over the app bar. - It focuses `<main>` directly rather than changing the URL fragment, so neither the router nor the leave guard sees it. **Focus on navigation** (`IS-296`), `shell/useRouteFocus.js`. After a pathname change it focuses the fragment's section (the bell's alias included), else the page's `h1`, else `<main>` until the heading arrives. It deliberately does **nothing**: - on the first load, meaning until the first key or pointer press, so a redirect on arrival counts as part of loading; - on a hash-only or `?tab=` change; - while a dialog is open; - when focus is already in a field inside the page. It also **stops the moment focus goes anywhere else**, so it can't fight the reader. I read the hook in review for exactly that. The focused heading takes `tabindex=-1` only while focused. **J1 now proves both by keyboard alone on every project:** Tab reaches the skip link, which is actually drawn; Enter focuses `main#main`; the next Tab lands in the page. After entering This week through the Contents, focus is on the page's heading. ## 3. The Contents rail keeps up - The campaign list re-fetches on navigation, keeping the old list on screen meanwhile. - A write that changes the list (create, join, leave, rename, archive, restore, role change, ownership accepted) also triggers a re-fetch, through a small `onApiWrite` announcement in `api/client.js`, fired after the cache is cleared. A wiki or notes write doesn't match, so it doesn't re-fetch. ## 4. Home's buttons on a fresh campaign - "Schedule the first session" → the session form. "Invite players" → Settings → Invites. - **One addition beyond the decision:** both buttons now show only to a GM, since both destinations are the GM's. A player on a fresh campaign would otherwise follow them into a "not yours" stop, so they get **Open** instead. Easy to revert. - J1 now checks both addresses and enters the campaign through the Contents. The spec comment about the stale rail is gone. ## 5. The bell's old `/profile#notifications` The leave prompt now honours the section alias, so a dirty Profile following the bell's old link isn't asked about leaving a page it isn't leaving. ## Found, not fixed - **Scroll position isn't reset on navigation.** `.book__main` is the scroll container, so a new page can open part-way down. Focusing the heading scrolls it into view after a keyboard or pointer move, but there's no general reset or restoration. - **There's no per-page `document.title`.** #780 mentions announcing the new title; for now the focused `h1` is what's read out. - **A player-owner's Contents meta line still says "You're a player".** Whether it should say they own the campaign is a wording call. - **`CHANGELOG.md`'s `[Unreleased]` has a second `**Added**`/`**Fixed**` pair** further down, predating this branch. It gets fixed once the PRs in flight have landed, so it doesn't conflict with all of them. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(webapp): the shell's follow-ups — skip link, focus on navigation, a Contents that keeps up (#780, D93, B5, B7, B11, B12)
Some checks failed
CI / Does this change touch the recording path? (pull_request) Successful in 27s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m7s
CI / Docker image build (pull_request) Successful in 1m20s
CI / Bot/backend version sync (pull_request) Successful in 1m21s
CI / Backend lint (ruff) (pull_request) Successful in 1m30s
CI / Bot tests and audit (pull_request) Successful in 1m56s
CI / Feedback site tests (pull_request) Successful in 2m20s
CI / Frontend tests, audit, and build (pull_request) Failing after 4m32s
CI / Backend migration, tests, and audit (pull_request) Successful in 11m29s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 13m32s
5acb6dad97
The owner's decisions of 2026-09-23 on the v4.5.0 shell (#729, B1-B13).

B5 / D93: the owner sees Settings even as a plain player. `GET /api/campaigns`
rows carry `is_owner`, relative to the caller (additive; not the bot API, so no
contract bump). The Contents lists Settings for a GM or the owner, with only the
sections that reader may open (`settingsFor`): a player-owner gets Ownership
and Archive, export & delete; a GM who does not own the campaign no longer
sees Ownership.

B7 / #780: a skip link and focus that follows a navigation (IS-292, IS-296,
WCAG 2.4.1). `SkipLink` in the kit, first in the frame, visible on focus,
landing on the Book's `<main>` (now focusable via `mainId`). `useRouteFocus`
moves focus after a change of pathname to the fragment's section, else the
page's h1, else `<main>` while the heading loads — not on the first load
(nothing the person did), not on a hash-only change, not while a dialog is
open, not out of a field the page focused, and never back once the reader has
moved focus themselves.

B11: the Contents rail re-reads the campaign list on every navigation (cheap:
the client's 15s cache, which every write already empties) and after any
write that can change it, via a new `onApiWrite` in the client filtered by
`changesCampaignList` — so a rename or a step-down shows without navigating.

B12: Home's fresh-campaign buttons go to the session form and to Settings >
Invites & joining; a player in a fresh campaign gets Open instead.

The bell's old alias: the leave prompt honours `SECTION_ALIASES`, so a dirty
Profile does not ask "Leave without saving?" of `/profile#notifications`.

J1 now enters the new campaign through the Contents, asserts focus lands on
This week's heading, and proves the skip link by keyboard on every project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test(frontend): the route-focus tests decide when a page's data arrives (#780)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m57s
CI / Backend lint (ruff) (pull_request) Successful in 33s
CI / Does this change touch the recording path? (pull_request) Successful in 15s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot/backend version sync (pull_request) Successful in 26s
CI / Feedback site tests (pull_request) Successful in 1m9s
CI / Bot tests and audit (pull_request) Successful in 3m18s
CI / Frontend tests, audit, and build (pull_request) Successful in 4m59s
CI / Docker image build (pull_request) Successful in 4m6s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 9m3s
CI / Backend migration, tests, and audit (pull_request) Successful in 23m10s
aa1add0dbe
CI failed on useRouteFocus.test.jsx: "waits for a heading that arrives with
the page's data" asserted that, while the page is still loading, the top of
the page holds focus. How long "still loading" lasted was a 30ms real timer,
and `findByText` polls about every 50ms, so on a loaded runner the data had
sometimes arrived before the test looked. It asserted a state that had
already passed.

The fifth race of the same family this week, and the same fix: the test,
not a timer, decides when the data arrives. `arrive()` opens a gate; until
then the loading state holds, so every in-between assertion is looking at
a state that cannot move under it. All three tests that used the timed pages
now call it, and the swapped-heading test also checks the loading heading
took focus first, which it only implied before.

Five runs on a single CPU: 11/11 each. eslint clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into build/shell-followups
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 50s
CI / Backend lint (ruff) (pull_request) Successful in 26s
CI / Bot/backend version sync (pull_request) Successful in 23s
CI / Does this change touch the recording path? (pull_request) Successful in 21s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot tests and audit (pull_request) Successful in 1m50s
CI / Feedback site tests (pull_request) Successful in 1m45s
CI / Docker image build (pull_request) Successful in 3m33s
CI / Frontend tests, audit, and build (pull_request) Successful in 6m16s
CI / Backend migration, tests, and audit (pull_request) Successful in 11m27s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 14m4s
90d20dba39
# Conflicts:
#	CHANGELOG.md
Merge main into build/shell-followups (#780, D93)
All checks were successful
CI / Does this change touch the recording path? (pull_request) Successful in 25s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 50s
CI / Bot/backend version sync (pull_request) Successful in 1m8s
CI / Backend lint (ruff) (pull_request) Successful in 1m13s
CI / Docker image build (pull_request) Successful in 1m18s
CI / Feedback site tests (pull_request) Successful in 1m43s
CI / Bot tests and audit (pull_request) Successful in 3m33s
CI / Frontend tests, audit, and build (pull_request) Successful in 6m58s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 8m45s
CI / Backend migration, tests, and audit (pull_request) Successful in 11m27s
22adb33abe
#792 (the retention preview) landed underneath. Three CHANGELOG hunks, each
side holding only its own entries; both kept. `schemas/campaign.py`,
`services/campaign_service.py` and `api/campaigns.js` auto-merged — this
branch adds `is_owner` to the campaign list, #792 the retention preview and
the erase count, in the same files.

Checked on the merged tree rather than assumed: the campaign, retention,
cap, preview, erase-count and AI-settings backend suites, 314 passed; the
whole frontend suite, 1994 tests in 142 files, green; eslint 0 errors, 228
warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.