Skip to content

UI Profile Level 0: a canonical profile for LLM-authored UI - #1

Merged
ymote merged 63 commits into
mainfrom
ui-profile-l0
Aug 2, 2026
Merged

ymote merged 63 commits into
mainfrom
ui-profile-l0

Conversation

@ymote

@ymote ymote commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Adds UI Profile Level 0 — a canonical Splash profile for LLM-authored UI —
as a sibling to the workflow profile. Additive: +12,007 lines, zero
deletions.
Nothing existing changes behaviour.

Why

Splash has no supported path for generated UI. The workflow profile rejects UI
constructs; the compatibility parser must not receive generated source. A host
that needs one embeds the raw Makepad VM, which restores the filesystem, process
and network surface the standalone runtime masks. That is not hypothetical — it
is what a downstream host did, and how its generated cards came to read and
write arbitrary files.

What L0 is

Constructors, bindings, keyed loops, guarded branches, and components with
declared local state. No expression form, so there is nothing to evaluate:
realization is a walk over the parsed tree with data substituted, and the
implementation contains no reference to the VM. Confinement is structural rather
than sandboxed — no execution machinery in the path, not a guard around it.

Sources are declared and resolved by the host before realization. A card names
what it needs; it cannot fetch.

Contents

  • docs/ui-profile-l0.md — the normative profile
  • docs/ui-l0-constructors.toml — 22 constructors and 12 source capabilities,
    both closed sets, cross-checked against the Rust catalog in both directions
  • crates/splash-core/src/ui_l0.rs — parser, validator, realizer, per-instance
    state store, dispatch, source plans, dependency tracking
  • Three reference cards, which are the profile's evidence: any construct they
    need that the document lacks is a defect in the document
  • mutate.py — mutation testing over the validator's rules

Verification

  • 318 tests
  • 16 named rules, none unprotected under behaviour mutation — each rule is
    disabled in turn and the suite must go red
  • 4 cards rendering correctly on a OnePlus 6T, checked against golden
    images, verified in both directions (an injected 8px padding change drifts
    4.78% and fails)
  • Zero clippy warnings

Review history

Five adversarial review rounds by an external reviewer. Roughly fifty defects
found, the majority in work that had just been called finished — including two
guarantees written into the spec that were false the same day, and two tests
that could not fail. Both are fixed and the spec now states what is enforced
rather than what was intended.

Two later additions — a Splash-grammar surface and a DSL lowering — were built
and reverted on review. Both asserted a contract with a consumer that had
never been opened. Their commits and the reasoning are on the branch.

Known gaps, stated rather than discovered later

  • L1 and L2 are unimplemented. Both are specified only as what L0 excludes,
    so the classifier can name them but cannot check one.
  • Reconciliation is computed but not applied. record_dependencies,
    dirty_records and patch_points work and are tested; realization still
    rebuilds the tree.
  • Cards are inert on device. Lowering emits l0_event/l0_key; nothing
    reads them, and no host drives the event loop.
  • The lowering target is unsettled. makepad::lower reaches one backend of
    three and carries a theme. §1.1 says what a card should lower to and records
    why the first attempt at that retarget failed.
  • StockPlot and AqiContour lower without their data arrays.
  • A raw string literal in a text position is accepted by decision; §4 says so
    plainly rather than implying otherwise.

ymote and others added 30 commits July 31, 2026 11:46
Splash currently has no supported path for LLM-authored UI. check_syntax
rejects UI constructs; eval_vm_compatibility accepts them but its contract says
it "must not receive LLM-generated or otherwise untrusted source". Hosts that
need one bypass Splash entirely, which is how one downstream host ended up
handing generated cards fs, run, net and cx.quit.

L0 is that path at its narrowest: admit UI constructs, admit nothing that can
reach a capability. Sources are queries the runtime executes before evaluation
and injects as data, so the evaluator is handed a host surface with no modules
at all. There is no dangerous call to block because none can be written.

Scope of the claim, stated narrowly: grammar membership and absence of
authority-bearing operations are decidable. Termination is NOT a consequence of
the grammar alone -- section 6 lists the five conditions an implementation must
enforce for it to hold, and a bounded evaluator remains mandatory at every
level. L0 removes the need for an OS authority boundary, not for resource
containment.

Transitions are total by construction: set / toggle / cycle / clear, no
expression form. An earlier draft allowed `expanded: !expanded` while claiming
no computation had been introduced, which was false -- and once expressions are
admitted the grammar must answer for recursion, unbounded numerics and event
cascades.

Section 2.0 records eight amendments the three reference cards in
octos-one/docs/l0/ forced on the first draft, and one they asked for that was
refused: a derived sub-collection (`stories.rest`) would be the thin end of
computation, so the runtime shapes the data and the view does not reshape it.

Component contracts (outputs, slots, shared state, lifecycle) and the level
classifier over the transitive component closure remain unspecified; section 8
lists them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Level 0 proposal was added as a standalone file that nothing referenced, so
the spec still read as though only two paths exist. Neither of them accepts
generated UI:

  grammar.md          canonical profile        rejects UI constructs
  grammar.md          compatibility APIs       "must not receive LLM-generated
                                                or otherwise untrusted source"

A host needing generated UI therefore embeds the raw Makepad VM, which restores
the inherited UI, debug, shader, GC and direct-output bindings the standalone
runtime masks -- and with them an ambient filesystem, process and network
surface. Not hypothetical: that is what a downstream host did, and how its
generated cards ended up able to read and overwrite arbitrary files.

This states the gap where a reader will meet it:

- grammar.md gains "Generated UI source" under the Compatibility Boundary,
  since that section otherwise implies the two boundaries are exhaustive.
- language-profile.md now scopes itself as the WORKFLOW profile and says the
  two profiles are siblings, not nested -- a card is not a workflow with widgets
  bolted on.
- roadmap.md lists what must exist before L0 is implementable: a normative AST,
  a level classifier over the transitive component closure (local inspection is
  insufficient -- a referenced component being replaced can widen a card's
  level, so versions must be pinned), component contracts, recursive instance
  identity, state-schema versioning, and the five conditions that make an L0
  realization terminate.

L0 remains proposed and unimplemented, and all three files say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Props are typed by the state shape vocabulary, may carry a literal default, and
are immutable inside the component -- an input, not storage.

Scope: a component may read its own props, state and events, plus card-scope
`source` and `copy`. It may NOT read card `state`. Sources and copy are
immutable and already declared dependencies, so reading them creates nothing
implicit; card state drives reconciliation, so it must arrive as a prop or the
dependency becomes invisible to the tracker.

Outputs are event props and need no new mechanism: an event name is a path,
operands already admit paths, and the transition runs in the scope where it was
DECLARED. A child therefore cannot reach state it was not handed a route to, and
cannot raise an arbitrary event because there is no expression form to build a
name with.

Refused, with reasons:
- hoisted/shared state -- raises an ownership question the grammar cannot
  answer, and a Context-like mechanism reintroduces the invisible dependencies
  the declared-source rule exists to prevent
- cross-field invariants -- total transition forms cannot compare, so
  start-before-end belongs in the shape and is enforced by the runtime. A card
  needing more is L1 and should say so rather than smuggling a comparison into
  a transition
- per-component sources -- a component instantiated n times would fan out n
  fetches with n determined by data, breaking the bound termination requires

Lifecycle is decided entirely by identity: mount initializes from `initial`,
update preserves local state, unmount discards it. Duplicate keys are an error
at realization, never coalesced -- silent coalescing would make two rows share
one state cell, which is what keys exist to prevent.

State schema versioning resets by default and preserves only opt-in. A ledger
edit that adds a field cannot be assumed compatible with live values, and
guessing is how a rolled-back ledger feeds version-A state to version-B code.
Consequence stated plainly: folding the ledger reconstructs the program, not
the visible application state.

Validated by adding components to two reference cards, which produced amendment
#9: bare identifiers are ambiguous -- `width: rank` is a layout token and
`text: position` a bound path. Resolving by scope would be a trap, since
declaring a prop named `fill` would silently change `width: fill` everywhere.
Argument shape decides instead. Nothing surfaced this before, because props are
the first bindings a card author names freely; both cards had to rename a prop
before the ambiguity was even visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 0 of the implementation plan: the closed token sets per constructor
argument that section 2.2 depended on. Derived from the three reference cards,
not invented -- every constructor and argument in the catalog is one they use.

Cataloguing them broke the rule it was meant to serve. Amendment #9 had said a
bare identifier resolves by the argument's declared shape, so each argument
decides whether a name is a token or a path. That fails the moment one argument
admits both, and `unit` does: `unit: units` follows card state while
`unit: .pct` is fixed.

Tokens are therefore lexically marked with a leading dot, and a bare identifier
is always a path. The ambiguity is removed at the lexical level rather than the
semantic one, so the catalog only says which tokens are LEGAL rather than
disambiguating what a name MEANS, and a prop named `fill` can never collide
with `.fill`.

Enum members follow the same rule: `shape: enum[c, f]` defines the set with
bare names, every use is dotted -- cycle(.c, .f), initial: .m1, range == .d1.

Worth recording how this surfaced. The token/path ambiguity was invisible until
components existed, because props are the first bindings a card author names
freely. The `unit` case that broke the fix was invisible one step further, until
arguments were catalogued. Each pass found what the previous one could not have,
which is the argument for cataloguing before writing a parser rather than after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1: the grammar meets a real parser. 28 tests, the three reference cards
accepted, the shipping nav card rejected as L2.

Decides two properties: membership in the L0 grammar, and absence of any
authority-bearing operation. The second is structural rather than enforced --
the grammar has no syntax for a module access or a call, so there is nothing to
reject; the mod./ui. checks exist to classify such source with a useful
diagnostic, not to hold a boundary. Termination is NOT decided: of the five
conditions in profile section 6 this checks two, component-graph acyclicity and
iteration over a bound path.

Validates transitions against the state they target -- a total form, `toggle`
only on bool, and `cycle` members declared in the enum shape. Validates
component scope: a component may read card sources and copy but not card state,
which must arrive as a prop or the dependency is invisible to the tracker.

Writing it found three defects that four prior passes could not have:

- `when expanded { }` -- a bare boolean guard. The grammar required a
  comparison, and the weather card had been using the natural form all along.
  Amendment #10.
- Indexed paths were specified in amendments #3 and #4 but never implemented,
  so `lead.0.title` failed to parse.
- Event props were specified in section 5.4 but the validator checked component
  arguments as paths, rejecting `on_open: open_story`.

