From 1c643a15d84445acfb3f7b80bcc767431b53ee8c Mon Sep 17 00:00:00 2001 From: KodeStar Date: Sun, 18 Oct 2026 11:51:52 +0100 Subject: [PATCH 1/2] Docs: move the long reference sections out of CLAUDE.md CLAUDE.md loads into every session and subagent that touches this repo. The long per-topic reference sections move verbatim to CONVENTIONS.md (read on demand), and CLAUDE.md keeps a one-line-per-topic index pointing at them. No rule changed. --- CLAUDE.md | 1929 +++--------------------------------------------- CONVENTIONS.md | 1845 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1935 insertions(+), 1839 deletions(-) create mode 100644 CONVENTIONS.md diff --git a/CLAUDE.md b/CLAUDE.md index 229938ab..9407fe06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,1845 +138,96 @@ change - for this repo that's chiefly `docs-users/listening/**` and `audiosilo-docs/docs-developers/contributing/documentation.md`. Docs gate: `npm run build` in audiosilo-docs. -## Architecture & conventions - -**Path is identity, scoped by connection.** All content is addressed by -`(library_id, rel_path)`, never a DB id, and every content call passes -`?path=`. But the app is **multi-connection** (signed in to several -servers at once, and two servers can each have a "library 1"), so **durable/cache -client state is additionally scoped by connection id**: React Query keys (`qk.*`), -the downloads registry + on-disk/Cache-API files, the progress mirror + offline -replay queue, and browse scroll memory all key on `(connectionId, library_id, -path)`, not just `(library_id, path)`. Without the connection id two servers' -libraries bleed together and the offline queue could replay one server's positions -onto another. **A connection is one user on one server**, so its id is -`.` (`connectionIdOf`, `src/lib/connection-id.ts`; two people signed in to -one server are two connections with their own tokens, downloads, progress and cache). Never -split an id yourself, never compare one with a `server_id` (compare servers through -`serverIdOf(conn)`), and keep every id-keyed document's key in `src/lib/storage-keys.ts` (one -leaf module the launch-time migrations read without importing the stores; add a new -connection-keyed document to `ID_DOCUMENTS` in the identity migration too). The seam is `src/api/connection-clients.ts` - framework-free modules -(progress-sync, the downloads store) resolve a connection id to its `ApiClient` -(`resolveClient`) and gate on the session having hydrated (`sessionReady`) before -reading storage, without importing React; removing/signing out of a connection -**purges** its scoped state through the `onConnectionRemoved` registry in -`src/stores/session.ts`. Stale persisted state is reconciled once, before any store -hydrates, by `resetStaleStorage()` (run inside the memoised launch migration `migrateStorage()`, -`src/lib/storage-migration.ts`, which `bootstrapPlayback` (the launch steps, below) and ThemeProvider both await) along **two independent version axes** so cache churn -can never log anyone out (`{ authReset, cacheReset }`): -- **`AUTH_STORAGE_VERSION`** gates the auth wipe (connections + their secure-store session - tokens). Session tokens never expire server-side, so wiping them is the ONLY thing that - logs a user out and forces a re-pair. Bump it **ONLY** when the connection *identity - scheme itself* changes AND can't be re-keyed (like v2: ids became the server-minted - `server_id`) - a deliberate, rare, breaking act that logs **everyone** out. **Never bump it - for a cache reason**, and not for an identity change that can be re-keyed: Phase 8's - `.` ids moved every connection with the identity migration (below) and - nobody was signed out. -- **`CACHE_STORAGE_VERSION`** gates the disposable per-server cache (downloads + the - progress mirror/queue + the on-disk downloads root). Bump **this** for any scoped-state / - cache schema change: it wipes that cache (it re-downloads / re-syncs from the server) - while keeping every login intact. A pre-existing install that predates this split adopts - the cache version silently, without wiping its downloads. - -**The launch steps are ONE memoised run, `bootstrapPlayback()` (`src/lib/bootstrap.ts`)**: the -storage migration, then the downloads-root wipe (`engine.clearAll()`) when **either** axis reset, -then hydrating the session, settings, downloads, series orderings and library selection, with the -listener's language applied alongside. The root layout calls it before any screen, and so does the -Android car's headless task (Car below), which has no layout; a later activity on the same JS -runtime finds it done. Add a new launch step there, never in `_layout.tsx` alone, or the car runs -without it. -`migrateStorage()` first writes the theme default (see Theme below), so the existing-install signal is read -before the reset can rewrite the session keys - no effect-order contract. -See `src/api/client.ts` + `src/api/types.ts`. - -**Connection identity migration (Phase 8, `src/lib/identity-migration.ts`)**, run by `migrateStorage()` -after the reset and before any store hydrates: every connection saved with a legacy id (the bare -`server_id`) moves to `.` with everything keyed by it (`ID_DOCUMENTS`), and -nobody is signed out. Tokens are copied and read back first (any failure, such as a CarPlay launch -with the phone locked, aborts with nothing changed); then the plan is stored -(`audiosilo.identityPlan`) and from there it only goes **forward**: native download folders move -(`engine.moveConnection`, never reversed), every document is rewritten in one `setMany`, the -aliases (`{ legacyId: newId }`, `audiosilo.connectionAliases`, never wiped) and the first profiles -document are written, the version is recorded, and only then are the old tokens and the plan -deleted. Every rewrite maps through the plan, so a run cut short is finished by the next with the -same plan; `migrateStorage` retries a few times holding every reader, then gives up for this launch -with `identityMovePending` set, so downloads and the offline queue keep what names an id no -connection has yet instead of pruning it. Nothing moves while the Android service holds a book of -a connection being moved (`busy`). A connection with no usable `user.id` stays legacy, and a -legacy id must keep working everywhere (`Connection.serverId` is optional for it: read it through -`serverIdOf`). **Old ids arriving from outside** (a widget link, a car snapshot item, a bookmark -the Android service queued, web history) go through `resolveConnectionId` (`connectionParam` does -it for routes; `resolveClient` too, as a safety net). **The web moves no files**: the Cache API -can't rename, so `engine.web.ts` keeps a moved connection's files under its legacy id -(`legacyIdOf`) while the registry uses the new one. - -**API envelopes** (from the Go handlers): auth returns `{ token, user }`; `/me` -returns the user directly; lists are wrapped (`{ libraries }`, `{ books, next_cursor }`, -`{ progress }`, `{ bookmarks }`, `{ notes }`); errors are `{ error }`. Pairing deep -link is `audiosilo://connect?server=&token=`. - -**Client identification.** `ApiClient.request()` sends -`X-AudioSilo-Client: AudioSilo/ ()` on every API call -so the server can record which app owns each session token (`src/lib/client-id.ts`). -On web it is sent **only when the API base is same-origin with the page**: a custom -header makes a cross-origin request non-simple (CORS preflight), and servers released -before the header don't allow it. The embedded `/web` player is same-origin and always -identifies; native has no CORS. `authHeaders()` (media layers) does not carry it. - -**Self-service password.** A signed-in user can set/change a password -(`client.setPassword`) from the per-server account screen so they can get back in on any -device after signing out without an admin. Sign-out is guarded -(`src/lib/account.ts` `needsPasswordWarning`): a non-admin, non-demo user with **neither a -password nor a recovery code** (`has_password === false && has_recovery === false`) is -warned before their only credential (the session token) is revoked - a legacy user who -still holds a working recovery code is exempt (it re-pairs them via the connect screen's -code field, so the "you'll need a new invite" warning would be false for them). The -warning's remedy is **"Set a password"** - it dismisses the -sheet and opens the account screen's set-password editor -(`src/components/account/sign-out-confirm.tsx`). The connect screen's code field still -redeems an admin invite code as the other way in. - -The **recovery-code** feature (a durable user-owned auth code minted from Settings) was -**removed from this client's UI** - it was confusing and unused. The server keeps its -`/auth/recovery` endpoints for already-shipped older clients, and `/me` still returns a -`has_recovery` field, so `User.has_recovery` (`src/api/types.ts`) is **retained as a -tolerated legacy wire field** (marked `@deprecated`). The only reader is -`needsPasswordWarning` (above), which uses it solely to **suppress** the sign-out warning -for a legacy user who still holds a working recovery code - the feature itself has no UI. -Any legacy recovery code a user still holds keeps working because the connect -screen's code field redeems it through the same `redeemCode → exchange` path as an invite. - -**Dead-token reconnect.** Session tokens never expire server-side, so an involuntary -logout is rare - but when a token IS genuinely dead (admin revoked it, or the server's -data dir was reset) the app must not fail every request silently forever. Detection lives -at ONE choke point - the `ApiClient` itself - so it covers **every** request path -(queries, the `useMutation`s in `src/api/hooks.ts`, and the framework-free progress-sync -save loop) without per-call handling or a background poller. `ApiClient.request()` invokes -an injected `onAuthError` callback whenever a request produces an `ApiError` with status -**401 only** (a 403 is "valid token, forbidden" - a scope/share denial, admin-only, or -api-key/demo restriction - NOT a dead token, so flagging it would false-positive the -reconnect prompt for a scoped user browsing outside their share). That check is reliable -because `ApiClient` only throws `ApiError` for a real HTTP response; a network/offline -failure throws `TimeoutError`/a raw fetch rejection with no status - so a 401 inherently -proves "server answered, token rejected", cleanly distinct from offline (the reachability -layer relies on the same invariant; the full rationale + the server-source citations live -on the `onAuthError` constructor doc in `client.ts`). The callback is injected wherever a per-connection client is -built - `provider.tsx`'s client map AND `connection-clients.resolveClient` - as `() => -markNeedsReconnect(cid, 'auth')`; `client.ts` never imports the session store (no cycle). -**Onboarding** flows (connect / sign-in / demo) build bare clients with NO callback, so a -wrong-password 401 on `/auth/login` can't false-flag a reconnect. The React Query -`QueryCache` keeps only the two success-side jobs a per-request error callback can't do: -**clearing** the flag when an authenticated query succeeds (the connection id resolved from -the query key by membership against the live ids, `connectionIdFromKey`, since keys are -connection-scoped but not at a fixed index), and detecting a **server reset** by -piggybacking on the existing `useServerInfo()` fetch - a successful public `/server` -response whose `server_id` differs from the connection's (`serverIdOf`) flags `server-reset` -(that public success is skipped for flag-clearing, since it proves nothing about the token). - -The flag is a per-connection `Connection.needsReconnect?: 'auth' | 'server-reset'` in -`src/stores/session.ts` (`markNeedsReconnect`/`clearNeedsReconnect`) - **in-memory only** -(stripped from the persisted shape, recomputed from the next failure), and marking it -never removes the connection or its token (only a successful `setSession` re-pair replaces -the token) - and `markNeedsReconnect` never downgrades a `server-reset` flag to `auth`, so -frequent authed 401s can't clobber the rarer, more informative server-reset reason. A -re-pair of a reset server (a *stale same-URL connection under a different `server_id`*, -`staleIdentities`) retires **only the signer's own** stale identity (`setSession({ retire })`; -`finishConnect` names them with `staleToRetire`: the reconnect banner's person, else the person -listening, nobody's for "Add someone"): its dead token and scoped state go through the -`onConnectionRemoved` registry, and the new connection takes its place in that person's profile -(`onConnectionReplaced`, so their PIN and kids settings stay). Every other person's stale -identity stays, flagged `server-reset`, for them to sign in again: never delete another person -from the device. A kids profile never runs the connect flow (`connect/_layout.tsx` is the only -gate, Phase 8b-1): a grown-up signs it in again. It surfaces as a slim accent bar (`src/components/layout/reconnect-banner.tsx`, -rendered by the shell beside the offline banner - under the phone header, or under the top bar -on tablet/desktop); tapping it pre-fills `pendingServerUrl` -and routes into the EXISTING connect → sign-in screens to re-enter a code/password. -`setSession` also upserts a durable, tokenless **known-servers** entry -(`src/lib/known-servers.ts`, AsyncStorage key `audiosilo.knownServers`, NOT touched by -`resetStaleStorage`), so after a full logout the connect screen offers one-tap -"Reconnect to " shortcuts (with a per-entry forget). - -**Home and away addresses (Phase 5, capability `addresses`).** A connection keeps the server's -`addresses: { home?, away? }` (persisted with its metadata, no storage-version bump). Addresses are -cleaned once where they arrive (`parsePairingScan`, and the `ApiClient` methods that return them); -`setSession` merges what a pairing link, redeem payload, exchange or login says and keeps the prior -value when absent, and `learnAddresses` does the same for a `GET /addresses` answer; -`mergeAddresses` (`src/lib/server-address.ts`) keeps a known `home` an answer lacks, because the -server derives home from the request (an answer read through the away address can't know it). -`serverUrl` stays what the user paired with and is never rewritten. Which address requests go to is -an in-memory pick (`src/api/address-route.ts`: `useAddressRoute`, `effectiveUrl(c)`, and -`useActiveAddress(cid)` plus `ADDRESS_KIND_LABEL`/`ADDRESS_IN_USE_LABEL` for UI); `ApiProvider` and -`resolveClient` are the only places that build a connection client and both use it, so never build -one from `serverUrl`. The pick rule is pure (`pickAddress`): home only when a tokenless -`GET /api/v1/server` (`probeServerId`, `src/api/server-id-probe.ts`) answers with this -connection's `server_id` (another box at the same private IP must never get the token), else away, -else `serverUrl`; the web never switches (the served player is same-origin). The runner -(`src/api/address-runner.ts`, `startAddressRouting` from the root layout, native only) re-picks at -launch, on foreground, on a network change and when reachability marks a connection offline, asks -home again every `HOME_RECHECK_MS` in the foreground (away, to come home; on home, to notice it -stopped answering, since an idle app sends nothing else that would fail), drops a -home pick at once when the device moves network, refreshes `GET /addresses` (`useServerAddresses` -is the hook form), and restarts a streamed book still playing from a previous address in place -through `startBookInPlace` (never a paused one). `parsePairingScan` returns `addresses` from -`home=`/`away=` on both link carriers. The known-servers entry keeps the addresses too -(the session hands `remember` / `rememberAddresses` what it merged, and `setSession` falls back to -them when signing in again after signing out), and a "Reconnect to " row on native signs in -through the home address when it answers as that server (`reconnectAddress`). The "At home and -away" card is ONE component, `AddressesCard` (`src/components/layout/addresses-card.tsx`): connect -shows it when the server has both addresses, Account shows whichever it knows and, on native, the -one in use (`inUse`); the web passes no `inUse` and says only what the addresses are for. - -**Connect and onboarding** (`src/components/connect/`, routes `src/app/connect/{index,sign-in,ready,scan}.tsx`): -every way in (pairing link or QR, invite code, password, demo) ends in `finishConnect`, which -stores the connection with the server's name and the addresses the link (`linkAddresses`) and the -answer taught, merged there once, and shows `/connect/ready?connection=` ("Your library is -ready.") only for the device's first connection; an added server goes back with `leaveOnboarding()`. A -link naming a book (`book=&path=` + `uid`, the admin console's "Listen in -player": `parseConnectBook`, carried on `PairingScan` as `book` + `userId` by the route and a pasted -link alike) ends on that book instead (`endConnect`, the one ending `finishConnect` shares: a -`dismissTo` of `bookHref`, never a URL from the link), and `useLinkPairing.pair` skips the exchange -when the device is already signed in as `uid` on `sid` (`signedInAs`) and the server still takes -that stored token (`stillSignedIn`: `GET /me` through the connection's own client, never the -link's base; `needsReconnect` is never stored, so a fresh tab can't tell otherwise), never for -"Add someone"; an invalid `book` is ignored. A -pairing link whose https base can't connect (a server on its own self-signed certificate) exchanges -once at the link's `home` instead (native only): only when that is plain http on the base's own -host and port, never after an `ApiError` or a timeout, and only when the link names the server -(`sid=`, `PairingScan.serverId`; an older server's link has none, so no fallback) and -home, asked with no token (`probeServerId`), answers as exactly that server (`pairingFallback`); a -session another server issues there is not kept. The token crosses the network in the clear, so -it goes to that server only. The connection is still stored with the https base as its `serverUrl` -(the address used when home is not an option fails safely by TLS on another network, where a -plain-http address could be another box), and `finishConnect`'s `answeredAt` makes home its first -address pick so the first requests go there; the address runner re-checks it as any home pick. -Android allows cleartext for it (`expo-build-properties` `android.usesCleartextTraffic`). -iOS does NOT allow arbitrary loads: Expo's ATS default is `NSAllowsArbitraryLoads: false` with -`NSAllowsLocalNetworking: true`, so plain http works there for IP addresses, `.local` and -single-label names, but not for a `.lan`, `.home.arpa` or `.internal` home. `repairPlan`: a -re-pair through one of a connection's own addresses keeps its paired `serverUrl`; the reconnect -banner signs in through `effectiveUrl` and passes `reconnect=`, so a reset server reached -through its away address still retires its dead identity. `/connect` decides its "nothing to add" -bounce when it opens and when it is back on top, never while the flow is over it; `sign-in` reads -`pendingServerUrl` once. The connect screens are full screens: `ConnectFrame` shrinks its column by -the keyboard overlap and `ConnectInput` scrolls the focused field into view (use both for any new -field there; Android edge to edge doesn't resize the window). The cascade panel is generated art: -never put fake titles on it. **The served player** (`servedOrigin()`, `src/lib/base-url.ts`: a -production web build, whose origin IS the server and whose CSP, `connect-src 'self'`, reaches no -other) asks for no address: `/connect` asks its own origin who it is and shows the found card -(Sign in, Try the demo) with no field or remembered servers, falling back to the field filled with -the origin only when that fails; "Add a server" (profile menu, Account) is hidden while every -connection is that origin (`onlyOwnServer`: a production build on a static host of its own, beside -servers whose CORS let it in, still adds them) and the sign-in step's "Another server" reads "Back" -and goes back (to the card, or to the app the reconnect banner was tapped in). Metro dev -(`npm run web`) is not the server and keeps the field. - -**Account page (Phase 5).** One server's account is `AccountSection({ connectionId? })` -(`src/components/account/account-section.tsx`): the `/account?connection=` route renders it, -with a breadcrumb only when the page under it is one the crumb can name (Settings, or a You hub -section: `accountParentKey`; from the profile menu or a cold link the chrome's own Back is the way -back); the phone You hub's Account segment renders it without an id (the default server, with a -server switcher when several are signed in). It is a non-scrolling column: the host scrolls it -(`TabPageScroll`). Blocks, each gated on its capability: identity (the session count needs -`my_devices`), password (hidden for demo; set/change in a dialog that lifts above the keyboard), pair -another device (`api.pair()`: QR, a 10-minute countdown from arrival, Copy link on web, Share on -native), "At home and away" (`addresses`, above), signed-in devices (`my_devices`; sessions only, -each with a glyph from `deviceGlyph`: laptop for a browser, tablet when its name says so, else -phone), personal API keys (`api_keys`; a notice when the flag is false; hidden for demo), sign out -of the server. **The current device's row never offers a sign-out** (`canRevoke` refuses -`current`); this device signs out only through `useSignOut` (teardown first). Pure helpers in -`account-model.ts`. - -**Personal API keys.** The account page (`AccountSection`, above) -renders an API-keys section (`src/components/account/api-keys-section.tsx` + -`use-api-keys-manager.ts`, one-time secret via `api-key-created-modal.tsx`) for -user-minted, non-expiring bearer tokens (dashboards, cron). It is **capability-gated** -(server `api_keys`) and **demo-hidden**. State is connection-scoped: hooks -(`useApiKeys`/`useCreateApiKey`/`useRevokeApiKey`) key on `qk.apiKeys(cid)`; -`client.{createApiKey,listApiKeys,revokeApiKey}` hit `/auth/tokens`. The secret is -returned once by `createApiKey` and shown in the copy-once modal (`ApiKeyCreated`); -the list is metadata-only (`ApiKey`, with `last_seen`). Strings under -`settings.apiKeys.*`. - -**Profiles and PIN (Phase 8, `src/stores/profiles.ts`, `src/components/profiles/`).** A profile -is a PERSON on this device: a device-local group of connections, at most one per server, with an -optional 4-digit PIN and kids mode. One document, `audiosilo.profiles` (the identity migration -writes the first, `initialProfiles`), hydrated in `bootstrapPlayback()` and kept consistent with -the session by `reconcileProfiles` (a removed connection leaves its profile, an empty profile is -deleted, an unplaced connection goes to the active person unless they have that server); the -connect flow places a new sign-in with `placeConnection` (another user on a server the active -person has, or "Add someone" `ADD_SOMEONE_HREF`, is a new person). **Background work keeps ALL -connections** (the offline queue, downloads, address routing, car bookmarks: a switch must never -make another person's saves unroutable); **every list a person SEES is the active profile's** -(`useProfileConnections` / `profileConnections`, never `useSession((s) => s.connections)`; the car -snapshot and the widget follow `activeProfileId`). The rules: -- **`mayActFor(cid)`** is the one play rule, enforced in the playback store at every start - (`playBook`, `startBookInPlace`, `adoptLoaded`: the player route, the car, a resumed Cast - session): never another person's book, never while nobody is chosen. Screens only offer the - switch. -- **Routes carrying `?connection=`** follow `useRouteConnection` (`content-scope.tsx`; `ContentScope` - for the content screens, called directly by the root routes: player, end credits, year, - `/connect/ready`): an unknown connection gets a not-found state, another person's gets - `OtherPersonGate` (switch first, their PIN asked). -- **Nobody chosen** (`mustChoose`: the active person left and others remain; persisted as - `activeProfileId: null`): the auth gate holds the shell and opens "Who's listening?" - (`/profiles`, a root full-screen modal); every person-facing list is empty - (`visibleConnectionIds`), the car lists nothing, the widget clears. A cold launch asks once - when `askAtLaunch` (turned on when a second person appears). -- **Switching** (`switchProfile`, through `useChooseProfile`): the PIN first when there is one, - then the other person's playing book is stopped (paused, saved, unloaded) and the default - connection moves to the new person's first. -- **The PIN's authority is the secure store** (`src/lib/profile-pin.ts`, `audiosilo.profilePin.`: - a salted, 10,000-round SHA-256 chain, constant-time compare, 5 tries then a 30 s wait). The - profile's `hasPin` is only a mirror: a hash the mirror lost still asks (and repairs it), and - setting a PIN over a stored one asks for it first. SHA-256 is pure JS (`src/lib/sha256.ts`: - no subtle crypto on a plain-http web origin, and an async digest per round was too slow), - hashed in chunks that let the UI paint. A forgotten PIN: remove that person from the device. -- **A document that can't be read** is retried (`HYDRATE_ATTEMPTS`), never taken as missing - (rebuilding it would drop every PIN and kids setting). Then: with someone signed in the store - stays shut (`hydrationFailed`: nothing shows or plays, nobody new signs in) and reads again - on the foreground; with nobody signed in (web storage blocked) it runs `unread`, in memory, - for whoever signs in now, never writing the document and never placing a connection it did - not see signed in. - -**Kids mode (Phase 8, `src/components/kids/`, route `/kids`).** A profile's `kids` settings -(library, bedtime). The whole app for that profile is `/kids`: `KidsRouteGuard` (root layout) -sends it back from anywhere but `/kids`, `/profiles` and `/connect` (`kidsAllowsPath`), and sends -a grown-up off `/kids`; the auth gate never mounts the shell for it. The page is ONE `FlashList` -(`KidsStoriesList`: the header, then the shelf as big cover tiles, stories in progress first, -paged as it scrolls; offline, the stories downloaded for that shelf, `kidsOfflineScope`); a story -starts in place with `startBookInPlace` (never the full player), played by `KidsPlayer`. The -Grown-ups button is a 2 s hold that opens "Who's listening?", where PINs apply. The shell's -reconnect banner never shows there (it opens the connect flow): a kid whose sign-in ended sees -`SignedOutNotice` in the page header, a line asking a grown-up (who signs them in again from the -Household page). **Bedtime** is the auto sleep rule while a kids -profile is active (`listenerSettings()` in `src/playback/listener-settings.ts`, which also makes -its default speed 1x: from its bedtime until `KIDS_BEDTIME_UNTIL`, a `KIDS_BEDTIME_MINUTES` timer on -every story; never in the car). **The gate**: `connect/_layout.tsx` is the only gate between the -connect flow and kids mode: while a kids profile listens it runs none of the flow (a link or the -reconnect banner takes the child back to `/kids`, with a toast asking a grown-up), except a -grown-up's "Add someone" on a device with no grown-up (`kidsConnectAllowed`). Nobody new joins -while a kids profile listens and a grown-up is on the device (`mayAddPerson`, in `finishConnect` -and `addMemberHere`), and kids mode is never turned on for the last grown-up (`mayTurnOnKids`; a -device already without one keeps "Add someone" as its way out). - -**Listening in the house (Phase 8, capability `household`, `src/components/house/`).** A person -who shares (`HouseSharingRow` on the Household page, `useSetHousehold`) sees what the others on -that server who share are listening to: the Home row (`HouseRow`, after "Next in your series") and -the book page aside's `InTheHouseCard`. `useHouseRows(cid, active)` asks `useHousehold` first and -polls `useHouseListening` (`HOUSE_POLL_MS`, foreground only) only while the caller shares and the -surface is on screen; loading, errors and "not sharing" all read as no rows. The pure rules -(`house-model.ts`) compute the local calendar day by hand (Hermes has no -`Intl.RelativeTimeFormat`). Strings under `house.*`. - -**Household members (Phase 8b-1, capability `household_members`, `src/components/household/`).** -An owner adds and manages accounts of their own: a kid (no password or API keys; the owner signs -them in) or a grown-up, each on "Everything I can listen to" or "Only collections I share". -- **Wire**: `useHouseholdMembers` and the member hooks in `hooks.ts`; their mutations reject with - `CapabilityError` (never to `noteError`). `allowed` (else a `reason`) gates adding only: an owner - can always rename, sign out and delete their members. `useRevokeMyDevice({ id, alsoMembers })` - also signs out the members signed in on that device (its `member_sign_ins`; sent only to a - server with the flag). Switching a member to everything sends `collection_ids: []` (the owner's - shares to them stop). Removing any connection reads the household again on its server - (`removeConnection`'s hook in `hooks.ts`). -- **Refusals**: `ApiError.code` with `apiErrorKind(e)` (`client.ts`): branch on the code, fall back - on the status for an older server, and say it in the app's words; never show `ApiError.message`. -- **Naming the owner**: copy names a member's owner through `managerName(user, { connection, - people })` / `useManagerName`: the profile holding the owner's own sign-in on this device, else - `managed_by.display_name`, else the username; never `managed_by.username` directly. -- **Signing a member in here**: `addMemberHere` (a new member, or "They'll also use this phone"), - `renewMemberHere` (their session here ended) and `signInMemberHere` (`use-sign-in-here.ts`). - They store the session with `setSession(..., { makeDefault: false })` and give the member a - profile with `placeConnection(id, { newProfile, keepActive, name })`: the owner stays the - person listening, so never `switchProfile`. A member's session on this device is marked "This - device" on their card; signed out from there it takes the dead-token path (`markNeedsReconnect`). -- **Kids**: a kid's account starts in kids mode (`kidsOnForKidAccount`), even with no grown-up on - the device (the `mayTurnOnKids` exception, `isKidsAccount`). Its shelf is the collections its - owner shares with it (`kidsShelfMode`; none, or an older server: one library, as before), and - it plays at 1x by default (`listenerSettings`). The connect gate above keeps the flow out. -- **The Household page**: the phone You hub's Household segment, and `/household` through - `openHousehold()` elsewhere; `/settings?section=household` redirects there. It holds the people - on this device, a card per member (access with Change, the Kids switch, their devices, "Set up - their own device", Rename, Delete twice confirmed, apart from "Remove from this device"), the - add dialog, then Listening in the house. My devices offers "Also sign out " for a device - holding member sign-ins, and the collection share sheet notes which members can listen to it. - -**The Bridge (Bridge Phase 1a, capability `bridge`, admin only, `src/components/bridge/`).** The -player's first admin-only UI (Bridge decision 9) and its one Bridge kind, `sidecars.generate` -("Generate characters and recaps"); the console covers everything else. -- **Wire** (`types.ts`): `Capabilities.bridge` (a Bridge paired and online) and - `ServerInfo.bridge {version, features}`; `BridgeJob` (state `queued | claimed | running | - needs_attention | done | failed | cancelled`; `path` is the book's own `rel_path`; `error` is a - failure or, while parked, the park message), `BridgeJobList`, `BridgeJobRequest`, - `SidecarsGenerateParams` (`source` auto/audio/ebook, `contribute` local/issue, `work_id`), - `SidecarsGenerateProgress` (step, message, done/total, stage, `eta_seconds`, `cost_usd`, - source, `park {code, hint, retry_at}`), `SidecarsGenerateResult` (with Phase 1c's - `description` and `description_error?`), `BridgeEstimate` (source, ebook, duration, - `asr_hours` (0 = not known for audio), `agent_cost_usd` (null = no history), - `subscription`, `agent_ready`, `can_contribute?`, `figures` (without them readiness and - costs are only defaults), `writable`, `can_describe?` (Phase 1c; the player queues no - description), `has {community, local, community_unknown?}` (each `{characters, recaps, - description?}`), `earlier` (up to 10 earlier series books without recaps, each costed), - `open_job`). `client.ts`: - `bridgeJobs(lib, path)` (`GET /admin/bridge/jobs`, first page), `queueBridgeJob` (201 new, 200 - the open one already queued), `cancelBridgeJob`, `bridgeEstimate` (the upstream budget, - `UPSTREAM_TIMEOUT_MS`); refusals `library_not_writable` and `kind_unavailable` are - `API_ERROR_CODES` (`bridgeRefusalKey` words them; never `ApiError.message`). -- **Hooks** (`hooks.ts`, `qk.bridgeJobs` / `qk.bridgeEstimate`): `useBridgeJobs(lib, path, - {enabled, active, select})` asks only a server with the flag and only while `enabled` (a - non-admin would get a 403), `retry: false`; it POLLS (`bridgePollInterval`, - `src/lib/bridge-jobs.ts`) only while a `sidecars.generate` job of the book is open and the - surface is `active` with the app foregrounded: `BRIDGE_POLL_MS` (10 s) while claimed or - running, `BRIDGE_IDLE_POLL_MS` (60 s) while queued or parked, never otherwise (the answer - that sees the job end stops it). When a read first sees the newest generate job `done`, - `refreshAfterFinish` invalidates the book's `/meta` (every variant) and its item (under any - path that names it) and WAITS for them before the answer lands, once per finish per cache - (`finishesActedOn`), so the tabs and `generated` are in before the status goes and the offer - never flashes back. `useBridgeEstimate` (`staleTime: 0`, read afresh each time the - confirmation opens), `useQueueBridgeJob` / `useCancelBridgeJob` (`CapabilityError` without - the flag; the answer goes into the book's job list, a cancel's 409 reads it again). - `useCapabilityQuery` gained `retry`, `select` and a query-aware `refetchInterval` for them. -- **The gate** (`bridge-model.ts`, pure and tested): `mayGenerate(user, server)` = an admin - (never a household member, a kid's or a demo account, whatever the role says), the `bridge` - flag, `features` including `sidecars`, AND `metadata` (generated content is served through - `/meta`). Nothing renders, and no job is asked for, without it. `generateOffer`: `generate` - for a book with no characters or recaps at all (community or generated), `regenerate` (the - book menu) when some were generated here, nothing until the book's `/meta` answer - (`metaAnswered`) and its jobs are known, or while a job shows its status (`statusJob`: the - newest generate job while open or once failed). The player always sends `GENERATE_PARAMS` - (`source: auto`, `contribute: local`): it never offers to contribute. -- **Surfaces**: `useGenerate` (one book's model: allowed, offer, the confirmation, the queue - and cancel writes; its own job read is `active: false` with a `select`, so progress polls - re-render only the status), `GenerateDialog` (the confirmation: source, transcription time - and agent cost of every run it queues, `runEstimate`, a subscription's note, where the - results appear, and a switch, on by default, to queue the series' earlier books without - recaps first, `queueOrder` / `queueInOrder`, stopping at the first refusal; Generate held - back with `blockedReason` while the library isn't writable or the agent is KNOWN not ready), - `GenerateStatus` (polls while its screen is focused: phase, stage words `bridge.stage.*` - from `stageOf`, counts, ETA, a park's reason `parkReason` and when it resumes, a failure, - Cancel, or "Try again", re-queueing, once it failed) and `GenerateOffer` (button or status under the companion's empty Who's - who / Story so far). The book page wires `useGenerate` into `HeroActions` with - `GenerateStatus` under the actions. Costs format with `formatUsd` (`src/lib/format.ts`). - Strings under `bridge.*` in all 6 locales; fixtures in `src/testing/bridge-fixtures.ts`. - -**The book page** (`src/components/book/`, the route `book/[libraryId].tsx` only names -`BookScreen`; rules in `book-page-model.ts` and `book-details-model.ts`, both tested) is the -Stacks book page, laid out by its MEASURED width (`bookPageLayout`: the cover beside the text -from 600, the aside as a right-hand column from 900, between the hero and the tabs below it; -the Up next drawer can leave a desktop page phone-narrow). **Hero** (`book-hero.tsx`): -`CoverWash` from `cover_color` (a neutral wash without one), quiet folder crumbs, the eyebrow -(series + "Book N", opening the series page - every series in `series_list` for a book in -several, apart by " / "; else library and server), the title (one size -smaller past 48 characters), the server's `subtitle` under it unless the title already says it -(`heroSubtitle`), the byline (author and narrator pages; the narrator is plain text -where `browse_people` is off), the facts (`bookFacts`), then the place of a book in progress -(percent, "Chapter N of M", time left at the book's own speed, the progress bar: the page's one -pink thing) or the finished badge (`finished_at`) and the stars (`ratings`, `useBookRating` -keeps the note). **Actions** (`hero-actions.tsx`): the primary (`primaryAction`: Pause while -this book plays, Resume chapter N, Start listening, Listen again; through `usePlayBook` with -`toggle`, so it never restarts the loaded book), `DownloadControl` (`short` on a stacked hero), Up next (Play -next / Add to the end, `queue`), favourite (ink, never pink), Add to collection -(`collections`) and `BookActionsMenu` with `omit` (the items the hero already has), and for an -admin the Bridge's `generate` (below: "Generate characters and recaps" as an outline button -after the others, its own row on a phone; "Regenerate" quieter, in the book menu's `extra`). -Labels that count a chapter by its place ("Resume chapter N" here, on Home and the series page, -"Ch. N of M") go through `contradictedTitle` / `resumeChapterLabel` (`src/lib/chapter-label.ts`): -when the chapter's title carries another number (a book that opens with a Prologue), the label -says the title. -`DownloadProgress` and the transcode note sit under them. Chapter rows, timeline taps and pins -go through `usePlayBook` with `at` (below): a phone opens the player on the place, a tablet or -desktop jumps the loaded book there and plays on, or starts this one there. **Tabs** (`bookTabs()`, `src/components/library/book-tabs.ts`): -Chapters (label Chapters / Parts / Files; the default tab) · Recaps · Characters · Bookmarks · -History · Notes · Series · **Details**, with counts where already in hand (chapters, bookmarks, -notes, characters met; the counts and the pins read `useBookAnnotations` under the route's path, -the same entries the Bookmarks and Notes tabs write). The active tab's panel is `BookTabPanel` -(`book-tab-panel.tsx`; the tab row is the page's). Chapters (`book-chapters-tab.tsx`) is "The whole book" (the player's -`BookTimeline` for ANY book, pins from `useBookAnnotations`, which `usePlayingPins` reads for -the playing book; the rows' place is `rowAt`, previous/next for a book not playing the player's -own `nextSegmentStart`/`previousSegmentStart`) then the rows (`chapterList`: the real chapters at their corrected starts, a -long chapterless file's 30-minute parts as the player makes them, else the files), ending with a -quiet globe caption ("Chapters from the AudioSilo community database") only when the chapters -answer says `chapters_source: "community"` and the rows are those chapters (not parts or files). Bookmarks and -Notes are the shared `AnnotationSection` (below), History is `HistorySection` over the shared -listening sessions. Details (`book-details-tab.tsx`): a `Notice` (`ui/notice.tsx`) saying direct -play / converted for this browser (web, `useNeedsWebTranscode`) / plays from this device, the files table ("about N kbps" = size * 8 / duration, folded past 6), -and the path progress keys on. The **aside** (`book-aside.tsx`): About (`BookAbout`, its words -from the pure `aboutContent` in `book-details-model.ts`: `community_description` with the server's -attribution and "Improve this", else the server's `description`, else the work's core -description, else the description a Bridge generated on this server (`aboutContent`'s `work`, the -`bookMetaWork` value, so an unmatched book has it too, and the one source of every work-level -field, the credit included; `BookAbout` takes it as a required prop; the order is picked once, -into `source`, and a `generated` text wears `GeneratedChip` (`testID` `about-generated-mark`) -under the eyebrow, never the credit or "Improve this"), else a sentence naming the author and -narrator; then the production facts known, the server's own `publisher` / `language` (named via -`Intl.DisplayNames` where the platform has it) / -`abridged` (absent = unknown, no row; the community only ever says true) before the community's; then the book's `genres` and `tags` -as chips (`chipClass`, the filter chips' pill), each opening the Library's Books mode on it in the -book's library, `openLabelBooks`; the publisher rule is `bookPublisher`, shared with the hero's facts), -Other versions -(`BookVersions`), Your listening (`listeningFigures`: started, finished, speed, and the time -listened summed from this book's history spans, `listeningSummary`). The panels render inside the page's own ScrollView (never a nested -vertical scroller). Which tabs exist: chapters when there's a list, the three metadata -tabs only when that data is non-empty (so nothing regresses on an older server; an unmatched -book shows Characters and Recaps only for what a Bridge generated on its server, `bookMetaWork`), bookmarks/history/notes always (they're user-creatable, so they must be reachable from -empty - hence the `emptyLabel`), Details always. `tab` is held as an *intent* (`?tab=`); render -falls back to the first existing tab when data changes under it. Labels come from -`TAB_LABEL_KEY` (same module), which deliberately **reuses** the existing strings -(`library.{bookmarks,history,notes}.title`, `book.meta.characters`). - -**Enriched book metadata.** One `useBookMeta` fetch at the screen level feeds -`matchedMeta()` and the placeable blocks exported from -`src/components/library/book-meta.tsx` (and the book page's About card, above): -`BookMetaRecapsTab`, `BookMetaCharactersTab`, -`BookMetaSeriesTab` (the rails' pure rules are `series-rails.ts`). Those take **plain data, not a query**, so a sibling block can be -appended without another restructure - which is how the **"catch up on previous books"** -block lands: `previousWorks(rails)` (pure, tested - earlier positions only, from each -rail's shown reading order, deduped, position-DESCENDING, unparsable positions dropped) -feeds one shared accordion into both the Recaps and Characters tabs, and each row -lazily fetches its own work with `useMetaWork(workId, open)` (`client.metaWork` → -`GET /meta/work?id=`, key `qk.metaWork(cid, workId)`, 1h/`retry:false`) - a closed row never fetches, and any -failure (an older server 404s, since it lacks the route) is a quiet caption + the -entry's `web_url` link, never an error. Bodies: the work's `recap_summary.in_short` -(else its furthest book-scope recap via `lastBookRecap`), and its `CharacterCard`s. -`recap_summary.ending` is a **full spoiler** - always behind its own extra tap -(`How it ends` + chip), and for the CURRENT book only offered once `progress.finished`. -`in_short` is **not** spoiler-free either: it is the whole book in one paragraph, -ending included. A previous book (whose row the reader opened deliberately) and a -finished current book show it inline under "In short"; an UNFINISHED current book -heads its Recaps tab with a collapsed `Whole-book summary` + chip row instead -(the shared `SpoilerAccordion`, which mounts its text only once opened). ONE exported -predicate, `summaryIsVisible(summary, finished)` (`in_short` - inline or as that tap -row - or an `ending` once the ending is in play), is called by the screen (feeding -both `bookTabs` and `BookMetaRecapsTab`), by `RecapSummaryBlock`'s own null-guard -and by the render tests - so a Recaps tab can never open onto a panel that withholds -everything; `bookTabs` likewise opens the Recaps/Characters tabs on `hasPreviousBooks` -alone, and counts Chapters as present while `useChapters` is still in flight (so the -row can't start on Bookmarks - firing its GET - and then snap over). -**Reading-order families** (rules in `src/lib/series-orderings.ts`, pick in -`src/stores/series-orderings.ts`): the server collapses a primary series and its -`ordering_of` variants into ONE rail (`BookMetaSeries` = main view + additive -`orderings[]`; an older server sends neither, so every series is its own family). The -reader's pick is remembered PER FAMILY, device-wide (not a scoped key). `seriesRails` -shows the picked order and `previousWorks` reads those same rails, so "previous books" -always follows the pick; a view without the current book contributes nothing (the -Narnia regression test). The collapse is server-side too so an unaware client still -gets one rail per family rather than a duplicate per reading order. Stores persisted as -one JSON document share `persistedDocument` (`@/lib/storage`): a change made before -hydration wins and never clobbers the stored rest. -Characters/recaps are the CC BY-SA layer under `work.characters`/`work.recaps` -(`BookMetaCharacter`/`BookMetaRecap`/`BookMetaPosition` in `types.ts`); pure helpers -(`roleLabelKey`/`revealFromStart`/`recapDescriptor`/`sortRecaps`/`seriesRails`/ -`previousWorks`) are unit-tested. **Community covers** (rail entries, previous-book rows) -never load from their own host on web: a server with `meta_covers` serves them -(`client.communityCoverUrl(lib, path, cover_url, {size})` -> `GET /libraries/{id}/meta/cover`, -`?token=` like every media URL; the server only serves a `cover_url` that book's envelope -hands out, so pass the same `libraryId`/`path` as `useBookMeta`). `BookTabPanel` builds the -resolver with `useCommunityCover(cid, lib, path)` and passes it to the tabs as `coverFor` -(rails and previous rows both at 320, so a cover is fetched and cached once); the rules are `communityCoverSource` (`community-cover.ts`): -proxied when the flag is on, else the direct `cover_url` on native and the placeholder on -web (the player's CSP takes images only from the server), nothing while `/server` is -unknown (an unreachable `/server` counts as no flag). A tab without `coverFor` shows placeholders. Progressive enhancement - **capability-gated** on -server `metadata` (`!!server.capabilities.metadata`, absent on older servers) and nothing renders while -loading/error/`matched:false`. `client.bookMeta` hits `/libraries/{id}/meta`; -`useBookMeta` keys on `qk.bookMeta(cid, lib, path)` (1h `staleTime`, `retry:false` so -a 502 from a down meta service doesn't spin). Strings under `book.meta.*` (plus the -two `book.tabs.*` keys) in all 6 locales. The wire envelope (`BookMeta` discriminated -union in `types.ts`) is hand-mirrored from the server. -**Generated characters and recaps (Bridge Phase 1a).** A book whose `.audiosilo/` holds -characters, recaps or (Phase 1c) a description a Bridge generated on its server carries -`Book.generated: true` (list, search, item, recent and the next book's `book`), and `/meta` -serves them: for an UNMATCHED book -as `{ matched: false, work }` (`work.id`, `authors` and `language` empty, `title` the book's -own), for a matched one in the dimension the community lacks (community content wins per -dimension); `work.generated` (`BookMetaGenerated {characters?, recaps?, description?, -generated_at?}`) says which. **A generated description (Bridge Phase 1c)** rides in its own -additive field, `work.generated_description` (`{ text }`), never in `community_description` (the -shipped 2.0 app shows that one first, as CC BY-SA): the server fills it only when the community -work has no `community_description`, and the player orders it below the book's own description -and the work's core one in About (above). It is spoiler-free by contract, so `spoilers=hide` -keeps it. Every `sidecars.generate` run writes one as it saves (its progress step -`describing`, its result `description` / `description_error?`), the player's Generate -included; the player has no description-only action (the console's "Write a description" -queues `sidecars.describe`, whose finish `latestDone` also counts, so the book's `/meta` is -read again). **The `/meta` gate is ONE predicate, `metaEnabledFor(metadata, book)` -(`meta-gating.ts`)**: the server's `metadata` flag and an ASIN, an ISBN **or `generated`**; -every surface that reads `/meta` goes through it (the book page, the companion and reveal -toast, Home's Now card and Previously on, the end credits, the character search, the offline -copy). Two readers of the envelope, and each field is read through one of them, never both: -`matchedMeta` (the matched half only: the series rails, the AudioSilo Meta links, the end -credits and About's recording facts need a real community match) and `bookMetaWork` (the -matched work or an unmatched book's generated one: About's text and every work-level field, -the Characters and Recaps tabs and their counts, the companion's Who's who and Story so far, -Home's Now card and Previously on, the search's characters). `metaAnswered(enabled, book, meta)` is "the answer is in" for the Generate offer -(a failed lookup is no answer). An unmatched answer WITH a `work` is fresh for only -`META_GENERATED_ONLY_STALE_MS` (2 min, `metaStaleTime`), because the server answers the same -way when the community lookup failed for a book it would otherwise match. Generated content -wears the quiet "Generated on this server" chip (`GeneratedChip`, `bridge.generate.mark`: -`GeneratedMark` puts it at the head of a Characters or Recaps panel, by `work.generated`'s -dimension, and the About card under its eyebrow for a generated description) and never the -community's CC BY-SA credit: -**`communityCredit(work, dimension)`** is the work's `attribution` unless that dimension was -generated, and every `Attribution` beside characters or recaps reads through it. - -**Player-redesign data API (Phase 1a, the wire).** `src/api` mirrors the -server's additive redesign endpoints, each gated on its own capability flag (absent on -older servers = false). `useCapability(flag, connectionId?)` reads a flag as `undefined` -(not known yet) / `true` / `false`, and the gated hooks give a server without the flag -no query function at all (`skipToken`), so it is never asked, not even by a manual -`refetch`: `client.authors`/`narrators` (normalised to `PeopleList {people, unknown}`) -and `seriesList` with `useAuthors`/`useNarrators`/`useSeriesList` (`browse_people`), and -`client.listBooks` with `useLibraryBooks` (a `narrator` filter waits for -`browse_people`; a `series` filter waits for `/server`, then asks with `memberships=1` on -`series_memberships`, as `useSeriesList` does: a book in several series, `Book.series_list`, -is matched and counted in each; with the `batch` option (series cards only) on `series_books` -a plain series list (`isPlainSeriesQuery`: `series` with `memberships`, no other key) fetches its -first page through `client.seriesBooksPage`, a per-(library, page size) `createBatchLoader` -(`src/lib/batch-loader.ts`) that collects every series asked for within 10 ms into one -`GET /libraries/{id}/series/books?name=...` per 50 names (fewer when long names would pass 4000 query bytes), under the same `qk.libraryBooks` key, -later pages staying on `/books`; that branch never reads react-query's `signal`, so a card -scrolled off still caches its page from the shared request, which nobody cancels, until a -later page of that query has read it: react-query then cancels the refetch, keeping the old -pages); `client.nextBook` + `useNextBook` (`next_book`, server-resolved: community when it -places its next work, else series -> folder -> none, `source` naming who produced -`next`; a `work` without `local` is the rail's next work left unplaced; a community -`next` can be in another library); -`coverUrl(lib, path, {size, version})` (`cover_sizes`; `size` bounds the longer side; -`version` is the book's `cover_version`, sent as `v=` purely as a cache buster); -`bookMeta(..., {includePrevious, hideSpoilers})` and `useBookMeta(..., opts)` -(`meta_bundle`; each variant has its own `qk.bookMeta` key; `nextBook` and an -`includePrevious` request get the server's 30 s budget, not 15 s). `Book` gains -`published`, `description` (item only), `cover_color`, `cover_version`; `BookMetaWork` -gains `community_description` + `attribution` (render the server's text beside CC BY-SA -content), rail entries `local`, the recording `chapter_count`. Phase 2's browse screens -(below) consume the browse lists, `next_book`, the thumbnails and the cover colours; the book -screen and the player still gate spoilers and pick the next book on the device. - -**Player-redesign user state (Phase 1b, the wire; Phase 2 uses `queue`, `collections`, -`progress_edit` and `user_stats`).** Same pattern, six more -flags: `queue` (`useQueue` + set/add/remove), `collections` (`useCollections`, -`useCollection(id)`, `useShareTargets(enabled)` - pass false for demo accounts, which -get a 403 - and the collection/items/shares mutations; read-only sharing, a viewer's -`shared_with` is absent), `ratings` (`useRating`, `useMyRatings`, set/delete; a PUT -replaces the whole rating, so an omitted `note` clears it; PUT resolves a part path to -its book but GET/DELETE are exact, so key rating UI on the book's path; list adds resolve -too and list removes are exact, so remove with the entry's own path), `progress_edit` -(`useEditProgress`: PATCH dates / mark unfinished; it does NOT touch the local progress -mirror or offline queue, decision 7; to mark unfinished a book finished at its end, send -a `position` too, or the player resumes it at the end and it finishes again), `user_stats` -(`useMyStats(range)`, `useMyListening(range)`, `useListeningGoal` + set/clear; server -time) and `my_devices` (`useMyDevices`, `useRevokeMyDevice`: NOT for the `current: true` -row - revoking it kills the token before `teardownBeforeTokenRevoke` can save the final -position and the queued progress, so that row signs out through `useSignOut`). A -whole-list PUT (`useSetQueue`, `useSetCollectionItems`) deletes the caller's hidden -(out-of-access) rows too; move one book with an add and a `position` (an index in the -visible order) instead. **Mutations check their flag at call time** and reject with -`CapabilityError` without sending anything when the flag is false or `/server` hasn't -answered yet (no request, so no 401 and no reconnect flag; never hand it to `noteError`, -which reads any non-`ApiError` as an unreachable server). Each mutation stores its answer -through `storeAnswer` (cancels a read of the same key in flight, which would land over -it, and reads again), is keyed by connection (`mutationKey`, so a pending write keeps -its own connection) and runs one at a time per capability and connection (`scope`). -Gating tests live in `hooks-capability.test.tsx`; each was checked to fail with its gate -removed. - -**Player-redesign annotations (Phase 4, the wire).** One more flag, `annotations`: bookmark -labels, edits, and the across-books lists. A bookmark's `label` is a machine key, never display -text (`''` for none; the player's keys and `isBookmarkLabel` are `src/api/bookmark-labels.ts`: -the five `PICKABLE_BOOKMARK_LABELS` and `FELL_ASLEEP_LABEL`; a newer client may store others, -so name a label only once the guard knows it). `client.updateBookmark` / `updateNote` PATCH -(`BookmarkPatch {note?, label?}`, `''` clears the label; `NotePatch {body?, position?}`) and send -only the patch's own fields; `myBookmarks` / `myNotes` / `allHistory` return one `Page {items, -next_cursor?}` (`allHistory` works on every server: an older one ignores the cursor and answers -one page without each row's `book`). The across-books lists are infinite-query option factories -in `hooks.ts`, `myHistoryQuery` / `myBookmarksQuery` / `myNotesQuery` (one `PAGE_SIZE` and stale -time; the two gated ones take `supported` and get `skipToken` without it), read with -`keepFirstPage` on release so a list read 20 pages deep doesn't refetch all 20 next time. -`useUpdateBookmark` / `useUpdateNote` are capability mutations (`CapabilityError`, nothing sent, -as above). **`label` is sent ONLY to a server known to have `annotations`**, because the server's -decoder refuses unknown fields (a 400, and the bookmark is lost): the one owner of that gate is -`postBookmark` in `hooks.ts` (`useAddBookmark` on `useCapability`, the framework-free -`addBookmark` on `cachedCapability`; not known yet counts as no, and the bookmark is still made -without its label). Never call `client.addBookmark` with a label directly. The server answers -fixed-width UTC timestamps with milliseconds (so they compare as strings) and `[]` for an empty -per-book list (the client still reads an older server's `null` as none). - -**Spoiler gating by listening progress** (`src/components/library/meta-gating.ts`, -all pure + tested). The listener's position is a 1-based chapter NUMBER derived -from **ONE whole-book POSITION** (`useBookPlace`'s `listening`, also Search's and the series -page's) - the player's live position when this book is loaded (never below the saved one; -and only once its engine load has landed, `selectPlacedBookKey`: until then the snapshot is -still the PREVIOUS book's place, the store's `loadingBook`), -else `useBookProgress` (`qk.progress(cid, lib, path)`) - walked through `chapterNumberAt` against the -screen's *corrected*, memoized chapter offsets (`chapterStarts`, recomputed from the -cumulative file durations, not the server's `book_offset`); no position → 0. **Never -the player's chapter identity**: a chapterless single-file book gets *synthetic* -30-minute chapters (`synthesizeChapters`) whose indexes are wall-clock slices, so -reading them as logical chapter numbers revealed the whole cast an hour in. The -consequence is that a chapterless book gates to 0 whether playing or not (accepted - -"Show anyway" is the escape hatch). The live position is sampled in coarse buckets -(`LIVE_POSITION_BUCKET_S`, exported by `meta-gating.ts` for the book page and Search's -characters; Previously on reads the saved place, never the live one) so the screen re-renders at chapter-ish granularity -rather than per tick; rounding DOWN can only delay a reveal, never reveal early. The player's -companion needs no bucket: `useListeningChapter` selects the chapter NUMBER on the exact live -place (`selectLivePosition`), so it re-renders only when the chapter changes and turns over at -the boundary itself. -That progress query rides the SAME gate as the metadata itself -(the screen passes `bookMetaEnabled`, so there's no wasted GET where nothing is -gated). Its `queryFn` falls back to the durable local mirror (`mirroredProgress` in -`progress-sync.ts`) **only for a non-`ApiError` failure** (offline/unreachable, and -it `noteError`s reachability like `loadInitialProgress` does) - an `ApiError` means -the server ANSWERED and is rethrown, because resolving a 401 as a query *success* -on `qk.progress` would have `provider.tsx`'s `QueryCache.onSuccess` immediately -`clearNeedsReconnect()` the banner that same request just raised. A character shows when `reveal.chapter <= max(current, 1)` (an -unstarted book still shows the from-the-start cast); a recap when -`through.chapter === 0` or `through.chapter < current` (a chapter is only "done" -once you're past it); `finished` reveals everything. Meta chapter numbers are the -*work's* logical chapters and needn't match the local edition - the comparison is -deliberately approximate. Not-yet-reached entries are **not rendered**; each tab -footers a quiet "N hidden to avoid spoilers" + **Show anyway** toggle that renders -them marked with a `Spoiler` chip. The reveal is ONE piece of state held by the -**screen** and passed to both tabs (`showSpoilers`/`onToggleSpoilers`) - revealing in -Characters and switching to Recaps must not re-hide what the reader just chose to -see. Descriptions/recap text stay behind their own per-card accordions either way. - -**Media auth rides in the URL on every platform** (`src/api/client.ts` -`mediaTokenQuery`): cover/stream URLs embed `?token=` everywhere (``/ -`