# StoryTangl Widget Vocabulary **Version:** v1.5 · supersedes v1.4 **Layer:** UI Vocabulary (Layer 1 of 3). **Implementation status** across reference clients (Layer 2: web/CLI/Tk), API transport, and engine backend (Layer 3) is tracked in `WIDGET_CONTRACT_RECONCILIATION.md`. **This document is target-truth.** **Audience:** anyone implementing a StoryTangl client (Vue, CLI, tkinter, Godot, Ren'Py, bespoke), or extending the engine's emitted contract **Source of truth (for engine model alignment):** - `tangl.journal.fragments` (fragment types, presentation hints) - `tangl.service.response` (`RuntimeEnvelope`, `ProjectedState`, section value union) - `tangl.journal.intent` (typed `Accepts`/`UIHints`; next-pass `Blocker` — see §6) This document defines the framework-independent rendering contract for the engine's `RuntimeEnvelope.fragments` and `ProjectedState.sections`. Visual treatments are author-swappable via bundle customization (§4); the vocabulary itself is not. > The Vue components in `apps/web/src/components/story/` are **one** > reference rendering. A CLI port, a tkinter port, and a Godot port are > equally valid — they each realize the same widget contract in their own > medium. **v1.5 changes summary (against v1.4).** v1.5 adopts the v1.4 genre-audit additions and reconciles them against the current repo implementation. No contract breaks; no new top-level vocabulary surfaces: - Keeps the new **§0.8 "Journal as narrative"** sidebar alongside §0.6 narrative authoring stance. Codifies the claim that v1.5-conforming envelope streams produce legible narrative transcripts as a consequence of traversal, without authored prose beyond per-location flavor. Elefant Hunt is the worked proof-of-concept. - Keeps the §1.5 "**Per-cursor projection of shared state**" paragraph naming the recipe: shared world, per-cursor visibility, same `cursor_id` keyed projection. Resolves an ambiguity that credentials, training, and elefant_hunt all hit. - Keeps the §0.9 **"Genre extensions index"** — short pointer table to all `bundles//EXTENSIONS.md` documents, with one line about what each genre stresses in the vocabulary. Helps readers find prior art. - Updates implementation-status wording for the repo-current typed `Accepts` / `UIHints` work: those engine surfaces are implemented; `Blocker`, `InterpretationFragment`, full info-channel typing, and several Tier P2 surfaces remain pending. - Aligns the conformance fixture list with the current repository, including `compose_payload.json` and the existing proposal fixtures. - Clarifies `place` payloads as carrying `source_zone_ref` when the client selected from a visible source zone. --- ## 0 · Conventions and principles ### 0.1 Tier tags Every section in this document carries one of four tier tags. These are not aspirations; they are operational. A reader should always know what's in the engine right now, what's a near-term proposal, and what's a longer-horizon direction. | Tag | Meaning | |---|---| | **Tier S** (Stable) | Stable target contract. Clients MAY rely on the subset marked current in `WIDGET_CONTRACT_RECONCILIATION.md`; proposal-only surfaces remain Tier P until implemented and covered by the CLI floor. | | **Tier P1** (Proposed, next engine epoch) | Concrete proposal with typed Pydantic models below. Additive. Backwards-compatible coercion path planned. | | **Tier P2** (Proposed, larger) | Architectural direction with sketch-level types. Pending settlement of §7 ontology. | | **Tier P3** (Genre extensions) | Domain-specific layers (carwars, hana-smuta, etc.). Defer until P1+P2 stabilize. Live in `bundles//EXTENSIONS.md`. | Each section's tier is given in its header. Subsections inherit unless overridden. ### 0.2 The CLI Floor Rule > A new widget, accepts kind, hint, fragment type, or `value_type` does > not enter Tier S until a worked CLI rendering exists in > `engine/contrib/conformance/cli_reference_port.py` and produces output > for every state described in its spec entry. This is the single rule that prevents the contract from drifting toward web-shaped affordances. Drag-drop, animation, hover preview — those are *reference renderings on top of* a contract that a CLI could fully execute. A widget whose semantics requires more than a CLI can do is not in the vocabulary; it is a renderer flourish. The Python `cli_reference_port.py` is the gating artifact. If a Tier P1/P2 proposal lands without a CLI rendering, it stays Tier P; it does not graduate. **The CLI Floor Rule is the *capability parity* axis of the contract.** Three sibling parity rules — *information parity* (§5.1 Decision Legibility), *time parity* (§5.2 Time Parity), and *input parity* (§5.3 Input Parity) — extend the same discipline to other dimensions of the player's experience. Together they form a four-legged stool: every richer port may exceed the CLI port, but never trap the player below CLI-floor accessibility on any axis. ### 0.3 Backend authority > The backend is authoritative for all state changes. The client MAY > preview, validate, and decorate, but the client never decides anything > that affects state without backend confirmation. §0.3 has three consequences: 1. **The client cannot mutate state without backend confirmation.** Grammar hints are advisory; client-side validators are advisory; capacity bars compute on the backend; predicates evaluate on the backend. 2. **The client cannot perceive state the backend has not sent.** Hidden information stays hidden because it never crosses the wire, not because the client agrees to look the other way. The client cannot implement game logic — including but not limited to: rule resolution, win/loss determination, predicate evaluation, hidden-information tracking, RNG, scoring, and pacing. 3. **The client cannot assume a coherent backend world model.** State may be authored on demand, retroactively committed, or refused. Each envelope is self-consistent at render time; the contract makes no guarantee that two envelopes describing "the same" world surface are explanations of a stable underlying truth. The client renders fragments; the backend is the story. ### 0.4 Records over tuples All ordered key/value structures in this document are arrays of records, not arrays of tuples. This applies to `KvRow` (§2.5), `compose.parts` payloads (§6.1.1), and any future ordered-pair surface. Records are extensible, narrow cleanly in TypeScript and Pydantic, and survive schema evolution. Tuples are not used at any wire boundary. ### 0.5 Naming changes from prior drafts Renames ratified in moving from v0.x through v1.5. Applied throughout; implementations migrating from prior versions update on the schedule in `WIDGET_CONTRACT_RECONCILIATION.md`. | Prior | Current | Reason | |---|---|---| | `token` (UI piece) | `piece` | Collides with `tangl.core.token.Token` (singleton wrapper). | | `ledger` (UI section type) | *removed* | Subsumed by annotated `kv_list` rows (§2.5). The engine's `tangl.vm.runtime.ledger.Ledger` keeps the name. | | `choice_id` (HTTP body) | `edge_id` | Reconciles with `ChoiceFragment.edge_id`. | | `interpretation.outcome` / `command_text` | `interpretation.result` / `text` | Spec-final names. | | `token_ids` / `offer_ids` (commit payload) | `piece_ids` | Follows `piece` rename; offers and pieces share one namespace. | | `cost_preview` (singular field) | `cost_previews: list[CostPreview]` | Multi-axis costs are common (money + time + reputation). Length-1 lists handle singular. | | `token_offer` fragment type | *removed* | Subsumed by `PieceFragment.realized: bool` (§7.1). | | `dice_roll` content_format | *removed* | Subsumed by `RollFragment` (§7.3); generalizes to card draws, random tables, etc. | | `accepts.kind="tokens"` | `accepts.kind="pieces"` | The kind name "tokens" collided with the deprecated `Token` fragment type. The rename to `pieces` mirrors the payload field `piece_ids` and the `PieceFragment` it operates on. (A v1.2 draft proposed `select`; rejected in v1.2.1 review because the payload is specifically `piece_ids`, not a generic selection. `select` stays reserved for a hypothetical future generic selection accepts.) | ### 0.6 Narrative authoring stance StoryTangl's contract is shaped to interactive fiction rather than digital tabletop simulation. The runtime is free to author state lazily, generatively, or in response to player input — there is no client-readable world model, no determinism guarantee, and no requirement that previously-rendered fragments correspond to a still- existing backend object beyond what later envelopes assert. Games built on StoryTangl are **interactive narrative opportunities**, not simulated worlds with computable invariants. Bundles that want simulation-like behavior (deterministic rules, queryable state, fair RNG) implement those guarantees on the backend; the contract does not provide them. Practically, this means: - A piece's identity might be authored at the moment it's revealed, not when the zone is first rendered. A memory game's facedown cards have no identity until the player flips them. - A predicate need not be referentially transparent. The same predicate may return different answers on different turns; the contract is that the backend has decided, not that any client can reproduce the decision. - An offer (`PieceFragment.realized=False`) need not promise a stable catalog. The runtime may synthesize offers on demand and let unsold ones evaporate when the player leaves the shop. - A "world" is whatever the transcript has committed to so far, plus whatever the backend is willing to commit to next. The contract makes no commitment beyond what the current envelope renders. This stance is what makes the contract small enough to be portable across CLI / web / Godot / Ren'Py without losing expressiveness. The constraints in §0.2 and §5 are downstream consequences of this design choice. ### 0.7 Three-layer architecture This document is **Layer 1** of a three-layer separation: | Layer | Document | What it specifies | |---|---|---| | **L1 — UI Vocabulary** | this doc (`STORYTANGL_WIDGET_VOCAB.md`) + `bundles//EXTENSIONS.md` | What data shapes the player-facing client needs, and at what levels of expressiveness. Target-truth. | | **L2 — API Transport** | `API_SPEC.md` (forthcoming) | REST endpoints (and other transport mechanisms) that route L1 data needs to L3 capabilities. Optional. Clients sharing an address space with the engine (CLI, embedded ports) skip this layer entirely. | | **L3 — Engine Capabilities** | `ENGINE_CAPABILITIES.md` (forthcoming) | Python callables that produce the data L1 wants. Ground truth of what's implementable in the current engine. | Per-surface, the three layers MAY be at different states. A typed `PiecesAccepts` may be Tier P1 in this spec, partial in the API, and freshly typed in the engine — all simultaneously, all during a settling phase. **`WIDGET_CONTRACT_RECONCILIATION.md` tracks per-surface status across the three layers.** When a row in the reconciliation tracker reads "implemented" across all three columns, the surface is settled. The negotiation direction is **UI-out**: the spec proposes target contract; the API and engine chase. CLI ports skip L2 and call L3 directly (in-process or via whatever shim a port chooses). ### 0.8 Journal as narrative A v1.5-conforming envelope stream produces a legible narrative transcript as a consequence of traversal — without authored prose beyond per-location and per-event flavor. This is the StoryTangl thesis claim, made testable. A rendered CLI transcript of a complete play session — for any bundle whose envelope stream is contract-correct — should read as a coherent story. The arc structure (a Proppian arc, in the narratological sense: departure → trials → boons → return → recognition, or any equivalent ordering) emerges from the bundle's graph topology and the sequence of `content`, `attributed`, `roll`, and `control` fragments, not from a separate narration layer. The strongest demonstration is **bundle/elefant_hunt** (see Appendix C): a board game whose mechanics produce a recognizable naturalist's-journal arc from pure procedural mechanics. The bundle's per-location prose is thin; the structure does the work. **Implications for bundle authors.** The journal-as-story claim holds when: - Each location, encounter, and outcome emits a `content` fragment with enough specificity to be re-readable in transcript form. ("Camp. Consumed 3 supplies." is enough; an empty envelope is not.) - `RollFragment.narrative` is populated when the outcome carries story weight — losses, captures, revelations. - `attributed` fragments give recurring NPCs stable speaker names so the transcript reads as a cast. - `update` and `delete` control fragments carry a `content` fragment companion when the state change is narratively significant ("Zartan is lost to the river."). **Implications for the contract.** The journal-as-narrative claim is a **bundle-authoring discipline**, not a contract enforcement rule. v1.5 does not gate conformance on it. But the conformance fixture suite SHOULD include at least one "render a complete session to transcript" test per genre, asserting the transcript is non-trivial and contains key narrative events. See §10.4. ### 0.9 Genre extensions index Tier P3 genre conventions live in `bundles//EXTENSIONS.md`. The current set: | Bundle | What it stresses | EXTENSIONS doc | |---|---|---| | `carwars` | Vehicle outfitting; slot zones with capacity constraints; RNG stat checks; drag-and-drop with click-pick parity | `bundles/carwars/EXTENSIONS.md` | | `credentials` | Inspection / verification gameplay; severity-coded findings; mediation move sequencing; backend-authored discrepancies | `bundles/credentials/EXTENSIONS.md` | | `training` | Scheduled skill progression; mood as growth modulator; scheduled-event checks; per-tag situational effects | `bundles/training/EXTENSIONS.md` | | `elefant_hunt` | Graph-traversal sandbox; backend-private token pools; hunt resolution as composite roll; journal-as-story validation | `bundles/elefant_hunt/EXTENSIONS.md` | | `hana_smuta` (sketch) | Card play with `pieces` constraints; hand/field/pile/score zones | `bundles/hana_smuta/EXTENSIONS.md` (TBD) | Cross-paradigm patterns that emerged from drafting the above and were NOT lifted to a shared `_common/EXTENSIONS.md` because they're each already covered by main-spec conventions: - **Severity emphasis** — `ui_hints.emphasis` and `KvRow.emphasis` carry author-stable severity. No genre needed to invent its own; the main-spec vocabulary covers credentials findings, carwars hazards, training mood states, elefant_hunt threat exits. - **Gate-check previews** — every genre that surfaces a "you're about to roll against difficulty N" preview uses the same shape via `ui_hints` with bundle-specific sub-keys (`stat_check` in carwars and training, `validity_check` in credentials, `encounter_check` in elefant_hunt). These are open hint surfaces by §6.2.1; a unified `gate_check` was considered but each genre's preview text and callout fields differ enough that forcing a single shape would obscure intent. **Genres keep their own; the underlying pattern is documented per-extension.** - **Owner-bound pieces with state** — carwars hunters, training inventory unlocks, elefant_hunt mobs all use the same `PieceFragment` shape with `owner` and `properties`. No cross-genre extension needed. If a fourth cross-paradigm pattern emerges from future genre work, `bundles/_common/EXTENSIONS.md` becomes the right home for it. Today it is not. --- ## 1 · Top-level contract — Tier S ### 1.1 RuntimeEnvelope ```python # tangl/service/response.py — current shape (Tier S) class RuntimeEnvelope(InfoModel): cursor_id: UUID | None = None step: int | None = None fragments: list[BaseFragment] = Field(default_factory=list) last_redirect: dict[str, Any] | None = None redirect_trace: list[dict[str, Any]] = Field(default_factory=list) metadata: dict[str, Any] = Field(default_factory=dict) ``` Each runtime turn produces one envelope **for one cursor** (§1.5). Fields: - **`cursor_id`** — identifies the journal channel. Stable within a session. Changes when state advances; unchanged across `interpretation` fragments. - **`step`** — monotonic counter, per-channel, incremented per state- changing turn. Unchanged by `interpretation` fragments. - **`fragments`** — ordered stream; see §2 for fragment types. - **`last_redirect` / `redirect_trace`** — runtime introspection for the ledger's most recent and historical redirects. Author/debug surface only; reader clients ignore. - **`metadata`** — open dict for cross-cutting hints. Reserved sub-keys: `metadata.grammar` (§6.6), `metadata.info_affordances` and `metadata.info_state` (§1.6). ### 1.2 Fragment registry and UID stability Every fragment carries a stable `uid`. Clients maintain a registry keyed by `uid` across envelopes within a session. The registry is the source of truth for the rendered scene; envelopes are diffs into it. Two registry-mutating fragment types exist (§2.7): - **`update`** control fragments mutate the registry entry at `ref_id` by merging `payload` into the existing fragment. The same UID is re- rendered in place; **no layout shift**. - **`delete`** control fragments remove the registry entry at `ref_id`. **Clients MUST NOT drop fragments they do not understand.** They MUST render a textual fallback (see §9 parity table) so UIDs remain resolvable by future control fragments. ### 1.3 ProjectedState ```python # tangl/service/response.py — current shape (Tier S) class ProjectedState(InfoModel): sections: list[ProjectedSection] = Field(default_factory=list) class ProjectedSection(BaseModel): section_id: str title: str kind: str | None = None value: SectionValue # discriminated union, see §3 hints: PresentationHints | None = None ``` `ProjectedState` is a **sidecar** to `RuntimeEnvelope`. Sections are re-projected every state-changing turn (i.e. when `step` advances). Shells MAY animate deltas. The `kind` field is a free string for semantic tagging (`wallet`, `score`, `inventory`, `world_time`, etc.); ports MAY use it to choose between visual treatments. See §1.6 for the current conventional `kind` values. ### 1.4 Flow vs rail A useful organizing distinction for shell designers: - **Flow content** comes from `RuntimeEnvelope.fragments`. It is scene-bound, accumulates as a transcript, and is the locus of player interaction. - **Rail content** comes from `ProjectedState.sections`. It is durable across turns, refreshes in place, and represents the world's persistent state visible to the player (purse, stats, inventory). Some widgets exist in both worlds — `kv` (§2.5) is the canonical example, appearing as a scene-bound *fragment* and as a durable *section value*. The shape is identical (§2.5); only the routing differs. ### 1.5 Cursors and journal channels — Tier P1 > Each cursor has its own journal channel. Envelopes are per-channel. > The backend coordinates shared world state across channels; the > contract makes no commitment about turn ordering or simultaneous input > — those are bundle concerns. **Status (L1):** committed target contract. **Status (L3):** single-cursor today; multi-cursor channel routing is a proposed extension awaiting an MVP author. The vocabulary commits to the framing; current engine and reference UI behave as if there is one cursor. A **cursor** identifies one participant's traversal through a story. For solo play, there's one cursor and one channel. For multi- participant play — Discord-style shared reading, head-to-head gamebooks, asynchronous group play — there are N cursors, each with its own channel, each receiving its own envelope stream. **The contract is cursor-local.** `RuntimeEnvelope.cursor_id` identifies the channel an envelope belongs to. `step` is monotonic per channel. Fragment registries are per channel. Two channels may render the same underlying world state differently (per-participant visibility) and at different paces (asynchronous turn-taking). **Shared world coordination is bundle territory.** When two cursors interact with shared state — one player buys an item from a shop the other player also frequents — the bundle decides: - How the runtime serializes commits across channels. - Whether turn order is round-robin, speed-first, GM-paced, or freeform. - How `update` control fragments propagate to other channels' registries. The contract makes none of these decisions cheap. It just guarantees that each channel sees a self-consistent envelope stream. **`visibility` and the cursor.** `visibility="owner_only"` is interpreted *against the channel's owner*. A fragment with `visibility="owner_only"` and `owner=A` is rendered in cursor A's channel and never crosses into cursor B's. `visibility="hidden"` means the fragment never reaches *any* channel — it lives backend-side only. **`visibility` accepts a list of participant IDs.** When a fragment is visible to a defined audience (team-mates, allies, the GM), the field takes a list instead of a singleton: ```python class BaseFragment: # ... visibility: VisibilityLevel | list[ParticipantId] = "public" ``` Where `VisibilityLevel` is `Literal["public", "owner_only", "hidden"]` and `ParticipantId` is the cursor's owning account. A list value means "rendered in any channel whose owner appears in this list." Teams, asymmetric cooperative roles, and "show this to the GM only" surfaces all use this form. Routing is a Service-layer concern. **Per-cursor projection of shared world state.** Many multi-cursor games share a world surface — a board, a market catalog, an event queue — across cursors. The rendering recipe is: 1. **The shared element exists once in backend world state.** A market zone, a board zone, an animal pool — one canonical object on the backend. 2. **Each cursor receives its own projected envelope.** The same shared element appears in each cursor's envelope as a fragment. `PieceFragment.owner` and `visibility` (per-fragment) control which cursor sees what about it. 3. **Updates to shared state propagate as control fragments to every relevant cursor's channel.** When cursor A captures an animal, cursor B's channel receives a `delete` control fragment removing that animal from the shared encounter zone and a `content` fragment narrating ("Hunter Red bags a hippo at the north watering hole."). This recipe lets a bundle implement Elefant Hunt's shared animal pool, a shared trick in trick-taking, a shared marketplace, or shared narrative arcs without needing a new fragment type. The `owner` field on pieces (Tier P2; §7.1) is the routing key for ownership-specific projection. `visibility="public"` means "render in every cursor's channel"; `owner_only` means "render only in the owner's channel"; the audience-list form (`visibility: list[ParticipantId]`, Tier P2 proposal fixture) handles team-scoped visibility. **The backend is the sole coordinator.** No cursor sees another cursor's intent before commit. No cursor's projection depends on inference about another cursor's state beyond what the backend has chosen to reveal. This is §0.3 backend authority applied to multi-cursor: the contract for cursor A makes no claim about cursor B's state that the backend hasn't explicitly projected. **Single-cursor is the floor case.** Most of this contract is written as if there's one cursor. The CLI port assumes one cursor. The `crossroads_inn.json` fixture assumes one cursor. Multi-cursor is the parallelizable extension; nothing in §§2–4 changes for it. **What is *not* in scope.** Couch multiplayer (two participants sharing one rendering surface and one input device) is out of scope by design; ports that want to host it run two independent client instances side-by-side, each with its own cursor, and the contract doesn't try to help. Mechanics that require simultaneous concealed input across channels (closed drafting, sealed-bid auctions) require cross-channel coordination that the contract treats as a bundle concern; they are expressible only via backend orchestration the spec does not make cheap. ### 1.6 Info channels — Tier S > An info channel is an advisory side-projection of world state the > player MAY query. Info channels are **discovery hints, not mandatory > client UI**. **Status (L1):** promoted Tier S contract. **Status (L2):** reference webapp implements `info_affordances` with `query` descriptors against `/story/info`, and the CLI reference floor exposes the same affordances through `?` / slash-command output. **Status (L3):** engine defines typed `InfoAffordance`, `InfoState`, and `StoryInfoRequest` models, advertises available channels on runtime envelopes, and routes `/story/info` through the service-info dispatch surface. Fine-grained dirty-kind tracking remains conservative in v1. A bundle MAY expose **info channels** — typed sub-surfaces of world state the player can pull on demand: a map, an inventory, a watch showing world time, a character sheet, a help screen, a list of active objectives. The runtime advertises these channels through two optional metadata keys on `RuntimeEnvelope`: ```python class InfoAffordance(BaseModel): kind: str # stable info-channel identifier label: str | None = None # short, player-facing; clients fall back to kind shortcuts: list[str] # CLI/keyboard aliases query: dict[str, Any] | None = None # Opaque query descriptor the backend interprets. # Hand-it-back semantic: clients pass it to the info endpoint without # inspecting its contents. Bundles decide what query keys mean. # Examples: { "type": "map", "format": "tiles" }, # { "kinds": ["party", "followers"] }, # None (no descriptor; default info kind is the channel itself) class InfoState(BaseModel): version: int # monotonic per cursor dirty_kinds: list[str] = [] # changed since prior turn available_kinds: list[str] = [] # what's queryable this turn # RuntimeEnvelope.metadata reserved sub-keys: # metadata.info_affordances: list[InfoAffordance] # metadata.info_state: InfoState ``` **Advisory, not authoritative.** A port that has room for an info-pill bar renders the affordances as buttons. A port without that affordance (CLI, narrow mobile, accessibility-mode) surfaces them through some other path — a `?` menu, slash commands, keyboard shortcuts, a hidden drawer. Per §5.3 Input Parity, every info channel MUST be reachable by some CLI-floor mode; how *prettily* is port-specific. **Rich rendering is a render-profile concern, not a contract entry.** An info channel of `kind="map"` might be rendered on the web as an animated tile grid, on the CLI as `[map: 12 known locations, 3 unexplored]`, on Godot as a 3D minimap. The *data* is canonical; the *rendering* is the port's call. The contract does not grow `format: "tiles" | "graph" | "ascii"` fields on sections. Bundles choose visual treatments via `hints.style_tags`, bundle widget variants (§4.2), or port-specific profiles (§4.3). **Every info channel has a `ProjectedState` fallback.** Any `kind` exposed as an info affordance MUST also be expressible as one of the five canonical `value_type`s (`scalar`, `kv_list`, `item_list`, `table`, `badges`) — either directly in `ProjectedState.sections` or via an info-channel query (§6.7). The fallback exists so a port that doesn't implement the rich rendering still has *something* to show. The fallback is the contract surface; the rich rendering is ornament. **Conventional `kind` values** (non-normative; bundles MAY add more): `world_time`, `location`, `inventory`, `agenda`, `objectives`, `roster`, `wallet`, `help`, `presence`. Ports MAY honor these for visual treatment selection — a `kind="world_time"` section might render with a clock icon by default; `kind="map"` might earn a fold- out treatment. None of this is contract; it's recommended convention. **Cache invalidation.** `info_state.version` is monotonic per cursor. `info_state.dirty_kinds` tells the client which channels' cached projections went stale since the prior turn. A client that does not cache info channels can ignore `info_state` entirely. **Single-cursor default.** All info channels are scoped to the cursor they're advertised in. Per §1.5, multi-cursor bundles project per-cursor channels; there is no global info-channel surface. --- ## 2 · Fragment widgets — Tier S Every fragment widget section has the same structure: a Pydantic shape, a behavior table (required/optional/states/a11y/fallback), and concrete port sketches. ### 2.1 `content` — Prose block ```python class ContentFragment(BaseFragment): fragment_type: str | Enum = "content" content: Any = None # usually str; may be richer source_id: UUID | None = None content_format: str | None = Field(None, alias="format") # md/plain/html presentation_hints: PresentationHints | None = Field(None, alias="hints") ``` | | | |---|---| | **Required** | `uid`, `fragment_type="content"` | | **Optional** | `content` (any; usually str), `content_format` (`md`/`plain`/`html`), `hints.style_tags[]`, `hints.style_dict`, `hints.icon`, `source_id` | | **Container rule** | Flows into the active `scene` group. Interrupts any preceding caption region. | | **States** | **empty** → skip. **loading** → stream chunks in as they arrive. **error** → render raw string with visible marker. **stale** (after `update` arrives) → re-render in place with same UID; no layout shift. | | **A11y** | Plain text selectable; if `hints.style_tags` contains `establishing` or `chapter`, treat as `` landmark. Honor `prefers-reduced-motion`. Time Parity (§5.2): typewriter / staggered-reveal effects MUST be skippable to canonical-instant rendering with a single user action. | | **Fallback** | Unknown `content_format` → plain text. Unknown hint tag → ignore. | **Port sketches.** Web: `