And two bugs of the implementation's own, both worth recording:

- A lone `!` lexed as a comparator, so the level classifier's punctuation check
  never fired and `open: !open` was reported as a parse error rather than as
  L1. It is now punctuation; only two-character forms are comparators.
- The classifier returned at the FIRST construct outside L0, which reports
  whichever appears earliest rather than the widest. The nav card proves it
  matters: arithmetic on line 55 precedes fn tick() on line 80, so it was
  classified L1 -- a card with 65 imperative widget commands. Level is a scan
  now, not a search, and the profile says so.

`//` is lexed as a comment alongside `#`, so a Splash-syntax card is classified
on its code rather than on its prose. Getting this wrong is what put incorrect
loop counts in a downstream design document across four revisions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2: lower a validated card to a renderer-neutral node tree. 38 tests; all
three reference cards realize, the nav card is still rejected as L2.

The finding is that the profile was wrong about its own execution model. It
said an L0 EVALUATOR is handed a host surface with no modules, and that a
bounded evaluator -- heap, stack, call-depth, instruction and time limits --
remains mandatory. But L0 has no expression form, so there is nothing to
evaluate: realization is a pure walk over the parsed tree with data
substituted. `grep -c 'ScriptVm|Runtime|vm::' ui_l0.rs` returns 0.

Authority confinement is therefore structural in the strongest available
sense. Not a sandbox that blocks calls, and not an evaluator holding an empty
module table, but no execution machinery in the path at all. The bounds that
remain are traversal bounds -- node count, nesting depth, collection length --
and the doc now says so instead of specifying instruction budgets that cannot
apply.

Realization details worth recording:

- Instance identity is the declaration path plus every enclosing loop key,
  never position. A test inserts a sibling ABOVE the forecast rows and asserts
  their keys are unchanged, which is the property the whole reconciliation
  story depends on.
- A missing binding realizes as NodeValue::Missing rather than defaulting to
  empty. A silently blank field is how a card renders an em dash and still
  looks correct.
- Event arguments carry a declared NAME for the runtime to bind at dispatch,
  resolved through the catalog rather than looked up in data -- directly, and
  through a component prop.
- An unresolved source renders nothing rather than an empty row; pending is the
  runtime's to surface, not realization's to invent.

Two implementation notes: the crate denies `unsafe`, which caught a lazy
raw-pointer workaround for a borrow that `card: &'a Card` outliving `&mut self`
resolves properly. And the parser had been discarding the guard comparator and
the loop key expression, both of which realization needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 3. weather.card goes card -> parse -> validate -> realize -> DSL ->
OnePlus 6T, through an UNMODIFIED octos-one. The bridge is text: the app
consumes Splash DSL, so lowering to it needs no host change and no new
dependency.

The DSL is fully concrete -- realization already substituted the data, so it
carries values rather than sys.* calls. That is the ledger discipline holding:
the card declares questions, and only the realized output contains answers.
A test asserts `sys.` appears nowhere in the lowered text.

Generating real output found two bugs that 41 passing tests had not:

- The parser recognised string and number literals and threw their VALUES
  away, so every glyph and literal label realized as empty. The card said
  `glyph: "↑"` and the screen showed a bare `21°`. Tests had only ever checked
  bindings, never literals.
- weather.card treated `week` as both a collection to iterate and a record
  carrying min_lo/max_hi. An array cannot be both, so every TempBar silently
  scaled against 0..0 -- a flat bar that looks plausible. The source now
  returns a record: `week.days` plus the bounds. Those bounds were a
  hand-authored wmin/wmax in an earlier card and got invented by the model;
  here they arrive with the data they describe.

Neither was reachable from unit tests. Both needed the pixels.

The lowering renders an unmapped constructor as a visible warning rather than
skipping it, and a missing binding as an em dash rather than a blank. A card
that silently drops a section looks correct and is not.

`makepad::lower` belongs in Splash-Makepad, not splash-core -- it is one
backend's text format and nothing in realize() depends on it. The module doc
says so; it lives here until that crate is wired up.

Known gap: AqiContour lowers with only its index, so it renders as a flat band
rather than a field. Passing the grid needs the shader's uniform layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
You were right that this was missing. The component model DECLARED local state,
keyed instances, and specified mount/update/unmount and schema versioning in
sections 5.7 and 5.8 -- but nothing stored a value. Every instance fell through
to one global data blob, so seven ForecastRow instances read the same cell. The
half the model exists to provide did not exist.

InstanceStore holds `instance-key -> field -> value`. Realization binds a
component's declared state from it, keyed by instance identity, falling back to
the declared initial. `dispatch` applies an event's transitions to one
instance's cells.

Nothing in the store is authored by the model. A card cannot name an instance,
cannot address another instance's cell, and cannot write anything except through
a declared transition on its own state -- there is no expression form to build a
key or a name with.

Two details worth recording:

- A backend dispatches with the key of the NODE that was tapped, which sits
  inside the component (`root/0[a]/0/Rowy/0`) rather than at its boundary. The
  cell belongs to the instance, so dispatch truncates to the component segment,
  matching whole segments only so `/Row` cannot match `/Rowy`. Where components
  nest, the innermost one declaring the event wins.
- A state schema change resets instances rather than migrating them (5.8).
  Guessing that an old value still fits is how a rolled-back ledger feeds
  version-A state to version-B code, at which point last-known-good is no longer
  known good.

Two tests carry the properties: opening one row leaves the other closed, and
adding a field to a component clears its live cells.

Process note: the first attempt at this patch silently did not apply, because
cargo fmt had reflowed the call site my exact-string match depended on. It
compiled and 42 tests passed while the feature was absent -- the failing test
was the only thing that caught it. Every subsequent patch asserts its match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five gaps, all "specified but not retained" -- the parser recognised the syntax
and threw the value away, so each compiled, passed its neighbours' tests, and
did nothing.

- `set(...)` discarded its operand and dispatch skipped the form entirely, so
  no list could select anything. It now carries a payload, a token, a literal
  or a path, and dispatch_with supplies the value the raising element held.
- `initial:` was parsed for its shape but not its VALUE, so a guard on a
  freshly-mounted state compared against null and took the wrong branch on
  first render.
- `slot` parsed and realized to nothing, so a component's children vanished.
- `prune` existed and nothing called it; RealizeReport now reports live_keys.
- `format:` was declared on six constructors and ignored.

Card-scope events and state also had no path: dispatch only resolved component
events, so `event choose { picked: set($value) }` at card scope was dead.

Three bugs the 6T found that no unit test could:

- Forcing `width: Fill` on every text node wrapped a long hero one character
  per line -- "Top Movers" rendered as "Top / Mover / s". Width is now emitted
  only when the card asks.
- `tint` was passed through and ignored, so every percentage was white. It
  colours by sign now; which colours mean up and down is the runtime's call.
- TextHero is sized for the ONE dominant value: "18°" fits at 62, "$184.20"
  clipped off the right edge. It scales to fit -- the card says "this is the
  hero", not "this is 62 points".

Both reference cards used TextHero for a masthead, which is the wrong role for
prose. Fixed in the cards rather than the lowering: the role vocabulary exists
to encode exactly that distinction.

Validated on a OnePlus 6T through an unmodified octos-one: the movers list
renders with signed tints, tapping NVDA switches to the quote detail, and the
stat grid shows $181.00 / 41.2M / 4.5T / 58.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The profile, the canonical grammar and the roadmap all still described L0 as
proposed and unimplemented, and all three described an "L0 evaluator" receiving
a host surface with no modules -- a model the implementation disproved.

- The profile is marked implemented, its transition table lists the four `set`
  forms now that they carry operands, and §5.7 states the pruning contract that
  `live_keys` makes possible.
- grammar.md's Generated UI source section names the entry point and says what
  it does NOT do: realization never enters the VM, so authority confinement is
  structural in the strongest sense -- no execution machinery in the path,
  rather than a sandbox or an empty module table.
- The roadmap moves the profile from Next to Complete and replaces the
  six prerequisites with what is actually left: component version pinning, live
  sources, reconciliation, named slots and migration, and the three termination
  conditions the implementation leaves to the caller.

