• v4.5.0-rc5 eab9cd561d

    v4.5.0-rc5
    Some checks failed
    Release / Create Forgejo release (push) Successful in 28s
    CI / Docker image build (push) Successful in 38s
    CI / Does this change touch the recording path? (push) Successful in 41s
    CI / Backend lint (ruff) (push) Successful in 51s
    CI / Bot/backend version sync (push) Successful in 1m9s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m18s
    CI / Feedback site tests (push) Successful in 1m49s
    CI / Bot tests and audit (push) Successful in 2m13s
    Release / Build and push versioned images (push) Failing after 3m33s
    CI / Frontend (shard 2/2) (push) Successful in 10m25s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 10m30s
    CI / End-to-end journeys (phone) (push) Has been cancelled
    CI / Frontend (shard 1/2) (push) Has been cancelled
    CI / End-to-end journeys (keyboard) (push) Has been cancelled
    CI / End-to-end journeys (desktop) (push) Has been cancelled
    CI / Backend migration, tests, and audit (push) Has been cancelled
    Pre-release

    claude-bot released this 2026-10-05 01:40:32 +00:00 | 765 commits to main since this release

    Prerelease of 4.5.0 for testing on the dev server; not for production.

    Added

    • [webapp] The bell reaches older notifications with "Show older". The
      panel showed only the newest twenty; a button at the foot of the list now
      adds the next twenty, moves focus to the first of them, and goes away when
      nothing older is left (#1000).
    • [webapp] Setup's AI step asks for the model's context window. It is
      the same field as Admin → AI models ("How much it can read at once"), saved
      through the same setting, so a server set up through the wizard no longer
      falls back to 32,768 tokens without anyone having been asked (#1001).
    • [webapp] In a campaign that levels by milestones, the GM can hand a level
      to the player (#1007).
      Until now only the GM could level a character
      there. Beside Level up in the sheet's header a GM now has "Let Priya level
      up": it marks the character's next level as waiting, the player is told in
      the bell, and their sheet says "Level 4 is waiting" with Level up and Not
      yet, exactly as it does in an XP campaign when the XP is there. The GM's
      header then reads "Level 4 is waiting for Priya", with Take it back.
      Reaching the level clears the mark, whether the player does it or the GM
      levels the character as before. It is one level at a time, a lock on Level
      still stops the player, and the server refuses a player's level-up in a
      milestone campaign unless the mark is set. Marking a level makes no version
      of the sheet, is not part of a sheet's export, and nothing about it goes to
      email or Discord. An XP campaign is unchanged: its levels wait on the XP.
      The demo seed's Hollow & Ash now levels by milestones, with Tam Ridley's
      level 4 waiting. Migration c1007a1b2c3d adds three nullable columns to
      character_sheets and the level_waiting notification kind.
    • [webapp] A result in the prep sheet's Rules & bestiary and Wiki drawers
      can be read before it is inserted.
      Its title opens the entry in the
      drawer, in the results' place — Table mode's own Find preview: a wiki page
      as the GM reads it, a creature as its block, a rule as its text — with
      Insert still there and "Back to results". Escape goes back too, and focus
      returns to the result it came from. It works the same in the phone's Tools
      sheet (IS-472).
    • [webapp] The campaign search has a page of results, and a rule you find opens
      where you are (#1033).
      The results under the search box now show three of each
      kind — sessions, people, wiki, rules — then "All 14 in Wiki" and "See all 23
      results", which lead to a page listing every match, grouped the same way, with a
      row of links to narrow it to one kind. Pressing Enter in the box goes there too.
      Until now a kind stopped at ten with no way to the rest, and "Rules · 10" could be
      hiding more. The page has no search box of its own: the one in the bar is still
      the only one. A GM gets the same "Look inside transcripts" switch on the page, and
      a player still never sees a GM's material there. Choosing a rule used to do
      nothing; it now opens the rule in place — its text, or a creature's block — and
      closing it puts you back on the result you chose. The wiki's own search box works
      the same way. A kind with more than 200 matches lists the first 200 and says how
      many there are.
    • [repo] The dev demo seed has lane 8's wiki and lane 10's bot tests. Both
      parties and who has been in them, reputation, downtime, custody and an
      unidentified ring; the 27 quests as pages with givers, rewards and
      objectives; mid-text :::gm, :::for and :::party blocks and pages for
      every audience; six handouts with their files and reveals; private page
      pictures; three atlases with maps drawn and tiled at seed time, markers of
      every kind and the party's trail; players' suggestions (the Tuesday Tide is
      now "Players suggest, I decide"); the link index and Recent changes; and six
      finished bot tests with what they spent. Files go under
      media/private/, only after the commit. The map tiling task's cutting is
      now a pure cut_tiles the seed calls directly (#734, #736).
    • [webapp] An article's read view, History, More, Recent changes and Deleted
      articles (lane 8 PR P2, #734; WIKI-3, WIKI-5, WIKI-6, WIKI-17, WIKI-18).
      • An article (/wiki/:entryId) renders the body through WikiText
        (PR P1) with the server's own ?as_user= for a GM's "See it as…"; a page
        outside the reader's audience says only "This page isn't shared with you"
        (W3). Relationships, appearances, mentioned-in, references and a timeline
        follow, with a margin for the picture, facts, a stat block, and (an item
        page) who has had it.
      • History (/wiki/:entryId/history) restores through the server's own
        POST …/versions/{id}/restore, replacing the old article page's plain
        PATCH, which never called it.
      • The ⋯ More menu: adding to a session's shelf, the three LLM drafts,
        Convert the stats, merging a page into this one, and deleting with an
        undo — replacing the last alert()-shaped delete in the product.
      • Recent changes (/wiki/recent) and Deleted articles
        (/wiki/deleted, GM only) round out the wiki's four remaining P1/P2
        surfaces; the front page's "All recent changes" now leads to the former.
      • Lane 7's Read · Talk · History tab bar is kept; History is now a plain
        link to its own page rather than an inline panel.
      • The read half of WikiArticle.jsx is deleted; its edit half stays at
        /wiki/:entryId/edit until PR 3 rebuilds the editor.
      • Frontend only: no backend or bot change.
    • [webapp] A draft, Suggested wiki updates and Wiki health on the new wiki
      (lane 8 page P4, #734; WIKI-9, WIKI-10, WIKI-16).
      • A draft shows the page beside the draft with each stage in plain
        words, typed stats, Send it back and Throw it away. A player's
        suggestion opens here too, compared line by line with the page as she
        reads it
        , never the GM's text.
      • Suggested wiki updates shows where each session's suggestions stand,
        then the pages and relationships waiting, one action at a time.
      • Wiki health lists wanted pages, unlinked mentions, orphans, possible
        duplicates and stale pages, each with its fix.
      • The Contents gains Wiki health for a GM, and Suggested wiki updates shows
        its count.
    • [webapp] The wiki's editor, a new page and Settings → Wiki, rebuilt on lane 8's
      text (WIKI-7, WIKI-8, CAMP-14, D72, #734).
      Write on the left and the page on the
      right (Write | Preview on a phone), with a toolbar, [[ autocomplete, and ◐, which
      opens the audience picker and writes a :::gm, :::party or :::for block for
      whoever was chosen. One Save sends the text, the fields, the facts and the timeline
      together; the text is kept on the device as it is typed, per person. A save that
      started from an older version says who saved since and offers merge by hand, use
      theirs or keep mine. One heading's section can be edited on its own. Pasting a
      picture uploads it to the page's private files; pasting from a document brings its
      headings, bold and lists across as Markdown. A GM sets who sees the whole page
      (D166). Relationships, "Who plays them", the picture, the stat block and links you
      could add moved in from the old edit page. A player's save is only ever her own
      copy of the page, edited:
      the blocks she can't see are never in the editor, and
      the server puts them back where they were. A new page starts from its kind's
      template; where players suggest, it goes to the GM's queue. A name two pages
      share opens a page listing both (D72), and "See it as…" can now pick a character.
      Settings → Wiki sets who may edit, what each kind of page starts with, and the
      reputation levels. WikiArticle.jsx, WikiNewEntry.jsx and WikiSearch.jsx are
      gone.
    • [webapp] History per reader, Recent changes, Wiki health, merging two pages,
      personal notes, and canon in a block
      (lane 8 wave 2 PR 2b, #734; IS-051,
      IS-055 to IS-060, IS-105, IS-147, IS-148, D1, D69, D197, D219). Server only.
      • History (GET /lore/{id}/versions) opens to anyone who may edit the page,
        not only the GM and the primary owner, and drops a version that changed
        nothing this reader could see. Restoring an earlier version widens the same
        way: a character's own player may restore her own page, safely — her restore
        is merged against the live body, never overwriting a block she can't see.
      • Recent changes (GET /wiki/recent) is one feed — made, saved, deleted,
        put back, renamed, merged — filtered so nobody learns that something they
        cannot see has changed. Migration 8c3d4e5f6a7b adds wiki_events.
      • Wiki health (GET /wiki/health, GM-only): wanted links, unlinked
        mentions, possible duplicates, orphans and stale pages.
      • Merging two pages (IS-060): "Merge or Keep both" starts the existing
        LLM-merge draft (POST /lore/{id}/merge); approving it now also moves the
        other page's aliases, links, relationships, references, appearances, owners,
        personal notes and Discussions threads onto the survivor, and trashes it.
      • Personal notes (IS-105, lore_personal_notes): a player's own notes on a
        page, read by the writer and every GM (D197), and never AI context.
      • Canon answers wrap in a block (IS-148, D219) instead of being refused: a
        party or private answer is written as :::party or :::for, naming a player
        as well as a character, pre-filled from the thread's or post's own audience.
        A new page made this way starts at that same audience, so its title doesn't
        leak to someone who can't read the one block on it. Every canon write adds a
        decided in footnote automatically, and a backfill adds one to every post
        already made canon.
      • The NPC tracker folds into the wiki (D1): the migration makes a People
        page for any tracked NPC with no page of that name, carrying its session
        appearances. The old tracker stays working for one more release.
      • The 60 s wait before wiki proposals land is now usually instant: each
        match pass fires consolidation itself once every category is in, rather than
        waiting for the 60 s backstop — which still runs, and now waits longer for a
        slow local model before giving up.
    • [webapp] The wiki's front page and a group, rebuilt (lane 8 PR P1, #734;
      WIKI-1, WIKI-2, D60).
      • The front page (/wiki) leads with a search box scoped to the wiki and
        the rules, then "Recently revealed or updated" (the top of Recent changes,
        D60), "People you've met" (appearances the reader may see, most recent
        session first), and Browse by group. A GM's copy also carries "Waiting for
        you" (the top of Suggested wiki updates) and Wiki health's counts.
      • A group (/wiki/g/:group) lists a group's pages A to Z, narrowed by
        type with a chip when it holds more than one — People, Places, Groups and
        Things are now Contents entries; Events and Lore stay reachable only from
        Browse by group. WikiFront.jsx and its test are deleted.
      • WikiText (src/wiki/): the parser and renderer for the wiki's block
        syntax (:::gm, :::for, :::party, footnotes, [[links]]) that PR P2
        wires into the article page — built here, tested, and not yet on any page.
      • Frontend only: no backend or bot change.
    • [webapp] Handouts, private files, and rules' "Make it ours" (lane 8 wave 2
      PR 5, #734, §3.8, §3.10; W7, W8). Server and the bot's own side; the pages
      follow.
      • A handout is a document wiki page with a file (lore_attachments): PDFs
        are validated by magic bytes with their page count read, images re-encoded
        through the existing picture pipeline. Migration 8a7b8c9d0e1f, on top of
        lane 8's quest pages.
      • Private files stay private (W7). A file narrower than the table no
        longer lives at a guessable, cached-forever address: GET /api/campaigns/{id}/files/{attachment_id} decides on every request whether
        the caller may have it — the page's own audience, or a live reveal's — and
        answers with X-Accel-Redirect to a new internal nginx location that
        refuses a direct request.
      • Reveals carry an audience, a page and an optional session (W8).
        shelf_reveals gains lore_entry_id, piece_label (for a torn letter
        shown in pieces), and lane 7's audience columns; session_id is now
        nullable, so a handout can be shown between sessions. A handout's own
        audience is the union of its live reveals.
      • handout_shown, a bell kind ([wiki] category) and a new, additive bot
        event: the bot fetches the file with GET /api/bot/files/{id} and posts it
        to the campaign's Discord channel, or DMs it to each named person instead of
        posting to the channel. BOT_CONTRACT_VERSION stays 2.
      • "Make it ours" (POST /api/reference/{id}/make-ours): a rules-pack
        entry becomes a GM-only campaign page that keeps a link back
        (source_reference_entry_id), so the rules chapter needs no endpoint of its
        own.
    • [webapp] Handouts and Rules & bestiary, two new chapters of the wiki
      (lane 8 page P6, #734; WIKI-14, WIKI-15).
      • Handouts (/wiki/handouts): a GM's handouts by session with who each
        went to, the ones not shown yet first as GM prep, and a reveal outside
        play as "Between sessions". Show it picks who (the audience picker,
        characters first), posts to Discord unless switched off (D61), says
        when, and names the piece of a letter shown in parts; Take it back
        uses the server's own revoke route. A player reads what was shown to
        her, by session, "Just you" on hers alone, and may add one (just her and
        the GMs) where the campaign lets her write pages.
      • A handout's own page shows its file: an image, a PDF previewed in the
        browser's viewer with "Download the PDF" beside it (download only on a
        phone), or plain text. Every file is read through the authorised files
        route, never a /media/ address.
      • Rules & bestiary (/wiki/rules): search the campaign's loaded rules,
        each hit tagged as rules; a rule opens with its source, a creature as a
        stat block, and a GM can Make it ours. The licence line for each
        loaded pack sits at the foot; with no pack loaded the page says where a
        GM loads one.
      • Server (#916): a live reveal that covers a player opens that
        handout's page to her to read (rendered for her, so GM blocks never
        reach her), and nothing else — not the wiki's lists, search or any
        write; taking it back closes it again. GET /api/campaigns/{cid}/handouts
        lists the handouts shown to the reader, grouped by session. A player's
        own handout page is hers and the GMs'. A page made with "Make it ours"
        says which rules entry it came from.
      • Both chapters join the Contents' wiki group.
    • [webapp] The wiki's links, references and names, one Save, and who may edit
      (lane 8 wave 1 PR 2a, #734, #779; IS-033, IS-036, IS-037, IS-042, IS-046, IS-048,
      IS-049, IS-053, IS-054, IS-085, IS-088, IS-399; D72). Server only; the pages follow.
      • Links are records. Every [[link]] and footnote in a page's text is written
        to lore_links / lore_references on each save (and backfilled by migration
        8b2c3d4e5f6a), each with the block it sits in, read per reader: "Mentioned in"
        never names a page, or a link inside a block, the reader can't see. An article
        now says what each name it links means to its reader (a page, the
        disambiguation page, plain words for a page they can't see, or wanted), and
        lists the footnotes they may open (https, a thread or post, a moment in a
        session, a rule), numbered for them. The server never fetches a URL.
      • Names (D72). A name another page has is a 409 that says what to call the
        two ("Kettlebridge (town)", "Kettlebridge (boat)"); the bare name then opens a
        disambiguation page (GET /lore/by-name/{name}), unless a GM points it at one
        page. A renamed page keeps its old name as another name, and can rewrite the
        links that use it; a renamed character keeps their :::for blocks.
      • One Save. A save sends the version_id it started from; if someone saved
        since, it is a 409 carrying the page as it now is, and nothing is saved. A
        phone can save one heading's section (GET /lore/{id}/sections/{n}).
      • Who may edit (Settings → Wiki): only the GMs, players their own pages (the
        default, and today's behaviour — now including pages they made, which they may
        start), players anything, or players suggest. A suggestion — a new page, an
        edit, a relationship — waits in the GMs' queue and is merged as its writer, so
        nothing they couldn't see is touched.
      • Relationships have an audience of their own and may start and end in a
        session. Templates per kind and the reputation levels (Pathfinder's
        seven to start) are the campaign's to change; exports carry all of it.
    • [webapp] The map, a new chapter of the wiki (lane 8 page P7, #734; WIKI-11).
      • The map (/wiki/map) draws each map over its private tiles, with the
        atlas's levels as crumbs, filters by kind, the party's trail (a stop per
        place, its sessions and days on the road) and "The party is here". A
        marker opens its own map, travels a link, or opens its page; a GM reveals
        a GM-only marker to the players, with an undo.
      • A player adds her own markers, for herself and the GMs, the party, or the
        table (IS-076). A GM adds a map, as its own atlas or opened from a marker,
        and where the party ended a session.
      • Every marker is also listed under the map, and the map pans with the arrow
        keys and zooms with its buttons.
      • The wiki's front page says where the party is (IS-124), and an article
        says where its page is on the map (IS-102, IS-087).
    • [webapp] The party, what it carries, where it stands and what it did between
      sessions
      (lane 8 wave 2 PR 3, #734; IS-107 to IS-122, IS-088, IS-116, IS-206,
      IS-218 to IS-221; D168, D198, D12). Server only; the party page follows.
      • Parties. Every campaign has a party, and may have more (/parties), with
        who has been in each over time: joined and left at a session, with why
        (left, retired, died, captured, swapped), and companions, hirelings, mounts
        and vehicles as pages. Swapping characters keeps the record in step. Migration
        8d4e5f6a7b8c makes each campaign's party and places every character at the
        first session they were played in.
      • "The party" names one party (D168). Threads, posts, pages, relationships,
        facts and blocks can be for a party by name (:::party The Tuesday Watch);
        everything already for "the party" is the first party, so with one party
        nothing changes, and a renamed party keeps its blocks.
      • Items belong to characters (D198): a line is held by a character, the
        party's pool, an NPC or a place, can link its own page, and records who has
        had it. An unidentified item is named as the party knows it everywhere a
        player reads it, until a GM identifies it. Existing lines, XP and gold went
        to the character each player was playing in that session.
      • Removing a line, an award or a movement is undone (D12), not confirmed.
      • Where the party stands with each organisation, changed at a session, on
        the campaign's levels; and downtime, logged by the player and approved by
        the GM, with Pathfinder's activities to pick from.
    • [webapp] The party and the quest log, as wiki pages (lane 8 PR P5, #734;
      WIKI-12, WIKI-13).
      • The party (/wiki/party): each party's home base, fund, XP pool,
        members with pronouns and companions, and active quests; What we carry
        (notable items, then the ledger, filtered by holder; add, give, change);
        XP and gold side by side with each character's total; downtime with
        Pathfinder's activities to pick from and a GM's Approve; Who has been in
        it, current then former, with moving a character to another party and
        why; and Where we stand in the margin. Removing a line is undone, never
        confirmed (D12). A player adds to the ledger only where the campaign
        allows it and changes only her own lines (D6); an unplaced row counts in
        the main party (D236).
      • Quests (/wiki/quests): Rumours waiting for the GM first, then each
        status, with its colour, a failed quest struck through, when it started,
        and "The quest log did not load" rather than an empty list. A new quest is
        a new page, opened in its editor.
      • The Contents gains Quests and The party under The wiki; "Quests & loot"
        goes. /campaigns/:id/manage now redirects to the party page, and the
        old campaign page is deleted with LootLedger, QuestLog and the
        components only it used. An ownership offer is answered with Accept and
        Decline on This week's banner itself.
      • Frontend only: no backend or bot change.
    • [webapp] Quests are wiki pages now (lane 8 wave 2 PR 4, #734, §3.7; D196).
      Server only; the quest page follows.
      • Every existing plot_threads row becomes a page of kind quest, keeping its
        title, status, sessions and reward note: the GM's private notes move into a
        :::gm block at the top of the body, and player_visible becomes the page's
        own audience — table when it was visible, gm when it wasn't. Migrations
        8e5f6a7b8c9d and 8f6a7b8c9d0e.
      • A rumour may be about no quest yet (D196/W10). Confirming one offers
        "Make it a quest," which creates the page and posts it under whoever started
        the rumour.
      • The bot's /quests, the pre-session reminder's open threads, Prep's context,
        the shelf's open loops and the Quests drawer's quick add all read quest pages
        now; GET/POST/PATCH/DELETE .../planning/threads and GET .../quests are
        removed in favour of GET/POST .../lore/quests, filtered per reader like any
        other page. The bot's response shape is unchanged — no contract bump.
    • [webapp] "Test the bot", a GM's way to check the recording pipeline
      before the evening without a real session (lane 10, PR 7a, #736, D124).

      Settings → Recording, retention & AI gains a launch (30 seconds, stoppable
      early, keep-or-delete after), the running stages, the result — per-stage
      timing, the transcript, the summary, and a plain sentence naming who can
      fix a failure — and the kept test runs, with a confirmed delete (D12). The
      session page offers a link to it while Start is refused for being early
      (D149, D222).
    • [webapp] Two public pages for an email's links, the notification grid's
      Discord DM column, and the installed app's update and offline notices
      (lane 10 PR 7b, #736).
      /email/stop and /email/answer work signed out
      (D154, D155): stopping a kind of email needs one click and never reveals
      more than the token names, and answering "Are you in?" records the RSVP
      through the same writer the session page uses. The grid's DM column shows
      "Discord can't reach you" when the bot's last message failed, without
      hiding the switch (HOME-6), and the star-and-wish ask is its own indented
      line under Sessions (D232). The installed app now offers a quiet "Quest
      Board has been updated" notice instead of reloading silently (D13), says
      so while offline, and no longer locks to portrait orientation.
    • [webapp] Email grew a one-click stop, answering "Are you in?" without signing in,
      and a proper recap timing (D154, D155, D162, O7, O8, O15, #736).
      Every outbound
      email now goes through one template, with List-Unsubscribe/List-Unsubscribe-Post
      headers (RFC 8058) and a footer link to /email/stop (POST /api/email/stop, with an
      undo). Session-confirmed and reminder emails carry "I'm in / Maybe / I can't" buttons
      that record an RSVP through /email/answer, good until the session starts. Calendar
      invites carry the session number in SUMMARY and a URL: back to it, no longer ask
      for a reply (RSVP=FALSE — nothing read the old one), and put a cancellation's reason
      at the top of DESCRIPTION. The post-session recap now sends when the GM approves the
      summary, not the moment it is first written, and never twice. A developer-only
      GET /api/dev/email-preview/{kind} (behind ENVIRONMENT=development) renders every
      template from invented fixture data, for reviewing them without real mail.
    • [webapp] The star-and-wish ask is its own row under Sessions in notification
      settings (D232, O29, #736).
      It used to share the "Sessions" cell; existing
      preferences carry over as the new row's starting value (migration 10d4e5f6a7b8).
    • [repo] Journeys J2 and J4b cover lane 10 (#736). J2 asserts what reaches
      the bot — a vote's number and closing time, the confirmation's number, a ❔ from
      Discord showing on the session page, the cancel reason, and a Discord DM with the
      bell's line — and what reaches Mailpit: the confirmation to the player's own
      address, the time in her zone, the unsubscribe headers, answering from the email,
      and Stop → Undo → Stop. The new J4b tests the bot from Settings → Recording and
      checks nothing else happened, and that a recording whose announcement lands
      nowhere still records and says so (D171). The fake bot hands a test's
      generated near-silent WAVs over as the real bot does. J2's bell check no longer
      depends on how many rows earlier runs added (#887).
    • [repo] The dev demo seed has character sheets and stat blocks (lane 11,
      wave A).
      Every sheet is made through the sheet services the routes call,
      from the mini character packs loaded as campaign packs. The Amberwake Accord
      (and its GM copy): Ossian Vale exactly as drawn, Wren's Spells page and focus
      pool, Bastian, Sister Quill, the owner's Hollis (Level & XP locked by the
      GM, "just you and the GM" campaign notes, and a row from the disabled Salt
      Crown pack that keeps its name and numbers), retired Aldous and Hesper's old
      sheet; conditions and hurt HP on two; every old stat block converted, Odile
      Marsh filled out to the drawing, one block partly shown to the party, one
      wholly the GMs', one projected from Hesper's sheet; a rules creature on
      Session 24's shelf with its own hit points; histories of 3 to 6 versions.
      And a new thin 5e campaign, Hollow & Ash, where the owner plays Tam
      Ridley (Fighter 3, HP 31 from rolls of 8 and 7) with four more characters
      and two blocks.
    • [webapp] Sheets reach Admin, the rules, Prep and Members & characters (#737).
      Admin → Content packs lists every sheet definition on the instance — built
      in, from a pack, or made from a pack's older stat blocks — with its version,
      whether it is active or deprecated, how many sheets are on it and how its
      fixtures did; a pack's row names its own definitions and which sheets are
      still on an older version. Loading a pack that carries definitions shows the
      validator's report for each: activated or not, every error (with a "did you
      mean" where there is one), every warning, and which fixtures failed and why.
      One error stops that definition only; the pack's content still goes in, and
      a campaign's own pack says its definitions are not used. An open rules
      result has Add to a sheet ▾, listing the characters and blocks the reader
      may edit that take it — a player's own character, a GM's every sheet — and
      puts the entry on with where it came from; a reader with nothing to edit
      gets no menu. Instant NPC can Save as a page, with a block: a new NPC
      page with a block drafted for the campaign's game, waiting in Suggested wiki
      updates to be checked (no button where the game has no block to draft).
      Members & characters has Open the sheet on a character whose page has
      one, and the members list names each character's sheet_id.
    • [webapp] Level up on the sheet, with XP from the campaign's ledger (#737).
      A character's XP is now every XP award to them, or to their party, since
      their sheet was made. When it reaches the next level the sheet's header says
      "Level 7 is waiting", with Level up and Not yet; a GM can level any character
      at any time. Settings → General chooses whether the campaign levels by XP or
      by milestone; a milestone campaign shows no XP and its GM says when. Level up
      walks the level in the system's own order: hit points (Pathfinder's worked
      out from the level; 5e's rolled on the class die or taken as the fixed value,
      the die kept with the sheet and the roll standing), the class's own features,
      each feat from the loaded content (one that can't be taken yet is shown,
      disabled, with why), a skill increase as far as the level allows, attribute
      boosts, and 5e's improvement (+2 to one score or +1 to two, none past 20) or a
      feat. What changes lists every number that moves, before and after, and why.
      Reach level n keeps the sheet as it was as a version first, so it can be put
      back, and adds "Ossian Vale reached level 7" to the campaign's milestones, on
      the last session played.
    • [repo] J7, the character journey, and the rest of the demo's sheets
      (lane 11, wave B).
      make journeys now walks a character from the first
      step to level 2, on desktop, a phone and the keyboard alone, with an
      accessibility scan at every page: Priya starts a Pathfinder character from
      Members & characters (ancestry, background and class from the loaded
      content, attributes by boosts, nothing rolled), leaves it and carries on,
      and finishes it; Dana shows Odile Marsh's block to the table by field, and
      Priya is handed the shown lines and no others at the table, on the page and
      in the answer behind it; Dana awards XP, the sheet says "Level 2 is
      waiting", and Priya levels up through What changes to Reach level 2, which
      keeps a version first and adds the milestone. The journeys' seed gains a
      campaign for it, with the sample character pack imported through the GM's
      own import. The dev demo seed gains: "Level 7 is waiting" on Ossian Vale's
      sheet, reached through the XP ledger; Tam Ridley's third level taken on the
      sheet, with its hit-point die kept and "Tam Ridley reached level 3" in the
      campaign's history; and two characters begun and left, waiting on Members as
      "Carry on with …" — one with its 4d6 kept, the lowest die of each struck.
    • [webapp] D&D 5e character sheets and stat blocks, built in (SRD 5.2.1).
      The character sheet works out ability modifiers, saving throws, the
      proficiency bonus by level, all eighteen skills with proficiency or
      expertise and Passive Perception, Armor Class from the armor and shield
      worn (medium armor capping Dex at 2), hit points from each level's rolled
      or fixed hit die plus Constitution, Hit Point Dice, death saves, weapon
      attacks and damage, and spellcasting (save DC, spell attack, slots for full
      and half casters, Pact Magic). Exhaustion and the conditions that stop a
      creature moving change the numbers they affect. Creation (standard array,
      27-point buy, 4d6 drop the lowest), levels 1–20 with the Ability Score
      Improvement or a feat at 4, 8, 12, 16 and 19, short and long rests, and
      the stat block in the SRD's order are all defined. Old 5e stat blocks map
      onto the new block. Includes material from the SRD 5.2.1 under CC BY 4.0,
      credited on the sheet. Nothing on screen yet (#737).
    • [webapp] The block on its page, Show the table by field, Make a block, and the block at the table (#737).
      An NPC's or a creature's page with a sheet draws its block in the game's own
      order under the page's lead. The GM sees every line and which the party can
      read — a GM-only line has the GM's rule down its side, with a legend — and
      has Show the table…, Edit the block, Put on the shelf and Export… beside it.
      Show the table is a checklist by the game's groups (name, level and traits;
      perception and senses; languages; skills; AC, saves and HP; strikes by name
      only, or with their attack and damage; abilities…); GM notes are never
      offered. What is ticked becomes the party's for good, on the page, in search
      and on the table, and unticking a group or Take it back hides it again.
      While a session is being played it is also shown at the table at once — the
      party's lines on the players' screens and, if asked, in the campaign's
      Discord channel — and Take it back takes that showing down, as a handout's
      is, telling the other GMs. That showing opens nothing else: a GM-only NPC's
      page stays the GMs'. A GM can make a block from a character's sheet (Make a
      block…): a new NPC page, the GMs' alone, with a copy of the character as it
      is now that later changes to the sheet do not touch. At the table, Find says
      which pages have a block, and a wiki card or the preview draws the block with
      its hit points (−5 / −1 / +) and conditions changed from the card with no
      version made; a monster from the rules keeps its own hit points and
      conditions on its card for the session. Showing a card with a block to the
      table now asks what to show, by field.
    • [webapp] Rules content now carries the numbers a sheet reads. Every
      reference entry has structured fields per kind — an item's Bulk or weight,
      a weapon's damage and traits, armour's AC and Dex cap, a class's hit die, a
      condition's modifiers — filled when a Foundry PF2e or Open5e pack is
      imported, or written directly in a Quest Board pack (checked per kind; ones
      that don't fit are kept as data with a warning). PF2e ancestries and
      heritages, and 5e species, import as their own kinds. The starter-pack
      scripts now build the Player Core's classes, ancestries, heritages,
      backgrounds, feats and equipment (ORC, setting prose stripped) and the
      SRD 5.2.1 only for 5e; publishing them is a release step
      (docs/OPERATIONS.md, "Building the starter packs"). A small mechanics-only
      character pack per system backs the tests, the journeys and the demo
      (#737).
    • [webapp] The engine that will work out character sheets and stat blocks.
      A sheet's definition (its fields, the sums behind them, the bonuses and
      penalties that apply) is now checked and evaluated on the server: every
      number comes with the named terms it was added up from, bonuses of the same
      kind stack the way the game says, conditions and equipped items apply
      themselves, and a definition that refers to a field that does not exist is
      refused with a "did you mean" suggestion. Ships with a generic sheet for any
      game without its own, and a shared set of test cases the browser will be
      held to as well. Nothing on screen yet (#737).
    • [webapp] The sheet at the table: hit points, conditions, spells, rests, and adding from the loaded content (#737).
      A character's player and the GM change the hot half straight from the sheet,
      on a phone too, with no version made: hit points with −5 / −1 / + and their
      temporary points, hero points, dying and wounded as tracks to press, and
      conditions as chips with − and + (Frightened 1), an "until" note (Off-guard
      until the start of your turn) and Add a condition; every figure a condition
      changes shows the penalty in its terms. A caster's spells have Cast, which
      spends the rank's slot (a focus point for a focus spell) and marks a prepared
      spell expended, and the expended mark takes it back; Refocus sits by the focus
      points. Rest… in the header lists the game's own rests and says what each
      gives back before it is taken: Pathfinder's daily preparations (hit points by
      Constitution and level, every spell slot and focus point, fatigued, drained
      and doomed eased) and Refocus, which is off for a character with no focus
      points and says why; 5e's short rest spends the Hit Point Dice the table
      rolled (the page rolls nothing; Constitution is added for each die), and the
      long rest gives back every hit point and every Hit Point Die (SRD 5.2.1) and
      takes off a level of Exhaustion. Hero points come back to 1 when a session
      starts being played, once per session. While a sheet is open it picks up what
      the other side changed within a few seconds. Editing a sheet now offers Add
      from the loaded content — search, the entry's card (damage, traits, category,
      bulk, price, and where it is from with its licence), then how many and where —
      and each row's own quantity, where it is, invested or equipped, prepared, and
      Remove; a row whose pack has gone keeps its name and says where it came from.
    • [webapp] The sheet engine runs in the browser too. The same sums the
      server works out for a character sheet or stat block can now be worked out
      in the page while someone types, so numbers will update as you edit; the
      server's answer still replaces them on every save. The browser's copy is
      held to the very same shared test cases as the server's, down to how it
      rounds, orders text and prints large numbers. It can also show dice the
      server rolled (which dice were kept and which dropped); the browser never
      rolls. Nothing on screen yet (#737).
    • [webapp] Sheets can name their numbers the way the page shows them.
      A line in the working-out under a number can now take its name from the
      sheet ("Dex 4" rather than "Attribute", "Prof 8" with "trained" beside
      it), a bonus or penalty can name itself ("Frightened 1"), and a sheet can
      pick one name out of a list ("Chain mail 16", "d10 → 8, 7"). A sheet
      definition that misspells a function now gets a "did you mean". Also, a
      malformed item row or an impossible rank no longer stops the whole sheet
      from being worked out (#737).
    • [webapp] A content pack can carry sheet definitions (pack format v2). A .qbpack may list sheets/*.json (and sheets/fixtures/*.json) under sheet_definitions in its manifest. An administrator's pack has each definition checked (unknown names come back with a did-you-mean, fixtures are run) and activated only if it passes; a broken one is refused while the pack's content still imports, and a GM's campaign pack keeps its content but has its definitions ignored, with a note. The import response carries the per-definition report, GET /api/admin/sheet-definitions lists every definition, disabling or removing a pack deprecates its definitions (sheets on them stay readable), export writes them back, and a pack's system.json may now update a game system that same pack introduced.
    • [webapp] Pathfinder 2e character sheets and creature blocks, built in.
      The Remaster character sheet is defined in full: attributes with boosts,
      Armor Class with the armor's Dex cap, saves, Perception and class DC by
      proficiency rank, Hit Points from ancestry, class and Constitution, dying,
      wounded and Hero Points, all sixteen skills and Lore with the armor check
      penalty, Strikes worked out from the weapons carried (attack, the second and
      third attack, damage), feats by level, Bulk and encumbrance, invested items,
      a spells page for casters with slots by rank and Focus Points, and each
      condition's real effect (frightened, sickened, clumsy, enfeebled, drained,
      stupefied, fatigued, off-guard, prone, unconscious, encumbered). The
      creature block follows Monster Core's order, takes over today's stat blocks
      field for field, and can be made from a character. Both carry the ORC
      License notice and no setting names. Nothing on screen yet (#737).
    • [webapp] Character sheets and stat blocks are kept on the server. A
      player character's page, an NPC's or a creature's can now carry a sheet,
      made with the game's own definition (or a generic one for any game without,
      and for a campaign with no system). Every value is checked against the
      definition as it is saved; each field can be shown to the table, the party,
      just its player and the GMs, or chosen people, and a character's player
      always reads the whole of their own sheet. A GM can lock part of a
      character's sheet. Autosaves that cross another window's edit are refused
      with the sheet as it now stands, edits keep a history (one entry per person
      per ten minutes of editing) that can be put back, and hit points and
      conditions change at the table without filling that history. Rows added
      from loaded rules keep their numbers if the pack is later taken down. The
      page's stat block is written from the sheet, and every reader gets only the
      lines they may see. A sheet exports as Quest Board JSON. Nothing on screen
      yet (#737).
    • [webapp] Edit a character's or a creature's sheet, and it saves itself (#737).
      Edit turns a sheet's source fields into inputs of the right kind (numbers,
      text, choices that also take a name of your own, ranks as pips you press,
      rows you add and remove on the generic sheet) while what is worked out stays
      text and moves as you type. The header says where the sheet stands (Saving…,
      Saved at, Not saved with Try again) and there is no Save button; what you
      typed is kept on this device until it lands. If somebody else saved first, the
      sheet says so and shows both versions of each field before you keep yours or
      take theirs. The GM can lock a field or a group, and a locked one reads as
      such to the player; each group that has its own audience can be set to who
      sees it. Every save is a version, and any of them can be put back from
      Versions. A person's or a creature's page with no sheet has Make a sheet, for
      the GM and for a character's own player. Also: a page with no sheet no longer
      ends its crumb on a Sheet that is not there, printing leaves the header
      controls off the paper, a private group's pill names the player by their first
      name, and a derivation whose constant is labelled with its own figure prints
      it once.
    • [webapp] A character's or a creature's sheet, to read: Open the sheet from its page (#737).
      The sheet is a document of its own with its pages (Statistics, Abilities &
      inventory, Notes & actions, and Spells for a caster) in the Contents and, on a
      phone, in the Contents sheet. Every figure is shown once with what it is made
      of beneath it, on tap on a phone; ranks, hit points, dying, wounded and hero
      points, strikes worked out from the inventory, feats by level, the inventory
      with its bulk and where each thing is, spells by rank with how many slots are
      left, and the character's own actions with their cost and full text are all
      drawn from the system's definition, so Pathfinder 2e, D&D 5e and the generic
      sheet are one page. An item whose pack has gone says so and keeps its last
      numbers. What the page itself says (appearance, backstory) is shown with Edit
      on the page; a group a player keeps for themselves and the GM wears the GM
      colour. A reader who may not change the sheet is told so quietly. Export
      gives Quest Board JSON, and Print the sheet prints every page. A page that is
      not a person or a creature says it has no sheet. Read only: editing comes
      next.
    • [webapp] The design system gains the character sheet's pieces: Derivation, RankPips, Track, Resource, Dice, StepStrip, WhatChanges and Picker, each with its states on /design (#737, #364).
    • [webapp] Start a character, in the game system's own steps (#737).
      Members & characters has a Start a character for you, and for the GM beside
      each player. A Pathfinder 2e character walks Concept, Ancestry, Background,
      Class, Attributes, Skills, Equipment and Details; a D&D 5e one walks Class,
      Origin, Ability scores, Alignment and Details. Pathfinder attributes are
      boosts by source, with the cap and how many free boosts are left. 5e ability
      scores come from the standard array (drag a number to an ability, or tap both
      to place or swap them), from point buy with its 27 points and the cost of each
      score, or from rolling 4d6 and dropping the lowest, with every die shown, the
      dropped one struck through, and no second roll where the method allows none.
      The dice are rolled by the server and kept with the sheet. Next says why it
      is held back, each step saves itself, and Finish makes the character's page at
      once, with its prose sections named and empty, and the sheet is the player's.
      A character left half-made is kept for 30 days as "Carry on with …" on Members
      & characters (and on its page), and Open the sheet appears beside a character
      that has one.
    • [docs] Lane 10's drawings, for review (#736, #824): the prototype's lane 8 page draws Test the
      bot (D124, D149, D150), the two pages an email opens (D154, D155), Discord DMs on the notification
      grid (D151, D161), the voice channel and "Where" (D159), and a recording announcement nobody could
      post (D171), and brings its older stills into line with D96, D97, D123 and D151–D163. The owner's
      answers to its questions are recorded as D221–D232, and Settings → Discord is redrawn with pickers
      for the server, channel and voice channel (D226). Catalogue rows IS-443 to IS-458;
      coverage/lane8.py and the CAMP-12, CAMP-13, SESS-2, TABLE-4 and HOME-6 scripts classify them.
    • [bot] The bot says it can run Test the bot (D124, #824, #864). GET /identity's
      features lists record_test beside dm, so Quest Board can tell a bot too old for
      Test the bot before a GM presses the button (additive; bot contract unchanged).
    • [webapp] Admin's recordings table says when each recording goes. "Kept 90
      days, as the campaign asks: until 23 November", or whose setting it is when
      that is the instance's or its cap. The date is the one the nightly clean-up
      acts on, taken from the same function; GET /api/admin/recordings gains
      audio_expires_at, audio_retention_mode, audio_retention_days,
      audio_retention_source and content_approved (#872).
    • [webapp][bot] Taking a handout back deletes its Discord post or DMs (D157,
      D238, #896). The handout path never recorded where it landed, so its own
      Undo neither told anyone nor deleted anything — this closes both gaps.
      • A handout reveal now records where it went: the campaign's channel post
        reuses lane 10's shelf_reveals.discord_message_id/discord_channel_id
        (POST /api/bot/reveals/{id}/message), and a chosen audience's DMs each
        get a row in a new shelf_reveal_messages table via a new, additive
        POST /api/bot/reveals/{id}/dm-message. Migration 8c9d0e1f2a3b, on top
        of lane 8's map. The reveal is now committed before the bot is asked to
        post it, so the bot's report of where it posted can never beat the row.
      • A handout's Undo and a shelf card's take-back are now one code path
        (shelf_service.take_back): stamp it, tell the campaign's other GMs
        (D238), commit, then publish reveal_taken_back carrying every recorded
        post or DM as extra.messages, so the bot deletes each — best-effort,
        silent on a message already gone. Lane 10's single-post fields stay in the
        event for an older bot. BOT_CONTRACT_VERSION stays 2.
      • Handout embeds are ported to faces.handout, the bot's one post shape
        (EXT-2), with a golden row in test_faces.py and a dev-preview specimen.
    • [webapp] A quest can be changed (D242, #925). A quest's page shows its status, main or side, who gave it, the reward, the sessions it started and ended in, and its objectives; a GM edits all of these, with an objective kept for the GMs never shown to players. Changing the status puts a line on the quest's timeline. A GM confirms a rumour as a quest, or marks it not canon, from the quest log.
    • [repo] A shared Claude Code setup for tiered delegation (#935). Specialist
      agents in .claude/agents/: qb-implementer and qb-frontend (Sonnet,
      medium effort), qb-preflight and qb-render-check (Sonnet, low), and
      qb-test-runner and qb-ci-triage (Haiku, low). Skills: /qb-test,
      /qb-ci-log, /qb-render, /qb-preflight and /qb-deploy-dev. Workflows:
      /qb-verify, /qb-build-slice and /qb-review. The local CI recipes now
      build test images from the Dockerfile's backend-dev and bot-dev targets,
      so they can no longer go stale.
    • [repo] The dev demo seed writes a GM copy of The Amberwake Accord. A fifth
      campaign, "The Amberwake Accord (GM copy)", mirrors the original row for row
      under its own ids, with the seats swapped: the owner's account owns and runs
      it and is credited with everything Dana did as GM, and Dana plays the
      owner's character. Every GM-only surface can now be reviewed on Amberwake's
      data. Its GM's bell also has a session short of players and a recording
      about to be deleted. The same builder writes both (engine.Variant), and
      --reset rebuilds the copy with the rest (#951).
    • [webapp] A campaign's export carries its handout files and its map, and
      an import brings them back (export schema 18; #991 part 2).
      An export
      used to carry a page's picture and no other file: a handout came back
      without its PDF, and no part of the map travelled at all. Now each page's
      attachments travel with the page (a binned page's only in the bin), and the
      map travels whole: atlases, maps, markers, the links between them and each
      party's trail, with each map's original image. Tiles are not carried; they
      are cut again on the server that imports the file. On import every file is
      checked as its upload was and stored privately under a new name, every
      reference points at the new campaign's own rows, and audiences only narrow:
      the table's and the party's are kept, anything else comes back as the GMs',
      and a marker narrowed to the GMs is no longer "revealed". An export is
      made to fit the server's import limit
      (Admin → Import a campaign): a file
      that would take it past the limit is left out and listed, with its page or
      its map, in the archive's README.md, and an import that would unpack to
      more than the limit leaves the file out the same way. The import's result
      screen lists every file that did not come and why (files_left_out in the
      response). A file larger than the server takes for one file of its kind is
      left out and listed in the same way, by the export and by the import: a
      stored file can be over the cap its own upload passed. An import brings a
      picture for at most 200 maps and at most 5,000 attachments, and a page's
      picture or an image attachment of more than 4000×3000 pixels is left out
      without being decoded (in a file of any version). A file that is not what
      it says it is, or an archive entry named as two files, still refuses the
      whole import. A refused or failed import now removes the files it had
      written
      ; a page's picture used to be left behind in the media store.
      A map's picture is checked by the worker after the import commits, because
      decoding a large map does not fit in the backend's 512 MB (a 70 MB PNG
      peaked at 403 MB); until it has, the map shows no picture. A map picture of
      more than 8192×6144 pixels (an 8K export, about 50 million) is not brought
      over, and the result screen says so. The worker now handles one map's
      picture at a time, an upload's tiles included. Every older file imports as
      it did, and a file that says it is older than 18 has its map and its
      attachments left unread. No migration,
      and no change to the bot. Self-hosters: an import now needs room on
      the media volume for the archive's files, and the worker must be running
      for an imported map to get its picture: see docs/OPERATIONS.md,
      "Campaign exports and imports: size, time and disk".
    • [webapp] A Test-the-bot run's AI usage now counts in the campaign's usage and credits like any other run, marked as a test (D227). session_usage.session_id is nullable; a test row carries recording_test_id and purpose="test" instead, and Admin → AI usage & speed shows it as "Test the bot".
    • [webapp] Settings → Discord chooses its server, channel and voice channel from
      pickers, once Discord is linked (D226, D159).
      The bot answers two new internal
      routes — the Discord servers it shares with a given user, and the text/voice
      channels it can see in one of them — and the backend asks them only for the
      signed-in GM's own verified Discord link, never a Discord id from the client. A
      GM with no verified link, whose bot can't be reached, or who just prefers to, keeps
      typing IDs, exactly as before.
    • [bot] GET /discord/guilds and GET /discord/channels, bot-auth and additive
      (BOT_CONTRACT_VERSION stays 2): list a user's shared servers, and the channels the
      bot can see in one of them. Both re-check that the given user is actually a member
      of the guild in question before answering.

    Changed

    • [webapp] A wiki page in the bin now travels in a campaign's export, and
      an import puts it straight into the bin (#1009).
      Since #990 a binned page
      was left out of the file, so moving a campaign ended the days a GM has to
      restore it (30 unless the instance says otherwise). It is now carried in
      data.json (a new wiki_bin section; export schema 16) with the time it
      was binned, and an import writes it already binned: for the GMs only until
      one restores it, never live for a moment, and with the days it had left
      rather than a fresh count. What stood
      beside it comes back as it stood (its aliases, owners, relationships, name
      targets, picture, shelf cards and prep items, the thread about it), so
      Restore brings back what it would have in the old campaign. What was shown
      at the table stays shown until a GM takes it back, as it does where it came
      from: a card shown from the page, and the page's own reveal when it was
      shown in a session, are still listed under that session for the people they
      were shown to, by the title they were shown with; a player's handouts list
      leaves the page out until it is restored. A binned page is in nothing a person reads:
      no file under wiki/, no count in README.md, its picture under
      media/bin/lore/. A page already past the bin's days when the file is
      imported is deleted by the next daily purge
      , as it would have been where
      it came from, so restore it as soon as you have imported. Every other bin still
      stays behind, and an archive from before this release imports as it did. A
      Quest Board older than this one refuses a schema 16 file by its version;
      this one now says "made by a newer Quest Board … update, then import it
      again" when it meets a file newer than it reads. A fact or a timeline row
      that names a page further down the file now keeps its link on import, where
      it used to lose it. See docs/API.md.
    • [webapp] Shortening how long a campaign keeps transcripts is an ordinary
      confirmation now (#1010).
      It was the serious kind, with a red button, from
      when the older transcripts were destroyed that night. Since #680 they go to a
      bin and can be restored, so the question keeps its words and its offer to
      export the campaign first, and loses the red.
    • [webapp] The footer is at the bottom of the page, not a strip pinned under it.
      The version and the project, Help, Accessibility, Privacy and Licences
      links used to sit under the page's scroll area at every width, taking two
      lines of a phone's screen for good and cutting off the end of long lists.
      They are now the last thing in the page itself, at the window's bottom on a
      short page and reached by scrolling on a long one, beside the Contents
      rather than under it (#1020).
    • [webapp] The party page shows XP and gold as their figures, with a small
      History beside each.
      History opens every entry, newest first, in a dialog
      ("XP history" / "Gold history"), with the same Remove and Undo; the page no
      longer lists them all (#1020).
    • [webapp] The GM's table on a phone: Take it back, and a phone held sideways gets the phone view (#1020). On the phone's stage, "Take it back" now sits beside "Show the table" while the card on stage is up on the table, with the same call as the desktop's "Shown to the table" list, a busy state, and Try again if it fails; a stat block shown by field has every showing taken down (D271). The phone view was chosen by width alone, so a phone at 844 x 390 got the desktop board in 390px of height: a window that is narrow, or short on a touch screen, now gets the phone view, and a short desktop window or a tablet on its side keeps the board. Turning the phone keeps the card on stage and any open "Show the table" or sweep (D272).
    • [webapp] The "Quest Board" wordmark in the app bar is now a link to Home.
      Inside a campaign it was only a picture of the name; it now takes you back
      out to Home from every page of the signed-in app, and is named "Quest Board,
      home" for a screen reader (#1020).
    • [repo] The Claude Code setup is now a thin layer over a shared, user-level set (#1037).
      Quest Board's test recipes, preflight checks, release rules, deploy facts and UI
      settings moved into a ## Project profile section of CLAUDE.md. /qb-test,
      /qb-ci-log, /qb-preflight, /qb-deploy-dev and /qb-render keep their names
      but delegate to the global skills and agents, and /qb-verify, /qb-review and
      /qb-build-slice delegate to the global workflows. The qb-test-runner,
      qb-ci-triage, qb-preflight and qb-render-check agents are gone, replaced by
      the global test-runner, ci-triage, preflight and render-check. The pull
      request template now asks for a changelog.d/ fragment instead of an edit to
      CHANGELOG.md.
    • [repo] Prereleases are cut for the dev server, and the Quest Board hosts
      run the published images.
      A vX.Y.Z-rcN tag now publishes release notes
      rendered from the pending changelog fragments (changelog.py render, which
      consumes nothing), and its images are :X.Y.Z-rcN only. A committed
      docker-compose.images.yml pins the six app services to the release's images
      and removes their build:; its tags must equal APP_VERSION, which
      check_version_sync.py now enforces. A host that adopts it sets
      COMPOSE_FILE=docker-compose.yml:docker-compose.images.yml in its .env and
      updates by checking out a release tag. A self-hoster who builds from source
      sets nothing and is unaffected. docs/OPERATIONS.md covers cutting a
      prerelease, the image update, rollback and production updates (#1043).
    • [webapp] A wiki page in a campaign file's bin whose days were already up
      now gets seven days from the import, not the next day's purge.
      Importing
      an old archive used to leave such a page for the daily sweep, so a GM had
      at most a day to restore it. It now stays in the bin for seven days from
      the import, and the bin's list shows that date. A page with any time left
      keeps its own day. (#1055)
    • [webapp] Cutting a recording into speech spans no longer starts ffmpeg
      once per span (#684).
      With silence trimming on — and always on a managed
      provider with a per-request size limit — every speaker's track is cut into
      its speech spans before transcription, and each span was its own ffmpeg
      process: around 1,300 of them for a three-hour, five-speaker session, most
      of whose time was process start-up. The spans of a track are now cut
      twenty-five to a process. The files are byte for byte what they were, so
      transcripts, timestamps and the counts on a summarisation run are unchanged;
      on the 30-minute test scene the cutting went from 208 processes and about 17
      seconds to 11 and about 2. If a batch fails, its spans are cut one at a time
      exactly as before and the worker log says so once
      (… in one ffmpeg run failed … cutting them one at a time instead). ffmpeg
      briefly uses a little more memory for a batch than for a single cut —
      roughly 65 to 80 MB against 56 MB; the batch size was chosen to keep that
      small in a worker limited to 1 GB. Nothing to configure.
    • [repo] The synthetic-session guard tests take about half as long (#684).
      Besides the batched cuts above, the guards no longer re-run silencedetect
      over a track ffmpeg has already scanned unchanged (test-only; a rewritten or
      copied track is always scanned again), and a new guard compares the batched
      cuts of the scene's tracks with single cuts, byte for byte, in the CI job
      that is not allowed to skip.
    • [webapp] Anyone who may edit a wiki page directly can upload or remove its
      picture
      (D234, #734). It follows the campaign's Who may edit: a player's own
      pages by default, any page they can see under "players anything", none under
      "suggest" (D233). Setting a picture from a web address stays the GMs', since it
      makes the server fetch something.
    • [webapp] Admin's Recording & retention, Backup, Import and Audit log are
      rebuilt on their own pages (lane 9 page 4b, #735).
      Recording & retention
      gains the default/cap split (D24) with IS-311's confirm before a tighter
      cap catches a campaign's sessions, and says D179's retention promises
      correctly. Backup queues without asking first (D12), polls while a run is
      in progress, and no longer claims uploaded images are left out, since
      D143 already put them in. Import a campaign now shares its form with
      Home's New campaign → Import a file. The audit log filters by name
      rather than id, groups "What happened" into categories, and reads its
      detail as labelled lines with the record as stored one click further.
    • [webapp] Admin's AI models, AI usage & speed, Discord, Email and
      Instance health are rebuilt on their own pages (lane 9 page 4c, #735).

      The old 49-field Bot Settings tab splits into AI models (the capability
      panel, the transcriber and summariser, the summary and wiki modes, and
      who is using their own) and Discord (the bot's address and its key,
      with IS-394's identity check replacing the token field per D139).
      AI usage & speed gets an address of its own for the hardware preflight
      and the usage report. Email's test now sends what is typed, saved or
      not, and answers with the SMTP server's own reply instead of queuing.
      Instance health has a screen for the first time: the AI queue's
      free/busy/stuck verdict, polled every 10 seconds, and which secrets
      this server can read. Also fixes a date column (Joined, Last here,
      Started, When) wrapping mid-date on Users, Campaigns and Audit at 390px.
    • [webapp] Admin's Content packs, Content notices and Plans & entitlements
      are rebuilt, and the legacy Admin page is gone (lane 9 page 4d, #735).

      Content packs takes lane 3's kit pieces rather than the old Tailwind
      component, and adds starter packs and an instance pack's own default
      ("Every member", AD-120). Content notices gets a screen for the first
      time: where a rightsholder writes, filing and filtering notices, and
      acting on one (turning a pack off, or removing it or just the records
      named, all or nothing). Plans & entitlements shows the four prices
      Plan & billing already reads, hosted servers only. pages/Admin.jsx
      and its last two orphaned panels (AdminAiUsage, AdminHardwareProfile)
      are deleted. Also fixes two dialogs (making an administrator, handing
      the server or a campaign on) that drew no buttons at all, and a narrow
      screen squeezing a table's columns instead of letting it scroll.
    • [bot] Commands, and a private way to raise a concern (lane 10, EXT-1, #736).
      /recap now also takes a session's number (/recap 13), resolved to the right
      campaign in a server that binds more than one (O9); /next, /quests, /history
      and /ask resolve the same way, and every one of them offers a campaign: picker
      when it needs to ask. /history and /ask now answer privately, /history lists
      numbers instead of session IDs, and /ask names the sessions its answer was drawn
      from — worked out by Quest Board, never the model (O16). /next shows RSVP counts.
      /note and /help's words follow D97/D106. A new /concern opens a private
      Discord form, in a server or a DM, that reaches only the campaign's GMs through the
      same anonymous queue as the web's Table rules & safety flag (O13, D160) — nothing it
      carries is ever echoed into a channel, logged, or stored with a sender. All
      additive: BOT_CONTRACT_VERSION stays at 2.
    • [webapp] The server side of the above (#736). GET /api/bot/guilds/{id}/sessions/by-number/{n} resolves a session by number within a
      guild's campaign; channel_id/campaign_id are new optional parameters on
      /next-session, /quests, /sessions/list and /sessions/ask for the same
      resolution, returning 409 with the campaign names when a server holds more than one
      and neither narrows it. /sessions/ask's response gains sources. A new POST /api/bot/safety/submissions is /concern's door into the existing, sender-less
      safety queue, kept out of the access log the same way the web route is. No
      migration.
    • [bot] Every Discord post has one shape (lane 10, EXT-2, #736). A coloured edge
      for its kind (six tones that read on Discord's light and dark themes), a title in words
      with the kind first and the session by number ("On: Session 14", "Cancelled: Session
      14", "Recording saved · Session 14"), the facts as fields — When, Where (the
      campaign's voice channel, D159) and Who's in — one link back to Quest Board and
      none on a server with no public address, and Discord timestamps throughout. All built
      in one place, questboard_bot/faces.py. A time added to an open vote is now also
      posted on its own ("A time was added · Session 14"); a cancellation says who and why
      (D101); the approved summary is the only summary post and says who approved it (D96);
      no post promises a summary "when it is ready" any more, and a session_summarised
      event still queued from before D96 is dropped rather than posted. The "Recording
      stopped" line is gone: the table hears once, after the handoff. The bot's status reads
      "Recording · /help" while any recording runs (D158). tests/test_faces.py pins every
      face, and python -m questboard_bot.preview --channel <id> --yes posts every one,
      from invented examples, into a channel of the developer's own test server (D164, O17).
    • [webapp] Bot events carry what the posts need (lane 10, #736). Every session event
      adds number and voice_channel_id; the poll adds closes_at, the reminder rsvp,
      the cancellation cancelled_by, the played post duration_s, the approval
      approved_by, and a slate change says whether a time was added and by whom. The
      bot's /api/bot/sessions/{id}/timeslots adds number. All additive; the bot contract
      stays at 2.
    • [bot] Reactions and taking things back (lane 10, EXT-3, #736). Reacting to a
      confirmed session gains ❔ ("I might make it", D50) alongside ✅ and ❌, with mutual
      exclusion between the three enforced on the message (Manage Messages permitting); the
      confirmed post's footer says so. Voting on a proposed session now names the voter's
      whole slate in the acknowledgement ("A and C work for you"), not only the option that
      just changed. Taking a revealed card back down in Quest Board (O10) now deletes the
      bot's own Discord post too, and tells the campaign's other GMs (reveal_taken_back, a
      new bell kind). All additive: BOT_CONTRACT_VERSION stays at 2.
    • [webapp] The reveal message, and its removal (#736). POST /api/bot/reveals/{id}/message records where a shelf_reveal embed landed
      (shelf_reveals.discord_message_id/discord_channel_id, migration
      10b2c3d4e5f6); revoke_reveal now publishes reveal_taken_back for the bot to
      delete it, and notifies the campaign's GMs other than the one who took it back.
      BotAttendanceRequest.attended is now optional alongside a new rsvp field
      (yes/maybe/no); the vote endpoint's reply carries the voter's your_answers.
    • [webapp] The D&D 5e sheet names what it adds up, as drawn. Each number's
      working now reads "Chain mail 16 + Shield 2", "10 + 2 × (d10 → 8, 7) +
      Con 2 × 3", "Str 3 + Prof 2" and "2 + ⌊(3 − 1) / 4⌋" instead of generic
      labels, and penalties say their level ("Exhaustion 2") or their cause
      ("Chain mail, needs Str 13") (#737).
    • [webapp] Existing stat blocks become blocks on the new sheets, automatically.
      On upgrade, every person's and creature's stat block turns into a creature
      sheet for its game — Pathfinder 2e, D&D 5e, a game added by a content pack,
      or the generic stat block when its game is gone — keeping who could see it:
      a block everyone could read stays readable by everyone at the table, and a
      GM-only or player-private one stays with the GMs. Nothing is lost: the old
      block is kept with the sheet, a value that doesn't fit the new sheet is kept
      aside rather than dropped, and the page's block is rebuilt the first time
      it's opened. The stat block editor on a page, the importer, "Stat block" and
      "Convert stats" all work on the sheet now; a drafted stat block fills the
      new block's fields and, once approved, lands on the page's sheet instead of
      in its text. Administrators can run python -m app.sheets.rebuild_projections
      (with --dry-run to see what it would do); the worker also does it once
      when it starts (#737).
    • [docs] The lane 9 drawing gains Actions and Rest… for the sheet build (#737).
      Notes & actions lists a character's actions with their cost and full text, and
      the sheet header gains Rest… (D209); the drawing also drops its explanatory
      lines, the recap promise and the Foundry export (D208, D210, D200). The
      catalogue gains IS-459 to IS-462 and the survey is committed as
      docs/design/lane11-build-contract.md.
    • [webapp] The old stat block editor is gone; stat blocks are edited on the sheet.
      On an NPC's or a creature's page, the editor now shows "Open the sheet" when
      the page has one and "Make a sheet" when it does not, in place of the form of
      fields. Saving a page with a stats value is refused with "Stats are edited
      on the sheet." Pages still show their stat block, and importing a campaign
      still brings stat blocks in (#737).
    • [webapp] Pathfinder 2e sheets read the way the game prints them. The
      sums under a number now name the attribute they use ("Dex 4", "Wis 4") for
      class DC, spell DC and attack, and Strikes; Hit Points read "Ancestry 8 +
      6 levels 60 (Class 8 + Con 2 each)"; and a condition that has a value says
      it ("Frightened 1", "Stupefied 2", "Drained 1"). The numbers themselves are
      unchanged (#737).
    • [webapp] Admin follow-ups and the demo seed's own surfaces (lane 9 PR 6, #879).
      GET /admin/settings/bot/identity passes through the bot's own reason for
      a disabled Discord connection, so the Discord section says "Discord
      refused the token" separately from "No Discord token set" instead of one
      generic message. Instance health's "Campaigns' own keys (N)" line is real:
      the secrets audit now counts every campaign's own transcriber/summariser
      key and how many this host's SECRET_KEY can no longer read. AdminAi
      and AdminRecording regain the test coverage the legacy Admin page's
      deletion (#735) didn't carry over — gap severity ordering, a provider
      guessed from the address, the retention form's defaults and caps together,
      and the wiki pipeline's mode change and error states.
    • [repo] The demo seed gains two thin campaigns purely for Admin's own
      pages (#879, D145).
      The Salt Crown (archived) and Brinewatch (a
      one-shot, binned), both owned and GM'd by an invented person who has not
      signed in since — a stale owner, a failed recording, a transcript kept
      "for good" for the retention cap-confirm to name, a second campaign pack
      disabled after a content notice, and nine more audit-log events. Nobody
      plays these two tables; see webapp/backend/demo/README.md for why they
      stay that thin.
    • [webapp] The email answer page now says when a session has started or been cancelled, and links to it; the Notifications page says which of Discord's two refusals stopped your DMs.
      Answering a started or cancelled session from an email is refused (409) instead of reading as an expired link.
    • [webapp] Awarding XP is the GMs' alone (D241). The campaign's "players can add loot" setting still lets players add items and gold, but no longer XP; removing or restoring an award is the GMs' too.
    • [repo] The backend test suite is much faster. The local-Whisper /health probe is stubbed by default (tests that exercise it opt in with @pytest.mark.real_asr_probe), removing the ~8 s DNS-plus-timeout wait from about thirty recording-pipeline tests; a test network guard now fails any test that resolves an unmocked hostname (opt out with @pytest.mark.allow_network); and the synthetic-session harness (@pytest.mark.synthetic) is deselected from the backend CI job's pytest -m "not synthetic" since its own job already runs it. Full run: ~1,061 s before, ~536 s after (about 9 min).
    • [repo] CI's two slowest jobs now run in parallel pieces (#939).
      • Vitest was running on one worker. CI jobs get 2 CPUs, and vitest's default is CPUs − 1, so the whole suite took 1,603 s. The frontend job is now two shards, each with --maxWorkers=2. Lint, the build and the audit run once, in shard 1.
      • The journeys were running one project after another. They're now a matrix of three jobs (desktop, phone, keyboard), each with its own stack. The one-worker constraint (per-IP login rate limit, one shared seeded database) is per stack, so no spec changes.
      • Three prep-sheet test waits no longer fail under load. Their explicit timeout: 4000 was shorter than the suite-wide 5 s asyncUtilTimeout. The two that wait on the 1 s autosave now wait 8 s.
    • [webapp] Session emails no longer repeat "Quest Board" as a heading under the brand line; they open with the news itself, as drawn (EX-040).
    • [webapp] The design system reference page, /design, now ships in production
      builds, for server administrators only.
      Anyone else, signed in or not, gets
      the same "not found" page as any unknown address. Development builds still
      open it to everyone.
    • [webapp] Cards on the GM's table board are packed, and a new one lands beside
      the last card on screen.
      A card nobody has moved used to take a slot by its
      place in the whole shelf, so a shelf whose cards had mostly been placed by hand
      drew a new one far down the board with empty slots above it. Unplaced cards now
      fill the first free spots around the ones the GM placed (which never move), and
      a note or a card added from Find goes next to the last card in view — to its
      right, else under it — never on top of another card.
    • [webapp] A campaign's export and import are handled on disk, an import
      is read within fixed bounds, and the largest import is a setting, 1 GB
      unless changed (#991 part 2, #1054).
      An import used to hold the whole
      upload in memory, up to 100 MB, and read data.json out of it with no cap
      on what it unpacked to, so a small file could exhaust the backend's memory.
      That read is now bounded, and a file written to be expensive that is inside
      the bounds no longer exhausts a backend at its default 512 MB (the dearest
      found took about 117 MB while it was parsed). The bounds on data.json are
      low for that reason; parsing it in the worker, which would let them rise,
      remains (#1054).
      The upload is now received into a temporary file as it arrives and read
      from there one entry at a time: an entry's declared size is checked before
      it is read and its real size counted as it is, data.json may unpack to at
      most 8 MB and hold at most 400,000 values, and an archive with an encrypted
      entry, two entries of one name, more than 20,000 entries, or an entry
      compressed with anything but store or deflate (a bzip2 or LZMA bomb
      decompresses a whole block before the size is checked) is refused. Each
      refusal is a 422 that says what was wrong. Imports are also bounded while
      in flight: two at a time per worker process, one per user, and an upload
      that stalls for two minutes is dropped — so a slow or many-at-once upload
      cannot fill the server's disk or memory. An export is built in a
      temporary file and downloaded by the browser from a link, with its length,
      where the page used to hold the whole zip in memory first. The largest
      file an import takes is now set on Admin → Import a campaign
      : 1 GB by
      default, between 100 MB and 4 GB; a file over it is refused with a 413
      that says so, before it has all arrived. Nothing about what an export
      holds has changed (export schema 17), and every older file imports as it
      did. If you run a reverse proxy in front of Quest Board, raise its upload
      limit for POST /api/campaigns/import
      : the bundled nginx sets none on
      that route, but yours has its own. Exports and imports now need disk
      rather than memory; TRANSFER_SPOOL_DIR says where. See
      docs/OPERATIONS.md, "Campaign imports and your proxy's upload limit" and
      "Campaign exports and imports: size, time and disk".
    • [webapp] A campaign's export now carries each quest's details and each
      character sheet and stat block, and an import brings them back (#991,
      part 1; export schema 17).
      A quest used to come back as a plain page: no
      status, giver, reward or objectives, and so not in the quest log. A
      character or a stat block came back as the block its page printed, with no
      sheet behind it. Both now travel on the page's own entry in data.json
      (quest and sheet), so they go where the page goes, into the bin with a
      binned page. A quest keeps its status, giver, reward, sessions, story
      arc and objectives; a hidden objective stays hidden. A sheet keeps its
      values, its rows, the damage taken and conditions, its locks, its kept
      rolls and the day it was made, and a character's player owns it again once
      the GM gives their placeholder to their account. Nothing is shown wider
      than it was: an objective or a field for the table or the main party comes
      back as it was, and one for named people or one person comes back for the
      GMs only until a GM shows it again.
      A sheet's version history does not travel: the sheet arrives with one
      version, "Imported with the campaign", and "Put back" has nothing older to
      offer. A character still being started does not travel: its player
      starts it again in the new campaign.
      A sheet is only ever put under the definition it was made under. The
      built-in ones are on every server; for a content pack's, the import looks
      for the identical definition and installs nothing, so a sheet whose rules
      are not on the new server is left out and its page arrives with its
      printed block as before. The import says so: under "Imported …" it lists
      each such page with the rules it needs (sheets_left_out in the
      response), and once an administrator has loaded those rules as a content
      pack, importing the file again brings the sheets. Rows from a pack the new
      server lacks read from their kept copy, marked as removed.
      XP is what it was. The campaign's parties and who was in them now
      travel, and an XP award keeps whose it was and the day it was given, where
      an import used to make every award the one party's: with sheets arriving,
      that would have counted every award on every sheet and lit "Level n is
      waiting" for characters who had earned none of it. An award to a party is
      that party's; an award to one character counts for nobody until that
      character's placeholder is claimed, and is the character's again then; an
      award to somebody or some party the file does not hold stays in the ledger
      and counts toward no sheet. A claimed character is in the party it was in,
      and one who had left or retired is not put back in the main party. The new
      campaign is linked to the same game system and levels the same way.
      A quest's file under wiki/ gains a ## Quest section and a page with a
      sheet a ## Stat block section. A Quest Board older than this one refuses
      a schema 17 file by its version. An archive from before this release
      imports as it did, with two differences, both refusals or narrowings: a
      printed block's line for named people now comes back for the GMs only, and
      a page whose stats is not a stat block (a file edited by hand) is refused
      with a message naming the page, where it used to import and then fail each
      time the page was opened. Maps and handout files are still to come. See
      docs/API.md.
    • [repo] Changelog entries are now fragments in changelog.d/, gathered
      into CHANGELOG.md at release.
      Every PR used to add its bullet
      directly at the top of [Unreleased]'s section lists, which clashed
      between PRs merged close together. Now each PR adds a small file under
      changelog.d/ instead; make changelog (scripts/changelog.py assemble)
      gathers them at release time, and make changelog-check (also run by CI)
      validates them. See changelog.d/README.md.

    Fixed

    • [webapp] Level up no longer offers another class's feat as if it could be taken. At a class feat step a Fighter was listed a rogue's feat, because feats carry their class as a trait rather than a prerequisite. It is shown disabled with the reason ("A rogue feat."), the way a feat with an unmet prerequisite is, and applying the level with it is refused. A feat can still be typed by name (D261, #1008).
    • [webapp] A temporary hit point figure typed straight after a sheet opens is no longer put back to the old number. The box reset itself to the server's figure on mount, which could run after an early edit (#1017).
    • [webapp] An article's text no longer sits against the rule under its
      tabs.
      The Read tab now leaves the same gap below the rule as the other
      pages, for a player's view and a GM's, at every width (#1020).
    • [webapp] On /design, pressing anything no longer blanks the open panels.
      The bell, menus, account menu and search drawn open closed on a press
      anywhere else on the page, or on Escape, and nothing brought them back; the
      ones drawn open now stay open (#1020).
    • [webapp] A page shown to you as a handout no longer logs a 404 for its map
      markers.
      "On the map" asked about a page the reader may open through a
      reveal, and the route only knew the page's own audience; it now answers with
      the markers she can see, usually none (#1020).
    • [webapp] Opening a notification from the bell now marks it read. Following a
      row's link (a click, Enter, or a new tab) did not clear it in a browser,
      because the frame closed the panel before the row heard the click; the row
      now marks itself first. The tick beside a row still marks it without opening
      it (#1020).
    • [webapp] The page a player meets on a GM's prep address reads as
      English.
      It said prep "is where each session is got ready"; it now says
      "Prep is for the GMs of this campaign: it is where a GM gets each session
      ready." Planning's own version of the line is reworded the same way
      (#1020).
    • [webapp] A scene can be deleted from the prep sheet, and a card's "File it
      in" menu there can be read and used.
      The sheet's Shelf section could make
      and rename a scene but not delete one; each scene now has Delete beside
      Rename, the same delete Table mode's Scenes panel has: at once, with an Undo,
      the scene's cards staying on the shelf under "No scene yet" until the undo
      files them back. The menu on a shelf card was drawn inside its list and cut
      off at the list's edge, so only its heading and "No scene" could be seen; it
      now hangs clear of the list, and it offers every scene with the card's own
      marked, where it used to leave that one out (#1020).
    • [webapp] A search puts the thing you named first. Searching the wiki for
      "Odile" listed Bastian Crowe, whose page mentions her, above Odile Marsh:
      a wiki search had no ranking at all and answered in the wiki's browsing
      order (kind, then title), where a character comes before an NPC. Every
      search now follows one rule, written once on the server
      (app/services/search_rank.py): the name that is the query, then a name
      that starts with it, then a name with a word that starts with it, then the
      same for its aliases, then everything that only mentions it — each of those
      in the order the search had before. Case, accents and extra spaces don't
      count. It applies to the app bar's search, the wiki's own, Table mode's
      Find, the prep sheet's Rules & bestiary and Wiki drawers and its article
      pickers, and the rules search, and it is applied before any limit, so the
      exact match is never the row a cap drops. A player's results are ranked by
      the names a player knows: an unidentified item by what the party calls it,
      and a page they can't see is not there to be ranked. A rules record is also
      now found by the start of any word in its name ("stalk" finds Reed Stalker).
    • [webapp] The wiki front page's results open under its search box. They
      hung from the far right of the page's main column, away from what was
      typed: the box is the app bar's search component, whose results hang from
      the right edge of a box that, on this page, was as wide as the column. The
      box is now up to 32rem wide and the results start at its left edge at the
      same width, on a phone too.
    • [webapp] The last results in a prep-sheet drawer are no longer cut off by
      the footer.
      The tool margin beside the sheet was sized from the window
      (100dvh - 6rem), but it sticks inside the page's scroller, which is the
      window less the app bar, any banner and the footer — so its last 36px or
      more sat behind the footer, where neither its own scroll nor the page's
      could reach. It is now sized from the scroller itself, measured, so it holds
      whatever the bar and the footer do.
    • [webapp] A result found by its own name no longer says the name twice.
      "Odile Marsh" sat over "npc · Odile Marsh". The line under it now says how
      the page starts — in the text that reader may read — or, for a session, how
      its summary starts; with nothing to say it is just the kind or the date.
    • [webapp] Table mode's Find no longer scrolls sideways. A row was as wide
      as its whole snippet (778px in a 590px list), so the list scrolled across
      and the snippet was cut at the list's edge. Rows now fit the list, the title
      keeps its room and the snippet ends in an ellipsis.
    • [webapp] In the prep sheet's lookup drawers, "In the sheet" stays on one
      line on a phone, and a preview that opens is brought into view
      so its
      Insert is not below the fold of the Tools sheet.
    • [webapp] A GM can find the sweep, and the table, from a session that has
      been played.
      "Sweep the shelf" was only at the foot of the Review tab's
      checklist, and nothing on a played session's page led back to its table. A
      GM now has both under the tabs on Read as well as on Review: Sweep the
      shelf
      while any card from the session is still on its shelf, and Open
      the table
      always — the evening's board, its scenes and what was shown are
      still there. In Table mode the sweep's button is called Sweep the shelf…
      too, in the bar and at the foot of the phone view, where it used to say
      "End of session…". Closing a sweep opened from the session page takes
      ?sweep=1 off the address, so reloading or going Back does not open it
      again (#1020).
    • [webapp] Showing something to the table from a GM's phone no longer turns
      large.
      A card put on stage on a phone was set in the desktop stage's
      display type — everything a size up, its first line at heading size — when
      only the title had been brought down for a phone; the phone's stage is now at
      the phone's size, as drawn. "Show the table" opened a dialog whose field was
      13px, which makes iOS Safari zoom the whole page in as the field takes focus;
      on a phone the field is 16px. A long session or card name wraps instead of
      pushing the page sideways, the bar's clock is the phone's size, and every
      button on the phone view and in its dialogs is at least 44px, including
      Leave, the stage's ×, ‹ and ›, and the sweep's rows (#1020).
    • [webapp] A thread marks the posts that were unread when you opened it.
      Each carries a dot and the word "New" (not colour alone), with a "New since
      you were last here" line above the first; they stay marked for the visit
      and are read on the next. The thread's read answer gains is_new on each
      post and read_through on the thread, both additive (#1020).
    • [webapp] The wiki's front page lists Quests under Browse by group.
      Every entry type had a row there but a quest's, which was only reachable
      from the Contents' quest log; the row opens it and counts the quest pages
      the reader can see (#1020).
    • [webapp] The Contents' wiki chapters now include Events and Lore.
      They were reachable only from the front page's Browse by group, so the
      sidebar and the phone's Contents looked as if categories were missing; both
      now list all six groups in Browse by group's order (#1020).
    • [webapp] Back and forward return to where you were on pages that load their content after the page opens. A session's Review tab and Admin → Audit log came back short, or at the top, because the position was set before the page was tall enough; it is now kept trying until the page can reach it, and stops the moment you scroll yourself (#1023).
    • [webapp] The notifications panel no longer scrolls inside itself. Its hidden "unread" labels were not contained by the list, which made the panel taller than its box; the heading could end up half cut off at the top (#1023).
    • [webapp] The wiki editor's Save row stays at the bottom of the window all the way down the page. It used to leave the screen once the reader scrolled past it to the stat block, picture and relationships; it is now the last thing in the editor and settles at the end of the page, above the footer (#1023).
    • [webapp] The page no longer lets the whole window scroll under the app frame, and the bell and Contents buttons are 44 pixels wide on a phone. A visually hidden label far down a long page could make the window itself scrollable; the page's own scroller now contains it. The footer's links no longer leave a dangling "·" at the end of a wrapped line (#1023).
    • [webapp] A menu no longer hangs off the side of a phone screen. A menu
      hangs from one edge of the button that opens it, which is right only while
      that button sits near that edge of the page. A wiki article's "See it as…"
      wraps to the left on a phone, and its menu ran 30px off the screen with its
      names cut short. Every menu now checks where it has opened and moves back
      inside the page, and is never wider than the screen (#1029).
    • [webapp] Choosing a feat or a class from the loaded content no longer
      loses the list while it searches.
      Level up's feat picker and Start a
      character's "Choose from the loaded content" emptied their list for the
      length of every search, said "Nothing matches" until the answer came, and
      took a row out from under whoever had just reached it with the keyboard. The
      results on screen now stay until the new ones arrive (#1030).
    • [webapp] Coming back to a discussion just as it finishes loading now
      refreshes it.
      A thread, a list of threads and a Talk tab's count read
      again when the window regains focus. A focus arriving in the instant the
      page finished loading was dropped, because the page had not yet been told it
      was loaded; the next one worked. It also made one test fail now and then on a
      busy build machine (#1036).
    • [webapp] A flaky Preview test waits for the scroll it asserts (#1039).
      The test for bringing the preview panel into view a second time checked the
      call on the line after the entry's text appeared, which on a busy CI runner
      is before the effect that makes the call has run. Test only.
    • [repo] make migrate-safe now fetches the new release's image (pull on an image host, build on a source build) and runs the preflight, backup and migration from it, instead of from the old running container; the docs no longer tell image hosts to avoid it (#1045).
    • [webapp] Uploads over 1 MB work in a deployed build (#1057).
      nginx had no upload limit set, so its default of 1 MB refused every map image, handout file, larger picture and campaign import before Quest Board saw it, with a bare "413" page. The proxy now lets through anything up to just above the largest upload Quest Board accepts, and Quest Board's own limits and messages apply. A self-hoster behind another reverse proxy should check its limit too.
    • [repo] Fixed uploads failing on an install whose media volume was created before #914: the one-shot migrate service now mounts it and hands it to the app user when it is not already owned by it (one cheap check, no walk of the tree on later starts), so an upgrade repairs it by itself (#1064).
    • [webapp] A batch of imported maps no longer holds up everything else the
      worker does (#1077).
      An import queued a task for each map. The worker has
      two slots and takes maps one at a time, so one slot worked on a map while
      the other held the next map's task, waiting: a recording handed off during
      an import was not transcribed, and no reminder went out, until the last map
      was done. An import now queues one task that takes its maps in turn, and no
      map task waits in a slot for another map: it steps aside and comes back.
      Each map still shows its picture as soon as it is done. A map uploaded
      during an import gets its turn between two of the import's maps.
    • [webapp] A photo that relies on its EXIF orientation is stored upright (#1079). A phone stores a portrait photo as landscape pixels plus a tag that says which way to turn them, and the picture check stripped the tag without making the turn, so such a photo came out on its side as a page's picture, an image attachment or a map. The turn is now made before the metadata is stripped, for all eight orientations, in a JPEG, a PNG and a WebP, on upload and on import, and a map's width and height are the upright ones. It costs no more memory than any other picture, so the pixel bounds are unchanged and a 12 MP phone photo is taken as before. Pictures already stored are not changed, because their orientation was stripped with the rest of their EXIF: re-upload one that is on its side to fix it.
    • [webapp] Accessibility gaps closed (#367). A transcript turning ready or failed is now announced, not only "working". Public pages move focus to their heading on a change of page, as the rest of the app does. The phone Contents sheet keeps Tab inside it and is announced as a dialog. A dialog whose opener has gone returns focus to the page's heading rather than the top of the document, and opens on its first enabled control. The account menu has an Accessibility entry, and the statement lists two more known gaps: small map markers, and status changes not always announced by every screen reader. The public pages Help, Privacy, Accessibility, Licences, auth-error and the not-found page are now scanned by axe in the journeys.
    • [webapp] The admin tables for provider health, notices, backups and a person's campaigns can be scrolled from the keyboard (#367). Each scrolling table is a named region you can Tab to, as Admin → Usage, Users and Audit already were, so it can be read on a narrow screen without a mouse.
    • [webapp] Links inside a sentence are underlined where axe found them told apart by colour alone, and Admin → Usage's tables scroll from the keyboard (#367).
      The links are on About this server, Settings → Ownership and the wiki's Rules page (WCAG 1.4.1). The three tables on Admin → Usage can now be reached and scrolled from the keyboard on a narrow screen. The journeys' axe scan now also covers every campaign, wiki, session, profile and admin page no journey reached.
    • [webapp] Links in running text are underlined everywhere, and map markers and stand-alone text buttons are easier to press. One rule in the design system underlines a link inside a paragraph, a hint, a banner or rendered markdown (wiki articles, summaries, Help) in the ink variant of the accent; menus, lists, navigation, cards, the app bar, the footer and buttons are left alone. A map marker keeps its drawn size but its pressable area is 24 by 24 pixels (44 by 44 on touch) around the same point, and where two markers are closer together than that a press goes to the nearer one; a text button that stands alone ("Show more", "Mark all read") is at least that tall; one inside a sentence stays on its line. A marker that has keyboard focus now opens with Enter or Space, which it did not before, and keeps the focus once it is open. The accessibility statement no longer says map markers are small; it says what is left, that two markers drawn very close together overlap where they can be pressed, and how to reach each (D277, D278, #367).
    • [webapp] A card's notes for tonight are no longer lost when the card
      closes mid-sentence.
      The notes save a second and a half after you stop
      typing. Closing the opened card, or the stage moving to another card, inside
      that moment threw away what had just been typed. A save still waiting is now
      sent as the field goes (#536).
    • [webapp] A character can no longer be given a name that another character
      is "heard as".
      Listing "Wyatt" as a misspelling of Idani and then naming a
      second character Wyatt was accepted, and from then on that misspelling
      silently stopped being corrected in transcripts, with nothing to say why.
      Creating or renaming a character, finishing a sheet, or assigning an imported
      member to a name like that is now refused, saying whose misspelling it is. A
      campaign already in that state is unaffected: no transcript was ever
      rewritten wrongly, and its characters can still be edited (#634).
    • [webapp] Shortening how long transcripts are kept no longer destroys them
      on the next nightly run (#680).
      A transcript past its campaign's window
      now goes to a bin first, as a recording does, and is deleted for good only
      once the bin's grace has passed — the same number of days the bin keeps
      audio, never fewer than seven. While it is in the bin nobody can read or
      search it, it is in no export and is sent to no model; the session's summary
      and highlights are untouched. The campaign's GMs are told three days before
      it is deleted (one notification when a session's recording and transcript
      are due together; nothing new is posted to Discord), and a GM can restore it
      from the session's Review tab. Lengthening the window again lets every
      transcript it no longer reaches back out of the bin on the next run, so a
      window shortened by mistake is undone by putting it back. Erasing a member
      still reaches a transcript in the bin. Settings → Recording, retention & AI
      now says where the transcripts go and for how long, instead of "There is no
      bin behind it". Upgrading runs one migration
      (sessions.transcript_trashed_at, sessions.transcript_deletion_warned_at);
      a transcript the old sweep already deleted is not brought back by it. See
      docs/OPERATIONS.md → "How retention removes recordings and transcripts".
    • [webapp] A player's Handouts page no longer breaks when a handout was shown to her with no file of its own; it is listed as a page.
    • [webapp] The party page's character links are underlined, not told apart from the text around them by colour alone (WCAG 1.4.1).
    • [webapp] Maps are cut into tiles. The worker mounted the media volume
      read-only, so every map's tiling failed on every install. It is writable
      now; a customised docker-compose.override.yml that redefines the worker's
      volumes must drop :ro from media:/app/media. A failed map now says why in
      words rather than showing the server's error and file paths.
    • [webapp] A party's trail stays in its own campaign. Its routes did not
      check the party's campaign, so a member of one campaign could read another
      campaign's trail by its id, and a GM could add stops to it. A stop on a map a
      player can't see no longer tells her where it is.
    • [webapp] Creating an atlas, a map or a marker no longer fails with a
      server error.
    • [webapp] The first person to sign in to a new server is its owner as well as its administrator, so Admin's "Run by" line and hand-the-server-on work from the first day. The J1 journey now walks Admin's leftovers, Backup and the not-an-administrator stop in all three projects.
    • [webapp] Fixed the Notifications grid's narrow-screen note always saying "always comes to the bell" even for the star-and-wish row, which shows on Home instead — it now names any such exception.
    • [webapp] A mail app's own Unsubscribe button now stops the emails it is on.
      The List-Unsubscribe header named the "Stop these emails" page, which answers
      the one-click POST (RFC 8058) with a 405, so pressing Unsubscribe in Gmail or
      Apple Mail stopped nothing. It now names POST /api/email/stop/one-click; opening
      that address instead shows the page, which asks first.
    • [webapp] A sheet no longer says "Someone else changed this sheet" about
      your own save (#737).
      Leaving a sheet, or a character being started, in
      the moment its changes were being saved kept a copy of them on the device;
      coming back, that copy was sent again and refused as a change made
      elsewhere. What the server already holds is now let go of, and only what is
      really still unsaved is put back.
    • [webapp] A character who has left the party stops collecting its XP
      (#737).
      A retired character's sheet went on counting every award to the
      party, and said a level was waiting. Their own awards are still theirs.
    • [webapp] Each "Choose from the loaded content" on Start a character says
      what it chooses (#737).
      The Ancestry step had three buttons with one
      name; a screen reader now hears which is the ancestry's, the heritage's
      and the ancestry feat's.
    • [webapp] A failed recording's retention clock no longer restarts when its
      session is written to (#798).
      A session whose processing only ever failed
      counted its audio retention from updated_at, which moves on any write to
      the row, so erasing a member from it (the erasure note) or editing its title
      kept the audio for another full window. It now counts from the new
      write-once sessions.audio_received_at, stamped when the bot's audio is
      first accepted. Migration a798c0d1e2f3 adds the column and backfills
      existing failed runs from updated_at, so nothing is caught earlier or
      later on upgrade day. The campaign export and import carry the new field.
    • [webapp] A campaign's export now carries its prep sheets, story arcs and
      the shelf, and an import puts them back (#830).
      A GM who exported a
      campaign to move it, or to keep a copy, lost their prep and their table
      setup. The file (schema 15) now holds each session's sheet, beats, plan and
      items (For later included), the arcs, and the shelf's scenes, pins, cards and
      reveals, each section marked as the GMs'. A pin rides the later sheets and
      shelves as it did, and a card taken off a shelf stays off it. A reveal comes
      back no wider than it was shown (one for named people becomes the GMs'), and
      one taken back comes back taken back. Discord message ids and Workbench runs
      do not travel: a generated or a rules card reads from what it kept. Older
      files import as before. See docs/API.md.
    • [webapp] A paused or unreachable Redis no longer hangs the request that
      touches it, and notify() stops trying recipients once the broker is down.

      Every async and sync Redis client the app builds now has a 2 s connect timeout and a 10 s
      reply timeout (the Celery side already had its own), built in one place,
      app/redis_pool.py; the one client that waits on pub/sub (the live
      recording-status stream) keeps no read timeout. A dead broker used to cost one
      publish timeout per recipient; the first connection failure now skips the
      rest of that batch (the in-app notifications are still written). (#841)
    • [webapp] Notification emails are sent again (#865, #736). The delivery task
      read its SMTP settings on a second event loop inside its own, which cannot start,
      so every per-person notification email since #655 was logged as "Failed to load
      SMTP config" and dropped — including, once lane 10 moved them onto that path, a
      group without Discord's time votes and confirmations (D153). The task now reads
      the settings in its own session and sends off the loop.
    • [webapp] A phone's Contents no longer names Admin twice. With Home's
      new "This instance" group on screen (lane 9 page 4a), the account rows
      below it dropped their own Admin link rather than repeat the door under
      the same name.
    • [repo] Two backend tests stop leaking rows into later test files (#869).
      test_recap_email deletes the SMTP config row rather than nulling it, so it no
      longer breaks test_admin's test-email tests when it runs first, and
      test_backup_offsite cleans up the BackupLog rows its task tests commit.
    • [webapp] The Review checklist's "Recap email" row is truthful before approval (#871,
      D162). Since the recap now fires on approval rather than at processing time, the row had
      no record to read yet and showed a bare "No record". It now reads a server-computed
      forecast — "To four people, once you approve" when the campaign has recaps on and someone
      would get one, or "Skipped" with why (recaps off, or nobody on the attendance list has
      opted in) — the same check the send itself makes, made read-only.
    • [webapp] Admin → Campaigns lists the campaigns in the bin, dimmed, with the day
      each goes for good, as the drawing does.
      The page never asked the server for
      them. GET /api/admin/campaigns gains purge_at. Its Hand it on… dialog also
      hands on to the member it shows selected, where it sent an empty choice unless
      the list was touched first. A server with no campaigns says so (#872).
    • [webapp] Admin → Campaigns' "Keeps its own" column says what a campaign keeps
      ("Audio 90 days", "Transcripts for good") instead of always "Its own settings",
      and a failed recording's error no longer ends in ".”." (#872).
    • [webapp] Admin's Audit, Users and Campaigns tables scroll sideways on a phone
      instead of wrapping every cell over several lines.
      Each table has a width of
      its own, dates and short cells stay on one line, and the scrolling box is
      named and reachable by keyboard (#872).
    • The GM who confirms a session, books one directly or moves it is no longer sent a bell row and an email about it; the rest of the table still is (#887).
    • [webapp] A page's picture is as private as the page (#894). Pictures were stored publicly, so a GM-only page's picture could be opened by anyone with its address. They are stored privately now and served only to people who can read the page. A picture uploaded before this moves to the private store the first time it is shown, and its old address no longer works. The backend's own media mount (used where nginx isn't in front of it) no longer serves private files either.
    • [webapp] A player's wiki save can no longer rename an item that isn't
      identified yet (#912, IS-122). A player only ever reads the party's name
      for such an item, never its real title, so PATCH .../lore/{id} now keeps
      the real title silently if she sends back what she was shown, and refuses
      the save with a 403 if she sends anything else — a GM's rename still works.
    • [repo] Fixed every upload failing on a fresh install: the backend image now creates /app/media owned by its own user, so a new media volume is writable (#914).
    • Choosing people to show something to, then clicking the next control in the dialog (such as "Send it to them on Discord"), now does both; the first click is no longer lost (#918).
    • A session page no longer flips back to what it said before a GM's action (Withdraw, say) when a background refresh that left earlier lands late (#932).
    • A slow answer can no longer overwrite a newer one anywhere the app re-reads while it waits: the Review tab, the vote results fold, and the Try again buttons (#941).
    • [webapp] A raw DELETE FROM campaigns no longer fails when a quest
      objective was done in one of the campaign's sessions (#943).
      The objective
      was reached by a cascade through its quest and a SET NULL through the
      session in one statement; its quest foreign key is now deferred to commit.
      The demo seed's delete-order workaround for it is gone.
    • [webapp] The demo seed's played sessions load again (#978). The seed wrote each played session's characters into canonical_names, which holds the name generator's picks, so every one of them failed with "This session could not be loaded"; a test now builds every seeded session's page as its GM and as a player.
    • [webapp] One star, one wish asks about every campaign that played, at
      once.
      A player in two campaigns that both played last night was asked
      about one, and the other appeared only after answering and reloading,
      which read as the same question twice. Home now shows one card per
      campaign, each naming its campaign when there is more than one, and
      GET /api/me/star-wish returns prompts (with prompt kept as the
      first of them) (#980).
    • [webapp] Campaign Settings → Recording's "Test the bot" box has room inside
      its border.
      Its text and buttons ran right up against the edge; the box now
      has the same inner padding as the AI panel above it.
    • [webapp] members.md in a campaign's export no longer prints a
      character's pronouns as if they were the player's (#991).
      The line read
      "Marcus (Player) — Wren Halloway (ey/em)", and for a member with no
      character name the pronouns stood beside the player's name alone. The
      character is on a line of its own now, "Character: Wren Halloway (the
      character's pronouns: ey/em)". A player's own pronouns were never in the
      archive and still are not.
    • [webapp] The models that write about people are now told their pronouns, so a saved recap no longer has to guess. The session summary, its event log and key moments, the wiki suggestions, /ask, the wiki's draft tools and one-line page summaries, and the GM's prep tools each get the pronouns of the people they can name, and only of those who have set some: players' (from their account), their characters', and those on wiki pages the table can read. A player's and their character's are sent as two separately labelled lists and never stand in for each other, and the model is told to use a name or singular they for anyone not listed rather than guess. Nothing is copied: they are read each time a prompt is built, so clearing them, leaving a campaign or deleting an account takes them out of every later prompt. Pronouns are personal data and now go to the configured model along with the names and transcript that already did; /privacy and docs/OPERATIONS.md ("What the LLM is sent") say so. (#999, finishes #653)
    • [webapp] A damaged image is refused with a message instead of "Internal server error".
      Uploading a picture whose file was cut short or whose image data was broken, as a map, a page's picture or an attached file, passed the checks on its header and then failed while Quest Board re-saved it, so the upload answered with a 500 and no reason. It now answers "This image is damaged or incomplete, so it could not be read." and stores nothing. Sound images are unaffected.
    • [webapp] A tap on a phone no longer goes missing while the page is still
      scrolling.
      The page's scroller had scroll-behavior: smooth, which
      animated every scroll — the browser's own scroll to whatever took focus, a
      restored position, the scroll to a button about to be pressed — and a tap's
      click, which a phone sends a moment after the finger lifts, landed on
      whatever had slid under it by then. It was J5's intermittent phone-only
      failure (Strike, Reword and "Accurate" not taking). Scrolls are now
      immediate; a jump you ask for (a Contents entry, an event's "at 1:09:12")
      still glides, unless your system asks for reduced motion.
    • [repo] A CI run of the journeys removes the images it built. Each run names its compose project after the run, so its images were never reused, and nothing removed them: about ten a project, three projects a run, the Playwright one near 4 GB. They filled the runners' disks, and jobs failed with "no space left on device". The journeys script now takes QB_E2E_RM_IMAGES=1, which CI sets, and removes them when the stack comes down; a local run keeps its images, since its project name is fixed and the next run reuses them.
    • A scheduling test no longer fails on Tuesdays: it put a session past the 14-day RSVP horizon on that one weekday.
    • [webapp] The web fonts are back. No production build had shipped a
      font file since the design system moved to self-hosted fontsource faces, so
      every page rendered in the browser's fallback sans-serif: Tailwind's PostCSS
      pass lost the path Vite needed to find the font files, and Vite only warns
      about that. The faces are now imported from JavaScript, and Vite emits them
      with hashed names (none inlined, which the CSP would refuse). npm run build
      now fails if any url() in the built CSS points at a file that is not there,
      and nginx answers a missing /assets/ file with a 404 instead of the app's
      index.html (#891).
    • The wiki no longer names the demo campaign to everyone: its front page, a group and Recent changes said "the Amber Coast" and "Dana" in every campaign. They say "the GMs" now (D174).
    • A wiki change reads "Dana started it", not "Dana new page it", on the front page and in Recent changes.

    Security

    • [webapp] A small picture that declares a huge size can no longer end the
      backend (#1074).
      Checking an uploaded picture means decoding it, and
      decoding takes memory by the picture's pixels, not by the size of its file.
      Nothing bounded the pixels: a 450 KB PNG of 12000×12000 in one flat colour,
      well inside the 8 MB cap, took the backend past its 512 MB, so anyone
      signed in who could add a picture to a page could stop the server for
      everyone. Every picture's pixels are now read from its header and one over
      the bound is refused, with a sentence that says its size and the largest
      taken, before anything is decoded: a page's picture, an image attachment
      and a picture fetched from a web address at 4080×3072 (about 12.5
      million pixels, over all its frames)
      , which takes a phone's 12 MP
      photograph, and a map at 8192×6144. The same bound now applies to an
      import, which was at 4000×3000 and refused an iPhone's 4032×3024. The
      check also takes about half the memory it did (a 4032×3024 JPEG took 95 MB
      to check and now takes 53 MB), and the backend checks one picture at a
      time. What changes for a picture that is within the bound: a WebP is
      stored as a JPEG, or as a PNG if it has transparency or was stored without
      loss, because encoding a WebP took up to nine times the memory of the
      picture itself; and two kinds count for more than their pixels, because
      they cost more to check: a WebP four times (the largest taken is 3.13
      million pixels, 2040×1536), a CMYK JPEG twice, and a progressive JPEG twice
      (so a page picture may be 6.27 million pixels as a progressive JPEG against
      12.53 million as a baseline; a map, decoded in the roomier worker, is not
      weighted). An animated GIF is
      counted by what it costs to write again, six times one frame and a quarter
      of all its frames, so that an ordinary one of many small frames is taken
      (480×270 may have 362 frames) and one of a few very large frames is not;
      it may have 1,000 frames, and is refused if it would be stored at more
      than twice the size cap. A GIF whose later frames reach past its own screen
      (which made the image library allocate at the larger size), and a JPEG with
      more than 32 scans (slow however few its pixels), are refused before any
      decode. An upload over the size cap is now refused before it is read into
      memory, and the picture decode runs off the event loop so one upload's wait
      never freezes the process for every other request. Every scrap of
      metadata is stripped on re-encode, on every path
      — EXIF including a phone
      photo's GPS, XMP, ICC profiles, JPEG and GIF comments, PNG text — so a
      player's photograph does not carry its location into the campaign.
      A map's picture is checked by the worker after the upload has
      answered
      , as an imported map's already was: a legitimate 70 MB map took
      more memory to check than the backend has. The map's page waits and shows
      the picture when it has passed, or says why it could not be used; a map
      that already had a picture keeps it until the new one has passed. API:
      POST /api/campaigns/{id}/maps/{map_id}/image now answers with the map
      "cutting" and without its new picture; see docs/API.md, "Picture uploads:
      what is taken". No migration, and no change to the bot. Self-hosters:
      the worker must be running for an uploaded map to get its picture.
    • [webapp] A picture's metadata can no longer exhaust the server's memory when it is opened (#1087). Opening a picture made the image library parse its metadata in full, and how much work that was is the file's to choose, not the picture's: a 70 kB JPEG whose EXIF directory pointed every entry at the same six megabytes took the open alone to +171 MB, and an APP2 MPF directory to +1.6 GB — before the pixel-count check that guards decoding, and outside the one-picture lock, for anyone who could upload a picture. Every scrap of metadata (EXIF, MPF, ICC, Photoshop, XMP and comment segments in a JPEG; text, ICC and EXIF chunks in a PNG; EXIF, XMP and ICC chunks in a WebP; comment, plain-text and application extensions in a GIF) is now removed from the bytes before the library is handed them, so there is nothing to parse whatever the file claims. Two fixed, bounded JPEG segments are kept because the decoder needs them, not for their metadata: the JFIF APP0, and an Adobe APP14, whose transform byte tells YCCK from CMYK and RGB from YCbCr — dropping it would store a Photoshop CMYK photo with the wrong colours. A file built from a vast number of empty segments is refused in a sentence rather than walked to the end; a picture's own image data (a PNG's or WebP's data chunks, which an honest large picture has thousands of) is not counted towards that limit. Honest files — a phone photo with a thumbnail and a maker note, an HDR/MPO, an edited photo with a large XMP block and an ICC profile — are taken as before, and the EXIF orientation (#1079) is still applied, read from the metadata as it is dropped.
    • [webapp] A member's email address is now sent only to the campaign's GMs and to that member; other players no longer receive it in the members list (#677).
    • [webapp] The add-member and attach-to-an-account search needs two letters
      before it finds anyone (#702).
      One letter, or an empty query, used to list a
      slice of every account on the instance whichever scope was set; it now answers
      nothing, and the page does not ask until two letters are typed. % and _
      in a search are plain characters rather than wildcards.
    • [webapp] An unidentified item's true name no longer reaches players
      outside the wiki (#877).
      A page with a name the party knows it by ("A
      tarnished silver ring") showed that name on every wiki read, but not
      everywhere a page is named. A player could read the real one in the
      page's History, in the bell line, email and Discord post for a handout
      shown to them, in a reveal's own title, in "another page is already
      called that" when they guessed it, in a campaign timeline row with no
      words of its own, in the quest log, the bot's /quests, the pre-session
      reminder's open threads and the session page's open loops, in "Add to a
      sheet", in the name a new character sheet starts with, in "… reached
      level 7" and in a rest's "… has no hit dice". The wiki's list was also
      sorted by the real name. Each now uses the name the reader knows; a GM
      still reads the real one in their own views, and words written once for
      everyone (a reveal, a Discord post, a milestone) carry the party's name.
      A new test lists every read of a title in the server and fails on one
      nobody has classified (tests/wiki/test_title_readers.py).
    • pypdf, which reads the page count of uploaded handout PDFs, is on 6.19.0 rather than 5.1.0, clearing the parser advisories pip-audit reports against it (#930).
    • [bot] pyjwt 2.13.0 → 2.14.0, clearing ten advisories (CVE-2026-101917, CVE-2026-102265 to 102274) against the version the bot pulled in through redis.
    • [frontend] Dev tooling: brace-expansion and fast-uri patch bumps, clearing four advisories (GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p, GHSA-hrr3-gc8f-f4qj) in build-time dependencies; nothing shipped to the browser changes.
    • [webapp] A wiki page in the bin is no longer in a campaign's export, so
      an import can no longer bring it back live (#990).
      The export wrote a
      binned page like any other, and the import has no way to put a page in the
      bin, so it came back as a live page with the audience it last had: a player
      of the new campaign could read what the GM had taken away. A soft-deleted
      row now stays behind, with everything in the file that hangs off it — the
      page's Markdown file and picture, its aliases, owners, name targets and
      relationships, any reveal of it, and the wiki cards, pins and prep items
      that carry a copy of it. A row that pointed at it keeps its own words and
      loses the link. The import also skips any row a file marks as deleted
      rather than importing it live. Nothing that is only ended is left out: a
      cancelled session, a hidden post, a reveal taken back and a card taken off
      a shelf travel as before. An archive made before this release may still
      contain binned pages, which import live — export the campaign again
      instead of importing an old file.
      See docs/API.md.
    • [webapp] The draft tools (rephrase, expand backstory, promote, stat block, merge) and the relationship-proposal prompt now show the model an unidentified item by the name the table knows it by, so a model can no longer write its true name into text meant for the table. What a GM reviews and what an approval writes back are unchanged, and an approved proposal never renames the page to its player title. A proposal about an unidentified item now finds its page by the table's name instead of arriving as a new page (two items sharing a player title are not guessed between). (#993, follows #877)
    • [bot] pyjwt 2.14.0 → 2.15.0, clearing CVE-2026-101918 against the version the bot pulls in through redis; and [webapp] serialize-javascript 7.1.1 → 7.1.2 in the frontend's lockfile (GHSA-gfhx-hw2g-v5hg), a build-time dependency that is not shipped (#995).
    Downloads