` or `

` honoring `style_tags` via classes. CLI: hard-wrap to terminal width; blank line above/below. tkinter: `Text` widget segment with tag set. Ren'Py / Godot: `RichTextLabel` / narrator say. ### 2.2 `attributed` — Dialog line ```python class AttributedFragment(ContentFragment): fragment_type: Literal["attributed"] = Field("attributed", alias="type") who: str how: str media: str ``` Note the `alias="type"` on `fragment_type` — the wire shape may use either `fragment_type: "attributed"` or `type: "attributed"`. Clients MUST accept both. (This is a legacy-compat surface; future fragment types should not introduce aliases.) | | | |---|---| | **Required** | `uid`, `who`, `how`, `media` (modality: `speech` / `text` / etc.), `content` | | **Optional** | `hints` | | **Container rule** | Almost always inside a group with `group_type="dialog"`. The immediately-following `media` fragment with `media_role ∈ {avatar_im, dialog_im}` binds to this line. | | **States** | **empty** → hide entire line. **loading** → placeholder avatar + ellipsis body. **error** → render `who: content` with `how` dropped. **stale** → same UID swap. | | **A11y** | Containing dialog group is `role="group" aria-label="dialog"`, `aria-live="polite"`. `who` MUST be announced before content. | | **Fallback** | If `media` modality is unknown, render as speech. | **Port sketches.** Web: avatar chip + speaker label + body. CLI: `who [how]> content`, wrapped. tkinter: `Frame` per line: image + label stack. Ren'Py: `define s = Character("Stranger")` + `s "content" (how="low")`. Godot: dialog bubble node with portrait slot. ### 2.3 `media` — Media frame ```python class MediaFragment(ContentFragment): fragment_type: str = "media" content: Pathlike | bytes | str | dict | MediaRIT content_format: Literal["url", "data", "xml", "json", "rit"] media_role: str | None = None # see below scope: str | None = "world" staging_hints: StagingHints | None = None ``` | | | |---|---| | **Required** | `uid`, `content`, `content_format` | | **Optional** | `media_role` ∈ `cover_im` / `narrative_im` / `avatar_im` / `dialog_im` / `sfx` / `bgm` / `video`; `scope` ∈ `world` / `scene` / `turn`; `staging_hints` (shape, size, position, transition, duration, timing) | | **Container rule** | Routed by `media_role`, not by order. `cover_im` is persistent chrome; `narrative_im` belongs to the active content region; `avatar_im` / `dialog_im` bind to the nearest preceding `attributed`; `bgm` is timelined against `staging_hints.media_timing`. | | **States** | **empty** → hide. **loading** → placeholder box with role label; ARIA busy. **pending** (`content_format="rit"` unresolved) → placeholder marked `data-pending`; swapped in place by later `update` to `url` or `data` — same widget, same DOM node, no reflow. **error** → placeholder + error text; preserve layout. | | **A11y** | Images need `content` labeled via `hints` or sibling text. Audio/video must expose native controls or keyboard toggle. `prefers-reduced-motion` disables `media_transition`. Time Parity (§5.2): the player MUST always be able to advance past time-bound media (audio/video) with a single action; the player MAY independently choose to let media continue playing. | | **Fallback** | Unknown `media_role` → render inline. `content_format="rit"` unresolved → pending placeholder. | **Port sketches.** Web: `` / `