Open question 4 is settled and struck: `format:` is a declared token the runtime
applies. A card that wrote "$" or "41.2M" itself would be authoring a fact, and
currency, grouping and compaction are locale-dependent besides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ui_l0.rs` states twice -- in the module doc and on the catalog module -- that
"a test asserts the two agree". It said so for two commits while no such test
existed, so the Rust catalog and docs/ui-l0-constructors.toml could drift
silently. A documented check that was never written is worse than no check,
because a reader stops looking.

It compares constructors in both directions and checks the shared token sets,
and it was verified to FAIL on divergence rather than pass vacuously: renaming
a constructor in the TOML and dropping one token each produce a specific
failure naming the offender.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps a golden review found on device, both invisible to 49 passing tests.

`copy` is authored in the ledger, so it must resolve from the CARD rather than
the injected data. It was being carried in both, which is duplication that
drifts -- the news card gained a `copy` its data blob lacked and the eyebrow
rendered as an em dash. The declarations are now parsed with their locale map,
selected by the language the runtime reports, falling back to English so a card
shipping zh text does not render a dash on an en device.

`suffix` was in the constructor catalog and ignored by the lowering, so every
score on the news card read "412" where the card said "412 pts".

The news card also used one `copy` for both its eyebrow and its title, so "Top
Stories" rendered twice. Fixed in the card: the plan layer it replaces had a
distinct label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o fetch

The fourth "specified but not retained" in this profile. `skip_source_query`
threw away the helper name and every argument, so a card could declare
`source now sys.weather(lat: place.lat, fields: [temp, hi])` and nothing could
act on the declaration. It is now parsed.

`source_plan` returns what to fetch and in what order. Sources form a DAG --
`now` reads `place.lat`, so `place` must resolve first -- and the order is
topological, so a host walking the list always has what the next request refers
to. A cycle is reported rather than resolved: two sources that read each other
cannot both go first, and picking one silently would fetch against a value that
does not exist yet.

It returns what to fetch and never fetches. The card names a helper; only a host
knows what answers `sys.weather`, and that separation is exactly what lets
realization run against an empty host surface.

Proven end to end with live data: the weather card's eight sources resolve in
dependency order against open-meteo -- geocoding Kyoto first, then current
conditions, forecast, daylight and air quality against those coordinates -- and
render on a OnePlus 6T at 25.4 degrees with the week scaled to a real 24.2-36.5
range.

One near-miss worth recording. The first forecast day rendered as Sat while the
device clock read Friday, which looks exactly like the "day labels drifted from
the forecast" bug that produced the no-facts rule. It was correct: open-meteo
returns Kyoto's local dates and it was already Aug 1 there. Verified against the
API before changing anything -- the same trap caught me earlier in this work
with a sunrise time, where I called a real pre-dawn reading a cache bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uches

L0 needs no dynamic read-tracking. It has no expression form, so every binding
is structurally visible and the dependency set is computable WITHOUT evaluating
anything. That is the difference from the signal graphs a dynamic language
needs: there is no branch whose reads depend on a value.

`patch_points` is the minimal set a backend must re-realize. Measuring the naive
version first showed why it is needed:

  write "expanded"  ->  1/7 records  (ForecastRow only)
  write "units"     ->  3/7          (sunmoon and airfield survive)
  write "city"      ->  7/7          (re-geocoding invalidates every source)

Two gaps the measurement exposed, neither visible from the code:

- `root` was dirty for almost every write, because it splices every other record
  and therefore reads everything they read. Patching root rebuilds the tree,
  which is what reconciliation exists to avoid. A record is a patch point only
  when its OWN bindings read a changed path; an ancestor containing it is
  handled by patching the descendant.
- Writing `city` reported ZERO dirty records, though a source reads it. Sources
  were absent from the graph entirely. A write a source reads now invalidates
  that fetch and everything binding its result, transitively — so `city`
  correctly reports a full rebuild rather than nothing.

Over-approximation is deliberate at the edges: re-realizing too much is slow,
re-realizing too little shows stale data, and only one of those is a
correctness bug.

This is the gap `sys.navsecs` exists to work around — a clock that avoids
triggering a rebuild because a rebuild tears down the live map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pt-in migration

Closes the profile's open-question list against the reference cards, which is
the method the profile prescribes: a construct the cards need that the document
lacks is a defect in the document.

Settled by implementing (spec §2, §5.5, §5.8, §5.9, §3):

  5  named slots — `slot name` in a component, `into name { … }` at the call
     site. The anonymous slot stays the default, so existing cards are
     unchanged. An `into` naming a slot that does not exist is REJECTED; its
     children would otherwise render nowhere.

  3  source lifecycle — `<source>.$state` yields .pending/.ready/.stale/.failed.
     Absence is not a state: without this a card cannot distinguish "loading"
     from "the network is down" from "no value", and renders the same em dash
     for all three, which looks like working software showing real data. The
     host reports it; the card never computes it. Cadence belongs to the
     capability, not the card.

  6  opt-in migration — `keep: true` exempts one cell from the §5.8 schema
     reset, and only while that field's own shape is unchanged. Without the
     second condition `keep` would BE the "assume the old value still fits"
     that §5.8 exists to prevent.

Settled by deciding, with the cards as evidence:

  1  `when` needs no `else` — both branching cards already use complementary
     guards, and there is no expression form to negate.
  2  `cycle` advances in enum declaration order. A `cycle(…)` listing a
     different order is now rejected rather than silently running the declared
     one — previously `cycle(.f, .c)` on `enum[c, f]` ran c→f.
  4, 7  already settled; struck from the table.

§6 condition 4 (event cascades) is guaranteed STRUCTURALLY, not by a check: no
total form can name an event, and a transition target must be declared state.
Pinned by a test enumerating the syntaxes a cascade could take, since a future
form that could raise an event would silently move termination to unchecked.

§7 version pinning: the report now carries a per-component definition digest.
Level is a judgment over the component closure, so pinning the level alone lets
a replaced definition carry an approved L0 verdict onto code that no longer
earns it. Ignores declaration order — a digest that moved on a reformat would
train hosts to ignore it.

Two bugs found on the way:

  - A LITERAL prop resolved to Missing. `Framed(title: "Details")` rendered an
    em dash for the life of the component feature, because the realizer's match
    had a catch-all `_ => Null` arm below Path and Token. No test caught it: the
    reference cards pass only paths. Same "specified but not retained" shape as
    every previous instance.
  - `set(path)` did not validate its operand, so `set(some_event)` parsed and
    wrote garbage into the cell.

85 L0 tests (was 64), 232 across the crate. Zero clippy warnings.
The 'Remaining' list is this repo's change-request log, and it still named four
things that are now implemented — component version pinning, named slots, state
migration and named-slot-adjacent termination gaps — plus a test count that was
stale by 37.

Replaces them with what is actually left: source-declaration-to-helper mapping,
applying the reconciliation the dependency tracking already derives,
makepad::lower living in the wrong crate, two widgets that lower without their
data arrays, and L1/L2 being specified only as what L0 excludes.
The profile's §5.1 says, as its stated difference from React: "L0 binds to a
declared path so that identity survives a ledger edit that inserts an element
above." The implementation keyed children by their raw child index, so it was
using React's rule while the document said it was not.

Inserting a Rule() above a component moved its key from root/0/Toggly/0 to
root/1/Toggly/0 and the instance lost its local state. For a ledger that is not
an edge case: appending a record that adds a heading above a list is ordinary,
and it collapsed every expanded row on screen.

Sibling path segments are now numbered within each element NAME — Row#0, Row#1 —
so keys stay unique for hit-testing while a Rule() inserted above leaves
Toggly#0 untouched.

Found by asking what L0 has in common with React, which is exactly the question
§5.1 answers; no test covered the claim, which is why the divergence survived.
Both directions are now tested: state survives an insertion above, and two
sibling instances of one component still keep separate cells.

87 L0 tests, 234 across the crate. Device suite green on all four cases.
… sys.shell

Codex review, critical finding. The parser accepted any dotted helper name with
no allowlist, and source_plan handed it to the host verbatim as the function to
resolve. This parsed clean, with zero diagnostics, and produced a well-formed
request:

    source leak sys.shell(command: "cat /etc/passwd")

Confinement then rested entirely on the host keeping its own allowlist — the
exact policy boundary §1 claims L0 removes. Realization was structurally
confined; card ingestion was not.

Twelve capabilities are now catalogued in docs/ui-l0-constructors.toml and
mirrored in catalog::SOURCES, derived from the reference cards rather than
invented. Arguments are checked too: a helper that silently ignores an unknown
argument is how a card asks for something it did not get and renders as though
it did.

Checked in BOTH places. check_ui_l0 rejects, and source_plan independently
filters and reports — a caller that skips validation must still not be handed
sys.shell. The catalog-agreement test compares names AND arguments in both
directions, which the constructor one does not yet do.

Test fixtures that invented sys.list/one/two now name real capabilities; the
helper was never what those tests were about.

238 tests, zero clippy warnings.
Codex review, five confirmed findings sharing one shape — the grammar accepted a
form, the parser threw its value away, and the card rendered as though it had
asked for nothing. Each reproduced before the fix and is now tested.

  active: range == .d1
      The parser kept the left path and discarded the comparator and right
      operand, so this realized as the VALUE of `range` rather than a boolean.
      Live in stock.card. `Operand::Predicate` now carries cmp and rhs, and
      guards and argument predicates share one `compare()` so they cannot drift.

  when selected != absent
      A guard's right operand was never checked, so an UNDECLARED name resolved
      to nothing and `!=` returned true on that absence — the branch rendered.
      Both operands are now checked, and an unresolvable comparison is false
      rather than true-for-!=.

  initial: env.locale.temp_unit
      A path-valued initial was recognised and dropped, so the state fell back
      to the enum's first member: a phone set to Fahrenheit rendered Celsius.
      This is weather.card's actual declaration. Now held as `initial_path` and
      resolved against injected data at realization.

  stories[selected].title
      The index path was parsed and discarded, leaving `stories.title`, which
      resolved to nothing. Now retained as a `[…]` segment, resolved through its
      inner path, and the inner path is itself checked — `movers[typo]` is a
      diagnostic rather than an em dash.

  set(1) / set(config.answer) / set(true)
      SetSource had no numeric or boolean variant, so `set(1)` fell through to
      the payload form and did nothing when no payload was present. Path was
      treated as a synonym for $value, so `set(config.answer)` wrote whatever
      the tap carried. Added Num and Bool; Path now reads, via a new
      dispatch_with_data that supplies what a reading transition reads from.
      `$anything` was also accepted as $value — a typo silently became the
      payload — and is now rejected.

Note the device suite is unchanged at 0.00%: the Chip `active` fix moves no
pixels because the Makepad lowering ignores the argument entirely. That is the
next finding, not evidence this one did nothing.

245 tests, zero clippy warnings.
…ppearance

Codex review. UiNode carried the event name, the instance key and the payload
all along; the Makepad backend emitted none of them. Every tappable node
rendered inert — the stock range chips could not reach `dispatch` at all, so
the cards looked finished and did nothing. No golden image could detect that,
because nothing about the pixels was wrong.

Row, Card, Chip and every Text role now emit `l0_event`, `l0_key` and
`l0_value`. The key is what lets a tap name WHICH instance was hit, which is the
entire basis of §5.1 identity; without it a tap could say what happened but not
to what.

`Chip(active:)` selected nothing, so all five range chips rendered identically.
Now the selected one takes a brighter fill.

Also extends the insertion-stable keys from the previous commit to guard, slot
and loop bodies. That fix only covered ordinary constructor children — codex was
right that it addressed one case of four, so `for x in xs { Rule() Toggly() }`
still lost state when the Rule was added.

The stock-detail golden changed and was recaptured after review: it had been
certifying the bug, showing five identical chips where the card asks for `1M`
to be selected. Verified by eye against the data (`range: "m1"`, `initial: .m1`)
before replacing.

100 L0 tests, 247 across the crate, zero clippy warnings, device suite green.
Codex review, two things the spec asserts and the code did not do.

§7 says "level is declared in the header and checked at append". Nothing read
it: every `#` line lexed as a comment, so a card could declare `# level: L2`
and a bogus profile and be accepted and realized as L0. A declaration that is
never compared to the derived level is a comment, not a check. The header is
now parsed and returned on the report, and a declared level or profile that
contradicts the card is rejected. A card with no header is still fine — only a
header that disagrees is an error.

§4 calls a model-authored literal in a data position "a structural violation,
not a judgement call". It was neither. `kind = "path"` was declared throughout
the catalog and never enforced, so `TempBar(lo: 5)` and
`WeatherIcon(cond: "sunny")` — widgets that exist to render live data — took
invented values. And `TextHero(value: "34 mph")`, codex's example, was accepted
verbatim: a measurement and a unit the model wrote as one string.

