# M8 — Browser UX Completion for v1 Story Operations **Final implementation, verification and closeout report.** Written by the implementer for an independent reviewer, and completed at closeout after that review returned **M8 IMPLEMENTATION: PASS**. The closeout additions are the build-evidence classification in §P, finding 14's resolution, and the acceptance record in §V; no verification figure was changed by them. **Closeout date: 2026-09-06.** > **Placeholders.** `inference.lan` stands for the trusted-LAN Ollama host and > `192.168.0.x` for LAN addresses, following the convention M1 established. No > real hostname, address or identifier of the machine this was built on appears > in any committed file. --- ## A. Executive result **PASS** Every M8-owned acceptance condition was demonstrated through the browser workflow against a real local narrator, on one frozen production build. No required condition is unverified, and there are no blockers to acceptance. **Two qualifications a reviewer should weigh, neither of them a blocker:** 1. **Seven product defects were found during verification, four of them by driving the application rather than reading it** (§S findings 1-4, 7). Two made the interface state something untrue to the reader. All are fixed with regression coverage, and two of the regressions were verified to fail against the unfixed code before the fix was kept. But their existence says the implementation pass was not as verified as it looked at the time. 2. **The evidence harness needed five corrections before it could be trusted** (§S finding 12), and three evidence runs were invalidated by process error (§S finding 13). Two of those harness defects were actively masking product defects. The figures below are from the corrected harness on the final build; the history is recorded so a reviewer can judge how much they rest on. **M8 is implemented, verified, reviewed and accepted.** The independent review returned `M8 IMPLEMENTATION: PASS` subject to evidence and documentation cleanup; that cleanup is §P's build classification and finding 14's resolution, both complete. M8 is **not yet committed**: the tree is staged for the repository owner's signature, which is the only step left before M9. ### What this milestone is, in one paragraph M8 turned an adapted AI-DnD interface into the interactive-story workspace `BROWSER-UX-SPEC.md` describes: one entry point, one story screen, one natural-language input, and everything advanced one layer deeper behind panels that start closed. Almost nothing underneath changed — the play loop, the history controls, the takes, the Save Points, the state engine and the knowledge library are M3-M7's and are untouched. What changed is what a reader is shown and asked to understand. ### Evidence at a glance | Gate | Result | | --- | --- | | Backend suite | **950 passed, 14 skipped, 0 failed** | | Frontend component suite | **132 passed, 10 files** | | Lint | exit 0 | | Production build | clean | | Docker build | clean | | Browser acceptance (6 scenarios, B01-B04, D01-D14) | **57/57** | | Hidden-information sentinel | **21/21** | | A05 failed generation | **23/23** | | Security / offline / performance | **27/27** | | Genuine process restart | **12/12** | | Migration against an M7-built database | **17/17** | | Reader-facing terminology audit | **0 hits** (17 normal-play components; both advanced surfaces) | Every browser figure is from one frozen production build, **`dist/assets/index-Ii-lARp9.js`**, built from the staged tree at 18:53:02 and unchanged for the rest of the campaign. An earlier artifact, `index-C6E5Uvtu.js`, is **superseded**: it predates finding 7's fix, its acceptance suite ended 54/55 on that defect, and no figure above comes from it. §P sets the two side by side with the evidence for the classification. ### What a reviewer should look at first 1. **§S findings 1, 2, 3 and 7** — four product defects that the browser work found and that no earlier milestone had caught. Two of them made the interface state something untrue to the reader. 2. **§S findings 12 and 13** — the harness needed five corrections before it could be trusted, and three evidence runs were invalidated by process error. This bears directly on how much weight the figures above deserve. 3. **§F** — the `canon_rules` decision, where the smallest correction was a surface constraint rather than an audit subsystem, and why. 4. **§T** — the one requirement whose wording M8 changed (§38), and the argument that it was tightened rather than weakened. --- ## B. Repository and provenance state | | | | --- | --- | | Branch | `m8-browser-ux` | | M8 base | `480414efe082a4bfe0600a19fe23961f6bddd925` — M7 | | M7 signature | **Good signature**, verified against the repository owner’s RSA key `02C9BF7D…B5C68569`, ultimate trust | | M7's parent | `a6e9c7a32bdf42f1e4cb837b70721d89422cef8d` (M6) — sole parent | | Current HEAD | `480414e…` — **still M7. M8 has made no commit.** | | Staged | **89 files** — 34 added, 24 deleted, 30 modified, 1 renamed (`git diff --cached --stat`) | | Unstaged | 0 | | Untracked | 0 | | `LICENSE` | unchanged, absent from the staged diff | | Upstream ancestry | `d72f7c1b…` (AI-DnD) is still an ancestor | ### Committed HEAD vs staged tree vs tested tree These are three different things and the distinction matters for review: - **Committed HEAD** is M7, untouched. Nothing in M8 has been committed. - **The staged tree** is the whole M8 result — 89 files, including this report and the planning corrections. - **The tested tree** is the staged tree. The frontend was built from it once and frozen; every browser figure in this report ran against that one artifact, **`dist/assets/index-Ii-lARp9.js`** (built 18:53:02, and still the only file in `frontend/dist/assets/`), and no source was edited between the freeze and the last suite. An earlier artifact, `index-C6E5Uvtu.js`, appears in the evidence directory and is **superseded, not final**: it predates finding 7's fix, its acceptance suite ended 54/55 on exactly that defect, and no figure in this report comes from it. §P records the classification and the evidence for it. That last point is stated because it was broken three times during M8 and each time the run was discarded rather than reported. See findings 11 and 13. **M8 is implemented, verified, reviewed and accepted** (2026-09-06). What is outstanding is the owner's signed commit, not a decision. --- ## C. The M7 browser baseline, and how it directed the work Measured **before any code was changed**, by driving the M7 build in a real Firefox against a real backend with the Continuity Test fixture and two real turns played (`scratchpad/m8/baseline.py`, 13 observations). Nothing here is recalled; each figure is a browser observation. | Observation | Measured | What it directed | | --- | --- | --- | | **Context inspector size** | **11,996 characters** on opening, beginning with the assembled prompt | §55 says do not begin with raw prompt text. Drove the whole restructure: four readable sections first, the prompt last and collapsed. Result ~1,400 chars (§R). | | **Unlabelled controls** | **16 of 16** per-message buttons had no accessible name — single glyphs `🔍 ✎ ⑂ ✕` with only a `title` | Drove named controls throughout, and the `a11y.test.jsx` assertion that a name under three characters counts as a glyph. | | **Branch UI visible in normal play** | panel tabs read `Story State · Plot · Memory · Knowledge · **Branches** · Save Points · Insights` | §27. Drove removing the branch panel and tree overlay from the browser, and the terminology audit (§M). | | **Rigid input modes** | `Do` / `Say` / `Story` | §12. Drove one natural-language field plus the direction toggle — and, indirectly, exposed the `> You I …` defect (finding 4). | | **No model status anywhere** | play header read `Continuity Test / Story State / Plot / …` and said nothing about Ollama | §44 and §8. Drove the five-state badge and the setup notice. | | **Navigation** | `Home · Adventures · Scenarios · Settings · AI Chat` | §97. Drove `Campaigns · Settings` with everything else inside a campaign. | | **Landing page vocabulary** | "The table is set", "worlds to explore", a scenario gallery | §26. Drove the campaign library. | | **Settings** | `Model` a free-text box; a raw request/response log un-collapsed at the bottom | §8 and §72. Drove the model picker and the folded diagnostics. | | **Campaign creation** | required choosing a Scenario; its editor held a JSON stat schema, a story-card table and an art picker | §41-42 and §93. Drove the one-screen setup form and the `opening` field. | | **Frontend test coverage** | none at all; two backend tests read JSX as text to approximate browser assertions | §30. Drove the whole test foundation (§N). | ### What the baseline showed was already right Worth recording, because it bounded the work: the play loop, streaming, Undo, Redo, Retry, alternate takes, Save Points, state correction, knowledge import and prompt inspection were all reachable and all correct. The Save Point panel's confirmations already said the right things. The State panel already avoided raw JSON. Undo and Redo already used the server's answer rather than deriving availability in React. M8 changed **what a reader is shown and asked to understand**, not the machinery. The two exceptions are findings 1 and 7, where the presentation layer turned out to be calling the wrong mechanism — and both were caught by driving the product, not by reading it. --- ## D. M8 change inventory 89 files — 34 added, 24 deleted, 30 modified and 1 renamed. The frontend was substantially rebuilt; the backend was touched in five places, each to expose a browser capability that could not otherwise be reached. ### Browser presentation — added | File | What it is | | --- | --- | | `pages/Campaigns.jsx` | the campaign library — the landing page | | `pages/NewCampaign.jsx` | genre-neutral setup, one screen | | `pages/Play/Transcript.jsx` | the story, extracted from the page component | | `pages/Play/Composer.jsx` | one input, direction toggle, history controls, reserved dictation | | `pages/Play/FailureNotice.jsx` | §71's five failure kinds | | `pages/Play/SidePanel.jsx` | the secondary panel and its tabs | | `pages/Play/panels/ContextPanel.jsx` | the context inspector, restructured | | `pages/Play/panels/CampaignSettingsPanel.jsx` | campaign settings, canon, export, delete | | `Dialog.jsx` | accessible modal with a real focus trap | | `ExternalLinkDialog.jsx` | §74's leaving-the-local-environment warning | | `ModelStatusBadge.jsx`, `ModelSetupNotice.jsx` | §44 status, and §8's way out of a blank model | | `markdown.jsx` | safe Markdown for story prose | | `errors.js` | the failure taxonomy | | `modelStatus.jsx` | one shared model-status answer, fetched once | | `styles/story.css`, `library.css`, `context.css`, `dialogs.css` | the new surfaces | ### Browser presentation — removed Sixteen components and eight stylesheets, each recorded with its reason in the file that replaced it. | Removed | Why | | --- | --- | | `pages/Home.jsx`, `pages/Adventures.jsx` | two screens listing the same rows; one library replaces both | | `pages/Scenarios.jsx`, `pages/ScenarioEditor.jsx`, `SchemaEditor.jsx`, `ArtPicker.jsx` | a campaign no longer needs a template, and the editor was §93's "dangerous advanced features" almost exactly | | `pages/Chat.jsx` | a raw model console in the primary navigation | | `panels/BranchPanel.jsx`, `BranchMap.jsx`, `branches.js` | §27 — branch management is not a v1 surface | | `panels/PlotPanel.jsx`, `RefreshModal.jsx` | AI Dungeon world-info editing; the M7 knowledge library supersedes it | | `panels/MemoryPanel.jsx` | memory is now shown where it is used, in the context inspector | | `panels/InsightsPanel.jsx` | renamed and restructured as `ContextPanel.jsx` | | `drawers/WorldStateDrawer.jsx` | the RPG stat editor (§18, §26) | | `Embers.jsx` | decorative fire; §40 | | 8 stylesheets | the surfaces they styled; `auth.css` reduced to `debuglog.css` | **No backend capability was removed.** The tree, branch switching, story cards and the RPG world state all still exist, are still tested, and still travel in the bundle. ### Backend support — five files, no schema change | Change | Why the existing API could not serve the UX | | --- | --- | | `AdventureCreate.opening` | a `start` action could only come from a Scenario's prompt, so every campaign made in the new setup flow opened on a blank page | | `canon_rules` on create, update and read | `campaign_canon` has been read by the prompt builder and the state validator since M5 and had **no API at all** — a fixture had to write it with SQL | | `format_player_input` | finding 4 | | `_friendly_http_error` 401/429 | the last user-facing text describing a hosted deployment | | `models.Adventure.canon_rules` | a read-only property. **No column, no migration.** | ### Test infrastructure Vitest + jsdom + Testing Library (5 devDependencies, no runtime dependency); ten frontend test files; two new backend test files (`test_m8_setup_surface.py`, and the normalization tests in `test_take_parentage.py`); two inherited source-level guards retargeted or replaced — see §N. ### Documentation `README.md`, `DEVELOPMENT.md`, and six planning documents — itemised in §T. --- ## E. The final user experience, in user terms A person opens the application and sees **their campaigns** — titles, when each was last played, how many moments it holds, and the opening of its most recent narration. No ids. Two buttons: import one, or start a new one. Starting one asks for a **name**, and nothing else is required. If they want to, they can say the genre and tone (free text, with suggestions spanning fantasy, science fiction, mystery, historical, western, horror, thriller and literary), choose a voice and a narration length, name a protagonist, write the opening scene, and write the rules the story must not contradict. Then Start. Then they are in the story, and the story is the whole screen. A thin bar at the top carries the campaign's name, whether Ollama is connected and with which model, and five tabs that are all closed. Underneath, prose at a readable measure — the reader's own turns indented and italic, the narrator's plain, an ornament between scenes, a drop cap on the opening. They type what they do into one box. Not a mode, not a command — *"I walk into the Crooked Lantern and look for Mara."* The reply streams in. If they want the narrator to do something rather than their character, they tick **Story direction** and the box says so. Above the box: Continue, Retry, Undo, Redo, Save Point. Undo and Redo grey out when there is nowhere to go. Hovering a turn reveals Inspect context, Edit, Try again, Copy — named, not glyphs. When something is wrong they are told which thing: the model is unreachable and here is how to start it; the turn failed and here is Retry, with their words still in the box; the story state could not be updated and the story itself is fine. When they want to know **why the narrator said that**, one click on the turn opens what it was given: what it cost, what it read, what it remembered, what it believes. If a passage was marked narrator-only, its text is not there — a control offers it, and says it will reveal secrets they marked for the narrator alone. They can play for an hour without meeting the word *branch*. --- ## F. Campaign setup, opening, and canon ### The `opening` field A `start` action could previously only come from a Scenario's prompt. M8's setup flow creates a campaign from a form, so without this every new campaign opened on a blank page — the reader had to invent the situation *and* the first move in one box. It builds the same node by the same path (`attempts.snapshot_outcome` → `tree.place_action`). Verified, 13 checks, now permanent in `backend/tests/test_m8_setup_surface.py`: | | | | --- | --- | | Setup can provide it, as a `start` action | PASS | | Never duplicated on re-read | PASS | | Placed at depth 0 on the tree, with a parent of `None` | PASS | | The head is at it; Undo past the opening is refused (HTTP 400) | PASS | | A blank opening still yields an empty campaign | PASS | | A Scenario's prompt takes precedence, and the two are never both inserted | PASS | | A scenario-made campaign is unchanged by the new field | PASS | | Survives export and import | PASS | One observation, **not** an M8 regression: the create response reports `action_count: 0` while carrying one action, because the count is computed on read. A scenario-made campaign has always done the same. The browser navigates and re-reads, so a reader never sees it; the test asserts on the read path. ### `canon_rules`, and what editing canon after play actually does `campaign_canon` has been the highest authority in a campaign since M5 — read by both the prompt builder and the state validator — and had **no API at all**. A fixture had to write it with SQL. M8 exposed the sentence list. That raised a question the column had never had to answer, and §6 of the review brief asked for it to be settled rather than assumed. It was **measured** against a real server with real turns (`scratchpad/m8/canon_provenance.py`): | Question | Answer | | --- | --- | | Does a historical turn keep the canon it was actually told? | **Yes** — the canon section is in that turn's stored context snapshot. After the edit it still shows the old rule and not the new one. | | Does the next turn get the new canon? | Yes — which is the point of editing it. | | Is any accepted story text rewritten? | No — byte-identical. | | Is the narrative state document changed? | No — byte-identical. | | Is the state audit log changed? | No — byte-identical. | | **Is the edit itself audited?** | **No.** It is a configuration overwrite. | | Is any other campaign setting audited? | No — editing narrator instructions is not either. | ### Why M5's audit log was deliberately not extended M5's `StateEvent` log audits accepted changes to *narrative state*. Canon is not narrative state: it is configuration, sitting with `ai_instructions` and the narrator prompt. Routing a configuration change through that log would create a second representation of canon — the same duplication `BROWSER-UX-SPEC.md` §38 forbids for hidden information, arrived at from a different direction — and §6 of the brief explicitly rules out inventing a parallel audit subsystem. So the correction was made at the editing surface instead, which is the brief's second option. Once a campaign has moments, the canon editor states that the change applies **from here on**, that everything already written stays exactly as it is, and where the per-turn record can be seen. Three component tests cover it. The guarantee M8 offers is therefore not "the edit is logged" but **"the edit cannot be mistaken for a retroactive one, and every turn can prove what it was told"** — which is what the per-turn snapshot already delivered, unasked. --- ## G. The story composer ### One field, not three modes The inherited composer had a `Do` / `Say` / `Story` selector, and the mode changed what the turn *meant*: "I say to Mara…" typed in Do mode and the same words in Say mode were different turns, and nothing on screen said so. That is a small command language wearing buttons. M8 sends one natural-language field. B01 and B02 are the proof that it works — an action and a piece of quoted dialogue are both just what the reader wrote, and the narrator reads the quotes without being told which kind of turn it is. What survives from the old `story` mode is the **Story direction** toggle, and it is deliberately not a fourth mode: it changes *who is being spoken to*, not what kind of action is taken. The box is visibly marked while it is on, and the send button reads "Direct" rather than "Send". ### The `> You I enter the tavern.` defect **Severity: high. Found by driving the new composer in a browser; fixed here.** AI Dungeon stores a player action with a `> You ` prefix. That was right when the Do mode asked for a bare verb phrase — `look around` became `> You look around.` — and it is what marks whose turn it is in the replayed prompt. `BROWSER-UX-SPEC.md` §11 tells the reader to write *"I enter the tavern."* With one field, the prefix produced: ```text > You I enter the tavern. ``` in the transcript, in the replayed history, and therefore **in the narration**, where a small model imitates the pattern it is shown and writes "You I thank her". The M7 baseline transcript contains exactly that phrasing, so the defect predates M8 in the data — but M8's own design is what made it reachable on every turn, which is why M8 owns it. The correction adds the subject only when the reader has not already written one. The `>` marker — which is what actually identifies a player turn — is unchanged in every case. Regression coverage is against the **shared normalizer**, not one rendered component, because storage, the transcript, the replayed history and the export all read the result of that one function: - §11's three examples verbatim; - eight first-person phrasings, each asserted to contain no duplicate subject and to keep its marker; - the legacy bare action still normalizes (`open the door` → `> You open the door.`), which is deliberate compatibility; - an explicit `You …` is de-duplicated rather than doubled; - dialogue and out-of-character direction untouched; - and a second test asserts on the **assembled prompt**, that each player line appears exactly once and `"You I "` appears nowhere — because a normalizer that is correct but applied twice would put the defect straight back in front of the model. ### The storage marker is not shown, and not parsed `>` is also Markdown for a blockquote, so leaving the stored marker in made every player turn render as a quote *by accident*, stacking the renderer's rule on the one `.turn-player` already draws. The marker is stripped for display only; the stored text keeps it, and editing a turn puts it back around the edited words. ### The reserved dictation control Present, permanently disabled, named "Dictate — not yet available". No handler, no permission request, no microphone. A component test tabs twelve times to assert it never takes focus, and another installs a fake `getUserMedia` and clicks the disabled button to assert it is never called. --- ## H. History controls M3's active-head machinery, M4's Save Points and the take/divergence rules are unchanged. M8 changed how they are presented and what they are called. ### Undo and Redo Availability is **the server's answer**, carried on every window it returns, and the browser never computes it. Neither is derivable client-side: Undo can reach past the top of the loaded page, and Redo depends on a retained future the transcript is never sent. Six component tests cover the enabled states, including that each is independent and that every control is disabled while a turn is generating. ### Retry and takes Retry sits in the story controls and "Try again" on each narrator turn. When a turn has more than one attempt the pager appears as `‹ 2/3 ›`, reading "Take 2 of 3" to a screen reader — §14 asks for a count, and `2/3` is too terse to hear. **A defect here had existed since the pager was written.** See finding 1: every step between takes performed a branch switch, which for two takes of an ordinary retry meant switching to the line already being read — the same window came back and the step did nothing at all. D07 is REQUIRED FOR V1 and had never been exercised in a browser. Fixed, and confirmed PASS in the final run. ### Save Points M4's panel needed little. Create with a name and an optional note, list, restore, rename, delete. Both confirmations say what does **not** happen, because that is the part a reader cannot see and would otherwise assume the worst about: > The story will return to this Save Point. Everything you wrote after it is > kept — it just stops being where you are. > Delete this Save Point? Deleting it does not delete any of the story — only > the name you gave this moment. Counted in **moments**, not turns, matching the vocabulary M4's closeout settled. ### Vocabulary `branch`, `fork`, `node`, `merge`, `head` and `depth` appear nowhere a reader operates. The branch panel and the tree overlay are gone from the browser entirely. The mechanism underneath is untouched and still fully tested — this is a decision about what a reader is asked to understand, not a reduction of what the product can do. The audit method and its result are in §M. --- ## I. State The server groups the authoritative state and the panel renders the groups, so the headings are whatever the campaign has established rather than a fixed list — a campaign with no items shows no Items heading. - **No raw JSON.** Asserted: the panel contains no `
` and no `{`.
- **No RPG vocabulary of its own.** Asserted against `hp`, `mana`, `quest`,
  `stat`, `cooldown`, `xp`. The product term is **Story Threads**, never Quests.
- **Correction without JSON.** "Correct something" asks for a sentence — the one
  shape a person can write without knowing the event vocabulary — and it goes
  through the same validator a narration's proposal does. C04 was exercised in
  the browser end to end: the correction appears in the panel, reaches
  authoritative state, is recorded as `manual_correction`, and the next turn's
  assembled prompt contains it.
- **It follows the head.** Keyed on the same signal as the other live panels, so
  Undo, Redo and a Save Point restore all move it — what it shows is the state at
  the position being read, not at the newest turn.

### Hidden state

There is none, and M8 did not invent one. A secret lives in a narrator-only
knowledge source and never enters the state document; M7 verified that against a
real narrator, and the sentinel run re-verified it here (§Q). §38's requirement
is met by this panel simply not containing narrator-only information. The
surface that must *actively* withhold it is the context inspector — see §K.

### The RPG world state

Read-only, and shown in the context inspector only for a campaign that has a
stat schema. A campaign created in M8's setup flow has none. The editing drawer
is gone (§18, §26); the backend and the bundle are untouched.

---

## J. Knowledge

Every M7 behaviour is unchanged. M8 owed the design, and §21-22 of the brief is
what it was measured against.

- **Classification is explained, not iconified** (§48). The import form carries
  the three classes as radio choices with a sentence each — authoritative truth
  / supporting information that establishes nothing / creative influence only —
  and the same explanations appear as a legend in the empty state. The class tag
  is one shared component, so a class looks identical in the library and in the
  context inspector.
- **Each source shows** its title, filename when it differs, class, in-use state,
  narrator-only and always-include badges, import date and passage count.
- **Deleting explains what it does not do:** turns that already used the source
  are unchanged, each keeping its own record of what the narrator was given —
  and it points at "In use" as the reversible alternative.
- **Source detail** carries the text, every passage with its heading trail and
  token count, and — behind a "Technical details" disclosure — the media type,
  parser and chunking versions, and the full SHA-256.

### Semantic status (§22)

Three states, told apart rather than run together:

| State | What the reader is told |
| --- | --- |
| on | searching by keyword and by meaning |
| no model configured | searching by keyword; setting a *model for meaning-based search* would add meaning — **and keyword search alone is a supported setup**, which is what finds names and invented terms |
| configured but uncalibrated | searching by keyword only, because that model has not been measured for this in this build; borrowing another model's measurement would let unrelated material through; **keyword search is unaffected** |

The third is the M7 closeout case, and the one that reads as a mysterious
failure if it is not explained.

**No threshold is exposed and no slider exists.** `0.58` is a measured property
of one embedding model, not a preference, and a control over it would invite a
reader to recreate the defect M7's review found. Asserted: the panel's text
never contains `0.58` and contains no `input[type=range]`.

### A terminology correction

The audit (§M) found the panel pointing readers at an "embedding model" while
the Settings field is called **Model for meaning-based search** — a reader sent
looking for a field that does not exist by that name. That is a usability defect
rather than vocabulary policing, and it is finding 6. Both states now name the
field as the reader sees it, and two tests assert the panel's text contains no
"embedding" at all.

### Rendering

Nothing here renders imported text as markup. Source text and passage text both
go into a `
` as React children. The safe Markdown renderer the transcript
uses is deliberately **not** used: this panel exists to show a reader exactly
what is in their file, and rendering is the opposite of that.

---

## K. The context inspector

### Reduction from the M7 baseline

| | M7 baseline | M8 |
| --- | --- | --- |
| Text on opening the panel, same campaign | **11,996 characters** | **~1,400 characters** |
| What it opens on | the assembled prompt, dumped into `
` | token usage, then four readable sections |
| The assembled prompt | first | last, collapsed |

§55 says not to begin with raw prompt text. The reader's question is almost
never "what were the exact bytes"; it is "why did the narrator say *that*?", and
the answer is one of four things — what it was told to be, what it remembered,
what it read, or what it believes.

### Provenance visibility

Each retrieved passage names its file, its class (using the same tag component
the Knowledge panel uses, so a class looks identical wherever a reader meets
it), its heading trail, its passage number, how it was found, its closeness, and
its token cost. Rows also appear for passages that were **suppressed** as
duplicates and for passages there was no **budget** for — so "why is that not
here?" has an answer rather than a silence.

**Click-through is implemented.** The row carries `source_id`, so the filename is
a button that opens the Knowledge panel on that source rather than on a list.
M7's note in §57 recorded this as not built; it is built now.

Memories show authority ("Something that happened" vs "Something inferred"),
source turn, and closeness. The summary section reads its text from the
assembled `story_summary` section rather than duplicating it into the API.

### Hidden narrator information

This is the surface §38's requirement actually lands on, and it took two
corrections to get right.

**First:** each narrator-only passage's text is replaced by *"Hidden — this
passage is narrator-only"* until the reader ticks a control that says, in words,
that it will reveal secrets they marked for the narrator alone. The control is
off by default, is not remembered across reopening the panel, and is reset when
the inspector is pointed at a different turn.

**Second, and this was a real leak:** the assembled prompt at the bottom of the
panel contains the same passage *verbatim*. A closed `
` still holds its contents in the DOM, where find-in-page reaches them — so the secret was one keystroke away from a reader who never touched the reveal control. The sentinel test caught it only because it asserted on `innerHTML` rather than `innerText`. Hiding it visually would have been the appearance of a guard rather than a guard. The assembled prompt is now **withheld entirely** while narrator-only material is in it and the reader has not asked, with a note saying so. Three component tests cover it, and the leak test was verified to fail against the unfixed component before the fix was kept. ### The §38 documentation correction §38 asked for a `Show Hidden Story State` control on the state panel. There is no hidden story state: a secret lives in a narrator-only knowledge source and never enters the state document — M7 verified that against a real narrator. A toggle there would reveal nothing, and building a hidden-state dimension to give it something to reveal would create precisely the duplicate representation the requirement exists to avoid. The section is rewritten as **Hidden Narrator Information / Spoilers**. It now states the protection as five requirements, forbids inventing a second store, and records where the surface actually is. §100's checklist entry follows it. This is a **requirement clarification that tightens the requirement**, not a weakening — see §T. --- ## L. Settings and model status ### The carried debt, and what closed it `Settings.model` could be empty with nothing saying so, and play then failed on the first turn with a provider error. That is the debt §8 of the brief names. The header now reports Ollama in five states, from one shared answer fetched on mount and on demand — **never on a timer**, because a status line that re-tested every few seconds would be a polling loop against the reader's own inference host: | State | Meaning | The way out | | --- | --- | --- | | `checking` | the test has not come back | — | | `ready` | reachable, and the chosen model is installed | — | | `no-model` | reachable, no narrator model chosen | pick from the models actually installed there | | `missing-model` | reachable, chosen model not installed | pull it, or pick one it has | | `unavailable` | not reachable | local troubleshooting steps | `no-model` and `missing-model` are separated because the fix differs: choose one from a list you already have, versus pull one that is not there. **Nothing is chosen automatically.** Picking the first model in a listing would silently narrate with whatever sorted first — possibly an embedding model, which cannot narrate at all. A component test asserts that rendering the notice writes no setting. When an embedding model *is* in the list, the notice says plainly that a model with "embed" in its name is not for narrating. An endpoint that lists nothing is **not** treated as evidence the model is missing — some servers answer `/models` with an empty body, and claiming the model is absent would send the reader to pull one they already have. ### Model selection `Model` was a free-text box; typing a name that is not pulled produces a correct-looking configuration that fails on every turn. It is now a picker over the connection test's own listing, with the free-text field kept for the case where the listing is empty or the reader wants a model not yet pulled. ### Local-only, and no cloud provider Settings opens with a plain statement: campaigns, imported files and every prompt stay on this machine; the one thing that leaves is the request to the Ollama endpoint, which must be on this machine or this network. No provider selector, no sign-in, no API key field. Two backend strings were the last user-facing text describing a hosted deployment: a 401 advised checking an API key (removed in M2 with the cloud providers), and a 429 explained a shared free tier's daily cap. Both sent a reader looking for a setting that does not exist. Rewritten to describe what Ollama's own 401 and 429 mean. ### Diagnostics kept, and folded away The raw request/response log is genuinely useful when a local model misbehaves and is exactly the "developer console" §26 wants out of the normal path. It stays, at the bottom, behind a disclosure, and loads only when opened. ### Guidance where the reader actually is The browser run found that a reader who opens the *story* screen with a dead endpoint got a disabled Send button and a red badge, and no explanation — the setup notice lived only on Settings and the setup form. A disabled button with a red badge is a puzzle. The story screen now carries the same shared notice when play is blocked. Asserted in the A05 suite. --- ## M. Accessibility and browser verification ### Asserted, in `a11y.test.jsx` | Check | Method | | --- | --- | | Every control has an accessible name of more than two characters | walks the composer, library and setup form, collecting failures — **the M7 baseline had 16 unnamed glyph buttons on a two-turn story** | | Every form field has a bound `