diff --git a/.github/workflows/portable-contracts.yml b/.github/workflows/portable-contracts.yml index caf6e8d..cc939f9 100644 --- a/.github/workflows/portable-contracts.yml +++ b/.github/workflows/portable-contracts.yml @@ -8,11 +8,6 @@ on: pull_request: workflow_dispatch: -concurrency: - # A newer push or PR update replaces the older run on the same ref; workflow_dispatch runs are never cancelled. - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} - jobs: contracts-and-browser: name: ${{ matrix.os }} ${{ matrix.package }} diff --git a/docs/thing2-build-spec.md b/docs/thing2-build-spec.md deleted file mode 100644 index 3c74b51..0000000 --- a/docs/thing2-build-spec.md +++ /dev/null @@ -1,825 +0,0 @@ -# Thing 2.0 build spec - -Status: build spec, refreshed 2026-11-10 against the thing2 rulings in force, 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. The standard is "prior-art sources cited"; O12 asks whether it -also fixes a format. - -## 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-ts.r3`, `-cs.r3`) | 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]) waits only on Eve taking the - CultLib pin that carries `decode-stack-safety` (Fixed, CultLib `bbb98eaf`, - `cultlib-gaps:resolution:follow_up.decode-stack-safety.n1`; 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 Eve's pin of CultLib `bbb98eaf` merges) | - | -| `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.r3`, `-cs.r3`; r2 -is superseded) and have -branches (`hands/thing2-contract-ts`, `hands/thing2-contract-cs`) not yet merged. -This spec adds no contract change. Rulings that bind the packages: - -- **Unknown union arms** are refused by CultCache in every runtime, the C# - reference included, and Thing packages carry no check of their own - (`thing2:ruling:contract-decode-tolerance`; the fix is CultLib's, follow-up - `thing2:follow_up:cultcache-union-parity`, resolved in the mind). The "unknown union arm is refused" - test below therefore exercises CultCache, not package code. -- **Nil in required slots** is refused by each package: constructors and factories - reject null, decoding nil into a slot the Rust contract does not make optional - is a typed refusal (`thing2:ruling:contract-refuses-nil-in-required-slots`, - Defaulted). In TS this is a `present(codec)` on every non-Option slot, and - results are validated and frozen at one factory (`cut-contract-ts.r3`). -- **Byte payloads** (`RecordPayload.Bytes`) are a copied read-only view in C# - (`ReadOnlyMemory`); Rust's `Vec` and the wire are unchanged - (`thing2:ruling:payload-bytes-read-only`, Defaulted). The catalog type name for - that slot changes with it, which the 1x store check must read from the C# set. - -- **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. No text-syntax parser - outside `thing-core` (`thing-script-form`): `resolve` consumes only the typed - document; a later `.thingscript` parser is core's and feeds it. -- **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` (`thing:ruling:thing2-thing-owns-text`, operator: - core owns shaping (Parley), editing and IME state, focus and the AccessKit tree; - backends only draw and bridge to platform protocols; glyph rasterization stays - backend-private and the render-command contract carries shaped glyph runs). -- **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 `