Enforcing it needed a distinction the catalog could not express, which is why
the rule had gone unimplemented rather than merely unchecked. `Number` covered
both `gap: 6` and `value: 41.2`, and those are not the same: a card is entitled
to choose its own layout and is not entitled to invent a measurement. Added
ArgKind::Data for the five `value` arguments that render facts. Every `value:`
in the three reference cards is already a bound path, so the cards were always
observing a rule the checker could not state.

254 tests, zero clippy warnings, device suite green.
Codex review. Separated from the code fixes because these are defects in the
document itself — several were assertions about behaviour that never existed,
which is worse than an unimplemented feature because a reader has no reason to
doubt them.

Factually wrong:
  - "48 tests" (254).
  - "a test asserts the Rust catalog and the TOML agree in both directions" —
    it compared constructor NAMES and three token sets. Changing TextHero.value
    from number to text in the TOML would have left it green. Reworded to what
    is actually asserted; the source-capability catalog does compare arguments.
  - The EBNF `shape` omitted `record`, `collection` and `event`, which §5.2 adds
    and the reference cards use.
  - §5.6's `state start { shape: date, initial: today, max: end }` is not
    grammatical in any part — now marked illustrative, with the real limit it
    describes kept.

Internally inconsistent:
  - §5.1 said identity includes "component name and version" while §5.8
    promises a view-only edit preserves state. Those contradict if "version"
    means the definition digest. It means the STATE SCHEMA version; the two
    instruments are now distinguished.
  - §5.1 also now states what path identity does NOT survive — reordering
    same-name siblings, or another instance inserted above. Numbering within a
    name is better than tree position, not a solution; that needs a persistent
    site id or ledger edit provenance.
  - §5.9 argued absence cannot distinguish loading from failure from no-value,
    then inferred ready/pending from presence. The fallback is now stated as
    what it is — sound only under an explicit host contract that a key is
    present iff it resolved successfully and is current.

Overstated, including by me in this branch:
  - §6 condition 4 claimed "no total form can name an event". False:
    `set(out)` is accepted where `out` is an event prop, because component prop
    types are discarded. It does not cascade, but the guarantee comes from the
    Form enum and its exhaustive dispatcher, not from the grammar. Recast, with
    the requirement that any new Form variant be checked against it.
  - §7's digest claim now lists what it misses: param shapes, text/number
    retyping, unreachable components causing false repins, and FNV-1a being a
    change detector rather than an adversarial boundary.
…y hold

Codex review, last group. Each of these existed and was cited as evidence; none
was strong enough to be.

Catalog agreement. The test compared constructor NAMES and three token sets, so
retyping an argument in the TOML left it green — and §8 cited it as proof the
two agreed "in both directions". Now compares every argument and its kind, and
the argument COUNT so an addition on either side is caught. Verified by
injecting a divergence: retyping one `value` from data to text now fails with
"TextHero.value: TOML says \"text\", Rust has Data". The old test passed on that
same injection.

Acyclicity (§6.1) walked components only. Two shapes recursed at realization:

    view a b        view b a
    component A { view v }   view v { A() }

Both were caught, if at all, by the node cap — a resource bound standing in for
a structural check, and only when the caller set one. Now walks components and
views as a single declaration graph.

Depth (§6.5) was approximated by the instance KEY'S LENGTH, which bounds neither
direction: a long loop key truncated a shallow tree, and short view names
recursed far past max_depth, which with a permissive caller limit is a stack
overflow. Replaced with an actual recursion counter. Tested with the case the
proxy got wrong — a two-row tree with 120-character keys, which used to truncate
and now realizes both rows.

259 tests, zero clippy warnings, device suite green on all four cases.
Three defects from the codex review that looked unrelated and shared one cause:
the parser read a param's name, skipped its type, and threw it away. With no
shape, none of the three checks could even be expressed.

  F(titel: "x")
      An argument the component does not declare was accepted, then silently
      dropped at realization — the card rendered an em dash and looked
      deliberate. Now rejected, naming the props that do exist.

  set(out) where `out: event`
      Accepted, because no param shape was known so none could be excluded from
      the readable set. Event props are no longer readable.

  Row(on_tap: label) where `label: text`
      Every param was added to the component's event names, since none could be
      ruled out. So a text prop could be handed to on_tap, and a runtime string
      that happened to equal a declared event would route it — event routing
      became data-selected. Only event-shaped props are event names now.

Fixing it required extending Shape, which had collapsed `text` and `number` into
one unclassified variant. That closes a fourth finding: retyping a state between
them moved neither the schema id nor the closure digest, so a live string
survived into number-shaped state and a host pinning the digest saw nothing
change. Both now move. §7's known-limits list is updated accordingly.

Enum props are checked against their members as a consequence — `F(unit:
.kelvin)` against `enum[c, f]` now names the two that are legal.

265 tests, zero clippy warnings, device suite green.
…ted sources

Review of the six commits that were themselves responses to the first review.
Six confirmed defects, four of them in things I had just built or just written.

  Header was last-declaration-wins, so a card could declare what it needed and
  then declare the truth after it — `# level: L2` followed by `# level: L0` was
  accepted. Contradictory duplicates are now rejected.

  check_header ran only after a successful L0 parse, so the path a card takes
  when it UNDER-declares its level — the case the check exists for — skipped
  the comparison entirely. Now checked on that return too.

  ArgKind::Data rejected strings and numbers and let TOKENS through, so
  `TextHero(value: .money)` passed the no-facts check added one commit earlier.

  `set` wrote whatever it was handed regardless of the target: `set(1)` into a
  bool, `set(true)` into an enum, `set(.c)` into an enum that has no `c`. All
  four spellings now check against the declared shape.

  A dotted source registered only its ROOT, so `source env.locale` could not be
  asked for its lifecycle while `env.$state` — naming no source at all — could.
  Registers the full name; the realizer navigates it rather than looking for a
  key spelled "env.locale".

  schema_id claimed to cover "names, shapes and initials" (§5.8) and hashed only
  path and shape, so changing an initial did not reset live state.

Four claims corrected in the spec, all mine:
  - §6 said Form has "exactly four variants" and an "exhaustive dispatcher". It
    has five and ends in a catch-all, so nothing would force review if a variant
    were added. The no-cascade property is true of the forms that exist and is
    not structurally protected — which is the opposite of what I wrote.
  - §5.1 claimed identity includes the card namespace and schema version.
    Neither is in the key. Also notes that two cards sharing a store can collide.
  - §5.1's survival list was incomplete: enclosing guards, loops, slots, view
    references and loop keys all change identity too.
  - §7 said the digest ignores declaration order. It sorts components; order
    within one is hashed. Added what it still omits — loop keys, view
    references, defaults, referenced view bodies.

Still open and NOT fixed: `TextTitle(text: "Revenue: $41.2M")` is accepted.
Copy provenance is parsed away, so §4's "vocabulary or source-derived" test
cannot be applied to a text position. Codex is right that blanket-marking text
arguments as data is the wrong fix — `glyph` is closed presentation vocabulary,
`suffix` can materially change a fact, and `label`/`text` are mixed. That needs
retained provenance, not a broader category.

270 tests, zero clippy warnings, device suite green.
…t is refused

§4 requires a rendered literal to be "vocabulary or source-derived" and calls
that "a structural violation, not a judgement call". It was neither — it was
UNDECIDABLE. parse_locale_map kept only entries whose value was a string, and
`class: vocabulary` is an identifier, so the provenance was discarded before
anything could read it. The rule had no data to run on.

Worse, the rule could not be written in its own language: `-` lexes as a minus,
so `class: user-copy` was classified as arithmetic and rejected as L1. Both
spellings §4 names were unusable in a card. The classifier now recognises a
hyphenated copy class; an unknown class is a diagnostic rather than a silent
fallback to vocabulary.

With provenance retained, `copy.x` where x is `model-copy` is refused in any
rendering position — the class exists to be refused, not recorded. An
undeclared `copy.name` is also rejected now; `copy` was a known root, so a typo
resolved to nothing and rendered an em dash.

NOT done, deliberately: a raw string literal in a text position is still
accepted, so `TextTitle(text: "Revenue: $41.2M")` passes. A literal has no
declared provenance, which by §4's own logic makes it model-copy — but refusing
it is a language-ergonomics decision, not a bug fix, and it is the user's call.
Measured cost: 5 sites in the reference cards and 17 test fragments. Codex is
right that a blanket rule is wrong regardless — of the 11 literals in the cards,
6 are `glyph:` symbols (‹ ↑ ↓ ≈) which are closed presentation vocabulary rather
than language, and only the 5 chip labels are copy in the sense that matters.

275 tests, zero clippy warnings, device suite green.
The section opened by calling a model-authored literal 'a structural violation,
not a judgement call'. That is true of numeric and path positions, where a
literal is now refused outright, and false of text positions, where a raw string
is accepted by decision.

Leaving the original wording would have repeated this session's most common
defect — the spec asserting a guarantee the code does not provide, which a
reader has no reason to doubt. There is now a table of what is checked where,
and a plain statement of the consequence: a fabricated NUMBER cannot reach a
numeric position, and fabricated PROSE can reach a text position.

The first is the failure mode the rule exists for and it is closed. The second
is open deliberately: closing it would require every human-readable string to be
declared copy, and six of the eleven literals in the reference cards are glyph
symbols where translation is meaningless.
A green suite proves the code does what the tests say. It does not prove a test
would notice if the code stopped. Three review rounds found guarantees that were
false within hours and a regression test of mine that could not fail at all, so
"285 tests pass" had repeatedly meant less than it looked like.

mutate.py answers the harder question mechanically: suppress one diagnostic at a
time, run the suite, see whether anything goes red. First run — 23 rules held,
14 unprotected. Every one of those 14 was enforced and free to regress silently.

Nine had no test at all and now do: `$state` as a non-final segment, `cycle` on
a non-enum, an undeclared view reference, `set` from an unreadable name, `into`
on a component with only an anonymous slot, a token where a path belongs, a
literal where an event prop belongs, a loop over a non-collection, and a card
with no `view root`.

Writing them corrected three of my own assumptions:

  - "not a collection" and "needs a view root" are REALIZATION diagnostics, not
    validation ones. Both tests failed until pointed at the right entry point.
  - That exposed a real gap: check_ui_l0 ACCEPTED a card with no `view root`.
    Only realization refused it — and the checker is what a host asks before
    storing a record, so an unusable card could be appended and fail later, at
    the point someone tried to look at it. Now refused at check.
  - `let` classifies as L2, not L1. Defensible — an imperative binding is not a
    pure expression — but I assumed L1 and the test said otherwise.

