Lane 3: Settings' last sections, D22's leave prompt, and a regression fix for players (#729, CAMP-15, CAMP-17, SH-7) #783

Merged
claude-bot merged 4 commits from build/lane3-settings-c into main 2026-09-23 17:37:16 +00:00
Collaborator

The last PR of lane 3's surfaces. It builds Content packs (CAMP-15) and Archive, export & delete (CAMP-17), replaces the leave prompt (D22, SH-7), and fixes a regression lane 3 itself put on main. Two sections stay deliberately unbuilt.

Read first: a regression, found reviewing this PR

From #769 until this PR, a player could not reach the quest log, the loot ledger, or an ownership offer.

The old campaign page (/manage) still holds all three, and all three are for every member. Its only door was "Everything else", a row I added inside Settings, which is the GM's. The sharpest case is an ownership offer. It can go to any member, and the bell's "has offered you this campaign" links to This week. This week didn't show the offer, and the player had no route to the page that could accept it.

Fixed here:

  • "Quests & loot" is a Contents entry for every member, pointing at /manage. "Everything else" is gone from Settings, whose sub-list is now only its built sections.
  • This week shows an offer addressed to the reader as a gm banner ("a decision addressed to you"). Answer leads to where it can be accepted or declined. That costs one request, and a failure draws nothing and doesn't cost the page.
  • This week's checklist goes straight to the Settings section each item is about (General, Invites & joining, Discord), instead of opening a panel on the old page. So does the session form's "Set the campaign's timezone". Nothing in the app links to ?open= any more. The old page still honours it for old links.

Tests cover the banner for its recipient, and its absence for anyone else and when the read fails. Both absence tests wait on the offer request itself, so neither can pass before it has answered. There's also a test for a player's Contents carrying the new door.

D22: the leave prompt, finally

SH-036 and IS-371 draw it: "Leave without saving? — Your changes to General are not saved." Stay (first, focused) / Leave without saving. It replaces window.confirm, which had two buttons and generic words.

  • ui/LeavePrompt is mounted once in RootProviders. The hook asks it through context; with no host above it (component tests) it falls back to window.confirm with the same words.
  • Escape counts as Stay. Focus goes into the dialog on open and back to what was pressed on close (IS-296, tested).
  • It fires on a pathname change, a ?tab= change, or a hash that isn't an element on the page. A literal "hash change" would prompt on Profile's own Contents jumps (/profile#p-when), where nothing is being left.
  • SH-035 is behaviour: beforeunload is unchanged and tested, and the browser's own "Leave site?" is not ours to style.

The hook's calling contract is kept. The one addition is an optional second argument, the name. hooks/leaveGuardCallers.test.js pins every caller and the name it passes:

Caller Name
Settings → General, Discord, Recording (×2) the section's label
Settings → Content packs new guard
Storyline "the story"
Members "this character" / "the new character"
Notifications, Profile "your notifications" / "your profile"
Session form "this session" / "the new session"
Session page "the summary" / "the transcript" / "the corrections", or "this page" when more than one is dirty
BeatNotesEditor, SessionShelf beforeunload only, unchanged

One behaviour change, and it's a bug fix. If the edit was saved between the click and the guard's answer, the navigation now goes ahead. It used to call reset() and drop the click, despite its own comment saying "let it through". useLeaveGuard also no longer registers beforeunload twice.

CAMP-15: Content packs, at campaign scope

Built fresh as pages/CampaignSettingsPacks.jsx. components/ContentPacks.jsx is untouched, because Admin (lane 9) still renders it and reskinning it would change Admin mid-lane. The two share api/contentPacks.js and the affirmation rules, and Admin's tests pass unchanged.

Every drawn state is built: nothing loaded, loading, would not load; Export and Remove (Remove asks first, names the record count, and a failure stays inside the dialog); the load form in each of its states; the state chips in words; an administrator turning one off, with a required reason recorded in the audit log; from a web address; CA-117 as replaced (two choices, default GM only); the affirmation's loading state, and its re-arm after a 422; importing / loaded with warnings / loading in the background; and a 4xx showing the server's sentence while a 5xx shows a plain one.

Not built: giving a pack a licence (IS-315's second half). No endpoint sets one (D36). "No licence given — nothing in this pack can be exported" is shown.

CAMP-17: Archive, export & delete

Who may do each part follows the endpoints, and each part is absent to anyone it isn't for. Export is require_gm; archive and delete are require_owner; restore is require_owner_or_admin. A GM who isn't the owner reads "Archiving and deleting the campaign are Dana Whitlock's, as its owner" (IS-199).

Delete is IS-012: Modal tier="typed". Tests cover near-misses (a partial name, the wrong case, a trailing dot, a doubled space), the exact name, and a failed delete that leaves the page usable.

What the page says is recoverable comes from the server. DELETE soft-deletes. The bin keeps a campaign for _DEFAULT_TRASH_RETENTION_DAYS = 30 days (floor 7, and nothing writes another value). Restore is the owner's or an admin's, from the bin on Home.

Not built: clone (IS-224–IS-226). That isn't the settings-copy the New campaign page already does. It's a clone with an optional member list and a wiki-entry checkbox tree, it has no endpoint, and it belongs to #125 (templates and cloning, v4.6.0).

Deliberately not built

  • CAMP-14, the wiki's rules. Who may edit is hard-coded in _can_edit_lore_entry, and reputation levels are stored nowhere. A form that saves neither is a dead control. #779.
  • CAMP-16, Foundry. There's no server at all: no Foundry fields on the campaign, no router, and scenes aren't first-class (#689). Recorded on #737, which now lists nine drawn Foundry rows across two surfaces waiting on phase 2.

Both stay built: false with a comment saying why, and neither appears in the index or the Contents.

SH-7: checked, and what was wrong

  • CA-003 / IS-370 failed and are fixed. Members (6 sites), the session form, Discord and Recording showed err.message verbatim, e.g. "Internal server error" or "Failed to fetch". The new utils/plainError.js shows a 4xx sentence, which is written for people, and otherwise the page's own plain words. Three tests had modelled a refusal as a 500; they now use realistic 400s and 409s.
  • Holds already: SH-034, IS-295 (chips carry words), IS-369 (Storyline's Undo; Invites deliberately has none, for the reason its comment gives).
  • Missing app-wide and not this PR's: there's no skip link anywhere (IS-292), and focus doesn't move on navigation (IS-296's other half). Both belong to the shell. #780.
  • Raw exception text can still arrive in a 4xx from three content-pack import sites, which the frontend can't filter without also dropping the good sentences beside them. #781.

Where the drawing is wrong

  • The typed delete says the campaign "waits in the bin for a week". The campaign bin is 30 days; a week is the audio bin. The page follows the server.
  • The export hint implies content packs are in the campaign export. They aren't in it at all, so the page says so.
  • Packs visibility "Just me" is wrong once a campaign has co-GMs, so it's "GM only".
  • The drawn affirmation wording isn't the server's. The page shows the server's text.

Checks

  • Frontend: 1925 tests in 141 files, green, run by me on the final tree.
  • eslint: 0 errors, 228 warnings, unchanged.
  • Coverage: lane2b.py and lane2c.py print no unclassified rows, and no classification changed.
  • Journeys: one full scripts/journeys.sh run by the build, 26 passed, which matters because D22 changes a hook the journeys' pages use.
  • Renders of the index, Packs, Archive, the delete dialog and the leave dialog, desktop and 390 px, light and dark. [clientWidth, scrollWidth] measured inside a 390 px iframe was [390, 390] on every page, and focus on the leave dialog's open landed on Stay.

🤖 Generated with Claude Code

The last PR of lane 3's surfaces. It builds **Content packs** (CAMP-15) and **Archive, export & delete** (CAMP-17), replaces the leave prompt (D22, SH-7), and fixes a regression lane 3 itself put on main. Two sections stay deliberately unbuilt. ## Read first: a regression, found reviewing this PR **From #769 until this PR, a player could not reach the quest log, the loot ledger, or an ownership offer.** The old campaign page (`/manage`) still holds all three, and all three are for every member. Its only door was "Everything else", a row I added **inside Settings, which is the GM's**. The sharpest case is an ownership offer. It can go to any member, and the bell's "has offered you this campaign" links to This week. This week didn't show the offer, and the player had no route to the page that could accept it. Fixed here: - **"Quests & loot"** is a Contents entry for every member, pointing at `/manage`. "Everything else" is gone from Settings, whose sub-list is now only its built sections. - **This week shows an offer addressed to the reader** as a `gm` banner ("a decision addressed to you"). **Answer** leads to where it can be accepted or declined. That costs one request, and a failure draws nothing and doesn't cost the page. - **This week's checklist goes straight to the Settings section each item is about** (General, Invites & joining, Discord), instead of opening a panel on the old page. So does the session form's "Set the campaign's timezone". Nothing in the app links to `?open=` any more. The old page still honours it for old links. Tests cover the banner for its recipient, and its absence for anyone else and when the read fails. Both absence tests wait on the offer request itself, so neither can pass before it has answered. There's also a test for a player's Contents carrying the new door. ## D22: the leave prompt, finally `SH-036` and `IS-371` draw it: *"Leave without saving? — Your changes to General are not saved."* **Stay** (first, focused) / **Leave without saving**. It replaces `window.confirm`, which had two buttons and generic words. - `ui/LeavePrompt` is mounted once in `RootProviders`. The hook asks it through context; with no host above it (component tests) it falls back to `window.confirm` with the same words. - Escape counts as Stay. Focus goes into the dialog on open and back to what was pressed on close (`IS-296`, tested). - It fires on a pathname change, a `?tab=` change, or a hash that isn't an element on the page. A literal "hash change" would prompt on Profile's own Contents jumps (`/profile#p-when`), where nothing is being left. - `SH-035` is behaviour: `beforeunload` is unchanged and tested, and the browser's own "Leave site?" is not ours to style. **The hook's calling contract is kept.** The one addition is an optional second argument, the name. `hooks/leaveGuardCallers.test.js` pins every caller and the name it passes: | Caller | Name | |---|---| | Settings → General, Discord, Recording (×2) | the section's label | | Settings → Content packs | new guard | | Storyline | "the story" | | Members | "this character" / "the new character" | | Notifications, Profile | "your notifications" / "your profile" | | Session form | "this session" / "the new session" | | Session page | "the summary" / "the transcript" / "the corrections", or "this page" when more than one is dirty | | BeatNotesEditor, SessionShelf | `beforeunload` only, unchanged | **One behaviour change, and it's a bug fix.** If the edit was saved between the click and the guard's answer, the navigation now goes ahead. It used to call `reset()` and drop the click, despite its own comment saying "let it through". `useLeaveGuard` also no longer registers `beforeunload` twice. ## CAMP-15: Content packs, at campaign scope Built fresh as `pages/CampaignSettingsPacks.jsx`. **`components/ContentPacks.jsx` is untouched**, because Admin (lane 9) still renders it and reskinning it would change Admin mid-lane. The two share `api/contentPacks.js` and the affirmation rules, and Admin's tests pass unchanged. Every drawn state is built: nothing loaded, loading, would not load; Export and Remove (Remove asks first, names the record count, and a failure stays inside the dialog); the load form in each of its states; the state chips in words; an administrator turning one off, with a required reason recorded in the audit log; from a web address; `CA-117` as replaced (two choices, default GM only); the affirmation's loading state, and its re-arm after a 422; importing / loaded with warnings / loading in the background; and a 4xx showing the server's sentence while a 5xx shows a plain one. **Not built:** giving a pack a licence (`IS-315`'s second half). No endpoint sets one (D36). "No licence given — nothing in this pack can be exported" is shown. ## CAMP-17: Archive, export & delete **Who may do each part follows the endpoints**, and each part is absent to anyone it isn't for. Export is `require_gm`; archive and delete are `require_owner`; restore is `require_owner_or_admin`. A GM who isn't the owner reads *"Archiving and deleting the campaign are Dana Whitlock's, as its owner"* (`IS-199`). **Delete is `IS-012`:** `Modal tier="typed"`. Tests cover near-misses (a partial name, the wrong case, a trailing dot, a doubled space), the exact name, and a failed delete that leaves the page usable. **What the page says is recoverable comes from the server.** `DELETE` soft-deletes. The bin keeps a campaign for `_DEFAULT_TRASH_RETENTION_DAYS` = **30 days** (floor 7, and nothing writes another value). Restore is the owner's or an admin's, from the bin on Home. **Not built:** clone (`IS-224`–`IS-226`). That isn't the settings-copy the New campaign page already does. It's a clone with an optional member list and a wiki-entry checkbox tree, it has no endpoint, and it belongs to **#125** (templates and cloning, v4.6.0). ## Deliberately not built - **CAMP-14, the wiki's rules.** Who may edit is hard-coded in `_can_edit_lore_entry`, and reputation levels are stored nowhere. A form that saves neither is a dead control. **#779**. - **CAMP-16, Foundry.** There's no server at all: no Foundry fields on the campaign, no router, and scenes aren't first-class (#689). Recorded on **#737**, which now lists nine drawn Foundry rows across two surfaces waiting on phase 2. Both stay `built: false` with a comment saying why, and neither appears in the index or the Contents. ## SH-7: checked, and what was wrong - **`CA-003` / `IS-370` failed and are fixed.** Members (6 sites), the session form, Discord and Recording showed `err.message` verbatim, e.g. "Internal server error" or "Failed to fetch". The new `utils/plainError.js` shows a 4xx sentence, which is written for people, and otherwise the page's own plain words. Three tests had modelled a *refusal* as a 500; they now use realistic 400s and 409s. - **Holds already:** `SH-034`, `IS-295` (chips carry words), `IS-369` (Storyline's Undo; Invites deliberately has none, for the reason its comment gives). - **Missing app-wide and not this PR's:** there's no skip link anywhere (`IS-292`), and focus doesn't move on navigation (`IS-296`'s other half). Both belong to the shell. **#780**. - **Raw exception text can still arrive in a 4xx** from three content-pack import sites, which the frontend can't filter without also dropping the good sentences beside them. **#781**. ## Where the drawing is wrong - **The typed delete says the campaign "waits in the bin for a week".** The campaign bin is 30 days; a week is the *audio* bin. The page follows the server. - **The export hint implies content packs are in the campaign export.** They aren't in it at all, so the page says so. - **Packs visibility "Just me"** is wrong once a campaign has co-GMs, so it's "GM only". - **The drawn affirmation wording isn't the server's.** The page shows the server's text. ## Checks - **Frontend:** 1925 tests in 141 files, green, run by me on the final tree. - **eslint:** 0 errors, 228 warnings, unchanged. - **Coverage:** `lane2b.py` and `lane2c.py` print no unclassified rows, and no classification changed. - **Journeys:** one full `scripts/journeys.sh` run by the build, **26 passed**, which matters because D22 changes a hook the journeys' pages use. - **Renders** of the index, Packs, Archive, the delete dialog and the leave dialog, desktop and 390 px, light and dark. `[clientWidth, scrollWidth]` measured inside a 390 px iframe was **[390, 390]** on every page, and focus on the leave dialog's open landed on **Stay**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
CAMP-15 Content packs and CAMP-17 Archive, export & delete, at
/campaigns/:id/settings/packs and /settings/archive, and SH-7's deferred
row: the in-app unsaved-changes prompt as a design-system dialog.

Content packs is built fresh in the kit; components/ContentPacks.jsx is
left untouched for Admin (lane 9). A row's controls follow the pack's
state; turning a pack off stays an administrator's and is absent for
anyone else; the load form keeps the affirmation's words the server's,
re-arms on a 422 and offers two visibility choices (CA-117).

Archive, export & delete gives each part to whoever its endpoint does:
export to a GM (require_gm), archive and delete to the owner
(require_owner, #644). The section is open to either, and says whose
the rest is. Delete is Modal tier="typed"; the page states the server's
bin: 30 days (_DEFAULT_TRASH_RETENTION_DAYS, which nothing overrides),
restorable from Home.

D22: LeavePrompt is mounted once in RootProviders and the guard asks it
through a context, naming the edit ("Your changes to General are not
saved."). Stay first and focused, focus returned on close. It asks on a
?tab= change and on a hash that is not a place on the page (IS-371), not
on a same-page jump. With no host above it the guard falls back to
window.confirm with the same words. Every caller keeps its guard and
names its edit; leaveGuardCallers.test.js pins the list.

Also: a navigation caught just after a save proceeds rather than being
dropped; useLeaveGuard no longer registers beforeunload twice; and
lane 3's pages stop showing a failed request's raw message (CA-003,
utils/plainError.js).

Not built, for want of a server (D36): the wiki's rules (IS-053,
IS-088), a pack's licence (IS-315), cloning (IS-224-226), Foundry
(CAMP-16).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up 23c82b4, the Contents test waiting for the request its
assertion needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A regression lane 3 put on main at #769, found reviewing this PR.

The old campaign page (`/manage`) still holds three things players use: the
quest log, the loot ledger, and answering an ownership offer
(`OwnershipNotices`, rendered for every member). Its only door since #769 was
"Everything else", a row inside Settings — and Settings is the GM's. So a
player could reach none of the three. The sharpest case: ownership can be
offered to any member, the bell's "has offered you this campaign" links to the
campaign's own address, and This week did not show the offer. A player could
be notified of an offer and have no way to answer it.

- The Contents gets a **"Quests & loot"** entry, for every member, pointing at
  `/manage`. "Everything else" leaves Settings, whose sub-list is now only its
  built sections.
- **This week draws an offer addressed to the reader** as a `gm` banner ("a
  decision addressed to you"), with **Answer** leading to the page that can
  accept or decline. One request, `GET …/ownership/offer`; a failure draws
  nothing and costs the page nothing.
- **This week's checklist and the session form's "Set the campaign's timezone"
  go to their Settings sections** — General, Invites & joining, Discord —
  instead of `?open=` panels on the old page. Nothing in the app links to
  `?open=` any more; the old page still honours it for old links.

Tests: the offer banner for its recipient, its absence for anybody else and
when the read fails (both waiting on the offer request itself, so neither
absence can pass before it has answered); a player's Contents carrying the
new door; all three checklist doors.

Full frontend suite: 1925 tests in 141 files, green. eslint 0 errors, 228
warnings (unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs(changelog): the quest log, loot ledger and ownership offer, reachable again (#729)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m3s
CI / Backend lint (ruff) (pull_request) Successful in 25s
CI / Bot/backend version sync (pull_request) Successful in 22s
CI / Bot tests and audit (pull_request) Successful in 1m47s
CI / Feedback site tests (pull_request) Successful in 1m35s
CI / Frontend tests, audit, and build (pull_request) Successful in 6m33s
CI / Docker image build (pull_request) Successful in 3m7s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 12m48s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 7m36s
CI / Backend migration, tests, and audit (pull_request) Successful in 24m33s
a85aecba46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.