Lane 3: Settings, the frame and its first three sections (#729, CAMP-9, CAMP-10, CAMP-11) #769

Merged
claude-bot merged 3 commits from build/lane3-settings-a into main 2026-09-23 08:36:23 +00:00
Collaborator

Settings is nine surfaces and 109 features — bigger than the whole rest of this lane — so it is built in three PRs. This is the first: the frame every later section hangs off, plus General (CAMP-9), Invites & joining (CAMP-10) and Ownership (CAMP-11).

The frame, which is the part with rules in it

shell/settingsSections.js is the nine sections in one list — id, label, summary, who, and a built flag. The index (pages/CampaignSettings.jsx) and the Contents (shell/useContents.js) both read it, so a later PR adds a section by flipping one word. The addresses are settled (/settings/general, /settings/invites, /settings/ownership); paths are contracts.

Two decisions live in it:

  • IS-199: the index says whose each section is. Not every section is the table's business — some are the GM's and some the owner's — and the index says so before you press rather than after. A section the reader may not open is absent, not disabled, which is D57's rule on the member page applied again.
  • IS-310: the way back out, which the first draw never gave. On a desktop the Contents highlights Settings; on a phone there was no exit at all. There is now, and each section is a page with its own address so Back works, a section can be sent to somebody, and D22's unsaved-changes prompt fires at all — SH-036 only triggers on a change of pathname, so nine sections built as tabs of one page would have lost an edit with the guard sitting right there.

Not built, each for a reason

  • CA-017 / CA-019, the About card. Its home is This week (IS-355), not Settings — D45 is precisely the decision that moved a member's reading of the campaign off this page, which is why CA-016 is classified replaced. The gen drawing shows the card only to say where it went. It is already built on This week (#767). A second copy here would duplicate it and put a reading card back in the GM's cupboard.
  • "Never" as an invite expiry (CA-223). InviteCreate.expires_in_days is int = Field(default=7, ge=1, le=30) — required, capped at a month. The control would 422. 1 / 7 / 30 days are offered and the code says why. Making it real is int | None on the schema plus a null branch in invite_service.create_invite; the UI already renders "never expires" correctly if it ever arrives.
  • Undo on a revoked link (CA-222, D12 tier one). There is no inverse of a revoke, and remaking the link hands back a different address. A deferred delete with an undo window was considered and rejected: leaving the page inside the window would silently drop the DELETE, which is a worse lie than no Undo.
  • D22's three-button leave prompt. nav draws "Stay here / Leave without saving / Save and leave"; useUnsavedChangesWarning is still window.confirm with two buttons, and its own comment says a design-system dialog is a later step. Replacing it changes a hook every editor in the app uses — WikiArticle, SessionDetail and others — and does not belong in a Settings PR. General arms the existing guard. D22 is not a coverage row in these three blocks.

Where the drawing and the server disagreed

  • "never expires" is unreachable — create_invite always sets expires_at, so the drawn "For Ade · never expires" row is a state today's server cannot produce.
  • "No longer works · you stopped it on 2 September" cannot be listed — revoking removes the row, so only Expired and Used up can appear. "No longer works" is kept as the honest fallback for a row the server calls not-live for a reason we did not compute.
  • The two drawings give Ownership different summaries. lane 2's is "handing it on"; lane 2b's is "who owns the campaign, and who runs the table". Used lane 2b's — it is the finishing pass and it matches what the page now says.
  • CampaignSummary carries my_role but not is_owner, so the Contents entry can only be GM-gated, as drawn — but #644 lets the owner be a plain player, and Ownership is theirs. The pages are gated on "a GM, or the owner" so a player-owner is not locked out of their own section; they reach it by address. Closing that properly is one field on CampaignSummary.

Where I think the drawing is wrong

  • sysRow({unlinked:true}) replaces the select with a bare input, stranding a GM with no visible way back to the registry. CA-216/CA-217 describe the right shape — a dropdown defaulting to "Free text / Other" with the text box shown only while it is selected — and NewCampaign.jsx already built it that way in lane 1. Followed the catalogue, and gave the box a visible label for the same reason lane 1 did.
  • genFoot has no "Saved". A form with a Save button and no acknowledgement is a defect, and its finished sibling aiFoot on CAMP-13 draws exactly that state (CA-091). Added a quiet "Saved" banner.
  • The index says "Nine sections" and three exist. Only built sections are listed (build plan §2), so it counts what is on the page and reads "Nine sections." by itself when the lane finishes.

One row added at the merge, by me, that neither drawing has

Settings' sub-list ends with "Everything else" → /campaigns/:id/manage. Six of the nine sections — Discord, AI & recording, the wiki's rules, content packs, Foundry, and archive, export & delete — are still on the old campaign page, and that page has no address per section: it draws them inline and its ?open= takes only the first-run actions. Without the row they would leave the navigation altogether the day Settings arrived, and archiving or deleting a campaign is not something to reach only by typing an address. It goes with the last of them. Owner, 2026-09-23: fine.

Checks

Frontend 1672 tests in 131 files, green on the merged tree; eslint 0 errors, 234 warnings — the same count as main; vite build green. lane2b.py and lane2.py both print with no unclassified rows and no classification changed.

Rendered lane2.html?only=set and lane2b.html?only={nav,gen,camp10,camp11} headless in both themes and read every slice — which caught two faults in this PR's own CSS, including the index row's "who" line landing under the icon rather than the name. The built CSS was probed separately against hand-written markup at two widths, because index.css's cascade-layer note is about exactly the difference between the dev server and the emitted bundle.

One honest limit: jsdom cannot evaluate a container query, so no test asserts what is visible at a given width. The way back out is tested in two halves — the desktop exit as the Contents, the phone's back bar by presence and address — and its phone-only-ness is a CSS fact verified by the render, not by a test.

🤖 Generated with Claude Code

Settings is **nine surfaces and 109 features** — bigger than the whole rest of this lane — so it is built in three PRs. This is the first: the frame every later section hangs off, plus **General** (CAMP-9), **Invites & joining** (CAMP-10) and **Ownership** (CAMP-11). ## The frame, which is the part with rules in it `shell/settingsSections.js` is the nine sections in one list — id, label, summary, `who`, and a `built` flag. The index (`pages/CampaignSettings.jsx`) and the Contents (`shell/useContents.js`) both read it, so a later PR adds a section by flipping one word. The addresses are settled (`/settings/general`, `/settings/invites`, `/settings/ownership`); paths are contracts. Two decisions live in it: - **`IS-199`: the index says whose each section is.** Not every section is the table's business — some are the GM's and some the owner's — and the index says so *before* you press rather than after. A section the reader may not open is **absent**, not disabled, which is D57's rule on the member page applied again. - **`IS-310`: the way back out**, which the first draw never gave. On a desktop the Contents highlights Settings; on a phone there was no exit at all. There is now, and each section is a page with its own address so Back works, a section can be sent to somebody, and **D22's unsaved-changes prompt fires at all** — `SH-036` only triggers on a change of *pathname*, so nine sections built as tabs of one page would have lost an edit with the guard sitting right there. ## Not built, each for a reason - **`CA-017` / `CA-019`, the About card.** Its home is **This week** (`IS-355`), not Settings — D45 is precisely the decision that moved a member's *reading* of the campaign off this page, which is why `CA-016` is classified `replaced`. The `gen` drawing shows the card only to say where it went. It is already built on This week (#767). A second copy here would duplicate it *and* put a reading card back in the GM's cupboard. - **"Never" as an invite expiry (`CA-223`).** `InviteCreate.expires_in_days` is `int = Field(default=7, ge=1, le=30)` — required, capped at a month. The control would 422. 1 / 7 / 30 days are offered and the code says why. Making it real is `int | None` on the schema plus a null branch in `invite_service.create_invite`; the UI already renders "never expires" correctly if it ever arrives. - **Undo on a revoked link (`CA-222`, D12 tier one).** There is no inverse of a revoke, and remaking the link hands back a different address. A deferred delete with an undo window was considered and rejected: leaving the page inside the window would silently drop the DELETE, which is a worse lie than no Undo. - **D22's three-button leave prompt.** `nav` draws "Stay here / Leave without saving / Save and leave"; `useUnsavedChangesWarning` is still `window.confirm` with two buttons, and its own comment says a design-system dialog is a later step. Replacing it changes a hook every editor in the app uses — WikiArticle, SessionDetail and others — and does not belong in a Settings PR. General arms the existing guard. D22 is not a coverage row in these three blocks. ## Where the drawing and the server disagreed - **"never expires" is unreachable** — `create_invite` always sets `expires_at`, so the drawn "For Ade · never expires" row is a state today's server cannot produce. - **"No longer works · you stopped it on 2 September" cannot be listed** — revoking removes the row, so only *Expired* and *Used up* can appear. "No longer works" is kept as the honest fallback for a row the server calls not-live for a reason we did not compute. - **The two drawings give Ownership different summaries.** lane 2's is "handing it on"; lane 2b's is "who owns the campaign, and who runs the table". Used lane 2b's — it is the finishing pass and it matches what the page now says. - **`CampaignSummary` carries `my_role` but not `is_owner`**, so the *Contents* entry can only be GM-gated, as drawn — but #644 lets the owner be a plain player, and Ownership is theirs. The **pages** are gated on "a GM, or the owner" so a player-owner is not locked out of their own section; they reach it by address. Closing that properly is one field on `CampaignSummary`. ## Where I think the drawing is wrong - **`sysRow({unlinked:true})` replaces the select with a bare input**, stranding a GM with no visible way back to the registry. `CA-216`/`CA-217` describe the right shape — a dropdown defaulting to "Free text / Other" with the text box shown only while it is selected — and `NewCampaign.jsx` already built it that way in lane 1. Followed the catalogue, and gave the box a visible label for the same reason lane 1 did. - **`genFoot` has no "Saved".** A form with a Save button and no acknowledgement is a defect, and its finished sibling `aiFoot` on CAMP-13 draws exactly that state (`CA-091`). Added a quiet "Saved" banner. - **The index says "Nine sections" and three exist.** Only built sections are listed (build plan §2), so it counts what is on the page and reads "Nine sections." by itself when the lane finishes. ## One row added at the merge, by me, that neither drawing has Settings' sub-list ends with **"Everything else" → `/campaigns/:id/manage`**. Six of the nine sections — Discord, AI & recording, the wiki's rules, content packs, Foundry, and archive, export & delete — are still on the old campaign page, and **that page has no address per section**: it draws them inline and its `?open=` takes only the first-run actions. Without the row they would leave the navigation altogether the day Settings arrived, and archiving or deleting a campaign is not something to reach only by typing an address. It goes with the last of them. Owner, 2026-09-23: fine. ## Checks Frontend **1672 tests in 131 files**, green on the merged tree; eslint **0 errors**, 234 warnings — the same count as main; `vite build` green. `lane2b.py` and `lane2.py` both print with no unclassified rows and **no classification changed**. Rendered `lane2.html?only=set` and `lane2b.html?only={nav,gen,camp10,camp11}` headless in both themes and read every slice — which caught two faults in this PR's own CSS, including the index row's "who" line landing under the icon rather than the name. The built CSS was probed separately against hand-written markup at two widths, because `index.css`'s cascade-layer note is about exactly the difference between the dev server and the emitted bundle. **One honest limit:** jsdom cannot evaluate a container query, so no test asserts what is *visible* at a given width. The way back out is tested in two halves — the desktop exit as the Contents, the phone's back bar by presence and address — and its phone-only-ness is a CSS fact verified by the render, not by a test. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A campaign's settings are a cupboard with one door and a page per section,
at `/campaigns/:id/settings`. This is the frame every later section hangs
off, plus General (CAMP-9), Invites & joining (CAMP-10) and Ownership
(CAMP-11).

The frame is the part with rules in it.

**`IS-199`: the index says whose each section is**, before you press rather
than after, and a section this reader may not open is *absent* rather than
greyed — the same rule as D57's step-down control on the member page. A GM
who does not own the campaign has no Ownership row; a player has no door to
Settings at all, because D45 moved what a member reads of a campaign — its
name, game and description — onto This week. The owner's sections are the
owner's whether or not they run the table, since #644 lets the owner be a
plain player.

**`IS-310`: each section keeps its own address.** That is what makes it
linkable, makes Back work, and makes D22's unsaved-changes prompt fire at
all — `SH-036` only triggers on a change of *pathname*, so nine sections
built as tabs of one page would lose an edit with the guard sitting right
there. The Contents opens into the sections while you are in one, on the
phone's sheet as well as the desktop rail; and because a phone has no
Contents on screen, a section carries its own "‹ Settings" bar — a container
query on the frame's width, not a prop.

**General** is the name, the game (registry-linked or typed, with the
registry-match nudge that was missing), what it is about, the table's
timezone — the zone a session is *set* in, which is not D17's "shown in the
reader's own zone" and must not be confused with it — up to four reminders,
the fewest players worth playing with, when a time vote closes, and the
stat-conversion list D5 put on this page. Save sends only the fields this
section owns; PATCH is partial, so Discord and retention are untouched.

**Invites & joining** manages the set of links and their two tiers (D12):
revoking one somebody may be holding states the consequence and waits,
removing a row that already does nothing asks nothing and says what it did.
The count of people waiting for approval sits beside the toggle that fills
that queue, because the queue itself is on another page.

**Ownership** says who owns the campaign and who runs the table in the same
breath, because they are different things; a campaign with no GM is a
legitimate state and nothing here guards against it (#644).

Two things the drawing asks for are not built, because the server cannot do
them: an invite link that never expires (`InviteCreate.expires_in_days` is
required and capped at 30 days) and an Undo on removing a dead link (there
is no inverse of a revoke). Both are named in the code where they would go.

Six sections — Discord, Recording, Wiki, Content packs, Foundry, and
Archive, export & delete — are settled in `shell/settingsSections.js` but
not built, so they are neither listed, linked nor routed: an address that
does not exist says so rather than drawing an empty page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The quiet banner that replaces D12's Undo — there is no inverse of a revoke,
so the removal says what it did instead — had two wordings for three states,
and told a GM that a link which had simply stopped working "had expired".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge build/lane3-members into build/lane3-settings-a (#729)
All checks were successful
CI / Feedback site tests (pull_request) Successful in 45s
CI / Backend lint (ruff) (pull_request) Successful in 1m5s
CI / Bot/backend version sync (pull_request) Successful in 52s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m12s
CI / Bot tests and audit (pull_request) Successful in 3m38s
CI / Docker image build (pull_request) Successful in 3m45s
CI / Frontend tests, audit, and build (pull_request) Successful in 6m1s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 3m32s
CI / Backend migration, tests, and audit (pull_request) Successful in 10m54s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 19m59s
f740861bfe
Both were built in parallel off the same main and both rearrange the campaign's
Contents. Resolutions:

- `App.jsx`, `vocabulary.js`, `AppFrame.test.jsx` — both sides' additions kept.
  `SETTINGS` loses the members branch's temporary "it points at the old page"
  note, because this branch is the PR that makes it false.
- `useContents.js` — the temporary top-level Settings → `…/manage` entry that
  the members merge added is replaced by this branch's real one, which is the
  GM's, opens into its sections, and is absent for a player (D45).
- Both of the settings pages' links to "today's member list" now point at
  `/campaigns/:id/members`, which is what their comments asked for once CAMP-8
  landed. Two test expectations follow them.

**One thing added at the merge, not by either delegate.** Settings' sub-list
carries a last row, "Everything else", pointing at `/campaigns/:id/manage`.
Six of the nine sections — Discord, AI & recording, the wiki's rules, content
packs, Foundry, and archive, export & delete — are still on the old campaign
page, and that page has no address per section: it draws them inline and its
`?open=` takes only the first-run actions. Without the row they would leave the
navigation altogether the day Settings arrived, and deleting or archiving a
campaign is not something to reach only by typing an address. It goes with the
last of them.

Full frontend suite on the merged tree: 1672 tests in 131 files, green.
eslint: 0 errors, 234 warnings — the same count as main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.