The most instructive find is in a test written the same day.
`a_contradictory_duplicate_header_field_is_rejected` used `# level: L2` then
`# level: L0`; the retained L2 tripped the level-vs-derived check, so the card
was refused by a DIFFERENT rule and the test passed with the contradiction check
removed entirely. It now uses a first value that is valid on its own, asserts
exactly one diagnostic fires, and was confirmed red with the rule disabled.

Five rules the harness cannot reach are documented in KNOWN_ARTIFACTS with the
reason and the hand-verification: three are held by the LEVEL a card is assigned
rather than by their message, and two write report.diagnostics directly, so the
hook in Diagnostics::push never sees them.

38 rules, 0 unprotected. 285 tests, zero clippy warnings, device suite green.
Eight of the thirteen findings, each written as a failing test first and
confirmed red before any fix. That discipline paid immediately: two of my tests
failed for reasons I had not predicted, and one of those was a real gap.

PARSER (#6, #7, #8)
  parse_state scanned the whole body for shape words instead of reading the
  value after `shape:`, so an enum RETYPED ITSELF: `shape: enum[a, text]`
  parsed as the enum and then the member `text` overwrote it, leaving a field
  that accepted any string. Rewritten to read each key's value.

  A misspelled shape produced `Other` silently, and `Other` is treated by the
  set checks as "accepts anything" — so `shape: nubmer` disabled them. Now a
  diagnostic. A missing `shape:` is one too.

  A token was checked only when the target was an enum; every other shape fell
  through, so `set(.c)` into a number wrote the string "c". And a declared
  initial was never checked against the shape declared beside it, so
  `shape: number, initial: "oops"` mounted a string into a numeric cell.

PROPS (#3, #4, #5)
  §4's claim that "a fabricated number cannot reach a numeric position" — which
  I wrote into the spec the same day — was false. `component Bar(n: number) {
  view TempBar(lo: n, …) }` with `Bar(n: 41.2)` launders the literal straight
  in. Now computed to a fixpoint, so a chain of three components hides it no
  better than one, while a BOUND value through the same chain and a literal in
  a non-data position both stay legal.

  Prop shapes were retained at parse and discarded again at realization. An
  event prop was looked up as data, so a state whose name collided with an
  event hijacked the routing; an enum token bound with its leading dot, so
  every comparison against it inside the component was false. Binding is now by
  declared shape.

  Parameter defaults were parsed and dropped, and an omitted argument created
  no binding at all — so lookup fell through to top-level injected data and an
  unfilled prop could capture whatever the host supplied under that name.

PROVENANCE (#1, #2)
  It failed OPEN: a `copy` declaration with no `class` defaulted to trusted, so
  the rule only bit when a card volunteered the label — which a model with
  something to hide would not. Absent class is now an error. And model-copy
  laundered through a state initial, because provenance was checked only where
  an element referenced copy.x directly; path-valued initials are checked now.

The harness had the same blind spot it exists to find: it selected diagnostics
by keyword and silently missed four newer rules, reporting full coverage while
not testing them. It now finds messages by where they are USED. That exposed
nine more unprotected rules — the parser errors and §6's resource bounds, all
of which the profile claims and none of which had a test.

42 rules, 0 unprotected. 299 tests, zero clippy warnings, device suite green.
… two doc claims

The remaining five findings, each written as a failing test first.

#9 A dotted source authorised its whole root namespace. `source_roots` had been
   fixed to hold full names, but ordinary Scope.roots still held the root — so
   declaring `env.locale` granted `env.theme.secret`, and the card would render
   an undeclared dependency. §4 calls that "a defect by construction"; it was
   ours. A declared name now matches its own segments only.

#10 The dependency edge for a dotted source was lost: the argument was reduced
   to its root and compared against the full declared name, so
   `sys.photo(query: env.locale.lang)` recorded no dependency on `env.locale`
   and the plan emitted the dependent FIRST. Now matched by segments, longest
   name first.

#11 Only `level` and `profile` were checked for contradictory duplicates, so a
   record could declare two ledger identities or two models and the last won.

#13 The §5.8 regression test compared `report.closure`, which the definition
   digest already made sensitive to initials — so it stayed green with the
   schema fix reverted. Rewritten to exercise InstanceStore reconciliation, and
   confirmed red by reverting that fix: the old test passed through exactly the
   regression it named, the new one fails.

#12 Two prose corrections from the previous round were still wrong.
   - The identity paragraph said any change to an enclosing guard moves
     identity. Only the element name and ordinal enter a path segment, so
     editing a guard's CONDITION leaves state intact. Wrong in the direction
     that matters, since it would have justified not checking.
   - The §6 note described prop types as discarded and `set(out)` as accepted.
     Both stopped being true in 824559a — the same commit that fixed them — so
     the correction documented a world that no longer existed by the time it
     was written.

That is all thirteen. 303 tests, 42 rules with none unprotected, zero clippy
warnings, device suite green on all four cards.
ymote added 17 commits July 31, 2026 22:31
…oes not keep

RED ON PURPOSE, committed before the fix.

  a_transition_batch_is_atomic                 FAILED
  a_payload_must_fit_the_state_it_is_written_to FAILED
  clear_restores_a_path_valued_initial          FAILED

§3 says an event's transitions are 'applied atomically'. They were applied
sequentially straight to the store, so a batch failing partway leaves the
earlier writes standing — a half-applied event, which is exactly what atomicity
is for.

set($value) performed no shape check at dispatch, so a string payload entered a
number cell. The type discipline the other set forms now have stops at the one
place the value comes from outside.

clear ignored a path-valued initial and fell back to the shape default, and
cycle started from the shape default too — so a card whose initial comes from
the host began in the wrong state and cycled from the wrong member.

Implementing these rather than downgrading the wording, per the goal.
…ransition

RED ON PURPOSE, committed before the fix.

§4's refusal was enforced where an element names copy.x, and the earlier rounds
closed the prop and state-initial routes. `set(copy.claim)` is still open:
transition validation checks that a path is READABLE and never asks what kind
of text it names, so a model-authored string can be written into state and
rendered from there.

The test enumerates all four routes rather than the one that fails, so the next
indirection added has somewhere obvious to be checked.
…de them

Green for 87bfc26 and the transitive-provenance test.

ATOMICITY. §3 said an event's transitions are "applied atomically as one batch"
and they were applied sequentially straight to the store, so a batch failing
partway left the earlier writes standing. Writes are staged and committed only
if the whole batch resolves. A transition also reads any write staged before it
in the same batch, so an event that touches one cell twice behaves as written.

PAYLOAD TYPING. `set($value)` is the one form whose value arrives from outside
the card, so it is the one the declared shape cannot decide statically — and it
was the one with no runtime check, letting a string into a number cell. Checked
now against the target's shape; a mismatch refuses the batch rather than
writing.

DECLARED INITIALS. `clear` restored the SHAPE default rather than the declared
initial when that initial was path-valued, and a first `cycle` started from the
shape default too — so a card whose initial comes from the host began in the
wrong state and cycled from the wrong member. Both honour the declaration now.

TRANSITIVE PROVENANCE. §4's refusal of model-authored text was enforced where an
element named copy.x directly. Earlier rounds closed the prop and state-initial
routes; `set(copy.claim)` was still open, because transition validation asked
whether a path was readable and never what kind of text it named. The test
enumerates all four routes rather than the one that failed, so the next
indirection has an obvious place to be checked.

§3 and §4 rewritten to describe what now holds. §4 still says plainly that a
raw literal in a text position is accepted by decision — that one is a choice,
not a gap.

316 tests, zero clippy warnings, device suite green.
… messages

The old harness reported "42 rules, 0 unprotected" and that number was not
sound. The reviewer found five reasons, all confirmed:

  - It filtered a diagnostic's TEXT inside Diagnostics::push, so a rule that
    also sets `valid` or the level was unaffected and reported unprotected while
    being perfectly well held.
  - Its five documented exceptions matched ZERO of the strings it generated.
    Relabelling them "verified by hand" was cosmetic.
  - It found messages by keyword and missed the six rules that matter most —
    the capability allowlist, provenance, laundering, recursion.
  - One substring could suppress several rules, so a test for any one flattered
    all of them.
  - With no baseline, a compile error counted as a rule being caught.

Now each rule names a BEHAVIOUR and an edit that disables it, the edit must
apply at exactly one site, and a mutation that fails to build is reported as
unusable rather than held. A stale patch is loud instead of silent, which is
exactly what the old design could not do.

It found two on its first run that the old one called held:

  - sibling-ordinals was MY bad mutation, not a gap: the counter is already
    per-name, so dropping the name prefix reverted nothing. Rewritten to
    actually restore positional numbering, and it is held.
  - depth-realize was a real gap. §6 caps nesting depth and node count as
    separate bounds, and the existing deep-source test trips the NODE cap on the
    way down — so removing the depth check entirely left it green. The new test
    sits between the two limits: deeper than the realizer's 64, under the
    parser's 128, and far short of the node cap, so only the depth bound can
    stop it.

The output now says plainly that 16 named rules is not a coverage percentage,
since a rule absent from the table is untested AND unmentioned — which is how
the previous version came to claim everything was covered.

317 tests, zero clippy warnings, device suite green.
…pec lines

The dependency-order test compared `Option<usize>` directly, and `None < Some(_)`
is true in Rust — so an ABSENT prerequisite satisfied the ordering assertion.
Both positions are unwrapped now.

The schema test never proved the caption was present before the edit, so a
dispatch regression alone satisfied it and would have masked the schema
regression it is named for. It now asserts the event applied and that the state
is live beforehand.

Spec lines corrected against the code:
  - "254 tests" (317).
  - The grammar required a header; the checker deliberately accepts none.
  - `<` and `>` were listed comparators, but lone forms lex as punctuation and
    are never parsed as comparisons.
  - §7 said parameter defaults are omitted from the digest; recognised ones are
    included as of the phase-2 work.
  - §5.7's duplicate-key claim now describes what happens: reported, and the
    colliding instances given distinct identity rather than sharing a cell.

317 tests, 16 rules held with none unprotected, zero clippy warnings.
UPSTREAM.md permits a behavioural change to the vendored VM "only when [it]
implements a published Splash language contract". This is that contract, so the
VM patch has something to be checked against. Nothing is implemented.

The finding that shaped it: Splash has NO state in the VM. `{{state.q}}` is
textual substitution at seed time — by the time the VM sees the source, the
interpolation has happened and the text is a literal — and writes leave via
`agent.notify`, after which the host re-seeds the whole card. The two mechanisms
never meet, and nothing in the program holds a value between them.

So this is not "add scoping to Splash's state". It adds a state mechanism that
does not exist, so a tap can be handled without rebuilding the card.

Published: declare with a shape, read as an ordinary binding, write with
`state.set` / `state.toggle`. A cell belongs to the innermost enclosing
instance, named by an OPAQUE KEY THE EMITTER SUPPLIES — the VM compares keys and
never interprets them. Identity is decided one layer up, where `ui_l0` already
computes it and already emits `l0_key`; re-deciding it in the VM would create a
second identity model to keep aligned with the first.

Deliberately excluded, each with its reason: reactivity beyond redraw,
persistence, and §5.8 schema versioning — which needs declared shapes, initials
and a schema id that the DSL does not carry, so the VM holds cells and the layer
that knows what a component IS decides when to discard them.

Two consequences stated rather than buried. Card-level state such as "which
city" would still not survive a restart, because persistence needs its own
contract. And L0's transitions would execute in the VM — trusted shapes from
trusted lowering, but executing — so §1 of the profile must be narrowed to say
so precisely. That claim has already been wrong twice today.

§9 lists what would make this contract wrong, including the walking skeleton
that should be built against it before any VM work starts.
Decision: Splash's vendored VM and octos-one's shipped VM stay separate. They
are already divergent lineages — Splash pins upstream 4f9ce7a8, octos-one is at
3a5ac114, and neither contains the other's history — and that is now a position
rather than an accident awaiting a merge.

The first draft was written against "the vendored VM", which under this decision
names nothing. Revised so §2-§6 name no VM, crate or language: if a sentence can
only be satisfied by one implementation, it is a defect.

What the decision costs is drift, and prose does not check drift. So §9 adds a
conformance corpus — programs, ordered events, and expected CELL VALUES after
each, saying nothing about rendering, because rendering is where the two
implementations legitimately differ. It lives here beside the contract, and each
implementation runs it in its own suite; a case is added here first and fails in
both until each lands the behaviour, which makes divergence a failing case
rather than a device-only bug six weeks later.

Two things §9 insists on. The most valuable cases are the ones asserting NOTHING
CHANGED — `{{state.q}}` and `agent.notify` behaving exactly as before — because a
state mechanism that quietly alters interpolation breaks every existing card and
no new-feature case would notice. And a case absent from the corpus is untested
AND unmentioned, which is precisely how the L0 mutation harness came to report
full coverage while missing the six rules that mattered most.

§10 gains two falsification conditions: a normative statement with no writable
case should be cut or restated, and two implementations passing the corpus while
a card can still observe a difference means the corpus is incomplete.
L0's constructors are semantic — TextHero, Panel, Chip — but its lowering
hardcodes ten hex colours and a font-size ramp, so `TextHero` currently MEANS
white-at-62pt. A theme is living inside the crate whose job is deciding whether
a card is safe.

§1.1 states the layering the architecture already has and the profile never
wrote down:

  L0 card       names roles           TextHero, Panel, Chip
  theme kit     role -> presentation  components/<theme>/*.splash
  splash-render DSL -> UiNode         evaluated in the VM, renderer-free
  backend       UiNode -> native      splash-makepad / Splash-OH / Splash-Android

So a card lowers to semantic invocations of the active theme's component kit —
`card(title)`, `filled_button(copy.open)` — never to a backend's widget dialect.

Why it is a rule rather than a preference: a card is a ledger record that
outlives the device it was written on. A card carrying `#0a0e14` makes dark
mode, a platform's native look, and every future theme into an edit of every
card ever written. Roles survive that; hex codes do not. It also keeps the three
backends honest, since the branch point is `UiNode` and anything lowering past
it reaches one platform while silently abandoning two.

The known-gaps entry for `makepad::lower` is restated. It was "belongs in a
backend crate", which understated it: the lowering breaks §1.1 three ways — it
targets a backend dialect instead of the component kit, it decides presentation
that belongs to a theme, and it therefore reaches one backend of three. It also
defines a second `UiNode`, duplicating the one splash-render already produces.
Moving it would not fix any of that; retargeting it at the component kit and
deleting the colour table would.

Consequence worth stating: the four device goldens are then testing the THEME,
not L0 — so they prove a colour table that this section says should not exist.

Spec and catalog only. 317 tests unchanged.
…iser

L0 has its own lexer and parser and imports exactly one type from the rest of
the crate, so "a Splash profile" has described a philosophy rather than an
implementation. This adds a second surface: a card written in Splash's own
grammar, recognised into the same declarations.

    let lead     = source("sys.news", {count: 1, fields: ["id", "title"]})
    let selected = state("text", "")
    let open     = event({selected: set_payload()})
    let c_pts    = copy("vocabulary", {en: "pts", zh: "分"})

Chosen over TSX, which was measured and rejected: no generation benefit in an
A/B (both surfaces produced clean cards first try), a formatter that inserts
`{" "}` expression containers into cards, a `.d.ts` third catalog, and a
rejection surface the size of TypeScript. Splash costs none of that, the
grammar is ours, `splash-lsp` exists, and it makes one language span card, theme
kit, DSL and VM.

POSITIVE RECOGNISER, not a denylist — the condition codex made existential for
any carrier grammar. Four forms are admitted by name and everything else is
refused: `let x = compute(1, 2)`, `let x = 41.2` and `let x = sys.news(1)` are
all errors rather than ignored statements, because skipping one is how a card
declares a source the runtime never fetches.

Carrier and profile are checked separately and neither subsumes the other: a
test asserts the surface is valid Splash via `check_vm_compatibility` (the VM
could run it) alongside projection (L0 will admit it).

The projector reproduces the hard-won rules rather than re-deriving them. §4
fails closed — a `copy` with no class is refused, which is the defect the
bespoke surface shipped with and needed a review round to find. A transition
outside the five total forms is refused by name.

Scope: declarations only. Views, components and the equality gate against the
bespoke surface are not done, and no card uses this surface yet.

322 tests, 16 mutation rules held, zero clippy warnings.
    fn StoryRow(story, position, on_open) {
      return Col([
        Row({align: ".center", on_tap: on_open, value: story.id}, [
          TextRow({text: position, width: ".rank"})
        ]),
        Rule()
      ])
    }

    fn root() {
      return Surface({pad: ".page"}, [ when_is(selected, "", latest) ])
    }

Capitalisation decides view from component, matching the convention the bespoke
surface already uses (`component StoryRow`, `view stream`), so a card reads the
same either way. A `fn` must be a single `return <element>` — statements in a
view are refused rather than ignored.

Structural forms are recognised BY NAME and cannot be spelled another way:
`each(feed, "id", StoryRow)` and `when_is`/`when_not`. A bare name is a view
reference. Everything else is refused, which keeps this a positive recogniser
rather than a grammar with holes.

TOKEN IDENTITY is preserved by lexical marking: a string beginning with `.` —
`".center"` — projects to Operand::Token, everything else to Operand::Str. Codex
flagged exactly this as lossy for the TSX surface, where both would have spelled
as plain strings and the AST distinction would have collapsed.

THE EQUALITY GATE. `project_bespoke` exposes the original surface through the
same report, so the two can be compared without either knowing about the other.
The same card written both ways must project the same tree — compared by SHAPE,
not byte-for-byte, because spans differ whenever formatting does, which is why
codex rejected "byte-identical Card" as a criterion.

Verified the gate can fail: dropping a single `Rule()` from one surface gives

    left:  Some("Col(Row(TextRow, TextRow))")
    right: Some("Col(Row(TextRow, TextRow), Rule)")

and the test goes red. Two surfaces are a second SURFACE only while they project
the same tree, and a second LANGUAGE the moment they stop.

Known gap: a Splash `fn` parameter carries no type, so component props project
with an unknown shape — and §5.2's checks (event-vs-value confusion, enum
membership, defaults) all depend on that shape. This surface cannot yet express
what the bespoke one declares.

324 tests, 16 mutation rules held, zero clippy warnings.
…the checks

    fn StoryRow(story, position, on_open) {
      props({story: "record", position: "number", on_open: "event"})
      return Col([ ... ])
    }

A Splash `fn` parameter carries no type, and §5.2's checks all consult one:
event-vs-value confusion, enum membership, defaults, the readable/event scope
split. Four separate defects, each of which took a review round to find.

Declared rather than inferred. A naming convention (`on_*` is an event) was the
alternative and it fails L0's own principles — `key s.id` is mandatory because
identity may not come from position, `copy` fails closed when its class is
absent, a source names its capability. Meaning is declared here. The convention
also fails on its own terms: `online_count` starts with `on`, and a text prop
silently misclassified as an event is precisely the defect that let event
routing be chosen by data.

`props(…)` is the one statement admitted before the return, recognised
positively and nowhere else. It buys a check the bespoke surface cannot have:
there the parameter list IS the declaration, so the two cannot disagree; here
they are separate, so a mismatch in either direction is an error. Redundancy
that is checked is a different thing from redundancy that drifts. A component
taking parameters with no `props(…)` is refused rather than admitted with
unknown shapes — the same fail-closed rule as `copy` without a class.

THE SECOND HALF, and the more important one. Declaring `on_open: "text"` and
handing it to `on_tap` — exactly what §5.2 refuses — passed all 180 tests. The
props were captured and never consulted, because `project_declarations` only
PROJECTS. `check_ui_l0_splash` now runs the same `validate()` the bespoke
surface runs, so every check applies identically: §5.2's prop rules, §4's
no-facts rule, §6's termination conditions.

Retained-but-never-used is the mirror of "specified but not retained", the
defect class this surface was built to avoid repeating. It reappeared inside the
avoidance.

329 tests, 16 mutation rules held, zero clippy warnings. Device suite NOT run —
the 6T is unplugged; this change adds a surface no card uses yet, so nothing
device-visible should have moved, but that is an argument rather than a check.
The daily forecast row rendered `Mon ☁ ▬▬▬ 21°` — one number. The low existed
only inside `when expanded`, so seeing it required a tap, and taps do not work
on device: lowering dropped on_tap until recently and no host drives the event
loop. So the low was never visible at all, on the most standard element of a
forecast list.

Now `Mon ☁ 12° ▬▬▬ 21°`: low to the left of the bar, high to the right, which
is what the bar already spans. The expanded row no longer repeats the low.

Reported by eye, not by any test. Every check was green — 329 unit tests and
four device goldens at 0.00% — because the goldens recorded whatever the card
did, and the card had always done this. A golden proves consistency with what
was captured, and a human noticing something is missing remains the only thing
that catches a card being wrong in a way it has always been wrong.

Golden recaptured and reviewed. The change drifted 1.82%, which is worth noting
against the harness thresholds: real layout changes here land at 1.8-4.8%, while
the three chrome contaminants land at 40-85%.
…gets

The retarget §1.1 implies was blocked on what happens to WeatherIcon, TempBar,
SunArc, MoonPhase, AqiContour and StockPlot. Material has no equivalent and
should not, so the question was whether to give them a theme kit, drop them, or
admit them as primitives.

The facts decide it against a theme kit. `TempBar` lowers to a native widget
driven by shader uniforms — `draw_bg.tlo`, `draw_bg.thi`, `draw_bg.wmin` — and
`splash-render` states the reason directly: a DSL node cannot carry shader
source, because MPSL compiles at build time. A gradient bar is not a composition
of boxes and text, so no `.splash` file could contain one. `components/dataviz/`
is impossible, not merely unattractive.

So they are backend widgets named semantically — the category `splash-render`
already has for `Map` ("a real map is not a platform view here, it is a
widget"), `Shader`, `Sdf`, `Fab` and `Segmented`. Not an exception carved for
L0.

§1.1 survives intact rather than needing an apology. A card writes four bound
paths and no presentation; colour, thickness and cap shape stay the backend's.
The card names a role that only a shader can fill.

The cost is stated in the spec rather than buried: every backend must implement
all six or those cards render blank there. Accepted because the alternative —
pre-rendering to images — turns a live value into a fact fetched once, which is
what §4 exists to prevent.

Two tests hold it. One pins the split (16 themed / 6 backend / 1 image) and
asserts a backend widget takes only bound data, which is what keeps §1.1 true
for them. The other turns §1.1's prohibition into a check: no role may accept a
colour, size, font, radius or margin. Verified it fails — adding a `color`
argument to TextTitle produces "TextTitle.color is presentation".

The retarget is now a clean split rather than a judgement call.

329 tests, zero clippy warnings.
The retarget §1.1 requires. `lower_dsl` emits the data form `splash-render`
evaluates into a UiNode, from which all three backends render — rather than
makepad's widget dialect, which reaches one backend and carries a theme.

On weather.card, the same tree:

    makepad dialect : 9236 chars, 54 colours, 1 font size
    Splash DSL      : 9682 chars,  0 colours, 0 font sizes

The split settled in b3cf51f, now implemented:

  16 roles  -> an existing node kind. The seven text roles collapse onto `text`
              with a `role:` attribute, because a hero and a caption differ in
              PRESENTATION and that is the theme's to decide. The role has to
              survive or the theme has nothing to decide with.
  6 roles   -> their own kind: tempbar, weathericon, sunarc, moonphase,
              aqicontour, stockplot. Fragment shaders parameterised by data,
              which no .splash file could contain.
  Photo     -> image.

`on_tap`, its payload and the instance key are emitted: they are interaction,
not presentation, and the key is what lets a tap name WHICH instance was hit.
A Missing binding emits `null` rather than an em dash — how absence LOOKS is
the theme's decision too, and a dash baked in here would be presentation.

Three tests, one of which caught a real mistake in another. Banning `#` outright
failed on instance keys, which legitimately contain one (`for#0[b]/StoryRow#0`);
the assertion now matches a hex literal. That is the second time this session a
test of mine was wrong in a way only running it revealed.

`makepad::lower` is untouched and still used by the device harness, so nothing
device-visible moves in this commit. Retiring it needs the backends to render
these node kinds first, and OH and Android must implement the six shaders or
those cards render blank there — the cost §1.1 states rather than buries.

334 tests, 16 mutation rules held, zero clippy warnings.
l0.d.ts and flight.tsx were scratch for the TSX experiment, committed only so
codex could read them during that review. The proposal was rejected on the
evidence — no generation benefit in an A/B, a formatter that inserts {" "} into
cards, a third catalog, and a rejection surface the size of TypeScript — and the
Splash-grammar surface was built instead. Keeping dead proposal files in the
repo root invites someone to mistake them for a direction.
… claimed

Codex verdict: revert. Every critical finding reproduced. This surface should
not exist in this form, and keeping it behind a caveat is not an option because
`check_ui_l0_splash` was a public entry point that accepted unsafe cards.

NOT A POSITIVE RECOGNISER, which was its entire justification. All of these
projected clean and reported valid:

    while true {}                        an L2 construct, silently skipped
    let e = event(dangerous())           an empty event; the object is optional
    fn root() { return Rule() dangerous() }   trailing statements skipped
    each(feed, "id", Rule())             a structural form outside a child slot

The top-level loop advances over any token that is not `let` or `fn` without a
diagnostic, and the object, body and argument parsers all recover with silent
`self.at += 1`. A recogniser that SKIPS is a denylist wearing a disguise, and
skipping is exactly how a declaration goes missing without anyone being told.

A PROVENANCE BYPASS. `set(copy.lie)` retains only the first identifier, giving
`SetSource::Path("copy")`. The validator checks model-copy on a `copy.` prefix
and bare `copy` is explicitly readable, so model-authored text reaches state on
this surface while the bespoke spelling is refused. That is the §4 hole the
bespoke surface needed a review round to close, reopened.

"ONE VALIDATOR" DID NOT MEAN ONE VALIDATION. Component states and events are
always empty, so every §5.7/§5.8 check ran against nothing and passed vacuously.
Predicates lost cmp and rhs. Path-valued initials were dropped silently. The
header was never parsed, so a card declaring L2 passed. No byte, token or
recursion limit — the stack-overflow class just fixed for the bespoke parser was
reintroduced.

THE EQUALITY GATE PASSED A KNOWN NON-EQUIVALENT PAIR. `shape_of` compares names
and nesting only, so bare `StoryRow` (a reference) and `StoryRow(story:, …)` (a
call with three bindings) both stringify as `for(StoryRow)`. I described it as
the gate deciding surface-versus-language; it was a skeleton smoke test.

And my own doc comment claimed `check_ui_l0_splash` runs `check_vm_compatibility`
first. It does not. That is the fourth false claim I have written on this branch.

The architectural point underneath: I justified this as "one language across the
stack" and then wrote a second hand-written parser over L0's bespoke lexer,
approximating Splash's grammar rather than using it. That makes the two-parser
problem worse, not better. A Splash-native surface remains reasonable, but it
must consume the canonical Splash AST and project a normalised, semantically
equal Card — not re-derive the grammar by hand.

Kept, being independent: lower_dsl and §1.1, the six-role decision, the
scoped-state contract, the weather fix, the harness hardening.

320 tests, 16 mutation rules held, zero clippy warnings. Device suite not run —
the 6T is unplugged; nothing device-visible is touched by a removal of unused
code paths, but that is an argument rather than a check.
…hecked

Codex verdict: revert. Every finding reproduced. This is the second revert in a
row and the cause is the same one: I specified a contract with a consumer I
never opened.

FIVE OF 23 KINDS DO NOT EXIST. splash-render's tag table has `weathericon` and
not `tempbar`, `sunarc`, `moonphase`, `aqicontour` or `stockplot`, and its
fallback is `_ => return None`. An unknown root makes the whole document fail;
an unknown child is silently omitted. So weather.card would have rendered with
four of its five data visualisations missing, no diagnostic — while the code
comment beside it claimed an unknown role should "fail visibly".

AND THE RECOGNISED ONES WERE MOSTLY WRONG TOO:

  role:      the consumer reads `variant`; `role` appears zero times in node.rs,
             so all seven text roles collapsed into indistinguishable Text
  Photo      became an image node, but weather.card uses Photo as the CONTAINER
             around the whole card, and image nodes do not render children
  Tile       became listitem, whose fields are label/text/supporting, not
             value/unit/format — a measurement tile would keep its label and
             lose its measurement
  WeatherIcon the widget expects value/w; lowering emitted cond/size
  align/pad   string tokens landed in numeric properties, which initialise to
             zero — "center" and "page" became 0

The consumer holds a FIXED attribute struct, not an open bag, so about twenty
emitted attributes were simply unread. splash-core has no splash-render
dependency, so nothing prevents the two schemas drifting. They drifted at once.

SEMANTICS WERE DROPPED, NOT ONLY PRESENTATION. Removing the colour palette and
the font-size ramp was right. Removing l0_event/l0_key/l0_value, chip `active`,
and formatting intent was not — a chip would render inactive and dispatch
nothing. `tint` is the instructive case: red-versus-green is presentation, "this
value is negative" is meaning, and deleting the attribute lost both.

MY TEST PASSED FOR THE WRONG REASON, again. `text_roles_lower_to_text_carrying
_the_role` asserted the emitted string contains `role: "hero"`. It does. Nothing
reads it. Third time this session I have tested that I wrote what I intended
rather than that it works.

§1.1 and the catalog are corrected rather than deleted. The principle — a card
names roles, the theme decides appearance — survives, and the presentation guard
test with it. What is removed is the claim that the routing was settled, and in
its place: how these six reach a backend is UNSETTLED, `Shader`/`Sdf` with a
variant was dismissed too quickly, and the pipeline should be proven on the
backend that already implements all six (octos-one, six of six; Splash-Makepad,
one of six) before a vocabulary is fixed for three backends when two do not
exist.

318 tests, 16 mutation rules held, zero clippy warnings, device suite green.
@r9s-codereview

r9s-codereview Bot commented Aug 2, 2026

Copy link
Copy Markdown

🤖 AI Code Review

Claude CLI exited with code 1:

✅ No significant issues found.


Tokens used: 0 | Powered by r9s.ai

@r9s-codereview r9s-codereview Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔐 Secrets Detected

This PR contains 1 potential secret(s) that must be removed before merging.

"movers": [], "selected": "NVDA", "range": "m1", "env": {"locale":{}},
"quote": {"name":"NVIDIA","last":184.2,"change":3.1,"pct":1.7,"open":181.0,
"high":185.6,"low":180.2,"volume":41200000.0,
"mktcap":4520000000000.0,"pe":58.3},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Secret Detected (credit_card_visa)

Visa Credit Card Number detected: LLM validation unavailable - flagging conservatively

Match preview: 4520****0000

Remediation: Please review manually and remove if this is a real secret

ymote and others added 6 commits August 1, 2026 19:16
A spike settled this rather than an argument. `aichat` cannot depend on
`splash-core`:

    error: package collision in the lockfile: makepad-error-log v1.0.0
    (Splash/vendor/makepad/…) and makepad-error-log v1.0.0 (octos-one/aichat/…)
    are different, but only one can be written to lockfile unambiguously

Not a type conflict — Cargo refuses outright, because both makepad lineages ship
identically named and versioned packages at different paths. No feature flag or
version pin reaches it.

But L0 never needed makepad. Measured before moving anything: `crate::JsonValue`
69 times and that is `serde_json::Value`; zero makepad types; two constants and
a diagnostic struct. Five thousand lines that were already a standalone library
wearing a module, carrying a dependency they inherited by address.

So `splash-ui-l0` depends on serde_json and nothing else, and the same spike now
builds and runs beside octos-one's makepad-widgets:

    dispatch applied: true
    cell: Some(Bool(true))

That unblocks making cards respond to touch on device, which no host could do
while the checker was unreachable.

`splash-core` re-exports it as `pub use splash_ui_l0 as ui_l0`, so every existing
path keeps working. Fixtures split by owner; examples and the device harness
follow the crate.

Two things worth recording. `cargo fmt --all` reformatted the vendored makepad —
reverted, since UPSTREAM.md requires every vendor change be a deliberate,
documented patch and a reformat is not one. And two doc comments began failing
as doctests: they hold L0 card snippets which were never collected inside a
private module and are compiled as Rust at a crate root. Fenced as `text`.

829 tests across the workspace, 171 of them L0's. 16 mutation rules held, zero
clippy warnings in Splash-owned crates, device suite green.

`makepad::lower` came along unchanged. It reaches one backend of three and
carries a theme, so §1.1 says it does not belong here — but moving it is a
decision about the device harness that uses it, not a consequence of this one.
…ality

GAP 1 — reconciliation was computed and discarded.

First, a correction to my own framing: I said §6 promises bounded work per event
and today's bound is the whole card. §6 promises no such thing — it promises
transitions are TOTAL, constant per key, which was already true. The real gap
was narrower: `patch_points` and `dirty_records` were tested and had no
consumer.

`stale_sources` exposes what `patch_points` computed internally and threw away —
which sources a change invalidates, following the cascade. That is the expensive
half: realization walks ninety nodes, a source is a network round trip. `city`
feeds `place` feeds `now`, so a host refetching only the direct dependent would
read a fresh coordinate against a stale forecast. Changing a display unit
refetches nothing, which is the case the mechanism exists for.

`realize_patch` carries over subtrees a change cannot have touched. The reuse
rule is conservative — a subtree is reused only when NOTHING inside it is dirty
— and the first version was wrong in the dangerous direction: it checked a
node's own key, so the root short-circuited the whole tree and everything was
reused. `patch_points` deliberately excludes ancestors that merely CONTAIN a
change, so a clean parent above a dirty child is the normal case. A test asserts
the patched tree equals a full realize, because an optimisation that changes
what is rendered is not one.

GAP 2 — not the bug I described.

I said StockPlot and AqiContour were declared with their data and lowered
without it. Reading the widgets says otherwise: AqiContour takes lat/lon/span
and StockPlot takes symbol/range. Both fetch their own data, deliberately. The
widget's own comment explains why the old shape was abandoned — supplying the
field meant sixteen scalar uniforms, and "that put a GPU uniform layout into the
authoring language; it is not a widget contract, it is an ABI."

So the CATALOG was wrong, describing an interface these widgets had already
dropped, and the lowering faithfully implemented it. Catalog, Rust table,
lowering and both reference cards now match what the widgets take.

The device suite shows what that was costing. The stock-detail golden was an
empty box with an em dash — the chart never rendered — and it had been passing
at 0.00% because the golden recorded a blank chart as correct. It is now a real
price series with axes and times. Recaptured after review.

That is twice today that reading the consumer contradicted what I assumed, and
both times the assumption had already shipped.

175 L0 tests, 831 across the workspace, 16 mutation rules held, zero clippy
warnings, device suite green.
…s it

Gap 3. A previous attempt was reverted for asserting a contract nobody had
opened: five of 23 kinds did not exist, it emitted `role` where the consumer
reads `variant`, and `Attrs` is a fixed 37-field struct rather than an open bag,
so about twenty attributes were unread. All of it was one grep away.

This time the contract was established by RUNNING `splash_render::build`, and
three facts came out of that which no amount of reading had produced:

  - the tag table is closed. An unknown kind at the ROOT rejects the whole
    document; as a CHILD it is dropped with no diagnostic
  - `Attrs` is fixed, so an attribute it does not name is simply unread —
    emitting one is the same as emitting nothing, only harder to notice
  - the root must be a literal object. `return card("x")` fails inside the VM
    with "pop_stack_resolved on empty stack", while
    `return {t:"column", c:[card("a")]}` works — so a card's outermost role
    cannot lower to a theme call, only its children can

So `lower_dsl_checked` emits only kinds the consumer accepts and only attributes
it reads, and REPORTS what it cannot map instead of guessing a name. `dsl_kind`
deliberately has no fallback: guessing is what produced five names the renderer
had never heard of.

Verified end to end rather than asserted. news.card lowers and the real renderer
returns ACCEPTED, root=Column, 79 nodes. weather.card returns ACCEPTED,
root=Image, 69 nodes, and names its four unmappable roles:

    unmappable: TempBar has no kind in the renderer's table, so it cannot be
    lowered — a card using it would render without it and look complete

That is the honest state of those six: no kind exists for them, and a card using
one now says so instead of losing it silently. Adding the kinds obliges every
backend at once, which is the decision to make against a pipeline that has
shipped — not ahead of one.

`makepad::lower` is untouched and still drives the device harness, so nothing
device-visible moves.

177 L0 tests, 835 across the workspace, 16 mutation rules held, zero clippy
warnings, device suite green.
…n be cut apart

A design conclusion reached in conversation — that state splits two ways, glue
at L0 and widget-internal below it, and that the two layers therefore need no
shared ledger — was put to an adversarial review against the code. The split
survives as a description. The corollary does not.

§5.10 records the classification, including the kind the two-way split missed:
source lifecycle state, which the host owns, several components read, and which
outlives any widget. It also names the axes the split collapses — a cell has a
visibility, an effect and a lifetime, not just an owner, and `selected` is
card-shared by visibility but navigation by effect.

§5.10.1 records why kind 2 cannot simply move down. L0 keeps deciding when a
subtree is rebuilt, and on the weather card a `week` refresh replaces the
forecast rows — so a kit holding its own `expanded` flag would lose it whenever
data arrived. The layers need no shared value store; they need a shared
identity, lifecycle and invalidation protocol, and that protocol is unwritten.
`scoped-state.md` describes the opposite arrangement and says so now.

Three sentences were false before any of this, and are corrected:

  - §5.8 said a component's state schema id "forms part of instance identity".
    It does not: the key is `{parent}/{component}` and the schema is held in a
    separate per-component map that reconciles cells in place.
  - §5.8 offered card state "with a declared migration" as the home for durable
    values. There is no card-state migration — no syntax, no implementation.
    Durability becomes open question 8 instead of a mechanism.
  - §5.1 said identity survives an insertion above. Only for a DIFFERENTLY
    NAMED sibling, as the same section's closing paragraph already said.

§8 also stops claiming StockPlot and AqiContour "lower without their data
arrays". They have no data arrays.

Three defects found and recorded, not yet fixed: a `source` and a `state` may
share a name with nothing rejecting the collision; a declared source nothing
reads is not reported (stock.card ships one — `series` went unread when
StockPlot began fetching its own); and nothing connects a state write to a
refetch, `stale_sources` and `source_plan` being called only from tests while
`dispatch_with_data` returns a bare bool.

§5.10.1's numbers are asserted rather than narrated. The new test kills a
mutation in both directions — reuse-everything and reuse-nothing — and
patch-reuse joins the mutation harness as rule 17.

  178 L0 tests, 836 workspace, 17 mutation rules held, 0 unprotected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… receive it

`tap_binding` emitted `l0_event`, `l0_key` and `l0_value` as attributes on the
lowered node, and nothing anywhere read them. The VM does not hit-test an
arbitrary attribute, so every card that declared `on_tap` rendered inert on
device — the stock range chips, the row that opens a quote, the back button —
while the tests asserting those attributes passed.

The mechanism that does work is the one hand-written cards already use and the
device already proves: a transparent `Button` laid over the content, calling
`agent.notify`, which the host receives as `SplashAction::Notify`. So a tappable
element is now wrapped in `flow: Overlay` with that button over it.

Two details that are not incidental:

  - The wrapper sizes like what it wraps. A Chip is `width: Fit` and sits in a
    row of chips; wrapping it in `Fill` makes the first chip eat the row.
  - `agent.notify`'s first argument stays a LITERAL. The host's
    `tag_notify_calls` only rewrites literals and discards an untagged event as
    unattributable, so the event name travels in the payload rather than as the
    channel — a computed channel would render the card inert again, in exactly
    the same silent way.

The attributes stay. They cost nothing and they are how a lowered card can be
read back.

Verified on the 6T rather than argued: all four goldens still render, three at
0.00% and weather at 0.09%. The new hit targets change no pixels.

The test kills the previous behaviour, and its discriminating half is the count
— one hit target per declared tap, no more. Wrapping every node would satisfy
every other assertion in it.

  179 L0 tests, 836 workspace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both are consequences of splitting `splash-ui-l0` out of `splash-core`, and
both were invisible locally.

`fuzz/Cargo.lock` did not list the new crate. No fuzz target uses L0 — the
dependency arrives through `splash-core`, which now depends on the extracted
crate — so the lock went stale without anything failing to build. CI passes
`--locked` precisely to catch that, and it did.

The clippy error is a lint my local toolchain does not have: CI resolves
`stable` to something newer than 1.95.0, so `--workspace --all-targets
--all-features -D warnings` was clean here and red there. The `match` on
`as_u64()` is a `?` and reads better as one.

  837 workspace tests, clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ymote
ymote merged commit e203733 into main Aug 2, 2026
8 checks passed
ymote added a commit that referenced this pull request Sep 13, 2026
Same tree as a434733 (fork PR #1 merged fast-forward-equivalent); one pin for the org.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgSir4cQyaakzPju4h6smw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant