MeridiansMeridians

The Reader — a dedicated visual-novel interface

From the Meridians Wiki · Public · Maintained · human-contract

Status: rough spec (direction). Not an implementation contract; nothing here ships until scheduled.

Companion to the visual-novel delivery (what the modality is and what it may not write) and decision architecture (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 owns how Reading produces the fork; production 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 §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 ScriptViewWhat a reader needs
Advance unitflat timeline over frames × moments (buildTimeline) — already moment-grainsame; keep
Transportprev / play-pause / next, AUTO_MS = 3200 dwell, ← → + Spacereader-paced by default; auto is opt-in with a configurable dwell
Positioncomponent-local useState, dies on unmounta durable cursor that survives reload and domain switch
Presentationavatar discs in coloured spotlight rings, per-modality glass panels, cast railsprites on a location plate, one persistent textbox, nameplate
Registeractivity panels keyed by modality (speak/act/react/think)one textbox; modality carried by typography, not by panel colour
Backlognonescrollback of every moment, with per-line voice replay
Savesnonequick-save + slots, each with a preview line
Skipnoneskip over seen moments only, halting at forks
Choicesnonethe choice screen and its commitment contract
Flowchartbranch tree exists as graph/board projections elsewherejump-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

┌──────────────────────────────────────────────────────────────────┐
│  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 §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:

ActionBindingSemantics
Advanceclick · 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
AutoAadvance on a dwell timer; any input cancels it. Dwell is per-reader config, not a constant
Skiphold Ctrl · toggle Sfast-advance over seen moments only, halting at any unseen moment or fork
Skip to next forkrail buttonthe replay affordance: skim seen material, stop where a different commitment is available
Backlogwheel-up (held) · Tabscrollback; per-line voice replay; read-only jump-to-view
Save / LoadS … / Lslot grid with preview lines
Quick save / loadF5 / F9one rotating slot
Hide textboxH · right-clicklook at the plate; any advance restores it
FlowchartFthe branch tree, visited nodes jumpable
ConfigCtext speed, auto dwell, voice, type size, contrast
FullscreenF11 ·

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 §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:

// 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 §10).

6. The state machine

        ┌──────────┐  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 firstbuildTimeline 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

RequirementStateWhere
Tableau model (frames, cast slots, props, moment sequence)shippedScriptContent / ScriptFrame / ScriptMoment
Moment-grain timeline + modality resolutionshippedsrc/lib/core/scripts/timeline.ts
Lens runs, reader knowledge, irony, and scene-exit readoutsshipped (derived)src/lib/core/scripts/
Transport, keyboard nav, auto-advance dwellshipped (moment-grain, session-only)ScriptView
Location plates, entity images by aspect, scrim/contrast handlingshippedimage lane, media-overlay
Durable per-domain local view cursor patternshippedPERSISTED_VIEW_FIELDS, saveViewState
Branch tree as graph/board projectionsshippedbranch views
Priced fork cohorts with provenanceshippedReading / Future
Governed live resolution (phases, seats, projection boundary)shippedScenario
The reader surface itself (full-bleed, own state machine)buildnew shell surface
Shared timeline/moment derivations extracted to coreshippedsrc/lib/core/scripts/timeline.ts
Textbox + nameplate + staged sprites registerbuildreader surface
Durable playthrough cursor, save slots, seen-statebuildnew aux store (§5)
Backlog with voice replaybuildderived from the cursor prefix
Skip / skip-to-next-fork gated on seen-statebuildreader surface
Choice screen honouring the commitment contractbuildreader surface + Reading projection
Flowchart jump-in for visited nodesbuildextends the branch projections
Ending card (threads resolved / open / breaks)buildreader 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.