diff --git a/docs/thing2-build-spec.md b/docs/thing2-build-spec.md new file mode 100644 index 0000000..f995c0f --- /dev/null +++ b/docs/thing2-build-spec.md @@ -0,0 +1,746 @@ +# Thing 2.0 build spec + +Status: proposal for review (PR), 2026-11-05. Written for Meta's ask t12-m0 +(#cultlib, 2026-11-04) on top of `docs/thing2-target.md` (why, invariants) and +`docs/thing2-map.md` (body facts P1-P22, model page, consumer contract, authority +map, cut order). It does not restate them; it adds what the map leaves at +deliverable level: **the pieces, each piece's contract, the build order, and the +test that defends each piece**, with prior art for every design choice. + +Nothing here overrides a ruling. Where this spec makes a choice the rulings do not +force, it is labelled **D** (decision, with confidence) and the ones it could +not settle are in "Open" at the end, each with a recommendation. + +## How to read the prior-art tags + +Each piece ends with **Prior art**. Tags: **[read]** = a source read in an earlier +Eyes or Imagination pass, with its evidence pointer in `docs/research/*` (cited as +R-F = `thing2-foundation-prior-art.md`, R-T = `thing2-text-input-a11y-prior-art.md`, +R-K = `thing2-contract-forks-prior-art.md`, R-C = `thing2-consumer-census.md`); +**[background]** = prior knowledge, not re-fetched for this spec, and the first +thing to verify when its piece is mapped into a `cut_spec`. Confidence is stated +per decision. Meta's rule, as passed to this run, is "prior-art sources cited"; +if the rule has more to it (format, depth, a required venue) the Open list asks. + +## 0. The machine in one page + +``` + provider ──publishes──▶ CultMesh state (typed .cc records) + │ StatePort (subscribe / read) + ▼ + ┌───────────────────────── thing-core (one crate, internal modules) ─────────────────────────┐ + │ resolve ─▶ bind ─▶ layout ─▶ text ─▶ render ─┐ │ + │ (docs, (Bind, (Taffy) (Parley or ├─▶ FrameOutput { commands, a11y, outbound, │ + │ compos.) Repeat) cells) │ ime, diagnostics } │ + │ input (focus, edit, hit-test, scroll) ─▶ command dispatcher ─▶ outbound CommandInvocation │ + │ a11y (AccessKit tree) │ + └───────────────────────────────────────────────────────────────────────────────────────────┘ + ▲ FrameInput { now, viewport, scale, events } │ commands + a11y + │ ▼ + host event loop ◀────── thin backend: TUI (crossterm) | web (wasm + JS) | later hosts +``` + +Core is a **function of (documents, state, input, time)**. The host owns the loop +and calls `Core::frame`; core never spawns, sleeps, reads a clock or touches a +socket except through a port. This is what makes every test below runnable without +a daemon (`doctrine/gamecult.md`, "dependency injection is boring") and what makes +goldens byte-stable (map rule 10). + +**D1. One crate for core, internal modules, not eight crates.** (Confidence +medium-high.) Authority separation (resolve, bind, layout, text, render, command) +is real, but the modules share one frame pipeline and one set of types; a crate per +stage buys build fan-out and a public surface no consumer may use, which +`narrow-consumer-contract` forbids. Splits that *do* earn a crate, each naming the +invariant it protects: + +| Crate | Earns its boundary because | +| --- | --- | +| `thing-contract` (landed) | consumers depend on it alone (`narrow-consumer-contract`); three runtimes are held to its goldens | +| `thing-core` | owns geometry, text, focus, binding, results; depends on no transport, no clock, no platform | +| `thing-mesh` | the only crate that depends on CultMesh/CultNet: `StatePort`/discovery implementations. Keeps rule 7 and the mockable-port invariant checkable by a dependency-tree grep on `thing-core` | +| `thing-tui` | backend; depends on `thing-core` output types and `crossterm`, never `taffy`/`parley` (rule 8) | +| `thing-web` | wasm entry (`wasm-bindgen`) plus the JS host package; same dependency rule | +| `thing-testkit` | fake ports, fixture loader, golden harness, shared by every crate's tests; never a dependency of a shipped artifact | + +Prior art: Xilem and egui split by renderer/backends and keep one core +(R-K §1: `masonry_core`, `egui`+`epaint`, backends as crates) **[read]**; AccessKit +and Parley are separate repos only where independently consumed or needing another +toolchain (R-K §1) **[read]**; Flutter merged repos to end rolls (R-K §1) +**[read]**. Same repo, many crates, is the dominant pattern. + +## 1. Build order and dependency graph + +``` + 0 decisions ─▶ 1 contract-ts ┐ + ├▶ 1 contract-cs ┴▶ 1x cross-store check + └▶ 2 testkit+ports ─▶ 3 resolve ─▶ 4 bind ─▶ 5 layout ─▶ 6 render+goldens ─▶ 7 commands + │ │ + 8 TUI ◀────────┘ │ + 9 text(Parley)/focus/edit ─▶ 10 a11y ─▶ 11 web ◀───┘ + 12 thing-mesh (parallel from 4 on) + 13 consumer ports (one per consumer) ─▶ 14 retirement +``` + +Rules for the order: (a) contract before core (map, rationale); (b) the pipeline is +built **end to end thin first** (cut 8 draws a real surface in a terminal before +text, IME or a11y exist), then deepened, because the first full run is where unknown +unknowns show; (c) `thing-mesh` runs beside core from cut 4 because it is the +other half of the `StatePort` contract and needs the same fixtures; (d) no consumer +port starts before cut 7 and one backend (cut 8) work, so a port can be viewed. + +Cuts are sized to Hands' budget (about 150 calls, 0.5M tokens is the danger line): +each row below is one `cut_spec`, or two where marked. Sizes are Self's estimates +in thousand lines of owned code (excluding goldens), pressure not metric. + +| # | Cut | Waits on | Est. | Parallel with | +| --- | --- | --- | --- | --- | +| 1a/1b | contract-ts, contract-cs (specs `thing2:cut_spec:cut-contract-*.r2`) | contract-rs (landed `6ae2da0`) | mapped | each other | +| 1x | cross-store check | 1a, 1b | 0.3 | 2 | +| 2 | testkit + ports | contract-rs | 1.2 | 1a, 1b | +| 3 | resolve | 2 | 1.8 | - | +| 4 | bind | 3 | 1.5 | 12 | +| 5 | layout | 4 | 1.8 | - | +| 6 | render + goldens | 5 | 1.5 | - | +| 7 | commands | 4 (needs 6 only for hit-test) | 1.6 | 8 | +| 8 | TUI backend | 6, 7 | 1.5 | 7 tail | +| 9a/9b | text: measure+shape, then focus+editing+IME state | 6 | 2 + 2 | 8 | +| 10 | a11y tree | 9b | 1.0 | 11 start | +| 11a/11b/11c | web: wasm+drawing, IME mirror + a11y mirror, shell+discovery | 6, 9b, 10 | 1.5+1.5+1.0 | - | +| 12 | thing-mesh | 2, 4 | 1.5 | 5-8 | +| 13.n | consumer ports | 7, 8 (web for browser consumers: 11c) | per consumer | one repo each | +| 14 | retirement | last port | -1x | - | + +## 2. Cross-cutting contracts + +### 2.1 Core API (the one public surface core has) + +``` +Core::new(ports: Ports, config: Config) -> Core +Core::frame(&mut self, input: FrameInput) -> FrameOutput + FrameInput { now: Instant, viewport: Size, scale: f32, events: Vec, metrics: MetricsMode } + FrameOutput { commands: Vec, a11y: Option, outbound: Vec, + ime: Option, diagnostics: Vec, next_wake: Option } +Ports { state: dyn StatePort, fonts: dyn FontPort, clock: dyn Clock, ids: dyn IdSource, host: dyn HostEffects } +``` + +- **One owner for time and ids.** `now` arrives in the frame input; `IdSource` + mints invocation ids. Tests substitute both, so a run is a pure function and + invocation ids in goldens are reproducible. +- **Outbound is invocations only** (rule 7). `Ports.state` has `read`, `subscribe`, + `list(schema, key_prefix)`; it has no `write`. The one write path is + `FrameOutput.outbound`, which the host or `thing-mesh` forwards. +- **`HostEffects`** has exactly the closed `effect` set of the contract: + `open_surface(key)`, `open_external(url)`; plus `clipboard` read/write when the + text piece needs it (see Open O7). +- Metrics mode (`Pixels` | `Cells`) is a core input, because the TUI measures in + cells inside the Taffy measure function (R-T §5: iocraft does exactly this + **[read]**) and rule 8 forbids the backend from owning any layout input. The + cell-metrics provider is part of core (small: `unicode-width` and + `unicode-segmentation`), not of `thing-tui`. + +Prior art: Clay's per-frame contract (set dimensions → pointer state → begin layout +→ end layout returns the command array) is the model for `frame` **[read, R-F +§2.1]**; egui and Bevy UI emit shapes or commands to thin backends **[read, R-F +§2.4, R-T §1]**. Elm/Redux style "update is a pure function of state and event" +**[background]**. Confidence high: no contrary evidence found. + +### 2.2 The closed render-command list (D2) + +`FillRect{rect, radius, color}`, `StrokeRect{rect, radius, width, color}`, +`Text{origin, run, color}` where `run` is `Glyphs{font, size, glyphs}` (Pixels mode) +or `Cells{col, row, text, style}` (Cells mode), `Image{rect, catalog, asset, fit}`, +`ClipPush{rect, radius}`, `ClipPop`, `Polyline{points, width, color}`, +`Polygon{points, color}` (plot marks), `Embed{rect, slot_kind, slot_id}`. +Nine variants. Selection highlights, carets, focus rings and scrollbars are +`FillRect`/`StrokeRect` that core emits. Z order is emission order; there is no +z-index, no layers, no custom escape. A new command is a reviewed change to core +plus every backend and the goldens at once (that is the point of closing it). + +Colours in commands are resolved literals; theme tokens never cross to a backend. +Coordinates are `f32` logical pixels in Pixels mode, integer cells in Cells mode. + +Prior art: Clay's complete enum is `NONE, RECTANGLE, BORDER, TEXT, IMAGE, +SCISSOR_START, SCISSOR_END, OVERLAY_COLOR_START/END, CUSTOM`, with renderers for +SDL, raylib, canvas2d, termbox2 and a terminal, all thin **[read, R-F §2.1]**. +Thing's list differs on purpose: no `CUSTOM` (Embed is the closed host slot), no +overlay-colour pair (an overlay scrim is a `FillRect`), plus polylines for `Plot`. +Confidence medium: the count is a proposal; whether `Plot` should lower to +`Polyline`/`Polygon` or be left to an `Embed` is O3. + +### 2.3 Diagnostics (D3) + +A closed `Diagnostic { code, node, document, detail }`: `UnresolvedUse`, +`BindSchemaMismatch`, `BindMemberMissing`, `UnknownNode`, `UndeclaredCommand`, +`DuplicateNodeId`, `StaleVersionIgnored`, `MalformedResult`, `UnknownInvocation`, +`InvocationTimedOut`, `DecodeRefused`. The same value drives (a) the placeholder or +error node drawn in the frame, (b) a log line, (c) the a11y tree (an error node is +announced), and (d) the tests. Rules 2, 4, 5 and 6 are asserted on these codes, so +a rule that is deleted fails by name, not by a screenshot. +Confidence high; the alternative (free-text logs) is what Thing 1's gap ledgers were. + +### 2.4 Test layers and golden format (D4) + +Doctrine splits tests into local-authority unit tests, adjacent-organ pipeline +smokes, and end-to-end Verse contract runs; every piece below names which layer. + +- **Fixtures** (`tests/fixtures/*.surface.cc`, committed): typed documents encoded + through `thing-contract`, one per row of the fixture table (§2.5). Built by a + `thing-testkit` builder in Rust, then frozen; the bytes are what is loaded. +- **Geometry golden**: for a fixture + viewport + state script, the resolved node + tree with `(node id, x, y, w, h)` in a stable text form. +- **Render golden**: the `Vec` encoded with CultCache MessagePack + through the same derive the contract uses, plus a line-oriented text rendering + beside it for diffs. The binary form is the oracle (byte-identical, rule 10); the + text form is only for review. +- **Regeneration** is `cargo xtask bless`, runs only on a reviewed core change, + and a backend crate cannot call it (model page: goldens are the core's). +- **Layout correctness is inherited, tree building is tested.** Taffy is itself + validated against Chrome with generated fixtures (`just gentest`, R-F §2.2 + **[read]**), as is Yoga (`gentest/`, R-F §2.3 **[read]**); Thing does not re-prove + flexbox. Thing's tests prove that *our* style mapping puts the right inputs into + Taffy and that the output flows into commands unchanged. One extra check + re-derives a small fixture's geometry from the raw Taffy API directly and compares + (guards against our mapping drifting while goldens are re-blessed). +- **Mutation**: `tools/cargo-mutants.sh` once per Hands pass, scoped to the cut's + diff, per the Eureka rule. Each piece's "Test" below names the rules whose + mutants must die; a survivor is triaged, not hidden. +- **Property tests** (`proptest` [background]) only where a total function has a + clear law: contract round-trip (exists), `resolve` never panics and either + returns a tree whose ids are unique or a refusal, `bind` is idempotent for the + same state, `frame` with no events and unchanged state returns an equal command + list (stability). Fuzzing the decoder (`cargo-fuzz` [background]) is deferred + until `decode-stack-safety` lands (the decode hazard is CultLib's, map P20). + +Prior art: golden command lists are not published by any project read (R-F §2.5: +"not found, not searched exhaustively"), so this is the least-supported decision +in the spec; the nearest supported techniques are Taffy/Yoga generated geometry +tests and egui's `egui_kittest` snapshot crate (R-K §1 lists the crate; +its snapshot method is **[background]**). Confidence medium: the idea is sound +because commands are renderer-independent by construction; cost of churn on +intentional change is the risk, mitigated by `bless` being one command. + +### 2.5 Fixture set (the census sizes the floor) + +Twelve fixtures cover the node set and every rule; each is named for the consumer +shape it models (R-C §7 cross-table is the source, and Thing is a general library, +so a fixture exists for a capability a reasonable consumer would expect even with +no caller today): + +| Fixture | Exercises | Rules | +| --- | --- | --- | +| `f01-swarm-dashboard` | VoidBot-shaped: Box grid, bound Text/Format, `Repeat` over a collection prefix, one Button | 6, 9 | +| `f02-login-effect` | Ghostlight/Heimdall: TextInput, Button, `OpenExternal{resume}` result | 1, 2 | +| `f03-input-target` | Muninn: `position: absolute` target over an `Image`, bound label (P6) | - | +| `f04-form` | Toggle, Slider, Select, TextInput number, `captures` and `clear_drafts` | 1 | +| `f05-modal-overlay` | `Overlay` modal with dismiss command, focus trap | - | +| `f06-scroll-list` | `overflow: scroll`, `Repeat` 1k items, keyed insert/remove | - | +| `f07-plot` | Stonks/StreamPixels-shaped `Plot` series | - | +| `f08-embed-viewport` | `Embed` slot as a leaf of given size | - | +| `f09-composition-stack` | nested `Use` with typed args and child slots, `thing.std` plus a publisher composition | 5 | +| `f10-hostile` | unknown arm, undeclared `CommandRef`, duplicate id after expansion, unresolved `Use`, schema-mismatched `Bind` | 4, 5, 6 | +| `f11-deep` | nesting at the 256 bound (decoded on a large-stack thread until CultLib's fix) | - | +| `f12-text-edge` | bidi, CJK wide characters, combining marks, emoji ZWJ, long wrap | - | + +## 3. The pieces + +Each piece lists **Owner** (one writer), **In**, **Out**, **Ports**, +**Invariants** (what must die under mutation), **Failure**, **Test** (layer and the +rule each pins), **Deletes/forbidden** (what the piece must not contain), and +**Prior art**. Map rules are cited as M1-M10 (the ten in the map's "Rules that must +die under their own mutation"). + +### Piece 1: contract (packages for TS and C#, and the store check) + +Rust (`crates/thing-contract`) landed after two Soul passes; `contract-ts` and +`contract-cs` are mapped (`thing2:cut_spec:cut-contract-ts.r2`, `-cs.r2`) and have +branches (`hands/thing2-contract-ts`, `hands/thing2-contract-cs`) not yet merged. +This spec adds no contract change. + +- **Owner:** the contract crate; packages own nothing (map authority). +- **Test (per package):** the shared golden fixtures decode and re-encode + byte-identical (M1, M3); the refusal goldens (`seq` 0, extra slot, unknown union + arm) are refused; a hand-built object with seq 0 is refused at encode. +- **1x cross-store check:** the C# package opens a `.cc` store the TS package wrote + and the reverse, and both open Rust's. The check lives in `thing-testkit` as a + scripted cross-runtime job run on Yggdrasil's verify stopgap + (`tools/stopgap/ygg-verify.sh`), because only that image has all three runtimes. + It proves the catalog type names agree (map cut 1 waiting line). +- **Prior art:** CultCache wire parity against the C# reference is the standing + CultLib rule; probe P12-P13 are the evidence **[read, map]**; golden-file + interoperability is how Protobuf and Cap'n Proto test language bindings + (`conformance` suites) **[background]**. Confidence high. + +### Piece 2: testkit and ports + +- **Owner:** `thing-testkit` owns fakes; `thing-core` owns the port *traits* + (a trait is the narrow port; the fake is a test crate's business). +- **In/Out:** `StatePort { read(schema,key)->Option, list(schema,prefix), + subscribe(schema,key|prefix) -> Stream }` where `Change` is + `Upsert(entry)|Delete(key)`; `FontPort { resolve(family, weight, style) -> + FontHandle, bytes(handle) }`; `Clock`; `IdSource`; `HostEffects`. All object-safe. +- **Fakes:** `MemState` (a `CultCache`-backed map with a scripted change feed and + a record of every call), `FixedFonts` (one bundled test font, so Parley output is + deterministic across machines), `ManualClock`, `SeqIds`. +- **Invariants:** `StatePort` has no write method (M7, checked by the trait's + method list in a test that enumerates it and by a grep over `thing-core`); + `MemState` returns entries only as decoded contract records, never `Value`. +- **Failure:** a decode refusal from the port surfaces as a `Change::Refused` + carrying the code, never as a missing record (a refusal must not look like + absence). +- **Test:** unit. `MemState` change ordering; fixture loader round-trips all twelve + fixtures through the contract (a fixture that fails the contract is a bug in the + fixture before it is a bug in core). +- **Deletes/forbidden:** no global state, no `Instant::now()` in core (negative + grep). +- **Prior art:** the port style is the project's own doctrine; Xilem's + `masonry_testing` and egui's `egui_kittest` are test-harness crates beside a core + (R-K §1) **[read]**. Confidence high. + +### Piece 3: resolve + +- **Owner:** `thing-core::resolve`. It is the only code that interprets Thing + Script structure. +- **In:** `SurfaceDocument`, a `CompositionStore` (std compiled in, plus publisher + compositions read through `StatePort`). **Out:** `ResolvedTree` (arena of + nodes, unique ids, every `Use` expanded or replaced by a placeholder node, every + `CommandRef` checked against the document's `CommandDecl`s, bindings still + unresolved), plus diagnostics. +- **Invariants:** M4 (unknown kind, undeclared `CommandRef`, duplicate id after + expansion refuse the document *whole*, and the old document stays displayed); + M5 (unresolved `Use` is a visible placeholder with a diagnostic); expansion is + substitution only (no conditionals, no arithmetic: a test that every + `thing.std` composition expands under a parameter-less evaluator); expansion + terminates (recursion in compositions is refused with a cycle diagnostic and a + depth bound); expansion is deterministic and order-preserving; ids of expanded + nodes are derived from the `Use` node id and the template id by one scheme, so + they are stable across republishes (focus and scroll state key on them). +- **Failure:** a referenced composition arrives later → the placeholder is + replaced next frame without a republish. +- **Test:** unit per rule on `f09`, `f10`; property (`resolve` total, ids unique or + refusal); pipeline smoke: `resolve` output fed to `layout` (cut 5) in `f01`. + Mutants that must die: skip the duplicate-id check; treat an undeclared + `CommandRef` as a warning; drop the placeholder; expand `Use` without pinning the + composition version. +- **Deletes/forbidden:** no `kind` string dispatch anywhere (closed enum only); no + fallback along a kind path (M4); no expression evaluator. +- **Prior art:** Adaptive Cards separates template expansion from render + (`adaptivecards-templating` expands a template against data before render) + **[read, R-K §2]**; Thing's `Use` is the same split without an expression + language, which R-K §2/P5 shows the failure of (the DSL's renderer-local `derive`) + **[read]**. Slint compiles a text language to a typed tree at build/run time + **[read, R-K §2]**. Confidence high on substitution-only; medium on id-derivation + (no source read; it is the same problem React solves with keys **[background]**). + +### Piece 4: bind + +- **Owner:** `thing-core::bind`. Only code that reads typed state. +- **In:** `ResolvedTree`, `StatePort`. **Out:** `BoundTree` with every `Bind` + replaced by a typed value or a binding-error value; `Repeat` expanded to one + subtree per item, keyed by the item key; subscriptions established and released + as bindings appear and disappear. +- **Invariants:** M6 (different schema or missing member renders a typed error, + never a coerced value); M9 (a version at or below the held one is ignored, and + the ignore is a `StaleVersionIgnored` diagnostic at most once per document + version, not per frame); a value change never triggers layout of anything but the + nodes whose bound values changed (dirty tracking is observable through a counter + the test reads); `Format` is total and locale-free in core (formats are the + closed set; any locale is a host input, see O8); `Repeat` item keys must be + unique, a duplicate key refuses the document at bind time as M4 does; keyed + reorder preserves node identity, so focus and scroll survive a reorder. +- **Subscription rule:** core subscribes to exactly the `(schema,key)` pairs and + prefixes the live tree references; a document replacement diffs its subscription + set. An unsubscribed record's arrival changes nothing. +- **Failure:** record absent → a typed `Absent` value rendered as the node's + declared empty text (the contract field), not an error; schema mismatch → error. +- **Test:** unit per rule on `f01`, `f06`, `f10` with scripted `MemState` feeds; + pipeline smoke `bind → layout` checks dirty-node count; mutants: coerce a + mismatched schema; drop the version gate; resubscribe every frame; reorder by + index instead of key. +- **Deletes/forbidden:** no local `var`/`derive`/`collection` store (P5); no draft + binding (BD2: editing state is `input`'s). +- **Prior art:** BD1-BD9 collapse in the map **[read, map]**; keyed list + reconciliation is React's `key` and Elm's `Html.Keyed` **[background]**; + Clay retains element state by ID and requires `CLAY_IDI` for loops (R-F §2.1) + **[read]**, the same constraint. Flutter's retained tree keeps state by key + **[background]**. Confidence high. + +### Piece 5: layout + +- **Owner:** `thing-core::layout`. The only crate-user of `taffy`. +- **In:** `BoundTree`, viewport, metrics mode, `FontPort`. **Out:** `LaidTree`: + per node a content box, scroll extents, clip, and absolute-positioned overlay + boxes. +- **Contract:** the mapping from contract `BoxStyle` to Taffy style is one + function with an exhaustive match (a new contract field fails to compile until it + is mapped). Leaf measure for `Text`, `TextInput`, `Image`, `Embed`, `Plot` + goes through one `Measure` trait: Pixels → Parley (piece 9), Cells → cell metrics, + `Image`/`Embed`/`Plot` → declared sizes. Overlay nodes are laid out as a + separate root attached to an anchor node's final rectangle or the viewport. + Scroll containers expose `content size` and clamp offsets that `input` owns. +- **Invariants:** layout is a pure function of (tree, viewport, scale, metrics, + fonts): same inputs, byte-identical geometry (M10); a style field with a literal + and a token resolves via the one theme resolver before reaching Taffy; `fr`, + `%`, `auto` and `content` sizes reach Taffy as the matching Taffy dimensions + with no rewriting; an `Embed` is a leaf of exactly its declared size. +- **Failure:** NaN or negative sizes from a document are refused at resolve (the + contract already refuses at decode for `Count`); layout never panics on a + constraint it cannot satisfy and instead overflows per CSS rules. +- **Test:** unit geometry goldens on `f01`, `f03`, `f05`, `f06`, `f08`; the + raw-Taffy rederivation on one small tree (§2.4); property: layout is + deterministic across repeats and scale 1 vs 2 scales positions linearly when + fonts are scale-independent (fixed font); Cells mode: `f12` widths equal + `unicode-width` expectations. Mutants: swap row/column mapping; drop `gap`; + round positions; ignore `position: absolute` insets. +- **Deletes/forbidden:** `taffy` appears in no other crate's tree (M8, `cargo tree + -i taffy` negative check on `thing-tui`, `thing-web`, `thing-mesh`, contract). +- **Prior art:** Taffy implements Block, Flexbox and Grid, is used by Servo, Blitz, + Bevy, Slint, iocraft, Zed/GPUI and is validated against Chrome (R-F §2.2) **[read]**; + measure-function leaf text is the documented integration point (R-F §2.2, R-T §1) + **[read]**; iocraft's measure for Cells (R-T §5) **[read]**. Ruling + `thing:ruling:thing2-one-solver-taffy`. Pin: Taffy 0.14.0 (2026-08-24 [read]); + Slint pins 0.10 flexbox-only and iocraft 0.5.2 (R-T §1, §5), so the Grid feature + surface at 0.14 is the one to prove first (confidence medium). + +### Piece 6: render and goldens + +- **Owner:** `thing-core::render`. Turns `LaidTree` + paint + input state into the + command list; draws selection, caret, focus ring, scrollbars. +- **Invariants:** M10 (byte-identical command list for equal inputs); painter + order is document order, then overlays last; `ClipPush`/`ClipPop` balance (a + validator over every golden); every `Embed` and `Image` node produces exactly + one command or a visible missing-asset placeholder (diagnostic `AssetMissing` + added to D3's list when this cut lands, O9); nothing outside the clip stack is + drawn for `overflow: clip|scroll` nodes. +- **Backends must prove consumption (M10 second half):** each backend's parity + test replays a golden list and asserts one draw call per command; a backend that + skips a command variant fails by name. +- **Test:** golden suite described in §2.4 over all fixtures; pipeline smoke + `resolve→bind→layout→render` on `f01`; mutants: drop `ClipPop`, reorder overlay, + emit caret when unfocused, emit a command with a theme token not a literal. +- **Prior art:** Clay's sorted command array and scissor start/end (R-F §2.1) + **[read]**; egui/epaint clipped shapes to thin backends (R-F §2.4) **[read, + background]**. Confidence high on structure, medium on exact variants (O3). + +### Piece 7: commands (dispatcher and result validation) + +- **Owner:** `thing-core::command`. Mints invocations, tracks them, validates + results. The only producer of `FrameOutput.outbound`. +- **Lifecycle (D5):** `Issued → Pending* → Completed | Denied | Failed`, with two + core-only terminal states, `Malformed` (result failed to decode, or named an + unknown invocation) and `TimedOut`. Terminal states are final: a later result + for a terminal invocation is a diagnostic, not a state change. +- **In:** input events that activate a `CommandRef` node; results arriving as + `StatePort` changes of schema `gamecult.thing.command_result.v1`. + **Out:** `CommandInvocation` (id from `IdSource`, `captured` from `input`'s editing + state for the nodes `CommandDecl.captures` lists, `surface` = key + held version, + `issued_at` from the clock; **no caller identity field written**, the transport + sets it), UI state for the issuing node (busy, denied-at-node), effects through + `HostEffects`. +- **Invariants:** M2 (a result that fails to decode, or an unknown `invocation_id`, + becomes a visible `Malformed` state for that invocation and a log line; no + invocation stays pending past its timeout without one); M7 (core writes nothing + but invocations); idempotency: one user action mints exactly one id, and a + re-delivered invocation carries the same id; `Completed{clear_drafts:true}` + clears exactly the captured nodes' drafts and no others; `OpenExternal{resume}` + is executed once per result `seq`; a `Denied{node}` marks that node and no + other; a double-click on a busy Button mints no second invocation while the first + is non-terminal (the Button node's enabled state is derived from lifecycle, not a + flag a document sets). +- **Timeout:** a core config value, default 15 s in `Config` (O5: it is a guess). +- **Failure:** `StatePort` refusal on a result (piece 2's `Change::Refused`) maps to + `Malformed`, which is the case P2 shows Thing 1 lacked (result rejected whole + by `ajv` with nothing visible). +- **Test:** unit lifecycle table (every transition, every illegal one), driven + with `ManualClock` for timeouts; pipeline smoke with `MemState`: press a Button in + `f02`, assert exactly one outbound invocation with the right captures, feed + `Pending` then `Completed{OpenExternal}`, assert one host effect; negative: + result with extra field (M1) never reaches the lifecycle. Mutants: no timeout; + mint id per frame; `clear_drafts` clears all; accept results after terminal; + write caller identity. +- **Prior art:** idempotency keys for commands and retries (Stripe API + idempotency, RFC draft "Idempotency-Key" header) **[background]**; command/result + split with a pending phase (job/operation resources in Google AIP-151 long-running + operations) **[background]**; Flutter's `TextInputConnection` shows what drafts the + host must hold (R-T §4) **[read]**. Evidence of the failure it prevents: P2, P7 + **[read, map]**. Confidence medium-high; the `Malformed` rule follows directly + from P2. + +### Piece 8: TUI backend (`thing-tui`) + +- **Owner:** `thing-tui`: terminal drawing, keyboard and mouse into `InputEvent`. + First end-to-end consumer of core; also the agents' reader. +- **In:** `FrameOutput.commands` in Cells mode. **Out:** terminal bytes via + `crossterm`; `InputEvent`s. +- **Invariants:** M8 (no `taffy`/`parley` in the tree); every `RenderCommand` + variant is handled and each is either drawn or explicitly declared + not-representable in cells (`Image` → block placeholder with alt text, + `Polyline` → braille or block approximation): "not representable" is a table in + the crate, tested; wide and zero-width characters advance the cursor as the + `Cells` run says (the backend never re-measures); the screen diff writes only + changed cells (testable on a `Vec` buffer without a terminal). +- **Test:** unit on a virtual screen (`ratatui`-style test buffer is + **[background]**; ours is a `Vec`): every golden command list renders to a + stored screen dump; pipeline smoke: `f01` through the whole of cuts 3-7 to a + screen dump; parity (M10): every command variant produces a cell effect. + Mutants: skip `ClipPop`; mis-advance on a wide char; draw a command outside the + clip; ignore `Text` colour. +- **Forbidden:** layout of any kind. +- **Prior art:** Clay has `termbox2` and terminal renderers as thin command + consumers (R-F §2.1) **[read]**; iocraft is a Taffy-based TUI (R-T §5) **[read]**; + ratatui uses Cassowary not Taffy and pins `unicode-width >= 0.2.0` (R-T §5) + **[read]**, a version issue to carry into our own pin (confidence medium: width + tables differ between 0.1.x and 0.2.x, so the cell widths in goldens must be + pinned to a crate version, D6). + +### Piece 9: text (9a measure+shape, 9b focus+editing+IME state) + +- **Owner:** `thing-core::text` (ruling `thing2-thing-owns-text`). +- **9a In/Out:** string + `TextStyle` + width constraint → `TextLayout` with + line boxes and glyph runs (Pixels, via Parley) or cell lines (Cells). One + `Measure` implementation per metrics mode behind the layout `Measure` trait. +- **9b In/Out:** `InputEvent`s targeted by hit-test/focus → editing state per + `TextInput` node (value, selection, composition range), caret rectangle, + `ImeRequest { rect, text_window, selection }` (the data a hidden host element + needs), and edits fed to `command` as captured values. +- **Focus (D7):** one focus owner in core. Tab order is document order of + focusable nodes, overlays trap focus while modal, focus is keyed by node id and + survives republish when the id survives, and a focused node removed moves focus + to its previous sibling then parent. A backend never owns focus; it forwards host + focus loss/gain only. +- **Invariants (9a):** shaping and bidi never run in a backend (M8); a run's glyph + positions are a pure function of (text, style, width, font bytes); wrap and + `max_lines` are applied by core and appear in the command list. +- **Invariants (9b):** the draft is core's state and dies with the session (model + page row 5); composition text is displayed but not committed or captured until + the host commits; an IME update never rewrites committed text; every selection + edit is on grapheme-cluster boundaries (`unicode-segmentation`); `ImeRequest` + windows the text rather than mirroring a whole large value, and coalesces to one + per frame (R-T §2: GPUI's mirror coalesces writes because each write restarts the + IME connection **[read]**). +- **Test:** 9a: unit on `f12` (bidi order, CJK width, combining marks, ZWJ + emoji clusters, wrap at several widths) with the bundled font; golden glyph + runs. 9b: scripted event sequences (type, move, select, delete across a + cluster, compose-update-commit, compose-cancel, undo if D8 includes it) assert + value/selection/`ImeRequest` at each step; focus: traversal order on `f04`, + trap on `f05`, removal on a republish. Mutants: byte-index deletion in a + cluster; commit composition on update; focus not keyed by id; mirror whole value. +- **Prior art:** Parley: rich-text layout with HarfRust shaping, ICU4X bidi and + segmentation, `PlainEditor`, IME primitives, an AccessKit editor example + (R-T §1) **[read]**; Bevy (taffy 0.14 + parley 0.11), Slint (`shared-parley`), + Blitz (Stylo + Taffy + Parley, text input state in the DOM node), Masonry (R-T §1-2) + **[read]**: four independent Taffy+Parley integrations. Cosmic-text is the + alternative; Parley wins on the integrations above and AccessKit ties, not on + measured quality (R-T §1; no benchmark read). IME-restart and mirror behaviour + from GPUI web, egui `text_agent`, Flutter web `text_editing.dart` (R-T §2) + **[read]**. Confidence medium: all of this is an assembly of read-but-not-built + parts; the biggest risk is Parley under `wasm32` and `no_std`-adjacent builds + (not established; O1). Slug/Vello GPU glyphs (R-T §6) is the operator's scope + addition and is **out of this build order**: glyphs reach the backend as + positioned runs, and how the web backend rasterizes them (canvas 2D first, + GPU later) is O2. + +### Piece 10: a11y tree + +- **Owner:** `thing-core::a11y`. Derives an AccessKit `TreeUpdate` per frame from + the resolved tree, roles, labels, focus, text editing state and layout. +- **Invariants:** every focusable node has a role and an accessible name (a + document that offers none gets the diagnostic `NoAccessibleName`, O9); roles come + from the contract's closed `Role` set mapped by one exhaustive match; bounds equal + layout bounds; updates are incremental (unchanged nodes omitted), keyed by node + id; an `Embed` is exposed as a generic node with its label; text input exposes + value, selection and composition as AccessKit text-run data. +- **Test:** unit: tree for `f01`, `f04`, `f05` compared to stored AccessKit + dumps; incremental update test (change one bound value → exactly the changed + node plus ancestors needed); mutants: drop `focus`; stale bounds after scroll; + role map swap. An actual screen reader cannot be run in CI; it is an operator + check at web cutover (O6). +- **Prior art:** AccessKit as the schema, with Bevy, Blitz, Masonry, Slint, GPUI + and egui integrating it (R-T §3) **[read]**; Blitz's `build_accessibility_tree` + tests (`accessibility_roles.rs`) are the closest test style **[read]**. Web + adapter: **planned only** in AccessKit (R-F §2.6, R-T §3), so the web backend + mirrors the tree to DOM itself (piece 11b); Flutter web is the shipped precedent, + and its open issues (semantics DOM stealing taps, R-T §3) are the failure + catalogue for 11b. Confidence high on native, medium on web. + +### Piece 11: web backend and shell (`thing-web`) + +- **Owner:** `thing-web` (wasm entry + a small JS host package). Replaces the + Nightwing browser reference (`nightwing-eve-browser-reference`, P8). +- **11a draw:** core compiled to wasm, `frame` called from `requestAnimationFrame` + with batched events; commands drawn to one canvas 2D context (D9: canvas 2D + first, because GPUI web already rasterizes through OffscreenCanvas 2D, R-T §2, + and the contract is already glyph-run based so a GPU path swaps in under the same + commands). The JS host holds **no layout, no text shaping, no focus state**. +- **11b IME + a11y:** a hidden `