The Reader — a dedicated visual-novel interface
Source path: knowledge-base/specs/visual-novel/reader.md
# The Reader — a dedicated visual-novel interface
Status: **rough spec (direction).** Not an implementation contract; nothing here ships until scheduled.
Companion to the [visual-novel delivery](README.md) (what the modality is and what it may not write) and
[decision architecture](decisions.md) (how a fork earns the reader's attention). This document owns the
**interface**: the surface, its controls, its state machine, and where reading state lives.
[Branching](branching.md) owns how Reading produces the fork; [production](production.md) owns the assets
the reader encounters.
The target is explicit: a reader of the class a Fate/Stay Night-era reader sets — forward and backward at
moment grain, auto and skip, a backlog with voice replay, save-anywhere slots with previews, a flowchart to
jump between explored branches, and a text presentation comfortable enough to read for an hour. That
feature set is not decoration; it is the medium's accumulated answer to the problem of a long branching text.
Of the four properties the product is judged on — consequence, meaning, perspective, immersion — this spec
owns **immersion**, and immersion here is mostly the absence of friction: nothing stalls on a model call,
nothing loses your place, nothing makes you re-read to recover what was said. It must never buy that
comfort by weakening the other three, which is why skip stops at an unread fork and the transport rewinds
reading position without rewinding canon.
Read first: [README](README.md) §3 (commitment contract) and §4 (reading position and what may write) — this
spec assumes both and does not restate them.
---
## 1. What exists today, honestly
`src/shell/stage/views/content/ScriptView.tsx` (617 lines) is a **cinematic playback surface**, and it is
good at what it does — but it is not the reader, and the gap is worth naming precisely:
| | Today in `ScriptView` | What a reader needs |
|---|---|---|
| Advance unit | flat timeline over frames × moments (`buildTimeline`) — **already moment-grain** | same; keep |
| Transport | prev / play-pause / next, `AUTO_MS = 3200` dwell, ← → + Space | reader-paced by default; auto is opt-in with a configurable dwell |
| Position | component-local `useState`, **dies on unmount** | a durable cursor that survives reload and domain switch |
| Presentation | avatar discs in coloured spotlight rings, per-modality glass panels, cast rail | sprites on a location plate, one persistent textbox, nameplate |
| Register | activity panels keyed by modality (`speak`/`act`/`react`/`think`) | one textbox; modality carried by typography, not by panel colour |
| Backlog | none | scrollback of every moment, with per-line voice replay |
| Saves | none | quick-save + slots, each with a preview line |
| Skip | none | skip over **seen** moments only, halting at forks |
| Choices | none | the choice screen and its commitment contract |
| Flowchart | branch tree exists as graph/board projections elsewhere | jump-in to any visited node |
Two things follow. First, `ScriptView` should **stay as it is** — it is the authoring-side playback and the
Stage's cinematic view, and re-pointing it at reader semantics would make one component serve two registers
badly. Second, the pieces worth sharing are the *derivations*, not the rendering: `buildTimeline` and the
moment → activity resolution (`activityOf`) are pure functions living inside a component. Extracting them to
`src/lib/core` and having both surfaces consume them is how "what is a moment" stays one truth rather than
two.
---
## 2. Layout
```text
┌──────────────────────────────────────────────────────────────────┐
│ location plate (16:9, the ScriptFrame's backdrop) │
│ │
│ [far-left] [left] [center] [right] [far-right] │ ← ScriptCastMember.slot
│ dim speaking · dim · │ ← .state / .facing
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ NAMEPLATE │ │
│ │ The moment's text, revealed at the reader's pace. │ │ ← one ScriptMoment
│ └────────────────────────────────────────────────────────────┘ │
│ auto · skip · backlog · save · load · flow · config · ⤢ │ ← quiet rail, hides with H
└──────────────────────────────────────────────────────────────────┘
```
- **The plate persists across moments and changes only on a new `ScriptFrame`.** Stillness is the medium's
default, not missing animation. A frame change may cross-fade; a moment change may not.
- **Figures are staged, not composited into the art** — the five `ScriptSlot` values are the horizontal
positions, `state` drives dim/lit/entering/exiting, `facing` mirrors. See
[fate-exploration](../fate-exploration/README.md) §7 for why generating in-situ art is the expensive
mistake.
- **One textbox, always in the same place**, with a fixed measure (~60–70 characters). The reader's eye must
not hunt. Modality (`line` / `thought` / `narration` / `memory` / `document`) is carried by typography and
the nameplate's presence, not by moving or recolouring the box — the existing per-modality colour language
belongs to the authoring view, where scanning matters more than reading.
- **Text over a photographic plate needs a scrim**, and the repo already solves this: the `media-overlay`
scope re-pins white to true white over a darkened backdrop when a frame has a location image. Reuse it
rather than inventing a second contrast strategy.
---
## 3. Controls
Conventional bindings, because the convention is the accessibility feature — a reader of the medium already
knows them:
| Action | Binding | Semantics |
|---|---|---|
| Advance | click · `Space` · `Enter` · `↓` | one moment. Mid-reveal, completes the reveal instead of advancing |
| Back | `↑` · wheel-up (one notch) | one moment. **A cursor move, not an undo** — see §4 |
| Auto | `A` | advance on a dwell timer; any input cancels it. Dwell is per-reader config, not a constant |
| Skip | hold `Ctrl` · toggle `S` | fast-advance **over seen moments only**, halting at any unseen moment or fork |
| Skip to next fork | rail button | the replay affordance: skim seen material, stop where a different commitment is available |
| Backlog | wheel-up (held) · `Tab` | scrollback; per-line voice replay; read-only jump-to-view |
| Save / Load | `S` … / `L` | slot grid with preview lines |
| Quick save / load | `F5` / `F9` | one rotating slot |
| Hide textbox | `H` · right-click | look at the plate; any advance restores it |
| Flowchart | `F` | the branch tree, visited nodes jumpable |
| Config | `C` | text speed, auto dwell, voice, type size, contrast |
| Fullscreen | `F11` · `⤢` | |
Rules that are easy to get wrong:
- **Skip never crosses unseen material.** Without that, skip is a way to accidentally erase a first reading.
It requires the seen-state set from the delivery spec §4 and is the reason that set exists.
- **Auto is off by default.** The delivery is *reader-paced*; an auto-advancing default silently converts it
back into the timed Episode the medium is meant to replace.
- **Back at moment grain, not frame grain.** The current transport steps frames; a reader re-reads a line.
- **Every control is optional.** A reader who only ever clicks to advance must never be blocked by a modal.
---
## 4. Back, and what rewinding may not undo
The sharpest design call in the interface, and it must be stated in the UI, not just here.
- **Within catch-up traversal**, back is free. Stepping back past a commitment returns the reader to the fork
and re-opens it; nothing in the record changed, because a traversal commitment only ever selected which
accepted passage to read.
- **Across a live commitment, back is a cursor move only.** Will that was spent stays spent: the Scenario
resolved, the branch preserved the consequence, and the record has provenance for it. The reader may re-read
the passage before their decision; they may not un-make the decision. The interface must say so at the
moment they try — quietly, once — because the medium's convention (rewind = undo) trains the opposite
expectation.
- **Load is the same story.** Restoring an earlier save moves the cursor; it does not roll back canon. A save
is `(cursor, route)` resolved against the record on load ([README](README.md) §4), which is exactly why it
cannot carry stale content — and also why it cannot carry a world state that no longer exists.
The honest framing for the reader: *you are re-reading a world that already happened to you, not rewinding
it.* That is a stronger premise than fake undo, and it is the one the architecture actually supports.
---
## 5. Reading state — where it lives
Nothing here touches canon. The precedent is already in the repo: `DomainViewState` is the per-domain local
cursor, explicitly excluded from package export, with `PERSISTED_VIEW_FIELDS` + `saveViewState` /
`loadViewState` (keyed `viewState:${id}`) as the durable-subset pattern. Reading state is the same posture,
one step more durable:
```ts
// per-domain, per-reader; aux/IDB, never the record, never exported
type PlaythroughCursor = {
branchId: string;
entryIndex: number;
frameIndex: number;
momentIndex: number;
takenForks: { entryId: string; chosenBranchId: string; at: number }[];
};
type SaveSlot = {
id: string; // "quick" | "auto" | slot-n
cursor: PlaythroughCursor;
previewLine: string; // resolved from the record at save time, for the slot grid
plateImageRef?: string; // the frame's existing location image — never a new render
createdAt: number;
};
type SeenState = {
moments: Set<string>; // enables skip-read
endings: Set<string>;
assets: Set<string>; // enables the gallery
};
```
- **Losing all of it loses a bookmark, never content.** That is the test for whether a field belongs here.
- **A slot preview is text and an existing image reference.** Rendering a fresh thumbnail per save would put a
paid image call behind a keypress — the exact failure the cost model forbids.
- **Autosave on every fork commitment**, plus one rotating quick slot. Fork-adjacent autosaves are what make
branch exploration bearable, and they are free.
- **Seen-state is per reader**, not per domain member: it drives skip, and skipping someone else's reading is
wrong.
- **Reader traversal telemetry** (which forks were presented and taken) may inform exploration priors only —
where to look next, never what is true ([fate-exploration](../fate-exploration/README.md) §10).
---
## 6. The state machine
```text
┌──────────┐ advance / back / auto / skip
│ reading │◄─────────────────┐
└────┬─────┘ │
fork gate │ (decisions.md §2) │ commitment recorded, consequence shown
┌────▼─────┐ │
│ choosing │──── traversal ───┘
└────┬─────┘
live │ hands off to a bounded Scenario
┌────▼─────┐ resolution → graph → arc (governed, with provenance)
│ resolving│───────────────────────────► back to `reading` on the new material
└──────────┘
┌──────────┐
│ ended │ route terminal → ending card (threads resolved / open / `breaks`)
└──────────┘
```
`choosing` is a **halt**, not an overlay on a running clock: auto and skip both cancel on entry, and the
textbox holds the last moment so the reader can still see what they are deciding about. `resolving` is the
only state that can wait on the network, and it must say what it is waiting for — a bounded Scenario is not
instant and pretending otherwise produces a frozen reader.
---
## 7. The reading experience itself
The part that decides whether anyone reads for an hour, and the part most easily left to defaults:
- **Reveal, don't type-write.** Per-moment reveal at a configurable speed, with instant as a first-class
option; clicking mid-reveal completes it rather than advancing. Never animate text the reader has re-read.
- **Fixed measure, generous leading, no scroll.** A moment that does not fit is a composition problem, not a
scrollbar. Long `document` / `memory` moments are the exception and get their own presentation.
- **Typography as modality.** Speech upright with a nameplate; thought italic without one; narration set
apart; `document` in a distinct face. This is already how the record distinguishes them
(`ScriptMoment.thought` / `narration` / `memory` / `document`) — the reader just has to honour it.
- **Glossary on first appearance.** An entity's first appearance in a route can carry a quiet affordance to
its glossary entry, inheriting the reader's information boundary. First appearance is derivable from
seen-state, so this costs nothing.
- **Voice on demand.** Voice is the most expensive lane in the whole system; it is opt-in per production and
cached per exact line version, and the backlog is where replay belongs.
- **Comfort settings are not optional extras**: type size, contrast over plate scrim, reduced motion (which
should disable frame cross-fades entirely), and a light/dark surface that respects the existing theme
ladder rather than inventing a reader-only palette.
- **Resume where you left off, every time.** The single highest-value affordance in a long branching text,
and it follows for free from §5.
---
## 8. Where it lives
- **A new surface, not a modification of `ScriptView`.** The reader is full-bleed, owns the keyboard, and has
its own state machine; the Stage's playback view is a panel inside a workspace. Sharing the shell would
compromise both.
- **Extract the shared derivations first** — `buildTimeline` and the moment resolution into
`src/lib/core` as pure functions, consumed by both surfaces. This is the one refactor worth doing before
any reader code exists, because it is what prevents two definitions of "the next moment".
- **Mobile is the same reader, not a cut-down one.** The phone shell already runs a compact theatre layout
(`mobile` in `ScriptView`), and the medium is historically at home on a handheld; tap-to-advance, swipe for
backlog, and the same cursor.
- **The record stays untouched.** No reducer action is required to *read*. The only writes are aux/IDB reading
state, and — in live posture only — the governed Scenario path.
---
## 9. The honest ledger
| Requirement | State | Where |
|---|---|---|
| Tableau model (frames, cast slots, props, moment sequence) | **shipped** | `ScriptContent` / `ScriptFrame` / `ScriptMoment` |
| Moment-grain timeline + modality resolution | **shipped** | `src/lib/core/scripts/timeline.ts` |
| Lens runs, reader knowledge, irony, and scene-exit readouts | **shipped** (derived) | `src/lib/core/scripts/` |
| Transport, keyboard nav, auto-advance dwell | **shipped** (moment-grain, session-only) | `ScriptView` |
| Location plates, entity images by aspect, scrim/contrast handling | **shipped** | image lane, `media-overlay` |
| Durable per-domain local view cursor pattern | **shipped** | `PERSISTED_VIEW_FIELDS`, `saveViewState` |
| Branch tree as graph/board projections | **shipped** | branch views |
| Priced fork cohorts with provenance | **shipped** | `Reading` / `Future` |
| Governed live resolution (phases, seats, projection boundary) | **shipped** | Scenario |
| **The reader surface itself (full-bleed, own state machine)** | **build** | new shell surface |
| **Shared timeline/moment derivations extracted to `core`** | **shipped** | `src/lib/core/scripts/timeline.ts` |
| **Textbox + nameplate + staged sprites register** | **build** | reader surface |
| **Durable playthrough cursor, save slots, seen-state** | **build** | new aux store (§5) |
| **Backlog with voice replay** | **build** | derived from the cursor prefix |
| **Skip / skip-to-next-fork gated on seen-state** | **build** | reader surface |
| **Choice screen honouring the commitment contract** | **build** | reader surface + `Reading` projection |
| **Flowchart jump-in for visited nodes** | **build** | extends the branch projections |
| **Ending card (threads resolved / open / `breaks`)** | **build** | reader surface |
---
## 10. Open questions
1. **Does the reader ship inside the app or as an export first?** A static bundle is a smaller surface and a
real artifact to hand someone; in-app is where the live posture and the record actually are.
2. **One reader per domain, or per Constellation?** A cross-domain reader implies a route across worlds,
which the branch model does not describe.
3. **How much of the flowchart is honest to show** — visited nodes only, or the unexplored edges too? Showing
them is the invitation to replay; it also reveals the shape of what the reader has not chosen.
4. **Does hiding the textbox pause auto?** (I think yes — it is a "look at this" gesture, not a reading mode.)
5. **Whether a save may be shared.** It is only a cursor, so it is technically harmless — but a cursor into
another reader's seat is an information-boundary decision, not a convenience.
Open on GitHubRaw Markdown source