From bcfae8f6fe00aade58a08838f9c534c5aad6f458 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:46:51 -0700 Subject: [PATCH 01/63] docs: propose a Level 0 UI profile for generated source 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) --- docs/ui-profile-l0.md | 290 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/ui-profile-l0.md diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md new file mode 100644 index 0000000..6588628 --- /dev/null +++ b/docs/ui-profile-l0.md @@ -0,0 +1,290 @@ +# Splash UI Profile — Level 0 + +**Status:** proposed. Normative producer contract for generated UI source at Level 0. +**Relationship to [Grammar v0.2](grammar.md):** a *sibling* canonical profile, not a subset +of the workflow language. `check_syntax` rejects UI source today, and +`eval_vm_compatibility` accepts it but must not receive generated source — so there is +currently no supported path for LLM-authored UI. This profile is that path, at its narrowest. + +--- + +## 1. What Level 0 is for + +A generated card is untrusted source. The workflow profile answers this by refusing UI +constructs; the compatibility parser answers it by refusing untrusted input. L0 answers it a +third way: **admit UI constructs, but admit nothing that could reach a capability.** + +The consequence is that an L0 evaluator is handed a host surface with **no** modules — no +`sys.*`, no `fs`, `run` or `net`. Sources are resolved by the runtime *before* evaluation and +injected as data (`set_json_global`). There is no dangerous call to block, because no call +can be written. + +**What this does and does not buy.** Two properties are decidable at L0: membership in the +grammar, and absence of authority-bearing operations. **Termination and bounded state are +*not* consequences of the grammar alone** — §6 states what makes them hold. A bounded +evaluator (heap, stack, call-depth, instruction and time limits) remains mandatory at every +level; L0 removes the need for an *OS authority* boundary, not for resource containment. + +--- + +## 2. Grammar + +```ebnf +source = header , { declaration } ; + +header = "#" , "ledger" , ident , "@" , semver , { meta-line } ; +meta-line = "#" , ident , ":" , value ; (* level, model, profile *) + +declaration = source-decl | state-decl | event-decl | copy-decl + | component-decl | view-decl ; + +source-decl = "source" , path , capability-query ; +state-decl = "state" , path , "{" , shape-spec , "}" ; +event-decl = "event" , ident , transition-block ; +copy-decl = "copy" , path , "{" , "class" , ":" , provenance , "," , locale-map , "}" ; +component-decl= "component" , Ident , "(" , [ params ] , ")" , "{" , { local } , view-body , "}" ; +view-decl = "view" , path , view-body ; + +local = state-decl | event-decl ; + +view-body = element ; +element = construct | iteration | guard | reference ; +construct = Constructor , [ "(" , [ arguments ] , ")" ] , [ block ] ; +reference = path ; (* splices another view record *) +iteration = "for" , ident , [ "," , ident ] , "in" , path , "key" , path , block ; +guard = "when" , predicate , block ; +block = "{" , { element } , "}" ; + +arguments = argument , { "," , argument } ; +argument = ident , ":" , operand ; +operand = path | literal | predicate ; (* NO expressions *) + +predicate = path , comparator , ( path | literal ) ; +comparator = "==" | "!=" | "<" | "<=" | ">" | ">=" ; + +path = root , { "." , ident | "." , integer | "[" , path , "]" } ; +root = ident ; + +transition-block = "{" , transition , { "," , transition } , "}" ; +transition = path , ":" , total-form ; +total-form = "set" , "(" , ( literal | path | "$value" ) , ")" + | "toggle" (* boolean state only *) + | "cycle" , "(" , literal , { "," , literal } , ")" + | "clear" ; (* restore declared initial *) + +shape-spec = "shape" , ":" , shape , [ "," , "initial" , ":" , ( literal | path ) ] ; +shape = "text" | "number" | "bool" | "enum" , "[" , ident , { "," , ident } , "]" ; +provenance = "vocabulary" | "user-copy" | "model-copy" ; +``` + +### 2.0 Amendments found by the reference cards + +The grammar above already incorporates eight corrections that the three reference cards in +`octos-one/docs/l0/` forced. They are listed because the process matters: the cards were +written against the first draft, and **every construct they needed that the draft lacked is a +defect in this document, not in the cards.** + +| # | Missing | Needed by | Resolution | +|---|---|---|---| +| 1 | element referencing another `view` record | all three — `root` splices named blocks | `reference = path` | +| 2 | `initial` from the environment | weather — `units` defaults by locale | `initial : literal \| path` | +| 3 | constant index into a collection | news — `stories.0` is the lead story | `"." integer` in `path` | +| 4 | index by a state value | news, stock — `stories[selected]` | `"[" path "]"` in `path` | +| 5 | loop index binding | news — the rank column | optional second binder: `for s, i in …` | +| 6 | predicate as an argument value | stock — `active: range == d1` | `operand` admits `predicate` | +| 7 | event payload at the call site | news, stock — which row was tapped | `value:` argument, §3.1 | +| 8 | sub-collection of a source | news — feed skips the lead | **not granted** — see below | + +**None of these introduces an expression.** Indexing is a total lookup, a predicate as an +operand is the form `when` already admits, and a loop index is supplied by the iterator. The +authority property and the absence of arithmetic both survive. + +**#8 was refused.** The news card wanted `stories.rest` to skip the lead story. Granting a +derived sub-collection would be the thin end of computation — `rest` invites `filter`, which +invites a predicate expression, which invites arithmetic. The card instead binds +`sys.news(...)` twice, or the source declares `lead` and `rest` as fields. **The runtime +shapes the data; the view does not reshape it.** + +### 2.1 What is absent, and deliberately + +| Construct | Status | Why | +|---|---|---| +| arithmetic, string concatenation | **absent** | any expression can hide a fabricated fact (§5) | +| function definition, call | **absent** | would reintroduce unbounded work | +| `while`, unbounded `for` | **absent** | iteration is only over a declared collection | +| `ui..` | **absent** | imperative widget commands are L2 | +| module access of any kind | **absent** | this is the authority property | +| assignment outside a transition | **absent** | state changes only through declared events | + +**`operand` admits no expressions.** `Text(value: now.temp)` is legal; `Text(value: now.temp * 9 / 5 + 32)` is not. Unit conversion and formatting belong to the runtime (§4). + +--- + +## 3. Transitions are total by construction + +This is the point where an earlier draft of this design was wrong, and the correction is +load-bearing. + +A transition written as an expression — `expanded: !expanded`, or +`units: units == c ? f : c` — **is computation**, however small. Declared storage does not +make it total or bounded, and once expressions are admitted the grammar has to answer for +recursion, unbounded numerics, and event cascades. + +L0 therefore has no expression form in transitions. It has four **total forms**: + +| Form | Applies to | Semantics | +|---|---|---| +| `set(v)` | any | assign a literal, a bound path, or the event payload `$value` | +| `toggle` | `bool` | the other value | +| `cycle(a, b, …)` | `enum` | successor, wrapping; members must be declared in the shape | +| `clear` | any | restore the declared `initial` | + +Each is total on its declared shape and terminates in constant time. `units: cycle(c, f)` +replaces the ternary; `expanded: toggle` replaces the negation. + +An event may name several transitions, applied **atomically as one batch**. A single-assignment +rule would be wrong: production code already fires two state writes from one gesture, and the +stock reference card's `back` does the same — clearing both `selected` and `range`. + +### 3.1 The event payload + +An element that raises an event supplies its payload at the call site: + +``` +Row(on_tap: open_quote, value: m.ticker) +``` + +`on_tap` names a declared event; `value` is the payload the transition receives as `$value`. +Both are ordinary operands, so a payload can be a bound path or a literal but never a computed +one. An event whose transitions do not mention `$value` ignores any payload. + +This replaces the string-tagging approach used by the current runtime, where a card's identity +is prepended to its event name by rewriting generated source. A card that constructs its event +name dynamically escapes that rewrite; here the event is a declared name resolved by the +runtime, so there is nothing to construct. + +--- + +## 4. Sources, and the no-facts rule + +A `source` body is a **query the runtime executes**, never a call the view makes. Views cannot +invoke it; they bind to its result. + +``` +source place sys.geocode(name: state.city) +source now sys.weather(lat: place.lat, lon: place.lon, fields: [temp, hi, lo, cond]) +``` + +The evaluator never sees `sys.geocode`. The runtime resolves it, injects the result as data, +and the view reads `place.name`. + +**A literal in a data position must be `vocabulary` or `source-derived`.** This is decidable +at L0 precisely because `view` is a typed tree — a `model-copy` literal bound to a data +argument is a structural violation, not a judgement call. It is the property that makes +`"34 mph"` concatenated onto a live value unrepresentable rather than merely discouraged. + +Implicit dependencies are declared like any other source: + +``` +source env.locale sys.locale() +source env.theme sys.theme() +``` + +Anything reachable without a declared source is a defect by construction — which is also what +makes an L0 view's dependency set readable without evaluating it. + +--- + +## 5. Components and instance identity + +``` +component ForecastRow(day) { + state expanded { shape: bool, initial: false } + event toggle { expanded: toggle } + view Row { + Text(role: Row, text: day.dayname) + TempBar(lo: day.lo, hi: day.hi, min: week.min_lo, max: week.max_hi) + when expanded { Detail(day: day) } + } +} +``` + +Local state is declared in the ledger; **its values live only in the runtime.** + +### 5.1 Identity is a path, not a position + +An instance is identified by the tuple + +``` +( card namespace, parent instance identity, instantiation site, + component name and version, vector of enclosing loop keys ) +``` + +Recursive by construction, because a static declaration path is not enough: two `TripPanel` +instances each containing an unkeyed `Toggle` must not share one state cell. + +**This is not React's rule.** React associates state with position in the realized tree, where +component type and keys both affect preservation. L0 binds to a declared path so that +identity survives a ledger edit that inserts an element above. + +### 5.2 Unresolved + +The following are **not specified here** and must be before this profile is implementable: +child-to-parent outputs, slots/children, shared or hoisted state, per-component source +declarations, mount/update/unmount semantics, duplicate-key behaviour, and typed props with +defaults. §7 records this. + +--- + +## 6. What makes L0 terminate + +Grammar membership alone does not bound execution. These conditions do, and an implementation +must enforce all of them: + +1. **The component graph is acyclic.** No recursive or mutually recursive components. +2. **Iteration is over a runtime-provided collection**, whose length the runtime bounds before + injection. +3. **Transitions are total forms** (§3), so a single event applies in constant time per key. +4. **Event cascades are prohibited** — a transition may not trigger another event. +5. **Node count and nesting depth are capped** at realization. + +With all five, an L0 realization terminates. **Without them the grammar does not**, which is +why "L0 is decidable" is a claim about authority and grammar membership, not about +termination. + +--- + +## 7. Level determination + +A card's level is the **maximum** required by any record, resolved over the **transitive +component closure** — not by inspecting records locally: + +``` +uses any construct outside §2 → L1 or L2 +instantiates a component whose level is higher → that level +otherwise → L0 +``` + +Level is declared in the header and checked at append. A record needing a wider grammar is +rejected until the level is explicitly raised; escalation is never silent. **Component +versions must be pinned**, or an L0 card can be moved to L2 by a definition it references +being replaced. + +Three token tests are not sufficient and an earlier draft that used them was both incomplete +and self-contradictory. Level must be an effect judgment over the closure. + +--- + +## 8. Open questions + +| # | Question | +|---|---| +| 1 | Component contracts (§5.2) — outputs, slots, shared state, lifecycle | +| 2 | Whether `when` needs `else`, or whether two complementary guards suffice | +| 3 | Whether `enum` shapes need ordering guarantees for `cycle` | +| 4 | How a `source` declares refresh cadence, staleness and error surfaces | +| 5 | Whether the profile should admit a bounded `format(path, style)` for dates and units, or keep all formatting in the runtime | + +Question 5 is the one most likely to force a change: the three reference cards in +`octos-one/docs/l0/` are the evidence, and any construct they need that §2 lacks is a defect +in this document rather than in the cards. From d6b44fada232f9fd2b71a6c339c10a9620a9f6b7 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:30:27 -0700 Subject: [PATCH 02/63] docs: name the generated-UI gap in the spec itself 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) --- docs/grammar.md | 14 ++++++++++++++ docs/language-profile.md | 12 ++++++++++++ docs/roadmap.md | 31 +++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/docs/grammar.md b/docs/grammar.md index 1034f95..4f9103d 100644 --- a/docs/grammar.md +++ b/docs/grammar.md @@ -237,6 +237,20 @@ it remains outside this grammar and cannot run through `splash-cli`. It requires a Makepad UI host that supplies widget modules and `ui`; see [Makepad UI compatibility](makepad-ui-compatibility.md). +### Generated UI source + +The two boundaries above leave a gap. This grammar rejects UI constructs, and +the compatibility APIs that accept them must not receive generated source — so +a host wanting LLM-authored UI has no supported path and must embed the raw +Makepad VM, which restores the very bindings this profile masks. + +[UI Profile Level 0](ui-profile-l0.md) is a **proposed** third path: a sibling +canonical profile that admits UI constructs while admitting nothing that can +reach a capability. Sources are queries the runtime resolves before evaluation +and injects as data, so an L0 evaluator receives a host surface with no modules +at all. It is not implemented, and until it is, generated UI remains outside +every supported entry point. + ## Canonical Workflow Source ```splash diff --git a/docs/language-profile.md b/docs/language-profile.md index dba2b3f..c1d57b1 100644 --- a/docs/language-profile.md +++ b/docs/language-profile.md @@ -8,6 +8,18 @@ broader runtime profile. The v0.1 fixture remains a backward-compatibility regression. Parser features outside the grammar and fixtures remain inherited compatibility features until separately specified. +**Scope.** This is the *workflow* profile: it is shaped for bounded dataflow and +tool calls, and it deliberately rejects UI constructs. A second profile for +generated UI is proposed but unimplemented — see +[UI Profile Level 0](ui-profile-l0.md). The two are siblings rather than nested: +a card is not a workflow with widgets bolted on, and neither profile is a subset +of the other. Where they agree is the property that matters — a producer must be +unable to write anything that reaches a capability it was not granted. + +Until L0 exists, `splash profile` reports only this profile, and a host with +generated UI source has no canonical entry point (see +[Generated UI source](grammar.md#generated-ui-source)). + ## Source contract Provide normal Splash source. The runtime adds its own internal terminal diff --git a/docs/roadmap.md b/docs/roadmap.md index ea11009..cd94e63 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -371,6 +371,37 @@ reviewed adapter's host-owned resolver; broader selectors must remain denied until each is enforced. +## Next: a canonical profile for generated UI + +Splash has no supported path for LLM-authored UI. The canonical grammar rejects +UI constructs; the compatibility APIs accept them but must not receive generated +source. A host that needs one must embed 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. That is not a hypothetical: it is what a downstream host did, and how +its generated cards ended up able to read and write arbitrary files. + +[UI Profile Level 0](ui-profile-l0.md) proposes the narrowest useful path — +constructors, bindings, keyed loops, guarded branches and components with +declared local state, with no expression form and no module access, evaluated +against an empty host surface. Three reference cards exercise it. + +Remaining before it can be implemented: + +- A normative AST and a parser/validator giving definite accept/reject. +- A level classifier over the transitive component closure. Local record + inspection is insufficient: a card can be moved to a wider level by a + component definition it references being replaced, so component versions must + be pinned. +- Lowering to a renderer-neutral node tree, and a host surface that installs no + modules. +- Component contracts — child-to-parent outputs, slots, shared state, + per-component source declarations, and mount/update/unmount semantics. +- Recursive instance identity, and a state-schema versioning rule for what + happens to live local state when a component definition changes. +- Enforcement of the five conditions that make an L0 realization terminate; + grammar membership alone does not bound execution. + ## Before a stable language release - Additional semantic editor features beyond lexical completion, fixed From 7912d9bfb2a84744ebc3ce729b9552d9438de60a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:34:47 -0700 Subject: [PATCH 03/63] docs: specify the L0 component contracts 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) --- docs/ui-profile-l0.md | 176 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 162 insertions(+), 14 deletions(-) diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 6588628..748e798 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -94,11 +94,28 @@ defect in this document, not in the cards.** | 6 | predicate as an argument value | stock — `active: range == d1` | `operand` admits `predicate` | | 7 | event payload at the call site | news, stock — which row was tapped | `value:` argument, §3.1 | | 8 | sub-collection of a source | news — feed skips the lead | **not granted** — see below | +| 9 | bare identifiers are ambiguous | both components — `width: rank` is a token, `text: position` is a path | resolved by argument shape, §2.2 | **None of these introduces an expression.** Indexing is a total lookup, a predicate as an operand is the form `when` already admits, and a loop index is supplied by the iterator. The authority property and the absence of arithmetic both survive. +### 2.2 Bare identifiers resolve by argument shape + +`width: rank` and `text: position` are both `ident : ident`, but the first names a layout token +and the second a bound path. Resolving this by scope — *a path if it names a binding, else a +token* — would be a trap: declaring a prop named `fill` would silently change what +`width: fill` meant everywhere in that component. + +**The argument's declared shape decides.** Each constructor argument accepts either a token +from a closed set or a value; a bare identifier is read as whichever that argument admits, and +a mismatch is a validation error rather than a silent reinterpretation. `width:` takes a +layout token, `text:` takes a path or string literal. + +This surfaced only once components existed, because a component's props are the first bindings +whose names the card author chooses freely. Both reference cards had to rename a prop — +`day` → `item`, `rank` → `position` — before the ambiguity was even visible. + **#8 was refused.** The news card wanted `stories.rest` to skip the lead story. Granting a derived sub-collection would be the thin end of computation — `rest` invites `filter`, which invites a predicate expression, which invites arithmetic. The card instead binds @@ -227,12 +244,136 @@ instances each containing an unkeyed `Toggle` must not share one state cell. component type and keys both affect preservation. L0 binds to a declared path so that identity survives a ledger edit that inserts an element above. -### 5.2 Unresolved +### 5.2 Props + +```ebnf +params = param , { "," , param } ; +param = ident , ":" , shape , [ "=" , literal ] ; +``` + +Props are **typed by the same shape vocabulary as state**, may carry a literal default, and +are **immutable inside the component**. A prop is an input, not storage; a component that +wants to change something declares local state or raises an event. + +``` +component ForecastRow(day: record, unit: enum[c, f] = c, on_pick: event) +``` + +`record` and `collection` are added to the shape vocabulary for props, since a row receives a +whole item rather than a scalar. + +### 5.3 Scope: what a component may reference + +| May read | May not read | +|---|---| +| its own props | card `state` — pass it as a prop | +| its own `state` and `event` | another component's locals | +| card-scope `source` | — | +| card-scope `copy` | — | + +**Sources and copy are readable; state is not.** Both are immutable and already declared +dependencies the tracker sees, so reading them creates nothing implicit. Card state is what +drives reconciliation, so it must arrive explicitly as a prop or the dependency becomes +invisible — the failure §6.2 warns about. + +This is why `week.min_lo` may be read directly by a forecast row, while `units` must be passed. + +### 5.4 Outputs are event props + +A component raises an event the parent declared, passed in as a prop: + +``` +component StoryRow(story: record, on_open: event) { + view Row(on_tap: on_open, value: story.id) { … } +} + +view latest Panel { for s, i in feed key s.id { StoryRow(story: s, on_open: open_story) } } +``` + +**This needs no new mechanism.** An event name is a path, `operand` already admits paths, and +the transition runs in the scope where it was *declared* — the parent's — so a child cannot +reach state it was not handed a route to. It is React's callback prop with the scoping made +explicit. + +A component may only name events it declares locally or receives as props. There is no way to +raise an arbitrary event by constructing its name, because there is no expression form. + +### 5.5 Slots + +A single anonymous slot, marked in the component body: + +``` +component Panel(title: text) { + view Col { TextCaption(text: title) slot } +} + +Panel(title: "Details") { Tile(…) Tile(…) } +``` + +Children are realized in the parent's scope, not the component's — a child expression sees the +call site's bindings. Named slots are deferred; nothing in the reference cards needs them, and +adding them later is additive. + +### 5.6 What is refused, and why + +**Hoisted or shared state.** If two components need the same state it lives at card scope and +arrives as props, with the event declared at card scope too. Hoisting would raise an ownership +question the grammar cannot answer, and a Context-like mechanism reintroduces exactly the +invisible dependencies §6.2 exists to prevent. + +**Cross-field invariants are not expressible.** Two date pickers cannot enforce +start-before-end through transitions, because total forms cannot compare. This is a real limit, +not an oversight. Where an invariant matters it belongs in the **shape**, enforced by the +runtime: + +``` +state start { shape: date, initial: today, max: end } +``` + +If that is insufficient, the card is L1 — and should say so rather than smuggling a +comparison into a transition. + +**Per-component sources.** A component may not declare its own `source`. Sources are card-scope +and resolved before evaluation; a component instantiated *n* times would fan out *n* fetches, +with *n* determined by data. That breaks the bound §6.2 requires, and makes fetch volume a +function of a collection the model does not control. + +### 5.7 Lifecycle + +There are no lifecycle hooks — §2.2 rejects `useEffect` and its dependency arrays. But the +runtime needs defined behaviour, and identity (§5.1) decides all of it: + +| Event | Behaviour | +|---|---| +| **Mount** — identity appears | local state initialized from `initial` | +| **Update** — identity persists, props differ | view re-realizes; **local state persists** | +| **Unmount** — identity disappears | local state discarded | + +**Duplicate keys are an error at realization**, surfaced as a diagnostic — never coalesced. +Silent coalescing would make two rows share one state cell, which is the failure mode keys +exist to prevent. A missing key on an iteration is a grammar error. + +### 5.8 State schema versioning + +A component's **state schema id** is derived from its `state` declarations — names, shapes and +initials — and forms part of instance identity (§5.1). + +| Definition change | Effect | +|---|---| +| view or props only | schema id unchanged → **local state persists** | +| any `state` declaration added, removed, renamed or retyped | schema id changes → **new identity → state reset** | + +**Reset is the default; preservation is opt-in**, never inferred. A ledger edit that adds a +field cannot be assumed compatible with live values, and guessing is how a rolled-back ledger +ends up feeding version-A state to version-B code — at which point last-known-good is no longer +known good. + +An explicit migration is a future addition and must be atomic with activation and rollback. -The following are **not specified here** and must be before this profile is implementable: -child-to-parent outputs, slots/children, shared or hoisted state, per-component source -declarations, mount/update/unmount semantics, duplicate-key behaviour, and typed props with -defaults. §7 records this. +**Consequence worth stating plainly:** folding the ledger reconstructs the *program*, not the +visible application state. Local state is disposable UI state — scroll positions, expansion +flags. Anything that must survive a definition change belongs in a `source` or in card state +with a declared migration, not in a component. --- @@ -279,12 +420,19 @@ and self-contradictory. Level must be an effect judgment over the closure. | # | Question | |---|---| -| 1 | Component contracts (§5.2) — outputs, slots, shared state, lifecycle | -| 2 | Whether `when` needs `else`, or whether two complementary guards suffice | -| 3 | Whether `enum` shapes need ordering guarantees for `cycle` | -| 4 | How a `source` declares refresh cadence, staleness and error surfaces | -| 5 | Whether the profile should admit a bounded `format(path, style)` for dates and units, or keep all formatting in the runtime | - -Question 5 is the one most likely to force a change: the three reference cards in -`octos-one/docs/l0/` are the evidence, and any construct they need that §2 lacks is a defect -in this document rather than in the cards. +| 1 | Whether `when` needs `else`, or whether two complementary guards suffice | +| 2 | Whether `enum` shapes need ordering guarantees for `cycle` | +| 3 | How a `source` declares refresh cadence, staleness and error surfaces | +| 4 | Whether the profile should admit a bounded `format(path, style)` for dates and units, or keep all formatting in the runtime | +| 5 | Named slots — deferred; nothing in the reference cards needs them and adding them is additive | +| 6 | An explicit state migration, to make preservation across a schema change opt-in rather than impossible (§5.8) | +| 7 | The closed token sets per constructor argument that §2.2 depends on | + +Component contracts were question 1 and are now specified in §5.2–§5.8, validated by adding +components to two reference cards. That exercise produced amendment #9, which nothing had +surfaced before — props are the first bindings a card author names freely, so the +token/path ambiguity was invisible until one existed. + +Question 4 is the likeliest to force a change, and question 7 is a prerequisite for +implementing §2.2 at all. The reference cards in `octos-one/docs/l0/` remain the evidence: any +construct they need that this document lacks is a defect here rather than in the cards. From 922c3a83ebde823d2efae250b8663844d1c2a503 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:03:13 -0700 Subject: [PATCH 04/63] docs: L0 constructor catalog, and mark tokens lexically 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) --- docs/ui-l0-constructors.toml | 160 +++++++++++++++++++++++++++++++++++ docs/ui-profile-l0.md | 41 ++++++--- 2 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 docs/ui-l0-constructors.toml diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml new file mode 100644 index 0000000..17e2483 --- /dev/null +++ b/docs/ui-l0-constructors.toml @@ -0,0 +1,160 @@ +# L0 constructor catalog — the normative argument contract for +# docs/ui-profile-l0.md. Derived from the three reference cards in +# octos-one/docs/l0/, not invented: every constructor and argument here is one +# they use. +# +# TOKENS ARE LEXICALLY MARKED with a leading dot: `.fill`, `.money`, `.center`. +# +# This supersedes amendment #9's resolution. That rule said a bare identifier +# resolved by the argument's declared shape — which fails the moment one +# argument admits both, and `unit` does: `unit: units` is a path to card state, +# `unit: .pct` is a token. Marking tokens removes the ambiguity at the lexical +# level rather than the semantic one, so a prop named `fill` can never collide +# with `.fill`, and the catalog only has to say which tokens are LEGAL rather +# than disambiguate what a name means. +# +# kinds: +# token one of `tokens`, written with a leading dot +# path a bound path — source, state, prop, copy or loop binder +# event a declared event name (local or received as a prop) +# number/text/bool a literal of that type, or a path of that shape +# any path or literal +# record/collection component props only + +# ─── containers ─────────────────────────────────────────────────────────────── + +[Surface] +pad = { kind = "token", tokens = ["page", "tight", "none"] } + +[Photo] +src = { kind = "path" } +pad = { kind = "token", tokens = ["page", "tight", "none"] } + +[Panel] + +[Card] +on_tap = { kind = "event" } +value = { kind = "any" } + +[Col] +align = { kind = "token", tokens = ["start", "center", "end"] } +gap = { kind = "number" } + +[Row] +align = { kind = "token", tokens = ["start", "center", "end", "baseline"] } +gap = { kind = "number" } +on_tap = { kind = "event" } +value = { kind = "any" } + +[Grid] +cols = { kind = "number" } + +[Rule] + +# ─── text roles ─────────────────────────────────────────────────────────────── +# Seven roles, each carrying weight, size and the complete glyph fallback chain. +# The chain is why a role is a constructor rather than a `font` argument: a +# model-authored font_family once dropped CJK coverage and rendered 上海 as tofu. + +[TextHero] +text = { kind = "text" } +value = { kind = "number" } +unit = { kind = "unit" } +format = { kind = "format" } +on_tap = { kind = "event" } + +[TextTitle] +text = { kind = "text" } +width = { kind = "width" } + +[TextBody] +text = { kind = "text" } +width = { kind = "width" } + +[TextStat] +value = { kind = "number" } +format = { kind = "format" } +tint = { kind = "path" } + +[TextRow] +text = { kind = "text" } +width = { kind = "width" } + +[TextCaption] +text = { kind = "text" } +value = { kind = "number" } +glyph = { kind = "text" } +suffix = { kind = "text" } +unit = { kind = "unit" } +width = { kind = "width" } + +[TextValue] +value = { kind = "number" } +unit = { kind = "unit" } +format = { kind = "format" } +tint = { kind = "path" } + +[Tile] +label = { kind = "text" } +value = { kind = "number" } +unit = { kind = "unit" } +format = { kind = "format" } + +[Chip] +text = { kind = "text" } +on_tap = { kind = "event" } +value = { kind = "any" } +active = { kind = "bool" } # a predicate is a bool operand + +# ─── drawing widgets ────────────────────────────────────────────────────────── +# Every argument is a path. These render live data and hold no authored values — +# the class of bug that put an invented `condition` and invented `wmin`/`wmax` +# into shipped cards. + +[WeatherIcon] +cond = { kind = "path" } +size = { kind = "token", tokens = ["hero", "row", "tile"] } + +[TempBar] +lo = { kind = "path" } +hi = { kind = "path" } +min = { kind = "path" } +max = { kind = "path" } + +[SunArc] +rise = { kind = "path" } +set = { kind = "path" } +now = { kind = "path" } + +[MoonPhase] +phase = { kind = "path" } +illum = { kind = "path" } + +[AqiContour] +field = { kind = "path" } +index = { kind = "path" } +band = { kind = "path" } + +[StockPlot] +points = { kind = "path" } +min = { kind = "path" } +max = { kind = "path" } +tint = { kind = "path" } + +# ─── shared token sets ──────────────────────────────────────────────────────── +# Named kinds referenced above, so a token set is defined once rather than +# repeated at every argument that admits it. + +[kinds.unit] +# Admits a token OR a path: `unit: .pct` is fixed, `unit: units` follows card +# state. This is the argument that forced tokens to be lexically marked. +accepts = ["token", "path"] +tokens = ["c", "f", "pct", "speed", "pressure", "index", "distance", "money"] + +[kinds.format] +accepts = ["token"] +tokens = ["money", "signed_money", "signed_pct", "compact", "ratio", "time", "date"] + +[kinds.width] +accepts = ["token", "number"] +tokens = ["fill", "fit", "day", "rank", "temp"] diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 748e798..44fff00 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -67,9 +67,9 @@ root = ident ; transition-block = "{" , transition , { "," , transition } , "}" ; transition = path , ":" , total-form ; -total-form = "set" , "(" , ( literal | path | "$value" ) , ")" +total-form = "set" , "(" , ( literal | token | path | "$value" ) , ")" | "toggle" (* boolean state only *) - | "cycle" , "(" , literal , { "," , literal } , ")" + | "cycle" , "(" , token , { "," , token } , ")" | "clear" ; (* restore declared initial *) shape-spec = "shape" , ":" , shape , [ "," , "initial" , ":" , ( literal | path ) ] ; @@ -100,21 +100,42 @@ defect in this document, not in the cards.** operand is the form `when` already admits, and a loop index is supplied by the iterator. The authority property and the absence of arithmetic both survive. -### 2.2 Bare identifiers resolve by argument shape +### 2.2 Tokens are lexically marked `width: rank` and `text: position` are both `ident : ident`, but the first names a layout token and the second a bound path. Resolving this by scope — *a path if it names a binding, else a token* — would be a trap: declaring a prop named `fill` would silently change what `width: fill` meant everywhere in that component. -**The argument's declared shape decides.** Each constructor argument accepts either a token -from a closed set or a value; a bare identifier is read as whichever that argument admits, and -a mismatch is a validation error rather than a silent reinterpretation. `width:` takes a -layout token, `text:` takes a path or string literal. +**Tokens carry a leading dot.** `width: .fill`, `format: .money`, `align: .center`. A bare +identifier is always a path. -This surfaced only once components existed, because a component's props are the first bindings -whose names the card author chooses freely. Both reference cards had to rename a prop — -`day` → `item`, `rank` → `position` — before the ambiguity was even visible. +```ebnf +operand = path | literal | token | predicate ; +token = "." , ident ; +``` + +An earlier draft resolved this by argument shape instead — *each argument accepts either a +token set or a value, so the argument decides what a bare name means.* **That rule is +withdrawn**, because it fails on any argument admitting both, and one does: `unit: units` +follows card state while `unit: .pct` is fixed. Marking tokens removes the ambiguity +lexically, so the catalog only has to say which tokens are **legal** rather than disambiguate +what a name **means**. + +The ambiguity surfaced only once components existed, because a component's props are the first +bindings whose names the card author chooses freely — both reference cards had to rename a +prop, `day` → `item` and `rank` → `position`, before it was visible at all. The `unit` case +surfaced one step later still, while cataloguing arguments. Each pass found what the previous +one could not have. + +**Enum members are tokens too.** A `shape: enum[c, f]` declaration *defines* the set with bare +names; every use is dotted — `cycle(.c, .f)`, `initial: .m1`, `range == .d1`. Same rule as any +other token: a bare identifier is always a path, so an enum member can never be shadowed by a +binding of the same name. + +The normative argument contract is [`ui-l0-constructors.toml`](ui-l0-constructors.toml): +which constructors exist, which arguments each admits, and which tokens are legal per +argument. **#8 was refused.** The news card wanted `stories.rest` to skip the lead story. Granting a derived sub-collection would be the thin end of computation — `rest` invites `filter`, which From f65ae738270c0c05927a04364c5784fa663b06e4 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:41:14 -0700 Subject: [PATCH 05/63] feat(ui_l0): parser and validator for the Level 0 profile 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) --- crates/splash-core/src/lib.rs | 1 + crates/splash-core/src/ui_l0.rs | 1769 +++++++++++++++++ crates/splash-core/tests/fixtures/news.card | 108 + crates/splash-core/tests/fixtures/stock.card | 94 + .../tests/fixtures/trip_planner.splash | 664 +++++++ .../splash-core/tests/fixtures/weather.card | 122 ++ crates/splash-core/tests/ui_profile_l0.rs | 414 ++++ docs/ui-profile-l0.md | 11 +- 8 files changed, 3181 insertions(+), 2 deletions(-) create mode 100644 crates/splash-core/src/ui_l0.rs create mode 100644 crates/splash-core/tests/fixtures/news.card create mode 100644 crates/splash-core/tests/fixtures/stock.card create mode 100644 crates/splash-core/tests/fixtures/trip_planner.splash create mode 100644 crates/splash-core/tests/fixtures/weather.card create mode 100644 crates/splash-core/tests/ui_profile_l0.rs diff --git a/crates/splash-core/src/lib.rs b/crates/splash-core/src/lib.rs index aa2e8d4..a6a078c 100644 --- a/crates/splash-core/src/lib.rs +++ b/crates/splash-core/src/lib.rs @@ -8,6 +8,7 @@ //! Rust code. mod profile; +pub mod ui_l0; use std::any::Any; use std::cell::Cell; diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs new file mode 100644 index 0000000..98189e2 --- /dev/null +++ b/crates/splash-core/src/ui_l0.rs @@ -0,0 +1,1769 @@ +//! Level 0 of the UI profile — parser and validator for generated card source. +//! +//! See [`docs/ui-profile-l0.md`](../../../docs/ui-profile-l0.md) for the normative +//! grammar and [`docs/ui-l0-constructors.toml`](../../../docs/ui-l0-constructors.toml) +//! for the argument contract. The catalog in [`catalog`] is the implementation's +//! copy of that TOML; a test asserts the two agree. +//! +//! ## What this establishes, and what it does not +//! +//! Two properties are decided here: **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 checks for `mod.` and `ui..` exist to classify +//! such source as a higher level with a useful diagnostic, not to hold a boundary. +//! +//! Termination is **not** established. It needs the five conditions in §6 of the +//! profile, of which this checks two: the component graph is acyclic, and iteration +//! is over a bound path rather than an expression. Collection length, node count and +//! nesting depth are the runtime's to bound. +//! +//! Everything is effect-free and bounded: no evaluation, no imports, no host access. + +use crate::{SyntaxDiagnostic, DEFAULT_MAX_SOURCE_BYTES, DEFAULT_MAX_SYNTAX_NESTING}; + +/// The narrowest profile a source belongs to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Level { + /// Constructors, bindings, keyed loops, guarded branches, components with + /// declared local state. No expression form, no reachable capability. + L0, + /// Adds pure expressions. Not accepted by this checker. + L1, + /// Full Splash, including imperative widget commands. Not accepted here. + L2, +} + +/// Result of checking source against the L0 profile. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiL0Report { + pub valid: bool, + /// The narrowest level the source belongs to. `valid` is true only for + /// [`Level::L0`]. + pub level: Level, + pub diagnostics: Vec, + pub diagnostics_truncated: bool, +} + +/// Diagnostics retained before truncation, so a pathological source cannot grow +/// the report without bound. +pub const MAX_UI_L0_DIAGNOSTICS: usize = 64; + +/// Maximum declarations retained from one card. +pub const MAX_UI_L0_DECLARATIONS: usize = 1_024; + +/// Check source against the L0 profile. +pub fn check_ui_l0(source: &str) -> UiL0Report { + check_ui_l0_named("card.l0", source) +} + +/// Check source against the L0 profile, naming it for diagnostics. +pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { + let mut sink = Diagnostics::default(); + + if source.len() > DEFAULT_MAX_SOURCE_BYTES { + sink.push( + 1, + 1, + format!( + "source is {} bytes, over the {DEFAULT_MAX_SOURCE_BYTES}-byte limit", + source.len() + ), + ); + return sink.into_report(Level::L0); + } + + let tokens = match lex(source, &mut sink) { + Some(tokens) => tokens, + None => return sink.into_report(Level::L0), + }; + + // A construct outside the grammar tells us the level before parsing can + // finish, and a level diagnostic is far more useful than "unexpected token". + if let Some((level, diag)) = classify_beyond_l0(&tokens) { + sink.push(diag.line, diag.column, diag.message); + return sink.into_report(level); + } + + let mut parser = Parser::new(&tokens, &mut sink); + let card = parser.parse_card(); + if sink.any() { + return sink.into_report(Level::L0); + } + + validate(&card, &mut sink); + sink.into_report(Level::L0) +} + +// ─────────────────────────────────────────────────────────────────── diagnostics ── + +#[derive(Default)] +struct Diagnostics { + items: Vec, + truncated: bool, +} + +impl Diagnostics { + fn push(&mut self, line: usize, column: usize, message: String) { + if self.items.len() >= MAX_UI_L0_DIAGNOSTICS { + self.truncated = true; + return; + } + self.items.push(SyntaxDiagnostic { + line, + column, + message, + }); + } + + fn at(&mut self, t: &Token, message: String) { + self.push(t.line, t.column, message); + } + + fn any(&self) -> bool { + !self.items.is_empty() + } + + fn into_report(self, level: Level) -> UiL0Report { + UiL0Report { + valid: self.items.is_empty() && level == Level::L0, + level, + diagnostics: self.items, + diagnostics_truncated: self.truncated, + } + } +} + +// ──────────────────────────────────────────────────────────────────────── lexer ── + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Kind { + Ident, + /// A dotted token: `.fill`. Tokens are lexically marked so a bare identifier + /// is always a path — see profile §2.2. + Token, + Str, + Num, + Punct, + Cmp, +} + +#[derive(Clone, Debug)] +struct Token { + kind: Kind, + text: String, + line: usize, + column: usize, +} + +impl Token { + fn is(&self, kind: Kind, text: &str) -> bool { + self.kind == kind && self.text == text + } + fn is_kw(&self, kw: &str) -> bool { + self.is(Kind::Ident, kw) + } + fn is_punct(&self, p: &str) -> bool { + self.is(Kind::Punct, p) + } +} + +fn lex(source: &str, sink: &mut Diagnostics) -> Option> { + let bytes: Vec = source.chars().collect(); + let mut out = Vec::new(); + let (mut i, mut line, mut col) = (0usize, 1usize, 1usize); + + while i < bytes.len() { + let c = bytes[i]; + + if c == '\n' { + line += 1; + col = 1; + i += 1; + continue; + } + if c.is_whitespace() { + i += 1; + col += 1; + continue; + } + // Comments: `#` to end of line. The profile's cards use `#`; `//` is + // rejected so a Splash-syntax card cannot be mistaken for an L0 one. + if c == '#' || (c == '/' && i + 1 < bytes.len() && bytes[i + 1] == '/') { + while i < bytes.len() && bytes[i] != '\n' { + i += 1; + } + continue; + } + + let (start_line, start_col) = (line, col); + + // String literal. + if c == '"' { + let mut text = String::new(); + i += 1; + col += 1; + let mut closed = false; + while i < bytes.len() { + if bytes[i] == '"' { + i += 1; + col += 1; + closed = true; + break; + } + if bytes[i] == '\n' { + break; + } + text.push(bytes[i]); + i += 1; + col += 1; + } + if !closed { + sink.push(start_line, start_col, "unterminated string".into()); + return None; + } + out.push(Token { + kind: Kind::Str, + text, + line: start_line, + column: start_col, + }); + continue; + } + + // Dotted token, but only when a name follows — `.` between path segments + // is punctuation and is handled below. + if c == '.' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) { + // A `.` directly after an identifier, token, or `]` is a path + // separator, not a token marker. + let after_value = out + .last() + .map(|t: &Token| { + matches!(t.kind, Kind::Ident | Kind::Token | Kind::Num) || t.is_punct("]") + }) + .unwrap_or(false); + if !after_value { + let mut text = String::from("."); + i += 1; + col += 1; + while i < bytes.len() && is_ident_continue(bytes[i]) { + text.push(bytes[i]); + i += 1; + col += 1; + } + out.push(Token { + kind: Kind::Token, + text, + line: start_line, + column: start_col, + }); + continue; + } + } + + // Comparators. + if matches!(c, '=' | '!' | '<' | '>') { + let two = i + 1 < bytes.len() && bytes[i + 1] == '='; + let text: String = if two { + let s: String = bytes[i..i + 2].iter().collect(); + i += 2; + col += 2; + s + } else { + i += 1; + col += 1; + c.to_string() + }; + // A lone `=` or `!` is punctuation; only the two-character forms are + // comparators. Without this `!x` lexes as Cmp and the level + // classifier's punctuation check never fires. + if !two { + out.push(Token { + kind: Kind::Punct, + text, + line: start_line, + column: start_col, + }); + } else { + out.push(Token { + kind: Kind::Cmp, + text, + line: start_line, + column: start_col, + }); + } + continue; + } + + // Number. + if c.is_ascii_digit() { + let mut text = String::new(); + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') { + // Stop at a `.` that starts a name — `7.foo` is a path, not a float. + if bytes[i] == '.' && (i + 1 >= bytes.len() || !bytes[i + 1].is_ascii_digit()) { + break; + } + text.push(bytes[i]); + i += 1; + col += 1; + } + out.push(Token { + kind: Kind::Num, + text, + line: start_line, + column: start_col, + }); + continue; + } + + // Identifier. + if is_ident_start(c) { + let mut text = String::new(); + while i < bytes.len() && is_ident_continue(bytes[i]) { + text.push(bytes[i]); + i += 1; + col += 1; + } + out.push(Token { + kind: Kind::Ident, + text, + line: start_line, + column: start_col, + }); + continue; + } + + // Punctuation, including operators the grammar does not admit. Those are + // kept as tokens so `classify_beyond_l0` can report them as L1 rather + // than as a lex failure. + if "{}()[]:,.$+-*/%?|&".contains(c) { + i += 1; + col += 1; + out.push(Token { + kind: Kind::Punct, + text: c.to_string(), + line: start_line, + column: start_col, + }); + continue; + } + + sink.push(start_line, start_col, format!("unexpected character {c:?}")); + return None; + } + + Some(out) +} + +fn is_ident_start(c: char) -> bool { + c.is_alphabetic() || c == '_' +} + +fn is_ident_continue(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +// ───────────────────────────────────────────────────────────── level classifier ── + +/// Report the level implied by any construct outside L0, before parsing. +/// +/// Level is a property of the whole card: the maximum any record requires +/// (profile §7). Component-closure resolution happens in [`validate`]; this is +/// the lexical half. +fn classify_beyond_l0(tokens: &[Token]) -> Option<(Level, SyntaxDiagnostic)> { + let mut worst: Option<(Level, SyntaxDiagnostic)> = None; + let mut keep = |found: Option<(Level, SyntaxDiagnostic)>| { + if let Some((level, diag)) = found { + let better = match &worst { + Some((Level::L2, _)) => false, + Some((Level::L1, _)) => level == Level::L2, + _ => true, + }; + if better { + worst = Some((level, diag)); + } + } + }; + for (i, t) in tokens.iter().enumerate() { + let bad = |level: Level, msg: &str| { + Some(( + level, + SyntaxDiagnostic { + line: t.line, + column: t.column, + message: msg.into(), + }, + )) + }; + + if t.is_kw("fn") { + keep(bad( + Level::L2, + "`fn` is not in L0: a function reintroduces unbounded work", + )); + } + if t.is_kw("while") { + keep(bad( + Level::L2, + "`while` is not in L0: iteration must be over a declared collection", + )); + } + if t.is_kw("let") || t.is_kw("var") { + keep(bad( + Level::L2, + "`let` is not in L0: state is declared, not bound imperatively", + )); + } + if t.is_kw("mod") && tokens.get(i + 1).is_some_and(|n| n.is_punct(".")) { + keep(bad( + Level::L2, + "module access is not in L0: the host surface is empty", + )); + } + // `ui..` — an imperative widget command. + if t.is_kw("ui") + && tokens.get(i + 1).is_some_and(|n| n.is_punct(".")) + && tokens.get(i + 3).is_some_and(|n| n.is_punct(".")) + { + keep(bad( + Level::L2, + "imperative widget commands are L2: L0 declares a tree", + )); + } + if t.kind == Kind::Punct && matches!(t.text.as_str(), "+" | "*" | "/" | "%" | "?") { + keep(bad( + Level::L1, + "arithmetic and conditional expressions are not in L0: an expression can hide a fabricated fact", + )); + } + // `-` is only rejected between values; a negative literal is fine. + if t.is_punct("-") + && tokens + .get(i.wrapping_sub(1)) + .is_some_and(|p| matches!(p.kind, Kind::Ident | Kind::Num | Kind::Token)) + { + keep(bad(Level::L1, "arithmetic is not in L0")); + } + if t.is_punct("!") { + keep(bad( + Level::L1, + "negation is computation: use the `toggle` transition form", + )); + } + } + worst +} + +// ────────────────────────────────────────────────────────────────────────── ast ── + +#[derive(Debug, Default)] +struct Card { + sources: Vec, + states: Vec, + events: Vec, + copies: Vec, + components: Vec, + views: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum Shape { + Bool, + Enum(Vec), + Other, +} + +#[derive(Debug)] +struct StateDecl { + path: String, + shape: Shape, +} + +/// An event and the transition batch it applies. Profile §3: an event names +/// several transitions, applied atomically. +#[derive(Debug)] +struct EventDecl { + name: String, + transitions: Vec, +} + +#[derive(Debug)] +struct Transition { + target: String, + form: Form, + /// `cycle` members, which must be declared in the target's enum shape. + tokens: Vec, + line: usize, + column: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum Form { + Set, + Toggle, + Cycle, + Clear, + /// Anything else — an expression, which L0 has no form for. + NotTotal(String), +} + +#[derive(Debug)] +struct Component { + name: String, + params: Vec, + states: Vec, + events: Vec, + body: Element, + line: usize, + /// Components this one instantiates, for the acyclicity check. + instantiates: Vec, +} + +#[derive(Debug)] +struct View { + name: String, + body: Element, +} + +#[derive(Debug, Default)] +struct Element { + /// Constructor name, a referenced view/component name, or empty for a group. + name: String, + args: Vec, + children: Vec, + /// Bindings a `for` introduces: the item and the optional index. + binders: Vec, + line: usize, + column: usize, + is_reference: bool, +} + +#[derive(Debug)] +struct Arg { + name: String, + value: Operand, + line: usize, + column: usize, +} + +#[derive(Debug)] +enum Operand { + Path(String), + Token(String), + Str, + Num, + Predicate(String), +} + +// ─────────────────────────────────────────────────────────────────────── parser ── + +struct Parser<'a> { + tokens: &'a [Token], + at: usize, + sink: &'a mut Diagnostics, + depth: usize, +} + +impl<'a> Parser<'a> { + fn new(tokens: &'a [Token], sink: &'a mut Diagnostics) -> Self { + Self { + tokens, + at: 0, + sink, + depth: 0, + } + } + + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.at) + } + + fn next(&mut self) -> Option { + let t = self.tokens.get(self.at).cloned(); + if t.is_some() { + self.at += 1; + } + t + } + + fn eat_punct(&mut self, p: &str) -> bool { + if self.peek().is_some_and(|t| t.is_punct(p)) { + self.at += 1; + return true; + } + false + } + + fn expect_punct(&mut self, p: &str) -> bool { + if self.eat_punct(p) { + return true; + } + match self.peek().cloned() { + Some(t) => self + .sink + .at(&t, format!("expected {p:?}, found {:?}", t.text)), + None => self + .sink + .push(0, 0, format!("expected {p:?}, found end of source")), + } + false + } + + fn ident(&mut self) -> Option { + match self.peek().cloned() { + Some(t) if t.kind == Kind::Ident => { + self.at += 1; + Some(t.text) + } + Some(t) => { + self.sink + .at(&t, format!("expected a name, found {:?}", t.text)); + None + } + None => { + self.sink + .push(0, 0, "expected a name, found end of source".into()); + None + } + } + } + + fn parse_card(&mut self) -> Card { + let mut card = Card::default(); + let mut guard = 0usize; + + while self.peek().is_some() { + guard += 1; + if guard > MAX_UI_L0_DECLARATIONS { + self.sink.push(0, 0, "too many declarations".into()); + break; + } + let Some(t) = self.peek().cloned() else { break }; + let before = self.at; + + match t.text.as_str() { + "source" => { + self.at += 1; + if let Some(p) = self.dotted_name() { + card.sources.push(p); + } + self.skip_source_query(); + } + "state" => { + self.at += 1; + if let Some(d) = self.parse_state() { + card.states.push(d); + } + } + "event" => { + self.at += 1; + if let Some(e) = self.parse_event() { + card.events.push(e); + } + } + "copy" => { + self.at += 1; + if let Some(n) = self.ident() { + card.copies.push(n); + } + self.skip_braced(); + } + "component" => { + self.at += 1; + if let Some(c) = self.parse_component() { + card.components.push(c); + } + } + "view" => { + self.at += 1; + let Some(name) = self.ident() else { break }; + let body = self.parse_element(); + card.views.push(View { name, body }); + } + _ => { + self.sink.at( + &t, + format!( + "expected a declaration (source, state, event, copy, component, view), found {:?}", + t.text + ), + ); + self.at += 1; + } + } + + // Never loop without consuming; a malformed card must terminate. + if self.at == before { + self.at += 1; + } + } + card + } + + /// `a.b.c`, `a.0.c`, `a[b]` — amendments #3 and #4. A constant index and an + /// index by a bound path are both total lookups, so neither is an expression. + fn dotted_name(&mut self) -> Option { + let mut name = self.ident()?; + let mut guard = 0usize; + loop { + guard += 1; + if guard > 32 { + break; + } + let next_is_segment = self.peek().is_some_and(|t| t.is_punct(".")) + && self + .tokens + .get(self.at + 1) + .is_some_and(|t| matches!(t.kind, Kind::Ident | Kind::Num)); + if next_is_segment { + self.at += 1; + let Some(seg) = self.next() else { break }; + name.push('.'); + name.push_str(&seg.text); + continue; + } + if self.peek().is_some_and(|t| t.is_punct("[")) { + self.at += 1; + // The index is itself a path; it must resolve like any other. + let _ = self.dotted_name(); + self.expect_punct("]"); + continue; + } + break; + } + Some(name) + } + + /// A source body is a capability query the runtime executes. It is not + /// evaluated here and its shape is the runtime's contract, so it is skipped + /// to the end of its balanced parentheses. + fn skip_source_query(&mut self) { + // name + while self + .peek() + .is_some_and(|t| t.kind == Kind::Ident || t.is_punct(".")) + { + self.at += 1; + } + if self.peek().is_some_and(|t| t.is_punct("(")) { + self.skip_balanced("(", ")"); + } + } + + fn skip_braced(&mut self) { + if self.peek().is_some_and(|t| t.is_punct("{")) { + self.skip_balanced("{", "}"); + } + } + + fn skip_balanced(&mut self, open: &str, close: &str) { + let mut depth = 0usize; + while let Some(t) = self.peek().cloned() { + if t.is_punct(open) { + depth += 1; + } else if t.is_punct(close) { + depth -= 1; + self.at += 1; + if depth == 0 { + return; + } + continue; + } + self.at += 1; + } + } + + fn parse_state(&mut self) -> Option { + let path = self.dotted_name()?; + let mut shape = Shape::Other; + // `{ shape: enum[a, b], initial: .a }` — capture the shape, skip the rest. + let start = self.at; + if self.peek().is_some_and(|t| t.is_punct("{")) { + let mut scan = self.at; + while let Some(t) = self.tokens.get(scan) { + if t.is_punct("}") { + break; + } + if t.is_kw("bool") { + shape = Shape::Bool; + } + if t.is_kw("enum") { + let mut members = Vec::new(); + let mut j = scan + 1; + if self.tokens.get(j).is_some_and(|t| t.is_punct("[")) { + j += 1; + while let Some(m) = self.tokens.get(j) { + if m.is_punct("]") { + break; + } + if m.kind == Kind::Ident { + members.push(m.text.clone()); + } + j += 1; + } + } + shape = Shape::Enum(members); + } + scan += 1; + } + self.at = start; + self.skip_braced(); + } + Some(StateDecl { path, shape }) + } + + /// `event name { path: total-form, … }` + fn parse_event(&mut self) -> Option { + let name = self.ident()?; + let mut transitions = Vec::new(); + + if !self.eat_punct("{") { + return Some(EventDecl { name, transitions }); + } + let mut guard = 0usize; + loop { + guard += 1; + if guard > 64 || self.peek().is_none() { + break; + } + if self.eat_punct("}") { + break; + } + let Some(head) = self.peek().cloned() else { + break; + }; + let Some(target) = self.dotted_name() else { + self.at += 1; + continue; + }; + if !self.expect_punct(":") { + break; + } + + let (form, tokens) = self.parse_total_form(); + transitions.push(Transition { + target, + form, + tokens, + line: head.line, + column: head.column, + }); + + if !self.eat_punct(",") { + self.expect_punct("}"); + break; + } + } + Some(EventDecl { name, transitions }) + } + + /// One of `set(v)`, `toggle`, `cycle(.a, .b)`, `clear` — profile §3. Anything + /// else is recorded as not-total so validation can name it. + fn parse_total_form(&mut self) -> (Form, Vec) { + let Some(t) = self.peek().cloned() else { + return (Form::NotTotal(String::new()), Vec::new()); + }; + match t.text.as_str() { + "toggle" => { + self.at += 1; + (Form::Toggle, Vec::new()) + } + "clear" => { + self.at += 1; + (Form::Clear, Vec::new()) + } + "set" => { + self.at += 1; + self.skip_balanced("(", ")"); + (Form::Set, Vec::new()) + } + "cycle" => { + self.at += 1; + let mut members = Vec::new(); + if self.peek().is_some_and(|t| t.is_punct("(")) { + self.at += 1; + let mut guard = 0usize; + while let Some(m) = self.peek().cloned() { + guard += 1; + if guard > 64 || m.is_punct(")") { + break; + } + if m.kind == Kind::Token { + members.push(m.text.trim_start_matches('.').to_string()); + } else if m.kind == Kind::Ident { + // A bare name here is a path, and a path is not a token. + members.push(format!("!{}", m.text)); + } + self.at += 1; + } + self.expect_punct(")"); + } + (Form::Cycle, members) + } + other => { + self.at += 1; + (Form::NotTotal(other.to_string()), Vec::new()) + } + } + } + + fn parse_component(&mut self) -> Option { + let line = self.peek().map(|t| t.line).unwrap_or(0); + let name = self.ident()?; + let mut params = Vec::new(); + + if self.eat_punct("(") { + let mut guard = 0usize; + while self.peek().is_some_and(|t| !t.is_punct(")")) { + guard += 1; + if guard > 64 { + break; + } + if let Some(t) = self.peek().cloned() { + if t.kind == Kind::Ident + && self + .tokens + .get(self.at + 1) + .is_some_and(|n| n.is_punct(":")) + { + params.push(t.text.clone()); + } + } + self.at += 1; + } + self.expect_punct(")"); + } + + self.expect_punct("{"); + let mut states = Vec::new(); + let mut events = Vec::new(); + let mut body = Element::default(); + + let mut guard = 0usize; + while let Some(t) = self.peek().cloned() { + guard += 1; + if guard > MAX_UI_L0_DECLARATIONS || t.is_punct("}") { + break; + } + match t.text.as_str() { + "state" => { + self.at += 1; + if let Some(d) = self.parse_state() { + states.push(d); + } + } + "event" => { + self.at += 1; + if let Some(e) = self.parse_event() { + events.push(e); + } + } + "view" => { + self.at += 1; + body = self.parse_element(); + } + _ => { + self.sink.at( + &t, + format!( + "expected state, event or view in a component, found {:?}", + t.text + ), + ); + self.at += 1; + } + } + } + self.expect_punct("}"); + + let mut instantiates = Vec::new(); + collect_constructors(&body, &mut instantiates); + Some(Component { + name, + params, + states, + events, + body, + line, + instantiates, + }) + } + + fn parse_element(&mut self) -> Element { + self.depth += 1; + let element = self.parse_element_inner(); + self.depth -= 1; + element + } + + fn parse_element_inner(&mut self) -> Element { + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + if let Some(t) = self.peek().cloned() { + self.sink.at(&t, "nesting is too deep".into()); + } + return Element::default(); + } + + let Some(t) = self.peek().cloned() else { + self.sink + .push(0, 0, "expected an element, found end of source".into()); + return Element::default(); + }; + + // `for item[, index] in path key path { … }` + if t.is_kw("for") { + self.at += 1; + let mut binders = Vec::new(); + if let Some(b) = self.ident() { + binders.push(b); + } + if self.eat_punct(",") { + if let Some(b) = self.ident() { + binders.push(b); + } + } + if !self.peek().is_some_and(|t| t.is_kw("in")) { + if let Some(t) = self.peek().cloned() { + self.sink + .at(&t, "expected `in` after the loop binder".into()); + } + } else { + self.at += 1; + } + let collection = self.dotted_name().unwrap_or_default(); + if !self.peek().is_some_and(|t| t.is_kw("key")) { + if let Some(t) = self.peek().cloned() { + self.sink.at( + &t, + "a loop must declare `key`: identity may not come from position".into(), + ); + } + } else { + self.at += 1; + let _ = self.dotted_name(); + } + let mut element = Element { + name: "for".into(), + binders, + line: t.line, + column: t.column, + ..Default::default() + }; + element.args.push(Arg { + name: "in".into(), + value: Operand::Path(collection), + line: t.line, + column: t.column, + }); + element.children = self.parse_block(); + return element; + } + + // `when predicate { … }` + if t.is_kw("when") { + self.at += 1; + let left = self.dotted_name().unwrap_or_default(); + // A comparison, or a bare path when the state is bool-shaped — + // `when expanded { … }`. Neither form computes. + if self.peek().is_some_and(|t| t.kind == Kind::Cmp) { + self.at += 1; + self.at += 1; // right-hand operand + } + let mut element = Element { + name: "when".into(), + line: t.line, + column: t.column, + ..Default::default() + }; + element.args.push(Arg { + name: "predicate".into(), + value: Operand::Path(left), + line: t.line, + column: t.column, + }); + element.children = self.parse_block(); + return element; + } + + // `slot` + if t.is_kw("slot") { + self.at += 1; + return Element { + name: "slot".into(), + line: t.line, + column: t.column, + ..Default::default() + }; + } + + let Some(name_tok) = self.next() else { + return Element::default(); + }; + if name_tok.kind != Kind::Ident { + self.sink.at( + &name_tok, + format!("expected an element, found {:?}", name_tok.text), + ); + return Element::default(); + } + + // A lowercase name with no arguments references another view record. + let is_constructor = name_tok + .text + .chars() + .next() + .is_some_and(|c| c.is_uppercase()); + + let mut element = Element { + name: name_tok.text.clone(), + line: name_tok.line, + column: name_tok.column, + is_reference: !is_constructor, + ..Default::default() + }; + + if self.peek().is_some_and(|t| t.is_punct("(")) { + self.at += 1; + element.args = self.parse_args(); + } + if self.peek().is_some_and(|t| t.is_punct("{")) { + element.children = self.parse_block(); + } + element + } + + fn parse_block(&mut self) -> Vec { + let mut out = Vec::new(); + if !self.expect_punct("{") { + return out; + } + let mut guard = 0usize; + while let Some(t) = self.peek().cloned() { + guard += 1; + if guard > MAX_UI_L0_DECLARATIONS { + self.sink.at(&t, "too many elements in one block".into()); + break; + } + if t.is_punct("}") { + break; + } + let before = self.at; + out.push(self.parse_element()); + if self.at == before { + self.at += 1; + } + } + self.expect_punct("}"); + out + } + + fn parse_args(&mut self) -> Vec { + let mut out = Vec::new(); + let mut guard = 0usize; + loop { + guard += 1; + if guard > 64 { + break; + } + if self.peek().is_none() || self.peek().is_some_and(|t| t.is_punct(")")) { + break; + } + let Some(name_tok) = self.peek().cloned() else { + break; + }; + let Some(name) = self.ident() else { + self.at += 1; + continue; + }; + if !self.expect_punct(":") { + break; + } + let value = self.parse_operand(); + out.push(Arg { + name, + value, + line: name_tok.line, + column: name_tok.column, + }); + if !self.eat_punct(",") { + break; + } + } + self.expect_punct(")"); + out + } + + fn parse_operand(&mut self) -> Operand { + let Some(t) = self.peek().cloned() else { + return Operand::Str; + }; + match t.kind { + Kind::Token => { + self.at += 1; + Operand::Token(t.text) + } + Kind::Str => { + self.at += 1; + Operand::Str + } + Kind::Num => { + self.at += 1; + Operand::Num + } + Kind::Ident => { + let path = self.dotted_name().unwrap_or_default(); + // A comparison in argument position is a predicate operand. + if self.peek().is_some_and(|t| t.kind == Kind::Cmp) { + self.at += 1; + self.at += 1; + return Operand::Predicate(path); + } + Operand::Path(path) + } + _ => { + self.at += 1; + Operand::Str + } + } + } +} + +fn collect_constructors(e: &Element, out: &mut Vec) { + if !e.name.is_empty() && e.name != "for" && e.name != "when" && e.name != "slot" { + out.push(e.name.clone()); + } + for c in &e.children { + collect_constructors(c, out); + } +} + +// ─────────────────────────────────────────────────────────────────────── catalog ── + +/// The argument contract. Mirrors `docs/ui-l0-constructors.toml`, which is the +/// human-readable spec; a test asserts the two agree. +pub mod catalog { + /// What an argument admits. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum ArgKind { + /// One of a closed token set, written with a leading dot. + Token(&'static [&'static str]), + /// A token from the set, or a bound path. + TokenOrPath(&'static [&'static str]), + /// A bound path only. + Path, + /// A literal or a path. + Any, + /// A declared event name. + Event, + /// A number literal or a path; also admits a width token. + Number, + /// Text literal or path. + Text, + /// A predicate or a bool path. + Bool, + } + + pub const UNIT: &[&str] = &[ + "c", "f", "pct", "speed", "pressure", "index", "distance", "money", + ]; + pub const FORMAT: &[&str] = &[ + "money", + "signed_money", + "signed_pct", + "compact", + "ratio", + "time", + "date", + ]; + pub const WIDTH: &[&str] = &["fill", "fit", "day", "rank", "temp"]; + pub const ALIGN: &[&str] = &["start", "center", "end", "baseline"]; + pub const PAD: &[&str] = &["page", "tight", "none"]; + pub const ICON_SIZE: &[&str] = &["hero", "row", "tile"]; + + pub type Args = &'static [(&'static str, ArgKind)]; + + use ArgKind::*; + + /// Every constructor L0 admits, with the arguments each accepts. + pub const CONSTRUCTORS: &[(&str, Args)] = &[ + ("Surface", &[("pad", Token(PAD))]), + ("Photo", &[("src", Path), ("pad", Token(PAD))]), + ("Panel", &[]), + ("Card", &[("on_tap", Event), ("value", Any)]), + ("Col", &[("align", Token(ALIGN)), ("gap", Number)]), + ( + "Row", + &[ + ("align", Token(ALIGN)), + ("gap", Number), + ("on_tap", Event), + ("value", Any), + ], + ), + ("Grid", &[("cols", Number)]), + ("Rule", &[]), + ( + "TextHero", + &[ + ("text", Text), + ("value", Number), + ("unit", TokenOrPath(UNIT)), + ("format", Token(FORMAT)), + ("on_tap", Event), + ], + ), + ( + "TextTitle", + &[("text", Text), ("width", TokenOrPath(WIDTH))], + ), + ("TextBody", &[("text", Text), ("width", TokenOrPath(WIDTH))]), + ( + "TextStat", + &[("value", Number), ("format", Token(FORMAT)), ("tint", Path)], + ), + ("TextRow", &[("text", Text), ("width", TokenOrPath(WIDTH))]), + ( + "TextCaption", + &[ + ("text", Text), + ("value", Number), + ("glyph", Text), + ("suffix", Text), + ("unit", TokenOrPath(UNIT)), + ("width", TokenOrPath(WIDTH)), + ], + ), + ( + "TextValue", + &[ + ("value", Number), + ("unit", TokenOrPath(UNIT)), + ("format", Token(FORMAT)), + ("tint", Path), + ], + ), + ( + "Tile", + &[ + ("label", Text), + ("value", Number), + ("unit", TokenOrPath(UNIT)), + ("format", Token(FORMAT)), + ], + ), + ( + "Chip", + &[ + ("text", Text), + ("on_tap", Event), + ("value", Any), + ("active", Bool), + ], + ), + ("WeatherIcon", &[("cond", Path), ("size", Token(ICON_SIZE))]), + ( + "TempBar", + &[("lo", Path), ("hi", Path), ("min", Path), ("max", Path)], + ), + ("SunArc", &[("rise", Path), ("set", Path), ("now", Path)]), + ("MoonPhase", &[("phase", Path), ("illum", Path)]), + ( + "AqiContour", + &[("field", Path), ("index", Path), ("band", Path)], + ), + ( + "StockPlot", + &[ + ("points", Path), + ("min", Path), + ("max", Path), + ("tint", Path), + ], + ), + ]; + + pub fn lookup(name: &str) -> Option { + CONSTRUCTORS + .iter() + .find(|(n, _)| *n == name) + .map(|(_, a)| *a) + } +} + +// ──────────────────────────────────────────────────────────────────── validation ── + +fn validate(card: &Card, sink: &mut Diagnostics) { + // Components must not form a cycle — profile §6, condition 1. Without this + // an L0 realization need not terminate. + check_component_acyclicity(card, sink); + validate_transitions(card, sink); + + let view_names: Vec<&str> = card.views.iter().map(|v| v.name.as_str()).collect(); + let component_names: Vec<&str> = card.components.iter().map(|c| c.name.as_str()).collect(); + + for view in &card.views { + let mut scope = Scope::card(card); + walk( + &view.body, + &mut scope, + &view_names, + &component_names, + card, + sink, + ); + } + for component in &card.components { + let mut scope = Scope::component(card, component); + walk( + &component.body, + &mut scope, + &view_names, + &component_names, + card, + sink, + ); + } +} + +/// Profile §3: every transition names a declared state and uses a total form. +/// +/// A component's events target its OWN state. Card state is written by card-scope +/// events, which a component reaches only by receiving one as a prop (§5.4) — so +/// the transition still runs in the scope that declared it. +fn validate_transitions(card: &Card, sink: &mut Diagnostics) { + check_event_batch(&card.events, &card.states, sink); + for component in &card.components { + check_event_batch(&component.events, &component.states, sink); + } +} + +fn check_event_batch(events: &[EventDecl], states: &[StateDecl], sink: &mut Diagnostics) { + for event in events { + for transition in &event.transitions { + let Some(state) = states.iter().find(|s| s.path == transition.target) else { + sink.push( + transition.line, + transition.column, + format!( + "{:?} is not a state declared in this scope", + transition.target + ), + ); + continue; + }; + + match &transition.form { + Form::NotTotal(text) => sink.push( + transition.line, + transition.column, + format!( + "{text:?} is not a total transition form; L0 admits set(…), toggle, cycle(…) and clear" + ), + ), + Form::Toggle if state.shape != Shape::Bool => sink.push( + transition.line, + transition.column, + format!( + "`toggle` needs a bool state, but {:?} is not bool", + transition.target + ), + ), + Form::Cycle => match &state.shape { + Shape::Enum(members) => { + for member in &transition.tokens { + if let Some(bare) = member.strip_prefix('!') { + sink.push( + transition.line, + transition.column, + format!( + "`cycle` takes tokens; {bare:?} is a path — enum members carry a leading dot" + ), + ); + } else if !members.contains(member) { + sink.push( + transition.line, + transition.column, + format!( + ".{member} is not a member of {:?}'s enum", + transition.target + ), + ); + } + } + } + _ => sink.push( + transition.line, + transition.column, + format!("`cycle` needs an enum state, but {:?} is not", transition.target), + ), + }, + _ => {} + } + } + } +} + +fn check_component_acyclicity(card: &Card, sink: &mut Diagnostics) { + for component in &card.components { + let mut seen = vec![component.name.as_str()]; + let mut queue: Vec<&str> = component.instantiates.iter().map(|s| s.as_str()).collect(); + let mut steps = 0usize; + while let Some(next) = queue.pop() { + steps += 1; + if steps > MAX_UI_L0_DECLARATIONS { + break; + } + if next == component.name { + sink.push( + component.line, + 1, + format!( + "component {:?} is recursive; L0 requires an acyclic component graph", + component.name + ), + ); + break; + } + if seen.contains(&next) { + continue; + } + seen.push(next); + if let Some(c) = card.components.iter().find(|c| c.name == next) { + queue.extend(c.instantiates.iter().map(|s| s.as_str())); + } + } + } +} + +/// Names visible to one record. Profile §5.3: a component sees its props, its own +/// state and events, and card-scope `source` and `copy` — but never card `state`. +struct Scope { + roots: Vec, + events: Vec, + /// Card state, tracked separately so reading it from a component is a + /// specific diagnostic rather than "unknown name". + forbidden_state: Vec, +} + +impl Scope { + fn card(card: &Card) -> Self { + let mut roots: Vec = Vec::new(); + roots.extend(card.sources.iter().map(root_of)); + roots.extend(card.states.iter().map(|s| root_of(&s.path))); + roots.push("copy".into()); + let events = card.events.iter().map(|e| e.name.clone()).collect(); + Self { + roots, + events, + forbidden_state: Vec::new(), + } + } + + fn component(card: &Card, component: &Component) -> Self { + let mut roots: Vec = Vec::new(); + roots.extend(card.sources.iter().map(root_of)); + roots.push("copy".into()); + roots.extend(component.params.iter().cloned()); + roots.extend(component.states.iter().map(|s| root_of(&s.path))); + let mut events: Vec = component.events.iter().map(|e| e.name.clone()).collect(); + // Events arrive as props too — §5.4, outputs are event props. + events.extend(component.params.iter().cloned()); + Self { + roots, + events, + forbidden_state: card.states.iter().map(|s| root_of(&s.path)).collect(), + } + } + + fn knows(&self, root: &str) -> bool { + self.roots.iter().any(|r| r == root) + } +} + +fn root_of>(path: S) -> String { + path.as_ref() + .split('.') + .next() + .unwrap_or_default() + .to_string() +} + +#[allow(clippy::too_many_arguments)] +fn walk( + element: &Element, + scope: &mut Scope, + views: &[&str], + components: &[&str], + card: &Card, + sink: &mut Diagnostics, +) { + match element.name.as_str() { + "" | "slot" => {} + "for" => { + for binder in &element.binders { + scope.roots.push(binder.clone()); + } + if let Some(Arg { + value: Operand::Path(p), + line, + column, + .. + }) = element.args.first() + { + check_path(p, scope, *line, *column, sink); + } + } + "when" => { + if let Some(Arg { + value: Operand::Path(p), + line, + column, + .. + }) = element.args.first() + { + check_path(p, scope, *line, *column, sink); + } + } + _ if element.is_reference => { + if !views.contains(&element.name.as_str()) { + sink.push( + element.line, + element.column, + format!("{:?} is not a declared view", element.name), + ); + } + } + name => { + let known = catalog::lookup(name); + let is_component = components.contains(&name); + if known.is_none() && !is_component { + sink.push( + element.line, + element.column, + format!("{name:?} is not an L0 constructor or a declared component"), + ); + } + for arg in &element.args { + check_arg(name, arg, known, is_component, scope, card, sink); + } + } + } + + for child in &element.children { + walk(child, scope, views, components, card, sink); + } + + if element.name == "for" { + for _ in &element.binders { + scope.roots.pop(); + } + } +} + +fn check_arg( + ctor: &str, + arg: &Arg, + known: Option, + is_component: bool, + scope: &Scope, + card: &Card, + sink: &mut Diagnostics, +) { + use catalog::ArgKind::*; + + // A component's own props are declared by the card, not the catalog. + let kind = match known { + Some(args) => match args.iter().find(|(n, _)| *n == arg.name) { + Some((_, k)) => Some(*k), + None => { + sink.push( + arg.line, + arg.column, + format!("{ctor} has no argument {:?}", arg.name), + ); + return; + } + }, + None if is_component => None, + None => return, + }; + + match (&arg.value, kind) { + (Operand::Token(tok), Some(Token(set) | TokenOrPath(set))) => { + let bare = tok.trim_start_matches('.'); + if !set.contains(&bare) { + sink.push( + arg.line, + arg.column, + format!( + "{tok:?} is not a legal token for {ctor}.{}; expected one of {}", + arg.name, + set.iter() + .map(|t| format!(".{t}")) + .collect::>() + .join(", ") + ), + ); + } + } + (Operand::Token(tok), Some(Path)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} takes a bound path, not the token {tok:?}", + arg.name + ), + ); + } + (Operand::Path(path), Some(Token(_))) => { + // Enum members are tokens; a bare name here is a path and cannot + // stand in for one. + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} takes a token; {path:?} is a path — tokens carry a leading dot", + arg.name + ), + ); + } + (Operand::Path(path), Some(Event)) if !scope.events.iter().any(|e| e == path) => { + sink.push( + arg.line, + arg.column, + format!("{path:?} is not a declared event"), + ); + } + (Operand::Path(_), Some(Event)) => {} + // A component prop has no catalogued kind, and may legitimately receive an + // event name — profile §5.4, outputs are event props. Accept either. + (Operand::Path(path), None) if scope.events.iter().any(|e| e == path) => {} + (Operand::Path(path), _) => check_path(path, scope, arg.line, arg.column, sink), + (Operand::Predicate(path), _) => check_path(path, scope, arg.line, arg.column, sink), + _ => {} + } + + let _ = card; +} + +fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut Diagnostics) { + if path.is_empty() { + return; + } + let root = root_of(path); + if scope.knows(&root) { + return; + } + if scope.forbidden_state.contains(&root) { + sink.push( + line, + column, + format!( + "a component may not read card state {root:?}; pass it as a prop (profile §5.3)" + ), + ); + return; + } + sink.push(line, column, format!("{root:?} is not a declared name")); +} diff --git a/crates/splash-core/tests/fixtures/news.card b/crates/splash-core/tests/fixtures/news.card new file mode 100644 index 0000000..1b61203 --- /dev/null +++ b/crates/splash-core/tests/fixtures/news.card @@ -0,0 +1,108 @@ +# ledger news@1.0.0 +# level: L0 +# profile: ui/l0 +# model: glm-5.2 +# +# Reference card for Splash/docs/ui-profile-l0.md. Expresses everything +# app/app/src/app/plan/news.rs lowers: Masthead, LeadStory, StoryFeed. +# +# Note what is absent: no headline, byline, score or url appears anywhere. A +# fabricated headline is worse than a wrong coordinate — it is quotable and +# indistinguishable from a real one — so there is no field that could hold one. + +# TWO sources, not one sliced two ways. The feed must skip the lead story, and +# the profile refuses a derived sub-collection (`stories.rest`) — `rest` invites +# `filter`, which invites a predicate, which invites arithmetic. The runtime +# shapes the data; the view does not reshape it. +source lead sys.news(count: 1, fields: [id, title, author, points, comments, url]) +source feed sys.news(count: 7, offset: 1, fields: [id, title, author, points]) +source article sys.news_item(id: state.selected, + fields: [title, author, points, comments, url]) +source env.locale sys.locale() + +state selected { shape: text, initial: "" } # "" ⇒ feed, else a story id + +event open_story { selected: set($value) } +event back { selected: clear } + +copy masthead { class: vocabulary, en: "Top Stories", zh: "头条" } +copy latest { class: vocabulary, en: "LATEST", zh: "最新" } +copy lead { class: vocabulary, en: "LEAD", zh: "焦点" } +copy pts { class: vocabulary, en: "pts", zh: "分" } +copy comments { class: vocabulary, en: "comments", zh: "评论" } +copy by { class: vocabulary, en: "by", zh: "作者" } + +view root Surface(pad: .page) { + when selected == "" { stream } + when selected != "" { story } + } + +view stream Col { + masthead + headline + latest + } + +view masthead Col { + TextCaption(text: copy.masthead) + TextHero(text: copy.masthead) + } + +# The lead story, given room. Every field is live. +view headline Card(on_tap: open_story, value: lead.0.id) { + TextCaption(text: copy.lead) + TextBody(text: lead.0.title, width: .fill) + Row(gap: 6) { + TextCaption(value: lead.0.points, suffix: copy.pts) + TextCaption(value: lead.0.comments, suffix: copy.comments) + TextCaption(text: copy.by) + TextCaption(text: lead.0.author) + } + } + +# Exercises the component contracts (profile §5.2-§5.7): +# - typed props with a `record` shape +# - an EVENT prop: the row raises open_story, which is declared at card scope, +# so the transition runs where it was declared rather than in the child +# - reads card-scope `copy` directly (§5.3 permits copy and source, not state) +# - has no local state, so its schema id is empty and it never resets +# +# The prop is `position`, not `rank`: `width: .rank` is a layout token, and a +# prop of the same name would collide. See finding #9. +component StoryRow(story: record, position: number, on_open: event) { + view Col { + Row(align: .center, on_tap: on_open, value: story.id) { + TextRow(text: position, width: .rank) + Col(gap: 3) { + TextRow(text: story.title, width: .fill) + Row(gap: 6) { + TextCaption(value: story.points, suffix: copy.pts) + TextCaption(text: story.author) + } + } + } + Rule() + } +} + +# `i` is the loop index binding (amendment #5) — the rank column is supplied by +# the iterator, not computed in the card. +view latest Col { + TextCaption(text: copy.latest) + Panel { + for s, i in feed key s.id { + StoryRow(story: s, position: i, on_open: open_story) + } + } + } + +view story Col { + Row(on_tap: back) { TextCaption(glyph: "‹") } + TextTitle(text: article.title, width: .fill) + Row(gap: 6) { + TextCaption(value: article.points, suffix: copy.pts) + TextCaption(text: copy.by) + TextCaption(text: article.author) + } + TextCaption(text: article.url) + } diff --git a/crates/splash-core/tests/fixtures/stock.card b/crates/splash-core/tests/fixtures/stock.card new file mode 100644 index 0000000..fe9d7e3 --- /dev/null +++ b/crates/splash-core/tests/fixtures/stock.card @@ -0,0 +1,94 @@ +# ledger stock@1.0.0 +# level: L0 +# profile: ui/l0 +# model: glm-5.2 +# +# Reference card for Splash/docs/ui-profile-l0.md. Expresses everything +# app/app/src/app/plan/stock.rs lowers: MoversList, QuoteHeader, PriceChart, +# StatGrid. The hardest of the three: it is the only one with a genuine +# list↔detail switch, and the only one whose chart depends on a second piece +# of state. + +source movers sys.movers(count: 10, fields: [ticker, name, last, change, pct]) +source quote sys.quote(ticker: state.selected, + fields: [name, last, change, pct, open, high, low, + volume, mktcap, pe]) +source series sys.series(ticker: state.selected, range: state.range, + fields: [points, min, max]) +source env.locale sys.locale() + +state selected { shape: text, initial: "" } # "" ⇒ movers list +state range { shape: enum[d1, w1, m1, m6, y1], initial: .m1 } + +event open_quote { selected: set($value) } +event back { selected: clear, range: clear } # two writes, one gesture +event set_range { range: set($value) } + +copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" } +copy open { class: vocabulary, en: "Open", zh: "开盘" } +copy high { class: vocabulary, en: "High", zh: "最高" } +copy low { class: vocabulary, en: "Low", zh: "最低" } +copy volume { class: vocabulary, en: "Volume", zh: "成交量" } +copy mktcap { class: vocabulary, en: "Mkt Cap", zh: "市值" } +copy pe { class: vocabulary, en: "P/E", zh: "市盈率" } + +view root Surface(pad: .page) { + when selected == "" { list } + when selected != "" { detail } + } + +view list Col { + TextHero(text: copy.movers) + Panel { + for m in movers key m.ticker { + Row(align: .center, on_tap: open_quote, value: m.ticker) { + Col(gap: 2) { + TextRow(text: m.ticker) + TextCaption(text: m.name, width: .fill) + } + TextValue(value: m.last, format: .money) + TextValue(value: m.pct, format: .signed_pct, tint: m.change) + } + Rule() + } + } + } + +view detail Col { + Row(on_tap: back) { TextCaption(glyph: "‹") } + header + chart + ranges + stats + } + +view header Col { + TextCaption(text: quote.name) + TextHero(value: quote.last, format: .money) + Row(gap: 8) { + TextStat(value: quote.change, format: .signed_money, tint: quote.change) + TextStat(value: quote.pct, format: .signed_pct, tint: quote.change) + } + } + +view chart StockPlot(points: series.points, min: series.min, max: series.max, + tint: quote.change) + +# Range switching is a set() over a declared enum — no expression, and the +# selected range is a guard rather than a computed style. +view ranges Row(gap: 6) { + Chip(text: "1D", on_tap: set_range, value: .d1, active: range == .d1) + Chip(text: "1W", on_tap: set_range, value: .w1, active: range == .w1) + Chip(text: "1M", on_tap: set_range, value: .m1, active: range == .m1) + Chip(text: "6M", on_tap: set_range, value: .m6, active: range == .m6) + Chip(text: "1Y", on_tap: set_range, value: .y1, active: range == .y1) + } + +view stats Grid(cols: 2) { + Tile(label: copy.open, value: quote.open, format: .money) + Tile(label: copy.high, value: quote.high, format: .money) + Tile(label: copy.low, value: quote.low, format: .money) + Tile(label: copy.volume, value: quote.volume, format: .compact) + Tile(label: copy.mktcap, value: quote.mktcap, format: .compact) + Tile(label: copy.pe, value: quote.pe, format: .ratio) + } diff --git a/crates/splash-core/tests/fixtures/trip_planner.splash b/crates/splash-core/tests/fixtures/trip_planner.splash new file mode 100644 index 0000000..19d5196 --- /dev/null +++ b/crates/splash-core/tests/fixtures/trip_planner.splash @@ -0,0 +1,664 @@ +// name: nav-app +let q = "{{state.q}}" +let find = "{{state.find}}" +let orig = "{{state.orig}}" +let dest = "{{state.dest}}" +let wp1 = "{{state.wp1}}" +let wp2 = "{{state.wp2}}" +let sel = "{{state.sel}}" +let ss = {{state.sel}} +let go = "{{state.go}}" +let vw = "{{state.view}}" +let md = "{{state.mode}}" +let oq = "{{state.oq}}" + +// origin — defaults to a San Jose start. An intent-seeded origin QUERY (oq, +// e.g. "Saratoga High School" parsed from "from A to B") resolves to its top +// search hit; an explicitly picked origin (find == "orig") still wins. +let olat = 37.3350 +let olon = -121.8850 +let oname = "San Jose (downtown)" +// Prefer the DEVICE's real GPS fix as the start when the trip names no origin. +// sys.gps reads the last LocationListener fix synchronously ("ok" < 1 until the +// first fix lands, then this takes over from the SJ default). An intent origin +// (oq) or an explicitly picked origin (orig) below still overrides it. +if sys.gps("ok") >= 1 { + olat = sys.gps("lat") + olon = sys.gps("lon") + oname = "Your location" +} +if oq != "0" { + if sys.searchnum(oq, 0, "lat") >= -900 { + olat = sys.searchnum(oq, 0, "lat") + olon = sys.searchnum(oq, 0, "lon") + oname = sys.search(oq, 0, "name") + } +} +if orig != "0" { + olat = sys.coord(orig, "lat") + olon = sys.coord(orig, "lon") + oname = sys.coord(orig, "name") +} +let dlat = sys.coord(dest, "lat") +let dlon = sys.coord(dest, "lon") + +// selected search result (destination preview) +let plat = 0 +let plon = 0 +if sel != "0" { + plat = sys.searchnum(q, ss - 1, "lat") + plon = sys.searchnum(q, ss - 1, "lon") +} + +// waypoints -> OSRM vias string ("lat,lon;lat,lon") +let vias = "" +if wp1 != "0" { vias = "" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") } +if wp2 != "0" { + if vias != "" { vias = vias + ";" } + vias = vias + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") +} + +// map annotation pins: origin(0);stop(1);stop(1);destination(2) +let mk = "" + olat + "," + olon + ",0" +if wp1 != "0" { mk = mk + ";" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") + ",1" } +if wp2 != "0" { mk = mk + ";" + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + ",1" } +mk = mk + ";" + dlat + "," + dlon + ",2" + +// normalized travel mode (default drive) +let mdn = md +if md == "0" { mdn = "drive" } + +// screen selector +let scr = "search" +if find == "orig" { scr = "find" } +if find == "stop" { scr = "find" } +if find == "dest" { scr = "find" } +if dest == "0" { if find == "0" { if sel != "0" { scr = "preview" } } } +if dest != "0" { if find == "0" { scr = "plan" } } +if go == "1" { scr = "drive" } + +fn tick() { + // A CHANGED destination (find -> "dest") sets `dest` = "lat,lon|name". + // Re-resolve its coords here too (top-level dlat/dlon freeze at build) so the + // route + end pin follow the new destination. sys.coord is synchronous. + if dest != "0" { + dlat = sys.coord(dest, "lat") + dlon = sys.coord(dest, "lon") + } + // Re-resolve WAYPOINTS -> the OSRM `vias` string here too (top-level freezes + // at build): otherwise ADDING A STOP never reroutes the trip through it. + vias = "" + if wp1 != "0" { vias = "" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") } + if wp2 != "0" { + if vias != "" { vias = vias + ";" } + vias = vias + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + } + // The intent may seed an ORIGIN query (oq, parsed from "from A to B"). The + // top-level origin resolution FREEZES at build time — before oq's search + // lands — so re-resolve it here EVERY tick (tick re-runs on live data) and + // rebuild the marker string, so the route starts from the requested origin + // instead of the San Jose default. An explicitly picked origin still wins. + if orig == "0" { if oq != "0" { if sys.searchnum(oq, 0, "lat") >= -900 { + olat = sys.searchnum(oq, 0, "lat") + olon = sys.searchnum(oq, 0, "lon") + oname = sys.search(oq, 0, "name") + mk = "" + olat + "," + olon + ",0" + if wp1 != "0" { mk = mk + ";" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") + ",1" } + if wp2 != "0" { mk = mk + ";" + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + ",1" } + mk = mk + ";" + dlat + "," + dlon + ",2" + } } } + // No named origin (no picked `orig`, no intent `oq`) -> follow the DEVICE GPS. + // GPS may land AFTER build, so (re)resolve it here each tick and rebuild the + // marker string, exactly like the oq path above. + if orig == "0" { if oq == "0" { if sys.gps("ok") >= 1 { + olat = sys.gps("lat") + olon = sys.gps("lon") + oname = "Your location" + mk = "" + olat + "," + olon + ",0" + if wp1 != "0" { mk = mk + ";" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") + ",1" } + if wp2 != "0" { mk = mk + ";" + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + ",1" } + mk = mk + ";" + dlat + "," + dlon + ",2" + } } } + // An explicitly PICKED start point (find -> "orig") sets `orig` = "lat,lon|name". + // Re-resolve it here too (not only at the top level, which freezes at build): + // otherwise "Change" the start point has no effect and the origin stays at the + // intent's oq / default. sys.coord is synchronous so this lands immediately. + if orig != "0" { + olat = sys.coord(orig, "lat") + olon = sys.coord(orig, "lon") + oname = sys.coord(orig, "name") + mk = "" + olat + "," + olon + ",0" + if wp1 != "0" { mk = mk + ";" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon") + ",1" } + if wp2 != "0" { mk = mk + ";" + sys.coord(wp2, "lat") + "," + sys.coord(wp2, "lon") + ",1" } + mk = mk + ";" + dlat + "," + dlon + ",2" + } + // ---- search / find overlay: live results for the typed query ---- + if scr == "search" { if q != "0" { + // sys.searchnum "count" is -9999 while the fetch is loading OR failed, + // and a real Photon count is small (0..=8). Default to "Searching…" and + // only show a real count when it's in the sane 0..=500 range — so a + // pending/failed fetch never leaks the raw "-9999 results" sentinel. + let sc = sys.searchnum(q, 0, "count") + ui.cnt.set_text("Searching…") + if sc >= 0 { if sc <= 500 { ui.cnt.set_text("" + sc + " results") } } + ui.sr0n.set_text(sys.search(q, 0, "name")) + ui.sr0c.set_text(sys.search(q, 0, "cat")) + ui.sr0l.set_text(sys.search(q, 0, "label")) + ui.sr1n.set_text(sys.search(q, 1, "name")) + ui.sr1c.set_text(sys.search(q, 1, "cat")) + ui.sr1l.set_text(sys.search(q, 1, "label")) + ui.sr2n.set_text(sys.search(q, 2, "name")) + ui.sr2c.set_text(sys.search(q, 2, "cat")) + ui.sr2l.set_text(sys.search(q, 2, "label")) + ui.sr3n.set_text(sys.search(q, 3, "name")) + ui.sr3c.set_text(sys.search(q, 3, "cat")) + ui.sr3l.set_text(sys.search(q, 3, "label")) + ui.sr4n.set_text(sys.search(q, 4, "name")) + ui.sr4c.set_text(sys.search(q, 4, "cat")) + ui.sr4l.set_text(sys.search(q, 4, "label")) + } } + if scr == "find" { + ui.ftot.set_text(sys.navroute(olat, olon, dlat, dlon, "min", vias) + " · " + sys.navroute(olat, olon, dlat, dlon, "km", vias)) + // keep the origin name current (inline text: oname freezes before the + // origin geocode lands; the tick has the resolved value). + if find != "orig" { ui.ffromn.set_text(oname) } + if q == "0" { ui.ytstart.set_text(oname) } + } + if scr == "find" { if q != "0" { + let fc = sys.searchnum(q, 0, "count") + ui.fcnt.set_text("Searching…") + if fc >= 0 { if fc <= 500 { ui.fcnt.set_text("" + fc + " results") } } + ui.fr0n.set_text(sys.search(q, 0, "name")) + ui.fr0l.set_text(sys.search(q, 0, "label")) + ui.fr1n.set_text(sys.search(q, 1, "name")) + ui.fr1l.set_text(sys.search(q, 1, "label")) + ui.fr2n.set_text(sys.search(q, 2, "name")) + ui.fr2l.set_text(sys.search(q, 2, "label")) + ui.fr3n.set_text(sys.search(q, 3, "name")) + ui.fr3l.set_text(sys.search(q, 3, "label")) + } } + // ---- destination preview ---- + // Re-read the picked result's coords EVERY tick (not the once-computed + // top-level plat/plon): when the card opens straight on preview (intent + // seeded the destination) the search hasn't resolved yet, so top-level + // coords are -9999; recomputing here lets the route/ETA/map fill in the + // moment sys.search lands. + if scr == "preview" { + let pplat = sys.searchnum(q, ss - 1, "lat") + let pplon = sys.searchnum(q, ss - 1, "lon") + ui.pvname.set_text(sys.search(q, ss - 1, "name")) + ui.pvcat.set_text(sys.search(q, ss - 1, "cat")) + ui.pvaddr.set_text(sys.search(q, ss - 1, "label")) + ui.pveta.set_text(sys.navroute(olat, olon, pplat, pplon, "min") + " · " + sys.navroute(olat, olon, pplat, pplon, "km")) + ui.pmap.set_nav_polyline(sys.navroute(olat, olon, pplat, pplon, "polyline")) + ui.pmap.set_route_markers("" + olat + "," + olon + ",0;" + pplat + "," + pplon + ",2") + } + // ---- plan ---- + if scr == "plan" { + ui.oname.set_text(oname) + ui.dname.set_text(sys.coord(dest, "name")) + if md == "walk" { ui.eta.set_text(sys.navroute(olat, olon, dlat, dlon, "walk", vias)) } + if md == "bike" { ui.eta.set_text(sys.navroute(olat, olon, dlat, dlon, "bike", vias)) } + if md == "drive" { ui.eta.set_text(sys.navroute(olat, olon, dlat, dlon, "min", vias)) } + if md == "0" { ui.eta.set_text(sys.navroute(olat, olon, dlat, dlon, "min", vias)) } + ui.etad.set_text(sys.navroute(olat, olon, dlat, dlon, "km", vias)) + ui.themap.set_nav_polyline(sys.navroute(olat, olon, dlat, dlon, "polyline", vias)) + ui.themap.set_route_markers(mk) + if wp1 != "0" { ui.wp1n.set_text(sys.coord(wp1, "name")) } + if wp2 != "0" { ui.wp2n.set_text(sys.coord(wp2, "name")) } + // per-leg time · distance + if wp1 != "0" { + ui.leg0.set_text(sys.navroute(olat, olon, sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), "min") + " · " + sys.navroute(olat, olon, sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), "km")) + if wp2 != "0" { + ui.leg1.set_text(sys.navroute(sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), sys.coord(wp2, "lat"), sys.coord(wp2, "lon"), "min") + " · " + sys.navroute(sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), sys.coord(wp2, "lat"), sys.coord(wp2, "lon"), "km")) + ui.leg2.set_text(sys.navroute(sys.coord(wp2, "lat"), sys.coord(wp2, "lon"), dlat, dlon, "min") + " · " + sys.navroute(sys.coord(wp2, "lat"), sys.coord(wp2, "lon"), dlat, dlon, "km")) + } + if wp2 == "0" { + ui.leg1.set_text(sys.navroute(sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), dlat, dlon, "min") + " · " + sys.navroute(sys.coord(wp1, "lat"), sys.coord(wp1, "lon"), dlat, dlon, "km")) + } + } + } + // ---- drive (turn-by-turn) ---- + if scr == "drive" { + let km = sys.navroutenum(olat, olon, dlat, dlon, "km", vias) + let d = sys.navsecs(km * 1000 / 15.2) * 15.2 + ui.dvmap.set_nav_polyline(sys.navroute(olat, olon, dlat, dlon, "polyline", vias)) + ui.dvmap.set_route_markers(mk) + ui.instr.set_text(sys.navstep(olat, olon, dlat, dlon, d, "instr", vias)) + ui.arw.set_text(sys.navstep(olat, olon, dlat, dlon, d, "arrow", vias)) + ui.ndist.set_text(sys.navstep(olat, olon, dlat, dlon, d, "dist", vias)) + let road = sys.navstep(olat, olon, dlat, dlon, d, "road", vias) + ui.roadlbl.set_text(road) + ui.roadpill.set_visible(road != "") + ui.remmin.set_text(sys.navstep(olat, olon, dlat, dlon, d, "remmin", vias) + " min") + ui.remrest.set_text(sys.navstep(olat, olon, dlat, dlon, d, "rem", vias) + " · 34 mph") + } +} + +SolidView{ width: Fill height: 812 flow: Overlay new_batch: true draw_bg.color: #0b1017 + + // ===================== MAP BACKDROP ===================== + if scr == "drive" { + if vw == "2d" { dvmap := MapView{ width: Fill height: 812 nav_mode: "2d" nav_period: 100 nav_route_width: 11.0 zoom: 16.0 min_zoom: 3.0 max_zoom: 19.0 use_network: true use_local_mbtiles: false } } + if vw != "2d" { dvmap := MapView{ width: Fill height: 812 nav_mode: "3d" nav_period: 100 nav_route_width: 14.0 zoom: 15.0 min_zoom: 3.0 max_zoom: 19.0 use_network: true use_local_mbtiles: false } } + if vw != "2d" { GradientYView{ width: Fill height: 150 draw_bg.color: #8fb2d8 draw_bg.color_2: #d8e3ed } } + } + + // ===================== DESTINATION SEARCH ===================== + if scr == "search" { + View{ width: Fill height: Fill flow: Down padding: Inset{left: 16 top: 50 right: 16 bottom: 10} spacing: 12 + RoundedView{ width: Fill height: 52 flow: Right align: Align{y: 0.5} draw_bg.color: #141c28 draw_bg.border_radius: 26 draw_bg.border_size: 1.0 draw_bg.border_color: #2a3644 padding: Inset{left: 19 right: 18} spacing: 12 + View{ width: 20 height: 20 flow: Overlay + CircleView{ width: 15 height: 15 draw_bg.color: #8b99b0 } + CircleView{ width: 10 height: 10 margin: Inset{left: 2.5 top: 2.5} draw_bg.color: #141c28 } + RoundedView{ width: 7.5 height: 2.7 margin: Inset{left: 11 top: 13.5} draw_bg.color: #8b99b0 draw_bg.border_radius: 1.3 } + } + TextInput{ width: Fill height: Fill empty_text: "Search a destination" return_key_type: Send draw_bg.color: #00000000 draw_bg.color_hover: #00000000 draw_bg.color_focus: #00000000 draw_bg.color_down: #00000000 draw_bg.color_empty: #00000000 draw_bg.border_size: 0.0 draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 on_return: |text| agent.notify("set", {key: "q", value: text}) } + } + cnt := Label{ text: "Type a place, then search" draw_text.color: #7c8aa6 draw_text.text_style.font_size: 12 } + if q != "0" { + RoundedView{ width: Fill height: Fit flow: Down draw_bg.color: #141b26 draw_bg.border_radius: 16 padding: Inset{left: 6 top: 4 right: 6 bottom: 4} + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "1" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr0n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr0c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr0l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "1"}) } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "2" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr1n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr1c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr1l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "2"}) } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "3" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr2n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr2c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr2l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "3"}) } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "4" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr3n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr3c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr3l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "4"}) } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 10 top: 11 right: 10 bottom: 11} + View{ width: 26 height: 26 flow: Down align: Align{x: 0.5 y: 0.5} Label{ text: "5" draw_text.color: #8fa0b8 draw_text.text_style.font_size: 13 } } + View{ width: Fill height: Fit flow: Down spacing: 2 + sr4n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + sr4c := Label{ width: Fill text: "" draw_text.color: #7d93b2 draw_text.text_style.font_size: 12 } + sr4l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + Label{ text: "›" draw_text.color: #4a5568 draw_text.text_style.font_size: 20 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || agent.notify("set", {key: "sel", value: "5"}) } + } + } + } + } + } + + // ===================== ORIGIN / STOP SEARCH OVERLAY ===================== + if scr == "find" { + SolidView{ width: Fill height: 812 flow: Down new_batch: true draw_bg.color: #0b1017 padding: Inset{left: 16 top: 50 right: 16 bottom: 10} spacing: 12 + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 10 + View{ width: 85 height: 34 flow: Overlay + RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 10 padding: Inset{left: 12 top: 8 right: 12 bottom: 8} Label{ text: "‹ Back" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 13 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "find", value: "0"}) } + } + View{ width: Fill height: Fit flow: Right align: Align{x: 1.0 y: 0.5} ftot := Label{ text: "" draw_text.color: #9fb0c4 draw_text.text_style.font_size: 14 } } + } + // FROM/TO stay visible while searching (Google-style): the field being + // edited is the active input; the other shows its current value and is + // tappable to switch to editing it. + View{ width: Fill height: 46 flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #22c55e } + if find == "orig" { + RoundedView{ width: Fill height: 44 flow: Right align: Align{y: 0.5} draw_bg.color: #172231 draw_bg.border_radius: 12 draw_bg.border_size: 1.2 draw_bg.border_color: #3f7bc4 padding: Inset{left: 16 right: 14} + TextInput{ width: Fill height: Fill empty_text: oname return_key_type: Send draw_bg.color: #00000000 draw_bg.color_hover: #00000000 draw_bg.color_focus: #00000000 draw_bg.color_down: #00000000 draw_bg.color_empty: #00000000 draw_bg.border_size: 0.0 draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 on_return: |text| agent.notify("set", {key: "q", value: text}) } + } + } + if find != "orig" { + View{ width: Fill height: 44 flow: Overlay + RoundedView{ width: Fill height: Fill flow: Right align: Align{y: 0.5} draw_bg.color: #141c28 draw_bg.border_radius: 12 padding: Inset{left: 16 right: 14} ffromn := Label{ width: Fill text: oname draw_text.color: #cdd8e6 draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff10 draw_bg.border_size: 0.0 draw_bg.border_radius: 12 text: "" on_click: || { agent.notify("set", {key: "find", value: "orig"}); agent.notify("set", {key: "q", value: "0"}) } } + } + } + } + View{ width: Fill height: 46 flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #ff6b6b } + if find == "dest" { + RoundedView{ width: Fill height: 44 flow: Right align: Align{y: 0.5} draw_bg.color: #172231 draw_bg.border_radius: 12 draw_bg.border_size: 1.2 draw_bg.border_color: #3f7bc4 padding: Inset{left: 16 right: 14} + TextInput{ width: Fill height: Fill empty_text: sys.coord(dest, "name") return_key_type: Send draw_bg.color: #00000000 draw_bg.color_hover: #00000000 draw_bg.color_focus: #00000000 draw_bg.color_down: #00000000 draw_bg.color_empty: #00000000 draw_bg.border_size: 0.0 draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 on_return: |text| agent.notify("set", {key: "q", value: text}) } + } + } + if find != "dest" { + View{ width: Fill height: 44 flow: Overlay + RoundedView{ width: Fill height: Fill flow: Right align: Align{y: 0.5} draw_bg.color: #141c28 draw_bg.border_radius: 12 padding: Inset{left: 16 right: 14} Label{ width: Fill text: sys.coord(dest, "name") draw_text.color: #ffffff draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff10 draw_bg.border_size: 0.0 draw_bg.border_radius: 12 text: "" on_click: || { agent.notify("set", {key: "find", value: "dest"}); agent.notify("set", {key: "q", value: "0"}) } } + } + } + } + if find == "stop" { + View{ width: Fill height: 46 flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #2c7be5 } + RoundedView{ width: Fill height: 44 flow: Right align: Align{y: 0.5} draw_bg.color: #172231 draw_bg.border_radius: 12 draw_bg.border_size: 1.2 draw_bg.border_color: #3f7bc4 padding: Inset{left: 16 right: 14} + TextInput{ width: Fill height: Fill empty_text: "Add a stop" return_key_type: Send draw_bg.color: #00000000 draw_bg.color_hover: #00000000 draw_bg.color_focus: #00000000 draw_bg.color_down: #00000000 draw_bg.color_empty: #00000000 draw_bg.border_size: 0.0 draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 on_return: |text| agent.notify("set", {key: "q", value: text}) } + } + } + } + fcnt := Label{ text: "Your trip" draw_text.color: #7c8aa6 draw_text.text_style.font_size: 12 } + // Default items (before a search): the current start + destination, so you + // can pick either without typing (tap the destination while editing the + // start to reverse the trip, etc.). Replaced by live results once you type. + if q == "0" { + RoundedView{ width: Fill height: Fit flow: Down draw_bg.color: #141b26 draw_bg.border_radius: 16 padding: Inset{left: 6 top: 4 right: 6 bottom: 4} + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 12 top: 12 right: 14 bottom: 12} + CircleView{ width: 9 height: 9 draw_bg.color: #22c55e } + View{ width: Fill height: Fit flow: Down spacing: 2 + ytstart := Label{ width: Fill text: oname draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + Label{ width: Fill text: "Start point" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: "" + olat + "," + olon + "|" + oname}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: "" + olat + "," + olon + "|" + oname}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: "" + olat + "," + olon + "|" + oname}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: "" + olat + "," + olon + "|" + oname}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 padding: Inset{left: 12 top: 12 right: 14 bottom: 12} + CircleView{ width: 9 height: 9 draw_bg.color: #ff6b6b } + View{ width: Fill height: Fit flow: Down spacing: 2 + Label{ width: Fill text: sys.coord(dest, "name") draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + Label{ width: Fill text: "Destination" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: dest}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: dest}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: dest}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: dest}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + } + } + if q != "0" { + RoundedView{ width: Fill height: Fit flow: Down draw_bg.color: #141b26 draw_bg.border_radius: 16 padding: Inset{left: 6 top: 4 right: 6 bottom: 4} + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 2 padding: Inset{left: 14 top: 12 right: 14 bottom: 12} + fr0n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + fr0l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: sys.search(q, 0, "lat") + "," + sys.search(q, 0, "lon") + "|" + sys.search(q, 0, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: sys.search(q, 0, "lat") + "," + sys.search(q, 0, "lon") + "|" + sys.search(q, 0, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: sys.search(q, 0, "lat") + "," + sys.search(q, 0, "lon") + "|" + sys.search(q, 0, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: sys.search(q, 0, "lat") + "," + sys.search(q, 0, "lon") + "|" + sys.search(q, 0, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 2 padding: Inset{left: 14 top: 12 right: 14 bottom: 12} + fr1n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + fr1l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: sys.search(q, 1, "lat") + "," + sys.search(q, 1, "lon") + "|" + sys.search(q, 1, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: sys.search(q, 1, "lat") + "," + sys.search(q, 1, "lon") + "|" + sys.search(q, 1, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: sys.search(q, 1, "lat") + "," + sys.search(q, 1, "lon") + "|" + sys.search(q, 1, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: sys.search(q, 1, "lat") + "," + sys.search(q, 1, "lon") + "|" + sys.search(q, 1, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 2 padding: Inset{left: 14 top: 12 right: 14 bottom: 12} + fr2n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + fr2l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: sys.search(q, 2, "lat") + "," + sys.search(q, 2, "lon") + "|" + sys.search(q, 2, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: sys.search(q, 2, "lat") + "," + sys.search(q, 2, "lon") + "|" + sys.search(q, 2, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: sys.search(q, 2, "lat") + "," + sys.search(q, 2, "lon") + "|" + sys.search(q, 2, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: sys.search(q, 2, "lat") + "," + sys.search(q, 2, "lon") + "|" + sys.search(q, 2, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff0d } + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 2 padding: Inset{left: 14 top: 12 right: 14 bottom: 12} + fr3n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } + fr3l := Label{ width: Fill text: "" draw_text.color: #63708a draw_text.text_style.font_size: 11 } + } + if find == "orig" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "orig", value: sys.search(q, 3, "lat") + "," + sys.search(q, 3, "lon") + "|" + sys.search(q, 3, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "dest" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "dest", value: sys.search(q, 3, "lat") + "," + sys.search(q, 3, "lon") + "|" + sys.search(q, 3, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } + if find == "stop" { if wp1 == "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp1", value: sys.search(q, 3, "lat") + "," + sys.search(q, 3, "lon") + "|" + sys.search(q, 3, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + if find == "stop" { if wp1 != "0" { Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_hover: #ffffff08 draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "wp2", value: sys.search(q, 3, "lat") + "," + sys.search(q, 3, "lon") + "|" + sys.search(q, 3, "name")}); agent.notify("set", {key: "find", value: "0"}) } } } } + } + } + } + } + } + + // ===================== DESTINATION PREVIEW ===================== + if scr == "preview" { + View{ width: Fill height: Fill flow: Overlay + pmap := MapView{ width: Fill height: 812 nav_mode: "plan" nav_route_width: 40.0 zoom: 14.0 min_zoom: 3.0 max_zoom: 16.0 use_network: true use_local_mbtiles: false } + View{ width: Fill height: Fill flow: Down align: Align{x: 0.5 y: 1.0} + RoundedView{ width: Fill height: Fit flow: Overlay draw_bg.color: #0f1620 draw_bg.border_radius: 22 margin: Inset{left: 8 right: 8 bottom: 30} + // transparent full-bleed catch so touches on the sheet's empty areas don't + // fall through to the map behind and pan it. Real buttons on top still win. + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || {} } + View{ width: Fill height: Fit flow: Down padding: Inset{left: 14 top: 13 right: 14 bottom: 13} spacing: 7 + View{ width: 110 height: 24 flow: Overlay + RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 10 padding: Inset{left: 12 top: 8 right: 12 bottom: 8} Label{ text: "‹ Results" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 9 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "sel", value: "0"}) } + } + pvname := Label{ width: Fill text: "…" draw_text.color: #ffffff draw_text.text_style.font_size: 15 } + View{ width: Fill height: Fit flow: Right align: Align{x: 0.5 y: 0.5} spacing: 12 + pvcat := Label{ text: "" draw_text.color: #f5a623 draw_text.text_style.font_size: 9 } + pveta := Label{ text: "" draw_text.color: #4ade80 draw_text.text_style.font_size: 9 } + } + pvaddr := Label{ width: Fill text: "" draw_text.color: #9fb0c4 draw_text.text_style.font_size: 9 } + View{ width: Fill height: 42 flow: Overlay margin: Inset{top: 6} + RoundedView{ width: Fill height: 42 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 14 Label{ text: "Directions ›" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "dest", value: sys.searchnum(q, ss - 1, "lat") + "," + sys.searchnum(q, ss - 1, "lon") + "|" + sys.search(q, ss - 1, "name") }) } + } + } + } + } + } + } + + // ===================== PLAN ===================== + if scr == "plan" { + View{ width: Fill height: Fill flow: Overlay + themap := MapView{ width: Fill height: 812 nav_mode: "plan" nav_route_width: 40.0 zoom: 15.0 min_zoom: 3.0 max_zoom: 16.0 use_network: true use_local_mbtiles: false } + View{ width: Fill height: Fill flow: Down align: Align{x: 0.5 y: 1.0} + RoundedView{ width: Fill height: Fit flow: Overlay draw_bg.color: #0f1620 draw_bg.border_radius: 22 margin: Inset{left: 8 right: 8 bottom: 30} + // transparent full-bleed catch: touches on the sheet's empty (non-button) + // areas are captured HERE instead of falling through to the map behind and + // panning it (the "sheet not responding" feel). Real buttons sit on top and win. + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || {} } + View{ width: Fill height: Fit flow: Down padding: Inset{left: 14 top: 11 right: 14 bottom: 11} spacing: 7 + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 8 + View{ width: 84 height: 24 flow: Overlay + RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 9 padding: Inset{left: 10 top: 6 right: 10 bottom: 6} Label{ text: "‹ Search" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 8 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "dest", value: "0"}) } + } + View{ width: 70 height: 24 flow: Overlay + if mdn == "drive" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 9 Label{ text: "Drive" draw_text.color: #ffffff draw_text.text_style.font_size: 8 } } } + if mdn != "drive" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 9 Label{ text: "Drive" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 8 } } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "mode", value: "drive"}) } + } + View{ width: 64 height: 24 flow: Overlay + if mdn == "walk" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 9 Label{ text: "Walk" draw_text.color: #ffffff draw_text.text_style.font_size: 8 } } } + if mdn != "walk" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 9 Label{ text: "Walk" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 8 } } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "mode", value: "walk"}) } + } + View{ width: 64 height: 24 flow: Overlay + if mdn == "bike" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 9 Label{ text: "Bike" draw_text.color: #ffffff draw_text.text_style.font_size: 8 } } } + if mdn != "bike" { RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #222c3c draw_bg.border_radius: 9 Label{ text: "Bike" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 8 } } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "mode", value: "bike"}) } + } + } + View{ width: Fill height: Fit flow: Right align: Align{x: 0.5 y: 0.5} spacing: 8 + eta := Label{ text: "…" draw_text.color: #4ade80 draw_text.text_style.font_size: 14 } + etad := Label{ text: "" draw_text.color: #9fb0c4 draw_text.text_style.font_size: 9 } + } + SolidView{ width: Fill height: 1 draw_bg.color: #ffffff12 } + // origin (editable) — the WHOLE row is tappable (Fill-width button, + // reliable hit area) so tapping the start field opens the origin search. + View{ width: Fill height: 30 flow: Overlay + View{ width: Fill height: Fill flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #22c55e } + oname := Label{ width: Fill text: "…" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff10 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "find", value: "orig"}); agent.notify("set", {key: "q", value: "0"}) } } + } + // per-leg time · distance (shown only when the trip has stops) + if wp1 != "0" { View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 margin: Inset{top: 1 bottom: 1} View{ width: 11 height: 15 flow: Overlay align: Align{x: 0.5 y: 0.5} SolidView{ width: 2 height: 15 draw_bg.color: #2c7be5 } } leg0 := Label{ text: "" draw_text.color: #8b99b0 draw_text.text_style.font_size: 8 } } } + // stop 1 + if wp1 != "0" { + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #2c7be5 } + wp1n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } + View{ width: 30 height: 30 flow: Overlay + RoundedView{ width: 30 height: 30 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #38445a draw_bg.border_radius: 15 Label{ text: "×" draw_text.color: #c6d0de draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "wp1", value: "0"}) } + } + } + } + if wp1 != "0" { View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 margin: Inset{top: 1 bottom: 1} View{ width: 11 height: 15 flow: Overlay align: Align{x: 0.5 y: 0.5} SolidView{ width: 2 height: 15 draw_bg.color: #2c7be5 } } leg1 := Label{ text: "" draw_text.color: #8b99b0 draw_text.text_style.font_size: 8 } } } + // stop 2 + if wp2 != "0" { + View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #2c7be5 } + wp2n := Label{ width: Fill text: "" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } + View{ width: 30 height: 30 flow: Overlay + RoundedView{ width: 30 height: 30 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #38445a draw_bg.border_radius: 15 Label{ text: "×" draw_text.color: #c6d0de draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "wp2", value: "0"}) } + } + } + } + if wp2 != "0" { View{ width: Fill height: Fit flow: Right align: Align{y: 0.5} spacing: 12 margin: Inset{top: 1 bottom: 1} View{ width: 11 height: 15 flow: Overlay align: Align{x: 0.5 y: 0.5} SolidView{ width: 2 height: 15 draw_bg.color: #2c7be5 } } leg2 := Label{ text: "" draw_text.color: #8b99b0 draw_text.text_style.font_size: 8 } } } + // destination (editable) — the WHOLE row is tappable, like the origin, + // so tapping the end field opens the destination search. + View{ width: Fill height: 30 flow: Overlay + View{ width: Fill height: Fill flow: Right align: Align{y: 0.5} spacing: 12 + CircleView{ width: 11 height: 11 draw_bg.color: #ff6b6b } + dname := Label{ width: Fill text: "…" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff10 draw_bg.border_size: 0.0 draw_bg.border_radius: 10 text: "" on_click: || { agent.notify("set", {key: "find", value: "dest"}); agent.notify("set", {key: "q", value: "0"}) } } + } + // add stop (searchable) — only if a slot is free + if wp2 == "0" { + View{ width: 140 height: 34 flow: Overlay margin: Inset{top: 2} + RoundedView{ width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #1b2534 draw_bg.border_radius: 18 spacing: 6 + Label{ text: "+" draw_text.color: #72c0ff draw_text.text_style.font_size: 10 } + Label{ text: "Add stop" draw_text.color: #cdd8e6 draw_text.text_style.font_size: 9 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || { agent.notify("set", {key: "find", value: "stop"}); agent.notify("set", {key: "q", value: "0"}) } } + } + } + View{ width: Fill height: 42 flow: Overlay margin: Inset{top: 4} + RoundedView{ width: Fill height: 42 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 draw_bg.border_radius: 14 Label{ text: "▶ Start" draw_text.color: #ffffff draw_text.text_style.font_size: 10 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "go", value: "1"}) } + } + } + } + } + // map controls (top-right): +/- zoom pill, then a "my location" button that + // recenters on the trip origin. Pinch-to-zoom and one-finger drag also work. + View{ width: Fill height: Fill flow: Down align: Align{x: 1.0 y: 0.0} padding: Inset{top: 74 right: 12} spacing: 12 + RoundedView{ width: 50 height: 104 flow: Down align: Align{x: 0.5 y: 0.5} draw_bg.color: #172232 draw_bg.border_radius: 16 + View{ width: 50 height: 51 flow: Overlay + Label{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} text: "+" draw_text.color: #eaf0f7 draw_text.text_style.font_size: 26 } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff1a draw_bg.border_size: 0.0 draw_bg.border_radius: 14 text: "" on_click: || ui.themap.nav_zoom_by("0.7") } + } + SolidView{ width: 34 height: 1 draw_bg.color: #ffffff22 } + View{ width: 50 height: 51 flow: Overlay + Label{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} text: "−" draw_text.color: #eaf0f7 draw_text.text_style.font_size: 26 } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff1a draw_bg.border_size: 0.0 draw_bg.border_radius: 14 text: "" on_click: || ui.themap.nav_zoom_by("-0.7") } + } + } + View{ width: 50 height: 50 flow: Overlay + RoundedView{ width: Fill height: Fill align: Align{x: 0.5 y: 0.5} draw_bg.color: #172232 draw_bg.border_radius: 25 Label{ text: "◎" draw_text.color: #72c0ff draw_text.text_style.font_size: 24 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.color_down: #ffffff1a draw_bg.border_size: 0.0 draw_bg.border_radius: 25 text: "" on_click: || ui.themap.nav_center_origin("1") } + } + } + } + } + + // ===================== DRIVE (turn-by-turn) ===================== + if scr == "drive" { + View{ width: Fill height: Fill flow: Down padding: Inset{left: 10 top: 44 right: 10 bottom: 10} + roadpill := RoundedView{ width: Fill height: Fit flow: Right align: Align{y: 0.5} draw_bg.color: #0d3a2f draw_bg.border_radius: 13 padding: Inset{left: 9 top: 7 right: 12 bottom: 7} spacing: 9 + CircleView{ width: 30 height: 30 flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #1a73e8 arw := Label{ text: "↑" draw_text.color: #ffffff draw_text.text_style.font_size: 14 } } + View{ width: Fill height: Fit flow: Down spacing: 2 align: Align{x: 0.5} + instr := Label{ text: "Starting…" draw_text.color: #ffffff draw_text.text_style.font_size: 11 } + ndist := Label{ text: "" draw_text.color: #a9d6c8 draw_text.text_style.font_size: 9 } + } + } + roadlbl := Label{ text: "" draw_text.color: #00000000 draw_text.text_style.font_size: 1 } + // 2D/3D toggle floats on the LEFT side, above the bottom bar (map control, + // not stacked under End) + View{ width: Fill height: Fill flow: Down align: Align{x: 0.0 y: 1.0} + View{ width: 60 height: 48 flow: Overlay margin: Inset{left: 4 bottom: 8} + if vw == "2d" { + RoundedView{ width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #172232 draw_bg.border_radius: 14 Label{ text: "3D" draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "view", value: "3d"}) } + } + if vw != "2d" { + RoundedView{ width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #172232 draw_bg.border_radius: 14 Label{ text: "2D" draw_text.color: #eaf0f7 draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "view", value: "2d"}) } + } + } + } + // bottom sheet. The handle + time/distance form one big swipe target: a Button + // sits ON TOP of them (transparent) so it (a) catches the swipe to toggle and + // (b) occludes the map so the swipe never leaks through to pan it. End is a + // separate row BELOW the swipe target, so it stays tappable when expanded. + // Swipe toggles endrow's visibility DIRECTLY (ui.endrow.set_visible) — no + // agent.notify/eval_body, so the map is never torn down / re-rendered. + View{ width: Fill height: Fit flow: Down align: Align{x: 0.5 y: 1.0} padding: Inset{left: 4 right: 4} + RoundedView{ width: Fill height: Fit flow: Down draw_bg.color: #0c1420 draw_bg.border_radius: 16 padding: Inset{left: 18 top: 7 right: 18 bottom: 12} spacing: 4 + View{ width: Fill height: Fit flow: Overlay + View{ width: Fill height: Fit flow: Down spacing: 4 align: Align{x: 0.5} + View{ width: Fill height: 6 flow: Right align: Align{x: 0.5 y: 0.5} + RoundedView{ width: 44 height: 5 draw_bg.color: #3a4658 draw_bg.border_radius: 3 } + } + remmin := Label{ width: Fill align: Align{x: 0.5} text: "" draw_text.color: #4ade80 draw_text.text_style.font_size: 22 } + remrest := Label{ width: Fill align: Align{x: 0.5} text: "" draw_text.color: #9fb0c4 draw_text.text_style.font_size: 13 } + } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" swipe: true on_swipe_up: || ui.endrow.set_visible(true) on_swipe_down: || ui.endrow.set_visible(false) } + } + endrow := View{ width: Fill height: 44 flow: Overlay margin: Inset{top: 6} visible: false + RoundedView{ width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} draw_bg.color: #7a1f1f draw_bg.border_radius: 12 Label{ text: "× End" draw_text.color: #ffd9d9 draw_text.text_style.font_size: 15 } } + Button{ width: Fill height: Fill draw_bg.color: #00000000 draw_bg.border_size: 0.0 text: "" on_click: || agent.notify("set", {key: "go", value: "0"}) } + } + } + } + } + } + +} diff --git a/crates/splash-core/tests/fixtures/weather.card b/crates/splash-core/tests/fixtures/weather.card new file mode 100644 index 0000000..115bd91 --- /dev/null +++ b/crates/splash-core/tests/fixtures/weather.card @@ -0,0 +1,122 @@ +# ledger weather@1.0.0 +# level: L0 +# profile: ui/l0 +# model: glm-5.2 +# +# Reference card for Splash/docs/ui-profile-l0.md. Expresses everything +# app/app/src/app/plan/weather.rs lowers: CurrentConditions, Forecast, +# AirQualityField, SunMoon, Details. + +# ── sources: queries the runtime executes; the view never calls them ────────── +source place sys.geocode(name: state.city) +source now sys.weather(lat: place.lat, lon: place.lon, + fields: [temp, feels, hi, lo, cond, humidity, wind, + pressure, uv, visibility]) +source week sys.weather(lat: place.lat, lon: place.lon, days: state.days, + fields: [dayname, hi, lo, cond], + aggregate: [min_lo, max_hi]) +source sun sys.daylight(lat: place.lat, lon: place.lon) +source moon sys.moonphase() +source aqi sys.airquality(lat: place.lat, lon: place.lon) +source scene sys.photo(query: place.name) +source env.locale sys.locale() + +# ── state: shape only; the runtime holds the values ────────────────────────── +state city { shape: text, initial: "" } # empty ⇒ device location +state units { shape: enum[c, f], initial: env.locale.temp_unit } +state days { shape: number, initial: 7 } + +# ── events: total transitions, applied atomically ──────────────────────────── +event toggle_units { units: cycle(.c, .f) } +event pick_city { city: set($value) } + +# ── copy: declared literals, all host-owned vocabulary ─────────────────────── +copy feels { class: vocabulary, en: "Feels like", zh: "体感" } +copy humidity { class: vocabulary, en: "Humidity", zh: "湿度" } +copy wind { class: vocabulary, en: "Wind", zh: "风速" } +copy pressure { class: vocabulary, en: "Pressure", zh: "气压" } +copy uv { class: vocabulary, en: "UV Index", zh: "紫外线" } +copy visibility { class: vocabulary, en: "Visibility", zh: "能见度" } +copy air { class: vocabulary, en: "Air Quality", zh: "空气质量" } +copy sunrise { class: vocabulary, en: "Sunrise", zh: "日出" } +copy sunset { class: vocabulary, en: "Sunset", zh: "日落" } + +# ── view ───────────────────────────────────────────────────────────────────── +view root Photo(src: scene, pad: .page) { + current + forecast + airfield + sunmoon + details + } + +view current Col(align: .center) { + TextTitle(text: place.name) + WeatherIcon(cond: now.cond, size: .hero) + TextHero(value: now.temp, unit: units, on_tap: toggle_units) + Row(gap: 8) { + TextCaption(glyph: "↑", value: now.hi, unit: units) + TextCaption(glyph: "↓", value: now.lo, unit: units) + TextCaption(glyph: "≈", value: now.feels, unit: units) + } + } + +# Exercises the other half of the component contracts: +# - LOCAL state and a local event (§5.7) — seven rows, seven `expanded` cells, +# keyed by (declaration path, loop key), none of them in the ledger +# - reads card-scope `source` directly (week.min_lo) — permitted by §5.3 +# - takes `unit` as a PROP, because card state may not be read directly; +# that keeps the dependency visible to the tracker +# - the transition is `toggle`, a total form, not `!expanded` (§3) +# +# Prop named `item`, not `day`: `width: .day` is a layout token. See finding #9. +component ForecastRow(item: record, unit: enum[c, f]) { + state expanded { shape: bool, initial: false } + event toggle { expanded: toggle } + view Col { + Row(align: .center, on_tap: toggle) { + TextRow(text: item.dayname, width: .day) + WeatherIcon(cond: item.cond, size: .row) + TempBar(lo: item.lo, hi: item.hi, min: week.min_lo, max: week.max_hi) + TextValue(value: item.hi, unit: unit) + } + when expanded { + Row(gap: 8) { + TextCaption(glyph: "↓", value: item.lo, unit: unit) + TextCaption(text: item.cond) + } + } + } +} + +view forecast Panel { + for d in week key d.dayname { + ForecastRow(item: d, unit: units) + } + } + +view airfield Panel { + TextCaption(text: copy.air) + AqiContour(field: aqi.grid, index: aqi.index, band: aqi.band) + } + +view sunmoon Panel { + Row { + Col { TextCaption(text: copy.sunrise) TextStat(value: sun.rise, format: .time) } + SunArc(rise: sun.rise, set: sun.set, now: sun.now) + Col { TextCaption(text: copy.sunset) TextStat(value: sun.set, format: .time) } + } + MoonPhase(phase: moon.phase, illum: moon.illumination) + } + +# The plan's `tiles: Vec` becomes explicit structure. The model picks +# tiles by writing them, not by naming them in an array — more expressive, and +# it removes a loop over a model-authored list. +view details Grid(cols: 2) { + Tile(label: copy.feels, value: now.feels, unit: units) + Tile(label: copy.humidity, value: now.humidity, unit: .pct) + Tile(label: copy.wind, value: now.wind, unit: .speed) + Tile(label: copy.pressure, value: now.pressure, unit: .pressure) + Tile(label: copy.uv, value: now.uv, unit: .index) + Tile(label: copy.visibility, value: now.visibility, unit: .distance) + } diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs new file mode 100644 index 0000000..8e4ef61 --- /dev/null +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -0,0 +1,414 @@ +//! The L0 profile checked against the reference cards. +//! +//! These three cards are the evidence for the profile: any construct they need +//! that the grammar lacks is a defect in `docs/ui-profile-l0.md`, not in the +//! cards. They are the same files as `octos-one/docs/l0/*.card`. + +use splash_core::ui_l0::{catalog, check_ui_l0_named, Level}; + +const WEATHER: &str = include_str!("fixtures/weather.card"); +const NEWS: &str = include_str!("fixtures/news.card"); +const STOCK: &str = include_str!("fixtures/stock.card"); + +fn accepts(name: &str, source: &str) { + let report = check_ui_l0_named(name, source); + assert!( + report.valid, + "{name} should be L0, but: {:#?}", + report.diagnostics + ); + assert_eq!(report.level, Level::L0); +} + +fn rejects_at(name: &str, source: &str, level: Level) -> String { + let report = check_ui_l0_named(name, source); + assert!(!report.valid, "{name} should have been rejected"); + assert_eq!( + report.level, level, + "wrong level for {name}: {:#?}", + report.diagnostics + ); + report + .diagnostics + .first() + .map(|d| d.message.clone()) + .unwrap_or_default() +} + +// ─────────────────────────────────────────────────────────── the reference cards ── + +#[test] +fn the_three_reference_cards_are_l0() { + accepts("weather", WEATHER); + accepts("news", NEWS); + accepts("stock", STOCK); +} + +/// Every constructor the cards use must be catalogued, or the checker is +/// accepting names it cannot check the arguments of. +#[test] +fn every_constructor_the_cards_use_is_catalogued_or_declared() { + for (name, source) in [("weather", WEATHER), ("news", NEWS), ("stock", STOCK)] { + let declared: Vec<&str> = source + .lines() + .filter_map(|l| l.trim().strip_prefix("component ")) + .filter_map(|l| l.split(['(', ' ']).next()) + .collect(); + + for line in source.lines() { + let line = line.split('#').next().unwrap_or(""); + for word in line.split(|c: char| !(c.is_alphanumeric() || c == '_')) { + let is_ctor = word.chars().next().is_some_and(|c| c.is_uppercase()) + && line.contains(&format!("{word}(")); + if is_ctor && catalog::lookup(word).is_none() && !declared.contains(&word) { + panic!("{name}: {word:?} is neither catalogued nor declared"); + } + } + } + } +} + +// ──────────────────────────────────────────────────────────── level classification ── + +#[test] +fn a_function_is_l2() { + let msg = rejects_at("fn", "fn tick() { }\nview root Panel", Level::L2); + assert!(msg.contains("unbounded work"), "{msg}"); +} + +#[test] +fn an_imperative_widget_command_is_l2() { + let msg = rejects_at( + "ui", + "view root Panel { Row(on_tap: go) }\nevent go { ui.map.set_text(1) }", + Level::L2, + ); + assert!(msg.contains("imperative"), "{msg}"); +} + +#[test] +fn module_access_is_l2() { + let msg = rejects_at("mod", "source x mod.fs.read(\"/etc/passwd\")", Level::L2); + assert!(msg.contains("host surface is empty"), "{msg}"); +} + +#[test] +fn arithmetic_is_l1() { + let msg = rejects_at( + "arith", + "view hero TextHero(value: now.temp * 2)", + Level::L1, + ); + assert!(msg.contains("fabricated fact"), "{msg}"); +} + +/// The correction that mattered: `expanded: !expanded` is computation, however +/// small, and L0 has `toggle` precisely so it need not be written. +#[test] +fn negation_in_a_transition_is_l1() { + let msg = rejects_at( + "neg", + "state open { shape: bool, initial: false }\nevent t { open: !open }", + Level::L1, + ); + assert!(msg.contains("toggle"), "{msg}"); +} + +#[test] +fn a_ternary_is_l1() { + rejects_at( + "ternary", + "state u { shape: enum[c, f], initial: .c }\nevent t { u: u == .c ? .f : .c }", + Level::L1, + ); +} + +// ────────────────────────────────────────────────────────────────── grammar rules ── + +/// Identity may never come from position — profile §6.3. +#[test] +fn a_loop_without_a_key_is_rejected() { + let report = check_ui_l0_named( + "nokey", + "source week sys.weather()\nview f Panel { for d in week { Row() } }", + ); + assert!(!report.valid); + assert!( + report.diagnostics.iter().any(|d| d.message.contains("key")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_unknown_constructor_is_rejected() { + let report = check_ui_l0_named("unknown", "view root Blockchain(hype: 11)"); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("not an L0 constructor"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_unknown_argument_is_rejected() { + let report = check_ui_l0_named("badarg", "view root Panel { Rule(sparkle: 3) }"); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("no argument"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_illegal_token_is_rejected_with_the_legal_set() { + let report = check_ui_l0_named("badtoken", "view root Surface(pad: .enormous)"); + assert!(!report.valid); + let m = &report.diagnostics[0].message; + assert!( + m.contains(".page"), + "the diagnostic should name the legal tokens: {m}" + ); +} + +/// The distinction lexical marking exists to make: a bare name is a path, and a +/// path cannot stand in for a token. +#[test] +fn a_bare_name_where_a_token_belongs_is_rejected() { + let report = check_ui_l0_named("bare", "view root Surface(pad: page)"); + assert!(!report.valid); + let m = &report.diagnostics[0].message; + assert!(m.contains("leading dot"), "{m}"); +} + +#[test] +fn an_undeclared_name_is_rejected() { + let report = check_ui_l0_named("undeclared", "view root TextTitle(text: nowhere.name)"); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("not a declared name"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_undeclared_event_is_rejected() { + let report = check_ui_l0_named("noevent", "view root Card(on_tap: nope)"); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("not a declared event"), + "{:#?}", + report.diagnostics + ); +} + +// ──────────────────────────────────────────────────────────── component contracts ── + +/// Profile §5.3: card state must arrive as a prop, or the dependency is +/// invisible to the tracker. +#[test] +fn a_component_may_not_read_card_state() { + let report = check_ui_l0_named( + "scope", + "state units { shape: enum[c, f], initial: .c }\n\ + component Row2(item: record) { view TextValue(value: item.hi, unit: units) }\n\ + view root Panel { }", + ); + assert!(!report.valid); + let m = &report.diagnostics[0].message; + assert!(m.contains("pass it as a prop"), "{m}"); +} + +/// A component reading a card-scope source is fine — sources are immutable and +/// already declared dependencies. +#[test] +fn a_component_may_read_a_card_source() { + let report = check_ui_l0_named( + "srcread", + "source week sys.weather()\n\ + component Bar(item: record) { view TempBar(lo: item.lo, hi: item.hi, min: week.min, max: week.max) }\n\ + view root Panel { }", + ); + assert!(report.valid, "{:#?}", report.diagnostics); +} + +/// Profile §6, condition 1. Without it an L0 realization need not terminate. +#[test] +fn a_recursive_component_is_rejected() { + let report = check_ui_l0_named( + "rec", + "component Node(item: record) { view Col { Node(item: item) } }\nview root Panel { }", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("recursive"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn mutual_recursion_is_rejected_too() { + let report = check_ui_l0_named( + "mutual", + "component A(x: record) { view Col { B(x: x) } }\n\ + component B(x: record) { view Col { A(x: x) } }\n\ + view root Panel { }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("recursive")), + "{:#?}", + report.diagnostics + ); +} + +// ────────────────────────────────────────────────────────────────────── bounds ── + +#[test] +fn diagnostics_are_bounded() { + let mut source = String::from("view root Panel {\n"); + for _ in 0..500 { + source.push_str(" Blockchain()\n"); + } + source.push('}'); + let report = check_ui_l0_named("many", &source); + assert!(!report.valid); + assert!(report.diagnostics.len() <= splash_core::ui_l0::MAX_UI_L0_DIAGNOSTICS); + assert!(report.diagnostics_truncated); +} + +#[test] +fn deeply_nested_source_terminates() { + let depth = 4_000; + let mut source = String::from("view root "); + for _ in 0..depth { + source.push_str("Panel { "); + } + for _ in 0..depth { + source.push('}'); + } + // The contract is that it returns, bounded, rather than overflowing. + let report = check_ui_l0_named("deep", &source); + assert!(!report.valid); +} + +#[test] +fn a_truncated_card_terminates() { + for source in [ + "view root Panel {", + "component A(", + "state x {", + "view root Row(align:", + "for d in", + ] { + let _ = check_ui_l0_named("partial", source); + } +} + +// ─────────────────────────────────────────────────────────── the negative fixture ── + +/// The shipping nav card. It is the reason the level model exists: 127 +/// conditionals that L0 could express, and a `fn tick()` plus 65 imperative +/// widget commands that it cannot. Level is the MAXIMUM any construct requires, +/// so this must classify L2 rather than stopping at the first arithmetic it hits. +const NAV: &str = include_str!("fixtures/trip_planner.splash"); + +#[test] +fn the_nav_card_is_l2() { + let report = check_ui_l0_named("trip-planner", NAV); + assert!(!report.valid); + assert_eq!( + report.level, + Level::L2, + "expected L2, got {:?}: {:#?}", + report.level, + report.diagnostics.first() + ); +} + +// ─────────────────────────────────────────────────────────────────── transitions ── + +#[test] +fn a_transition_must_name_a_declared_state() { + let report = check_ui_l0_named("nostate", "event go { nowhere: clear }\nview root Panel"); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("not a state declared"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn toggle_requires_a_bool() { + let report = check_ui_l0_named( + "toggle", + "state u { shape: enum[c, f], initial: .c }\nevent t { u: toggle }\nview root Panel", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("needs a bool"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn cycle_members_must_be_declared_in_the_shape() { + let report = check_ui_l0_named( + "cycle", + "state u { shape: enum[c, f], initial: .c }\nevent t { u: cycle(.c, .kelvin) }\nview root Panel", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("not a member"), + "{:#?}", + report.diagnostics + ); +} + +/// Enum members are tokens. A bare name is a path and cannot stand in for one. +#[test] +fn cycle_rejects_bare_names() { + let report = check_ui_l0_named( + "cyclebare", + "state u { shape: enum[c, f], initial: .c }\nevent t { u: cycle(c, f) }\nview root Panel", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("leading dot"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_untotal_transition_form_is_rejected() { + let report = check_ui_l0_named( + "untotal", + "state n { shape: number, initial: 0 }\nevent t { n: increment }\nview root Panel", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0] + .message + .contains("total transition form"), + "{:#?}", + report.diagnostics + ); +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 44fff00..04c235c 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -59,7 +59,7 @@ arguments = argument , { "," , argument } ; argument = ident , ":" , operand ; operand = path | literal | predicate ; (* NO expressions *) -predicate = path , comparator , ( path | literal ) ; +predicate = path , [ comparator , ( path | literal | token ) ] ; comparator = "==" | "!=" | "<" | "<=" | ">" | ">=" ; path = root , { "." , ident | "." , integer | "[" , path , "]" } ; @@ -94,7 +94,8 @@ defect in this document, not in the cards.** | 6 | predicate as an argument value | stock — `active: range == d1` | `operand` admits `predicate` | | 7 | event payload at the call site | news, stock — which row was tapped | `value:` argument, §3.1 | | 8 | sub-collection of a source | news — feed skips the lead | **not granted** — see below | -| 9 | bare identifiers are ambiguous | both components — `width: rank` is a token, `text: position` is a path | resolved by argument shape, §2.2 | +| 9 | bare identifiers are ambiguous | both components — `width: rank` is a token, `text: position` is a path | tokens marked lexically, §2.2 | +| 10 | a bare boolean guard | weather — `when expanded { … }` | `predicate` admits a lone path | **None of these introduces an expression.** Indexing is a total lookup, a predicate as an operand is the form `when` already admits, and a loop index is supplied by the iterator. The @@ -435,6 +436,12 @@ being replaced. Three token tests are not sufficient and an earlier draft that used them was both incomplete and self-contradictory. Level must be an effect judgment over the closure. +**"Maximum" is a scan, not a search.** A classifier that returns at the first construct outside +L0 reports whichever appears earliest in the source, not the highest. The nav card demonstrates +it: arithmetic on line 55 precedes `fn tick()` on line 80, so a short-circuiting classifier +calls it L1 — a card with 65 imperative widget commands. The implementation must examine every +construct and keep the widest. + --- ## 8. Open questions From e2fe82c3f0be8f6187305b2a3196356fc32b7fee Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:48:29 -0700 Subject: [PATCH 06/63] =?UTF-8?q?feat(ui=5Fl0):=20realization=20=E2=80=94?= =?UTF-8?q?=20and=20L0=20turns=20out=20to=20need=20no=20evaluator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/splash-core/src/ui_l0.rs | 459 +++++++++++++++++++++- crates/splash-core/tests/ui_profile_l0.rs | 220 +++++++++++ docs/ui-profile-l0.md | 36 +- 3 files changed, 697 insertions(+), 18 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 98189e2..79b5f68 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -536,6 +536,11 @@ struct Element { line: usize, column: usize, is_reference: bool, + /// `when` guard: comparator and right-hand operand, absent for a bare bool. + cmp: Option, + rhs: Option, + /// `for` loop: the per-item key expression. Identity never comes from position. + key_path: Option, } #[derive(Debug)] @@ -1031,22 +1036,24 @@ impl<'a> Parser<'a> { self.at += 1; } let collection = self.dotted_name().unwrap_or_default(); - if !self.peek().is_some_and(|t| t.is_kw("key")) { + let key_path = if self.peek().is_some_and(|t| t.is_kw("key")) { + self.at += 1; + self.dotted_name() + } else { if let Some(t) = self.peek().cloned() { self.sink.at( &t, "a loop must declare `key`: identity may not come from position".into(), ); } - } else { - self.at += 1; - let _ = self.dotted_name(); - } + None + }; let mut element = Element { name: "for".into(), binders, line: t.line, column: t.column, + key_path, ..Default::default() }; element.args.push(Arg { @@ -1065,14 +1072,21 @@ impl<'a> Parser<'a> { let left = self.dotted_name().unwrap_or_default(); // A comparison, or a bare path when the state is bool-shaped — // `when expanded { … }`. Neither form computes. - if self.peek().is_some_and(|t| t.kind == Kind::Cmp) { - self.at += 1; - self.at += 1; // right-hand operand + let mut cmp = None; + let mut rhs = None; + if let Some(op) = self.peek().cloned() { + if op.kind == Kind::Cmp { + self.at += 1; + cmp = Some(op.text.clone()); + rhs = Some(self.parse_operand()); + } } let mut element = Element { name: "when".into(), line: t.line, column: t.column, + cmp, + rhs, ..Default::default() }; element.args.push(Arg { @@ -1767,3 +1781,432 @@ fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut } sink.push(line, column, format!("{root:?} is not a declared name")); } + +// ─────────────────────────────────────────────────────────────────── realization ── + +/// A realized node. Renderer-neutral: a backend maps `kind` to its own widget. +#[derive(Clone, Debug, PartialEq)] +pub struct UiNode { + pub kind: String, + /// Instance identity — the declaration path plus every enclosing loop key. + /// Never derived from position, so an edit that inserts a sibling above does + /// not rebind this node's state (profile §5.1). + pub key: String, + pub args: Vec<(String, NodeValue)>, + pub children: Vec, +} + +/// A resolved argument value. +#[derive(Clone, Debug, PartialEq)] +pub enum NodeValue { + Text(String), + Number(f64), + Bool(bool), + /// A `.token`, carried through with its meaning intact for the backend. + Token(String), + /// A declared event name for the runtime to bind at dispatch — never a + /// string constructed by the card (profile §3.1). + Event(String), + /// A binding that resolved to nothing. Surfaced rather than defaulted: a + /// silently empty field is how a card renders an em dash and looks fine. + Missing, +} + +/// Traversal bounds. L0 has no expressions, so there is nothing to evaluate and +/// no instruction budget to set — these bound the walk instead. +#[derive(Clone, Copy, Debug)] +pub struct RealizeLimits { + pub max_nodes: usize, + pub max_depth: usize, + pub max_collection: usize, +} + +impl Default for RealizeLimits { + fn default() -> Self { + Self { + max_nodes: 8_192, + max_depth: 64, + max_collection: 512, + } + } +} + +#[derive(Clone, Debug)] +pub struct RealizeReport { + pub root: Option, + pub diagnostics: Vec, + pub nodes: usize, + /// A bound was reached; the tree is partial. + pub truncated: bool, +} + +/// Realize a card against resolved data. +/// +/// `data` carries what the runtime fetched and holds: source results, state +/// values and copy, keyed by declaration name. **No capability is reachable from +/// here** — not because one is blocked, but because realization is a tree walk +/// and there is no evaluator to reach anything with. +pub fn realize(source: &str, data: &crate::JsonValue, limits: RealizeLimits) -> RealizeReport { + let mut sink = Diagnostics::default(); + + let check = check_ui_l0_named("card.l0", source); + if !check.valid { + return RealizeReport { + root: None, + diagnostics: check.diagnostics, + nodes: 0, + truncated: false, + }; + } + + let tokens = match lex(source, &mut sink) { + Some(t) => t, + None => { + return RealizeReport { + root: None, + diagnostics: sink.items, + nodes: 0, + truncated: false, + } + } + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + + let Some(root) = card.views.iter().find(|v| v.name == "root") else { + sink.push(1, 1, "a card needs a `view root`".into()); + return RealizeReport { + root: None, + diagnostics: sink.items, + nodes: 0, + truncated: false, + }; + }; + + let mut ctx = Realizer { + card: &card, + limits, + nodes: 0, + truncated: false, + sink: &mut sink, + }; + let mut scope = ValueScope { + frames: vec![], + data, + }; + let mut out = Vec::new(); + ctx.element(&root.body, &mut scope, "root", &mut out); + + let nodes = ctx.nodes; + let truncated = ctx.truncated; + RealizeReport { + root: out.into_iter().next(), + diagnostics: sink.items, + nodes, + truncated, + } +} + +/// Bindings introduced by loops and component props, innermost last. +struct ValueScope<'a> { + frames: Vec<(String, crate::JsonValue)>, + data: &'a crate::JsonValue, +} + +impl ValueScope<'_> { + fn lookup(&self, path: &str) -> Option { + let mut segments = path.split('.'); + let root = segments.next()?; + + let mut current = match self.frames.iter().rev().find(|(n, _)| n == root) { + Some((_, v)) => v.clone(), + None => self.data.get(root)?.clone(), + }; + for segment in segments { + current = match segment.parse::() { + Ok(index) => current.get(index)?.clone(), + Err(_) => current.get(segment)?.clone(), + }; + } + Some(current) + } +} + +struct Realizer<'a> { + card: &'a Card, + limits: RealizeLimits, + nodes: usize, + truncated: bool, + sink: &'a mut Diagnostics, +} + +impl Realizer<'_> { + fn element( + &mut self, + element: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { + if self.nodes >= self.limits.max_nodes || key.len() > self.limits.max_depth * 32 { + self.truncated = true; + return; + } + + match element.name.as_str() { + "" => {} + "slot" => {} + "for" => self.iteration(element, scope, key, out), + "when" => { + if self.guard_holds(element, scope) { + for (i, child) in element.children.iter().enumerate() { + self.element(child, scope, &format!("{key}/w{i}"), out); + } + } + } + _ if element.is_reference => { + let card = self.card; + match card.views.iter().find(|v| v.name == element.name) { + Some(view) => { + // Keyed by the referenced view's own name, so its nodes + // keep identity wherever it is spliced. + self.element(&view.body, scope, &format!("{key}/{}", element.name), out); + } + None => self.sink.push( + element.line, + element.column, + format!("{:?} is not a declared view", element.name), + ), + } + } + name => { + let card = self.card; + if let Some(component) = card.components.iter().find(|c| c.name == name) { + self.component(component, element, scope, key, out); + } else { + self.constructor(element, scope, key, out); + } + } + } + } + + fn constructor( + &mut self, + element: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { + self.nodes += 1; + let mut node = UiNode { + kind: element.name.clone(), + key: key.to_string(), + args: Vec::new(), + children: Vec::new(), + }; + for arg in &element.args { + let value = self.value(&element.name, &arg.name, &arg.value, scope); + node.args.push((arg.name.clone(), value)); + } + for (i, child) in element.children.iter().enumerate() { + self.element(child, scope, &format!("{key}/{i}"), &mut node.children); + } + out.push(node); + } + + fn component( + &mut self, + component: &Component, + call: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { + // Props bind by name; a prop the caller omits is simply absent, and any + // path inside the component resolves against this frame first. + let mut bound = 0usize; + for arg in &call.args { + if component.params.contains(&arg.name) { + let value = match &arg.value { + Operand::Path(p) => scope + .lookup(p) + // An event prop has no value in data; pass the name. + .unwrap_or_else(|| crate::JsonValue::String(p.clone())), + Operand::Token(t) => crate::JsonValue::String(t.clone()), + _ => crate::JsonValue::Null, + }; + scope.frames.push((arg.name.clone(), value)); + bound += 1; + } + } + self.element( + &component.body, + scope, + &format!("{key}/{}", component.name), + out, + ); + for _ in 0..bound { + scope.frames.pop(); + } + } + + fn iteration( + &mut self, + element: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { + let Some(Arg { + value: Operand::Path(path), + .. + }) = element.args.first() + else { + return; + }; + let Some(collection) = scope.lookup(path) else { + // A source that has not resolved yet renders nothing rather than an + // empty row; pending is the runtime's to surface, not ours to invent. + return; + }; + let Some(items) = collection.as_array() else { + self.sink.push( + element.line, + element.column, + format!("{path:?} is not a collection"), + ); + return; + }; + + for (index, item) in items.iter().enumerate() { + if index >= self.limits.max_collection { + self.truncated = true; + break; + } + // The key expression is evaluated per item, and identity is the + // enclosing key plus it — never the index. + let item_key = element + .key_path + .as_deref() + .and_then(|k| { + let mut segments = k.split('.'); + segments.next()?; + let mut current = item.clone(); + for segment in segments { + current = current.get(segment)?.clone(); + } + Some(json_to_key(¤t)) + }) + .unwrap_or_else(|| index.to_string()); + + let mut bound = 0usize; + if let Some(binder) = element.binders.first() { + scope.frames.push((binder.clone(), item.clone())); + bound += 1; + } + if let Some(index_binder) = element.binders.get(1) { + scope + .frames + .push((index_binder.clone(), crate::JsonValue::from(index + 1))); + bound += 1; + } + + for (i, child) in element.children.iter().enumerate() { + self.element(child, scope, &format!("{key}[{item_key}]/{i}"), out); + } + + for _ in 0..bound { + scope.frames.pop(); + } + } + } + + fn guard_holds(&mut self, element: &Element, scope: &mut ValueScope) -> bool { + let Some(Arg { + value: Operand::Path(path), + .. + }) = element.args.first() + else { + return false; + }; + let left = scope.lookup(path); + + let Some(cmp) = element.cmp.as_deref() else { + // Bare boolean guard — amendment #10. + return matches!(left, Some(crate::JsonValue::Bool(true))); + }; + let right = match element.rhs.as_ref() { + Some(Operand::Path(p)) => scope.lookup(p), + Some(Operand::Token(t)) => Some(crate::JsonValue::String( + t.trim_start_matches('.').to_string(), + )), + Some(Operand::Str) | Some(Operand::Num) => None, + _ => None, + }; + + // A comparison against a literal the parser did not retain cannot be + // decided here; treat it as false rather than guess. + let (Some(left), Some(right)) = (left, right) else { + return cmp == "!="; + }; + match cmp { + "==" => left == right, + "!=" => left != right, + _ => match (left.as_f64(), right.as_f64()) { + (Some(a), Some(b)) => match cmp { + "<" => a < b, + "<=" => a <= b, + ">" => a > b, + ">=" => a >= b, + _ => false, + }, + _ => false, + }, + } + } + + fn value( + &mut self, + ctor: &str, + arg: &str, + operand: &Operand, + scope: &mut ValueScope, + ) -> NodeValue { + // An event argument carries a declared NAME through to the runtime, + // which binds it at dispatch. Nothing about it is looked up in data, and + // nothing about it is constructed by the card. + let is_event = catalog::lookup(ctor) + .and_then(|args| args.iter().find(|(n, _)| *n == arg)) + .is_some_and(|(_, k)| matches!(k, catalog::ArgKind::Event)); + if is_event { + if let Operand::Path(p) = operand { + // Through a component prop the name arrives bound; directly it + // is the path itself. + return match scope.lookup(p) { + Some(crate::JsonValue::String(name)) => NodeValue::Event(name), + _ => NodeValue::Event(p.clone()), + }; + } + } + match operand { + Operand::Token(t) => NodeValue::Token(t.trim_start_matches('.').to_string()), + Operand::Str => NodeValue::Text(String::new()), + Operand::Num => NodeValue::Number(0.0), + Operand::Predicate(p) | Operand::Path(p) => match scope.lookup(p) { + Some(crate::JsonValue::String(s)) => NodeValue::Text(s), + Some(crate::JsonValue::Bool(b)) => NodeValue::Bool(b), + Some(v) => match v.as_f64() { + Some(n) => NodeValue::Number(n), + None => NodeValue::Missing, + }, + None => NodeValue::Missing, + }, + } + } +} + +fn json_to_key(value: &crate::JsonValue) -> String { + match value { + crate::JsonValue::String(s) => s.clone(), + other => other.to_string(), + } +} diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 8e4ef61..da25c50 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -412,3 +412,223 @@ fn an_untotal_transition_form_is_rejected() { report.diagnostics ); } + +// ────────────────────────────────────────────────────────────────── realization ── + +use splash_core::ui_l0::{realize, NodeValue, RealizeLimits}; + +fn weather_data() -> serde_json::Value { + serde_json::json!({ + "place": { "name": "Kyoto" }, + "now": { "temp": 18.0, "feels": 16.0, "hi": 21.0, "lo": 12.0, "cond": "cloudy", + "humidity": 61.0, "wind": 9.0, "pressure": 1013.0, "uv": 3.0, + "visibility": 10.0 }, + "week": [ { "dayname": "Mon", "hi": 21.0, "lo": 12.0, "cond": "cloudy" }, + { "dayname": "Tue", "hi": 23.0, "lo": 13.0, "cond": "sun" }, + { "dayname": "Wed", "hi": 19.0, "lo": 11.0, "cond": "rain" } ], + "sun": { "rise": 5.1, "set": 18.9, "now": 12.0 }, + "moon": { "phase": 0.5, "illumination": 0.5 }, + "aqi": { "grid": [1, 2], "index": 42.0, "band": "good" }, + "scene": "https://example.invalid/kyoto.jpg", + "units": "c", + "days": 7.0, + "city": "Kyoto", + "env": { "locale": { "temp_unit": "c" } }, + "copy": { "feels": "Feels like", "humidity": "Humidity", "wind": "Wind", + "pressure": "Pressure", "uv": "UV Index", "visibility": "Visibility", + "air": "Air Quality", "sunrise": "Sunrise", "sunset": "Sunset" } + }) +} + +fn find<'a>( + node: &'a splash_core::ui_l0::UiNode, + kind: &str, + out: &mut Vec<&'a splash_core::ui_l0::UiNode>, +) { + if node.kind == kind { + out.push(node); + } + for c in &node.children { + find(c, kind, out); + } +} + +#[test] +fn a_card_realizes_to_a_node_tree() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + let root = report.root.expect("a root node"); + assert_eq!(root.kind, "Photo"); + assert!( + report.nodes > 20, + "expected a populated tree, got {}", + report.nodes + ); +} + +/// Bindings resolve against injected data — the card names a question, the +/// runtime supplied the answer. +#[test] +fn bindings_resolve_from_injected_data() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut titles = Vec::new(); + find(&root, "TextTitle", &mut titles); + let (_, place) = titles[0].args.iter().find(|(n, _)| n == "text").unwrap(); + assert_eq!(*place, NodeValue::Text("Kyoto".into())); +} + +/// One row per item, keyed by the declared key expression rather than position. +#[test] +fn a_loop_produces_one_instance_per_item_keyed_by_its_key() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut bars = Vec::new(); + find(&root, "TempBar", &mut bars); + assert_eq!(bars.len(), 3, "three days of data, three rows"); + for (bar, day) in bars.iter().zip(["Mon", "Tue", "Wed"]) { + assert!( + bar.key.contains(day), + "key {:?} should carry {day}", + bar.key + ); + } +} + +/// Identity must survive an edit that inserts a sibling above — that is why keys +/// come from the declaration path and loop key, not position. +#[test] +fn keys_are_stable_when_a_sibling_is_inserted_above() { + let before = realize(WEATHER, &weather_data(), RealizeLimits::default()) + .root + .unwrap(); + let edited = WEATHER.replace( + "view current Col(align: .center) {", + "view current Col(align: .center) {\n Rule()", + ); + let after = realize(&edited, &weather_data(), RealizeLimits::default()) + .root + .unwrap(); + + let (mut a, mut b) = (Vec::new(), Vec::new()); + find(&before, "TempBar", &mut a); + find(&after, "TempBar", &mut b); + let keys_a: Vec<&str> = a.iter().map(|n| n.key.as_str()).collect(); + let keys_b: Vec<&str> = b.iter().map(|n| n.key.as_str()).collect(); + assert_eq!( + keys_a, keys_b, + "inserting a node above must not rebind the forecast rows" + ); +} + +/// A bare boolean guard — amendment #10 — and it must actually gate. +#[test] +fn a_guard_includes_children_only_when_it_holds() { + let mut data = weather_data(); + let report = realize(WEATHER, &data, RealizeLimits::default()); + let root = report.root.unwrap(); + let mut captions = Vec::new(); + find(&root, "TextCaption", &mut captions); + let collapsed = captions.len(); + + // `expanded` is component-local state the runtime holds; with none supplied + // the guard is false and the detail row is absent. + data["expanded"] = serde_json::Value::Bool(true); + let expanded = realize(WEATHER, &data, RealizeLimits::default()) + .root + .unwrap(); + let mut captions2 = Vec::new(); + find(&expanded, "TextCaption", &mut captions2); + assert!( + captions2.len() >= collapsed, + "an open guard must not remove nodes" + ); +} + +/// A missing binding is surfaced, not defaulted. A silently empty field is how a +/// card renders an em dash and still looks correct. +#[test] +fn an_unresolved_binding_is_reported_as_missing() { + let mut data = weather_data(); + data["now"]["humidity"] = serde_json::Value::Null; + let report = realize(WEATHER, &data, RealizeLimits::default()); + let root = report.root.unwrap(); + let mut tiles = Vec::new(); + find(&root, "Tile", &mut tiles); + assert!( + tiles.iter().any(|t| t + .args + .iter() + .any(|(n, v)| n == "value" && *v == NodeValue::Missing)), + "a null source field should realize as Missing" + ); +} + +#[test] +fn events_carry_their_declared_name_rather_than_a_constructed_string() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut heroes = Vec::new(); + find(&root, "TextHero", &mut heroes); + let (_, ev) = heroes[0].args.iter().find(|(n, _)| n == "on_tap").unwrap(); + assert_eq!(*ev, NodeValue::Event("toggle_units".into())); +} + +#[test] +fn realization_is_bounded() { + let limits = RealizeLimits { + max_nodes: 10, + ..RealizeLimits::default() + }; + let report = realize(WEATHER, &weather_data(), limits); + assert!(report.truncated); + assert!(report.nodes <= 10); +} + +#[test] +fn a_long_collection_is_truncated_rather_than_unbounded() { + let mut data = weather_data(); + let week: Vec = (0..5_000) + .map(|i| serde_json::json!({ "dayname": format!("d{i}"), "hi": 1.0, "lo": 0.0, "cond": "x" })) + .collect(); + data["week"] = serde_json::Value::Array(week); + let limits = RealizeLimits { + max_collection: 8, + ..RealizeLimits::default() + }; + let report = realize(WEATHER, &data, limits); + let root = report.root.unwrap(); + let mut bars = Vec::new(); + find(&root, "TempBar", &mut bars); + assert_eq!(bars.len(), 8); + assert!(report.truncated); +} + +#[test] +fn the_other_two_cards_realize_too() { + let news = serde_json::json!({ + "lead": [{ "id": "1", "title": "T", "author": "A", "points": 9.0, "comments": 3.0, "url": "u" }], + "feed": [{ "id": "2", "title": "U", "author": "B", "points": 4.0 }], + "article": { "title": "T", "author": "A", "points": 9.0, "comments": 3.0, "url": "u" }, + "selected": "", + "env": { "locale": {} }, + "copy": { "masthead": "Top Stories", "latest": "LATEST", "lead": "LEAD", + "pts": "pts", "comments": "comments", "by": "by" } + }); + let report = realize(NEWS, &news, RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + assert!(report.root.is_some()); + + let stock = serde_json::json!({ + "movers": [{ "ticker": "AAA", "name": "A", "last": 1.0, "change": 0.5, "pct": 2.0 }], + "quote": { "name": "A", "last": 1.0, "change": 0.5, "pct": 2.0, "open": 1.0, + "high": 2.0, "low": 0.5, "volume": 10.0, "mktcap": 99.0, "pe": 12.0 }, + "series": { "points": [1, 2], "min": 0.5, "max": 2.0 }, + "selected": "", "range": "m1", "env": { "locale": {} }, + "copy": { "movers": "Top Movers", "open": "Open", "high": "High", "low": "Low", + "volume": "Volume", "mktcap": "Mkt Cap", "pe": "P/E" } + }); + let report = realize(STOCK, &stock, RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + assert!(report.root.is_some()); +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 04c235c..6ca3869 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -14,16 +14,32 @@ A generated card is untrusted source. The workflow profile answers this by refus constructs; the compatibility parser answers it by refusing untrusted input. L0 answers it a third way: **admit UI constructs, but admit nothing that could reach a capability.** -The consequence is that an L0 evaluator is handed a host surface with **no** modules — no -`sys.*`, no `fs`, `run` or `net`. Sources are resolved by the runtime *before* evaluation and -injected as data (`set_json_global`). There is no dangerous call to block, because no call -can be written. - -**What this does and does not buy.** Two properties are decidable at L0: membership in the -grammar, and absence of authority-bearing operations. **Termination and bounded state are -*not* consequences of the grammar alone** — §6 states what makes them hold. A bounded -evaluator (heap, stack, call-depth, instruction and time limits) remains mandatory at every -level; L0 removes the need for an *OS authority* boundary, not for resource containment. +Sources are resolved by the runtime *before* realization and injected as data. There is no +dangerous call to block, because no call can be written. + +**L0 needs no evaluator at all.** Earlier drafts of this section 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. Implementing the profile showed that to be +wrong in an interesting direction: **L0 has no expression form, so there is nothing to +evaluate.** Realization is a pure walk over the parsed tree with data substituted. The +reference implementation contains no reference to the VM whatsoever. + +What that changes: + +| | Earlier claim | Actual | +|---|---|---| +| Host surface | empty | **absent** — no evaluator to hand one to | +| Instruction limit | mandatory | not applicable | +| Heap / stack / call-frame caps | mandatory | not applicable | +| Bounds still required | — | node count, nesting depth, collection length — all traversal bounds | + +So authority confinement is structural in the strongest available sense: not a sandbox that +blocks calls, and not an evaluator with an empty module table, but **no execution machinery in +the path at all**. + +**What is still not bought.** Two properties are decidable at L0 — membership in the grammar, +and absence of authority-bearing operations. **Termination is not a consequence of the grammar +alone**; §6 states the five conditions that make it hold, and traversal bounds enforce the rest. --- From 6a2f7e664e6a288f1ce603d85d50c0492d8ad4bf Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:01:48 -0700 Subject: [PATCH 07/63] =?UTF-8?q?feat(ui=5Fl0):=20Makepad=20lowering=20?= =?UTF-8?q?=E2=80=94=20an=20L0=20card=20renders=20on=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/splash-core/examples/lower_l0.rs | 34 ++ crates/splash-core/src/ui_l0.rs | 329 +++++++++++++++++- .../splash-core/tests/fixtures/weather.card | 6 +- crates/splash-core/tests/ui_profile_l0.rs | 76 +++- 4 files changed, 431 insertions(+), 14 deletions(-) create mode 100644 crates/splash-core/examples/lower_l0.rs diff --git a/crates/splash-core/examples/lower_l0.rs b/crates/splash-core/examples/lower_l0.rs new file mode 100644 index 0000000..5fcf55e --- /dev/null +++ b/crates/splash-core/examples/lower_l0.rs @@ -0,0 +1,34 @@ +//! Realize an L0 card against a data snapshot and print the Makepad DSL. +//! +//! Usage: cargo run -p splash-core --example lower_l0 -- +//! +//! The data stands in for what the runtime would have fetched. Realization is a +//! tree walk, so this never evaluates anything and reaches nothing. + +use splash_core::ui_l0::{makepad, realize, RealizeLimits}; + +fn main() { + let mut args = std::env::args().skip(1); + let card_path = args.next().expect("usage: lower_l0 "); + let data_path = args.next().expect("usage: lower_l0 "); + + let card = std::fs::read_to_string(&card_path).expect("card"); + let data: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&data_path).expect("data")).expect("json"); + + let report = realize(&card, &data, RealizeLimits::default()); + for d in &report.diagnostics { + eprintln!("{}:{}:{}: {}", card_path, d.line, d.column, d.message); + } + match report.root { + Some(root) => { + eprintln!( + "realized {} nodes{}", + report.nodes, + if report.truncated { " (truncated)" } else { "" } + ); + print!("{}", makepad::lower(&root)); + } + None => std::process::exit(1), + } +} diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 79b5f68..a428e7e 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -555,8 +555,8 @@ struct Arg { enum Operand { Path(String), Token(String), - Str, - Num, + Str(String), + Num(f64), Predicate(String), } @@ -1209,7 +1209,7 @@ impl<'a> Parser<'a> { fn parse_operand(&mut self) -> Operand { let Some(t) = self.peek().cloned() else { - return Operand::Str; + return Operand::Str(String::new()); }; match t.kind { Kind::Token => { @@ -1218,11 +1218,11 @@ impl<'a> Parser<'a> { } Kind::Str => { self.at += 1; - Operand::Str + Operand::Str(t.text.clone()) } Kind::Num => { self.at += 1; - Operand::Num + Operand::Num(t.text.parse().unwrap_or(0.0)) } Kind::Ident => { let path = self.dotted_name().unwrap_or_default(); @@ -1236,7 +1236,7 @@ impl<'a> Parser<'a> { } _ => { self.at += 1; - Operand::Str + Operand::Str(String::new()) } } } @@ -2139,7 +2139,8 @@ impl Realizer<'_> { Some(Operand::Token(t)) => Some(crate::JsonValue::String( t.trim_start_matches('.').to_string(), )), - Some(Operand::Str) | Some(Operand::Num) => None, + Some(Operand::Str(s)) => Some(crate::JsonValue::String(s.clone())), + Some(Operand::Num(n)) => crate::JsonValue::from(*n).into(), _ => None, }; @@ -2189,8 +2190,8 @@ impl Realizer<'_> { } match operand { Operand::Token(t) => NodeValue::Token(t.trim_start_matches('.').to_string()), - Operand::Str => NodeValue::Text(String::new()), - Operand::Num => NodeValue::Number(0.0), + Operand::Str(s) => NodeValue::Text(s.clone()), + Operand::Num(n) => NodeValue::Number(*n), Operand::Predicate(p) | Operand::Path(p) => match scope.lookup(p) { Some(crate::JsonValue::String(s)) => NodeValue::Text(s), Some(crate::JsonValue::Bool(b)) => NodeValue::Bool(b), @@ -2210,3 +2211,313 @@ fn json_to_key(value: &crate::JsonValue) -> String { other => other.to_string(), } } + +// ──────────────────────────────────────────────────────── makepad text lowering ── + +/// Lower a realized tree to the Splash DSL a Makepad host renders. +/// +/// **This is one backend's lowering and belongs in Splash-Makepad**, not here. It +/// lives beside the node model only until that crate is wired up; nothing in +/// [`realize`] depends on it, and a different backend consumes [`UiNode`] +/// directly rather than going through text. +/// +/// The output is fully concrete: realization already substituted the runtime's +/// data, so the DSL carries values rather than `sys.*` calls. That is the ledger +/// discipline holding — the card declares questions, and only the *realized +/// output* contains answers. +pub mod makepad { + use super::{NodeValue, UiNode}; + use std::fmt::Write as _; + + // Matching octos-one's `plan/common.rs`, so a lowered card sits beside the + // existing ones rather than looking like a different app. + const BASE: &str = "#0a0e14"; + const SCRIM: &str = "#00000066"; + const PANEL: &str = "#ffffff12"; + const HAIRLINE: &str = "#ffffff1a"; + const TEXT: &str = "#ffffff"; + const SOFT: &str = "#ffffffe6"; + const DIM: &str = "#ffffff99"; + const PAGE_PAD: &str = "Inset{left: 20 top: 54 right: 20 bottom: 24}"; + + pub fn lower(root: &UiNode) -> String { + let mut out = String::new(); + let _ = writeln!(out, "// name: l0-card"); + let _ = writeln!(out, "// REALIZED from an L0 ledger — do not edit."); + element(root, 0, &mut out); + out + } + + fn arg<'a>(node: &'a UiNode, name: &str) -> Option<&'a NodeValue> { + node.args.iter().find(|(n, _)| n == name).map(|(_, v)| v) + } + + /// A value in text position. `Missing` becomes an em dash rather than an + /// empty string, so an unresolved binding is visible on screen instead of + /// silently rendering a blank field. + fn text_of(value: Option<&NodeValue>) -> String { + match value { + Some(NodeValue::Text(s)) => format!("{s:?}"), + Some(NodeValue::Number(n)) => format!("{:?}", trim_num(*n)), + Some(NodeValue::Bool(b)) => format!("{b:?}"), + Some(NodeValue::Token(t)) => format!("{t:?}"), + Some(NodeValue::Event(_)) | None => "\"\"".into(), + Some(NodeValue::Missing) => "\"—\"".into(), + } + } + + fn num_of(value: Option<&NodeValue>) -> f64 { + match value { + Some(NodeValue::Number(n)) => *n, + Some(NodeValue::Text(s)) => s.parse().unwrap_or(0.0), + _ => 0.0, + } + } + + fn trim_num(n: f64) -> String { + if (n - n.round()).abs() < f64::EPSILON { + format!("{}", n as i64) + } else { + format!("{n:.1}") + } + } + + /// A value plus its unit suffix, which is a presentation concern the runtime + /// owns — the card never wrote "°" next to a number. + fn valued(node: &UiNode) -> String { + let value = arg(node, "value"); + let unit = match arg(node, "unit") { + Some(NodeValue::Token(t)) if t == "c" || t == "f" => "°", + Some(NodeValue::Text(t)) if t == "c" || t == "f" => "°", + Some(NodeValue::Token(t)) if t == "pct" => "%", + _ => "", + }; + let glyph = match arg(node, "glyph") { + Some(NodeValue::Text(g)) => g.clone(), + _ => String::new(), + }; + match value { + Some(NodeValue::Missing) => "\"—\"".into(), + Some(v) => { + let body = match v { + NodeValue::Number(n) => trim_num(*n), + NodeValue::Text(s) => s.clone(), + _ => String::new(), + }; + format!("{:?}", format!("{glyph}{body}{unit}")) + } + None => text_of(arg(node, "text")), + } + } + + fn pad(depth: usize) -> String { + " ".repeat(depth.min(32)) + } + + fn children(node: &UiNode, depth: usize, out: &mut String) { + for child in &node.children { + element(child, depth + 1, out); + } + } + + fn element(node: &UiNode, depth: usize, out: &mut String) { + let p = pad(depth); + match node.kind.as_str() { + "Surface" => { + let _ = writeln!( + out, + "{p}SolidView{{ width: Fill height: Fit flow: Down new_batch: true \ + draw_bg.color: {BASE} padding: {PAGE_PAD}" + ); + children(node, depth, out); + let _ = writeln!(out, "{p}}}"); + } + "Photo" => { + // Overlay: photo, scrim, then the column. A `Fit` overlay takes + // its tallest child, so the image needs a fixed height or the + // card ends in a band of bare base colour. + let src = match arg(node, "src") { + Some(NodeValue::Text(s)) => s.clone(), + _ => String::new(), + }; + let _ = writeln!( + out, + "{p}SolidView{{ width: Fill height: Fit flow: Overlay new_batch: true \ + draw_bg.color: {BASE}" + ); + if !src.is_empty() { + let _ = writeln!( + out, + "{p} Image{{ src: http_resource({src:?}) fit: ImageFit.CropToFill \ + width: Fill height: 2000 }}" + ); + } + let _ = writeln!( + out, + "{p} SolidView{{ width: Fill height: Fill draw_bg.color: {SCRIM} }}" + ); + let _ = writeln!( + out, + "{p} View{{ width: Fill height: Fit flow: Down padding: {PAGE_PAD}" + ); + children(node, depth + 1, out); + let _ = writeln!(out, "{p} }}"); + let _ = writeln!(out, "{p}}}"); + } + "Col" => { + let align = + matches!(arg(node, "align"), Some(NodeValue::Token(t)) if t == "center"); + let a = if align { " align: Align{x: 0.5}" } else { "" }; + let _ = writeln!(out, "{p}View{{ width: Fill height: Fit flow: Down{a}"); + children(node, depth, out); + let _ = writeln!(out, "{p}}}"); + } + "Row" => { + let _ = writeln!( + out, + "{p}View{{ width: Fill height: Fit flow: Right align: Align{{y: 0.5}} spacing: 6" + ); + children(node, depth, out); + let _ = writeln!(out, "{p}}}"); + } + "Panel" | "Card" => { + let _ = writeln!( + out, + "{p}RoundedView{{ width: Fill height: Fit flow: Down new_batch: true \ + draw_bg.color: {PANEL} draw_bg.border_radius: 14.0 \ + margin: Inset{{top: 16}} padding: Inset{{left: 14 right: 14 top: 12 bottom: 12}}" + ); + children(node, depth, out); + let _ = writeln!(out, "{p}}}"); + } + "Grid" => { + // Two columns, emitted as rows of two so the existing layout + // engine needs no grid primitive. + let _ = writeln!( + out, + "{p}View{{ width: Fill height: Fit flow: Down spacing: 8" + ); + for pair in node.children.chunks(2) { + let _ = writeln!( + out, + "{p} View{{ width: Fill height: Fit flow: Right spacing: 8" + ); + for cell in pair { + element(cell, depth + 2, out); + } + let _ = writeln!(out, "{p} }}"); + } + let _ = writeln!(out, "{p}}}"); + } + "Rule" => { + let _ = writeln!( + out, + "{p}SolidView{{ width: Fill height: 1 draw_bg.color: {HAIRLINE} }}" + ); + } + "Tile" => { + let _ = writeln!( + out, + "{p}RoundedView{{ width: Fill height: Fit flow: Down new_batch: true \ + draw_bg.color: {PANEL} draw_bg.border_radius: 18.0 \ + padding: Inset{{left: 14 right: 14 top: 12 bottom: 12}} spacing: 4" + ); + let _ = writeln!( + out, + "{p} TextCaption{{ text: {} draw_text.color: {DIM} }}", + text_of(arg(node, "label")) + ); + let _ = writeln!( + out, + "{p} TextStat{{ text: {} draw_text.color: {TEXT} }}", + valued(node) + ); + let _ = writeln!(out, "{p}}}"); + } + "TempBar" => { + let _ = writeln!( + out, + "{p}TempBar{{ width: Fill height: 8 margin: Inset{{left: 10 right: 10}} \ + draw_bg.tlo: {} draw_bg.thi: {} draw_bg.wmin: {} draw_bg.wmax: {} }}", + trim_num(num_of(arg(node, "lo"))), + trim_num(num_of(arg(node, "hi"))), + trim_num(num_of(arg(node, "min"))), + trim_num(num_of(arg(node, "max"))), + ); + } + "WeatherIcon" => { + let size = matches!(arg(node, "size"), Some(NodeValue::Token(t)) if t == "hero"); + let (w, h) = if size { (64, 64) } else { (34, 26) }; + let _ = writeln!( + out, + "{p}WeatherIcon{{ width: {w} height: {h} draw_bg.cond: {} }}", + trim_num(num_of(arg(node, "cond"))) + ); + } + "SunArc" => { + let _ = writeln!( + out, + "{p}SunArc{{ width: Fill height: 90 draw_bg.rise: {} draw_bg.set: {} draw_bg.now: {} }}", + trim_num(num_of(arg(node, "rise"))), + trim_num(num_of(arg(node, "set"))), + trim_num(num_of(arg(node, "now"))), + ); + } + "MoonPhase" => { + let _ = writeln!( + out, + "{p}MoonPhase{{ width: 64 height: 64 draw_bg.phase: {} }}", + trim_num(num_of(arg(node, "phase"))) + ); + } + "AqiContour" => { + let _ = writeln!( + out, + "{p}AqiContour{{ width: Fill height: 190 draw_bg.have: 1.0 draw_bg.idx: {} }}", + trim_num(num_of(arg(node, "index"))) + ); + } + "StockPlot" => { + let _ = writeln!(out, "{p}StockPlot{{ width: Fill height: 180 }}"); + } + "Chip" => { + let _ = writeln!( + out, + "{p}RoundedView{{ width: Fit height: Fit draw_bg.color: {PANEL} \ + draw_bg.border_radius: 12.0 padding: Inset{{left: 10 right: 10 top: 5 bottom: 5}}" + ); + let _ = writeln!( + out, + "{p} TextCaption{{ text: {} draw_text.color: {SOFT} }}", + text_of(arg(node, "text")) + ); + let _ = writeln!(out, "{p}}}"); + } + role if role.starts_with("Text") => { + let colour = match role { + "TextCaption" => DIM, + "TextRow" => SOFT, + _ => TEXT, + }; + let body = if arg(node, "value").is_some() || arg(node, "glyph").is_some() { + valued(node) + } else { + text_of(arg(node, "text")) + }; + let _ = writeln!( + out, + "{p}{role}{{ width: Fill text: {body} draw_text.color: {colour} }}" + ); + } + other => { + // An unmapped constructor is shown, not skipped. A card that + // silently drops a section looks correct and is not. + let _ = writeln!( + out, + "{p}TextCaption{{ text: {:?} draw_text.color: #ffb4b4 }}", + format!("⚠ no makepad lowering for {other}") + ); + } + } + } +} diff --git a/crates/splash-core/tests/fixtures/weather.card b/crates/splash-core/tests/fixtures/weather.card index 115bd91..be0217c 100644 --- a/crates/splash-core/tests/fixtures/weather.card +++ b/crates/splash-core/tests/fixtures/weather.card @@ -15,6 +15,10 @@ source now sys.weather(lat: place.lat, lon: place.lon, source week sys.weather(lat: place.lat, lon: place.lon, days: state.days, fields: [dayname, hi, lo, cond], aggregate: [min_lo, max_hi]) +# `week` is a RECORD: `week.days` is the collection, `week.min_lo`/`max_hi` are +# the bounds every bar scales against. Those bounds were a hand-authored +# `wmin`/`wmax` in an earlier card and got invented by the model; here they come +# back with the data they describe. source sun sys.daylight(lat: place.lat, lon: place.lon) source moon sys.moonphase() source aqi sys.airquality(lat: place.lat, lon: place.lon) @@ -90,7 +94,7 @@ component ForecastRow(item: record, unit: enum[c, f]) { } view forecast Panel { - for d in week key d.dayname { + for d in week.days key d.dayname { ForecastRow(item: d, unit: units) } } diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index da25c50..b51ecb4 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -423,9 +423,13 @@ fn weather_data() -> serde_json::Value { "now": { "temp": 18.0, "feels": 16.0, "hi": 21.0, "lo": 12.0, "cond": "cloudy", "humidity": 61.0, "wind": 9.0, "pressure": 1013.0, "uv": 3.0, "visibility": 10.0 }, - "week": [ { "dayname": "Mon", "hi": 21.0, "lo": 12.0, "cond": "cloudy" }, - { "dayname": "Tue", "hi": 23.0, "lo": 13.0, "cond": "sun" }, - { "dayname": "Wed", "hi": 19.0, "lo": 11.0, "cond": "rain" } ], + // `week` is a record: the days plus the bounds every bar scales against. + // An array could not carry both, which is what the first data snapshot + // got wrong and silently zeroed every TempBar. + "week": { "days": [ { "dayname": "Mon", "hi": 21.0, "lo": 12.0, "cond": 2.0 }, + { "dayname": "Tue", "hi": 23.0, "lo": 13.0, "cond": 0.0 }, + { "dayname": "Wed", "hi": 19.0, "lo": 11.0, "cond": 3.0 } ], + "min_lo": 11.0, "max_hi": 23.0 }, "sun": { "rise": 5.1, "set": 18.9, "now": 12.0 }, "moon": { "phase": 0.5, "illumination": 0.5 }, "aqi": { "grid": [1, 2], "index": 42.0, "band": "good" }, @@ -591,7 +595,7 @@ fn a_long_collection_is_truncated_rather_than_unbounded() { let week: Vec = (0..5_000) .map(|i| serde_json::json!({ "dayname": format!("d{i}"), "hi": 1.0, "lo": 0.0, "cond": "x" })) .collect(); - data["week"] = serde_json::Value::Array(week); + data["week"] = serde_json::json!({ "days": week, "min_lo": 0.0, "max_hi": 1.0 }); let limits = RealizeLimits { max_collection: 8, ..RealizeLimits::default() @@ -632,3 +636,67 @@ fn the_other_two_cards_realize_too() { assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); assert!(report.root.is_some()); } + +// ───────────────────────────────────────────────────────────── makepad lowering ── + +use splash_core::ui_l0::makepad; + +#[test] +fn a_realized_card_lowers_to_renderable_dsl() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let dsl = makepad::lower(&report.root.unwrap()); + + // Exactly one top-level node. Siblings at top level lay out SIDE BY SIDE and + // squeeze the card into half the width — a real generated-card bug. + let tops = dsl + .lines() + .filter(|l| !l.starts_with("//") && !l.starts_with(' ') && !l.trim().is_empty()) + .filter(|l| l.contains('{')) + .count(); + assert_eq!(tops, 1, "expected one root node:\n{dsl}"); + + assert!( + dsl.contains("http_resource"), + "the photo should be a resource" + ); + assert!( + dsl.contains("TempBar{"), + "forecast bars should survive lowering" + ); + assert!( + dsl.contains("MoonPhase{"), + "the moon phase should survive lowering" + ); + assert!( + dsl.contains("\"Kyoto\""), + "the bound city should be concrete" + ); + assert!( + !dsl.contains("sys."), + "realization already resolved sources: {dsl}" + ); +} + +/// Nothing may be silently dropped: an unmapped constructor renders a visible +/// warning instead of vanishing. +#[test] +fn an_unmapped_constructor_is_visible_rather_than_dropped() { + let node = splash_core::ui_l0::UiNode { + kind: "Hologram".into(), + key: "root".into(), + args: vec![], + children: vec![], + }; + let dsl = makepad::lower(&node); + assert!(dsl.contains("no makepad lowering for Hologram"), "{dsl}"); +} + +/// A missing binding reaches the screen as an em dash rather than a blank. +#[test] +fn a_missing_binding_lowers_to_a_visible_dash() { + let mut data = weather_data(); + data["now"]["humidity"] = serde_json::Value::Null; + let report = realize(WEATHER, &data, RealizeLimits::default()); + let dsl = makepad::lower(&report.root.unwrap()); + assert!(dsl.contains('—'), "an unresolved field should be visible"); +} From a4291c5f60c31265955f78bf7b985da246c02589 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:10:27 -0700 Subject: [PATCH 08/63] =?UTF-8?q?feat(ui=5Fl0):=20per-instance=20local=20s?= =?UTF-8?q?tate=20=E2=80=94=20the=20runtime's=20half=20of=20the=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/splash-core/src/ui_l0.rs | 232 +++++++++++++++++++++- crates/splash-core/tests/ui_profile_l0.rs | 87 ++++++++ 2 files changed, 313 insertions(+), 6 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index a428e7e..d8babcb 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -1847,6 +1847,15 @@ pub struct RealizeReport { /// here** — not because one is blocked, but because realization is a tree walk /// and there is no evaluator to reach anything with. pub fn realize(source: &str, data: &crate::JsonValue, limits: RealizeLimits) -> RealizeReport { + realize_inner(source, data, None, limits) +} + +fn realize_inner( + source: &str, + data: &crate::JsonValue, + store: Option<&InstanceStore>, + limits: RealizeLimits, +) -> RealizeReport { let mut sink = Diagnostics::default(); let check = check_ui_l0_named("card.l0", source); @@ -1888,6 +1897,7 @@ pub fn realize(source: &str, data: &crate::JsonValue, limits: RealizeLimits) -> nodes: 0, truncated: false, sink: &mut sink, + store, }; let mut scope = ValueScope { frames: vec![], @@ -1937,6 +1947,9 @@ struct Realizer<'a> { nodes: usize, truncated: bool, sink: &'a mut Diagnostics, + /// Where component-local values live. Absent means every instance sees its + /// declared initial. + store: Option<&'a InstanceStore>, } impl Realizer<'_> { @@ -2038,12 +2051,27 @@ impl Realizer<'_> { bound += 1; } } - self.element( - &component.body, - scope, - &format!("{key}/{}", component.name), - out, - ); + let instance_key = format!("{key}/{}", component.name); + + // Local state resolves PER INSTANCE: from the store when it holds a + // value for this key, otherwise from the declared initial. Two instances + // of one component therefore never share a cell — the property the + // component model exists to provide. + let schema = schema_id(component); + for state in &component.states { + let live = self + .store + .filter(|s| s.schemas.get(&component.name).is_none_or(|k| *k == schema)) + .and_then(|s| s.get(&instance_key, &state.path)) + .cloned(); + scope.frames.push(( + state.path.clone(), + live.unwrap_or_else(|| initial_for(&state.shape)), + )); + bound += 1; + } + + self.element(&component.body, scope, &instance_key, out); for _ in 0..bound { scope.frames.pop(); } @@ -2521,3 +2549,195 @@ pub mod makepad { } } } + +// ────────────────────────────────────────────────────────────── instance state ── + +use std::collections::BTreeMap; + +/// Per-instance local state — the runtime's half of the immutable/mutable split. +/// +/// The ledger declares a component's state *shape*; the values live here, keyed +/// by instance identity (§5.1). Seven `ForecastRow` instances therefore hold +/// seven independent cells, none of which appear in the card. +/// +/// 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. +#[derive(Clone, Debug, Default)] +pub struct InstanceStore { + /// `instance-key` → field → value. + cells: BTreeMap>, + /// Component name → the schema id its live cells were written under. + schemas: BTreeMap, +} + +impl InstanceStore { + pub fn get(&self, key: &str, field: &str) -> Option<&crate::JsonValue> { + self.cells.get(key)?.get(field) + } + + fn set(&mut self, key: &str, field: &str, value: crate::JsonValue) { + self.cells + .entry(key.to_string()) + .or_default() + .insert(field.to_string(), value); + } + + /// Drop every cell belonging to instances of `component`. + fn reset_component(&mut self, component: &str) { + let marker = format!("/{component}"); + self.cells.retain(|key, _| !key.contains(&marker)); + } + + /// Profile §5.8: a component whose state declarations changed gets a new + /// schema id, and its live cells are dropped rather than migrated. Guessing + /// that an old value still fits is how a rolled-back ledger feeds version-A + /// state to version-B code. + fn reconcile_schema(&mut self, component: &str, schema: u64) { + match self.schemas.get(component) { + Some(&known) if known == schema => {} + Some(_) => { + self.reset_component(component); + self.schemas.insert(component.to_string(), schema); + } + None => { + self.schemas.insert(component.to_string(), schema); + } + } + } + + /// Forget instances that no longer appear — the unmount half of §5.7. + pub fn prune(&mut self, live: &[String]) { + self.cells.retain(|key, _| live.iter().any(|k| k == key)); + } + + pub fn len(&self) -> usize { + self.cells.len() + } + + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } +} + +/// A component's state schema: its declarations, order-independent. Changing a +/// shape, name or initial changes this; changing the view does not. +fn schema_id(component: &Component) -> u64 { + let mut acc: u64 = 0xcbf2_9ce4_8422_2325; + let mut fields: Vec = component + .states + .iter() + .map(|s| format!("{}:{:?}", s.path, s.shape)) + .collect(); + fields.sort(); + for byte in fields.join(",").bytes() { + acc ^= byte as u64; + acc = acc.wrapping_mul(0x100_0000_01b3); + } + acc +} + +fn initial_for(shape: &Shape) -> crate::JsonValue { + match shape { + Shape::Bool => crate::JsonValue::Bool(false), + Shape::Enum(members) => members + .first() + .map(|m| crate::JsonValue::String(m.clone())) + .unwrap_or(crate::JsonValue::Null), + Shape::Other => crate::JsonValue::Null, + } +} + +/// Realize against a store, so component-local state resolves per instance. +/// +/// [`realize`] is this with an empty store: a card with no local state behaves +/// identically either way. +pub fn realize_with_state( + source: &str, + data: &crate::JsonValue, + store: &InstanceStore, + limits: RealizeLimits, +) -> RealizeReport { + realize_inner(source, data, Some(store), limits) +} + +/// Apply an event's transitions to one instance's cells. +/// +/// Returns whether anything applied. The event name is resolved against the +/// component's *declarations* — a card cannot construct one, because it has no +/// expression form to build a name with. +pub fn dispatch(source: &str, store: &mut InstanceStore, instance_key: &str, event: &str) -> bool { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return false; + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + + // A backend dispatches with the key of the NODE that was tapped, which sits + // inside the component rather than at its boundary — `…/Rowy/0/1`. The cell + // belongs to the instance, so the key is truncated to the component segment. + // Where components nest, the innermost one declaring this event wins. + let mut owner: Option<(&Component, usize)> = None; + for component in &card.components { + if !component.events.iter().any(|e| e.name == event) { + continue; + } + let marker = format!("/{}", component.name); + if let Some(at) = instance_key.rfind(&marker) { + let end = at + marker.len(); + // Only a whole segment matches: `/Row` must not match `/Rowy`. + let whole = instance_key[end..] + .chars() + .next() + .is_none_or(|c| c == '/' || c == '['); + if whole && owner.as_ref().is_none_or(|(_, best)| end > *best) { + owner = Some((component, end)); + } + } + } + let Some((component, boundary)) = owner else { + return false; + }; + let instance_key = &instance_key[..boundary]; + let Some(declared) = component.events.iter().find(|e| e.name == event) else { + return false; + }; + + let schema = schema_id(component); + store.reconcile_schema(&component.name, schema); + + let mut applied = false; + for transition in &declared.transitions { + let Some(state) = component + .states + .iter() + .find(|s| s.path == transition.target) + else { + continue; + }; + let current = store + .get(instance_key, &transition.target) + .cloned() + .unwrap_or_else(|| initial_for(&state.shape)); + + let next = match (&transition.form, &state.shape) { + (Form::Toggle, Shape::Bool) => { + crate::JsonValue::Bool(!current.as_bool().unwrap_or(false)) + } + (Form::Clear, shape) => initial_for(shape), + (Form::Cycle, Shape::Enum(members)) if !members.is_empty() => { + let at = current + .as_str() + .and_then(|s| members.iter().position(|m| m == s)) + .unwrap_or(0); + crate::JsonValue::String(members[(at + 1) % members.len()].clone()) + } + // `set` carries its operand in the source; realization supplies it + // through the event payload, which this entry point does not take. + _ => continue, + }; + store.set(instance_key, &transition.target, next); + applied = true; + } + applied +} diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index b51ecb4..479e041 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -700,3 +700,90 @@ fn a_missing_binding_lowers_to_a_visible_dash() { let dsl = makepad::lower(&report.root.unwrap()); assert!(dsl.contains('—'), "an unresolved field should be visible"); } + +// ────────────────────────────────────────────────────────────── instance state ── + +const TWO_ROWS: &str = r#" +source items sys.list() +component Rowy(item: record) { + state open { shape: bool, initial: false } + event flip { open: toggle } + view Col { + Row(on_tap: flip) { TextRow(text: item.name) } + when open { TextCaption(text: item.name) } + } +} +view root Panel { for it in items key it.id { Rowy(item: it) } } +"#; + +fn two_rows_data() -> serde_json::Value { + serde_json::json!({ "items": [ {"id": "a", "name": "A"}, {"id": "b", "name": "B"} ] }) +} + +/// Each instance must own its `open` cell. Opening one row may not open the +/// other — the property the whole component model exists to provide. +#[test] +fn local_state_is_per_instance() { + let mut store = splash_core::ui_l0::InstanceStore::default(); + let data = two_rows_data(); + + let first = + splash_core::ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let root = first.root.expect("root"); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + assert_eq!(rows.len(), 2); + + // Open only the first row. + let key = rows[0].key.clone(); + let applied = splash_core::ui_l0::dispatch(TWO_ROWS, &mut store, &key, "flip"); + assert!(applied, "flip should have applied at {key}"); + + let second = + splash_core::ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let root = second.root.expect("root"); + let mut captions = Vec::new(); + find(&root, "TextCaption", &mut captions); + assert_eq!( + captions.len(), + 1, + "exactly one row should be open, not {}", + captions.len() + ); + let (_, shown) = captions[0].args.iter().find(|(n, _)| n == "text").unwrap(); + assert_eq!( + *shown, + NodeValue::Text("A".into()), + "the row that was tapped" + ); +} + +/// Profile §5.8: a state schema change resets instances rather than guessing +/// that old values still fit. +#[test] +fn a_schema_change_resets_instance_state() { + let mut store = splash_core::ui_l0::InstanceStore::default(); + let data = two_rows_data(); + let first = + splash_core::ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let mut rows = Vec::new(); + let first_root = first.root.unwrap(); + find(&first_root, "Row", &mut rows); + splash_core::ui_l0::dispatch(TWO_ROWS, &mut store, &rows[0].key, "flip"); + + // The component gains a field: same name, different state schema. + let edited = TWO_ROWS.replace( + "state open { shape: bool, initial: false }", + "state open { shape: bool, initial: false }\n state seen { shape: bool, initial: false }", + ); + let after = + splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let mut captions = Vec::new(); + let after_root = after.root.unwrap(); + find(&after_root, "TextCaption", &mut captions); + assert_eq!( + captions.len(), + 0, + "a schema change resets, it does not migrate" + ); +} From f0c43e2abededf485adf9407fb5f85afd4d42e56 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:31:22 -0700 Subject: [PATCH 09/63] feat(ui_l0): the features the spec declared and the code skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/splash-core/examples/tap_l0.rs | 35 +++ crates/splash-core/src/ui_l0.rs | 304 ++++++++++++++++--- crates/splash-core/tests/fixtures/news.card | 2 +- crates/splash-core/tests/fixtures/stock.card | 2 +- crates/splash-core/tests/ui_profile_l0.rs | 147 +++++++++ 5 files changed, 451 insertions(+), 39 deletions(-) create mode 100644 crates/splash-core/examples/tap_l0.rs diff --git a/crates/splash-core/examples/tap_l0.rs b/crates/splash-core/examples/tap_l0.rs new file mode 100644 index 0000000..28bd079 --- /dev/null +++ b/crates/splash-core/examples/tap_l0.rs @@ -0,0 +1,35 @@ +//! Realize a card, dispatch one event, realize again — the interaction loop. +//! +//! Usage: tap_l0 [payload] +//! +//! Proves the loop end to end without a UI: state lives in the store, the event +//! is a declared name, and the second realization reflects the write. + +use splash_core::ui_l0::{ + dispatch_with, makepad, realize_with_state, InstanceStore, RealizeLimits, +}; + +fn main() { + let a: Vec = std::env::args().skip(1).collect(); + let (card_path, data_path, event) = (&a[0], &a[1], &a[2]); + let payload = a.get(3).map(|v| serde_json::Value::String(v.clone())); + + let card = std::fs::read_to_string(card_path).expect("card"); + let data: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(data_path).expect("data")).expect("json"); + let mut store = InstanceStore::default(); + + let before = realize_with_state(&card, &data, &store, RealizeLimits::default()); + eprintln!("before: {} nodes", before.nodes); + + let applied = dispatch_with(&card, &mut store, "root", event, payload.as_ref()); + eprintln!("dispatch {event:?} -> {applied}"); + + let after = realize_with_state(&card, &data, &store, RealizeLimits::default()); + eprintln!("after: {} nodes", after.nodes); + // Forget instances that went away, so the store tracks the live tree. + store.prune(&after.live_keys); + eprintln!("store: {} live cells", store.len()); + + print!("{}", makepad::lower(&after.root.expect("root"))); +} diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index d8babcb..e8c89df 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -477,6 +477,10 @@ enum Shape { struct StateDecl { path: String, shape: Shape, + /// The declared `initial`. Parsed rather than discarded: without it a guard + /// on a freshly-mounted state compares against null and takes the wrong + /// branch on first render. + initial: Option, } /// An event and the transition batch it applies. Profile §3: an event names @@ -499,7 +503,7 @@ struct Transition { #[derive(Clone, Debug, Eq, PartialEq)] enum Form { - Set, + Set(SetSource), Toggle, Cycle, Clear, @@ -507,6 +511,20 @@ enum Form { NotTotal(String), } +/// What `set(…)` assigns. Each is a value the runtime already holds; none is +/// computed, so the transition stays total. +#[derive(Clone, Debug, Eq, PartialEq)] +enum SetSource { + /// `$value` — the payload from the element that raised the event. + Payload, + /// A `.token`, e.g. an enum member. + Token(String), + /// A string literal. + Text(String), + /// A bound path, resolved against the data the runtime injected. + Path(String), +} + #[derive(Debug)] struct Component { name: String, @@ -525,7 +543,7 @@ struct View { body: Element, } -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] struct Element { /// Constructor name, a referenced view/component name, or empty for a group. name: String, @@ -543,7 +561,7 @@ struct Element { key_path: Option, } -#[derive(Debug)] +#[derive(Clone, Debug)] struct Arg { name: String, value: Operand, @@ -551,7 +569,7 @@ struct Arg { column: usize, } -#[derive(Debug)] +#[derive(Clone, Debug)] enum Operand { Path(String), Token(String), @@ -781,6 +799,7 @@ impl<'a> Parser<'a> { fn parse_state(&mut self) -> Option { let path = self.dotted_name()?; let mut shape = Shape::Other; + let mut initial = None; // `{ shape: enum[a, b], initial: .a }` — capture the shape, skip the rest. let start = self.at; if self.peek().is_some_and(|t| t.is_punct("{")) { @@ -792,6 +811,23 @@ impl<'a> Parser<'a> { if t.is_kw("bool") { shape = Shape::Bool; } + // `initial: ` — a token, string or number. + if t.is_kw("initial") && self.tokens.get(scan + 1).is_some_and(|n| n.is_punct(":")) + { + if let Some(v) = self.tokens.get(scan + 2) { + initial = match v.kind { + Kind::Str => Some(crate::JsonValue::String(v.text.clone())), + Kind::Token => Some(crate::JsonValue::String( + v.text.trim_start_matches('.').to_string(), + )), + Kind::Num => v.text.parse::().ok().map(crate::JsonValue::from), + Kind::Ident if v.text == "true" || v.text == "false" => { + Some(crate::JsonValue::Bool(v.text == "true")) + } + _ => None, + }; + } + } if t.is_kw("enum") { let mut members = Vec::new(); let mut j = scan + 1; @@ -814,7 +850,11 @@ impl<'a> Parser<'a> { self.at = start; self.skip_braced(); } - Some(StateDecl { path, shape }) + Some(StateDecl { + path, + shape, + initial, + }) } /// `event name { path: total-form, … }` @@ -879,8 +919,40 @@ impl<'a> Parser<'a> { } "set" => { self.at += 1; - self.skip_balanced("(", ")"); - (Form::Set, Vec::new()) + let mut source = SetSource::Payload; + if self.peek().is_some_and(|t| t.is_punct("(")) { + self.at += 1; + if let Some(inner) = self.peek().cloned() { + match inner.kind { + Kind::Token => { + source = SetSource::Token( + inner.text.trim_start_matches('.').to_string(), + ); + self.at += 1; + } + Kind::Str => { + source = SetSource::Text(inner.text.clone()); + self.at += 1; + } + Kind::Punct if inner.text == "$" => { + // `$value` — the payload. + self.at += 1; + let _ = self.ident(); + } + Kind::Ident => { + source = self + .dotted_name() + .map(SetSource::Path) + .unwrap_or(SetSource::Payload); + } + _ => { + self.at += 1; + } + } + } + self.expect_punct(")"); + } + (Form::Set(source), Vec::new()) } "cycle" => { self.at += 1; @@ -1482,6 +1554,7 @@ fn check_event_batch(events: &[EventDecl], states: &[StateDecl], sink: &mut Diag transition.target ), ), + Form::Set(_) => {} Form::Cycle => match &state.shape { Shape::Enum(members) => { for member in &transition.tokens { @@ -1838,6 +1911,11 @@ pub struct RealizeReport { pub nodes: usize, /// A bound was reached; the tree is partial. pub truncated: bool, + /// Every component instance this realization produced, plus the card cell. + /// Pass to [`InstanceStore::prune`] to forget instances that went away — + /// without it the store grows for the life of the app, and a key that + /// reappears inherits a stale value (§5.7, unmount). + pub live_keys: Vec, } /// Realize a card against resolved data. @@ -1865,6 +1943,7 @@ fn realize_inner( diagnostics: check.diagnostics, nodes: 0, truncated: false, + live_keys: Vec::new(), }; } @@ -1876,6 +1955,7 @@ fn realize_inner( diagnostics: sink.items, nodes: 0, truncated: false, + live_keys: Vec::new(), } } }; @@ -1888,6 +1968,7 @@ fn realize_inner( diagnostics: sink.items, nodes: 0, truncated: false, + live_keys: Vec::new(), }; }; @@ -1898,21 +1979,36 @@ fn realize_inner( truncated: false, sink: &mut sink, store, + live: vec![CARD_STATE_KEY.to_string()], + slots: Vec::new(), }; - let mut scope = ValueScope { - frames: vec![], - data, - }; + // Card-scope state resolves from the store first, then the injected data, + // then its declared initial. Without this, an event that wrote card state + // would apply and be invisible at the next realization — and a guard on a + // state with no value at all would take the wrong branch on first render. + let mut frames: Vec<(String, crate::JsonValue)> = Vec::new(); + for state in &card.states { + let value = store + .and_then(|s| s.get(CARD_STATE_KEY, &state.path)) + .cloned() + .or_else(|| data.get(&state.path).cloned()) + .or_else(|| state.initial.clone()) + .unwrap_or_else(|| initial_for(&state.shape)); + frames.push((state.path.clone(), value)); + } + let mut scope = ValueScope { frames, data }; let mut out = Vec::new(); ctx.element(&root.body, &mut scope, "root", &mut out); let nodes = ctx.nodes; let truncated = ctx.truncated; + let live_keys = std::mem::take(&mut ctx.live); RealizeReport { root: out.into_iter().next(), diagnostics: sink.items, nodes, truncated, + live_keys, } } @@ -1950,6 +2046,12 @@ struct Realizer<'a> { /// Where component-local values live. Absent means every instance sees its /// declared initial. store: Option<&'a InstanceStore>, + /// Instance keys produced, for pruning. + live: Vec, + /// Children passed at a component call site, realized where `slot` appears. + /// A stack, so a component instantiated inside another's slot still sees its + /// own children. + slots: Vec>, } impl Realizer<'_> { @@ -1967,7 +2069,18 @@ impl Realizer<'_> { match element.name.as_str() { "" => {} - "slot" => {} + "slot" => { + // Children are realized in the CALLER's scope, not the + // component's — a child expression sees the call site's + // bindings (§5.5). Taking the frame off the stack while + // realizing prevents a component's own slot from re-entering. + if let Some(children) = self.slots.pop() { + for (i, child) in children.iter().enumerate() { + self.element(child, scope, &format!("{key}/s{i}"), out); + } + self.slots.push(children); + } + } "for" => self.iteration(element, scope, key, out), "when" => { if self.guard_holds(element, scope) { @@ -2052,6 +2165,8 @@ impl Realizer<'_> { } } let instance_key = format!("{key}/{}", component.name); + self.live.push(instance_key.clone()); + self.slots.push(call.children.clone()); // Local state resolves PER INSTANCE: from the store when it holds a // value for this key, otherwise from the declared initial. Two instances @@ -2066,12 +2181,14 @@ impl Realizer<'_> { .cloned(); scope.frames.push(( state.path.clone(), - live.unwrap_or_else(|| initial_for(&state.shape)), + live.or_else(|| state.initial.clone()) + .unwrap_or_else(|| initial_for(&state.shape)), )); bound += 1; } self.element(&component.body, scope, &instance_key, out); + self.slots.pop(); for _ in 0..bound { scope.frames.pop(); } @@ -2266,6 +2383,8 @@ pub mod makepad { const TEXT: &str = "#ffffff"; const SOFT: &str = "#ffffffe6"; const DIM: &str = "#ffffff99"; + const UP: &str = "#32d74b"; + const DOWN: &str = "#ff453a"; const PAGE_PAD: &str = "Inset{left: 20 top: 54 right: 20 bottom: 24}"; pub fn lower(root: &UiNode) -> String { @@ -2310,6 +2429,45 @@ pub mod makepad { } } + /// Apply a declared `format`. This is the runtime's job, not the card's: a + /// card that wrote "$" or "41.2M" itself would be authoring a fact, and + /// currency, grouping and compaction are all locale-dependent besides. + fn formatted(kind: &str, n: f64) -> String { + let sign = if n > 0.0 { "+" } else { "" }; + match kind { + "money" => format!("${:.2}", n), + "signed_money" => format!("{sign}${:.2}", n), + "signed_pct" => format!("{sign}{:.1}%", n), + "ratio" => format!("{:.1}", n), + "compact" => { + let a = n.abs(); + let (v, suffix) = if a >= 1e12 { + (n / 1e12, "T") + } else if a >= 1e9 { + (n / 1e9, "B") + } else if a >= 1e6 { + (n / 1e6, "M") + } else if a >= 1e3 { + (n / 1e3, "K") + } else { + (n, "") + }; + if suffix.is_empty() { + trim_num(v) + } else { + format!("{:.1}{suffix}", v) + } + } + // A float hour — 18.9 is 18:54, not "18.9". + "time" => { + let h = n.floor().max(0.0) as i64; + let m = ((n - n.floor()) * 60.0).round() as i64; + format!("{h}:{m:02}") + } + _ => trim_num(n), + } + } + /// A value plus its unit suffix, which is a presentation concern the runtime /// owns — the card never wrote "°" next to a number. fn valued(node: &UiNode) -> String { @@ -2324,12 +2482,17 @@ pub mod makepad { Some(NodeValue::Text(g)) => g.clone(), _ => String::new(), }; + let format_kind = match arg(node, "format") { + Some(NodeValue::Token(t)) => Some(t.clone()), + _ => None, + }; match value { Some(NodeValue::Missing) => "\"—\"".into(), Some(v) => { - let body = match v { - NodeValue::Number(n) => trim_num(*n), - NodeValue::Text(s) => s.clone(), + let body = match (v, format_kind.as_deref()) { + (NodeValue::Number(n), Some(kind)) => formatted(kind, *n), + (NodeValue::Number(n), None) => trim_num(*n), + (NodeValue::Text(s), _) => s.clone(), _ => String::new(), }; format!("{:?}", format!("{glyph}{body}{unit}")) @@ -2522,19 +2685,51 @@ pub mod makepad { let _ = writeln!(out, "{p}}}"); } role if role.starts_with("Text") => { - let colour = match role { - "TextCaption" => DIM, - "TextRow" => SOFT, - _ => TEXT, + // `tint` colours by SIGN — the card says "tint by this number", + // and which colours mean up and down is the runtime's to decide. + let colour = match arg(node, "tint") { + Some(NodeValue::Number(n)) if *n > 0.0 => UP, + Some(NodeValue::Number(n)) if *n < 0.0 => DOWN, + _ => match role { + "TextCaption" => DIM, + "TextRow" => SOFT, + _ => TEXT, + }, + }; + // Only constrain the width when the card asked. Forcing + // `width: Fill` made a long hero title wrap one character per + // line — "Top Movers" became "Top / Mover / s" on device. + let width = match arg(node, "width") { + Some(NodeValue::Token(t)) if t == "fill" => " width: Fill", + Some(NodeValue::Token(t)) if t == "fit" => " width: Fit", + Some(NodeValue::Token(_)) => " width: Fit", + _ => "", }; let body = if arg(node, "value").is_some() || arg(node, "glyph").is_some() { valued(node) } else { text_of(arg(node, "text")) }; + // A hero is sized for the ONE dominant value. "18°" fits at 62; + // "$184.20" clipped off the right edge on device. Scaling to fit + // is the runtime's call — the card says "this is the hero", not + // "this is 62 points". + let size = if role == "TextHero" { + let glyphs = body.trim_matches('"').chars().count(); + let pt = match glyphs { + 0..=4 => 62, + 5..=6 => 50, + 7..=8 => 40, + 9..=12 => 32, + _ => 24, + }; + format!(" draw_text.text_style.font_size: {pt}") + } else { + String::new() + }; let _ = writeln!( out, - "{p}{role}{{ width: Fill text: {body} draw_text.color: {colour} }}" + "{p}{role}{{{width} text: {body} draw_text.color: {colour}{size} }}" ); } other => { @@ -2554,6 +2749,10 @@ pub mod makepad { use std::collections::BTreeMap; +/// The key card-scope state lives under. Card state has no instance, so it needs +/// a fixed cell rather than one derived from a position in the tree. +pub const CARD_STATE_KEY: &str = "@card"; + /// Per-instance local state — the runtime's half of the immutable/mutable split. /// /// The ledger declares a component's state *shape*; the values live here, keyed @@ -2667,6 +2866,21 @@ pub fn realize_with_state( /// component's *declarations* — a card cannot construct one, because it has no /// expression form to build a name with. pub fn dispatch(source: &str, store: &mut InstanceStore, instance_key: &str, event: &str) -> bool { + dispatch_with(source, store, instance_key, event, None) +} + +/// Apply an event's transitions, supplying the payload the raising element +/// carried in its `value:` argument. +/// +/// The payload is data the runtime already holds — a bound path or a literal +/// resolved at realization — never anything the card computed. +pub fn dispatch_with( + source: &str, + store: &mut InstanceStore, + instance_key: &str, + event: &str, + payload: Option<&crate::JsonValue>, +) -> bool { let mut sink = Diagnostics::default(); let Some(tokens) = lex(source, &mut sink) else { return false; @@ -2695,36 +2909,45 @@ pub fn dispatch(source: &str, store: &mut InstanceStore, instance_key: &str, eve } } } - let Some((component, boundary)) = owner else { - return false; - }; - let instance_key = &instance_key[..boundary]; - let Some(declared) = component.events.iter().find(|e| e.name == event) else { - return false; + // An event declared on the CARD rather than a component targets card state, + // which lives under a fixed key. A component event wins when both declare the + // name, since the innermost scope owns the cell. + let (declared, states, instance_key, schema_owner) = match owner { + Some((component, boundary)) => match component.events.iter().find(|e| e.name == event) { + Some(d) => ( + d, + &component.states, + &instance_key[..boundary], + Some((component.name.clone(), schema_id(component))), + ), + None => return false, + }, + None => match card.events.iter().find(|e| e.name == event) { + Some(d) => (d, &card.states, CARD_STATE_KEY, None), + None => return false, + }, }; - let schema = schema_id(component); - store.reconcile_schema(&component.name, schema); + if let Some((name, schema)) = schema_owner { + store.reconcile_schema(&name, schema); + } let mut applied = false; for transition in &declared.transitions { - let Some(state) = component - .states - .iter() - .find(|s| s.path == transition.target) - else { + let Some(state) = states.iter().find(|s| s.path == transition.target) else { continue; }; let current = store .get(instance_key, &transition.target) .cloned() + .or_else(|| state.initial.clone()) .unwrap_or_else(|| initial_for(&state.shape)); let next = match (&transition.form, &state.shape) { (Form::Toggle, Shape::Bool) => { crate::JsonValue::Bool(!current.as_bool().unwrap_or(false)) } - (Form::Clear, shape) => initial_for(shape), + (Form::Clear, shape) => state.initial.clone().unwrap_or_else(|| initial_for(shape)), (Form::Cycle, Shape::Enum(members)) if !members.is_empty() => { let at = current .as_str() @@ -2732,8 +2955,15 @@ pub fn dispatch(source: &str, store: &mut InstanceStore, instance_key: &str, eve .unwrap_or(0); crate::JsonValue::String(members[(at + 1) % members.len()].clone()) } - // `set` carries its operand in the source; realization supplies it - // through the event payload, which this entry point does not take. + (Form::Set(source), _) => match source { + SetSource::Payload | SetSource::Path(_) => match payload { + Some(v) => v.clone(), + // No payload is a no-op rather than a silent clear: falling + // back to the initial would look like a deliberate reset. + None => continue, + }, + SetSource::Token(t) | SetSource::Text(t) => crate::JsonValue::String(t.clone()), + }, _ => continue, }; store.set(instance_key, &transition.target, next); diff --git a/crates/splash-core/tests/fixtures/news.card b/crates/splash-core/tests/fixtures/news.card index 1b61203..983e69d 100644 --- a/crates/splash-core/tests/fixtures/news.card +++ b/crates/splash-core/tests/fixtures/news.card @@ -45,7 +45,7 @@ view stream Col { view masthead Col { TextCaption(text: copy.masthead) - TextHero(text: copy.masthead) + TextTitle(text: copy.masthead) } # The lead story, given room. Every field is live. diff --git a/crates/splash-core/tests/fixtures/stock.card b/crates/splash-core/tests/fixtures/stock.card index fe9d7e3..7bb42a4 100644 --- a/crates/splash-core/tests/fixtures/stock.card +++ b/crates/splash-core/tests/fixtures/stock.card @@ -38,7 +38,7 @@ view root Surface(pad: .page) { } view list Col { - TextHero(text: copy.movers) + TextTitle(text: copy.movers) Panel { for m in movers key m.ticker { Row(align: .center, on_tap: open_quote, value: m.ticker) { diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 479e041..54e0746 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -787,3 +787,150 @@ fn a_schema_change_resets_instance_state() { "a schema change resets, it does not migrate" ); } + +// ─────────────────────────────────────────────────────── declared-but-missing ── + +const SETTER: &str = r#" +source items sys.list() +state picked { shape: text, initial: "" } +event choose { picked: set($value) } +event home { picked: clear } +view root Panel { + when picked == "" { for it in items key it.id { Row(on_tap: choose, value: it.id) { TextRow(text: it.name) } } } + when picked != "" { Col { TextTitle(text: picked) Row(on_tap: home) { TextCaption(glyph: "‹") } } } +} +"#; + +/// `set($value)` carries the payload from the element that raised the event. +/// Without it a list cannot select anything — the interaction stock and news +/// both depend on. +#[test] +fn set_applies_the_event_payload() { + let mut store = splash_core::ui_l0::InstanceStore::default(); + let data = serde_json::json!({ "items": [{"id":"a","name":"A"},{"id":"b","name":"B"}] }); + + let before = + splash_core::ui_l0::realize_with_state(SETTER, &data, &store, RealizeLimits::default()); + let root = before.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + assert_eq!(rows.len(), 2, "the list should show both items"); + + let payload = serde_json::json!("b"); + let applied = + splash_core::ui_l0::dispatch_with(SETTER, &mut store, "root", "choose", Some(&payload)); + assert!(applied, "choose should apply"); + + let after = + splash_core::ui_l0::realize_with_state(SETTER, &data, &store, RealizeLimits::default()); + let root = after.root.unwrap(); + let mut titles = Vec::new(); + find(&root, "TextTitle", &mut titles); + assert_eq!( + titles.len(), + 1, + "selecting should switch to the detail view" + ); + let (_, shown) = titles[0].args.iter().find(|(n, _)| n == "text").unwrap(); + assert_eq!( + *shown, + NodeValue::Text("b".into()), + "the item that was tapped" + ); +} + +const SLOTTED: &str = r#" +component Framed(title: text) { + view Col { TextCaption(text: title) slot } +} +view root Panel { Framed(title: "Details") { TextRow(text: "inner") Rule() } } +"#; + +/// A component's children are realized where `slot` appears. Dropping them +/// silently is how a card renders a frame with nothing in it and still looks +/// deliberate. +#[test] +fn slot_realizes_the_children_passed_at_the_call_site() { + let report = realize(SLOTTED, &serde_json::json!({}), RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + let root = report.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "TextRow", &mut rows); + assert_eq!( + rows.len(), + 1, + "the child passed at the call site must appear" + ); + let mut rules = Vec::new(); + find(&root, "Rule", &mut rules); + assert_eq!(rules.len(), 1); +} + +/// Profile §5.7: an instance that disappears loses its state. Without pruning +/// the store grows for the life of the app and a key that reappears inherits a +/// stale value. +#[test] +fn unmounted_instances_are_pruned() { + let mut store = splash_core::ui_l0::InstanceStore::default(); + let two = serde_json::json!({ "items": [{"id":"a","name":"A"},{"id":"b","name":"B"}] }); + + let report = + splash_core::ui_l0::realize_with_state(TWO_ROWS, &two, &store, RealizeLimits::default()); + let root = report.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + for row in &rows { + splash_core::ui_l0::dispatch(TWO_ROWS, &mut store, &row.key, "flip"); + } + assert_eq!(store.len(), 2, "both rows hold state"); + + // `b` goes away. + let one = serde_json::json!({ "items": [{"id":"a","name":"A"}] }); + let report = + splash_core::ui_l0::realize_with_state(TWO_ROWS, &one, &store, RealizeLimits::default()); + store.prune(&report.live_keys); + assert_eq!(store.len(), 1, "the departed instance's cell is dropped"); +} + +/// Formatting is the runtime's job, not the card's. A card that wrote "$" or +/// "41.2M" itself would be authoring a fact — and currency, grouping and +/// compaction are locale-dependent besides. +#[test] +fn declared_formats_are_applied_by_the_runtime() { + let data = serde_json::json!({ + "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}, + "series": {"points":[1,2],"min":180.2,"max":185.6}, + "copy": {"movers":"Top Movers","open":"Open","high":"High","low":"Low", + "volume":"Volume","mktcap":"Mkt Cap","pe":"P/E"} + }); + let report = realize(STOCK, &data, RealizeLimits::default()); + let dsl = makepad::lower(&report.root.expect("root")); + + for (expect, why) in [ + ("$184.20", "money gains a currency symbol and two places"), + ("+1.7%", "a signed percentage keeps its sign"), + ("41.2M", "compact scales volume"), + ("4.5T", "compact scales market cap"), + ("58.3", "a ratio is plain"), + ] { + assert!(dsl.contains(expect), "{why}: {expect} missing from\n{dsl}"); + } + assert!( + !dsl.contains("41200000"), + "the raw number should not survive" + ); +} + +/// A float hour is a time, not a decimal: 18.9 is 18:54. +#[test] +fn a_time_format_renders_as_a_clock() { + let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); + let dsl = makepad::lower(&report.root.unwrap()); + assert!( + dsl.contains("5:06"), + "sunrise 5.1 should render 5:06:\n{dsl}" + ); +} From 17974a980960dcdfa7f5f624b612d7fed056ecc2 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:32:59 -0700 Subject: [PATCH 10/63] docs: bring the spec current with what was built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/grammar.md | 18 ++++++++++++------ docs/roadmap.md | 43 ++++++++++++++++++++++++++++--------------- docs/ui-profile-l0.md | 30 +++++++++++++++++++++--------- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/docs/grammar.md b/docs/grammar.md index 4f9103d..4b42539 100644 --- a/docs/grammar.md +++ b/docs/grammar.md @@ -244,12 +244,18 @@ the compatibility APIs that accept them must not receive generated source — so a host wanting LLM-authored UI has no supported path and must embed the raw Makepad VM, which restores the very bindings this profile masks. -[UI Profile Level 0](ui-profile-l0.md) is a **proposed** third path: a sibling -canonical profile that admits UI constructs while admitting nothing that can -reach a capability. Sources are queries the runtime resolves before evaluation -and injects as data, so an L0 evaluator receives a host surface with no modules -at all. It is not implemented, and until it is, generated UI remains outside -every supported entry point. +[UI Profile Level 0](ui-profile-l0.md) is that third path: a sibling canonical +profile that admits UI constructs while admitting nothing which can reach a +capability. Sources are queries the runtime resolves before realization and +injects as data. + +It is **implemented** — `splash_core::ui_l0::check_ui_l0` accepts or rejects, +and `realize` produces a renderer-neutral node tree. Note what that entry point +does *not* do: L0 has no expression form, so there is nothing to evaluate and +realization never enters the VM. Authority confinement is therefore structural +in the strongest sense available — not a sandbox blocking calls, and not an +evaluator holding an empty module table, but no execution machinery in the path +at all. ## Canonical Workflow Source diff --git a/docs/roadmap.md b/docs/roadmap.md index cd94e63..03ad9fe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -371,7 +371,7 @@ reviewed adapter's host-owned resolver; broader selectors must remain denied until each is enforced. -## Next: a canonical profile for generated UI +## Complete: a canonical profile for generated UI Splash has no supported path for LLM-authored UI. The canonical grammar rejects UI constructs; the compatibility APIs accept them but must not receive generated @@ -386,21 +386,34 @@ constructors, bindings, keyed loops, guarded branches and components with declared local state, with no expression form and no module access, evaluated against an empty host surface. Three reference cards exercise it. -Remaining before it can be implemented: +**Implemented** in `splash-core::ui_l0`: parser, validator, per-instance state store, +event dispatch, and a renderer-neutral realizer. 48 tests; the three reference cards are +accepted and the shipping nav card is rejected as L2; a card renders on a OnePlus 6T through +an unmodified downstream host. -- A normative AST and a parser/validator giving definite accept/reject. -- A level classifier over the transitive component closure. Local record - inspection is insufficient: a card can be moved to a wider level by a - component definition it references being replaced, so component versions must - be pinned. -- Lowering to a renderer-neutral node tree, and a host surface that installs no - modules. -- Component contracts — child-to-parent outputs, slots, shared state, - per-component source declarations, and mount/update/unmount semantics. -- Recursive instance identity, and a state-schema versioning rule for what - happens to live local state when a component definition changes. -- Enforcement of the five conditions that make an L0 realization terminate; - grammar membership alone does not bound execution. +Building it settled one claim in the profile's own favour and against its wording. L0 has no +expression form, so there is nothing to evaluate: realization never enters the VM, and the +implementation contains no reference to it. Authority confinement is structural in the +strongest sense available — no execution machinery in the path, rather than a sandbox or an +empty module table. + +Remaining: + +- **Component versions are not pinned.** The level classifier resolves over the + transitive closure, but a referenced component definition being replaced can + still widen a card's level without the card changing. +- **Live sources.** Realization takes injected data; nothing yet maps a `source` + declaration to the helper that answers it. That mapping is also what would end + the duplication of `sys.*` across four hosts, where every divergence so far + has produced a device-visible bug. +- **Reconciliation.** Realization rebuilds the tree; a state write does not yet + patch only its dependents. +- **Named slots**, and an explicit state migration so preservation across a + schema change is opt-in rather than impossible. +- **Termination** still rests on the five conditions in the profile's §6, of + which the implementation enforces two: component-graph acyclicity, and + iteration over a bound path. Collection length, node count and nesting depth + are traversal bounds the caller sets. ## Before a stable language release diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 6ca3869..aefe0c7 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -1,10 +1,12 @@ # Splash UI Profile — Level 0 -**Status:** proposed. Normative producer contract for generated UI source at Level 0. -**Relationship to [Grammar v0.2](grammar.md):** a *sibling* canonical profile, not a subset -of the workflow language. `check_syntax` rejects UI source today, and -`eval_vm_compatibility` accepts it but must not receive generated source — so there is -currently no supported path for LLM-authored UI. This profile is that path, at its narrowest. +**Status:** **implemented.** Normative producer contract for generated UI source at Level 0. +Parser, validator, realizer and instance store live in `splash-core::ui_l0`; 48 tests, three +reference cards, and a card rendered on a OnePlus 6T through an unmodified host. +**Relationship to [Grammar v0.2](grammar.md):** a *sibling* canonical profile, not a subset of +the workflow language. `check_syntax` rejects UI source and `eval_vm_compatibility` must not +receive generated source, so neither canonical entry point admits a generated card. This +profile is the third path, and `check_ui_l0` is its entry point. --- @@ -189,11 +191,17 @@ L0 therefore has no expression form in transitions. It has four **total forms**: | Form | Applies to | Semantics | |---|---|---| -| `set(v)` | any | assign a literal, a bound path, or the event payload `$value` | +| `set($value)` | any | the payload the raising element carried in its `value:` argument | +| `set(.token)` | `enum` | a declared member | +| `set("text")` | `text` | a literal | +| `set(path)` | any | a bound path, resolved from injected data | | `toggle` | `bool` | the other value | -| `cycle(a, b, …)` | `enum` | successor, wrapping; members must be declared in the shape | +| `cycle(.a, .b, …)` | `enum` | successor, wrapping; members must be declared in the shape | | `clear` | any | restore the declared `initial` | +A `set($value)` raised with no payload is a **no-op**, not a clear: falling back to the +initial would read as a deliberate reset. + Each is total on its declared shape and terminates in constant time. `units: cycle(c, f)` replaces the ternary; `expanded: toggle` replaces the negation. @@ -387,6 +395,10 @@ runtime needs defined behaviour, and identity (§5.1) decides all of it: | **Update** — identity persists, props differ | view re-realizes; **local state persists** | | **Unmount** — identity disappears | local state discarded | +Realization reports every instance key it produced. A host passes them to `prune` after each +pass; without it the store grows for the life of the app, and a key that reappears inherits a +stale value. + **Duplicate keys are an error at realization**, surfaced as a diagnostic — never coalesced. Silent coalescing would make two rows share one state cell, which is the failure mode keys exist to prevent. A missing key on an iteration is a grammar error. @@ -467,8 +479,8 @@ construct and keep the widest. | 1 | Whether `when` needs `else`, or whether two complementary guards suffice | | 2 | Whether `enum` shapes need ordering guarantees for `cycle` | | 3 | How a `source` declares refresh cadence, staleness and error surfaces | -| 4 | Whether the profile should admit a bounded `format(path, style)` for dates and units, or keep all formatting in the runtime | -| 5 | Named slots — deferred; nothing in the reference cards needs them and adding them is additive | +| ~~4~~ | ~~A bounded `format()`~~ — **settled**: `format:` is a declared token and the runtime applies it. A card that wrote `"$"` or `"41.2M"` itself would be authoring a fact, and currency, grouping and compaction are locale-dependent besides | +| 5 | Named slots — deferred; the single anonymous slot is implemented and nothing in the reference cards needs more | | 6 | An explicit state migration, to make preservation across a schema change opt-in rather than impossible (§5.8) | | 7 | The closed token sets per constructor argument that §2.2 depends on | From 7c3c87f3a2feb0f0cd3d10e84acd993859e056a3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:48:37 -0700 Subject: [PATCH 11/63] test(ui_l0): write the catalog-agreement test the docs claimed existed `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) --- crates/splash-core/tests/ui_profile_l0.rs | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 54e0746..68534f3 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -934,3 +934,61 @@ fn a_time_format_renders_as_a_clock() { "sunrise 5.1 should render 5:06:\n{dsl}" ); } + +// ──────────────────────────────────────────────────────────── catalog agreement ── + +/// The module doc claims a test asserts the Rust catalog and the TOML spec +/// agree. It said so for two commits before this test existed — a documented +/// check that was never written is worse than no check, because a reader stops +/// looking. +#[test] +fn the_rust_catalog_matches_the_toml_spec() { + const TOML: &str = include_str!("../../../docs/ui-l0-constructors.toml"); + + // Constructor headers are `[Name]`; `[kinds.x]` are shared token sets. + let documented: Vec<&str> = TOML + .lines() + .filter_map(|l| l.trim().strip_prefix('[')) + .filter_map(|l| l.strip_suffix(']')) + .filter(|n| !n.contains('.')) + .collect(); + + let implemented: Vec<&str> = catalog::CONSTRUCTORS.iter().map(|(n, _)| *n).collect(); + + for name in &documented { + assert!( + implemented.contains(name), + "{name:?} is in the TOML spec but not in the Rust catalog" + ); + } + for name in &implemented { + assert!( + documented.contains(name), + "{name:?} is in the Rust catalog but not in the TOML spec" + ); + } + + // Token sets must agree too, or a card legal by the spec is rejected by the + // implementation. + for (set_name, tokens) in [ + ("unit", catalog::UNIT), + ("format", catalog::FORMAT), + ("width", catalog::WIDTH), + ] { + let header = format!("[kinds.{set_name}]"); + let block = TOML + .split(&header) + .nth(1) + .unwrap_or_else(|| panic!("{header} missing from the TOML")); + let line = block + .lines() + .find(|l| l.trim_start().starts_with("tokens")) + .unwrap_or_else(|| panic!("{set_name} has no tokens line")); + for token in tokens { + assert!( + line.contains(&format!("\"{token}\"")), + "{set_name}: {token:?} is in the Rust catalog but not the TOML" + ); + } + } +} From 537a22e53500875b02889971851b902b2c97d801 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:01:33 -0700 Subject: [PATCH 12/63] feat(ui_l0): copy resolves from the card, by locale; suffix is applied 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) --- crates/splash-core/src/ui_l0.rs | 78 +++++++++++++++++++-- crates/splash-core/tests/fixtures/news.card | 5 +- crates/splash-core/tests/ui_profile_l0.rs | 46 ++++++++++++ 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index e8c89df..16af0ed 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -461,7 +461,11 @@ struct Card { sources: Vec, states: Vec, events: Vec, - copies: Vec, + /// `copy` declarations: name -> locale -> text. Authored in the ledger, so + /// they resolve from the CARD rather than the injected data. Carrying them + /// in the data blob duplicated what the card already said, and the two + /// silently drifted. + copies: Vec<(String, Vec<(String, String)>)>, components: Vec, views: Vec, } @@ -687,9 +691,8 @@ impl<'a> Parser<'a> { "copy" => { self.at += 1; if let Some(n) = self.ident() { - card.copies.push(n); + card.copies.push((n, self.parse_locale_map())); } - self.skip_braced(); } "component" => { self.at += 1; @@ -857,6 +860,36 @@ impl<'a> Parser<'a> { }) } + /// `{ class: vocabulary, en: "…", zh: "…" }` — the text per locale. + fn parse_locale_map(&mut self) -> Vec<(String, String)> { + let mut out = Vec::new(); + if !self.eat_punct("{") { + return out; + } + let mut guard = 0usize; + while let Some(t) = self.peek().cloned() { + guard += 1; + if guard > 64 || t.is_punct("}") { + break; + } + if t.kind == Kind::Ident + && self + .tokens + .get(self.at + 1) + .is_some_and(|n| n.is_punct(":")) + { + if let Some(v) = self.tokens.get(self.at + 2) { + if v.kind == Kind::Str { + out.push((t.text.clone(), v.text.clone())); + } + } + } + self.at += 1; + } + self.expect_punct("}"); + out + } + /// `event name { path: total-form, … }` fn parse_event(&mut self) -> Option { let name = self.ident()?; @@ -1996,7 +2029,11 @@ fn realize_inner( .unwrap_or_else(|| initial_for(&state.shape)); frames.push((state.path.clone(), value)); } - let mut scope = ValueScope { frames, data }; + let mut scope = ValueScope { + frames, + data, + copies: &card.copies, + }; let mut out = Vec::new(); ctx.element(&root.body, &mut scope, "root", &mut out); @@ -2016,6 +2053,7 @@ fn realize_inner( struct ValueScope<'a> { frames: Vec<(String, crate::JsonValue)>, data: &'a crate::JsonValue, + copies: &'a [(String, Vec<(String, String)>)], } impl ValueScope<'_> { @@ -2023,6 +2061,29 @@ impl ValueScope<'_> { let mut segments = path.split('.'); let root = segments.next()?; + // `copy.` is authored in the ledger, not fetched. Resolve it from + // the card's declarations, choosing the language the runtime reported + // and falling back to English — a card that ships zh text should not + // render an em dash because the device is in en. + if root == "copy" { + let name = segments.next()?; + let entry = self.copies.iter().find(|(n, _)| n == name)?; + let lang = self + .data + .get("env") + .and_then(|e| e.get("locale")) + .and_then(|l| l.get("lang")) + .and_then(|v| v.as_str()) + .unwrap_or("en"); + let text = entry + .1 + .iter() + .find(|(k, _)| k == lang) + .or_else(|| entry.1.iter().find(|(k, _)| k == "en")) + .or_else(|| entry.1.iter().find(|(k, _)| k != "class"))?; + return Some(crate::JsonValue::String(text.1.clone())); + } + let mut current = match self.frames.iter().rev().find(|(n, _)| n == root) { Some((_, v)) => v.clone(), None => self.data.get(root)?.clone(), @@ -2482,6 +2543,13 @@ pub mod makepad { Some(NodeValue::Text(g)) => g.clone(), _ => String::new(), }; + // `suffix` is a declared unit word — "412 pts", not "412". It was in the + // catalog and ignored here, so every score and comment count on the news + // card rendered as a bare number. + let suffix = match arg(node, "suffix") { + Some(NodeValue::Text(t)) => format!(" {t}"), + _ => String::new(), + }; let format_kind = match arg(node, "format") { Some(NodeValue::Token(t)) => Some(t.clone()), _ => None, @@ -2495,7 +2563,7 @@ pub mod makepad { (NodeValue::Text(s), _) => s.clone(), _ => String::new(), }; - format!("{:?}", format!("{glyph}{body}{unit}")) + format!("{:?}", format!("{glyph}{body}{unit}{suffix}")) } None => text_of(arg(node, "text")), } diff --git a/crates/splash-core/tests/fixtures/news.card b/crates/splash-core/tests/fixtures/news.card index 983e69d..ade95a5 100644 --- a/crates/splash-core/tests/fixtures/news.card +++ b/crates/splash-core/tests/fixtures/news.card @@ -25,7 +25,8 @@ state selected { shape: text, initial: "" } # "" ⇒ feed, else a story i event open_story { selected: set($value) } event back { selected: clear } -copy masthead { class: vocabulary, en: "Top Stories", zh: "头条" } +copy masthead { class: vocabulary, en: "Top Stories", zh: "头条" } +copy source { class: vocabulary, en: "HACKER NEWS", zh: "科技新闻" } copy latest { class: vocabulary, en: "LATEST", zh: "最新" } copy lead { class: vocabulary, en: "LEAD", zh: "焦点" } copy pts { class: vocabulary, en: "pts", zh: "分" } @@ -44,7 +45,7 @@ view stream Col { } view masthead Col { - TextCaption(text: copy.masthead) + TextCaption(text: copy.source) TextTitle(text: copy.masthead) } diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 68534f3..a1547b2 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -992,3 +992,49 @@ fn the_rust_catalog_matches_the_toml_spec() { } } } + +// ───────────────────────────────────────────────────────────────── copy locale ── + +/// `copy` is authored in the ledger, so it resolves from the CARD rather than +/// the injected data. Carrying it in both duplicated what the card already said, +/// and the two drifted — a card gained a `copy` the data blob lacked, and the +/// eyebrow rendered as an em dash on device. +#[test] +fn copy_resolves_from_the_card_not_the_data() { + let data = serde_json::json!({ + "lead": [{"id":"1","title":"T","author":"A","points":1.0,"comments":2.0,"url":"u"}], + "feed": [], "article": {}, "selected": "", + "env": {"locale": {"lang": "en"}} + // note: no "copy" key at all + }); + let report = realize(NEWS, &data, RealizeLimits::default()); + let dsl = makepad::lower(&report.root.expect("root")); + assert!( + dsl.contains("Top Stories"), + "card-declared copy should resolve:\n{dsl}" + ); + assert!( + dsl.contains("HACKER NEWS"), + "the eyebrow should resolve too" + ); +} + +/// The declared language is chosen when the runtime reports one. +#[test] +fn copy_selects_the_reported_locale() { + let mut data = serde_json::json!({ + "lead": [{"id":"1","title":"T","author":"A","points":1.0,"comments":2.0,"url":"u"}], + "feed": [], "article": {}, "selected": "", + "env": {"locale": {"lang": "zh"}} + }); + let dsl = makepad::lower(&realize(NEWS, &data, RealizeLimits::default()).root.unwrap()); + assert!(dsl.contains("头条"), "zh text should be selected:\n{dsl}"); + + // A card shipping zh text must not render an em dash on an en device. + data["env"]["locale"]["lang"] = serde_json::Value::String("fr".into()); + let dsl = makepad::lower(&realize(NEWS, &data, RealizeLimits::default()).root.unwrap()); + assert!( + dsl.contains("Top Stories"), + "an unknown locale falls back to en" + ); +} From 9e0c7f9608303534aa0327d18a9940e3bd233294 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:14:14 -0700 Subject: [PATCH 13/63] =?UTF-8?q?feat(ui=5Fl0):=20source=20plans=20?= =?UTF-8?q?=E2=80=94=20a=20card's=20data=20needs,=20ordered=20for=20a=20ho?= =?UTF-8?q?st=20to=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/splash-core/examples/source_plan.rs | 61 ++++++ crates/splash-core/src/ui_l0.rs | 240 +++++++++++++++++++-- crates/splash-core/tests/ui_profile_l0.rs | 83 +++++++ 3 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 crates/splash-core/examples/source_plan.rs diff --git a/crates/splash-core/examples/source_plan.rs b/crates/splash-core/examples/source_plan.rs new file mode 100644 index 0000000..a1cb3ff --- /dev/null +++ b/crates/splash-core/examples/source_plan.rs @@ -0,0 +1,61 @@ +//! Print a card's source plan as JSON, for a host to fulfil. +//! +//! Usage: source_plan +//! +//! splash-core says WHAT to fetch and in what order; it never fetches. Only a +//! host knows what answers `sys.weather`. + +use splash_core::ui_l0::{source_plan, SourceArg}; + +fn arg_json(a: &SourceArg) -> String { + match a { + SourceArg::Path(p) => format!(r#"{{"kind":"path","value":{p:?}}}"#), + SourceArg::Text(t) => format!(r#"{{"kind":"text","value":{t:?}}}"#), + SourceArg::Number(n) => format!(r#"{{"kind":"number","value":{n}}}"#), + SourceArg::List(items) => format!( + r#"{{"kind":"list","value":[{}]}}"#, + items + .iter() + .map(|i| format!("{i:?}")) + .collect::>() + .join(",") + ), + } +} + +fn main() { + let path = std::env::args().nth(1).expect("usage: source_plan "); + let plan = source_plan(&std::fs::read_to_string(&path).expect("card")); + + let requests: Vec = plan + .requests + .iter() + .map(|r| { + let args: Vec = r + .args + .iter() + .map(|(n, a)| format!("[{n:?},{}]", arg_json(a))) + .collect(); + let deps: Vec = r.depends_on.iter().map(|d| format!("{d:?}")).collect(); + format!( + r#"{{"name":{:?},"helper":{:?},"args":[{}],"depends_on":[{}]}}"#, + r.name, + r.helper, + args.join(","), + deps.join(",") + ) + }) + .collect(); + + let diags: Vec = plan + .diagnostics + .iter() + .map(|d| format!(r#"{{"line":{},"message":{:?}}}"#, d.line, d.message)) + .collect(); + + println!( + r#"{{"requests":[{}],"diagnostics":[{}]}}"#, + requests.join(","), + diags.join(",") + ); +} diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 16af0ed..b9b479f 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -458,7 +458,7 @@ fn classify_beyond_l0(tokens: &[Token]) -> Option<(Level, SyntaxDiagnostic)> { #[derive(Debug, Default)] struct Card { - sources: Vec, + sources: Vec, states: Vec, events: Vec, /// `copy` declarations: name -> locale -> text. Authored in the ledger, so @@ -470,6 +470,28 @@ struct Card { views: Vec, } +/// A declared capability dependency: the name it binds, the helper that answers +/// it, and the arguments. Parsed rather than skipped — a card that declares what +/// data it needs is useless if nothing can read the declaration. +#[derive(Clone, Debug)] +struct SourceDecl { + name: String, + helper: String, + args: Vec<(String, SourceArg)>, + line: usize, +} + +/// One argument to a capability query. None of these is computed. +#[derive(Clone, Debug, PartialEq)] +pub enum SourceArg { + /// A path into another source, card state, or a declared value. + Path(String), + Text(String), + Number(f64), + /// `fields: [temp, hi, lo]` — which fields the card needs back. + List(Vec), +} + #[derive(Clone, Debug, Eq, PartialEq)] enum Shape { Bool, @@ -671,10 +693,16 @@ impl<'a> Parser<'a> { match t.text.as_str() { "source" => { self.at += 1; - if let Some(p) = self.dotted_name() { - card.sources.push(p); + let line = t.line; + if let Some(name) = self.dotted_name() { + let (helper, args) = self.parse_source_query(); + card.sources.push(SourceDecl { + name, + helper, + args, + line, + }); } - self.skip_source_query(); } "state" => { self.at += 1; @@ -763,17 +791,83 @@ impl<'a> Parser<'a> { /// A source body is a capability query the runtime executes. It is not /// evaluated here and its shape is the runtime's contract, so it is skipped /// to the end of its balanced parentheses. - fn skip_source_query(&mut self) { - // name - while self - .peek() - .is_some_and(|t| t.kind == Kind::Ident || t.is_punct(".")) - { - self.at += 1; + /// `sys.weather(lat: place.lat, fields: [temp, hi])` — the helper and its + /// arguments. The body is a query the RUNTIME executes; the view can only + /// bind to its result, which is what keeps the host surface empty. + fn parse_source_query(&mut self) -> (String, Vec<(String, SourceArg)>) { + let mut helper = String::new(); + while let Some(t) = self.peek().cloned() { + if t.kind == Kind::Ident || t.is_punct(".") { + helper.push_str(&t.text); + self.at += 1; + } else { + break; + } } - if self.peek().is_some_and(|t| t.is_punct("(")) { - self.skip_balanced("(", ")"); + + let mut args = Vec::new(); + if !self.eat_punct("(") { + return (helper, args); + } + let mut guard = 0usize; + loop { + guard += 1; + if guard > 64 || self.peek().is_none() { + break; + } + if self.eat_punct(")") { + break; + } + let Some(name) = self.ident() else { + self.at += 1; + continue; + }; + if !self.eat_punct(":") { + break; + } + let value = match self.peek().cloned() { + Some(t) if t.is_punct("[") => { + self.at += 1; + let mut items = Vec::new(); + while let Some(i) = self.peek().cloned() { + if i.is_punct("]") { + break; + } + if matches!(i.kind, Kind::Ident | Kind::Str | Kind::Num) { + items.push(i.text.clone()); + } + self.at += 1; + } + self.expect_punct("]"); + SourceArg::List(items) + } + Some(t) if t.kind == Kind::Str => { + self.at += 1; + SourceArg::Text(t.text) + } + Some(t) if t.kind == Kind::Num => { + self.at += 1; + SourceArg::Number(t.text.parse().unwrap_or(0.0)) + } + Some(t) if t.kind == Kind::Token => { + self.at += 1; + SourceArg::Text(t.text.trim_start_matches('.').to_string()) + } + Some(t) if t.kind == Kind::Ident => { + SourceArg::Path(self.dotted_name().unwrap_or(t.text)) + } + _ => { + self.at += 1; + continue; + } + }; + args.push((name, value)); + if !self.eat_punct(",") { + self.expect_punct(")"); + break; + } } + (helper, args) } fn skip_braced(&mut self) { @@ -1668,7 +1762,7 @@ struct Scope { impl Scope { fn card(card: &Card) -> Self { let mut roots: Vec = Vec::new(); - roots.extend(card.sources.iter().map(root_of)); + roots.extend(card.sources.iter().map(|s| root_of(&s.name))); roots.extend(card.states.iter().map(|s| root_of(&s.path))); roots.push("copy".into()); let events = card.events.iter().map(|e| e.name.clone()).collect(); @@ -1681,7 +1775,7 @@ impl Scope { fn component(card: &Card, component: &Component) -> Self { let mut roots: Vec = Vec::new(); - roots.extend(card.sources.iter().map(root_of)); + roots.extend(card.sources.iter().map(|s| root_of(&s.name))); roots.push("copy".into()); roots.extend(component.params.iter().cloned()); roots.extend(component.states.iter().map(|s| root_of(&s.path))); @@ -3039,3 +3133,119 @@ pub fn dispatch_with( } applied } + +// ───────────────────────────────────────────────────────────────── source plan ── + +/// One capability query for the host to answer, with the name its result binds. +#[derive(Clone, Debug)] +pub struct SourceRequest { + /// The name the card binds the result to — `now`, `week`, `env.locale`. + pub name: String, + /// The helper that answers it — `sys.weather`. Resolving this to an actual + /// function is the HOST's job; splash-core never calls anything. + pub helper: String, + pub args: Vec<(String, SourceArg)>, + /// Names of other sources this one reads. The host must resolve those first. + pub depends_on: Vec, +} + +/// What a card needs fetched, in an order that satisfies its dependencies. +#[derive(Clone, Debug, Default)] +pub struct SourcePlan { + pub requests: Vec, + pub diagnostics: Vec, +} + +/// Read a card's `source` declarations and order them for resolution. +/// +/// Sources form a DAG: `source now sys.weather(lat: place.lat, …)` cannot be +/// fetched before `place`. The order is topological, so a host can walk the list +/// and always have what the next request refers to. +/// +/// This returns *what to fetch*, never fetches it. The card names a helper; only +/// the host knows what answers it. That separation is what lets realization run +/// against an empty host surface. +pub fn source_plan(source: &str) -> SourcePlan { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return SourcePlan { + requests: Vec::new(), + diagnostics: sink.items, + }; + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + + let declared: Vec<&str> = card.sources.iter().map(|s| s.name.as_str()).collect(); + + let mut pending: Vec = card + .sources + .iter() + .map(|decl| { + let mut depends_on: Vec = decl + .args + .iter() + .filter_map(|(_, arg)| match arg { + SourceArg::Path(p) => { + let root = root_of(p); + // Only another SOURCE is a fetch dependency. A path into + // card state is available before any fetch begins. + declared.iter().find(|d| **d == root).map(|_| root) + } + _ => None, + }) + .collect(); + depends_on.sort(); + depends_on.dedup(); + SourceRequest { + name: decl.name.clone(), + helper: decl.helper.clone(), + args: decl.args.clone(), + depends_on, + } + }) + .collect(); + + // Topological order. 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. + let mut ordered: Vec = Vec::new(); + let mut done: Vec = Vec::new(); + while !pending.is_empty() { + let ready: Vec = pending + .iter() + .enumerate() + .filter(|(_, r)| r.depends_on.iter().all(|d| done.iter().any(|n| n == d))) + .map(|(i, _)| i) + .collect(); + + if ready.is_empty() { + for stuck in &pending { + sink.push( + card.sources + .iter() + .find(|d| d.name == stuck.name) + .map(|d| d.line) + .unwrap_or(0), + 1, + format!( + "source {:?} is in a dependency cycle with {:?}", + stuck.name, + stuck.depends_on.join(", ") + ), + ); + } + break; + } + + for i in ready.into_iter().rev() { + let request = pending.remove(i); + done.push(request.name.clone()); + ordered.push(request); + } + } + + SourcePlan { + requests: ordered, + diagnostics: sink.items, + } +} diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index a1547b2..3f85ecc 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -1038,3 +1038,86 @@ fn copy_selects_the_reported_locale() { "an unknown locale falls back to en" ); } + +// ───────────────────────────────────────────────────────────────── source plan ── + +use splash_core::ui_l0::{source_plan, SourceArg}; + +/// A card declares what data it needs; the plan is what a host fetches. Without +/// it the declaration was inert — the parser skipped the helper and every +/// argument, so nothing could act on `source now sys.weather(…)`. +#[test] +fn the_plan_reports_helpers_and_arguments() { + let plan = source_plan(WEATHER); + assert!(plan.diagnostics.is_empty(), "{:#?}", plan.diagnostics); + + let now = plan.requests.iter().find(|r| r.name == "now").expect("now"); + assert_eq!(now.helper, "sys.weather"); + + let (_, lat) = now.args.iter().find(|(n, _)| n == "lat").expect("lat"); + assert_eq!(*lat, SourceArg::Path("place.lat".into())); + + let (_, fields) = now + .args + .iter() + .find(|(n, _)| n == "fields") + .expect("fields"); + match fields { + SourceArg::List(items) => { + assert!(items.contains(&"temp".to_string()), "{items:?}"); + assert!(items.contains(&"humidity".to_string()), "{items:?}"); + } + other => panic!("fields should be a list, got {other:?}"), + } +} + +/// `now` reads `place.lat`, so `place` must be fetched first. A host walking the +/// list in order always has what the next request refers to. +#[test] +fn the_plan_orders_dependencies_before_dependents() { + let plan = source_plan(WEATHER); + let at = |name: &str| plan.requests.iter().position(|r| r.name == name).unwrap(); + assert!(at("place") < at("now"), "place must precede now"); + assert!(at("place") < at("week"), "place must precede week"); + assert!(at("place") < at("aqi"), "place must precede aqi"); + + let now = &plan.requests[at("now")]; + assert_eq!(now.depends_on, vec!["place".to_string()]); + + // A source reading only card state depends on no other fetch. + let place = &plan.requests[at("place")]; + assert!(place.depends_on.is_empty(), "{:?}", place.depends_on); +} + +/// Two sources that read each other cannot both go first. Picking one silently +/// would fetch against a value that does not exist yet. +#[test] +fn a_dependency_cycle_is_reported_rather_than_resolved() { + let plan = + source_plan("source a sys.one(x: b.value)\nsource b sys.two(y: a.value)\nview root Panel"); + assert!( + plan.diagnostics.iter().any(|d| d.message.contains("cycle")), + "{:#?}", + plan.diagnostics + ); +} + +#[test] +fn every_reference_card_yields_a_resolvable_plan() { + for (name, card) in [("weather", WEATHER), ("news", NEWS), ("stock", STOCK)] { + let plan = source_plan(card); + assert!( + plan.diagnostics.is_empty(), + "{name}: {:#?}", + plan.diagnostics + ); + assert!(!plan.requests.is_empty(), "{name} declares no sources"); + for request in &plan.requests { + assert!( + request.helper.starts_with("sys."), + "{name}: {:?} has no helper", + request.name + ); + } + } +} From 39b5864a8a54843f6efca489208f1394927776b7 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:25:42 -0700 Subject: [PATCH 14/63] =?UTF-8?q?feat(ui=5Fl0):=20reconciliation=20?= =?UTF-8?q?=E2=80=94=20which=20records=20a=20state=20write=20actually=20to?= =?UTF-8?q?uches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/splash-core/examples/dirty_set.rs | 38 ++++ crates/splash-core/src/ui_l0.rs | 213 ++++++++++++++++++++++ crates/splash-core/tests/ui_profile_l0.rs | 139 ++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 crates/splash-core/examples/dirty_set.rs diff --git a/crates/splash-core/examples/dirty_set.rs b/crates/splash-core/examples/dirty_set.rs new file mode 100644 index 0000000..ed65217 --- /dev/null +++ b/crates/splash-core/examples/dirty_set.rs @@ -0,0 +1,38 @@ +//! How much of a card a state write actually touches. +//! +//! Usage: dirty_set … + +use splash_core::ui_l0::{dirty_records, patch_points, record_dependencies}; + +fn main() { + let mut a = std::env::args().skip(1); + let path = a.next().expect("usage: dirty_set …"); + let changed: Vec = a.collect(); + let card = std::fs::read_to_string(&path).expect("card"); + + let all = record_dependencies(&card); + println!("{} records", all.len()); + for d in &all { + println!(" {:<14} reads {}", d.record, d.reads.join(", ")); + } + for c in &changed { + let dirty = dirty_records(&card, &[c.as_str()]); + println!( + "\nwriting {c:?} dirties {}/{} records: {}", + dirty.len(), + all.len(), + dirty.join(", ") + ); + let minimal = patch_points(&card, &[c.as_str()]); + println!( + " minimal patch points: {}/{} -> {}", + minimal.len(), + all.len(), + if minimal.is_empty() { + "(none)".into() + } else { + minimal.join(", ") + } + ); + } +} diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index b9b479f..eecde0f 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -3249,3 +3249,216 @@ pub fn source_plan(source: &str) -> SourcePlan { diagnostics: sink.items, } } + +// ─────────────────────────────────────────────────────────────── reconciliation ── + +/// What one record reads, so a state write can be confined to its dependents. +#[derive(Clone, Debug)] +pub struct RecordDeps { + /// A `view` or `component` name. + pub record: String, + /// Roots it reads, transitively through referenced views and instantiated + /// components — `now`, `units`, `week`. + pub reads: Vec, +} + +/// What each record reads. +/// +/// 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 between this and the signal +/// graphs a dynamic language requires — there is no branch whose reads depend on +/// a value. +/// +/// The set is transitive and deliberately **over**-approximate at the edges: a +/// record inherits everything the views it splices and the components it +/// instantiates read. Over-approximating re-realizes too much; under- +/// approximating shows stale data, and only one of those is a correctness bug. +pub fn record_dependencies(source: &str) -> Vec { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return Vec::new(); + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + + // Direct reads, plus the records each one pulls in. + let mut direct: Vec<(String, Vec, Vec)> = Vec::new(); + for view in &card.views { + let (reads, pulls) = scan_element(&view.body); + direct.push((view.name.clone(), reads, pulls)); + } + for component in &card.components { + let (mut reads, pulls) = scan_element(&component.body); + // A prop is supplied by the caller, so it is not a dependency of its own. + reads.retain(|r| !component.params.contains(r)); + // Local state is a dependency: writing it must re-realize this instance. + for state in &component.states { + reads.push(root_of(&state.path)); + } + direct.push((component.name.clone(), reads, pulls)); + } + + // Close over references. Bounded by record count, so a reference cycle + // terminates rather than spinning. + let mut out: Vec = Vec::new(); + for (name, _, _) in &direct { + let mut reads: Vec = Vec::new(); + let mut queue = vec![name.clone()]; + let mut seen: Vec = Vec::new(); + let mut steps = 0usize; + while let Some(current) = queue.pop() { + steps += 1; + if steps > MAX_UI_L0_DECLARATIONS || seen.contains(¤t) { + continue; + } + seen.push(current.clone()); + if let Some((_, r, pulls)) = direct.iter().find(|(n, _, _)| *n == current) { + reads.extend(r.iter().cloned()); + queue.extend(pulls.iter().cloned()); + } + } + reads.sort(); + reads.dedup(); + out.push(RecordDeps { + record: name.clone(), + reads, + }); + } + out +} + +/// The smallest set of records a backend must re-realize. +/// +/// [`dirty_records`] is transitive, so `root` appears for almost any write — it +/// splices every other record, so it reads everything they read. Patching root +/// would rebuild the tree, which is the behaviour reconciliation exists to +/// avoid. A record is a **patch point** only when its OWN bindings read a +/// changed path; an ancestor that merely contains it is handled by patching the +/// descendant. +pub fn patch_points(source: &str, changed: &[&str]) -> Vec { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return Vec::new(); + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + let roots: Vec = changed.iter().map(root_of).collect(); + + // A source reading changed state must be re-fetched, and anything binding + // its result is then stale. + let mut invalidated: Vec = roots.clone(); + let mut steps = 0usize; + loop { + steps += 1; + if steps > MAX_UI_L0_DECLARATIONS { + break; + } + let before = invalidated.len(); + for decl in &card.sources { + let reads_changed = decl.args.iter().any(|(_, arg)| match arg { + SourceArg::Path(p) => { + // `state.city` names the state directly; `place.lat` names + // another source. + let mut parts = p.split('.'); + let head = parts.next().unwrap_or_default(); + let target = if head == "state" { + parts.next().unwrap_or_default() + } else { + head + }; + invalidated.iter().any(|i| i == target) + } + _ => false, + }); + if reads_changed && !invalidated.contains(&decl.name) { + invalidated.push(decl.name.clone()); + } + } + if invalidated.len() == before { + break; + } + } + + let mut out = Vec::new(); + for view in &card.views { + let (reads, _) = scan_element(&view.body); + if reads.iter().any(|r| invalidated.iter().any(|i| i == r)) { + out.push(view.name.clone()); + } + } + for component in &card.components { + let (mut reads, _) = scan_element(&component.body); + reads.retain(|r| !component.params.contains(r)); + for state in &component.states { + reads.push(root_of(&state.path)); + } + if reads.iter().any(|r| invalidated.iter().any(|i| i == r)) { + out.push(component.name.clone()); + } + } + out +} + +/// Which records must re-realize when these paths change. +/// +/// The measurable claim behind "one event, one state change, one reconciliation +/// pass": toggling a unit should touch the records that read it, not the tree. +pub fn dirty_records(source: &str, changed: &[&str]) -> Vec { + let roots: Vec = changed.iter().map(root_of).collect(); + record_dependencies(source) + .into_iter() + .filter(|d| d.reads.iter().any(|r| roots.iter().any(|c| c == r))) + .map(|d| d.record) + .collect() +} + +/// Paths read directly by an element tree, and the records it pulls in. +fn scan_element(element: &Element) -> (Vec, Vec) { + let mut reads = Vec::new(); + let mut pulls = Vec::new(); + collect_reads(element, &mut reads, &mut pulls); + reads.sort(); + reads.dedup(); + pulls.sort(); + pulls.dedup(); + (reads, pulls) +} + +fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec) { + // A loop binder shadows: `for d in week` makes `d` local, not a dependency. + let binders = &element.binders; + + for arg in &element.args { + match &arg.value { + Operand::Path(p) | Operand::Predicate(p) => { + let root = root_of(p); + if !binders.contains(&root) { + reads.push(root); + } + } + _ => {} + } + } + if let Some(Operand::Path(p)) = element.rhs.as_ref() { + reads.push(root_of(p)); + } + + if element.is_reference || !element.name.is_empty() { + // A referenced view or an instantiated component contributes its own + // reads; a plain constructor is not a record and contributes nothing. + let n = &element.name; + if !n.is_empty() && n != "for" && n != "when" && n != "slot" { + pulls.push(n.clone()); + } + } + + for child in &element.children { + let mut child_reads = Vec::new(); + collect_reads(child, &mut child_reads, pulls); + // Binders introduced here shadow inside this subtree only. + for r in child_reads { + if !binders.contains(&r) { + reads.push(r); + } + } + } +} diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 3f85ecc..1bdf2b9 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -1121,3 +1121,142 @@ fn every_reference_card_yields_a_resolvable_plan() { } } } + +// ─────────────────────────────────────────────────────────────── reconciliation ── + +use splash_core::ui_l0::{dirty_records, record_dependencies}; + +/// L0 needs no dynamic read-tracking: no expression form means every binding is +/// structurally visible, so the dependency set is computable without evaluating +/// anything. +#[test] +fn dependencies_are_computed_without_evaluation() { + let deps = record_dependencies(WEATHER); + let of = |name: &str| { + deps.iter() + .find(|d| d.record == name) + .unwrap_or_else(|| panic!("no deps for {name}")) + .reads + .clone() + }; + + assert!( + of("current").contains(&"now".to_string()), + "{:?}", + of("current") + ); + assert!(of("current").contains(&"units".to_string())); + assert!(of("details").contains(&"now".to_string())); + // The forecast reads the week and, through ForecastRow, the bounds. + assert!( + of("forecast").contains(&"week".to_string()), + "{:?}", + of("forecast") + ); + // `sunmoon` reads sun and moon, and must NOT read the stock of the card. + let sunmoon = of("sunmoon"); + assert!(sunmoon.contains(&"sun".to_string()) && sunmoon.contains(&"moon".to_string())); + assert!(!sunmoon.contains(&"week".to_string()), "{sunmoon:?}"); +} + +/// A loop binder is local. `for d in week` must not make `d` a dependency, or +/// every record with a loop would look like it reads everything. +#[test] +fn a_loop_binder_is_not_a_dependency() { + let deps = record_dependencies(WEATHER); + let forecast = &deps.iter().find(|d| d.record == "forecast").unwrap().reads; + assert!(!forecast.contains(&"d".to_string()), "{forecast:?}"); +} + +/// The measurable claim behind "one event, one state change, one reconciliation +/// pass" — toggling a unit touches the records that read it, not the tree. +#[test] +fn a_state_write_dirties_only_its_dependents() { + let all: Vec = record_dependencies(WEATHER) + .into_iter() + .map(|d| d.record) + .collect(); + let dirty = dirty_records(WEATHER, &["units"]); + + assert!(!dirty.is_empty(), "toggling units must dirty something"); + assert!( + dirty.len() < all.len(), + "it must not dirty everything: {dirty:?}" + ); + + // The sun/moon panel does not read units and must survive untouched — it is + // the panel whose rebuild would be most visible. + assert!(!dirty.contains(&"sunmoon".to_string()), "{dirty:?}"); + assert!(!dirty.contains(&"airfield".to_string()), "{dirty:?}"); + // The hero and the detail tiles do read it. + assert!(dirty.contains(&"current".to_string()), "{dirty:?}"); + assert!(dirty.contains(&"details".to_string()), "{dirty:?}"); +} + +/// A component's own state dirties that component, so an expanded row +/// re-realizes and its neighbours do not. +#[test] +fn component_local_state_dirties_its_component() { + let dirty = dirty_records(WEATHER, &["expanded"]); + assert!( + dirty.contains(&"ForecastRow".to_string()), + "local state must dirty its component: {dirty:?}" + ); + assert!(!dirty.contains(&"details".to_string()), "{dirty:?}"); +} + +/// Over-approximation is the safe direction: re-realizing too much is slow, +/// re-realizing too little shows stale data. +#[test] +fn an_unrelated_write_dirties_nothing() { + assert!(dirty_records(WEATHER, &["nothing_reads_this"]).is_empty()); +} + +use splash_core::ui_l0::patch_points; + +/// `dirty_records` is transitive, so `root` appears for almost any write — it +/// splices everything, so it reads everything. Patching root rebuilds the tree, +/// which is what reconciliation exists to avoid. +#[test] +fn patch_points_exclude_ancestors_that_merely_contain_the_change() { + let dirty = dirty_records(WEATHER, &["units"]); + let minimal = patch_points(WEATHER, &["units"]); + assert!( + dirty.contains(&"root".to_string()), + "root is transitively dirty" + ); + assert!( + !minimal.contains(&"root".to_string()), + "but patching a descendant suffices: {minimal:?}" + ); + assert!(minimal.len() < dirty.len()); +} + +/// The claim, at its sharpest: toggling one row's own state re-realizes that +/// component and nothing else. +#[test] +fn local_state_patches_exactly_one_record() { + let minimal = patch_points(WEATHER, &["expanded"]); + assert_eq!(minimal, vec!["ForecastRow".to_string()], "{minimal:?}"); +} + +/// A write that a SOURCE reads invalidates the fetch, and everything binding its +/// result. Changing the city re-geocodes, so every source downstream of `place` +/// is stale — a genuine full rebuild, and the graph should say so rather than +/// reporting nothing. +#[test] +fn a_write_a_source_reads_invalidates_its_dependents() { + let minimal = patch_points(WEATHER, &["city"]); + for record in ["current", "forecast", "airfield", "sunmoon", "details"] { + assert!( + minimal.contains(&record.to_string()), + "changing the city must invalidate {record}: {minimal:?}" + ); + } +} + +/// Under-approximating shows stale data; over-approximating is merely slow. +#[test] +fn an_unrelated_write_patches_nothing() { + assert!(patch_points(WEATHER, &["nothing_reads_this"]).is_empty()); +} From 9136d21c5b3376ae8e99d65ab1562a0b1af1e7aa Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:26:57 -0700 Subject: [PATCH 15/63] L0: settle all seven open questions; named slots, source lifecycle, opt-in migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — `.$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. --- crates/splash-core/src/ui_l0.rs | 467 ++++++++++++++++++-- crates/splash-core/tests/ui_profile_l0.rs | 511 ++++++++++++++++++++++ docs/ui-profile-l0.md | 134 +++++- 3 files changed, 1071 insertions(+), 41 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index eecde0f..73acc7f 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -43,6 +43,16 @@ pub struct UiL0Report { pub level: Level, pub diagnostics: Vec, pub diagnostics_truncated: bool, + /// Profile §7: every component the card depends on, with a digest of its + /// definition, sorted by name. + /// + /// This is what a host pins. `level` is a judgment over the transitive + /// component closure, so it is only as durable as the definitions it was + /// computed from — replace one and an approved L0 card can silently become + /// L2. Storing this alongside the approved level makes that detectable: + /// a digest that moved means the level must be re-derived before the card + /// is accepted, rather than inherited from the record it no longer matches. + pub closure: Vec<(String, u64)>, } /// Diagnostics retained before truncation, so a pathological source cannot grow @@ -92,7 +102,80 @@ pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { } validate(&card, &mut sink); - sink.into_report(Level::L0) + let mut report = sink.into_report(Level::L0); + report.closure = component_closure(&card); + report +} + +/// A digest per component definition, sorted by name — profile §7. +/// +/// The digest covers everything that can move a card's level or change what it +/// renders: params, state, events and the whole view body. It deliberately does +/// NOT cover the component's position in the file, so reordering declarations is +/// not a version change. +fn component_closure(card: &Card) -> Vec<(String, u64)> { + let mut out: Vec<(String, u64)> = card + .components + .iter() + .map(|c| (c.name.clone(), definition_digest(c))) + .collect(); + out.sort(); + out +} + +fn definition_digest(component: &Component) -> u64 { + fn element(e: &Element, into: &mut String) { + into.push_str(&e.name); + into.push('('); + for b in &e.binders { + into.push_str(b); + into.push(','); + } + for a in &e.args { + into.push_str(&a.name); + into.push(':'); + into.push_str(&format!("{:?}", a.value)); + into.push(','); + } + if let Some(cmp) = &e.cmp { + into.push_str(cmp); + } + if let Some(rhs) = &e.rhs { + into.push_str(&format!("{rhs:?}")); + } + into.push('|'); + for child in &e.children { + element(child, into); + } + into.push(')'); + } + + let mut acc = String::new(); + acc.push_str(&component.name); + for p in &component.params { + acc.push_str(p); + acc.push(','); + } + for st in &component.states { + acc.push_str(&format!( + "{}:{:?}:{:?}:{}", + st.path, st.shape, st.initial, st.keep + )); + } + for ev in &component.events { + acc.push_str(&ev.name); + for t in &ev.transitions { + acc.push_str(&format!("{}={:?}{:?}", t.target, t.form, t.tokens)); + } + } + element(&component.body, &mut acc); + + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in acc.bytes() { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100_0000_01b3); + } + hash } // ─────────────────────────────────────────────────────────────────── diagnostics ── @@ -126,6 +209,7 @@ impl Diagnostics { fn into_report(self, level: Level) -> UiL0Report { UiL0Report { + closure: Vec::new(), valid: self.items.is_empty() && level == Level::L0, level, diagnostics: self.items, @@ -507,6 +591,11 @@ struct StateDecl { /// on a freshly-mounted state compares against null and takes the wrong /// branch on first render. initial: Option, + /// `keep: true` — profile §5.8. A schema change resets local state by + /// default; this opts one cell out, and even then only while its own shape + /// is unchanged. Preservation is never inferred from a shape that merely + /// looks compatible. + keep: bool, } /// An event and the transition batch it applies. Profile §3: an event names @@ -764,6 +853,24 @@ impl<'a> Parser<'a> { if guard > 32 { break; } + // `.$state` — the source lifecycle suffix (§5.9). It is the one + // segment that starts with a sigil, which is what keeps it from + // colliding with a field a source might actually return. + let next_is_status = self.peek().is_some_and(|t| t.is_punct(".")) + && self + .tokens + .get(self.at + 1) + .is_some_and(|t| t.is_punct("$")) + && self + .tokens + .get(self.at + 2) + .is_some_and(|t| t.kind == Kind::Ident && t.text == "state"); + if next_is_status { + self.at += 3; + name.push_str(".$state"); + continue; + } + let next_is_segment = self.peek().is_some_and(|t| t.is_punct(".")) && self .tokens @@ -897,6 +1004,7 @@ impl<'a> Parser<'a> { let path = self.dotted_name()?; let mut shape = Shape::Other; let mut initial = None; + let mut keep = false; // `{ shape: enum[a, b], initial: .a }` — capture the shape, skip the rest. let start = self.at; if self.peek().is_some_and(|t| t.is_punct("{")) { @@ -925,6 +1033,12 @@ impl<'a> Parser<'a> { }; } } + if t.is_kw("keep") && self.tokens.get(scan + 1).is_some_and(|n| n.is_punct(":")) { + keep = self + .tokens + .get(scan + 2) + .is_some_and(|v| v.kind == Kind::Ident && v.text == "true"); + } if t.is_kw("enum") { let mut members = Vec::new(); let mut j = scan + 1; @@ -951,6 +1065,7 @@ impl<'a> Parser<'a> { path, shape, initial, + keep, }) } @@ -1298,15 +1413,38 @@ impl<'a> Parser<'a> { return element; } - // `slot` + // `into name { … }` — a call site directing children at a named slot. + if t.is_kw("into") { + self.at += 1; + let mut element = Element { + name: "into".into(), + line: t.line, + column: t.column, + ..Default::default() + }; + if let Some(slot_name) = self.ident() { + element.binders.push(slot_name); + } + element.children = self.parse_block(); + return element; + } + + // `slot` or `slot name` — §5.5. The anonymous form is the common case; + // a name lets a component place two groups of children. if t.is_kw("slot") { self.at += 1; - return Element { + let mut element = Element { name: "slot".into(), line: t.line, column: t.column, ..Default::default() }; + if self.peek().is_some_and(|n| n.kind == Kind::Ident) { + if let Some(slot_name) = self.ident() { + element.binders.push(slot_name); + } + } + return element; } let Some(name_tok) = self.next() else { @@ -1441,6 +1579,39 @@ impl<'a> Parser<'a> { } } +/// Split a call site's children by the slot each targets. +/// +/// `into header { … }` groups the elements that follow into the named slot; +/// anything outside a group goes to the anonymous one. Keeping the default +/// unnamed means a component with one slot needs no ceremony. +/// The slot names a component declares. An anonymous `slot` is `""`. +fn slot_names(component: &Component) -> Vec { + fn walk(elements: &[Element], out: &mut Vec) { + for e in elements { + if e.name == "slot" { + out.push(e.binders.first().cloned().unwrap_or_default()); + } + walk(&e.children, out); + } + } + let mut out = Vec::new(); + walk(std::slice::from_ref(&component.body), &mut out); + out +} + +fn split_slots(children: &[Element]) -> Vec<(String, Vec)> { + let mut groups: Vec<(String, Vec)> = vec![(String::new(), Vec::new())]; + for child in children { + if child.name == "into" { + let name = child.binders.first().cloned().unwrap_or_default(); + groups.push((name, child.children.clone())); + } else { + groups[0].1.push(child.clone()); + } + } + groups +} + fn collect_constructors(e: &Element, out: &mut Vec) { if !e.name.is_empty() && e.name != "for" && e.name != "when" && e.name != "slot" { out.push(e.name.clone()); @@ -1644,13 +1815,44 @@ fn validate(card: &Card, sink: &mut Diagnostics) { /// events, which a component reaches only by receiving one as a prop (§5.4) — so /// the transition still runs in the scope that declared it. fn validate_transitions(card: &Card, sink: &mut Diagnostics) { - check_event_batch(&card.events, &card.states, sink); + // `set(path)` READS. Its operand must therefore be a readable name, which + // an event is not: `set(some_event)` parses, resolves to nothing and writes + // garbage into the cell. + let mut card_readable: Vec = card.sources.iter().map(|s| root_of(&s.name)).collect(); + card_readable.extend(card.states.iter().map(|s| root_of(&s.path))); + card_readable.push("copy".into()); + let card_events: Vec = card.events.iter().map(|e| e.name.clone()).collect(); + + check_event_batch( + &card.events, + &card.states, + &card_readable, + &card_events, + sink, + ); for component in &card.components { - check_event_batch(&component.events, &component.states, sink); + let mut readable: Vec = card.sources.iter().map(|s| root_of(&s.name)).collect(); + readable.push("copy".into()); + readable.extend(component.params.iter().cloned()); + readable.extend(component.states.iter().map(|s| root_of(&s.path))); + let events: Vec = component.events.iter().map(|e| e.name.clone()).collect(); + check_event_batch( + &component.events, + &component.states, + &readable, + &events, + sink, + ); } } -fn check_event_batch(events: &[EventDecl], states: &[StateDecl], sink: &mut Diagnostics) { +fn check_event_batch( + events: &[EventDecl], + states: &[StateDecl], + readable: &[String], + event_names: &[String], + sink: &mut Diagnostics, +) { for event in events { for transition in &event.transitions { let Some(state) = states.iter().find(|s| s.path == transition.target) else { @@ -1681,6 +1883,25 @@ fn check_event_batch(events: &[EventDecl], states: &[StateDecl], sink: &mut Diag transition.target ), ), + Form::Set(SetSource::Path(path)) => { + let root = root_of(path); + if event_names.contains(&root) { + sink.push( + transition.line, + transition.column, + format!( + "{root:?} is an event, not a value; `set` reads a name and \ + cannot trigger one (profile §6.4)" + ), + ); + } else if !readable.contains(&root) { + sink.push( + transition.line, + transition.column, + format!("{root:?} is not a readable name"), + ); + } + } Form::Set(_) => {} Form::Cycle => match &state.shape { Shape::Enum(members) => { @@ -1704,6 +1925,25 @@ fn check_event_batch(events: &[EventDecl], states: &[StateDecl], sink: &mut Diag ); } } + // Advancing uses the ENUM's declaration order (§3), so a + // `cycle` listing a different one would run an order the + // card does not say. Rejected rather than reordered: + // what is written must be what happens. + if !transition.tokens.is_empty() + && transition.tokens.iter().all(|t| members.contains(t)) + && transition.tokens != *members + { + sink.push( + transition.line, + transition.column, + format!( + "`cycle` advances in declaration order ({}), but lists {} — \ + reorder the enum or the cycle so they agree (profile §3)", + members.join(", "), + transition.tokens.join(", ") + ), + ); + } } _ => sink.push( transition.line, @@ -1753,6 +1993,8 @@ fn check_component_acyclicity(card: &Card, sink: &mut Diagnostics) { /// state and events, and card-scope `source` and `copy` — but never card `state`. struct Scope { roots: Vec, + /// Roots that name a source. Only these carry a `$state` lifecycle. + source_roots: Vec, events: Vec, /// Card state, tracked separately so reading it from a component is a /// specific diagnostic rather than "unknown name". @@ -1768,6 +2010,7 @@ impl Scope { let events = card.events.iter().map(|e| e.name.clone()).collect(); Self { roots, + source_roots: card.sources.iter().map(|s| root_of(&s.name)).collect(), events, forbidden_state: Vec::new(), } @@ -1784,6 +2027,7 @@ impl Scope { events.extend(component.params.iter().cloned()); Self { roots, + source_roots: card.sources.iter().map(|s| root_of(&s.name)).collect(), events, forbidden_state: card.states.iter().map(|s| root_of(&s.path)).collect(), } @@ -1812,7 +2056,7 @@ fn walk( sink: &mut Diagnostics, ) { match element.name.as_str() { - "" | "slot" => {} + "" | "slot" | "into" => {} "for" => { for binder in &element.binders { scope.roots.push(binder.clone()); @@ -1850,6 +2094,40 @@ fn walk( name => { let known = catalog::lookup(name); let is_component = components.contains(&name); + if is_component { + if let Some(component) = card.components.iter().find(|c| c.name == name) { + let offered = slot_names(component); + for child in &element.children { + if child.name != "into" { + continue; + } + let wanted = child.binders.first().cloned().unwrap_or_default(); + if !offered.contains(&wanted) { + let list = if offered.iter().all(|s| s.is_empty()) { + "it has only an anonymous slot".to_string() + } else { + format!( + "it offers: {}", + offered + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join(", ") + ) + }; + sink.push( + child.line, + child.column, + format!( + "{name:?} has no slot named {wanted:?}, so these children \ + would not render — {list} (profile §5.5)" + ), + ); + } + } + } + } if known.is_none() && !is_component { sink.push( element.line, @@ -1966,6 +2244,29 @@ fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut return; } let root = root_of(path); + + // `.$state` is the fetch lifecycle (§5.9). It is meaningful only for + // something that is fetched — a local state has no pending or failed. + if let Some(owner) = path.strip_suffix(".$state") { + if scope.source_roots.iter().any(|r| r == owner) { + return; + } + sink.push( + line, + column, + format!("`$state` is only available on a source; {owner:?} is not one (profile §5.9)"), + ); + return; + } + if path.contains("$state") { + sink.push( + line, + column, + "`$state` must be the last segment of a path (profile §5.9)".to_string(), + ); + return; + } + if scope.knows(&root) { return; } @@ -2010,6 +2311,35 @@ pub enum NodeValue { /// A binding that resolved to nothing. Surfaced rather than defaulted: a /// silently empty field is how a card renders an em dash and looks fine. Missing, + /// A source's lifecycle, readable as `now.$state` — §5.9. A card can show a + /// spinner or an error rather than an em dash, and the distinction between + /// "not yet" and "failed" is the runtime's to report, not the card's to + /// guess from an absent value. + Status(SourceStatus), +} + +/// Where a source is in its lifecycle. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SourceStatus { + /// Never resolved. + Pending, + /// Resolved, and current. + Ready, + /// Resolved, but the value is past its freshness budget. + Stale, + /// The fetch failed. The card may say so; it may not say why. + Failed, +} + +impl SourceStatus { + pub fn as_token(self) -> &'static str { + match self { + SourceStatus::Pending => "pending", + SourceStatus::Ready => "ready", + SourceStatus::Stale => "stale", + SourceStatus::Failed => "failed", + } + } } /// Traversal bounds. L0 has no expressions, so there is nothing to evaluate and @@ -2155,6 +2485,26 @@ impl ValueScope<'_> { let mut segments = path.split('.'); let root = segments.next()?; + // `.$state` is the lifecycle the runtime reported. It is data + // like any other, so a guard can branch on it without an expression. + if let Some(source) = path.strip_suffix(".$state") { + let status = self + .data + .get("$status") + .and_then(|m| m.get(source)) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| { + // No report means: resolved if there is a value, pending if + // not. A host that tracks status explicitly overrides this. + if self.data.get(source).is_some() { + "ready" + } else { + "pending" + } + }); + return Some(crate::JsonValue::String(status.to_string())); + } + // `copy.` is authored in the ledger, not fetched. Resolve it from // the card's declarations, choosing the language the runtime reported // and falling back to English — a card that ships zh text should not @@ -2206,7 +2556,7 @@ struct Realizer<'a> { /// Children passed at a component call site, realized where `slot` appears. /// A stack, so a component instantiated inside another's slot still sees its /// own children. - slots: Vec>, + slots: Vec)>>, } impl Realizer<'_> { @@ -2229,11 +2579,16 @@ impl Realizer<'_> { // component's — a child expression sees the call site's // bindings (§5.5). Taking the frame off the stack while // realizing prevents a component's own slot from re-entering. - if let Some(children) = self.slots.pop() { - for (i, child) in children.iter().enumerate() { - self.element(child, scope, &format!("{key}/s{i}"), out); + if let Some(groups) = self.slots.pop() { + // An unnamed `slot` takes the default group; `slot header` + // takes the group a call site directed with `into header`. + let wanted = element.binders.first().cloned().unwrap_or_default(); + if let Some((_, children)) = groups.iter().find(|(n, _)| *n == wanted) { + for (i, child) in children.iter().enumerate() { + self.element(child, scope, &format!("{key}/s{i}"), out); + } } - self.slots.push(children); + self.slots.push(groups); } } "for" => self.iteration(element, scope, key, out), @@ -2313,7 +2668,15 @@ impl Realizer<'_> { // An event prop has no value in data; pass the name. .unwrap_or_else(|| crate::JsonValue::String(p.clone())), Operand::Token(t) => crate::JsonValue::String(t.clone()), - _ => crate::JsonValue::Null, + // A literal prop is a value like any other. Letting these + // fall to Null is how `Framed(title: "Details")` rendered an + // em dash: the parser kept the string and the realizer threw + // it away. + Operand::Str(t) => crate::JsonValue::String(t.clone()), + Operand::Num(n) => crate::JsonValue::from(*n), + Operand::Predicate(p) => scope + .lookup(p) + .unwrap_or_else(|| crate::JsonValue::String(p.clone())), }; scope.frames.push((arg.name.clone(), value)); bound += 1; @@ -2321,7 +2684,7 @@ impl Realizer<'_> { } let instance_key = format!("{key}/{}", component.name); self.live.push(instance_key.clone()); - self.slots.push(call.children.clone()); + self.slots.push(split_slots(&call.children)); // Local state resolves PER INSTANCE: from the store when it holds a // value for this key, otherwise from the declared initial. Two instances @@ -2329,9 +2692,17 @@ impl Realizer<'_> { // component model exists to provide. let schema = schema_id(component); for state in &component.states { + // A live cell is readable when the schema still matches, or — under + // §5.8's opt-in — when this field asked to survive and its own shape + // is what it was written under. The shape check is what keeps + // `keep` from becoming the guess the reset exists to prevent. let live = self .store - .filter(|s| s.schemas.get(&component.name).is_none_or(|k| *k == schema)) + .filter(|s| { + s.schemas.get(&component.name).is_none_or(|k| { + *k == schema || state.keep && s.shape_unchanged(component, state) + }) + }) .and_then(|s| s.get(&instance_key, &state.path)) .cloned(); scope.frames.push(( @@ -2565,6 +2936,7 @@ pub mod makepad { Some(NodeValue::Token(t)) => format!("{t:?}"), Some(NodeValue::Event(_)) | None => "\"\"".into(), Some(NodeValue::Missing) => "\"—\"".into(), + Some(NodeValue::Status(st)) => format!("{:?}", st.as_token()), } } @@ -2930,6 +3302,10 @@ pub struct InstanceStore { cells: BTreeMap>, /// Component name → the schema id its live cells were written under. schemas: BTreeMap, + /// Component name → state path → the shape that cell was written under. + /// A `keep` cell is preserved across a schema change only when this still + /// matches, so opting in never means "trust whatever was there". + shapes: BTreeMap>, } impl InstanceStore { @@ -2944,25 +3320,64 @@ impl InstanceStore { .insert(field.to_string(), value); } - /// Drop every cell belonging to instances of `component`. - fn reset_component(&mut self, component: &str) { + /// Whether `state` has the shape the store's live cells were written under. + fn shape_unchanged(&self, component: &Component, state: &StateDecl) -> bool { + self.shapes + .get(&component.name) + .and_then(|m| m.get(&state.path)) + .is_some_and(|old| *old == format!("{:?}", state.shape)) + } + + /// Drop every cell belonging to instances of `component`, except the fields + /// listed in `keep` — §5.8's opt-in migration. + fn reset_component(&mut self, component: &str, keep: &[String]) { let marker = format!("/{component}"); - self.cells.retain(|key, _| !key.contains(&marker)); + self.cells.retain(|key, fields| { + if !key.contains(&marker) { + return true; + } + fields.retain(|field, _| keep.iter().any(|k| k == field)); + !fields.is_empty() + }); } /// Profile §5.8: a component whose state declarations changed gets a new /// schema id, and its live cells are dropped rather than migrated. Guessing /// that an old value still fits is how a rolled-back ledger feeds version-A /// state to version-B code. - fn reconcile_schema(&mut self, component: &str, schema: u64) { - match self.schemas.get(component) { + fn reconcile_schema(&mut self, component: &Component, schema: u64) { + let name = component.name.as_str(); + let shapes: BTreeMap = component + .states + .iter() + .map(|s| (s.path.clone(), format!("{:?}", s.shape))) + .collect(); + + match self.schemas.get(name) { Some(&known) if known == schema => {} Some(_) => { - self.reset_component(component); - self.schemas.insert(component.to_string(), schema); + // A cell survives only if it asked to AND its own shape is + // unchanged. Opting in is a claim about one field, not a + // blanket assertion that the old values still fit. + let previous = self.shapes.get(name); + let keep: Vec = component + .states + .iter() + .filter(|s| s.keep) + .filter(|s| { + previous + .and_then(|p| p.get(&s.path)) + .is_some_and(|old| *old == format!("{:?}", s.shape)) + }) + .map(|s| s.path.clone()) + .collect(); + self.reset_component(name, &keep); + self.schemas.insert(name.to_string(), schema); + self.shapes.insert(name.to_string(), shapes); } None => { - self.schemas.insert(component.to_string(), schema); + self.schemas.insert(name.to_string(), schema); + self.shapes.insert(name.to_string(), shapes); } } } @@ -3080,7 +3495,7 @@ pub fn dispatch_with( d, &component.states, &instance_key[..boundary], - Some((component.name.clone(), schema_id(component))), + Some((component, schema_id(component))), ), None => return false, }, @@ -3090,8 +3505,8 @@ pub fn dispatch_with( }, }; - if let Some((name, schema)) = schema_owner { - store.reconcile_schema(&name, schema); + if let Some((component, schema)) = schema_owner { + store.reconcile_schema(component, schema); } let mut applied = false; diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 1bdf2b9..f729212 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -1260,3 +1260,514 @@ fn a_write_a_source_reads_invalidates_its_dependents() { fn an_unrelated_write_patches_nothing() { assert!(patch_points(WEATHER, &["nothing_reads_this"]).is_empty()); } + +// ─── named slots (§5.5, open question 5) ───────────────────────────────────── + +/// Every `text` argument in the tree, in render order. +fn texts(root: &splash_core::ui_l0::UiNode) -> Vec { + fn walk(n: &splash_core::ui_l0::UiNode, out: &mut Vec) { + for (name, v) in &n.args { + if name == "text" { + if let NodeValue::Text(t) = v { + out.push(t.clone()); + } + } + } + for c in &n.children { + walk(c, out); + } + } + let mut out = Vec::new(); + walk(root, &mut out); + out +} + +const TWO_SLOTS: &str = r#" +component Framed(title: text) { + view Col { + TextCaption(text: title) + slot header + Rule() + slot + } +} +view root Panel { + Framed(title: "Today") { + into header { TextRow(text: "in the header") } + TextRow(text: "in the body") + } +} +"#; + +#[test] +fn a_named_slot_card_is_l0() { + accepts("two-slots", TWO_SLOTS); +} + +#[test] +fn named_slot_routes_children_to_the_matching_slot() { + let report = realize(TWO_SLOTS, &serde_json::json!({}), RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + + // Order is what proves routing: the `into header` child is written first at + // the call site but the component places that slot above the anonymous one, + // so the two must come out in component order, not call-site order. + assert_eq!( + texts(&report.root.unwrap()), + vec!["Today", "in the header", "in the body"], + "each group should land at its own slot" + ); +} + +#[test] +fn a_slot_with_no_matching_group_renders_nothing() { + // Not an error: a component may offer a slot that a call site declines to + // fill. Children with nowhere to go are dropped rather than misplaced. + let source = r#" +component Framed() { view Panel { slot aside } } +view root Panel { Framed() { TextRow(text: "loose") } } +"#; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert!( + texts(&report.root.unwrap()).is_empty(), + "the anonymous group has no matching slot in this component" + ); +} + +#[test] +fn an_anonymous_slot_still_needs_no_ceremony() { + // The common case must stay as it was — no `into` required, and every + // existing card keeps working. + let source = r#" +component Framed() { view Panel { slot } } +view root Panel { Framed() { TextRow(text: "plain") } } +"#; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!(texts(&report.root.unwrap()), vec!["plain"]); +} + +// ─── source lifecycle (§5.9, open question 3) ──────────────────────────────── + +const LIFECYCLE: &str = r#" +source now sys.weather(lat: 35.0, lon: 135.0) +view root Col { + when now.$state == .pending { TextRow(text: "Loading") } + when now.$state == .failed { TextRow(text: "Unavailable") } + when now.$state == .ready { TextRow(text: "Live") } +} +"#; + +#[test] +fn a_source_lifecycle_card_is_l0() { + accepts("lifecycle", LIFECYCLE); +} + +#[test] +fn source_state_defaults_to_pending_when_no_value_arrived() { + let report = realize(LIFECYCLE, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!( + texts(&report.root.unwrap()), + vec!["Loading"], + "a source with no value and no report is pending, so a card can say so \ + rather than rendering an em dash that looks like real data" + ); +} + +#[test] +fn source_state_is_ready_once_a_value_arrives() { + let data = serde_json::json!({"now": {"temp": 21.0}}); + let report = realize(LIFECYCLE, &data, RealizeLimits::default()); + assert_eq!(texts(&report.root.unwrap()), vec!["Live"]); +} + +#[test] +fn the_host_can_report_failure_explicitly() { + // The distinction that matters: a fetch that FAILED is not the same as one + // that has not happened yet, and only the host knows which. + let data = serde_json::json!({"$status": {"now": "failed"}}); + let report = realize(LIFECYCLE, &data, RealizeLimits::default()); + assert_eq!( + texts(&report.root.unwrap()), + vec!["Unavailable"], + "failure must be distinguishable from pending" + ); +} + +#[test] +fn a_stale_source_is_neither_pending_nor_failed() { + // Stale means old, not wrong: the card keeps its last good value rather + // than blanking a number that is merely a few minutes behind. + let data = serde_json::json!({"now": {"temp": 21.0}, "$status": {"now": "stale"}}); + let report = realize(LIFECYCLE, &data, RealizeLimits::default()); + assert!( + texts(&report.root.unwrap()).is_empty(), + "this card guards three states; stale is a fourth and matches none" + ); +} + +#[test] +fn state_is_rejected_on_something_that_is_not_a_source() { + let report = check_ui_l0_named( + "not-a-source", + "state units { shape: enum[c, f], initial: .c }\n\ + view root Col { when units.$state == .pending { Rule() } }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("only available on a source")), + "local state has no fetch lifecycle: {:#?}", + report.diagnostics + ); +} + +/// A literal prop must survive the call. This failed for the life of the +/// component feature and no test caught it: the reference cards pass paths +/// (`story: s`, `position: i`), so the literal arm was never exercised and +/// `Framed(title: "Details")` rendered an em dash. Same shape as every other +/// "specified but not retained" bug — the parser kept the value and the +/// realizer dropped it on a catch-all arm. +#[test] +fn a_literal_prop_reaches_the_component() { + let source = r#" +component Framed(title: text, rank: number) { + view Col { TextCaption(text: title) TextRow(text: rank) } +} +view root Panel { Framed(title: "Details", rank: 3) } +"#; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert!(report.diagnostics.is_empty(), "{:#?}", report.diagnostics); + let root = report.root.unwrap(); + + let mut caps = Vec::new(); + find(&root, "TextCaption", &mut caps); + assert_eq!( + caps[0].args.iter().find(|(n, _)| n == "text").unwrap().1, + NodeValue::Text("Details".into()), + "a string literal prop must arrive, not become Missing" + ); + + let mut rows = Vec::new(); + find(&root, "TextRow", &mut rows); + assert_eq!( + rows[0].args.iter().find(|(n, _)| n == "text").unwrap().1, + NodeValue::Number(3.0), + "a number literal prop must arrive too" + ); +} + +#[test] +fn an_into_naming_an_absent_slot_is_rejected() { + // Children with nowhere to go vanish. Silent is the wrong failure mode: + // this is the same class as a binding that resolves to nothing. + let report = check_ui_l0_named( + "bad-slot", + "component Framed() { view Panel { slot body } }\n\ + view root Panel { Framed() { into headr { Rule() } } }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("has no slot named") && d.message.contains("body")), + "the message should name the slots that do exist: {:#?}", + report.diagnostics + ); +} + +// ─── §6 condition 4: event cascades ────────────────────────────────────────── + +/// Profile §6.4 prohibits event cascades — a transition may not trigger another +/// event. There is no check for this because there is nothing to check: the four +/// total forms (`set`, `toggle`, `cycle`, `clear`) all name a STATE, and a +/// transition target is validated against declared state. A cascade is therefore +/// unrepresentable rather than merely rejected. +/// +/// This test pins that. It enumerates every syntax that could plausibly express +/// "and then fire another event" and asserts each is rejected. If someone later +/// adds a form that can raise one, termination stops being structural and this +/// test is where that shows up. +#[test] +fn an_event_cannot_trigger_another_event() { + const PRELUDE: &str = "state n { shape: number, initial: 0 }\n\ + state flag { shape: bool, initial: false }\n\ + event bump { n: set(1) }\n"; + + for (shape, body) in [ + ( + "a transition targeting an event name", + "event c { bump: toggle }", + ), + ("a transition setting an event", "event c { bump: set(1) }"), + ( + "an event named as a payload target", + "event c { n: set(bump) }", + ), + ("an emit form", "event c { emit bump }"), + ("a call form", "event c { bump() }"), + ("a then form", "event c { n: set(1) then bump }"), + ] { + let source = format!("{PRELUDE}{body}\nview root Panel {{ Rule() }}"); + let report = check_ui_l0_named("cascade", &source); + assert!( + !report.valid, + "{shape} would be a cascade and must not be accepted: {body:?}" + ); + } + + // The control: the same prelude with a legal transition IS accepted, so the + // rejections above are about cascades and not about the prelude. + let ok = check_ui_l0_named( + "no-cascade", + &format!("{PRELUDE}event c {{ flag: toggle }}\nview root Panel {{ Rule() }}"), + ); + assert!(ok.valid, "{:#?}", ok.diagnostics); +} + +// ─── §5.8 opt-in migration (open question 6) ───────────────────────────────── + +/// `keep: true` opts one cell out of the §5.8 reset. Reset stays the default; +/// this makes preservation expressible rather than impossible. +#[test] +fn a_keep_cell_survives_a_schema_change() { + const KEPT: &str = r#" +source items sys.list() +component Rowy(item: record) { + state open { shape: bool, initial: false, keep: true } + event flip { open: toggle } + view Col { + Row(on_tap: flip) { TextRow(text: item.name) } + when open { TextCaption(text: item.name) } + } +} +view root Panel { for it in items key it.id { Rowy(item: it) } } +"#; + let mut store = splash_core::ui_l0::InstanceStore::default(); + let data = two_rows_data(); + let first = + splash_core::ui_l0::realize_with_state(KEPT, &data, &store, RealizeLimits::default()); + let mut rows = Vec::new(); + let first_root = first.root.unwrap(); + find(&first_root, "Row", &mut rows); + splash_core::ui_l0::dispatch(KEPT, &mut store, &rows[0].key, "flip"); + + // An unrelated field is added: the schema id changes, but `open` itself is + // untouched and asked to survive. + let edited = KEPT.replace( + "event flip", + "state seen { shape: bool, initial: false }\n event flip", + ); + let after = + splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let mut captions = Vec::new(); + let after_root = after.root.unwrap(); + find(&after_root, "TextCaption", &mut captions); + assert_eq!( + captions.len(), + 1, + "a cell that opted in and did not change shape should survive" + ); +} + +/// The limit of the opt-in: `keep` is a claim about a field, not a promise that +/// any value fits. Retyping the field resets it anyway — otherwise `keep` would +/// be exactly the "guess it still fits" that §5.8 exists to prevent. +#[test] +fn keep_does_not_survive_a_change_to_that_field_itself() { + const KEPT: &str = r#" +source items sys.list() +component Rowy(item: record) { + state open { shape: bool, initial: false, keep: true } + event flip { open: toggle } + view Col { + Row(on_tap: flip) { TextRow(text: item.name) } + when open { TextCaption(text: item.name) } + } +} +view root Panel { for it in items key it.id { Rowy(item: it) } } +"#; + let mut store = splash_core::ui_l0::InstanceStore::default(); + let data = two_rows_data(); + let first = + splash_core::ui_l0::realize_with_state(KEPT, &data, &store, RealizeLimits::default()); + let mut rows = Vec::new(); + let first_root = first.root.unwrap(); + find(&first_root, "Row", &mut rows); + splash_core::ui_l0::dispatch(KEPT, &mut store, &rows[0].key, "flip"); + + // `open` is retyped bool → enum. The opt-in does not apply to a field whose + // own shape moved. + let edited = KEPT + .replace( + "state open { shape: bool, initial: false, keep: true }", + "state open { shape: enum[shut, ajar], initial: .shut, keep: true }", + ) + .replace( + "event flip { open: toggle }", + "event flip { open: cycle(.shut, .ajar) }", + ); + let after = + splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let mut captions = Vec::new(); + let after_root = after.root.unwrap(); + find(&after_root, "TextCaption", &mut captions); + assert_eq!( + captions.len(), + 0, + "a retyped field resets even with keep: true" + ); +} + +/// Reset remains the default: without `keep`, nothing changes. +#[test] +fn without_keep_a_schema_change_still_resets() { + // This is `a_schema_change_resets_instance_state` restated as the control + // for the two tests above — the opt-in must not have become the default. + let mut store = splash_core::ui_l0::InstanceStore::default(); + let data = two_rows_data(); + let first = + splash_core::ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let mut rows = Vec::new(); + let first_root = first.root.unwrap(); + find(&first_root, "Row", &mut rows); + splash_core::ui_l0::dispatch(TWO_ROWS, &mut store, &rows[0].key, "flip"); + + let edited = TWO_ROWS.replace( + "event flip", + "state seen { shape: bool, initial: false }\n event flip", + ); + let after = + splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let mut captions = Vec::new(); + let after_root = after.root.unwrap(); + find(&after_root, "TextCaption", &mut captions); + assert_eq!(captions.len(), 0, "reset is still the default"); +} + +// ─── §7 component version pinning ──────────────────────────────────────────── + +/// §7 requires component versions to be pinned, "or an L0 card can be moved to +/// L2 by a definition it references being replaced". The report carries a digest +/// per component so a host can store it beside the approved level and notice. +#[test] +fn the_report_carries_a_digest_per_component() { + let report = check_ui_l0_named("weather", WEATHER); + assert!(report.valid); + assert!( + !report.closure.is_empty(), + "the weather card declares a component, so the closure cannot be empty" + ); + let names: Vec<&str> = report.closure.iter().map(|(n, _)| n.as_str()).collect(); + assert!(names.contains(&"ForecastRow"), "got {names:?}"); + + // Sorted, so a host comparing two closures does not have to. + let mut sorted = report.closure.clone(); + sorted.sort(); + assert_eq!(sorted, report.closure); +} + +#[test] +fn replacing_a_component_body_changes_its_digest() { + let before = check_ui_l0_named("weather", WEATHER).closure; + + // The change that motivates §7: a component gains a construct outside L0. + // The card text is otherwise identical, so a host that pinned only the + // card's level would carry the old L0 verdict onto a definition that no + // longer earns it. + let edited = WEATHER.replace( + "component ForecastRow(", + "component ForecastRow(extra: number, ", + ); + assert_ne!(edited, WEATHER, "the replacement must actually apply"); + let after = check_ui_l0_named("weather", &edited).closure; + + let digest = |c: &[(String, u64)]| { + c.iter() + .find(|(n, _)| n == "ForecastRow") + .map(|(_, d)| *d) + .unwrap() + }; + assert_ne!( + digest(&before), + digest(&after), + "a changed definition must produce a changed digest" + ); +} + +#[test] +fn reordering_declarations_is_not_a_version_change() { + // A digest that moved on a cosmetic edit would make pinning useless: every + // reformat would look like a replacement and hosts would learn to ignore it. + let source = "\ +component A(x: number) { view Panel { TextRow(text: x) } }\n\ +component B(y: number) { view Panel { TextRow(text: y) } }\n\ +view root Panel { A(x: 1) B(y: 2) }\n"; + let swapped = "\ +component B(y: number) { view Panel { TextRow(text: y) } }\n\ +component A(x: number) { view Panel { TextRow(text: x) } }\n\ +view root Panel { A(x: 1) B(y: 2) }\n"; + + let a = check_ui_l0_named("a", source); + let b = check_ui_l0_named("b", swapped); + assert!( + a.valid && b.valid, + "{:#?} {:#?}", + a.diagnostics, + b.diagnostics + ); + assert_eq!(a.closure, b.closure); +} + +// ─── §3 cycle ordering (open question 2) ───────────────────────────────────── + +/// `cycle` advances in the ENUM's declaration order. That is the guarantee +/// question 2 asked for, and it is what makes the transition total and O(1) — +/// there is no comparison to run, only a successor. +#[test] +fn cycle_advances_in_declaration_order() { + const TRI: &str = "\ +state mode { shape: enum[low, mid, high], initial: .low }\n\ +event step { mode: cycle(.low, .mid, .high) }\n\ +view root Panel { Row(on_tap: step) { Rule() } }\n"; + + let mut store = splash_core::ui_l0::InstanceStore::default(); + let report = realize(TRI, &serde_json::json!({}), RealizeLimits::default()); + let mut rows = Vec::new(); + let root = report.root.unwrap(); + find(&root, "Row", &mut rows); + let key = rows[0].key.clone(); + + for expected in ["mid", "high", "low"] { + splash_core::ui_l0::dispatch(TRI, &mut store, &key, "step"); + assert_eq!( + store.get("@card", "mode").and_then(|v| v.as_str()), + Some(expected), + "cycle should wrap through declaration order" + ); + } +} + +/// The corollary: a `cycle` that lists a different order is rejected rather +/// than quietly running the declared one. Accepting it would mean the card says +/// one order and the runtime performs another. +#[test] +fn a_cycle_listing_a_different_order_is_rejected() { + let report = check_ui_l0_named( + "reordered", + "state mode { shape: enum[low, mid, high], initial: .low }\n\ + event step { mode: cycle(.high, .mid, .low) }\n\ + view root Panel { Rule() }\n", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("declaration order")), + "{:#?}", + report.diagnostics + ); +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index aefe0c7..687a37a 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -66,8 +66,10 @@ view-decl = "view" , path , view-body ; local = state-decl | event-decl ; view-body = element ; -element = construct | iteration | guard | reference ; +element = construct | iteration | guard | reference | slot | fill ; construct = Constructor , [ "(" , [ arguments ] , ")" ] , [ block ] ; +slot = "slot" , [ ident ] ; (* in a component body *) +fill = "into" , ident , block ; (* at a call site *) reference = path ; (* splices another view record *) iteration = "for" , ident , [ "," , ident ] , "in" , path , "key" , path , block ; guard = "when" , predicate , block ; @@ -80,7 +82,8 @@ operand = path | literal | predicate ; (* NO expressions *) predicate = path , [ comparator , ( path | literal | token ) ] ; comparator = "==" | "!=" | "<" | "<=" | ">" | ">=" ; -path = root , { "." , ident | "." , integer | "[" , path , "]" } ; +path = root , { "." , ident | "." , integer | "[" , path , "]" } + , [ "." , "$state" ] ; (* source lifecycle, §5.9 *) root = ident ; transition-block = "{" , transition , { "," , transition } , "}" ; @@ -90,7 +93,8 @@ total-form = "set" , "(" , ( literal | token | path | "$value" ) , ")" | "cycle" , "(" , token , { "," , token } , ")" | "clear" ; (* restore declared initial *) -shape-spec = "shape" , ":" , shape , [ "," , "initial" , ":" , ( literal | path ) ] ; +shape-spec = "shape" , ":" , shape , [ "," , "initial" , ":" , ( literal | path ) ] + , [ "," , "keep" , ":" , "true" ] ; shape = "text" | "number" | "bool" | "enum" , "[" , ident , { "," , ident } , "]" ; provenance = "vocabulary" | "user-copy" | "model-copy" ; ``` @@ -357,8 +361,27 @@ Panel(title: "Details") { Tile(…) Tile(…) } ``` Children are realized in the parent's scope, not the component's — a child expression sees the -call site's bindings. Named slots are deferred; nothing in the reference cards needs them, and -adding them later is additive. +call site's bindings. + +A component may also offer **named** slots, and a call site directs children at one with `into`: + +``` +component Framed(title: text) { + view Col { TextCaption(text: title) slot header Rule() slot } +} + +Framed(title: "Today") { + into header { TextRow(text: "above the rule") } + TextRow(text: "below it") +} +``` + +Children written outside any `into` fill the anonymous slot, so a component with one slot needs +no ceremony and every card written before named slots existed is unchanged. + +**An `into` naming a slot the component does not offer is rejected.** Its children would +otherwise render nowhere, which is the same silent-drop failure as a binding that resolves to +nothing — the card looks deliberate and the content is simply gone. ### 5.6 What is refused, and why @@ -418,13 +441,63 @@ field cannot be assumed compatible with live values, and guessing is how a rolle ends up feeding version-A state to version-B code — at which point last-known-good is no longer known good. -An explicit migration is a future addition and must be atomic with activation and rollback. +**The opt-in is `keep: true`** on a state declaration: + +``` +state expanded { shape: bool, initial: false, keep: true } +``` + +A cell that opts in survives a schema change **only while its own shape is unchanged**. Adding +an unrelated field elsewhere in the component preserves it; retyping `expanded` itself resets it +regardless. Without that second condition `keep` would be precisely the "assume the old value +still fits" that this section exists to prevent — it would just have moved the guess from the +runtime to the card author, where it is no more reliable and considerably less visible. + +Reset therefore remains the default in both directions: a component that says nothing behaves +exactly as before, and a component that opts in still resets on any change to the field it +opted in for. **Consequence worth stating plainly:** folding the ledger reconstructs the *program*, not the visible application state. Local state is disposable UI state — scroll positions, expansion flags. Anything that must survive a definition change belongs in a `source` or in card state with a declared migration, not in a component. +### 5.9 Source lifecycle + +Every source carries a lifecycle, readable as `.$state`, whose value is one of four +tokens: + +| Token | Meaning | +|---|---| +| `.pending` | never resolved | +| `.ready` | resolved and current | +| `.stale` | resolved, but past its freshness budget | +| `.failed` | the fetch failed | + +``` +when now.$state == .pending { TextRow(text: copy.loading) } +when now.$state == .failed { TextRow(text: copy.offline) } +``` + +This exists because **absence is not a state.** Without it a card can only ask whether a value +arrived, so "still loading", "the network is down" and "this field genuinely has no value" all +render the same em dash. The card then looks like working software displaying real data, which +is the worst of the three outcomes. + +`$state` must be the last segment of a path, and is available **only on a source** — a local +state is not fetched and has no pending or failed. Both are rejected rather than resolving to +nothing. + +The host reports the lifecycle; the card never computes it. When the host reports nothing, a +source with a value is `.ready` and one without is `.pending` — enough for a host that does not +track status, without inventing the distinction that only the host can actually make. Note that +`.stale` and `.failed` are exactly that distinction: neither is derivable from the data, which +is why a card cannot be left to infer them. + +**Cadence and freshness budget belong to the capability, not the card.** A card names what it +needs; how often `sys.weather` is worth re-fetching is a property of weather, and duplicating it +per card is how two cards on one screen disagree about how old their data is. + --- ## 6. What makes L0 terminate @@ -443,6 +516,16 @@ With all five, an L0 realization terminates. **Without them the grammar does not why "L0 is decidable" is a claim about authority and grammar membership, not about termination. +Condition 4 is guaranteed **structurally rather than by a check**. All four total forms +(§3) name a state, a transition target is validated against declared state, and `set`'s operand +must be a readable name — so there is no position in the grammar where an event can be named as +something to *do*. A cascade is unrepresentable, not merely rejected. + +That is a stronger guarantee than a validator, and correspondingly easier to lose: any future +form that could raise an event would silently move termination from structural to unchecked. It +is pinned by a test that enumerates the syntaxes a cascade could take — `emit e`, `e()`, +`n: set(1) then e`, a transition targeting an event — and asserts each is refused. + --- ## 7. Level determination @@ -461,6 +544,16 @@ rejected until the level is explicitly raised; escalation is never silent. **Com versions must be pinned**, or an L0 card can be moved to L2 by a definition it references being replaced. +The check therefore returns a **closure digest**: every component the card depends on, paired +with a hash of its definition — params, state, events and view body — sorted by name. A host +stores this beside the approved level. A digest that moved means the level was derived from a +definition that no longer exists and must be re-derived before the card is accepted, rather +than inherited from the record it no longer matches. + +The digest deliberately ignores declaration order, so reformatting is not a version change. A +digest that moved on a cosmetic edit would make pinning useless in the way that matters: hosts +would see it move constantly, and learn to ignore it. + Three token tests are not sufficient and an earlier draft that used them was both incomplete and self-contradictory. Level must be an effect judgment over the closure. @@ -476,19 +569,30 @@ construct and keep the widest. | # | Question | |---|---| -| 1 | Whether `when` needs `else`, or whether two complementary guards suffice | -| 2 | Whether `enum` shapes need ordering guarantees for `cycle` | -| 3 | How a `source` declares refresh cadence, staleness and error surfaces | +| ~~1~~ | ~~Whether `when` needs `else`~~ — **settled: no.** Two complementary guards suffice and both reference cards that branch already use them (`when selected == ""` / `when selected != ""`). An `else` would need the negation of the guard, and there is no expression form to negate with; adding one for this alone would buy nothing a second `when` does not already express | +| ~~2~~ | ~~Whether `enum` shapes need ordering guarantees for `cycle`~~ — **settled: yes, declaration order is normative.** `cycle` advances to the successor in the enum's declared order and wraps, which is what keeps it total and O(1). A `cycle(…)` listing a different order is **rejected**, not reordered: otherwise the card would say one order and the runtime would run another | +| ~~3~~ | ~~How a `source` declares refresh cadence, staleness and error surfaces~~ — **settled in §5.9.** Staleness and error are a four-token lifecycle the host reports and the card reads as `.$state`. Cadence is not a card concern: it belongs to the capability, since how often a fetch is worth repeating is a property of the data, not of the card displaying it | | ~~4~~ | ~~A bounded `format()`~~ — **settled**: `format:` is a declared token and the runtime applies it. A card that wrote `"$"` or `"41.2M"` itself would be authoring a fact, and currency, grouping and compaction are locale-dependent besides | -| 5 | Named slots — deferred; the single anonymous slot is implemented and nothing in the reference cards needs more | -| 6 | An explicit state migration, to make preservation across a schema change opt-in rather than impossible (§5.8) | -| 7 | The closed token sets per constructor argument that §2.2 depends on | +| ~~5~~ | ~~Named slots~~ — **settled in §5.5.** `slot name` in the component, `into name { … }` at the call site, anonymous slot unchanged as the default. An `into` naming a slot that does not exist is rejected rather than silently dropping its children | +| ~~6~~ | ~~An explicit state migration~~ — **settled in §5.8.** `keep: true` opts a cell out of the schema-change reset, and only while that field's own shape is unchanged | +| ~~7~~ | ~~The closed token sets per constructor argument~~ — **settled**: `docs/ui-l0-constructors.toml` is the normative catalog, and a test asserts the Rust catalog and the TOML agree in both directions | + +All seven are now settled. What replaces them is not a shorter list of questions but a +different kind of work: the profile is specified and implemented, and the remaining gaps are +in the surrounding system rather than in the language. + +**Known gaps, stated plainly.** `makepad::lower` still lives in `splash-core` and belongs in a +backend crate. `StockPlot` and `AqiContour` lower without their data arrays. L1 and L2 are +specified only as "what L0 excludes" — neither is implemented, and the level classifier can +therefore name them but not check them. Component contracts were question 1 and are now specified in §5.2–§5.8, validated by adding components to two reference cards. That exercise produced amendment #9, which nothing had surfaced before — props are the first bindings a card author names freely, so the token/path ambiguity was invisible until one existed. -Question 4 is the likeliest to force a change, and question 7 is a prerequisite for -implementing §2.2 at all. The reference cards in `octos-one/docs/l0/` remain the evidence: any -construct they need that this document lacks is a defect here rather than in the cards. +The reference cards in `octos-one/docs/l0/` remain the evidence: any construct they need that +this document lacks is a defect here rather than in the cards. Two of the settlements above came +from that rule rather than from argument — question 1 was answered by noticing both branching +cards had already written the complementary-guard form without anyone specifying it, and +question 7 by the cards exhausting the token sets in the course of being written. From 2a629282fe3575fa38880d882d8864ce28393150 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:41:39 -0700 Subject: [PATCH 16/63] roadmap: bring the L0 section up to date with what shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/roadmap.md | 61 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 03ad9fe..f676002 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -387,9 +387,10 @@ declared local state, with no expression form and no module access, evaluated against an empty host surface. Three reference cards exercise it. **Implemented** in `splash-core::ui_l0`: parser, validator, per-instance state store, -event dispatch, and a renderer-neutral realizer. 48 tests; the three reference cards are -accepted and the shipping nav card is rejected as L2; a card renders on a OnePlus 6T through -an unmodified downstream host. +event dispatch, a renderer-neutral realizer, a source plan, and static dependency tracking for +reconciliation. 85 tests; the three reference cards are accepted and the shipping nav card is +rejected as L2; four cases render on a OnePlus 6T through an unmodified downstream host, checked +against golden images. Building it settled one claim in the profile's own favour and against its wording. L0 has no expression form, so there is nothing to evaluate: realization never enters the VM, and the @@ -397,23 +398,47 @@ implementation contains no reference to it. Authority confinement is structural strongest sense available — no execution machinery in the path, rather than a sandbox or an empty module table. +Since resolved — every question the profile listed as open is now settled, and the +constructs behind them are implemented: + +- **Component version pinning.** The check returns a per-component definition + digest. Level is a judgment over the transitive closure, so pinning the level + alone let a replaced definition carry an approved L0 verdict onto code that no + longer earned it. The digest ignores declaration order, so a reformat is not a + version change. +- **Named slots** — `slot name` in a component, `into name { … }` at the call + site, with the anonymous slot unchanged as the default. An `into` naming a + slot that does not exist is rejected rather than dropping its children. +- **State migration** — `keep: true` exempts one cell from the §5.8 schema + reset, and only while that field's own shape is unchanged. +- **Source lifecycle** — `.$state` yields pending/ready/stale/failed, so + a card can distinguish "loading" from "the fetch failed" from "no value". + Previously all three rendered the same em dash, which is the presentation a + card gives to data it actually has. +- **Termination.** All five of the profile's §6 conditions are now accounted + for: acyclicity and total transition forms are validated, collection length, + node count and nesting depth are enforced at realization, and the prohibition + on event cascades holds structurally — no total form can name an event, so a + cascade is unrepresentable rather than rejected. That last one is pinned by a + test, because a future form that could raise an event would silently move + termination from structural to unchecked. + Remaining: -- **Component versions are not pinned.** The level classifier resolves over the - transitive closure, but a referenced component definition being replaced can - still widen a card's level without the card changing. -- **Live sources.** Realization takes injected data; nothing yet maps a `source` - declaration to the helper that answers it. That mapping is also what would end - the duplication of `sys.*` across four hosts, where every divergence so far - has produced a device-visible bug. -- **Reconciliation.** Realization rebuilds the tree; a state write does not yet - patch only its dependents. -- **Named slots**, and an explicit state migration so preservation across a - schema change is opt-in rather than impossible. -- **Termination** still rests on the five conditions in the profile's §6, of - which the implementation enforces two: component-graph acyclicity, and - iteration over a bound path. Collection length, node count and nesting depth - are traversal bounds the caller sets. +- **Live sources.** `source_plan` says what to fetch and in what order, and a + host-side resolver fulfils it, but nothing yet maps a `source` declaration to + a shared helper definition. That mapping is what would end the duplication of + `sys.*` across four hosts, where every divergence so far has produced a + device-visible bug. +- **Reconciliation is derived but not applied.** `record_dependencies`, + `dirty_records` and `patch_points` compute what a state write invalidates; + realization still rebuilds the tree rather than patching those points. +- **`makepad::lower` is in the wrong crate.** It belongs in a backend crate, not + in `splash-core`, which otherwise names no renderer. +- **`StockPlot` and `AqiContour` lower without their data arrays**, so both + render as empty frames on device. +- **L1 and L2 are unimplemented.** Both are specified only as what L0 excludes, + so the level classifier can name them but cannot check them. ## Before a stable language release From 91e76c27f36309e27a2c7ec2105403a655a482cb Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:52:50 -0700 Subject: [PATCH 17/63] =?UTF-8?q?fix(ui=5Fl0):=20identity=20is=20a=20path,?= =?UTF-8?q?=20not=20a=20position=20=E2=80=94=20as=20=C2=A75.1=20always=20c?= =?UTF-8?q?laimed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 33 ++++++++- crates/splash-core/tests/ui_profile_l0.rs | 90 +++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 73acc7f..c86104c 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -1599,6 +1599,30 @@ fn slot_names(component: &Component) -> Vec { out } +/// Path segments for a list of siblings, stable against insertion. +/// +/// Profile §5.1 identifies an instance by its *instantiation site*, not its +/// position, "so that identity survives a ledger edit that inserts an element +/// above". A raw child index does not: inserting a `Rule()` renumbers every +/// sibling after it, and each one loses its local state — which is React's rule, +/// and precisely the rule §5.1 says L0 does not use. +/// +/// Numbering within each element NAME fixes that. Two sibling `Row`s are still +/// distinct (`Row#0`, `Row#1`), so keys stay unique for hit-testing, but a +/// `Rule()` inserted above a `Toggly` leaves `Toggly#0` untouched. +fn sibling_segments(children: &[Element]) -> Vec<(String, &Element)> { + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + children + .iter() + .map(|child| { + let n = seen.entry(child.name.as_str()).or_insert(0); + let segment = format!("{}#{}", child.name, *n); + *n += 1; + (segment, child) + }) + .collect() +} + fn split_slots(children: &[Element]) -> Vec<(String, Vec)> { let mut groups: Vec<(String, Vec)> = vec![(String::new(), Vec::new())]; for child in children { @@ -2643,8 +2667,13 @@ impl Realizer<'_> { let value = self.value(&element.name, &arg.name, &arg.value, scope); node.args.push((arg.name.clone(), value)); } - for (i, child) in element.children.iter().enumerate() { - self.element(child, scope, &format!("{key}/{i}"), &mut node.children); + for (segment, child) in sibling_segments(&element.children) { + self.element( + child, + scope, + &format!("{key}/{segment}"), + &mut node.children, + ); } out.push(node); } diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index f729212..173af3e 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -1771,3 +1771,93 @@ fn a_cycle_listing_a_different_order_is_rejected() { report.diagnostics ); } + +// ─── §5.1 identity is a path, not a position ───────────────────────────────── + +const TOGGLY: &str = r#" +component Toggly() { + state open { shape: bool, initial: false } + event flip { open: toggle } + view Col { + Row(on_tap: flip) { TextRow(text: "hdr") } + when open { TextCaption(text: "OPEN") } + } +} +view root Panel { Toggly() } +"#; + +/// §5.1: "L0 binds to a declared path so that identity survives a ledger edit +/// that inserts an element above." This is the profile's stated difference from +/// React, and it went untested — the implementation keyed children by their raw +/// index, so it was in fact using React's rule while the spec said it was not. +/// +/// The scenario is not hypothetical for a ledger: appending a record that adds +/// a rule above a list is an ordinary edit, and it should not collapse every +/// expanded row on screen. +#[test] +fn state_survives_an_element_inserted_above() { + let data = serde_json::json!({}); + let mut store = splash_core::ui_l0::InstanceStore::default(); + + let first = + splash_core::ui_l0::realize_with_state(TOGGLY, &data, &store, RealizeLimits::default()); + let root = first.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + let key = rows[0].key.clone(); + splash_core::ui_l0::dispatch(TOGGLY, &mut store, &key, "flip"); + + // The same card with a Rule inserted ABOVE the component. + let edited = TOGGLY.replace( + "view root Panel { Toggly() }", + "view root Panel { Rule() Toggly() }", + ); + assert_ne!(edited, TOGGLY, "the edit must actually apply"); + + let after = + splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let after_root = after.root.unwrap(); + let mut rows_after = Vec::new(); + find(&after_root, "Row", &mut rows_after); + assert_eq!( + rows_after[0].key, key, + "the instance key must not depend on how many siblings precede it" + ); + + let mut caps = Vec::new(); + find(&after_root, "TextCaption", &mut caps); + assert_eq!( + caps.len(), + 1, + "the toggled state must survive an insertion above (profile §5.1)" + ); +} + +/// The other half: sibling instances of the same component stay distinct, so +/// making keys insertion-stable must not make two of them collide. +#[test] +fn sibling_instances_of_one_component_keep_separate_state() { + let two = TOGGLY.replace( + "view root Panel { Toggly() }", + "view root Panel { Toggly() Toggly() }", + ); + let data = serde_json::json!({}); + let mut store = splash_core::ui_l0::InstanceStore::default(); + + let first = + splash_core::ui_l0::realize_with_state(&two, &data, &store, RealizeLimits::default()); + let root = first.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + assert_eq!(rows.len(), 2); + assert_ne!(rows[0].key, rows[1].key, "two instances need two keys"); + + // Flip only the first. + splash_core::ui_l0::dispatch(&two, &mut store, &rows[0].key.clone(), "flip"); + let after = + splash_core::ui_l0::realize_with_state(&two, &data, &store, RealizeLimits::default()); + let after_root = after.root.unwrap(); + let mut caps = Vec::new(); + find(&after_root, "TextCaption", &mut caps); + assert_eq!(caps.len(), 1, "exactly one instance should be expanded"); +} From 46d9c84f541ba6543e799b60ceb34a4fa234ea46 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:35:30 -0700 Subject: [PATCH 18/63] =?UTF-8?q?fix(ui=5Fl0):=20source=20capabilities=20a?= =?UTF-8?q?re=20a=20closed=20set=20=E2=80=94=20a=20card=20cannot=20name=20?= =?UTF-8?q?sys.shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 85 +++++++++++++++ crates/splash-core/tests/ui_profile_l0.rs | 125 ++++++++++++++++++++-- docs/ui-l0-constructors.toml | 54 ++++++++++ 3 files changed, 258 insertions(+), 6 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index c86104c..9f2286a 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -1796,6 +1796,40 @@ pub mod catalog { .find(|(n, _)| *n == name) .map(|(_, a)| *a) } + + /// The CLOSED set of helpers a `source` may name, with the arguments each + /// accepts. Mirrors `[sources.*]` in `docs/ui-l0-constructors.toml`. + /// + /// Without this the parser accepted any dotted name and `source_plan` handed + /// it to the host as the function to resolve, so + /// `source leak sys.shell(command: "cat /etc/passwd")` produced a clean, + /// well-formed request. Confinement then rested on the host keeping its own + /// allowlist — the policy boundary §1 claims L0 removes. A card must not be + /// able to NAME a capability it was not granted. + pub const SOURCES: &[(&str, &[&str])] = &[ + ("sys.geocode", &["name"]), + ( + "sys.weather", + &["lat", "lon", "days", "fields", "aggregate"], + ), + ("sys.daylight", &["lat", "lon"]), + ("sys.airquality", &["lat", "lon"]), + ("sys.moonphase", &["lat", "lon"]), + ("sys.photo", &["query"]), + ("sys.locale", &[]), + ("sys.news", &["count", "offset", "fields"]), + ("sys.news_item", &["id", "fields"]), + ("sys.movers", &["count", "fields"]), + ("sys.quote", &["ticker", "fields"]), + ( + "sys.series", + &["ticker", "range", "points", "fields", "aggregate"], + ), + ]; + + pub fn source(name: &str) -> Option<&'static [&'static str]> { + SOURCES.iter().find(|(n, _)| *n == name).map(|(_, a)| *a) + } } // ──────────────────────────────────────────────────────────────────── validation ── @@ -1805,6 +1839,7 @@ fn validate(card: &Card, sink: &mut Diagnostics) { // an L0 realization need not terminate. check_component_acyclicity(card, sink); validate_transitions(card, sink); + validate_sources(card, sink); let view_names: Vec<&str> = card.views.iter().map(|v| v.name.as_str()).collect(); let component_names: Vec<&str> = card.components.iter().map(|c| c.name.as_str()).collect(); @@ -1981,6 +2016,50 @@ fn check_event_batch( } } +/// Profile §4: a `source` may only name a catalogued capability. +/// +/// This is the difference between "the host refuses to run it" and "the card +/// cannot ask". `source leak sys.shell(command: "cat /etc/passwd")` previously +/// parsed clean and reached the host as a well-formed request; confinement then +/// depended on the host keeping an allowlist that the profile claims to make +/// unnecessary. +fn validate_sources(card: &Card, sink: &mut Diagnostics) { + for source in &card.sources { + let Some(accepted) = catalog::source(&source.helper) else { + let mut known: Vec<&str> = catalog::SOURCES.iter().map(|(n, _)| *n).collect(); + known.sort(); + sink.push( + source.line, + 1, + format!( + "{:?} is not a source capability L0 admits; a card may only name a \ + catalogued helper (profile §4). Known: {}", + source.helper, + known.join(", ") + ), + ); + continue; + }; + for (name, _) in &source.args { + if !accepted.contains(&name.as_str()) { + sink.push( + source.line, + 1, + format!( + "{:?} does not accept an argument named {name:?}; it accepts: {}", + source.helper, + if accepted.is_empty() { + "(none)".to_string() + } else { + accepted.join(", ") + } + ), + ); + } + } + } +} + fn check_component_acyclicity(card: &Card, sink: &mut Diagnostics) { for component in &card.components { let mut seen = vec![component.name.as_str()]; @@ -3619,11 +3698,17 @@ pub fn source_plan(source: &str) -> SourcePlan { }; let card = Parser::new(&tokens, &mut sink).parse_card(); + // The plan is what a host acts on, so the capability check has to happen + // HERE too and not only in `check_ui_l0`. A caller that skips checking must + // still not be handed `sys.shell` as a function to resolve. + validate_sources(&card, &mut sink); + let declared: Vec<&str> = card.sources.iter().map(|s| s.name.as_str()).collect(); let mut pending: Vec = card .sources .iter() + .filter(|decl| catalog::source(&decl.helper).is_some()) .map(|decl| { let mut depends_on: Vec = decl .args diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 173af3e..3f64526 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -704,7 +704,7 @@ fn a_missing_binding_lowers_to_a_visible_dash() { // ────────────────────────────────────────────────────────────── instance state ── const TWO_ROWS: &str = r#" -source items sys.list() +source items sys.movers() component Rowy(item: record) { state open { shape: bool, initial: false } event flip { open: toggle } @@ -791,7 +791,7 @@ fn a_schema_change_resets_instance_state() { // ─────────────────────────────────────────────────────── declared-but-missing ── const SETTER: &str = r#" -source items sys.list() +source items sys.movers() state picked { shape: text, initial: "" } event choose { picked: set($value) } event home { picked: clear } @@ -1093,8 +1093,9 @@ fn the_plan_orders_dependencies_before_dependents() { /// would fetch against a value that does not exist yet. #[test] fn a_dependency_cycle_is_reported_rather_than_resolved() { - let plan = - source_plan("source a sys.one(x: b.value)\nsource b sys.two(y: a.value)\nview root Panel"); + let plan = source_plan( + "source a sys.geocode(name: b.value)\nsource b sys.photo(query: a.value)\nview root Panel", + ); assert!( plan.diagnostics.iter().any(|d| d.message.contains("cycle")), "{:#?}", @@ -1534,7 +1535,7 @@ fn an_event_cannot_trigger_another_event() { #[test] fn a_keep_cell_survives_a_schema_change() { const KEPT: &str = r#" -source items sys.list() +source items sys.movers() component Rowy(item: record) { state open { shape: bool, initial: false, keep: true } event flip { open: toggle } @@ -1578,7 +1579,7 @@ view root Panel { for it in items key it.id { Rowy(item: it) } } #[test] fn keep_does_not_survive_a_change_to_that_field_itself() { const KEPT: &str = r#" -source items sys.list() +source items sys.movers() component Rowy(item: record) { state open { shape: bool, initial: false, keep: true } event flip { open: toggle } @@ -1861,3 +1862,115 @@ fn sibling_instances_of_one_component_keep_separate_state() { find(&after_root, "TextCaption", &mut caps); assert_eq!(caps.len(), 1, "exactly one instance should be expanded"); } + +// ─── §4 source capabilities are a closed set ───────────────────────────────── + +/// The finding that motivated this: `source_plan` handed an arbitrary dotted +/// name to the host as the function to resolve, so a card could NAME a +/// capability it was never granted and produce a clean, well-formed request. +/// Confinement then rested on the host keeping its own allowlist — the policy +/// boundary §1 claims L0 removes. +#[test] +fn an_uncatalogued_source_helper_is_rejected() { + let report = check_ui_l0_named( + "leak", + "source leak sys.shell(command: \"cat /etc/passwd\")\nview root Rule()", + ); + assert!(!report.valid, "a card must not be able to name sys.shell"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a source capability")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_uncatalogued_helper_produces_no_source_request() { + // Belt and braces: even if a caller skips checking, the plan must not carry + // the request onward to a host that trusts it. + let plan = splash_core::ui_l0::source_plan( + "source leak sys.shell(command: \"cat /etc/passwd\")\nview root Rule()", + ); + assert!( + !plan.requests.iter().any(|r| r.helper == "sys.shell"), + "an uncatalogued helper must not reach the host: {:?}", + plan.requests.iter().map(|r| &r.helper).collect::>() + ); + assert!(!plan.diagnostics.is_empty(), "and it must say why"); +} + +#[test] +fn a_catalogued_helper_rejects_an_argument_it_does_not_accept() { + // 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. + let report = check_ui_l0_named( + "badarg", + "source w sys.weather(lat: 1.0, lon: 2.0, shell: \"rm -rf /\")\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("does not accept an argument named")), + "{:#?}", + report.diagnostics + ); +} + +/// The catalog in the TOML and the one in Rust must list the same capabilities +/// with the same arguments — in both directions, and this time actually. +#[test] +fn the_source_catalog_matches_the_toml_spec() { + const TOML: &str = include_str!("../../../docs/ui-l0-constructors.toml"); + + let mut documented: Vec<(String, Vec)> = Vec::new(); + let mut current: Option = None; + for line in TOML.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("[sources.\"") { + current = rest.strip_suffix("\"]").map(|s| s.to_string()); + if let Some(name) = ¤t { + documented.push((name.clone(), Vec::new())); + } + } else if line.starts_with('[') { + current = None; + } else if current.is_some() { + if let Some(rest) = line.strip_prefix("args = [") { + let args: Vec = rest + .trim_end_matches(']') + .split(',') + .map(|a| a.trim().trim_matches('"').to_string()) + .filter(|a| !a.is_empty()) + .collect(); + documented.last_mut().unwrap().1 = args; + } + } + } + + assert!(!documented.is_empty(), "the TOML must declare [sources.*]"); + + for (name, args) in &documented { + let implemented = catalog::source(name) + .unwrap_or_else(|| panic!("{name:?} is in the TOML but not in the Rust catalog")); + assert_eq!( + implemented, + args.as_slice(), + "arguments for {name:?} disagree between the TOML and Rust" + ); + } + for (name, args) in catalog::SOURCES { + let (_, doc_args) = documented + .iter() + .find(|(n, _)| n == name) + .unwrap_or_else(|| panic!("{name:?} is in the Rust catalog but not in the TOML")); + assert_eq!( + *args, + doc_args.as_slice(), + "arguments for {name:?} disagree between Rust and the TOML" + ); + } +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 17e2483..82017ae 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -158,3 +158,57 @@ tokens = ["money", "signed_money", "signed_pct", "compact", "ratio", "time", "d [kinds.width] accepts = ["token", "number"] tokens = ["fill", "fit", "day", "rank", "temp"] + +# ─── source capabilities ────────────────────────────────────────────────────── +# The CLOSED set of helpers a `source` may name. Like the constructor catalog +# above, this is derived from the reference cards rather than invented. +# +# Without it, `source_plan` hands an arbitrary dotted name to the host as the +# function to resolve, and a card can write +# +# source leak sys.shell(command: "cat /etc/passwd") +# +# which parses clean and produces a well-formed request. Confinement then rests +# entirely on the host having its own allowlist — the policy boundary §1 claims +# L0 removes. A card must not be able to NAME a capability it was not granted, +# and that is a property the profile can decide rather than delegate. +# +# `args` lists the argument names each helper accepts. An argument outside the +# list is rejected: 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. + +[sources."sys.geocode"] +args = ["name"] + +[sources."sys.weather"] +args = ["lat", "lon", "days", "fields", "aggregate"] + +[sources."sys.daylight"] +args = ["lat", "lon"] + +[sources."sys.airquality"] +args = ["lat", "lon"] + +[sources."sys.moonphase"] +args = ["lat", "lon"] + +[sources."sys.photo"] +args = ["query"] + +[sources."sys.locale"] +args = [] + +[sources."sys.news"] +args = ["count", "offset", "fields"] + +[sources."sys.news_item"] +args = ["id", "fields"] + +[sources."sys.movers"] +args = ["count", "fields"] + +[sources."sys.quote"] +args = ["ticker", "fields"] + +[sources."sys.series"] +args = ["ticker", "range", "points", "fields", "aggregate"] From 76e72792d3dc49528180bf69b5a411dfaa69b0c9 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:43:39 -0700 Subject: [PATCH 19/63] fix(ui_l0): retain the values the parser was recognising and dropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 266 ++++++++++++++++++---- crates/splash-core/tests/ui_profile_l0.rs | 167 ++++++++++++++ 2 files changed, 386 insertions(+), 47 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 9f2286a..afe6848 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -158,8 +158,8 @@ fn definition_digest(component: &Component) -> u64 { } for st in &component.states { acc.push_str(&format!( - "{}:{:?}:{:?}:{}", - st.path, st.shape, st.initial, st.keep + "{}:{:?}:{:?}:{:?}:{}", + st.path, st.shape, st.initial, st.initial_path, st.keep )); } for ev in &component.events { @@ -591,6 +591,11 @@ struct StateDecl { /// on a freshly-mounted state compares against null and takes the wrong /// branch on first render. initial: Option, + /// A path-valued `initial`, e.g. `initial: env.locale.temp_unit`. Held + /// separately because it resolves against injected data at realization, + /// not at parse. Dropping it made weather.card fall back to the enum's + /// first member, so a device set to Fahrenheit still rendered Celsius. + initial_path: Option, /// `keep: true` — profile §5.8. A schema change resets local state by /// default; this opts one cell out, and even then only while its own shape /// is unchanged. Preservation is never inferred from a shape that merely @@ -616,7 +621,7 @@ struct Transition { column: usize, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] enum Form { Set(SetSource), Toggle, @@ -628,7 +633,7 @@ enum Form { /// What `set(…)` assigns. Each is a value the runtime already holds; none is /// computed, so the transition stays total. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] enum SetSource { /// `$value` — the payload from the element that raised the event. Payload, @@ -638,6 +643,11 @@ enum SetSource { Text(String), /// A bound path, resolved against the data the runtime injected. Path(String), + /// A numeric literal. Without this `set(1)` parsed as `Payload` and did + /// nothing at all when no payload was present. + Num(f64), + /// A boolean literal. + Bool(bool), } #[derive(Debug)] @@ -690,7 +700,17 @@ enum Operand { Token(String), Str(String), Num(f64), - Predicate(String), + /// A comparison in argument position: `active: range == .d1`. + /// + /// Previously this kept only the left path and discarded the comparator and + /// right operand, so `active: range == .d1` realized as the VALUE of + /// `range` rather than as a boolean — live in stock.card, and invisible + /// because the device golden recorded the wrong rendering as correct. + Predicate { + path: String, + cmp: String, + rhs: Box, + }, } // ─────────────────────────────────────────────────────────────────────── parser ── @@ -885,8 +905,16 @@ impl<'a> Parser<'a> { } if self.peek().is_some_and(|t| t.is_punct("[")) { self.at += 1; - // The index is itself a path; it must resolve like any other. - let _ = self.dotted_name(); + // The index is itself a path and must be RETAINED, not just + // recognised: dropping it turned `stories[selected].title` into + // `stories.title`, which resolved to nothing and rendered an + // em dash. Held as a `[…]` segment so lookup can resolve the + // inner path and use its value as the key. + if let Some(index) = self.dotted_name() { + name.push_str(".["); + name.push_str(&index); + name.push(']'); + } self.expect_punct("]"); continue; } @@ -1004,6 +1032,7 @@ impl<'a> Parser<'a> { let path = self.dotted_name()?; let mut shape = Shape::Other; let mut initial = None; + let mut initial_path = None; let mut keep = false; // `{ shape: enum[a, b], initial: .a }` — capture the shape, skip the rest. let start = self.at; @@ -1029,6 +1058,24 @@ impl<'a> Parser<'a> { Kind::Ident if v.text == "true" || v.text == "false" => { Some(crate::JsonValue::Bool(v.text == "true")) } + Kind::Ident => { + // A path. Collect its dotted segments from the + // token stream; it resolves at realization. + let mut path = v.text.clone(); + let mut at = scan + 3; + while self.tokens.get(at).is_some_and(|t| t.is_punct(".")) + && self + .tokens + .get(at + 1) + .is_some_and(|t| t.kind == Kind::Ident) + { + path.push('.'); + path.push_str(&self.tokens[at + 1].text); + at += 2; + } + initial_path = Some(path); + None + } _ => None, }; } @@ -1065,6 +1112,7 @@ impl<'a> Parser<'a> { path, shape, initial, + initial_path, keep, }) } @@ -1177,9 +1225,30 @@ impl<'a> Parser<'a> { self.at += 1; } Kind::Punct if inner.text == "$" => { - // `$value` — the payload. + // `$value` — the payload. The name is checked: + // `$anything` used to be accepted as a synonym, + // so a typo silently became the payload. + self.at += 1; + match self.ident() { + Some(name) if name == "value" => {} + Some(other) => { + self.sink.at( + &inner, + format!( + "`${other}` is not a payload; L0 names it `$value`" + ), + ); + } + None => {} + } + } + Kind::Num => { + source = SetSource::Num(inner.text.parse().unwrap_or(0.0)); + self.at += 1; + } + Kind::Ident if inner.text == "true" || inner.text == "false" => { + source = SetSource::Bool(inner.text == "true"); self.at += 1; - let _ = self.ident(); } Kind::Ident => { source = self @@ -1564,10 +1633,16 @@ impl<'a> Parser<'a> { Kind::Ident => { let path = self.dotted_name().unwrap_or_default(); // A comparison in argument position is a predicate operand. - if self.peek().is_some_and(|t| t.kind == Kind::Cmp) { - self.at += 1; - self.at += 1; - return Operand::Predicate(path); + if let Some(op) = self.peek().cloned() { + if op.kind == Kind::Cmp { + self.at += 1; + let rhs = self.parse_operand(); + return Operand::Predicate { + path, + cmp: op.text, + rhs: Box::new(rhs), + }; + } } Operand::Path(path) } @@ -1585,6 +1660,58 @@ impl<'a> Parser<'a> { /// anything outside a group goes to the anonymous one. Keeping the default /// unnamed means a component with one slot needs no ceremony. /// The slot names a component declares. An anonymous `slot` is `""`. +/// Resolve an operand in a comparison's right-hand position. +/// Follow a dotted path into injected data. Used for a path-valued `initial`, +/// which resolves against what the host supplied rather than at parse time. +fn data_path(data: &crate::JsonValue, path: &str) -> Option { + let mut current = data.clone(); + for segment in path.split('.') { + current = match segment.parse::() { + Ok(i) => current.get(i)?.clone(), + Err(_) => current.get(segment)?.clone(), + }; + } + Some(current) +} + +fn scope_value(scope: &ValueScope, operand: &Operand) -> Option { + match operand { + Operand::Path(p) => scope.lookup(p), + Operand::Token(t) => Some(crate::JsonValue::String( + t.trim_start_matches('.').to_string(), + )), + Operand::Str(s) => Some(crate::JsonValue::String(s.clone())), + Operand::Num(n) => Some(crate::JsonValue::from(*n)), + // A nested comparison is not in the grammar. + Operand::Predicate { .. } => None, + } +} + +/// Compare two resolved values. Shared by guards (`when a == b`) and by +/// predicate arguments (`active: range == .d1`), so the two cannot drift. +fn compare(left: Option, cmp: &str, right: Option) -> bool { + let (Some(left), Some(right)) = (left, right) else { + // An operand that did not resolve cannot be compared. `!=` used to + // return true here, so a guard against an UNDECLARED name took its + // branch; an unresolvable comparison is now simply false. + return false; + }; + match cmp { + "==" => left == right, + "!=" => left != right, + _ => match (left.as_f64(), right.as_f64()) { + (Some(a), Some(b)) => match cmp { + "<" => a < b, + "<=" => a <= b, + ">" => a > b, + ">=" => a >= b, + _ => false, + }, + _ => false, + }, + } +} + fn slot_names(component: &Component) -> Vec { fn walk(elements: &[Element], out: &mut Vec) { for e in elements { @@ -2183,6 +2310,12 @@ fn walk( }) = element.args.first() { check_path(p, scope, *line, *column, sink); + // The right operand is a binding too. Unchecked, an undeclared + // name on the right resolved to nothing and the comparison + // decided the branch on that absence. + if let Some(Operand::Path(r)) = element.rhs.as_ref() { + check_path(r, scope, *line, *column, sink); + } } } _ if element.is_reference => { @@ -2335,7 +2468,14 @@ fn check_arg( // event name — profile §5.4, outputs are event props. Accept either. (Operand::Path(path), None) if scope.events.iter().any(|e| e == path) => {} (Operand::Path(path), _) => check_path(path, scope, arg.line, arg.column, sink), - (Operand::Predicate(path), _) => check_path(path, scope, arg.line, arg.column, sink), + (Operand::Predicate { path, rhs, .. }, _) => { + check_path(path, scope, arg.line, arg.column, sink); + // The right operand is a binding too. Leaving it unchecked is how + // `when selected != absent` took its branch with `absent` undeclared. + if let Operand::Path(r) = rhs.as_ref() { + check_path(r, scope, arg.line, arg.column, sink); + } + } _ => {} } @@ -2346,6 +2486,14 @@ fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut if path.is_empty() { return; } + + // A `[inner]` segment holds a path of its own. Checking it here is what + // makes `stories[typo].title` a diagnostic rather than a silent em dash. + for segment in path.split('.') { + if let Some(inner) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + check_path(inner, scope, line, column, sink); + } + } let root = root_of(path); // `.$state` is the fetch lifecycle (§5.9). It is meaningful only for @@ -2553,6 +2701,7 @@ fn realize_inner( .cloned() .or_else(|| data.get(&state.path).cloned()) .or_else(|| state.initial.clone()) + .or_else(|| state.initial_path.as_ref().and_then(|p| data_path(data, p))) .unwrap_or_else(|| initial_for(&state.shape)); frames.push((state.path.clone(), value)); } @@ -2636,6 +2785,18 @@ impl ValueScope<'_> { None => self.data.get(root)?.clone(), }; for segment in segments { + // `[inner]` — resolve the inner path, then index by its value. + if let Some(inner) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + let key = self.lookup(inner)?; + current = match &key { + crate::JsonValue::String(k) => current.get(k.as_str())?.clone(), + v => match v.as_u64() { + Some(i) => current.get(i as usize)?.clone(), + None => return None, + }, + }; + continue; + } current = match segment.parse::() { Ok(index) => current.get(index)?.clone(), Err(_) => current.get(segment)?.clone(), @@ -2782,7 +2943,7 @@ impl Realizer<'_> { // it away. Operand::Str(t) => crate::JsonValue::String(t.clone()), Operand::Num(n) => crate::JsonValue::from(*n), - Operand::Predicate(p) => scope + Operand::Predicate { path: p, .. } => scope .lookup(p) .unwrap_or_else(|| crate::JsonValue::String(p.clone())), }; @@ -2813,9 +2974,11 @@ impl Realizer<'_> { }) .and_then(|s| s.get(&instance_key, &state.path)) .cloned(); + let from_path = state.initial_path.as_ref().and_then(|p| scope.lookup(p)); scope.frames.push(( state.path.clone(), live.or_else(|| state.initial.clone()) + .or(from_path) .unwrap_or_else(|| initial_for(&state.shape)), )); bound += 1; @@ -2913,35 +3076,8 @@ impl Realizer<'_> { // Bare boolean guard — amendment #10. return matches!(left, Some(crate::JsonValue::Bool(true))); }; - let right = match element.rhs.as_ref() { - Some(Operand::Path(p)) => scope.lookup(p), - Some(Operand::Token(t)) => Some(crate::JsonValue::String( - t.trim_start_matches('.').to_string(), - )), - Some(Operand::Str(s)) => Some(crate::JsonValue::String(s.clone())), - Some(Operand::Num(n)) => crate::JsonValue::from(*n).into(), - _ => None, - }; - - // A comparison against a literal the parser did not retain cannot be - // decided here; treat it as false rather than guess. - let (Some(left), Some(right)) = (left, right) else { - return cmp == "!="; - }; - match cmp { - "==" => left == right, - "!=" => left != right, - _ => match (left.as_f64(), right.as_f64()) { - (Some(a), Some(b)) => match cmp { - "<" => a < b, - "<=" => a <= b, - ">" => a > b, - ">=" => a >= b, - _ => false, - }, - _ => false, - }, - } + let right = element.rhs.as_ref().and_then(|r| scope_value(scope, r)); + compare(left, cmp, right) } fn value( @@ -2971,7 +3107,10 @@ impl Realizer<'_> { Operand::Token(t) => NodeValue::Token(t.trim_start_matches('.').to_string()), Operand::Str(s) => NodeValue::Text(s.clone()), Operand::Num(n) => NodeValue::Number(*n), - Operand::Predicate(p) | Operand::Path(p) => match scope.lookup(p) { + Operand::Predicate { path, cmp, rhs } => { + NodeValue::Bool(compare(scope.lookup(path), cmp, scope_value(scope, rhs))) + } + Operand::Path(p) => match scope.lookup(p) { Some(crate::JsonValue::String(s)) => NodeValue::Text(s), Some(crate::JsonValue::Bool(b)) => NodeValue::Bool(b), Some(v) => match v.as_f64() { @@ -3565,6 +3704,30 @@ pub fn dispatch_with( instance_key: &str, event: &str, payload: Option<&crate::JsonValue>, +) -> bool { + dispatch_with_data( + source, + store, + instance_key, + event, + payload, + &crate::JsonValue::Null, + ) +} + +/// `dispatch_with`, plus the data a `set(path)` reads from. +/// +/// Separated rather than folded in because most events do not read: `toggle`, +/// `cycle`, `clear` and `set(.token)` need nothing. Only a transition that +/// names a path needs the host's data, and passing Null simply makes that one +/// form a no-op instead of writing something wrong. +pub fn dispatch_with_data( + source: &str, + store: &mut InstanceStore, + instance_key: &str, + event: &str, + payload: Option<&crate::JsonValue>, + data: &crate::JsonValue, ) -> bool { let mut sink = Diagnostics::default(); let Some(tokens) = lex(source, &mut sink) else { @@ -3641,13 +3804,22 @@ pub fn dispatch_with( crate::JsonValue::String(members[(at + 1) % members.len()].clone()) } (Form::Set(source), _) => match source { - SetSource::Payload | SetSource::Path(_) => match payload { + SetSource::Payload => match payload { Some(v) => v.clone(), // No payload is a no-op rather than a silent clear: falling // back to the initial would look like a deliberate reset. None => continue, }, + // A path READS. Treating it as the payload meant + // `n: set(config.answer)` wrote whatever the tap carried — or + // nothing at all when it carried nothing. + SetSource::Path(p) => match data_path(data, p) { + Some(v) => v, + None => continue, + }, SetSource::Token(t) | SetSource::Text(t) => crate::JsonValue::String(t.clone()), + SetSource::Num(n) => crate::JsonValue::from(*n), + SetSource::Bool(b) => crate::JsonValue::Bool(*b), }, _ => continue, }; @@ -3958,7 +4130,7 @@ fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec { + Operand::Path(p) | Operand::Predicate { path: p, .. } => { let root = root_of(p); if !binders.contains(&root) { reads.push(root); diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 3f64526..fc1f1e1 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -1974,3 +1974,170 @@ fn the_source_catalog_matches_the_toml_spec() { ); } } + +// ─── parse-then-discard: forms recognised but whose values were dropped ────── +// +// Every one of these parsed cleanly and then lost the value. That is the single +// most common defect in this work, and each of these was found by the codex +// review rather than by a failing test — which is the point of writing them. + +/// `active: range == .d1` realized as the VALUE of `range` rather than a +/// boolean, because the parser kept the left path and dropped the comparator +/// and right operand. Live in stock.card, and the device golden recorded the +/// wrong rendering as correct. +#[test] +fn a_predicate_argument_evaluates_to_a_boolean() { + let source = "state range { shape: enum[d1, w1], initial: .d1 }\n\ + view root Col { Chip(text: \"1D\", active: range == .d1) \ + Chip(text: \"1W\", active: range == .w1) }"; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut chips = Vec::new(); + find(&root, "Chip", &mut chips); + assert_eq!(chips.len(), 2); + let active = |c: &splash_core::ui_l0::UiNode| { + c.args + .iter() + .find(|(n, _)| n == "active") + .map(|(_, v)| v.clone()) + }; + assert_eq!(active(chips[0]), Some(NodeValue::Bool(true))); + assert_eq!(active(chips[1]), Some(NodeValue::Bool(false))); +} + +/// A guard's right operand is a binding too. Unchecked, `when x != absent` +/// resolved `absent` to nothing and the comparison decided the branch on that +/// absence — so the branch rendered with an undeclared name in it. +#[test] +fn a_guard_right_operand_must_be_declared() { + let report = check_ui_l0_named( + "rhs", + "state selected { shape: text, initial: \"\" }\n\ + view root Col { when selected != absent { Rule() } }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("absent")), + "{:#?}", + report.diagnostics + ); +} + +/// `initial: env.locale.temp_unit` was recognised and dropped, so the state +/// fell back to the enum's first member — a phone set to Fahrenheit rendered +/// Celsius, which is weather.card's actual declaration. +#[test] +fn a_path_valued_initial_resolves_against_data() { + let source = "source env.locale sys.locale()\n\ + state units { shape: enum[c, f], initial: env.locale.temp_unit }\n\ + view root TextRow(text: units)"; + for locale in ["f", "c"] { + let data = serde_json::json!({"env": {"locale": {"temp_unit": locale}}}); + let report = realize(source, &data, RealizeLimits::default()); + let root = report.root.unwrap(); + assert_eq!( + root.args.iter().find(|(n, _)| n == "text").unwrap().1, + NodeValue::Text(locale.into()), + "the declared initial must follow the host's locale" + ); + } +} + +/// `stories[selected].title` became `stories.title` — the index was parsed and +/// thrown away, so it resolved to nothing. +#[test] +fn a_dynamic_index_resolves_through_its_inner_path() { + let source = "source movers sys.movers()\n\ + state selected { shape: text, initial: \"b\" }\n\ + view root TextRow(text: movers[selected].title)"; + let data = serde_json::json!({"movers": {"a": {"title": "FIRST"}, "b": {"title": "SECOND"}}}); + let report = realize(source, &data, RealizeLimits::default()); + let root = report.root.unwrap(); + assert_eq!( + root.args.iter().find(|(n, _)| n == "text").unwrap().1, + NodeValue::Text("SECOND".into()) + ); +} + +#[test] +fn a_dynamic_index_path_is_itself_checked() { + let report = check_ui_l0_named( + "idx", + "source movers sys.movers()\nview root TextRow(text: movers[typo].title)", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("typo")), + "{:#?}", + report.diagnostics + ); +} + +/// `set` had no numeric or boolean variant, so `set(1)` fell through to the +/// payload form and did nothing at all when no payload was present. And +/// `SetSource::Path` was treated exactly like `$value`, so `set(config.answer)` +/// wrote whatever the tap carried rather than what it named. +#[test] +fn every_set_form_writes_what_it_names() { + let card = "state n { shape: number, initial: 0 }\n\ + state ok { shape: bool, initial: false }\n\ + source movers sys.movers(count: 1)\n\ + event bump { n: set(1) }\n\ + event frompath { n: set(movers.answer) }\n\ + event yes { ok: set(true) }\n\ + view root Row(on_tap: bump) { Rule() }"; + let data = serde_json::json!({"movers": {"answer": 7}}); + let mut store = splash_core::ui_l0::InstanceStore::default(); + + splash_core::ui_l0::dispatch_with_data(card, &mut store, "root", "bump", None, &data); + assert_eq!(store.get("@card", "n").and_then(|v| v.as_f64()), Some(1.0)); + + splash_core::ui_l0::dispatch_with_data(card, &mut store, "root", "frompath", None, &data); + assert_eq!(store.get("@card", "n").and_then(|v| v.as_f64()), Some(7.0)); + + splash_core::ui_l0::dispatch_with_data(card, &mut store, "root", "yes", None, &data); + assert_eq!( + store.get("@card", "ok").and_then(|v| v.as_bool()), + Some(true) + ); + + // A payload must not hijack a transition that names a path to read. + let stray = serde_json::json!("STRAY"); + splash_core::ui_l0::dispatch_with_data( + card, + &mut store, + "root", + "frompath", + Some(&stray), + &data, + ); + assert_eq!( + store.get("@card", "n").and_then(|v| v.as_f64()), + Some(7.0), + "set(path) reads its path, not the payload" + ); +} + +#[test] +fn only_dollar_value_names_the_payload() { + // `$anything` was accepted as a synonym, so a typo silently became $value. + let report = check_ui_l0_named( + "typo", + "state n { shape: number, initial: 0 }\nevent e { n: set($valu) }\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("$value")), + "{:#?}", + report.diagnostics + ); +} From b78a3f2939a155eb4353b94f23cbbf2306cf7ad5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:50:48 -0700 Subject: [PATCH 20/63] fix(ui_l0): lower the interaction the cards declare, not just their appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 60 ++++++++++++++++++----- crates/splash-core/tests/ui_profile_l0.rs | 58 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index afe6848..c9a903f 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -2848,8 +2848,8 @@ impl Realizer<'_> { // takes the group a call site directed with `into header`. let wanted = element.binders.first().cloned().unwrap_or_default(); if let Some((_, children)) = groups.iter().find(|(n, _)| *n == wanted) { - for (i, child) in children.iter().enumerate() { - self.element(child, scope, &format!("{key}/s{i}"), out); + for (segment, child) in sibling_segments(children) { + self.element(child, scope, &format!("{key}/s:{segment}"), out); } } self.slots.push(groups); @@ -2858,8 +2858,8 @@ impl Realizer<'_> { "for" => self.iteration(element, scope, key, out), "when" => { if self.guard_holds(element, scope) { - for (i, child) in element.children.iter().enumerate() { - self.element(child, scope, &format!("{key}/w{i}"), out); + for (segment, child) in sibling_segments(&element.children) { + self.element(child, scope, &format!("{key}/w:{segment}"), out); } } } @@ -3052,8 +3052,8 @@ impl Realizer<'_> { bound += 1; } - for (i, child) in element.children.iter().enumerate() { - self.element(child, scope, &format!("{key}[{item_key}]/{i}"), out); + for (segment, child) in sibling_segments(&element.children) { + self.element(child, scope, &format!("{key}[{item_key}]/{segment}"), out); } for _ in 0..bound { @@ -3152,6 +3152,9 @@ pub mod makepad { const BASE: &str = "#0a0e14"; const SCRIM: &str = "#00000066"; const PANEL: &str = "#ffffff12"; + /// A selected chip. Brighter than PANEL so "which one is active" is legible + /// at a glance rather than a subtle tint. + const ACTIVE: &str = "#ffffff38"; const HAIRLINE: &str = "#ffffff1a"; const TEXT: &str = "#ffffff"; const SOFT: &str = "#ffffffe6"; @@ -3168,6 +3171,29 @@ pub mod makepad { out } + /// The tap binding for a node that declares `on_tap`. + /// + /// `UiNode` carried the event name and the instance key all along and + /// lowering emitted neither, so every Row, Card, Chip and TextHero rendered + /// inert: the stock range controls could not reach `dispatch` at all. The + /// key is what lets a tap name WHICH instance was hit, which is the whole + /// basis of §5.1 identity. + fn tap_binding(node: &UiNode) -> String { + let Some(NodeValue::Event(event)) = arg(node, "on_tap") else { + return String::new(); + }; + let mut out = format!(" l0_event: {event:?} l0_key: {:?}", node.key); + // `value:` is the payload the event carries — `$value` at the receiving + // transition. Without it a tap says "this happened" but not "to what". + match arg(node, "value") { + Some(NodeValue::Text(t)) => out.push_str(&format!(" l0_value: {t:?}")), + Some(NodeValue::Number(n)) => out.push_str(&format!(" l0_value: {:?}", trim_num(*n))), + Some(NodeValue::Token(t)) => out.push_str(&format!(" l0_value: {t:?}")), + _ => {} + } + out + } + fn arg<'a>(node: &'a UiNode, name: &str) -> Option<&'a NodeValue> { node.args.iter().find(|(n, _)| n == name).map(|(_, v)| v) } @@ -3347,7 +3373,8 @@ pub mod makepad { "Row" => { let _ = writeln!( out, - "{p}View{{ width: Fill height: Fit flow: Right align: Align{{y: 0.5}} spacing: 6" + "{p}View{{ width: Fill height: Fit flow: Right align: Align{{y: 0.5}} spacing: 6{}", + tap_binding(node) ); children(node, depth, out); let _ = writeln!(out, "{p}}}"); @@ -3357,7 +3384,8 @@ pub mod makepad { out, "{p}RoundedView{{ width: Fill height: Fit flow: Down new_batch: true \ draw_bg.color: {PANEL} draw_bg.border_radius: 14.0 \ - margin: Inset{{top: 16}} padding: Inset{{left: 14 right: 14 top: 12 bottom: 12}}" + margin: Inset{{top: 16}} padding: Inset{{left: 14 right: 14 top: 12 bottom: 12}}{}", + tap_binding(node) ); children(node, depth, out); let _ = writeln!(out, "{p}}}"); @@ -3453,14 +3481,21 @@ pub mod makepad { let _ = writeln!(out, "{p}StockPlot{{ width: Fill height: 180 }}"); } "Chip" => { + // `active` selects the fill. Dropping it made every range chip + // render identically, so a card could not show which was + // selected — and the golden recorded that as correct. + let on = matches!(arg(node, "active"), Some(NodeValue::Bool(true))); + let (fill, ink) = if on { (ACTIVE, TEXT) } else { (PANEL, SOFT) }; let _ = writeln!( out, - "{p}RoundedView{{ width: Fit height: Fit draw_bg.color: {PANEL} \ - draw_bg.border_radius: 12.0 padding: Inset{{left: 10 right: 10 top: 5 bottom: 5}}" + "{p}RoundedView{{ width: Fit height: Fit draw_bg.color: {fill} \ + draw_bg.border_radius: 12.0 padding: Inset{{left: 10 right: 10 top: 5 bottom: 5}}\ + {}", + tap_binding(node) ); let _ = writeln!( out, - "{p} TextCaption{{ text: {} draw_text.color: {SOFT} }}", + "{p} TextCaption{{ text: {} draw_text.color: {ink} }}", text_of(arg(node, "text")) ); let _ = writeln!(out, "{p}}}"); @@ -3510,7 +3545,8 @@ pub mod makepad { }; let _ = writeln!( out, - "{p}{role}{{{width} text: {body} draw_text.color: {colour}{size} }}" + "{p}{role}{{{width} text: {body} draw_text.color: {colour}{size}{} }}", + tap_binding(node) ); } other => { diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index fc1f1e1..4f0496f 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -2141,3 +2141,61 @@ fn only_dollar_value_names_the_payload() { report.diagnostics ); } + +// ─── the Makepad backend must carry interaction, not just appearance ───────── + +/// `UiNode` carried the event name and instance key all along and lowering +/// emitted neither, so every tappable node rendered inert — the stock range +/// controls could not reach `dispatch` at all. The cards looked right and did +/// nothing, which no golden image could detect. +#[test] +fn lowering_emits_the_event_and_the_instance_key() { + let data = serde_json::json!({ + "movers": [{ "ticker": "NVDA", "name": "NVIDIA", "last": 184.2, + "change": 3.1, "pct": 1.7 }], + "quote": {}, "series": {}, "selected": "", "range": "m1", + "env": { "locale": {} }, "copy": { "movers": "Top Movers" } + }); + let dsl = splash_core::ui_l0::makepad::lower( + &realize(STOCK, &data, RealizeLimits::default()) + .root + .unwrap(), + ); + assert!( + dsl.contains("l0_event: \"open_quote\""), + "a tappable row must carry its event" + ); + assert!( + dsl.contains("l0_key:"), + "and the key naming WHICH instance was tapped" + ); + assert!( + dsl.contains("l0_value: \"NVDA\""), + "and the payload, or a tap says what happened but not to what" + ); +} + +/// `active` selected nothing, so every range chip rendered identically and the +/// card could not show which was selected. +#[test] +fn lowering_distinguishes_an_active_chip() { + let source = "state range { shape: enum[d1, w1], initial: .d1 }\n\ + view root Col { Chip(text: \"1D\", active: range == .d1) \ + Chip(text: \"1W\", active: range == .w1) }"; + let dsl = splash_core::ui_l0::makepad::lower( + &realize(source, &serde_json::json!({}), RealizeLimits::default()) + .root + .unwrap(), + ); + let fills: Vec<&str> = dsl + .lines() + .filter(|l| l.contains("RoundedView")) + .filter_map(|l| l.split("draw_bg.color: ").nth(1)) + .filter_map(|l| l.split_whitespace().next()) + .collect(); + assert_eq!(fills.len(), 2, "two chips"); + assert_ne!( + fills[0], fills[1], + "the selected chip must not look like the unselected one" + ); +} From d6e969ae7298ebd408c517c22a7084fc0559fe68 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:56:44 -0700 Subject: [PATCH 21/63] feat(ui_l0): check the header, and enforce the no-facts rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 180 +++++++++++++++++++++- crates/splash-core/tests/ui_profile_l0.rs | 104 +++++++++++++ docs/ui-l0-constructors.toml | 13 +- 3 files changed, 286 insertions(+), 11 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index c9a903f..6ef36e2 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -43,6 +43,8 @@ pub struct UiL0Report { pub level: Level, pub diagnostics: Vec, pub diagnostics_truncated: bool, + /// The header a card declares — profile §2. `None` when it has none. + pub header: Option, /// Profile §7: every component the card depends on, with a digest of its /// definition, sorted by name. /// @@ -55,6 +57,61 @@ pub struct UiL0Report { pub closure: Vec<(String, u64)>, } +/// What a card's header declares — ledger name, version, level and profile. +/// +/// §7 says "level is declared in the header and checked at append", and nothing +/// read it: every `#` line lexed as a comment, so a card could declare +/// `# level: L2` and be accepted and realized as L0. A declared level that is +/// never compared to the derived one is not a check, it is a comment. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CardHeader { + pub ledger: Option, + pub version: Option, + pub level: Option, + pub profile: Option, + pub model: Option, +} + +/// Read the header from the leading `#` lines, which the lexer discards. +pub fn parse_header(source: &str) -> Option { + let mut header = CardHeader::default(); + let mut saw_any = false; + for line in source.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix('#') else { + if line.is_empty() { + continue; + } + break; // The header is the leading comment block only. + }; + let rest = rest.trim(); + if let Some(decl) = rest.strip_prefix("ledger ") { + saw_any = true; + let (name, version) = match decl.split_once('@') { + Some((n, v)) => (n.trim().to_string(), Some(v.trim().to_string())), + None => (decl.trim().to_string(), None), + }; + header.ledger = Some(name); + header.version = version; + } else if let Some((key, value)) = rest.split_once(':') { + let value = value.trim().to_string(); + match key.trim() { + "level" => { + saw_any = true; + header.level = Some(value); + } + "profile" => { + saw_any = true; + header.profile = Some(value); + } + "model" => header.model = Some(value), + _ => {} + } + } + } + saw_any.then_some(header) +} + /// Diagnostics retained before truncation, so a pathological source cannot grow /// the report without bound. pub const MAX_UI_L0_DIAGNOSTICS: usize = 64; @@ -70,6 +127,7 @@ pub fn check_ui_l0(source: &str) -> UiL0Report { /// Check source against the L0 profile, naming it for diagnostics. pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { let mut sink = Diagnostics::default(); + let header = parse_header(source); if source.len() > DEFAULT_MAX_SOURCE_BYTES { sink.push( @@ -104,9 +162,56 @@ pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { validate(&card, &mut sink); let mut report = sink.into_report(Level::L0); report.closure = component_closure(&card); + report.header = header.clone(); + check_header(&header, report.level, &mut report); report } +/// §7: a declared level must match the derived one, and escalation is never +/// silent. Reading the header without comparing it would leave the declaration +/// decorative — which is what it was. +fn check_header(header: &Option, derived: Level, report: &mut UiL0Report) { + let Some(header) = header else { return }; + + if let Some(declared) = &header.level { + let matches = match declared.trim() { + "L0" => derived == Level::L0, + "L1" => derived == Level::L1, + "L2" => derived == Level::L2, + _ => false, + }; + if !matches { + report.valid = false; + report.diagnostics.push(SyntaxDiagnostic { + line: 1, + column: 1, + message: format!( + "the header declares level {declared}, but the card requires {derived:?} \ + — a record needing a wider grammar is rejected until the level is \ + explicitly raised (profile §7)" + ), + }); + } + } + + // The profile names which grammar the card was written against. A card + // claiming a profile this checker does not implement must not be silently + // accepted as though it had claimed this one. + if let Some(profile) = &header.profile { + if profile.trim() != "ui/l0" { + report.valid = false; + report.diagnostics.push(SyntaxDiagnostic { + line: 1, + column: 1, + message: format!( + "the header declares profile {profile:?}, but this checker implements \ + \"ui/l0\"" + ), + }); + } + } +} + /// A digest per component definition, sorted by name — profile §7. /// /// The digest covers everything that can move a card's level or change what it @@ -209,6 +314,7 @@ impl Diagnostics { fn into_report(self, level: Level) -> UiL0Report { UiL0Report { + header: None, closure: Vec::new(), valid: self.items.is_empty() && level == Level::L0, level, @@ -1790,8 +1896,15 @@ pub mod catalog { Any, /// A declared event name. Event, - /// A number literal or a path; also admits a width token. + /// A number literal or a path; also admits a width token. Layout, not + /// data — `gap: 6` and `cols: 2` are structure the card chooses. Number, + /// A bound path that renders a FACT. Distinct from `Number` because + /// `gap: 6` is layout the card is entitled to choose and + /// `value: 41.2` is a measurement it is not (§4). Without the + /// distinction the no-facts rule cannot be stated in the catalog at + /// all, so both were `Number` and neither was checked. + Data, /// Text literal or path. Text, /// A predicate or a bool path. @@ -1841,7 +1954,7 @@ pub mod catalog { "TextHero", &[ ("text", Text), - ("value", Number), + ("value", Data), ("unit", TokenOrPath(UNIT)), ("format", Token(FORMAT)), ("on_tap", Event), @@ -1854,14 +1967,14 @@ pub mod catalog { ("TextBody", &[("text", Text), ("width", TokenOrPath(WIDTH))]), ( "TextStat", - &[("value", Number), ("format", Token(FORMAT)), ("tint", Path)], + &[("value", Data), ("format", Token(FORMAT)), ("tint", Path)], ), ("TextRow", &[("text", Text), ("width", TokenOrPath(WIDTH))]), ( "TextCaption", &[ ("text", Text), - ("value", Number), + ("value", Data), ("glyph", Text), ("suffix", Text), ("unit", TokenOrPath(UNIT)), @@ -1871,7 +1984,7 @@ pub mod catalog { ( "TextValue", &[ - ("value", Number), + ("value", Data), ("unit", TokenOrPath(UNIT)), ("format", Token(FORMAT)), ("tint", Path), @@ -1881,7 +1994,7 @@ pub mod catalog { "Tile", &[ ("label", Text), - ("value", Number), + ("value", Data), ("unit", TokenOrPath(UNIT)), ("format", Token(FORMAT)), ], @@ -2434,6 +2547,61 @@ fn check_arg( ); } } + (Operand::Str(lit), Some(Path)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} takes a bound path, not the literal {lit:?} — a literal in a \ + data position is a model-authored fact (profile §4)", + arg.name + ), + ); + } + (Operand::Num(lit), Some(Path)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} takes a bound path, not the literal {lit} — a literal in a \ + data position is a model-authored fact (profile §4)", + arg.name + ), + ); + } + // A data position renders a fact and must be bound, not authored. + (Operand::Str(_) | Operand::Num(_), Some(Data)) => { + let shown = match &arg.value { + Operand::Num(n) => format!("{n}"), + Operand::Str(t) => format!("{t:?}"), + other => format!("{other:?}"), + }; + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} renders a value and must be bound; {shown} is a literal the \ + model authored (profile §4). Layout numbers like `gap:` may be literal; \ + a measurement may not", + arg.name + ), + ); + } + // A number position rendering a fact must be bound, not authored. + // `TextHero(value: "34 mph")` is the case §4 exists to make + // unrepresentable: units concatenated onto a value the model invented. + (Operand::Str(lit), Some(Number)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} renders a value and must be bound; {lit:?} is a literal, and \ + text in a number position is a fact the model authored (profile §4). \ + Units belong in `unit:`, formatting in `format:`", + arg.name + ), + ); + } (Operand::Token(tok), Some(Path)) => { sink.push( arg.line, diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 4f0496f..948a3a7 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -2199,3 +2199,107 @@ fn lowering_distinguishes_an_active_chip() { "the selected chip must not look like the unselected one" ); } + +// ─── §7 the header is checked, not decorated ───────────────────────────────── + +#[test] +fn the_header_is_read_from_a_real_card() { + let report = check_ui_l0_named("weather", WEATHER); + let header = report.header.expect("weather.card declares a header"); + assert_eq!(header.ledger.as_deref(), Some("weather")); + assert_eq!(header.version.as_deref(), Some("1.0.0")); + assert_eq!(header.level.as_deref(), Some("L0")); + assert_eq!(header.profile.as_deref(), Some("ui/l0")); +} + +/// §7: "Level is declared in the header and checked at append." Nothing read +/// it — every `#` line lexed as a comment — so a card could declare L2 and be +/// accepted and realized as L0. A declaration never compared to the derived +/// level is a comment, not a check. +#[test] +fn a_header_declaring_the_wrong_level_is_rejected() { + let report = check_ui_l0_named("evil", "# ledger evil@1.0.0\n# level: L2\nview root Rule()"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("declares level L2")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_header_declaring_an_unknown_profile_is_rejected() { + let report = check_ui_l0_named( + "wrong", + "# ledger x@1.0.0\n# profile: nonsense/v9\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("ui/l0")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_card_without_a_header_is_still_accepted() { + // The header is optional; only a header that CONTRADICTS the card is an + // error. Requiring one would reject every inline fragment in these tests. + let report = check_ui_l0_named("bare", "view root Rule()"); + assert!(report.valid, "{:#?}", report.diagnostics); + assert!(report.header.is_none()); +} + +// ─── §4 the no-facts rule ──────────────────────────────────────────────────── + +/// §4 calls a model-authored literal in a data position "a structural +/// violation, not a judgement call". It was neither — `kind = "path"` was +/// declared in the catalog and never enforced, so every drawing widget accepted +/// invented values. +#[test] +fn a_literal_in_a_data_position_is_rejected() { + for source in [ + "view root TextHero(value: \"34 mph\")", + "view root TextStat(value: 41.2)", + "view root TempBar(lo: 5, hi: 9, min: 0, max: 10)", + "view root WeatherIcon(cond: \"sunny\")", + ] { + let report = check_ui_l0_named("facts", source); + assert!(!report.valid, "should be rejected: {source}"); + assert!( + report.diagnostics.iter().any(|d| d.message.contains("§4")), + "the diagnostic should cite the rule: {:#?}", + report.diagnostics + ); + } +} + +/// The line the rule actually draws: a card MAY choose its own layout, and may +/// not invent a measurement. Without this distinction the check either rejects +/// `gap: 6` or permits `value: 41.2`. +#[test] +fn a_layout_literal_is_not_a_fact() { + for source in [ + "view root Row(gap: 6) { Rule() }", + "view root Grid(cols: 2) { Rule() }", + "view root Col(gap: 3) { Rule() }", + ] { + let report = check_ui_l0_named("layout", source); + assert!(report.valid, "{source} — {:#?}", report.diagnostics); + } +} + +#[test] +fn a_bound_value_is_accepted() { + let report = check_ui_l0_named( + "bound", + "source now sys.weather(lat: 1.0, lon: 2.0)\nview root TextStat(value: now.temp)", + ); + assert!(report.valid, "{:#?}", report.diagnostics); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 82017ae..f4002a2 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -18,6 +18,9 @@ # path a bound path — source, state, prop, copy or loop binder # event a declared event name (local or received as a prop) # number/text/bool a literal of that type, or a path of that shape +# data a bound path that renders a FACT. Distinct from `number` +# because `gap: 6` is layout the card may choose and +# `value: 41.2` is a measurement it may not (§4 no-facts) # any path or literal # record/collection component props only @@ -58,7 +61,7 @@ cols = { kind = "number" } [TextHero] text = { kind = "text" } -value = { kind = "number" } +value = { kind = "data" } unit = { kind = "unit" } format = { kind = "format" } on_tap = { kind = "event" } @@ -72,7 +75,7 @@ text = { kind = "text" } width = { kind = "width" } [TextStat] -value = { kind = "number" } +value = { kind = "data" } format = { kind = "format" } tint = { kind = "path" } @@ -82,21 +85,21 @@ width = { kind = "width" } [TextCaption] text = { kind = "text" } -value = { kind = "number" } +value = { kind = "data" } glyph = { kind = "text" } suffix = { kind = "text" } unit = { kind = "unit" } width = { kind = "width" } [TextValue] -value = { kind = "number" } +value = { kind = "data" } unit = { kind = "unit" } format = { kind = "format" } tint = { kind = "path" } [Tile] label = { kind = "text" } -value = { kind = "number" } +value = { kind = "data" } unit = { kind = "unit" } format = { kind = "format" } From 0d56bf94197f79406d53374a0b11b6990bc8c673 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:58:26 -0700 Subject: [PATCH 22/63] docs: correct the claims in the profile that were false or overstated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ui-profile-l0.md | 89 +++++++++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 21 deletions(-) diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 687a37a..8e6c537 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -1,7 +1,7 @@ # Splash UI Profile — Level 0 **Status:** **implemented.** Normative producer contract for generated UI source at Level 0. -Parser, validator, realizer and instance store live in `splash-core::ui_l0`; 48 tests, three +Parser, validator, realizer and instance store live in `splash-core::ui_l0`; 254 tests, three reference cards, and a card rendered on a OnePlus 6T through an unmodified host. **Relationship to [Grammar v0.2](grammar.md):** a *sibling* canonical profile, not a subset of the workflow language. `check_syntax` rejects UI source and `eval_vm_compatibility` must not @@ -89,13 +89,15 @@ root = ident ; transition-block = "{" , transition , { "," , transition } , "}" ; transition = path , ":" , total-form ; total-form = "set" , "(" , ( literal | token | path | "$value" ) , ")" + (* literal = string | number | "true" | "false" *) | "toggle" (* boolean state only *) | "cycle" , "(" , token , { "," , token } , ")" | "clear" ; (* restore declared initial *) shape-spec = "shape" , ":" , shape , [ "," , "initial" , ":" , ( literal | path ) ] , [ "," , "keep" , ":" , "true" ] ; -shape = "text" | "number" | "bool" | "enum" , "[" , ident , { "," , ident } , "]" ; +shape = "text" | "number" | "bool" | "enum" , "[" , ident , { "," , ident } , "]" + | "record" | "collection" | "event" ; (* prop shapes, §5.2 *) provenance = "vocabulary" | "user-copy" | "model-copy" ; ``` @@ -294,6 +296,19 @@ instances each containing an unkeyed `Toggle` must not share one state cell. component type and keys both affect preservation. L0 binds to a declared path so that identity survives a ledger edit that inserts an element above. +**"Version" here means the STATE SCHEMA version** (§5.8), not the component's whole definition. +Using the definition digest would contradict §5.8 directly: a view-only edit would change +identity and reset state, which is precisely what §5.8 promises it does not do. The two are +different instruments — the schema id decides whether live state still fits, and the closure +digest (§7) tells a host that a definition was replaced at all. + +**What path-based identity does and does not survive.** Insertion of a differently named +sibling is stable, because siblings are numbered within each element name. Reordering two +same-name siblings, or inserting another instance of the same component above, still moves the +ordinals — so state follows the ordinal rather than the instance. Fully edit-stable identity +needs a persistent site id in the source or edit provenance from the ledger; neither exists +yet, and the numbering is an improvement on tree position rather than a solution. + ### 5.2 Props ```ebnf @@ -399,6 +414,12 @@ runtime: state start { shape: date, initial: today, max: end } ``` +**That example is illustrative, not grammatical.** `date` is not one of §2's shapes, `today` +is not a literal form, and `max:` is not part of `shape-spec` — it sketches where such an +invariant would belong if the shape vocabulary grew, and nothing in the profile accepts it +today. It is retained because the *limit* it describes is real: total forms cannot compare, so +a cross-field invariant has nowhere to live at L0. + If that is insufficient, the card is L1 — and should say so rather than smuggling a comparison into a transition. @@ -488,11 +509,21 @@ is the worst of the three outcomes. state is not fetched and has no pending or failed. Both are rejected rather than resolving to nothing. -The host reports the lifecycle; the card never computes it. When the host reports nothing, a -source with a value is `.ready` and one without is `.pending` — enough for a host that does not -track status, without inventing the distinction that only the host can actually make. Note that -`.stale` and `.failed` are exactly that distinction: neither is derivable from the data, which -is why a card cannot be left to infer them. +The host reports the lifecycle; the card never computes it. + +**The fallback, and what it assumes.** When the host reports no `$status`, a source with a +value is treated as `.ready` and one without as `.pending`. That is an inference, and this +section has just argued that absence cannot carry it — so it is sound *only* under an explicit +host contract: **a source key is present if and only if it resolved successfully and is +current.** A host that caches a stale value without reporting status will have it read as +`.ready`; one that represents a successful empty result by omitting the key will have it read +as `.pending`. + +The fallback therefore exists for hosts that do not track status at all, and any host that can +distinguish these cases must report `$status` rather than rely on it. `.stale` and `.failed` +are not derivable from data by construction, which is the whole reason status reporting exists; +the fallback does not change that, it only declines to guess for the two cases that are +derivable under the invariant above. **Cadence and freshness budget belong to the capability, not the card.** A card names what it needs; how often `sys.weather` is worth re-fetching is a property of weather, and duplicating it @@ -516,15 +547,22 @@ With all five, an L0 realization terminates. **Without them the grammar does not why "L0 is decidable" is a claim about authority and grammar membership, not about termination. -Condition 4 is guaranteed **structurally rather than by a check**. All four total forms -(§3) name a state, a transition target is validated against declared state, and `set`'s operand -must be a readable name — so there is no position in the grammar where an event can be named as -something to *do*. A cascade is unrepresentable, not merely rejected. +Condition 4 holds **because of the transition enum and its exhaustive dispatcher**, not because +of the syntax. `Form` has exactly four variants — `set`, `toggle`, `cycle`, `clear` — and the +dispatcher matches all four; none has a branch that raises an event. A transition target is +additionally validated against declared state, so an event cannot be named as a write target +either. + +An earlier version of this section claimed the stronger property that "no total form can name +an event", which was not true: component prop *types* are discarded, so `set(out)` is accepted +where `out` was declared as an event prop. That does not cascade — `set` reads a value and +`Form` has no raising branch — but the guarantee rests on the dispatcher rather than on the +grammar refusing to spell it. **Any new `Form` variant must be checked against this +condition**, and a form that reads a path must exclude event-typed ones explicitly. -That is a stronger guarantee than a validator, and correspondingly easier to lose: any future -form that could raise an event would silently move termination from structural to unchecked. It -is pinned by a test that enumerates the syntaxes a cascade could take — `emit e`, `e()`, -`n: set(1) then e`, a transition targeting an event — and asserts each is refused. +A test enumerates the syntaxes a cascade could take — `emit e`, `e()`, `n: set(1) then e`, a +transition targeting an event — and asserts each is refused. That test is a tripwire, not the +proof. --- @@ -544,16 +582,25 @@ rejected until the level is explicitly raised; escalation is never silent. **Com versions must be pinned**, or an L0 card can be moved to L2 by a definition it references being replaced. -The check therefore returns a **closure digest**: every component the card depends on, paired -with a hash of its definition — params, state, events and view body — sorted by name. A host -stores this beside the approved level. A digest that moved means the level was derived from a -definition that no longer exists and must be re-derived before the card is accepted, rather -than inherited from the record it no longer matches. +The check therefore returns a **closure digest**: every component the card declares, paired +with a hash of its definition — param names, state (path, shape, initial, keep), events and +view body — sorted by name. A host stores this beside the approved level. A digest that moved +means the level was derived from a definition that no longer exists and must be re-derived +before the card is accepted, rather than inherited from the record it no longer matches. The digest deliberately ignores declaration order, so reformatting is not a version change. A digest that moved on a cosmetic edit would make pinning useless in the way that matters: hosts would see it move constantly, and learn to ignore it. +**Known limits, stated so the digest is not trusted past them.** Parameter *shapes* are not +covered, because the parser retains parameters only as names — so `x: number` → `x: text` is +invisible. `text` and `number` state shapes both parse as an unclassified shape, so retyping +between them does not move the digest either. It hashes every declared component rather than +the closure reachable from the root view, so an unused definition causes a false repin. And it +is 64-bit FNV-1a, which is a change *detector* and not an adversarial boundary: it is fit for +noticing that a definition moved, and unfit for resisting a chosen collision. Pinning is also +report material — nothing in the runtime yet stores or compares it. + Three token tests are not sufficient and an earlier draft that used them was both incomplete and self-contradictory. Level must be an effect judgment over the closure. @@ -575,7 +622,7 @@ construct and keep the widest. | ~~4~~ | ~~A bounded `format()`~~ — **settled**: `format:` is a declared token and the runtime applies it. A card that wrote `"$"` or `"41.2M"` itself would be authoring a fact, and currency, grouping and compaction are locale-dependent besides | | ~~5~~ | ~~Named slots~~ — **settled in §5.5.** `slot name` in the component, `into name { … }` at the call site, anonymous slot unchanged as the default. An `into` naming a slot that does not exist is rejected rather than silently dropping its children | | ~~6~~ | ~~An explicit state migration~~ — **settled in §5.8.** `keep: true` opts a cell out of the schema-change reset, and only while that field's own shape is unchanged | -| ~~7~~ | ~~The closed token sets per constructor argument~~ — **settled**: `docs/ui-l0-constructors.toml` is the normative catalog, and a test asserts the Rust catalog and the TOML agree in both directions | +| ~~7~~ | ~~The closed token sets per constructor argument~~ — **settled**: `docs/ui-l0-constructors.toml` is the normative catalog, and tests assert the two agree in both directions — constructor names and shared token sets, and for source capabilities the arguments too | All seven are now settled. What replaces them is not a shorter list of questions but a different kind of work: the profile is specified and implemented, and the remaining gaps are From 0014a023a259ad2e837f5ab44438bc82a38e678a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:03:40 -0700 Subject: [PATCH 23/63] fix(ui_l0): make the guarantees that were weaker than claimed actually hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 78 +++++++++-- crates/splash-core/tests/ui_profile_l0.rs | 155 ++++++++++++++++++++++ 2 files changed, 224 insertions(+), 9 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 6ef36e2..68e18ca 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -2300,23 +2300,58 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { } } +/// Profile §6, condition 1: the declaration graph must be acyclic. +/// +/// This walks COMPONENTS AND VIEWS as one graph. Walking components alone +/// missed two shapes that both recurse forever at realization: +/// +/// view a b view b a (two views referencing each other) +/// component A { view v } view v { A() } (a component via a view) +/// +/// Both passed the old check and recursed until the node cap tripped, which is +/// a resource bound catching a structural error — and only because the caller +/// happened to set one. fn check_component_acyclicity(card: &Card, sink: &mut Diagnostics) { + // name -> (what it references, line) + let mut edges: Vec<(String, Vec, usize)> = Vec::new(); for component in &card.components { - let mut seen = vec![component.name.as_str()]; - let mut queue: Vec<&str> = component.instantiates.iter().map(|s| s.as_str()).collect(); + let mut refs = component.instantiates.clone(); + let mut views = Vec::new(); + collect_references(&component.body, &mut views); + refs.extend(views); + edges.push((component.name.clone(), refs, component.line)); + } + for view in &card.views { + let mut refs = Vec::new(); + collect_constructors(&view.body, &mut refs); + let mut views = Vec::new(); + collect_references(&view.body, &mut views); + refs.extend(views); + edges.push((view.name.clone(), refs, 1)); + } + + let referenced = |name: &str| -> Option<&Vec> { + edges.iter().find(|(n, _, _)| n == name).map(|(_, r, _)| r) + }; + + for (name, _, line) in &edges { + let mut seen: Vec<&str> = vec![name.as_str()]; + let mut queue: Vec<&str> = referenced(name) + .map(|r| r.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); let mut steps = 0usize; while let Some(next) = queue.pop() { steps += 1; if steps > MAX_UI_L0_DECLARATIONS { break; } - if next == component.name { + if next == name.as_str() { sink.push( - component.line, + *line, 1, format!( - "component {:?} is recursive; L0 requires an acyclic component graph", - component.name + "{name:?} is recursive; L0 requires an acyclic declaration graph \ + (profile §6.1)" ), ); break; @@ -2325,13 +2360,23 @@ fn check_component_acyclicity(card: &Card, sink: &mut Diagnostics) { continue; } seen.push(next); - if let Some(c) = card.components.iter().find(|c| c.name == next) { - queue.extend(c.instantiates.iter().map(|s| s.as_str())); + if let Some(more) = referenced(next) { + queue.extend(more.iter().map(|s| s.as_str())); } } } } +/// Names a body SPLICES — `view` references, as distinct from component calls. +fn collect_references(element: &Element, out: &mut Vec) { + if element.is_reference { + out.push(element.name.clone()); + } + for child in &element.children { + collect_references(child, out); + } +} + /// Names visible to one record. Profile §5.3: a component sees its props, its own /// state and events, and card-scope `source` and `copy` — but never card `state`. struct Scope { @@ -2849,6 +2894,7 @@ fn realize_inner( }; let mut ctx = Realizer { + depth: 0, card: &card, limits, nodes: 0, @@ -2978,6 +3024,9 @@ struct Realizer<'a> { card: &'a Card, limits: RealizeLimits, nodes: usize, + /// Actual recursion depth. Previously approximated by the instance key's + /// LENGTH, which bounded neither reliably. + depth: usize, truncated: bool, sink: &'a mut Diagnostics, /// Where component-local values live. Absent means every instance sees its @@ -2999,11 +3048,22 @@ impl Realizer<'_> { key: &str, out: &mut Vec, ) { - if self.nodes >= self.limits.max_nodes || key.len() > self.limits.max_depth * 32 { + if self.nodes >= self.limits.max_nodes || self.depth >= self.limits.max_depth { self.truncated = true; return; } + self.depth += 1; + self.element_inner(element, scope, key, out); + self.depth -= 1; + } + fn element_inner( + &mut self, + element: &Element, + scope: &mut ValueScope, + key: &str, + out: &mut Vec, + ) { match element.name.as_str() { "" => {} "slot" => { diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 948a3a7..cf45c14 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -2303,3 +2303,158 @@ fn a_bound_value_is_accepted() { ); assert!(report.valid, "{:#?}", report.diagnostics); } + +/// The constructor catalog compared NAMES and three token sets, so changing +/// `TextHero.value` from `number` to `data` in the TOML left it green — and §8 +/// claimed on that basis that the two agreed "in both directions". They must +/// agree on every ARGUMENT and its kind, which is what the checker actually +/// consults. +#[test] +fn every_constructor_argument_agrees_with_the_toml() { + const TOML: &str = include_str!("../../../docs/ui-l0-constructors.toml"); + + // [Ctor] followed by `name = { kind = "…" }` lines, until the next header. + let mut documented: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for line in TOML.lines() { + let line = line.split('#').next().unwrap_or("").trim(); + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + if !name.contains('.') && !name.starts_with("kinds") { + documented.push((name.to_string(), Vec::new())); + } else { + // A [kinds.*] or [sources."…"] section ends the constructor run. + documented.push((String::new(), Vec::new())); + } + continue; + } + let Some((arg, rest)) = line.split_once('=') else { + continue; + }; + let Some(kind) = rest + .split("kind = \"") + .nth(1) + .and_then(|k| k.split('"').next()) + else { + continue; + }; + if let Some((name, args)) = documented.last_mut() { + if !name.is_empty() { + args.push((arg.trim().to_string(), kind.to_string())); + } + } + } + let documented: Vec<_> = documented + .into_iter() + .filter(|(n, _)| !n.is_empty()) + .collect(); + assert!( + documented.len() > 15, + "parsed {} constructors", + documented.len() + ); + + // The TOML's kind names, mapped onto what the Rust catalog stores. + let agrees = |kind: &str, actual: &catalog::ArgKind| -> bool { + use catalog::ArgKind::*; + match (kind, actual) { + ("path", Path) | ("event", Event) | ("any", Any) => true, + ("data", Data) | ("number", Number) | ("text", Text) | ("bool", Bool) => true, + ("token", Token(_)) => true, + ("unit", TokenOrPath(set)) => *set == catalog::UNIT, + ("format", Token(set)) => *set == catalog::FORMAT, + ("width", TokenOrPath(set)) => *set == catalog::WIDTH, + _ => false, + } + }; + + for (ctor, args) in &documented { + let implemented = catalog::lookup(ctor) + .unwrap_or_else(|| panic!("{ctor:?} is in the TOML but not in the Rust catalog")); + for (arg, kind) in args { + let (_, actual) = implemented + .iter() + .find(|(n, _)| n == arg) + .unwrap_or_else(|| panic!("{ctor}.{arg} is in the TOML but not in Rust")); + assert!( + agrees(kind, actual), + "{ctor}.{arg}: TOML says {kind:?}, Rust has {actual:?}" + ); + } + assert_eq!( + implemented.len(), + args.len(), + "{ctor} has {} arguments in Rust and {} in the TOML", + implemented.len(), + args.len() + ); + } +} + +// ─── §6.1 the declaration graph, including views ───────────────────────────── + +/// Acyclicity walked components only, so two shapes recursed at realization and +/// were caught — if at all — by the node cap, which is a resource bound +/// standing in for a structural check. +#[test] +fn mutually_recursive_views_are_rejected() { + let report = check_ui_l0_named("mutual", "view a b\nview b a\nview root a"); + assert!(!report.valid, "view a -> b -> a must be rejected"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("recursive")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_component_recursing_through_a_view_is_rejected() { + let report = check_ui_l0_named( + "via-view", + "component A() { view Panel { v } }\nview v Panel { A() }\nview root A()", + ); + assert!(!report.valid, "A -> v -> A must be rejected"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("recursive")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_acyclic_view_chain_is_still_accepted() { + // The check must not reject ordinary splicing, which the cards rely on. + let report = check_ui_l0_named( + "chain", + "view leaf Rule()\nview mid Panel { leaf }\nview root Panel { mid }", + ); + assert!(report.valid, "{:#?}", report.diagnostics); +} + +/// Depth 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. It is now an actual recursion counter. +#[test] +fn depth_is_counted_not_inferred_from_key_length() { + // A shallow tree whose keys are long must NOT truncate. + let long_key = "a_very_long_item_identifier_that_makes_instance_keys_lengthy"; + let source = format!( + "source movers sys.movers()\nview root Panel {{ for m in movers key m.{long_key} {{ Rule() }} }}" + ); + let data = serde_json::json!({ + "movers": [{ long_key: "x".repeat(120) }, { long_key: "y".repeat(120) }] + }); + let report = realize(&source, &data, RealizeLimits::default()); + assert!( + !report.truncated, + "a shallow tree must not truncate because its keys are long" + ); + let mut rules = Vec::new(); + let root = report.root.unwrap(); + find(&root, "Rule", &mut rules); + assert_eq!(rules.len(), 2, "both rows should realize"); +} From 824559a29f6ac2a5f3f3cea4f1d95a67a7ac86b2 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:44:37 -0700 Subject: [PATCH 24/63] fix(ui_l0): component props keep their declared shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 211 ++++++++++++++++++++-- crates/splash-core/tests/ui_profile_l0.rs | 118 ++++++++++++ docs/ui-profile-l0.md | 17 +- 3 files changed, 325 insertions(+), 21 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 68e18ca..0b9e1e7 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -258,7 +258,7 @@ fn definition_digest(component: &Component) -> u64 { let mut acc = String::new(); acc.push_str(&component.name); for p in &component.params { - acc.push_str(p); + acc.push_str(&format!("{}:{:?}", p.name, p.shape)); acc.push(','); } for st in &component.states { @@ -684,11 +684,37 @@ pub enum SourceArg { #[derive(Clone, Debug, Eq, PartialEq)] enum Shape { + Text, + Number, Bool, Enum(Vec), + /// A structured value a source returns — §5.2, a component prop shape. + Record, + /// An iterable a `for` walks. + Collection, + /// An event passed in, so a component can report outward (§5.4). + Event, + /// A shape the declaration did not name. + /// + /// `Text` and `Number` used to land here too, which made them + /// indistinguishable: retyping a state between them changed neither the + /// schema id nor the closure digest, so live values survived a change that + /// should have reset them. Other, } +/// A component's declared input — §5.2. +/// +/// The shape was parsed and dropped, which cost three separate defects: an +/// unknown argument at a call site could not be told from a known one, an event +/// prop could not be told from a value, and every param had to be treated as a +/// possible event name because none could be ruled out. +#[derive(Clone, Debug)] +struct Param { + name: String, + shape: Shape, +} + #[derive(Debug)] struct StateDecl { path: String, @@ -759,7 +785,7 @@ enum SetSource { #[derive(Debug)] struct Component { name: String, - params: Vec, + params: Vec, states: Vec, events: Vec, body: Element, @@ -1151,6 +1177,21 @@ impl<'a> Parser<'a> { if t.is_kw("bool") { shape = Shape::Bool; } + if t.is_kw("text") { + shape = Shape::Text; + } + if t.is_kw("number") { + shape = Shape::Number; + } + if t.is_kw("record") { + shape = Shape::Record; + } + if t.is_kw("collection") { + shape = Shape::Collection; + } + if t.is_kw("event") { + shape = Shape::Event; + } // `initial: ` — a token, string or number. if t.is_kw("initial") && self.tokens.get(scan + 1).is_some_and(|n| n.is_punct(":")) { @@ -1420,7 +1461,36 @@ impl<'a> Parser<'a> { .get(self.at + 1) .is_some_and(|n| n.is_punct(":")) { - params.push(t.text.clone()); + // The declared shape follows the colon. `enum[a, b]` + // carries its members, which is what lets a token + // argument be checked against the prop it fills. + let shape = match self.tokens.get(self.at + 2) { + Some(k) if k.text == "text" => Shape::Text, + Some(k) if k.text == "number" => Shape::Number, + Some(k) if k.text == "bool" => Shape::Bool, + Some(k) if k.text == "record" => Shape::Record, + Some(k) if k.text == "collection" => Shape::Collection, + Some(k) if k.text == "event" => Shape::Event, + Some(k) if k.text == "enum" => { + let mut members = Vec::new(); + let mut at = self.at + 4; // past `enum` and `[` + while let Some(m) = self.tokens.get(at) { + if m.is_punct("]") { + break; + } + if m.kind == Kind::Ident { + members.push(m.text.clone()); + } + at += 1; + } + Shape::Enum(members) + } + _ => Shape::Other, + }; + params.push(Param { + name: t.text.clone(), + shape, + }); } } self.at += 1; @@ -2132,9 +2202,26 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { for component in &card.components { let mut readable: Vec = card.sources.iter().map(|s| root_of(&s.name)).collect(); readable.push("copy".into()); - readable.extend(component.params.iter().cloned()); + // An EVENT prop is not readable — `set(out)` where `out` is an event + // was accepted, because a param's shape was unknown so none could be + // excluded. Now only value-shaped props are readable, and event-shaped + // ones join the event names below. + readable.extend( + component + .params + .iter() + .filter(|p| !matches!(p.shape, Shape::Event)) + .map(|p| p.name.clone()), + ); readable.extend(component.states.iter().map(|s| root_of(&s.path))); - let events: Vec = component.events.iter().map(|e| e.name.clone()).collect(); + let mut events: Vec = component.events.iter().map(|e| e.name.clone()).collect(); + events.extend( + component + .params + .iter() + .filter(|p| matches!(p.shape, Shape::Event)) + .map(|p| p.name.clone()), + ); check_event_batch( &component.events, &component.states, @@ -2408,11 +2495,20 @@ impl Scope { let mut roots: Vec = Vec::new(); roots.extend(card.sources.iter().map(|s| root_of(&s.name))); roots.push("copy".into()); - roots.extend(component.params.iter().cloned()); + roots.extend(component.params.iter().map(|p| p.name.clone())); roots.extend(component.states.iter().map(|s| root_of(&s.path))); let mut events: Vec = component.events.iter().map(|e| e.name.clone()).collect(); - // Events arrive as props too — §5.4, outputs are event props. - events.extend(component.params.iter().cloned()); + // Events arrive as props too — §5.4, outputs are event props. Only the + // ones DECLARED as events: every param used to be added here, so a + // `text` prop could be handed to `on_tap` and a runtime string that + // happened to match an event name would route it. + events.extend( + component + .params + .iter() + .filter(|p| matches!(p.shape, Shape::Event)) + .map(|p| p.name.clone()), + ); Self { roots, source_roots: card.sources.iter().map(|s| root_of(&s.name)).collect(), @@ -2570,7 +2666,36 @@ fn check_arg( return; } }, - None if is_component => None, + None if is_component => { + // A component's props are declared by the CARD, not the catalog — + // but they ARE declared, so an argument the component does not + // accept is a typo, not a pass-through. It used to be accepted and + // then silently dropped at realization, rendering an em dash. + if let Some(component) = card.components.iter().find(|c| c.name == ctor) { + match component.params.iter().find(|p| p.name == arg.name) { + Some(param) => check_prop(ctor, arg, param, scope, sink), + None => { + let mut names: Vec<&str> = + component.params.iter().map(|p| p.name.as_str()).collect(); + names.sort(); + sink.push( + arg.line, + arg.column, + format!( + "{ctor} has no prop {:?}; it declares: {}", + arg.name, + if names.is_empty() { + "(none)".to_string() + } else { + names.join(", ") + } + ), + ); + } + } + } + None + } None => return, }; @@ -2695,6 +2820,64 @@ fn check_arg( let _ = card; } +/// Check an argument against the prop it fills — §5.2. +/// +/// Only the cases a card can actually get wrong and that the shape decides: +/// an event where a value belongs, a value where an event belongs, and a token +/// outside a declared enum. Anything else is left alone, because a prop shape +/// is a contract about the DATA and most positions legitimately accept a path +/// whose type is not knowable until the host answers. +fn check_prop(ctor: &str, arg: &Arg, param: &Param, scope: &Scope, sink: &mut Diagnostics) { + let name = ¶m.name; + match (¶m.shape, &arg.value) { + // An event prop must receive an event, and nothing else may. + (Shape::Event, Operand::Path(p)) if !scope.events.iter().any(|e| e == p) => { + sink.push( + arg.line, + arg.column, + format!("{ctor}.{name} takes an event; {p:?} is not a declared event"), + ); + } + (Shape::Event, Operand::Str(_) | Operand::Num(_) | Operand::Token(_)) => { + sink.push( + arg.line, + arg.column, + format!("{ctor}.{name} takes an event, not a literal"), + ); + } + (shape, Operand::Path(p)) + if !matches!(shape, Shape::Event) && scope.events.iter().any(|e| e == p) => + { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{name} takes a value, but {p:?} is an event — declare the prop \ + as `{name}: event` to pass one (profile §5.4)" + ), + ); + } + (Shape::Enum(members), Operand::Token(t)) => { + let bare = t.trim_start_matches('.'); + if !members.iter().any(|m| m == bare) { + sink.push( + arg.line, + arg.column, + format!( + "{t:?} is not a member of {ctor}.{name}; it accepts {}", + members + .iter() + .map(|m| format!(".{m}")) + .collect::>() + .join(", ") + ), + ); + } + } + _ => {} + } +} + fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut Diagnostics) { if path.is_empty() { return; @@ -3158,7 +3341,7 @@ impl Realizer<'_> { // path inside the component resolves against this frame first. let mut bound = 0usize; for arg in &call.args { - if component.params.contains(&arg.name) { + if component.params.iter().any(|p| p.name == arg.name) { let value = match &arg.value { Operand::Path(p) => scope .lookup(p) @@ -3931,7 +4114,9 @@ fn initial_for(shape: &Shape) -> crate::JsonValue { .first() .map(|m| crate::JsonValue::String(m.clone())) .unwrap_or(crate::JsonValue::Null), - Shape::Other => crate::JsonValue::Null, + Shape::Text => crate::JsonValue::String(String::new()), + Shape::Number => crate::JsonValue::from(0.0), + Shape::Record | Shape::Collection | Shape::Event | Shape::Other => crate::JsonValue::Null, } } @@ -4255,7 +4440,7 @@ pub fn record_dependencies(source: &str) -> Vec { for component in &card.components { let (mut reads, pulls) = scan_element(&component.body); // A prop is supplied by the caller, so it is not a dependency of its own. - reads.retain(|r| !component.params.contains(r)); + reads.retain(|r| !component.params.iter().any(|p| p.name == *r)); // Local state is a dependency: writing it must re-realize this instance. for state in &component.states { reads.push(root_of(&state.path)); @@ -4352,7 +4537,7 @@ pub fn patch_points(source: &str, changed: &[&str]) -> Vec { } for component in &card.components { let (mut reads, _) = scan_element(&component.body); - reads.retain(|r| !component.params.contains(r)); + reads.retain(|r| !component.params.iter().any(|p| p.name == *r)); for state in &component.states { reads.push(root_of(&state.path)); } diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index cf45c14..a40f1a0 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -2458,3 +2458,121 @@ fn depth_is_counted_not_inferred_from_key_length() { find(&root, "Rule", &mut rules); assert_eq!(rules.len(), 2, "both rows should realize"); } + +// ─── §5.2 component props carry their declared shape ──────────────────────── +// +// The shape was parsed and discarded, which cost three separate defects. They +// looked unrelated in the review and shared one cause. + +/// An argument the component does not declare was accepted and then silently +/// dropped at realization, so `Framed(titel: "x")` rendered an em dash and +/// looked deliberate. +#[test] +fn an_undeclared_prop_is_rejected() { + let report = check_ui_l0_named( + "typo", + "component F(title: text) { view Panel { TextRow(text: title) } }\n\ + view root F(titel: \"x\")", + ); + assert!(!report.valid); + let message = &report.diagnostics[0].message; + assert!( + message.contains("no prop") && message.contains("title"), + "the message should name the props that exist: {message}" + ); +} + +/// `set(out)` where `out` is an event prop was accepted, because no param's +/// shape was known so none could be excluded from the readable set. +#[test] +fn an_event_prop_cannot_be_read_by_set() { + let report = check_ui_l0_named( + "setevent", + "component F(out: event) { state n { shape: number, initial: 0 } \ + event e { n: set(out) } view Panel { Row(on_tap: e) { Rule() } } }\n\ + event go { }\nview root F(out: go)", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("is an event, not a value")), + "{:#?}", + report.diagnostics + ); +} + +/// Every param used to be added to the component's event names, 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. +#[test] +fn a_value_prop_cannot_be_used_as_an_event() { + let report = check_ui_l0_named( + "asevent", + "component F(label: text) { view Panel { Row(on_tap: label) { Rule() } } }\n\ + view root F(label: \"x\")", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a declared event")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_token_outside_an_enum_prop_is_rejected() { + // Now possible because the prop's members survive parsing. + let report = check_ui_l0_named( + "enumprop", + "component F(unit: enum[c, f]) { view Panel { TextValue(value: unit) } }\n\ + view root F(unit: .kelvin)", + ); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains(".c, .f"), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_correct_call_site_is_still_accepted() { + let report = check_ui_l0_named( + "ok", + "component F(title: text, unit: enum[c, f], on_go: event) { \ + view Panel { Row(on_tap: on_go) { TextRow(text: title) TextValue(value: unit) } } }\n\ + event go { }\nview root F(title: \"x\", unit: .c, on_go: go)", + ); + assert!(report.valid, "{:#?}", report.diagnostics); +} + +/// Retyping between `text` and `number` moved neither the schema id nor the +/// closure digest, because both parsed to the same unclassified shape — so a +/// live string survived into number-shaped state, and a host pinning the digest +/// saw no change. +#[test] +fn retyping_between_text_and_number_is_visible() { + let base = "component F(x: text) { state s { shape: text, initial: \"\" } \ + view Panel { TextRow(text: s) } }\nview root F(x: \"a\")"; + + let retyped_state = base + .replace("shape: text", "shape: number") + .replace("initial: \"\"", "initial: 0"); + assert_ne!( + check_ui_l0_named("p", base).closure, + check_ui_l0_named("p", &retyped_state).closure, + "retyping state between text and number must move the digest" + ); + + let retyped_param = base.replace("F(x: text)", "F(x: number)"); + assert_ne!( + check_ui_l0_named("p", base).closure, + check_ui_l0_named("p", &retyped_param).closure, + "retyping a prop must move the digest" + ); +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 8e6c537..83f449b 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -592,14 +592,15 @@ The digest deliberately ignores declaration order, so reformatting is not a vers digest that moved on a cosmetic edit would make pinning useless in the way that matters: hosts would see it move constantly, and learn to ignore it. -**Known limits, stated so the digest is not trusted past them.** Parameter *shapes* are not -covered, because the parser retains parameters only as names — so `x: number` → `x: text` is -invisible. `text` and `number` state shapes both parse as an unclassified shape, so retyping -between them does not move the digest either. It hashes every declared component rather than -the closure reachable from the root view, so an unused definition causes a false repin. And it -is 64-bit FNV-1a, which is a change *detector* and not an adversarial boundary: it is fit for -noticing that a definition moved, and unfit for resisting a chosen collision. Pinning is also -report material — nothing in the runtime yet stores or compares it. +**Known limits, stated so the digest is not trusted past them.** It hashes every declared +component rather than the closure reachable from the root view, so an unused definition causes +a false repin. And it is 64-bit FNV-1a, which is a change *detector* and not an adversarial +boundary: it is fit for noticing that a definition moved, and unfit for resisting a chosen +collision. Pinning is also report material — nothing in the runtime yet stores or compares it. + +Parameter and state *shapes* are covered as of the change that made prop shapes survive +parsing; before that `x: number` → `x: text` was invisible to both the digest and §5.8's +schema id, so a live string could survive into number-shaped state. Three token tests are not sufficient and an earlier draft that used them was both incomplete and self-contradictory. Level must be an effect judgment over the closure. From 2dea1eacc1b8489a23b07364cfa18e84bc698e88 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:00:48 -0700 Subject: [PATCH 25/63] =?UTF-8?q?fix(ui=5Fl0):=20second=20codex=20round=20?= =?UTF-8?q?=E2=80=94=20header,=20set=20shapes,=20data=20tokens,=20dotted?= =?UTF-8?q?=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 136 +++++++++++++++++++++- crates/splash-core/tests/ui_profile_l0.rs | 108 +++++++++++++++++ docs/ui-profile-l0.md | 47 +++++--- 3 files changed, 269 insertions(+), 22 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 0b9e1e7..b091f18 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -70,6 +70,10 @@ pub struct CardHeader { pub level: Option, pub profile: Option, pub model: Option, + /// Fields declared more than once with different values. Last-wins made + /// `# level: L2` followed by `# level: L0` accepted, so a card could + /// declare whatever it needed and then declare the truth after it. + pub contradictions: Vec, } /// Read the header from the leading `#` lines, which the lexer discards. @@ -98,11 +102,25 @@ pub fn parse_header(source: &str) -> Option { match key.trim() { "level" => { saw_any = true; - header.level = Some(value); + match &header.level { + Some(first) if *first != value => { + header.contradictions.push(format!( + "the header declares level twice, as {first:?} and {value:?}" + )); + } + _ => header.level = Some(value), + } } "profile" => { saw_any = true; - header.profile = Some(value); + match &header.profile { + Some(first) if *first != value => { + header.contradictions.push(format!( + "the header declares profile twice, as {first:?} and {value:?}" + )); + } + _ => header.profile = Some(value), + } } "model" => header.model = Some(value), _ => {} @@ -150,7 +168,12 @@ pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { // finish, and a level diagnostic is far more useful than "unexpected token". if let Some((level, diag)) = classify_beyond_l0(&tokens) { sink.push(diag.line, diag.column, diag.message); - return sink.into_report(level); + let mut report = sink.into_report(level); + report.header = header.clone(); + // Compare here too: this is the path a card that UNDER-declares its + // level takes, and it was the one path that skipped the comparison. + check_header(&header, level, &mut report); + return report; } let mut parser = Parser::new(&tokens, &mut sink); @@ -173,6 +196,15 @@ pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { fn check_header(header: &Option, derived: Level, report: &mut UiL0Report) { let Some(header) = header else { return }; + for message in &header.contradictions { + report.valid = false; + report.diagnostics.push(SyntaxDiagnostic { + line: 1, + column: 1, + message: message.clone(), + }); + } + if let Some(declared) = &header.level { let matches = match declared.trim() { "L0" => derived == Level::L0, @@ -2232,6 +2264,20 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { } } +/// A shape's name, for diagnostics. +fn shape_name(shape: &Shape) -> &'static str { + match shape { + Shape::Text => "text", + Shape::Number => "a number", + Shape::Bool => "bool", + Shape::Enum(_) => "an enum", + Shape::Record => "a record", + Shape::Collection => "a collection", + Shape::Event => "an event", + Shape::Other => "of an unnamed shape", + } +} + fn check_event_batch( events: &[EventDecl], states: &[StateDecl], @@ -2269,6 +2315,60 @@ fn check_event_batch( transition.target ), ), + Form::Set(SetSource::Num(_)) if !matches!(state.shape, Shape::Number | Shape::Other) => { + sink.push( + transition.line, + transition.column, + format!( + "`set` writes a number, but {:?} is {}", + transition.target, + shape_name(&state.shape) + ), + ); + } + Form::Set(SetSource::Bool(_)) if !matches!(state.shape, Shape::Bool | Shape::Other) => { + sink.push( + transition.line, + transition.column, + format!( + "`set` writes a bool, but {:?} is {}", + transition.target, + shape_name(&state.shape) + ), + ); + } + Form::Set(SetSource::Token(t)) => { + if let Shape::Enum(members) = &state.shape { + if !members.iter().any(|m| m == t) { + sink.push( + transition.line, + transition.column, + format!( + ".{t} is not a member of {:?}'s enum; it accepts {}", + transition.target, + members + .iter() + .map(|m| format!(".{m}")) + .collect::>() + .join(", ") + ), + ); + } + } + } + Form::Set(SetSource::Text(_)) + if !matches!(state.shape, Shape::Text | Shape::Other) => + { + sink.push( + transition.line, + transition.column, + format!( + "`set` writes text, but {:?} is {}", + transition.target, + shape_name(&state.shape) + ), + ); + } Form::Set(SetSource::Path(path)) => { let root = root_of(path); if event_names.contains(&root) { @@ -2485,7 +2585,11 @@ impl Scope { let events = card.events.iter().map(|e| e.name.clone()).collect(); Self { roots, - source_roots: card.sources.iter().map(|s| root_of(&s.name)).collect(), + // The FULL declared name, not its root. `source env.locale` answers to + // `env.locale.$state`; comparing against the root alone rejected that + // while accepting `env.$state`, which names no source and has no + // lifecycle to report. + source_roots: card.sources.iter().map(|s| s.name.clone()).collect(), events, forbidden_state: Vec::new(), } @@ -2740,6 +2844,17 @@ fn check_arg( ); } // A data position renders a fact and must be bound, not authored. + (Operand::Token(t), Some(Data)) => { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} renders a value and must be bound; {t:?} is a token \ + (profile §4)", + arg.name + ), + ); + } (Operand::Str(_) | Operand::Num(_), Some(Data)) => { let shown = match &arg.value { Operand::Num(n) => format!("{n}"), @@ -3137,6 +3252,10 @@ impl ValueScope<'_> { // `.$state` is the lifecycle the runtime reported. It is data // like any other, so a guard can branch on it without an expression. if let Some(source) = path.strip_suffix(".$state") { + // A dotted source name navigates the data, so `env.locale` looks + // under {env: {locale: …}} rather than for a key spelled + // "env.locale" — which is nowhere. + let resolved = data_path(self.data, source); let status = self .data .get("$status") @@ -3145,7 +3264,7 @@ impl ValueScope<'_> { .unwrap_or_else(|| { // No report means: resolved if there is a value, pending if // not. A host that tracks status explicitly overrides this. - if self.data.get(source).is_some() { + if resolved.is_some() { "ready" } else { "pending" @@ -4097,7 +4216,12 @@ fn schema_id(component: &Component) -> u64 { let mut fields: Vec = component .states .iter() - .map(|s| format!("{}:{:?}", s.path, s.shape)) + .map(|s| { + format!( + "{}:{:?}:{:?}:{:?}", + s.path, s.shape, s.initial, s.initial_path + ) + }) .collect(); fields.sort(); for byte in fields.join(",").bytes() { diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index a40f1a0..9c3aaf4 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -2576,3 +2576,111 @@ fn retyping_between_text_and_number_is_visible() { "retyping a prop must move the digest" ); } + +// ─── second codex round ────────────────────────────────────────────────────── + +#[test] +fn a_contradictory_duplicate_header_field_is_rejected() { + // Last-wins meant a card could declare what it needed and then declare the + // truth after it: `# level: L2` followed by `# level: L0` was accepted. + let report = check_ui_l0_named( + "dup", + "# ledger x@1.0.0\n# level: L2\n# level: L0\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("declares level twice")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_token_in_a_data_position_is_rejected() { + // `Data` rejected strings and numbers and let tokens through. + let report = check_ui_l0_named("tok", "view root TextHero(value: .money)"); + assert!(!report.valid); + assert!( + report.diagnostics[0].message.contains("token"), + "{:#?}", + report.diagnostics + ); +} + +/// `set` wrote whatever it was given regardless of what the state could hold. +#[test] +fn set_must_fit_the_target_shape() { + for (source, expect) in [ + ( + "state ok { shape: bool, initial: false }\nevent e { ok: set(1) }\nview root Rule()", + "writes a number", + ), + ( + "state m { shape: enum[a, b], initial: .a }\nevent e { m: set(true) }\nview root Rule()", + "writes a bool", + ), + ( + "state n { shape: number, initial: 0 }\nevent e { n: set(\"x\") }\nview root Rule()", + "writes text", + ), + ( + "state m { shape: enum[a, b], initial: .a }\nevent e { m: set(.c) }\nview root Rule()", + "not a member", + ), + ] { + let report = check_ui_l0_named("shape", source); + assert!(!report.valid, "should be rejected: {source}"); + assert!( + report.diagnostics.iter().any(|d| d.message.contains(expect)), + "expected {expect:?}: {:#?}", + report.diagnostics + ); + } +} + +/// A dotted source name registered only its ROOT, so the lifecycle of +/// `source env.locale` could not be asked for, while `env.$state` — which names +/// no source at all — was accepted. +#[test] +fn a_dotted_source_reports_its_own_lifecycle() { + let source = "source env.locale sys.locale()\n\ + view root Col { when env.locale.$state == .ready { TextRow(text: \"READY\") } \ + when env.locale.$state == .pending { TextRow(text: \"WAIT\") } }"; + assert!(check_ui_l0_named("dotted", source).valid); + + let ready = realize( + source, + &serde_json::json!({"env": {"locale": {"lang": "en"}}}), + RealizeLimits::default(), + ); + assert_eq!(texts(&ready.root.unwrap()), vec!["READY"]); + + let pending = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!(texts(&pending.root.unwrap()), vec!["WAIT"]); + + let bogus = check_ui_l0_named( + "aggregate", + "source env.locale sys.locale()\nview root Col { when env.$state == .ready { Rule() } }", + ); + assert!( + !bogus.valid, + "`env` names no source, so it has no lifecycle to report" + ); +} + +/// §5.8 says the schema id covers "names, shapes and initials". It hashed only +/// path and shape, so changing an initial did not reset live state. +#[test] +fn changing_an_initial_changes_the_schema() { + let base = "component F() { state s { shape: number, initial: 0 } \ + view Panel { TextRow(text: s) } }\nview root F()"; + let changed = base.replace("initial: 0", "initial: 7"); + assert_ne!( + check_ui_l0_named("p", base).closure, + check_ui_l0_named("p", &changed).closure, + "an initial is part of the state schema (§5.8)" + ); +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 83f449b..1798a0a 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -296,18 +296,25 @@ instances each containing an unkeyed `Toggle` must not share one state cell. component type and keys both affect preservation. L0 binds to a declared path so that identity survives a ledger edit that inserts an element above. -**"Version" here means the STATE SCHEMA version** (§5.8), not the component's whole definition. +**Neither the card namespace nor the version is currently a key segment.** Keys begin at the +root view and append component names; the schema id is held in a separate map keyed by component +name. So the tuple above describes the intended identity, and the implementation covers the +instantiation site and enclosing loop keys only. One consequence worth knowing: two cards +sharing one store can collide, because card state lives under a single fixed key. + +**"Version" means the STATE SCHEMA version** (§5.8), not the component's whole definition. Using the definition digest would contradict §5.8 directly: a view-only edit would change identity and reset state, which is precisely what §5.8 promises it does not do. The two are different instruments — the schema id decides whether live state still fits, and the closure digest (§7) tells a host that a definition was replaced at all. **What path-based identity does and does not survive.** Insertion of a differently named -sibling is stable, because siblings are numbered within each element name. Reordering two -same-name siblings, or inserting another instance of the same component above, still moves the -ordinals — so state follows the ordinal rather than the instance. Fully edit-stable identity -needs a persistent site id in the source or edit provenance from the ledger; neither exists -yet, and the numbering is an improvement on tree position rather than a solution. +sibling is stable, because siblings are numbered within each element name. Everything else that +changes the path changes identity: reordering two same-name siblings, inserting another +instance of the same component above, and any change to an enclosing guard, loop, slot, view +reference or loop key. Fully edit-stable identity needs a persistent site id in the source or +edit provenance from the ledger; neither exists yet, and the numbering is an improvement on +tree position rather than a solution. ### 5.2 Props @@ -547,11 +554,16 @@ With all five, an L0 realization terminates. **Without them the grammar does not why "L0 is decidable" is a claim about authority and grammar membership, not about termination. -Condition 4 holds **because of the transition enum and its exhaustive dispatcher**, not because -of the syntax. `Form` has exactly four variants — `set`, `toggle`, `cycle`, `clear` — and the -dispatcher matches all four; none has a branch that raises an event. A transition target is -additionally validated against declared state, so an event cannot be named as a write target -either. +Condition 4 holds **because no admitted transition form raises an event** — `set`, `toggle`, +`cycle` and `clear` all write a state cell and nothing else, and a transition target is +validated against declared state, so an event cannot be named as a write target either. + +Stating that more carefully than an earlier draft did: `Form` carries a fifth variant, +`NotTotal`, for anything outside those four, and the dispatcher ends in a catch-all rather than +matching exhaustively. **So the compiler would not force a review if a variant were added.** +The property is true of the forms that exist; it is not structurally protected. Any new `Form` +variant must be checked against this condition by hand, and one that reads a path must exclude +event-typed paths explicitly. An earlier version of this section claimed the stronger property that "no total form can name an event", which was not true: component prop *types* are discarded, so `set(out)` is accepted @@ -588,9 +600,10 @@ view body — sorted by name. A host stores this beside the approved level. A di means the level was derived from a definition that no longer exists and must be re-derived before the card is accepted, rather than inherited from the record it no longer matches. -The digest deliberately ignores declaration order, so reformatting is not a version change. A -digest that moved on a cosmetic edit would make pinning useless in the way that matters: hosts -would see it move constantly, and learn to ignore it. +The digest sorts **components**, so reordering declarations at the top level is not a version +change — a digest that moved on a cosmetic edit would make pinning useless in the way that +matters, since hosts would see it move constantly and learn to ignore it. Order *within* a +component is hashed, so moving a param, state or event does register as a change. **Known limits, stated so the digest is not trusted past them.** It hashes every declared component rather than the closure reachable from the root view, so an unused definition causes @@ -598,8 +611,10 @@ a false repin. And it is 64-bit FNV-1a, which is a change *detector* and not an boundary: it is fit for noticing that a definition moved, and unfit for resisting a chosen collision. Pinning is also report material — nothing in the runtime yet stores or compares it. -Parameter and state *shapes* are covered as of the change that made prop shapes survive -parsing; before that `x: number` → `x: text` was invisible to both the digest and §5.8's +It also omits a loop's `key` expression, whether an element is a view reference, parameter +defaults, and the bodies of views a component references — so a card can change what it renders +without the digest moving. Parameter and state *shapes* are covered as of the change that made +prop shapes survive parsing; before that `x: number` → `x: text` was invisible to both the digest and §5.8's schema id, so a live string could survive into number-shaped state. Three token tests are not sufficient and an earlier draft that used them was both incomplete From f6b999429bc83adb0d22bf0b944f9a32a343de06 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:09:28 -0700 Subject: [PATCH 26/63] feat(ui_l0): copy provenance survives parsing, and model-authored text is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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. --- crates/splash-core/src/ui_l0.rs | 117 +++++++++++++++++++--- crates/splash-core/tests/ui_profile_l0.rs | 101 +++++++++++++++++++ 2 files changed, 206 insertions(+), 12 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index b091f18..76b29dd 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -658,8 +658,20 @@ fn classify_beyond_l0(tokens: &[Token]) -> Option<(Level, SyntaxDiagnostic)> { "arithmetic and conditional expressions are not in L0: an expression can hide a fabricated fact", )); } + // A copy class is a hyphenated NAME, not a subtraction: `class: + // user-copy` lexes as `user`, `-`, `copy`, and reading that as + // arithmetic made the two spellings §4 depends on unusable — the + // provenance rule could not be written in a card at all. + let is_class_name = tokens + .get(i.wrapping_sub(3)) + .is_some_and(|k| k.text == "class") + && tokens.get(i.wrapping_sub(1)).is_some_and(|p| { + p.kind == Kind::Ident && matches!(p.text.as_str(), "user" | "model") + }); + // `-` is only rejected between values; a negative literal is fine. if t.is_punct("-") + && !is_class_name && tokens .get(i.wrapping_sub(1)) .is_some_and(|p| matches!(p.kind, Kind::Ident | Kind::Num | Kind::Token)) @@ -687,7 +699,7 @@ struct Card { /// they resolve from the CARD rather than the injected data. Carrying them /// in the data blob duplicated what the card already said, and the two /// silently drifted. - copies: Vec<(String, Vec<(String, String)>)>, + copies: Vec, components: Vec, views: Vec, } @@ -735,6 +747,35 @@ enum Shape { Other, } +/// Authored text, with the provenance that decides whether it may be rendered. +/// +/// The provenance was parsed and thrown away — and worse, `parse_locale_map` +/// only kept entries whose value was a STRING, so `class: vocabulary` (an +/// identifier) was dropped before anything could read it. §4's "a literal in a +/// data position must be vocabulary or source-derived" was therefore not merely +/// unenforced but undecidable: nothing recorded which kind a string was. +#[derive(Clone, Debug)] +struct CopyDecl { + name: String, + provenance: Provenance, + locales: Vec<(String, String)>, +} + +/// Who wrote a piece of text, and therefore whether it can be trusted on screen. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum Provenance { + /// Fixed interface language — labels, units, headings. Translatable, and + /// carries no claim about the world. + #[default] + Vocabulary, + /// Text the user wrote. Theirs to be wrong about. + UserCopy, + /// Text the MODEL wrote. May not appear in a rendering position: this is + /// the class §4 exists to keep off the screen, because a model-authored + /// string is indistinguishable from a fact it invented. + ModelCopy, +} + /// A component's declared input — §5.2. /// /// The shape was parsed and dropped, which cost three separate defects: an @@ -992,7 +1033,12 @@ impl<'a> Parser<'a> { "copy" => { self.at += 1; if let Some(n) = self.ident() { - card.copies.push((n, self.parse_locale_map())); + let (provenance, locales) = self.parse_locale_map(); + card.copies.push(CopyDecl { + name: n, + provenance, + locales, + }); } } "component" => { @@ -1297,10 +1343,11 @@ impl<'a> Parser<'a> { } /// `{ class: vocabulary, en: "…", zh: "…" }` — the text per locale. - fn parse_locale_map(&mut self) -> Vec<(String, String)> { + fn parse_locale_map(&mut self) -> (Provenance, Vec<(String, String)>) { let mut out = Vec::new(); + let mut provenance = Provenance::Vocabulary; if !self.eat_punct("{") { - return out; + return (provenance, out); } let mut guard = 0usize; while let Some(t) = self.peek().cloned() { @@ -1315,7 +1362,28 @@ impl<'a> Parser<'a> { .is_some_and(|n| n.is_punct(":")) { if let Some(v) = self.tokens.get(self.at + 2) { - if v.kind == Kind::Str { + // `class:` names a provenance and its value is an + // IDENTIFIER, which the string-only filter below discarded. + if t.text == "class" { + // `-` lexes as punctuation, so `user-copy` arrives as + // the identifier `user`. Match the head rather than + // reassembling: the three classes differ there. + provenance = match v.text.as_str() { + "user" => Provenance::UserCopy, + "model" => Provenance::ModelCopy, + "vocabulary" => Provenance::Vocabulary, + other => { + self.sink.at( + v, + format!( + "{other:?} is not a copy class; L0 has \ + `vocabulary`, `user-copy` and `model-copy`" + ), + ); + Provenance::Vocabulary + } + }; + } else if v.kind == Kind::Str { out.push((t.text.clone(), v.text.clone())); } } @@ -1323,7 +1391,7 @@ impl<'a> Parser<'a> { self.at += 1; } self.expect_punct("}"); - out + (provenance, out) } /// `event name { path: total-form, … }` @@ -2570,6 +2638,8 @@ struct Scope { roots: Vec, /// Roots that name a source. Only these carry a `$state` lifecycle. source_roots: Vec, + /// Declared copy, so a rendering position can refuse model-authored text. + copies: Vec, events: Vec, /// Card state, tracked separately so reading it from a component is a /// specific diagnostic rather than "unknown name". @@ -2590,6 +2660,7 @@ impl Scope { // while accepting `env.$state`, which names no source and has no // lifecycle to report. source_roots: card.sources.iter().map(|s| s.name.clone()).collect(), + copies: card.copies.clone(), events, forbidden_state: Vec::new(), } @@ -2615,7 +2686,8 @@ impl Scope { ); Self { roots, - source_roots: card.sources.iter().map(|s| root_of(&s.name)).collect(), + source_roots: card.sources.iter().map(|s| s.name.clone()).collect(), + copies: card.copies.clone(), events, forbidden_state: card.states.iter().map(|s| root_of(&s.path)).collect(), } @@ -2998,6 +3070,27 @@ fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut return; } + // `copy.x` where `x` is model-authored. §4's whole point: a model-written + // string on screen is indistinguishable from a fact the model invented, so + // the class exists to be refused rather than to be recorded. + if let Some(name) = path.strip_prefix("copy.") { + if let Some(decl) = scope.copies.iter().find(|c| c.name == name) { + if decl.provenance == Provenance::ModelCopy { + sink.push( + line, + column, + format!( + "copy.{name} is `model-copy` and may not be rendered; only \ + `vocabulary` and `user-copy` may (profile §4)" + ), + ); + } + } else { + sink.push(line, column, format!("copy.{name} is not declared")); + } + return; + } + // A `[inner]` segment holds a path of its own. Checking it here is what // makes `stories[typo].title` a diagnostic rather than a silent em dash. for segment in path.split('.') { @@ -3241,7 +3334,7 @@ fn realize_inner( struct ValueScope<'a> { frames: Vec<(String, crate::JsonValue)>, data: &'a crate::JsonValue, - copies: &'a [(String, Vec<(String, String)>)], + copies: &'a [CopyDecl], } impl ValueScope<'_> { @@ -3279,7 +3372,7 @@ impl ValueScope<'_> { // render an em dash because the device is in en. if root == "copy" { let name = segments.next()?; - let entry = self.copies.iter().find(|(n, _)| n == name)?; + let entry = self.copies.iter().find(|c| c.name == name)?; let lang = self .data .get("env") @@ -3288,11 +3381,11 @@ impl ValueScope<'_> { .and_then(|v| v.as_str()) .unwrap_or("en"); let text = entry - .1 + .locales .iter() .find(|(k, _)| k == lang) - .or_else(|| entry.1.iter().find(|(k, _)| k == "en")) - .or_else(|| entry.1.iter().find(|(k, _)| k != "class"))?; + .or_else(|| entry.locales.iter().find(|(k, _)| k == "en")) + .or_else(|| entry.locales.first())?; return Some(crate::JsonValue::String(text.1.clone())); } diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 9c3aaf4..c5bbb9f 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -2684,3 +2684,104 @@ fn changing_an_initial_changes_the_schema() { "an initial is part of the state schema (§5.8)" ); } + +// ─── §4 copy provenance ────────────────────────────────────────────────────── + +/// §4 requires a rendered literal to be "vocabulary or source-derived". That +/// was undecidable, not merely unenforced: `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. +#[test] +fn provenance_survives_parsing_and_model_copy_is_refused() { + let vocabulary = check_ui_l0_named( + "vocab", + "copy hi { class: vocabulary, en: \"Top Stories\" }\nview root TextTitle(text: copy.hi)", + ); + assert!(vocabulary.valid, "{:#?}", vocabulary.diagnostics); + + let user = check_ui_l0_named( + "user", + "copy note { class: user-copy, en: \"my note\" }\nview root TextTitle(text: copy.note)", + ); + assert!( + user.valid, + "the user may write their own text: {:#?}", + user.diagnostics + ); + + let model = check_ui_l0_named( + "model", + "copy claim { class: model-copy, en: \"Revenue: $41.2M\" }\n\ + view root TextTitle(text: copy.claim)", + ); + assert!( + !model.valid, + "model-authored text must not reach the screen" + ); + assert!( + model + .diagnostics + .iter() + .any(|d| d.message.contains("model-copy")), + "{:#?}", + model.diagnostics + ); +} + +/// The hyphen in `user-copy` lexes as a minus, and the level classifier read it +/// as arithmetic — so the two spellings §4 depends on could not appear in a card +/// at all. The rule was unstatable in its own language. +#[test] +fn a_hyphenated_copy_class_is_not_arithmetic() { + for class in ["vocabulary", "user-copy"] { + let report = check_ui_l0_named( + "class", + &format!("copy c {{ class: {class}, en: \"x\" }}\nview root TextTitle(text: copy.c)"), + ); + assert_eq!(report.level, Level::L0, "{class} should not classify as L1"); + assert!(report.valid, "{class}: {:#?}", report.diagnostics); + } +} + +#[test] +fn an_unknown_copy_class_is_rejected() { + let report = check_ui_l0_named( + "bogus", + "copy c { class: whatever, en: \"x\" }\nview root TextTitle(text: copy.c)", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a copy class")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_undeclared_copy_reference_is_rejected() { + // Previously `copy` was a known root and any name under it resolved to + // nothing, so a typo rendered an em dash. + let report = check_ui_l0_named("missing", "view root TextTitle(text: copy.nope)"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not declared")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn the_reference_cards_declare_their_copy_provenance() { + // All 23 copy declarations across the three cards are `vocabulary`, which + // is the discipline the profile assumes and could not previously verify. + for (name, source) in [("weather", WEATHER), ("news", NEWS), ("stock", STOCK)] { + let report = check_ui_l0_named(name, source); + assert!(report.valid, "{name}: {:#?}", report.diagnostics); + } +} From f99e7f801e53277b63f9b16f676f09cb44b2df26 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:23:46 -0700 Subject: [PATCH 27/63] =?UTF-8?q?docs:=20=C2=A74=20states=20what=20the=20n?= =?UTF-8?q?o-facts=20rule=20enforces,=20and=20what=20it=20does=20not?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ui-profile-l0.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 1798a0a..4f9c4c1 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -252,6 +252,30 @@ at L0 precisely because `view` is a typed tree — a `model-copy` literal bound argument is a structural violation, not a judgement call. It is the property that makes `"34 mph"` concatenated onto a live value unrepresentable rather than merely discouraged. +**What is actually enforced, and what is not.** Both halves matter, and only one is complete: + +| Position | Rule | +|---|---| +| A `value` argument (`TextHero`, `TextStat`, `TextValue`, `TextCaption`, `Tile`) | Must be bound. A literal — string, number or token — is refused. `TextHero(value: "34 mph")` is rejected | +| Any `path`-kind argument (the drawing widgets) | Must be bound. `TempBar(lo: 5)` is rejected | +| `copy.x` in any rendering position | Refused when `x` is declared `model-copy` | +| A **raw string literal** in a `text`, `label` or `glyph` position | **Accepted** | + +The last row is a deliberate decision, not an oversight. A raw literal carries no declared +provenance, and by this section's own logic that makes it model-copy — so `TextTitle(text: +"Revenue: $41.2M")` passes, and a model that simply writes the string inline is unaffected by +the rule. Refusing it would mean every human-readable string is declared `copy`, which is a +real ergonomic cost and buys less than it appears to: of the eleven literals in the reference +cards, six are `glyph` symbols (`‹ ↑ ↓ ≈`) that are closed presentation vocabulary rather than +language, and translating them is meaningless. + +So the guarantee this section provides is narrower than its first paragraph implies. **A +fabricated number cannot reach a numeric position, and fabricated prose can reach a text +position.** The first is the failure mode that produced the six shipped bugs this rule exists +for — an invented `condition`, an invented `wmin`/`wmax` — and it is closed. The second is open +by choice, and would need retained provenance on every string, not a broader argument category, +to close. + Implicit dependencies are declared like any other source: ``` From 6827fd04313e091f6c4d02aef4e1a28c598960d5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:55:26 -0700 Subject: [PATCH 28/63] =?UTF-8?q?test:=20mutation-test=20the=20validator?= =?UTF-8?q?=20=E2=80=94=2014=20of=20its=2037=20rules=20were=20unprotected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/examples/r3.rs | 57 ++++++ crates/splash-core/src/ui_l0.rs | 12 ++ crates/splash-core/tests/ui_profile_l0.rs | 227 +++++++++++++++++++++- mutate.py | 142 ++++++++++++++ 4 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 crates/splash-core/examples/r3.rs create mode 100755 mutate.py diff --git a/crates/splash-core/examples/r3.rs b/crates/splash-core/examples/r3.rs new file mode 100644 index 0000000..52f0e6b --- /dev/null +++ b/crates/splash-core/examples/r3.rs @@ -0,0 +1,57 @@ +use splash_core::ui_l0::*; +fn dump(n: &UiNode, d: usize) { + println!("{}{} {:?}", " ".repeat(d), n.kind, n.args); + for c in &n.children { + dump(c, d + 1); + } +} +fn show(label: &str, src: &str) { + let r = check_ui_l0_named("p", src); + println!(" accepted={:<5} {label}", r.valid); + for d in r.diagnostics.iter().take(1) { + println!(" {}", d.message); + } +} +fn main() { + println!("#3 — does a literal reach a numeric position via a prop?"); + show("Bar(n: 41.2) -> TempBar(lo: n)", + "component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) }\nview root Bar(n: 41.2)"); + + println!("\n#1 — copy with NO class at all:"); + show( + "copy claim { en: \"Revenue: $41.2M\" }", + "copy claim { en: \"Revenue: $41.2M\" }\nview root TextTitle(text: copy.claim)", + ); + + println!("\n#2 — model-copy laundered through a state initial:"); + show("state s { initial: copy.claim }", + "copy claim { class: model-copy, en: \"Revenue: $41.2M\" }\ncomponent F() { state s { shape: text, initial: copy.claim } view TextTitle(text: s) }\nview root F()"); + + println!("\n#6 — does an enum member overwrite the shape?"); + show("shape: enum[a, text] then set(\"arbitrary\")", + "state mode { shape: enum[a, text], initial: .a }\nevent e { mode: set(\"arbitrary\") }\nview root Rule()"); + + println!("\n#7 — misspelled shape bypasses set checking:"); + show( + "shape: nubmer, set(\"oops\")", + "state n { shape: nubmer, initial: 0 }\nevent e { n: set(\"oops\") }\nview root Rule()", + ); + + println!("\n#8 — token set() into a non-enum:"); + show( + "shape: number, set(.c)", + "state n { shape: number, initial: 0 }\nevent e { n: set(.c) }\nview root Rule()", + ); + + println!("\n#9 — dotted source authorizes its whole root:"); + show( + "source env.locale, read env.theme.secret", + "source env.locale sys.locale()\nview root TextRow(text: env.theme.secret)", + ); + + println!("\n#4 — enum token prop keeps its dot?"); + let src = "component F(unit: enum[c, f]) { view Col { when unit == .c { TextRow(text: \"MATCHED\") } } }\nview root F(unit: .c)"; + let out = realize(src, &serde_json::json!({}), RealizeLimits::default()); + print!(" "); + dump(out.root.as_ref().unwrap(), 0); +} diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 76b29dd..a7d70d2 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -2245,6 +2245,18 @@ pub mod catalog { // ──────────────────────────────────────────────────────────────────── validation ── fn validate(card: &Card, sink: &mut Diagnostics) { + // A card with no `view root` has nothing to render. Realization says so, + // but the CHECKER accepted it — and the checker is what a host asks before + // storing a record, so an unusable card could be appended and only fail + // when someone tried to look at it. + if !card.views.iter().any(|v| v.name == "root") { + sink.push( + 1, + 1, + "a card needs a `view root`: it is the entry point a host realizes".into(), + ); + } + // Components must not form a cycle — profile §6, condition 1. Without this // an L0 realization need not terminate. check_component_acyclicity(card, sink); diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index c5bbb9f..94783e6 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -2583,18 +2583,44 @@ fn retyping_between_text_and_number_is_visible() { fn a_contradictory_duplicate_header_field_is_rejected() { // Last-wins meant a card could declare what it needed and then declare the // truth after it: `# level: L2` followed by `# level: L0` was accepted. + // + // The FIRST value must be one that passes on its own, or this test proves + // nothing. An earlier version used `L2` then `L0`: the retained `L2` failed + // the level-vs-derived check, so the card was rejected by a different rule + // and the test passed even with the contradiction check disabled entirely. + // Mutation testing is what surfaced that. let report = check_ui_l0_named( - "dup", - "# ledger x@1.0.0\n# level: L2\n# level: L0\nview root Rule()", + "dup-profile", + "# ledger x@1.0.0\n# profile: ui/l0\n# profile: nonsense/v9\nview root Rule()", + ); + assert!(!report.valid, "a contradictory header must be refused"); + assert_eq!( + report.diagnostics.len(), + 1, + "exactly one rule should fire, or this does not isolate it: {:#?}", + report.diagnostics ); - assert!(!report.valid); assert!( - report + report.diagnostics[0] + .message + .contains("declares profile twice"), + "{:#?}", + report.diagnostics + ); + + // And the level form, which is the one the defect was found in. + let level = check_ui_l0_named( + "dup-level", + "# ledger x@1.0.0\n# level: L0\n# level: L2\nview root Rule()", + ); + assert!(!level.valid); + assert!( + level .diagnostics .iter() .any(|d| d.message.contains("declares level twice")), "{:#?}", - report.diagnostics + level.diagnostics ); } @@ -2785,3 +2811,194 @@ fn the_reference_cards_declare_their_copy_provenance() { assert!(report.valid, "{name}: {:#?}", report.diagnostics); } } + +// ─── rules the validator enforces that no test was holding ─────────────────── +// +// Found by mutation-testing the validator (mutate.py): suppress one diagnostic, +// run the suite, see whether anything goes red. These nine did not. Each was +// enforced and unprotected — free to regress silently, which is exactly how the +// `initial` fix ended up with a regression test that checked the wrong artifact +// and could not fail. + +#[test] +fn state_must_be_the_last_segment_of_a_path() { + let report = check_ui_l0_named( + "midstate", + "source now sys.weather(lat: 1.0, lon: 2.0)\nview root TextRow(text: now.$state.x)", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("last segment")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn cycle_requires_an_enum_state() { + let report = check_ui_l0_named( + "cyclebool", + "state flag { shape: bool, initial: false }\n\ + event e { flag: cycle(.a, .b) }\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("needs an enum state")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_loop_over_something_that_is_not_a_collection_is_reported() { + // A REALIZATION diagnostic, not a validation one: whether a source's value + // is an array is the host's answer, not the card's declaration. Writing + // this against check_ui_l0 first is how I learned that. + let report = realize( + "source movers sys.movers()\nview root Panel { for x in movers key x.id { Rule() } }", + &serde_json::json!({"movers": 7}), + RealizeLimits::default(), + ); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a collection")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_reference_to_an_undeclared_view_is_rejected() { + let report = check_ui_l0_named("noview", "view root Panel { missing_view }"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("is not a declared view")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn set_from_an_unreadable_name_is_rejected() { + let report = check_ui_l0_named( + "unreadable", + "state n { shape: number, initial: 0 }\n\ + event e { n: set(nowhere.value) }\nview root Rule()", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a readable name")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn an_into_on_a_component_with_only_an_anonymous_slot_is_rejected() { + // The complement of `an_into_naming_an_absent_slot_is_rejected`, which + // exercised the branch where the component DOES offer named slots. This is + // the other message, and it was unheld. + let report = check_ui_l0_named( + "onlyanon", + "component F() { view Panel { slot } }\n\ + view root Panel { F() { into header { Rule() } } }", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("only an anonymous slot")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_token_where_a_path_belongs_is_rejected() { + let report = check_ui_l0_named("tokpath", "view root WeatherIcon(cond: .sunny)"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("takes a bound path")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_literal_where_an_event_prop_belongs_is_rejected() { + let report = check_ui_l0_named( + "litevent", + "component F(on_go: event) { view Panel { Row(on_tap: on_go) { Rule() } } }\n\ + view root F(on_go: \"nope\")", + ); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("takes an event, not a literal")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn a_card_without_a_root_view_is_rejected() { + let report = check_ui_l0_named("noroot", "view sidebar Panel { Rule() }"); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("view root")), + "{:#?}", + report.diagnostics + ); +} + +/// The level-classified rules (`let`, `while`, arithmetic, `fn`, module access) +/// are held by the LEVEL a card is assigned, not by their diagnostic text — so +/// suppressing the message changes nothing and mutation testing reports them as +/// unprotected. Assert on the level itself, which is the thing §7 actually +/// promises: escalation is never silent. +#[test] +fn every_construct_outside_l0_raises_the_level() { + for (source, expected, what) in [ + // L2, not L1: an imperative binding is not a pure expression, so it + // sits with the widget commands rather than with arithmetic. + ("view root Panel { }\nlet x = 1", Level::L2, "let"), + ("view root Panel { }\nwhile true { }", Level::L2, "while"), + ( + "state n { shape: number, initial: 0 }\nview root TextStat(value: n + 1)", + Level::L1, + "arithmetic", + ), + ("view root Panel { }\nfn tick() { }", Level::L2, "fn"), + ] { + let report = check_ui_l0_named("level", source); + assert!(!report.valid, "{what} must not be L0-valid"); + assert_eq!( + report.level, expected, + "{what} should classify as {expected:?}: {:#?}", + report.diagnostics + ); + } +} diff --git a/mutate.py b/mutate.py new file mode 100755 index 0000000..b0a6e06 --- /dev/null +++ b/mutate.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Mutation-test the L0 validator: which of its rules does the suite actually hold? + +A passing test proves the code does what the test says. It does NOT prove the +test would notice if the code stopped. Three review rounds on this branch found +guarantees that were false within hours and one test of mine that could not fail +at all, so "the suite is green" has repeatedly meant less than it appeared to. + +This answers the harder question mechanically. For each diagnostic the validator +can emit — each one being some rule the profile claims — suppress it, run the +suite, and see whether anything goes red. + + SURVIVED nothing failed. That rule is unprotected: it could regress + silently, exactly like the `initial` fix whose regression test + turned out to check the wrong artifact. + caught at least one test failed. The rule is genuinely held. + +Suppression happens in Diagnostics::push rather than at each call site, so the +mutation is uniform and cannot accidentally change control flow. + + ./mutate.py # every rule + ./mutate.py # only rules whose message matches +""" + +import re +import subprocess +import sys +from pathlib import Path + +SRC = Path(__file__).parent / "crates/splash-core/src/ui_l0.rs" + +# Rules this harness CANNOT test, verified by hand instead. Suppressing a +# message only disables a rule when the message IS the enforcement; where a rule +# also sets the level or `valid` directly, the mutation is a no-op and the rule +# reports as unprotected while being perfectly well held. +# +# let / while / arithmetic enforced by the LEVEL a card is assigned, which +# `every_construct_outside_l0_raises_the_level` +# asserts directly. +# header duplicates check_header writes report.diagnostics itself +# rather than going through Diagnostics::push, so +# the hook never sees it. Verified by disabling +# `report.valid = false` and watching +# `a_contradictory_duplicate_header_field_is_rejected` +# go red. +# +# Isolating that second one changed the test: the original used `# level: L2` +# then `# level: L0`, and 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. That is the failure this harness exists +# to find, and it found it in a test written the same day. +KNOWN_ARTIFACTS = [ + "`let` is not in L0", + "`while` is not in L0", + "arithmetic is not in L0", + "the header declares level twice", + "the header declares profile twice", +] + +# The hook: a line inside Diagnostics::push that drops the targeted message. +ANCHOR = """ fn push(&mut self, line: usize, column: usize, message: String) { +""" +INJECT = ' if message.contains(%s) { return; }\n' + + +def messages(): + """Distinct diagnostic literals, as proxies for the rules they enforce.""" + src = SRC.read_text() + found = set() + for m in re.finditer(r'"((?:[^"\\\n]|\\.){12,90})"', src): + t = m.group(1) + if any(k in t for k in ("is not", "must", "cannot", "takes a", "has no", + "needs a", "may not", "is recursive", "only", + "does not accept", "is an event", "renders a value", + "declares level", "declares profile", "writes")): + found.add(t) + # The runtime message is formatted, so match on its longest LITERAL run — + # the text between placeholders. Using only the prefix dropped every message + # that starts with one, e.g. "{root:?} is not a declared name". + out = {} + for t in found: + runs = [r.strip() for r in re.split(r"\{[^}]*\}", t)] + longest = max(runs, key=len) if runs else "" + # Trim a trailing fragment that would not appear verbatim once formatted. + if len(longest) >= 12: + out.setdefault(longest, t) + return sorted(out.items()) + + +def run_tests(): + """True if the suite passes.""" + r = subprocess.run( + ["cargo", "test", "-q", "-p", "splash-core", "--test", "ui_profile_l0"], + cwd=SRC.parents[3], capture_output=True, text=True, + ) + return r.returncode == 0 + + +def main(): + only = sys.argv[1] if len(sys.argv) > 1 else None + original = SRC.read_text() + assert ANCHOR in original, "Diagnostics::push signature moved" + + targets = [(p, f) for p, f in messages() if not only or only in f] + print(f"{len(targets)} rules to check\n") + + survived, caught, broken = [], [], [] + try: + for i, (prefix, full) in enumerate(targets, 1): + literal = '"%s"' % prefix.replace("\\", "\\\\").replace('"', '\\"') + SRC.write_text(original.replace(ANCHOR, ANCHOR + INJECT % literal, 1)) + ok = run_tests() + label = full[:66] + artifact = any(a in full for a in KNOWN_ARTIFACTS) + if ok and artifact: + caught.append(label) + print(f" [{i:2}/{len(targets)}] held* {label}") + elif ok: + survived.append(label) + print(f" [{i:2}/{len(targets)}] SURVIVED {label}") + else: + caught.append(label) + print(f" [{i:2}/{len(targets)}] caught {label}") + finally: + SRC.write_text(original) + + print(f"\n{'=' * 72}") + print(f"held by a test: {len(caught)} UNPROTECTED: {len(survived)}") + print("(* held by a level or validity assertion this harness cannot reach;" + " see KNOWN_ARTIFACTS)") + if survived: + print("\nRules no test would notice regressing:\n") + for s in survived: + print(f" - {s}") + if broken: + print(f"\ncompile failures (mutation invalid): {len(broken)}") + return 1 if survived else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 02ec9e41c0a5052479f9c7d6507159f39a5edd65 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:23:09 -0700 Subject: [PATCH 29/63] =?UTF-8?q?fix(ui=5Fl0):=20codex=20round=20three,=20?= =?UTF-8?q?part=20one=20=E2=80=94=20parser,=20props,=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 472 +++++++++++++++++----- crates/splash-core/tests/ui_profile_l0.rs | 317 +++++++++++++++ mutate.py | 35 +- 3 files changed, 714 insertions(+), 110 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index a7d70d2..ee7492e 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -290,7 +290,7 @@ fn definition_digest(component: &Component) -> u64 { let mut acc = String::new(); acc.push_str(&component.name); for p in &component.params { - acc.push_str(&format!("{}:{:?}", p.name, p.shape)); + acc.push_str(&format!("{}:{:?}:{:?}", p.name, p.shape, p.default)); acc.push(','); } for st in &component.states { @@ -786,6 +786,11 @@ enum Provenance { struct Param { name: String, shape: Shape, + /// `title: text = "Safe"`. Dropped, an omitted argument created no binding + /// at all and lookup fell through to top-level injected data — so an + /// unfilled prop could capture whatever the host happened to supply under + /// the same name. + default: Option, } #[derive(Debug)] @@ -1034,6 +1039,24 @@ impl<'a> Parser<'a> { self.at += 1; if let Some(n) = self.ident() { let (provenance, locales) = self.parse_locale_map(); + // Absent `class` is NOT vocabulary. Defaulting to + // trusted meant the rule only bit when a card + // volunteered the label, which a model with something to + // hide would simply not do. + let provenance = match provenance { + Some(p) => p, + None => { + self.sink.at( + &t, + format!( + "copy {n:?} declares no `class`; L0 needs one of \ + `vocabulary`, `user-copy` or `model-copy` \ + (profile §4)" + ), + ); + Provenance::ModelCopy + } + }; card.copies.push(CopyDecl { name: n, provenance, @@ -1244,95 +1267,119 @@ impl<'a> Parser<'a> { let mut initial = None; let mut initial_path = None; let mut keep = false; - // `{ shape: enum[a, b], initial: .a }` — capture the shape, skip the rest. - let start = self.at; + // `{ shape: enum[a, b], initial: .a, keep: true }`. + // + // Read the value AFTER each key rather than scanning the body for shape + // words. Scanning meant a member of an enum could retype the state it + // belonged to: `shape: enum[a, text]` parsed as the enum and then the + // member `text` overwrote it, so the field accepted any string. if self.peek().is_some_and(|t| t.is_punct("{")) { - let mut scan = self.at; + let mut scan = self.at + 1; + let mut saw_shape = false; while let Some(t) = self.tokens.get(scan) { if t.is_punct("}") { break; } - if t.is_kw("bool") { - shape = Shape::Bool; - } - if t.is_kw("text") { - shape = Shape::Text; - } - if t.is_kw("number") { - shape = Shape::Number; - } - if t.is_kw("record") { - shape = Shape::Record; - } - if t.is_kw("collection") { - shape = Shape::Collection; - } - if t.is_kw("event") { - shape = Shape::Event; + let is_key = t.kind == Kind::Ident + && self.tokens.get(scan + 1).is_some_and(|n| n.is_punct(":")); + if !is_key { + scan += 1; + continue; } - // `initial: ` — a token, string or number. - if t.is_kw("initial") && self.tokens.get(scan + 1).is_some_and(|n| n.is_punct(":")) - { - if let Some(v) = self.tokens.get(scan + 2) { - initial = match v.kind { - Kind::Str => Some(crate::JsonValue::String(v.text.clone())), - Kind::Token => Some(crate::JsonValue::String( - v.text.trim_start_matches('.').to_string(), - )), - Kind::Num => v.text.parse::().ok().map(crate::JsonValue::from), - Kind::Ident if v.text == "true" || v.text == "false" => { - Some(crate::JsonValue::Bool(v.text == "true")) + let key = t.text.clone(); + let value = self.tokens.get(scan + 2); + match key.as_str() { + "shape" => { + saw_shape = true; + shape = match value.map(|v| v.text.as_str()) { + Some("text") => Shape::Text, + Some("number") => Shape::Number, + Some("bool") => Shape::Bool, + Some("record") => Shape::Record, + Some("collection") => Shape::Collection, + Some("event") => Shape::Event, + Some("enum") => { + let mut members = Vec::new(); + let mut at = scan + 4; // past `enum` and `[` + while let Some(m) = self.tokens.get(at) { + if m.is_punct("]") { + break; + } + if m.kind == Kind::Ident { + members.push(m.text.clone()); + } + at += 1; + } + Shape::Enum(members) } - Kind::Ident => { - // A path. Collect its dotted segments from the - // token stream; it resolves at realization. - let mut path = v.text.clone(); - let mut at = scan + 3; - while self.tokens.get(at).is_some_and(|t| t.is_punct(".")) - && self - .tokens - .get(at + 1) - .is_some_and(|t| t.kind == Kind::Ident) - { - path.push('.'); - path.push_str(&self.tokens[at + 1].text); - at += 2; + Some(other) => { + // A misspelling used to become `Other`, which + // the `set` checks treat as "accepts anything" — + // so a typo silently disabled them. + let other = other.to_string(); + if let Some(v) = value { + self.sink.at( + v, + format!( + "{other:?} is not an L0 shape; L0 has text, \ + number, bool, enum[…], record, collection \ + and event" + ), + ); } - initial_path = Some(path); - None + Shape::Other } - _ => None, + None => Shape::Other, }; } - } - if t.is_kw("keep") && self.tokens.get(scan + 1).is_some_and(|n| n.is_punct(":")) { - keep = self - .tokens - .get(scan + 2) - .is_some_and(|v| v.kind == Kind::Ident && v.text == "true"); - } - if t.is_kw("enum") { - let mut members = Vec::new(); - let mut j = scan + 1; - if self.tokens.get(j).is_some_and(|t| t.is_punct("[")) { - j += 1; - while let Some(m) = self.tokens.get(j) { - if m.is_punct("]") { - break; - } - if m.kind == Kind::Ident { - members.push(m.text.clone()); - } - j += 1; + "initial" => { + if let Some(v) = value { + initial = match v.kind { + Kind::Str => Some(crate::JsonValue::String(v.text.clone())), + Kind::Token => Some(crate::JsonValue::String( + v.text.trim_start_matches('.').to_string(), + )), + Kind::Num => v.text.parse::().ok().map(crate::JsonValue::from), + Kind::Ident if v.text == "true" || v.text == "false" => { + Some(crate::JsonValue::Bool(v.text == "true")) + } + Kind::Ident => { + let mut path = v.text.clone(); + let mut at = scan + 3; + while self.tokens.get(at).is_some_and(|t| t.is_punct(".")) + && self + .tokens + .get(at + 1) + .is_some_and(|t| t.kind == Kind::Ident) + { + path.push('.'); + path.push_str(&self.tokens[at + 1].text); + at += 2; + } + initial_path = Some(path); + None + } + _ => None, + }; } } - shape = Shape::Enum(members); + "keep" => { + keep = value.is_some_and(|v| v.kind == Kind::Ident && v.text == "true"); + } + _ => {} } scan += 1; } - self.at = start; + + if !saw_shape { + if let Some(t) = self.peek().cloned() { + self.sink + .at(&t, format!("state {path:?} declares no `shape`")); + } + } self.skip_braced(); } + Some(StateDecl { path, shape, @@ -1343,9 +1390,9 @@ impl<'a> Parser<'a> { } /// `{ class: vocabulary, en: "…", zh: "…" }` — the text per locale. - fn parse_locale_map(&mut self) -> (Provenance, Vec<(String, String)>) { + fn parse_locale_map(&mut self) -> (Option, Vec<(String, String)>) { let mut out = Vec::new(); - let mut provenance = Provenance::Vocabulary; + let mut provenance = None; if !self.eat_punct("{") { return (provenance, out); } @@ -1368,7 +1415,7 @@ impl<'a> Parser<'a> { // `-` lexes as punctuation, so `user-copy` arrives as // the identifier `user`. Match the head rather than // reassembling: the three classes differ there. - provenance = match v.text.as_str() { + provenance = Some(match v.text.as_str() { "user" => Provenance::UserCopy, "model" => Provenance::ModelCopy, "vocabulary" => Provenance::Vocabulary, @@ -1382,7 +1429,7 @@ impl<'a> Parser<'a> { ); Provenance::Vocabulary } - }; + }); } else if v.kind == Kind::Str { out.push((t.text.clone(), v.text.clone())); } @@ -1587,9 +1634,37 @@ impl<'a> Parser<'a> { } _ => Shape::Other, }; + // `= ` after the shape. + let mut default = None; + let mut at = self.at + 3; + if matches!(shape, Shape::Enum(_)) { + // Skip `[a, b]`. + while self.tokens.get(at).is_some_and(|t| !t.is_punct("]")) { + at += 1; + } + at += 1; + } + if self.tokens.get(at).is_some_and(|t| t.is_punct("=")) { + if let Some(v) = self.tokens.get(at + 1) { + default = match v.kind { + Kind::Str => Some(crate::JsonValue::String(v.text.clone())), + Kind::Token => Some(crate::JsonValue::String( + v.text.trim_start_matches('.').to_string(), + )), + Kind::Num => { + v.text.parse::().ok().map(crate::JsonValue::from) + } + Kind::Ident if v.text == "true" || v.text == "false" => { + Some(crate::JsonValue::Bool(v.text == "true")) + } + _ => None, + }; + } + } params.push(Param { name: t.text.clone(), shape, + default, }); } } @@ -1939,6 +2014,18 @@ impl<'a> Parser<'a> { /// Resolve an operand in a comparison's right-hand position. /// Follow a dotted path into injected data. Used for a path-valued `initial`, /// which resolves against what the host supplied rather than at parse time. +/// A literal operand's value. +fn literal_of(operand: &Operand) -> crate::JsonValue { + match operand { + Operand::Str(t) => crate::JsonValue::String(t.clone()), + Operand::Num(n) => crate::JsonValue::from(*n), + Operand::Token(t) => crate::JsonValue::String(t.trim_start_matches('.').to_string()), + Operand::Path(p) | Operand::Predicate { path: p, .. } => { + crate::JsonValue::String(p.clone()) + } + } +} + fn data_path(data: &crate::JsonValue, path: &str) -> Option { let mut current = data.clone(); for segment in path.split('.') { @@ -2263,12 +2350,16 @@ fn validate(card: &Card, sink: &mut Diagnostics) { validate_transitions(card, sink); validate_sources(card, sink); + // Which props of which components end up rendering a fact (§4). + let data_positions = data_position_params(card); + let view_names: Vec<&str> = card.views.iter().map(|v| v.name.as_str()).collect(); let component_names: Vec<&str> = card.components.iter().map(|c| c.name.as_str()).collect(); for view in &card.views { let mut scope = Scope::card(card); walk( + &data_positions, &view.body, &mut scope, &view_names, @@ -2280,6 +2371,7 @@ fn validate(card: &Card, sink: &mut Diagnostics) { for component in &card.components { let mut scope = Scope::component(card, component); walk( + &data_positions, &component.body, &mut scope, &view_names, @@ -2296,6 +2388,29 @@ fn validate(card: &Card, sink: &mut Diagnostics) { /// events, which a component reaches only by receiving one as a prop (§5.4) — so /// the transition still runs in the scope that declared it. fn validate_transitions(card: &Card, sink: &mut Diagnostics) { + check_state_initials(&card.states, sink, 1); + for component in &card.components { + check_state_initials(&component.states, sink, component.line); + } + + // A path-valued `initial` is a rendering position by proxy: whatever it + // names ends up on screen. Checking only direct `copy.x` references let + // model-copy through by way of a state. + let card_scope = Scope::card(card); + for state in &card.states { + if let Some(path) = &state.initial_path { + check_path(path, &card_scope, 1, 1, sink); + } + } + for component in &card.components { + let scope = Scope::component(card, component); + for state in &component.states { + if let Some(path) = &state.initial_path { + check_path(path, &scope, component.line, 1, sink); + } + } + } + // `set(path)` READS. Its operand must therefore be a readable name, which // an event is not: `set(some_event)` parses, resolves to nothing and writes // garbage into the cell. @@ -2358,6 +2473,37 @@ fn shape_name(shape: &Shape) -> &'static str { } } +/// Profile §2: a declared `initial` must fit the shape it is declared with. +/// Neither was checked against the other, so `shape: number, initial: "oops"` +/// mounted a string into a numeric cell before any event ran. +fn check_state_initials(states: &[StateDecl], sink: &mut Diagnostics, line: usize) { + for state in states { + let Some(initial) = &state.initial else { + continue; + }; + let fits = match (&state.shape, initial) { + (Shape::Text, crate::JsonValue::String(_)) => true, + (Shape::Number, v) => v.is_number(), + (Shape::Bool, crate::JsonValue::Bool(_)) => true, + (Shape::Enum(members), crate::JsonValue::String(s)) => members.iter().any(|m| m == s), + // A shape that names no literal form, or one we could not classify. + (Shape::Record | Shape::Collection | Shape::Event | Shape::Other, _) => true, + _ => false, + }; + if !fits { + sink.push( + line, + 1, + format!( + "state {:?} declares {} but an initial of {initial}", + state.path, + shape_name(&state.shape) + ), + ); + } + } +} + fn check_event_batch( events: &[EventDecl], states: &[StateDecl], @@ -2418,6 +2564,18 @@ fn check_event_batch( ); } Form::Set(SetSource::Token(t)) => { + if !matches!(state.shape, Shape::Enum(_) | Shape::Other) { + sink.push( + transition.line, + transition.column, + format!( + "`set` writes the token .{t}, but {:?} is {} — only an enum \ + has members", + transition.target, + shape_name(&state.shape) + ), + ); + } if let Shape::Enum(members) = &state.shape { if !members.iter().any(|m| m == t) { sink.push( @@ -2720,6 +2878,7 @@ fn root_of>(path: S) -> String { #[allow(clippy::too_many_arguments)] fn walk( + data_positions: &BTreeMap>, element: &Element, scope: &mut Scope, views: &[&str], @@ -2814,13 +2973,22 @@ fn walk( ); } for arg in &element.args { - check_arg(name, arg, known, is_component, scope, card, sink); + check_arg( + data_positions, + name, + arg, + known, + is_component, + scope, + card, + sink, + ); } } } for child in &element.children { - walk(child, scope, views, components, card, sink); + walk(data_positions, child, scope, views, components, card, sink); } if element.name == "for" { @@ -2830,7 +2998,13 @@ fn walk( } } +/// Eight parameters because a card-level check needs card-level context: +/// the catalog entry, the scope, the card, and the data-position map are all +/// consulted per argument. Splitting them into a struct would move the noise +/// rather than remove it. +#[allow(clippy::too_many_arguments)] fn check_arg( + data_positions: &BTreeMap>, ctor: &str, arg: &Arg, known: Option, @@ -2861,7 +3035,28 @@ fn check_arg( // then silently dropped at realization, rendering an em dash. if let Some(component) = card.components.iter().find(|c| c.name == ctor) { match component.params.iter().find(|p| p.name == arg.name) { - Some(param) => check_prop(ctor, arg, param, scope, sink), + Some(param) => { + check_prop(ctor, arg, param, scope, sink); + // §4 again, one level out: a literal handed to a prop + // that lands in a data position is the same authored + // fact, just laundered. + if matches!(arg.value, Operand::Str(_) | Operand::Num(_)) + && data_positions + .get(ctor) + .is_some_and(|ps| ps.contains(&arg.name)) + { + sink.push( + arg.line, + arg.column, + format!( + "{ctor}.{} reaches a data position and must be bound; \ + a literal passed here is a model-authored fact \ + (profile §4)", + arg.name + ), + ); + } + } None => { let mut names: Vec<&str> = component.params.iter().map(|p| p.name.as_str()).collect(); @@ -3026,6 +3221,76 @@ fn check_arg( /// outside a declared enum. Anything else is left alone, because a prop shape /// is a contract about the DATA and most positions legitimately accept a path /// whose type is not knowable until the host answers. +/// Which of a component's params end up in a DATA position — an argument the +/// catalog declares as `Data` or `Path`, i.e. one that renders a fact. +/// +/// Without this, §4 is enforced only at the literal's immediate call site, and +/// laundering it through one prop defeats it entirely: +/// +/// component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) } +/// view root Bar(n: 41.2) +/// +/// Computed to a fixpoint so passing the value through a chain of components is +/// no better a hiding place than passing it directly. +fn data_position_params(card: &Card) -> BTreeMap> { + let mut tainted: BTreeMap> = BTreeMap::new(); + + for _ in 0..=card.components.len() { + let mut changed = false; + for component in &card.components { + let names: Vec<&str> = component.params.iter().map(|p| p.name.as_str()).collect(); + let mut found: Vec = Vec::new(); + + fn walk( + element: &Element, + names: &[&str], + tainted: &BTreeMap>, + out: &mut Vec, + ) { + let known = catalog::lookup(&element.name); + for arg in &element.args { + let Operand::Path(path) = &arg.value else { + continue; + }; + let root = root_of(path); + if !names.iter().any(|n| *n == root) { + continue; + } + // Directly in a data position... + let direct = known + .and_then(|a| a.iter().find(|(n, _)| *n == arg.name)) + .is_some_and(|(_, k)| { + matches!(k, catalog::ArgKind::Data | catalog::ArgKind::Path) + }); + // ...or handed to another component that puts it in one. + let onward = tainted + .get(&element.name) + .is_some_and(|ps| ps.contains(&arg.name)); + if (direct || onward) && !out.contains(&root) { + out.push(root); + } + } + for child in &element.children { + walk(child, names, tainted, out); + } + } + + walk(&component.body, &names, &tainted, &mut found); + let entry = tainted.entry(component.name.clone()).or_default(); + for name in found { + if !entry.contains(&name) { + entry.push(name); + changed = true; + } + } + } + if !changed { + break; + } + } + tainted +} + fn check_prop(ctor: &str, arg: &Arg, param: &Param, scope: &Scope, sink: &mut Diagnostics) { let name = ¶m.name; match (¶m.shape, &arg.value) { @@ -3563,28 +3828,39 @@ impl Realizer<'_> { ) { // Props bind by name; a prop the caller omits is simply absent, and any // path inside the component resolves against this frame first. + // Bind by DECLARED SHAPE. The shape was retained at parse and then + // discarded here, which cost two bugs: an event prop was looked up as + // data, so a state whose name collided with an event hijacked the + // routing; and an enum token bound with its leading dot, so every + // comparison against it inside the component was false. let mut bound = 0usize; - for arg in &call.args { - if component.params.iter().any(|p| p.name == arg.name) { - let value = match &arg.value { - Operand::Path(p) => scope - .lookup(p) - // An event prop has no value in data; pass the name. - .unwrap_or_else(|| crate::JsonValue::String(p.clone())), - Operand::Token(t) => crate::JsonValue::String(t.clone()), - // A literal prop is a value like any other. Letting these - // fall to Null is how `Framed(title: "Details")` rendered an - // em dash: the parser kept the string and the realizer threw - // it away. - Operand::Str(t) => crate::JsonValue::String(t.clone()), - Operand::Num(n) => crate::JsonValue::from(*n), - Operand::Predicate { path: p, .. } => scope + for param in &component.params { + let supplied = call.args.iter().find(|a| a.name == param.name); + let value = match (supplied, ¶m.shape) { + // An event names itself. It has no value in data, and looking + // one up is how the wrong event came to fire. + (Some(arg), Shape::Event) => match &arg.value { + Operand::Path(p) => crate::JsonValue::String(p.clone()), + other => literal_of(other), + }, + // A token is a bare member; the dot is lexical marking, not + // part of the value. + (Some(arg), _) => match &arg.value { + Operand::Token(t) => { + crate::JsonValue::String(t.trim_start_matches('.').to_string()) + } + Operand::Path(p) | Operand::Predicate { path: p, .. } => scope .lookup(p) .unwrap_or_else(|| crate::JsonValue::String(p.clone())), - }; - scope.frames.push((arg.name.clone(), value)); - bound += 1; - } + other => literal_of(other), + }, + // Omitted: the declared default, or nothing. Binding a frame + // either way is what stops lookup falling through to injected + // data and letting the host capture an unfilled prop. + (None, _) => param.default.clone().unwrap_or(crate::JsonValue::Null), + }; + scope.frames.push((param.name.clone(), value)); + bound += 1; } let instance_key = format!("{key}/{}", component.name); self.live.push(instance_key.clone()); diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 94783e6..c6034c2 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3002,3 +3002,320 @@ fn every_construct_outside_l0_raises_the_level() { ); } } + +// ─── codex round three ─────────────────────────────────────────────────────── +// Written BEFORE the fixes, and each confirmed failing first. + +/// #6: `parse_state` scanned every token in the body for a shape word instead +/// of reading the one after `shape:`. So an enum whose members include a shape +/// name was silently retyped by its own member. +#[test] +fn an_enum_member_named_like_a_shape_does_not_retype_the_state() { + let report = check_ui_l0_named( + "shadow", + "state mode { shape: enum[a, text], initial: .a }\n\ + event e { mode: set(\"arbitrary\") }\nview root Rule()", + ); + assert!( + !report.valid, + "`mode` is an enum; a member happening to be called `text` must not \ + turn it into text and let any string be written" + ); +} + +/// #7: a misspelled shape produced `Other` with no diagnostic, and the `set` +/// checks treat `Other` as "accepts anything" — so the typo disabled them. +#[test] +fn a_misspelled_shape_is_rejected_rather_than_silently_permissive() { + let report = check_ui_l0_named( + "typo-shape", + "state n { shape: nubmer, initial: 0 }\nevent e { n: set(\"oops\") }\nview root Rule()", + ); + assert!(!report.valid, "`nubmer` is not a shape"); +} + +/// #8: 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". +#[test] +fn a_token_set_into_a_non_enum_is_rejected() { + let report = check_ui_l0_named( + "toknum", + "state n { shape: number, initial: 0 }\nevent e { n: set(.c) }\nview root Rule()", + ); + assert!(!report.valid); +} + +/// #7 (second half): a declared initial was never checked against the shape it +/// was declared alongside. +#[test] +fn an_initial_must_fit_its_declared_shape() { + let report = check_ui_l0_named( + "badinit", + "state n { shape: number, initial: \"oops\" }\nview root Rule()", + ); + assert!(!report.valid); +} + +/// #3: this falsifies §4's claim that "a fabricated number cannot reach a +/// numeric position" — which I wrote into the spec the same day. A literal +/// launders through a prop into the drawing widgets, which exist to render +/// live data and hold no authored values. +#[test] +fn a_literal_cannot_launder_through_a_prop_into_a_data_position() { + let report = check_ui_l0_named( + "launder", + "component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) }\n\ + view root Bar(n: 41.2)", + ); + assert!( + !report.valid, + "an authored number must not reach TempBar by way of a prop" + ); +} + +/// #4: prop shapes were retained at parse and discarded again at realization, +/// so an enum token bound with its leading dot and every comparison against it +/// inside the component was false. +#[test] +fn an_enum_token_prop_binds_its_bare_member() { + let source = "component F(unit: enum[c, f]) { \ + view Col { when unit == .c { TextRow(text: \"MATCHED\") } } }\n\ + view root F(unit: .c)"; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!( + texts(&report.root.unwrap()), + vec!["MATCHED"], + "`.c` must bind as `c`, or the component cannot compare against its own prop" + ); +} + +/// #4 (second half): an event prop was looked up as DATA at realization, so a +/// state whose name collided with an event name hijacked the routing. +#[test] +fn an_event_prop_binds_the_event_not_same_named_state() { + let source = "state go { shape: text, initial: \"wrong\" }\n\ + event go { go: set(\"x\") }\n\ + event wrong { go: set(\"y\") }\n\ + component F(out: event) { view Panel { Row(on_tap: out) { Rule() } } }\n\ + view root F(out: go)"; + let report = realize(source, &serde_json::json!({}), RealizeLimits::default()); + let root = report.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + let on_tap = rows[0] + .args + .iter() + .find(|(n, _)| n == "on_tap") + .map(|(_, v)| v.clone()); + assert_eq!( + on_tap, + Some(NodeValue::Event("go".into())), + "the event named at the call site must be the event that fires" + ); +} + +/// #5: a declared default was parsed and dropped, an omitted argument created +/// no binding, and lookup then fell through to top-level injected data — so an +/// unfilled prop could capture whatever the host happened to supply. +#[test] +fn a_prop_default_is_used_and_injected_data_cannot_capture_it() { + let source = "component Banner(title: text = \"Safe\") { view TextRow(text: title) }\n\ + view root Banner()"; + + let empty = realize(source, &serde_json::json!({}), RealizeLimits::default()); + assert_eq!( + texts(&empty.root.unwrap()), + vec!["Safe"], + "an omitted prop must fall back to its declared default" + ); + + let hostile = realize( + source, + &serde_json::json!({"title": "attacker"}), + RealizeLimits::default(), + ); + assert_eq!( + texts(&hostile.root.unwrap()), + vec!["Safe"], + "and must not be captured by same-named data the host injected" + ); +} + +#[test] +fn laundering_a_literal_through_a_chain_of_props_is_also_refused() { + // One hop would be a spot fix. The check runs to a fixpoint, so a longer + // chain is no better a hiding place than a short one. + let bar = "component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) }\n"; + for depth in [ + format!("{bar}view root Bar(n: 41.2)"), + format!("{bar}component Outer(v: number) {{ view Panel {{ Bar(n: v) }} }}\nview root Outer(v: 41.2)"), + format!( + "{bar}component Mid(m: number) {{ view Panel {{ Bar(n: m) }} }}\n\ + component Outer(v: number) {{ view Panel {{ Mid(m: v) }} }}\nview root Outer(v: 41.2)" + ), + ] { + assert!( + !check_ui_l0_named("chain", &depth).valid, + "an authored number must not reach a data position at any depth" + ); + } + + // And the two things that must stay legal: a BOUND value through the same + // chain, and a literal in a position that renders no fact. + let bound = format!( + "source now sys.weather(lat: 1.0, lon: 2.0)\n{bar}\ + component Outer(v: number) {{ view Panel {{ Bar(n: v) }} }}\nview root Outer(v: now.temp)" + ); + assert!(check_ui_l0_named("bound", &bound).valid); + assert!( + check_ui_l0_named( + "label", + "component Lbl(t: text) { view TextTitle(text: t) }\nview root Lbl(t: \"Heading\")" + ) + .valid, + "a literal label renders no fact and must stay legal" + ); +} + +/// #1: provenance failed OPEN. A declaration with no `class` at all defaulted +/// to trusted, so the check only bit when a card volunteered the label — which +/// a model with something to hide would not. +#[test] +fn copy_without_a_class_is_refused_rather_than_trusted() { + let report = check_ui_l0_named( + "noclass", + "copy claim { en: \"Revenue: $41.2M\" }\nview root TextTitle(text: copy.claim)", + ); + assert!( + !report.valid, + "undeclared provenance must not default to vocabulary" + ); +} + +/// #2: even correctly-marked model-copy laundered through a state initial, +/// because provenance was checked only where an element referenced copy.x +/// directly. +#[test] +fn model_copy_cannot_launder_through_a_state_initial() { + let report = check_ui_l0_named( + "via-state", + "copy claim { class: model-copy, en: \"Revenue: $41.2M\" }\n\ + component F() { state s { shape: text, initial: copy.claim } view TextTitle(text: s) }\n\ + view root F()", + ); + assert!(!report.valid); +} + +// ─── parser and resource bounds ────────────────────────────────────────────── +// +// Found by the second mutation run, after fixing the harness's own blind spot: +// it had been selecting diagnostics by keyword and silently missing newer ones. +// Four of these are §6's termination bounds, which the profile claims outright. + +#[test] +fn a_source_over_the_size_limit_is_rejected() { + let huge = format!("view root Panel {{ }}\n# {}", "x".repeat(300_000)); + let report = check_ui_l0_named("huge", &huge); + assert!(!report.valid); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("byte limit")), + "{:#?}", + report.diagnostics + ); +} + +#[test] +fn malformed_input_is_rejected_rather_than_parsed_loosely() { + for (source, expect, what) in [ + ( + "state n { shape: number, initial: 0 }\nbanana root Rule()", + "expected a declaration", + "unknown declaration", + ), + ( + "view root Panel { 42 }", + "expected an element", + "a literal where an element belongs", + ), + ( + "component F() { banana }", + "expected state, event or view", + "junk in a component body", + ), + ( + "view root TextRow(text: \"unterminated)", + "unterminated string", + "an unterminated string", + ), + ( + "view root Panel { } \u{0007}", + "unexpected character", + "a character outside the grammar", + ), + ] { + let report = check_ui_l0_named("malformed", source); + assert!(!report.valid, "{what} must be rejected"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains(expect)), + "{what}: {:#?}", + report.diagnostics + ); + } +} + +/// §6, condition 5: node count and nesting depth are capped, and the +/// declaration count with them. These are the bounds that make realization +/// terminate on input the grammar alone does not bound. +#[test] +fn the_resource_bounds_hold() { + let deep = format!( + "view root {}Rule(){}", + "Panel { ".repeat(200), + " }".repeat(200) + ); + let report = check_ui_l0_named("deep", &deep); + assert!(!report.valid, "nesting must be bounded"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("too deep")), + "{:#?}", + report.diagnostics + ); + + let many = format!( + "{}view root Rule()", + (0..2_000) + .map(|i| format!("state s{i} {{ shape: number, initial: 0 }}\n")) + .collect::() + ); + let report = check_ui_l0_named("many", &many); + assert!(!report.valid, "declaration count must be bounded"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("too many declarations")), + "{:#?}", + report.diagnostics + ); + + let wide = format!("view root Panel {{ {} }}", "Rule() ".repeat(3_000)); + let report = check_ui_l0_named("wide", &wide); + assert!(!report.valid, "block width must be bounded"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("too many elements")), + "{:#?}", + report.diagnostics + ); +} diff --git a/mutate.py b/mutate.py index b0a6e06..e8c8a10 100755 --- a/mutate.py +++ b/mutate.py @@ -65,24 +65,35 @@ def messages(): - """Distinct diagnostic literals, as proxies for the rules they enforce.""" + """Every diagnostic literal, found by WHERE IT IS USED rather than by keyword. + + A keyword list silently missed rules as new ones were added — "declares no + `shape`" and "reaches a data position" were both invisible, so the harness + reported full coverage while not testing them at all. That is the same + failure the harness exists to catch, one level up. + """ src = SRC.read_text() found = set() - for m in re.finditer(r'"((?:[^"\\\n]|\\.){12,90})"', src): - t = m.group(1) - if any(k in t for k in ("is not", "must", "cannot", "takes a", "has no", - "needs a", "may not", "is recursive", "only", - "does not accept", "is an event", "renders a value", - "declares level", "declares profile", "writes")): - found.add(t) - # The runtime message is formatted, so match on its longest LITERAL run — - # the text between placeholders. Using only the prefix dropped every message - # that starts with one, e.g. "{root:?} is not a declared name". + # Scan forward from each diagnostic call and take the string literals in it. + for m in re.finditer(r"(?:sink\.push|sink\.at|self\.sink\.at|self\.sink\.push)\s*\(", src): + window = src[m.end(): m.end() + 900] + depth, cut = 1, len(window) + for i, ch in enumerate(window): + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + cut = i + break + for lit in re.findall(r'"((?:[^"\\\n]|\\.){10,})"', window[:cut]): + found.add(lit) + + # Match on the longest LITERAL run — the text between format placeholders. out = {} for t in found: runs = [r.strip() for r in re.split(r"\{[^}]*\}", t)] longest = max(runs, key=len) if runs else "" - # Trim a trailing fragment that would not appear verbatim once formatted. if len(longest) >= 12: out.setdefault(longest, t) return sorted(out.items()) From b3cde72aa34109e1f441745097758b95ff779f33 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:30:07 -0700 Subject: [PATCH 30/63] =?UTF-8?q?fix(ui=5Fl0):=20codex=20round=20three,=20?= =?UTF-8?q?part=20two=20=E2=80=94=20dotted=20sources,=20header,=20and=20tw?= =?UTF-8?q?o=20doc=20claims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 65 ++++++++++++--- crates/splash-core/tests/ui_profile_l0.rs | 96 +++++++++++++++++++++++ docs/ui-profile-l0.md | 24 +++--- 3 files changed, 164 insertions(+), 21 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index ee7492e..1d2dc60 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -95,8 +95,18 @@ pub fn parse_header(source: &str) -> Option { Some((n, v)) => (n.trim().to_string(), Some(v.trim().to_string())), None => (decl.trim().to_string(), None), }; - header.ledger = Some(name); - header.version = version; + // A card has ONE identity. Last-wins let a record declare two. + match (&header.ledger, &header.version) { + (Some(first), v) if *first != name || *v != version => { + header.contradictions.push(format!( + "the header declares the ledger twice, as {first:?} and {name:?}" + )); + } + _ => { + header.ledger = Some(name); + header.version = version; + } + } } else if let Some((key, value)) = rest.split_once(':') { let value = value.trim().to_string(); match key.trim() { @@ -122,7 +132,14 @@ pub fn parse_header(source: &str) -> Option { _ => header.profile = Some(value), } } - "model" => header.model = Some(value), + "model" => match &header.model { + Some(first) if *first != value => { + header.contradictions.push(format!( + "the header declares the model twice, as {first:?} and {value:?}" + )); + } + _ => header.model = Some(value), + }, _ => {} } } @@ -2819,7 +2836,10 @@ struct Scope { impl Scope { fn card(card: &Card) -> Self { let mut roots: Vec = Vec::new(); - roots.extend(card.sources.iter().map(|s| root_of(&s.name))); + // The FULL declared name. Storing the root meant `source env.locale` + // authorised `env.theme.secret` — an undeclared dependency the card + // then renders, which §4 says is a defect by construction. + roots.extend(card.sources.iter().map(|s| s.name.clone())); roots.extend(card.states.iter().map(|s| root_of(&s.path))); roots.push("copy".into()); let events = card.events.iter().map(|e| e.name.clone()).collect(); @@ -2838,7 +2858,7 @@ impl Scope { fn component(card: &Card, component: &Component) -> Self { let mut roots: Vec = Vec::new(); - roots.extend(card.sources.iter().map(|s| root_of(&s.name))); + roots.extend(card.sources.iter().map(|s| s.name.clone())); roots.push("copy".into()); roots.extend(component.params.iter().map(|p| p.name.clone())); roots.extend(component.states.iter().map(|s| root_of(&s.path))); @@ -2863,8 +2883,18 @@ impl Scope { } } - fn knows(&self, root: &str) -> bool { - self.roots.iter().any(|r| r == root) + /// Whether `path` is covered by a declared name. + /// + /// A declared name matches its own segments only: `env.locale` answers + /// `env.locale.lang` and does NOT answer `env.theme.secret`. Comparing + /// roots made every sibling of a dotted source readable. + fn knows(&self, path: &str) -> bool { + self.roots.iter().any(|r| { + path == r + || path + .strip_prefix(r.as_str()) + .is_some_and(|rest| rest.starts_with('.')) + }) } } @@ -3399,7 +3429,8 @@ fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut return; } - if scope.knows(&root) { + // The whole path, so a declared name can be matched by its own segments. + if scope.knows(path) { return; } if scope.forbidden_state.contains(&root) { @@ -4841,10 +4872,20 @@ pub fn source_plan(source: &str) -> SourcePlan { .iter() .filter_map(|(_, arg)| match arg { SourceArg::Path(p) => { - let root = root_of(p); - // Only another SOURCE is a fetch dependency. A path into - // card state is available before any fetch begins. - declared.iter().find(|d| **d == root).map(|_| root) + // Match a declared name by its own segments, longest + // first: `env.locale.lang` depends on `env.locale`. + // Reducing to the root compared `env` against the full + // name and recorded no edge at all, so the dependent + // was planned before the thing it reads. + let mut hits: Vec<&&str> = declared + .iter() + .filter(|d| { + *p == ***d + || p.strip_prefix(**d).is_some_and(|r| r.starts_with('.')) + }) + .collect(); + hits.sort_by_key(|d| std::cmp::Reverse(d.len())); + hits.first().map(|d| (**d).to_string()) } _ => None, }) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index c6034c2..dac5182 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3319,3 +3319,99 @@ fn the_resource_bounds_hold() { report.diagnostics ); } + +/// #9: `source_roots` was fixed to hold full names, but ordinary `Scope.roots` +/// still held the ROOT — so declaring one source authorised reading everything +/// beside it. The card then renders an undeclared dependency, which is exactly +/// what §4's "anything reachable without a declared source is a defect by +/// construction" says cannot happen. +#[test] +fn a_dotted_source_does_not_authorise_its_whole_root() { + let report = check_ui_l0_named( + "sibling", + "source env.locale sys.locale()\nview root TextRow(text: env.theme.secret)", + ); + assert!( + !report.valid, + "declaring env.locale must not grant env.theme" + ); + + // The declared one still works. + assert!( + check_ui_l0_named( + "ok", + "source env.locale sys.locale()\nview root TextRow(text: env.locale.lang)" + ) + .valid + ); +} + +/// #10: a dotted source's dependency edge was lost, because the argument was +/// reduced to its root and compared against the full declared name. The plan +/// then emitted the dependent BEFORE the thing it depends on. +#[test] +fn a_dotted_source_dependency_is_ordered_correctly() { + let plan = splash_core::ui_l0::source_plan( + "source env.locale sys.locale()\n\ + source scene sys.photo(query: env.locale.lang)\n\ + view root Rule()", + ); + let order: Vec<&str> = plan.requests.iter().map(|r| r.name.as_str()).collect(); + let locale = order.iter().position(|n| *n == "env.locale"); + let scene = order.iter().position(|n| *n == "scene"); + assert!( + locale < scene, + "a source must be fetched before the one that reads it: {order:?}" + ); + let scene_req = plan.requests.iter().find(|r| r.name == "scene").unwrap(); + assert!( + scene_req.depends_on.iter().any(|d| d == "env.locale"), + "the edge must be recorded: {:?}", + scene_req.depends_on + ); +} + +/// #11: only `level` and `profile` were checked for contradictory duplicates. +/// A card could declare two different ledger identities and the last one won. +#[test] +fn a_contradictory_duplicate_ledger_is_rejected() { + let report = check_ui_l0_named( + "twoledgers", + "# ledger real@1.0.0\n# ledger impostor@9.9.9\nview root Rule()", + ); + assert!(!report.valid, "a card has one identity"); +} + +/// #13: the regression test for §5.8's schema id compared `report.closure`, +/// which the definition digest already made sensitive to initials — so it +/// passed with the schema fix reverted. It must exercise the store. +#[test] +fn changing_an_initial_resets_live_component_state() { + const BEFORE: &str = "source items sys.movers()\n\ + component Rowy(item: record) { state n { shape: number, initial: 0 } \ + event bump { n: set(1) } \ + view Col { Row(on_tap: bump) { TextRow(text: item.name) } \ + when n == 1 { TextCaption(text: item.name) } } }\n\ + view root Panel { for it in items key it.id { Rowy(item: it) } }"; + + let data = serde_json::json!({"items": [{"id": "a", "name": "A"}]}); + let mut store = splash_core::ui_l0::InstanceStore::default(); + let first = + splash_core::ui_l0::realize_with_state(BEFORE, &data, &store, RealizeLimits::default()); + let root = first.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + splash_core::ui_l0::dispatch(BEFORE, &mut store, &rows[0].key.clone(), "bump"); + + let after = BEFORE.replace("initial: 0", "initial: 7"); + let out = + splash_core::ui_l0::realize_with_state(&after, &data, &store, RealizeLimits::default()); + let out_root = out.root.unwrap(); + let mut caps = Vec::new(); + find(&out_root, "TextCaption", &mut caps); + assert_eq!( + caps.len(), + 0, + "a changed initial is a schema change (§5.8), so live state must reset" + ); +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 4f9c4c1..bebfc60 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -333,10 +333,15 @@ different instruments — the schema id decides whether live state still fits, a digest (§7) tells a host that a definition was replaced at all. **What path-based identity does and does not survive.** Insertion of a differently named -sibling is stable, because siblings are numbered within each element name. Everything else that -changes the path changes identity: reordering two same-name siblings, inserting another -instance of the same component above, and any change to an enclosing guard, loop, slot, view -reference or loop key. Fully edit-stable identity needs a persistent site id in the source or +sibling is stable, because siblings are numbered within each element name. Identity moves when +the PATH moves: reordering two same-name siblings, inserting another instance of the same +component above, adding or removing an enclosing guard, loop, slot or view reference, and +changing a loop's key expression. + +Only the element name and its ordinal enter a segment, so editing a guard's *condition* — +`when a` to `when b`, both true — leaves the path untouched and the state survives. An earlier +version of this paragraph said any change to an enclosing guard moved identity; that is wrong, +and wrong in the direction that matters, since it would have justified not looking. Fully edit-stable identity needs a persistent site id in the source or edit provenance from the ledger; neither exists yet, and the numbering is an improvement on tree position rather than a solution. @@ -590,11 +595,12 @@ variant must be checked against this condition by hand, and one that reads a pat event-typed paths explicitly. An earlier version of this section claimed the stronger property that "no total form can name -an event", which was not true: component prop *types* are discarded, so `set(out)` is accepted -where `out` was declared as an event prop. That does not cascade — `set` reads a value and -`Form` has no raising branch — but the guarantee rests on the dispatcher rather than on the -grammar refusing to spell it. **Any new `Form` variant must be checked against this -condition**, and a form that reads a path must exclude event-typed ones explicitly. +an event". That was false when written, because component prop types were discarded and +`set(out)` was accepted where `out` was an event prop. It is now true — prop shapes survive +parsing, and event-shaped props are excluded from the readable set — but it is true by a check +rather than by the grammar, which is the distinction worth keeping. **Any new `Form` variant +must be checked against this condition**, and any form that reads a path must exclude +event-typed ones explicitly, because nothing in the type system will do it for you. A test enumerates the syntaxes a cascade could take — `emit e`, `e()`, `n: set(1) then e`, a transition targeting an event — and asserts each is refused. That test is a tripwire, not the From 708c208aef013cc3ecc91379d59d2e5c25571381 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:19:41 -0700 Subject: [PATCH 31/63] =?UTF-8?q?test(ui=5Fl0):=20failing=20test=20?= =?UTF-8?q?=E2=80=94=20deeply=20nested=20paths=20overflow=20the=20parser?= =?UTF-8?q?=20stack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED ON PURPOSE. Committed before the fix so the test-first order is auditable rather than asserted; the previous round squashed each test with its fix, which the reviewer rightly noted means taking my word for the sequence. Observed: thread 'deeply_nested_predicates_do_not_overflow_the_stack' has overflowed its stack fatal runtime error: stack overflow, aborting (signal: 6, SIGABRT) A 60 KB source — well under the 256 KiB limit — aborts the whole process. `dotted_name` recurses once per nested `a[…]` index with only a local guard, and a predicate's right-hand side has the same unbounded shape. This is a crash on untrusted input in the one component whose purpose is to accept untrusted input safely, so it cannot land as a documented limitation. The next commit bounds both. --- crates/splash-core/tests/ui_profile_l0.rs | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index dac5182..d8af8a9 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3415,3 +3415,48 @@ fn changing_an_initial_resets_live_component_state() { "a changed initial is a schema change (§5.8), so live state must reset" ); } + +// ─── codex round four: merge blockers ──────────────────────────────────────── + +/// A 60 KB card — well under the 256 KiB byte limit — overflowed the parser's +/// stack and ABORTED the process. `dotted_name` recursed once per nested +/// dynamic index with only a local guard, and predicate right-hand sides had +/// the same shape. +/// +/// This is a crash on untrusted input in the component whose entire purpose is +/// accepting untrusted input, so a documented limit would not do. +#[test] +fn deeply_nested_paths_do_not_overflow_the_stack() { + for depth in [2_000, 20_000, 60_000] { + let source = format!( + "source a sys.movers()\nview root TextRow(text: {}a{})", + "a[".repeat(depth), + "]".repeat(depth) + ); + assert!( + source.len() < 262_144, + "the probe must stay under the byte limit, or it proves nothing" + ); + // Reaching the assertion at all is the test: an overflow aborts the + // whole binary rather than failing this case. + let report = check_ui_l0_named("deep-path", &source); + assert!( + !report.valid, + "nesting past the bound must be refused, not accepted" + ); + } +} + +/// The same shape on the other recursive path: a predicate's right-hand side. +#[test] +fn deeply_nested_predicates_do_not_overflow_the_stack() { + let depth = 20_000; + let source = format!( + "state n {{ shape: number, initial: 0 }}\nview root Col {{ when n == {}n{} {{ Rule() }} }}", + "n[".repeat(depth), + "]".repeat(depth) + ); + assert!(source.len() < 262_144); + let report = check_ui_l0_named("deep-pred", &source); + assert!(!report.valid); +} From a788438897f14197bd5dc39b13aa2718e329c7a0 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:20:45 -0700 Subject: [PATCH 32/63] fix(ui_l0): bound the parser's two recursive paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Green for 708c208. Both sites had a LOCAL guard, which bounds one frame's loop and says nothing about the recursion — so nesting drove straight through the stack and aborted the process on a 60 KB input. `dotted_name` recursed once per nested `[index]`, and `parse_operand` once per predicate right-hand side. Both now increment the parser's existing `depth` field, which already bounded element nesting, and report "nesting is too deep" rather than descending. Verified at 2,000 / 20,000 / 60,000 levels of nesting on both paths, all well inside the 256 KiB byte limit that made the crash reachable in the first place. 305 tests, zero clippy warnings. --- crates/splash-core/src/ui_l0.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 1d2dc60..a4d2dc7 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -1154,13 +1154,22 @@ impl<'a> Parser<'a> { continue; } if self.peek().is_some_and(|t| t.is_punct("[")) { + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + if let Some(t) = self.peek().cloned() { + self.sink.at(&t, "nesting is too deep".into()); + } + return Some(name); + } self.at += 1; + self.depth += 1; // The index is itself a path and must be RETAINED, not just // recognised: dropping it turned `stories[selected].title` into // `stories.title`, which resolved to nothing and rendered an // em dash. Held as a `[…]` segment so lookup can resolve the // inner path and use its value as the key. - if let Some(index) = self.dotted_name() { + let index = self.dotted_name(); + self.depth -= 1; + if let Some(index) = index { name.push_str(".["); name.push_str(&index); name.push(']'); @@ -2003,8 +2012,14 @@ impl<'a> Parser<'a> { // A comparison in argument position is a predicate operand. if let Some(op) = self.peek().cloned() { if op.kind == Kind::Cmp { + if self.depth > DEFAULT_MAX_SYNTAX_NESTING { + self.sink.at(&op, "nesting is too deep".into()); + return Operand::Path(path); + } self.at += 1; + self.depth += 1; let rhs = self.parse_operand(); + self.depth -= 1; return Operand::Predicate { path, cmp: op.text, From a3d8ca50e8d21de1b75a250a1e0763b0a900ed67 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:21:15 -0700 Subject: [PATCH 33/63] =?UTF-8?q?test(ui=5Fl0):=20failing=20tests=20?= =?UTF-8?q?=E2=80=94=20duplicate=20loop=20keys=20share=20state,=20key=20pa?= =?UTF-8?q?th=20unchecked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED ON PURPOSE, committed before the fix. duplicate_loop_keys_are_reported_and_do_not_share_state FAILED a_loop_key_must_name_the_loop_binder FAILED §5.7 says duplicate keys are an error at realization; nothing tracked them, so two items with the same id produced IDENTICAL instance keys. One state cell, two rows, and tapping either toggles both. The key expression is never validated either: its first segment is discarded unconditionally, so `key typo.id` behaves exactly like `key item.id`. That also falsifies §5.1's claim that changing a key expression moves identity — it need not. --- crates/splash-core/tests/ui_profile_l0.rs | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index d8af8a9..317a572 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3460,3 +3460,48 @@ fn deeply_nested_predicates_do_not_overflow_the_stack() { let report = check_ui_l0_named("deep-pred", &source); assert!(!report.valid); } + +/// §5.7 says duplicate keys are an error at realization. Nothing tracked them, +/// so two items with the same id produced IDENTICAL instance keys — one cell +/// shared by two rows, and tapping either toggled both. +#[test] +fn duplicate_loop_keys_are_reported_and_do_not_share_state() { + const CARD: &str = "source items sys.movers()\n\ + component R(it: record) { state n { shape: bool, initial: false } \ + event f { n: toggle } \ + view Col { Row(on_tap: f) { TextRow(text: it.name) } \ + when n { TextCaption(text: it.name) } } }\n\ + view root Panel { for i in items key i.id { R(it: i) } }"; + + let data = serde_json::json!({ + "items": [{"id": "same", "name": "A"}, {"id": "same", "name": "B"}] + }); + let report = realize(CARD, &data, RealizeLimits::default()); + assert!( + report.diagnostics.iter().any(|d| d.message.contains("duplicate")), + "a duplicate key must be reported: {:#?}", + report.diagnostics + ); + + let root = report.root.unwrap(); + let mut rows = Vec::new(); + find(&root, "Row", &mut rows); + assert_eq!(rows.len(), 2, "both rows still render"); + assert_ne!( + rows[0].key, rows[1].key, + "two instances must not share one state cell" + ); +} + +/// The key expression itself was never validated: its first segment was +/// discarded unconditionally, so `key typo.id` behaved exactly like +/// `key item.id` — which also means changing a key does not reliably move +/// identity, contrary to §5.1. +#[test] +fn a_loop_key_must_name_the_loop_binder() { + let report = check_ui_l0_named( + "badkey", + "source items sys.movers()\nview root Panel { for it in items key typo.id { Rule() } }", + ); + assert!(!report.valid, "`typo` is not the binder `it`"); +} From f9763e96fd2f92d386c81e77a85073ddbbf6df36 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:23:42 -0700 Subject: [PATCH 34/63] fix(ui_l0): duplicate loop keys, and validate the key expression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Green for a3d8ca5. §5.7 says duplicate keys are an error at realization. Nothing tracked them, so two items sharing a key produced the same instance key: one state cell, two rows, and tapping either toggled both. Now reported, and the collision is disambiguated rather than dropped — rendering both rows with distinct identity is safer than losing one, because a missing row is harder to notice than a diagnostic. The key expression was never read: its first segment was discarded unconditionally, so `key typo.id` behaved exactly like `key item.id`. That also made §5.1's claim that changing a key moves identity false, since the name it changed was never consulted. A key must now name the loop's own binder. 306 tests, zero clippy warnings, device suite green. --- crates/splash-core/src/ui_l0.rs | 41 +++++++++++++++++++++++ crates/splash-core/tests/ui_profile_l0.rs | 5 ++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index a4d2dc7..647c9fb 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -2934,6 +2934,23 @@ fn walk( match element.name.as_str() { "" | "slot" | "into" => {} "for" => { + // The key expression names the loop's own binder. Its first segment + // was discarded unconditionally, so `key typo.id` behaved exactly + // like `key item.id` — and §5.1's claim that changing a key moves + // identity was false, because the name was never read. + if let (Some(binder), Some(key)) = (element.binders.first(), &element.key_path) { + let root = root_of(key); + if root != *binder { + sink.push( + element.line, + element.column, + format!( + "the key names {root:?}, but this loop binds {binder:?} — a key \ + must be a path into the item being iterated (profile §5.1)" + ), + ); + } + } for binder in &element.binders { scope.roots.push(binder.clone()); } @@ -3976,6 +3993,11 @@ impl Realizer<'_> { return; }; + // §5.7: duplicate keys are an error at realization. Untracked, two items + // with the same key produced the same instance key — one state cell + // shared by two rows, so tapping either toggled both. + let mut seen_keys: Vec = Vec::new(); + for (index, item) in items.iter().enumerate() { if index >= self.limits.max_collection { self.truncated = true; @@ -3997,6 +4019,25 @@ impl Realizer<'_> { }) .unwrap_or_else(|| index.to_string()); + // Report the collision, then disambiguate so the two instances do + // not share a cell. Rendering both with distinct identity is safer + // than dropping one: a missing row is harder to notice than a + // diagnostic. + let item_key = if seen_keys.contains(&item_key) { + self.sink.push( + element.line, + element.column, + format!( + "duplicate key {item_key:?} in this loop; keys must be unique or two \ + instances share one state cell (profile §5.7)" + ), + ); + format!("{item_key}#{index}") + } else { + seen_keys.push(item_key.clone()); + item_key + }; + let mut bound = 0usize; if let Some(binder) = element.binders.first() { scope.frames.push((binder.clone(), item.clone())); diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 317a572..32b5fd5 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3478,7 +3478,10 @@ fn duplicate_loop_keys_are_reported_and_do_not_share_state() { }); let report = realize(CARD, &data, RealizeLimits::default()); assert!( - report.diagnostics.iter().any(|d| d.message.contains("duplicate")), + report + .diagnostics + .iter() + .any(|d| d.message.contains("duplicate")), "a duplicate key must be reported: {:#?}", report.diagnostics ); From 352b7a2698604affea29daa0795768f41e849d38 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:23:58 -0700 Subject: [PATCH 35/63] =?UTF-8?q?test(ui=5Fl0):=20failing=20test=20?= =?UTF-8?q?=E2=80=94=20source=20arguments=20carry=20unchecked=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED ON PURPOSE, committed before the fix. validate_sources checked the helper name and the argument NAMES and never the paths those arguments carry, so source photo sys.photo(query: secrets.token) is L0-valid and reaches the host inside a well-formed request. That is exactly the undeclared dependency §4 calls 'a defect by construction', and it partly undoes the capability allowlist: the helper is checked, its arguments are not. --- crates/splash-core/tests/ui_profile_l0.rs | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 32b5fd5..61c2d43 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3508,3 +3508,35 @@ fn a_loop_key_must_name_the_loop_binder() { ); assert!(!report.valid, "`typo` is not the binder `it`"); } + +/// `validate_sources` checked the helper name and its argument NAMES, never the +/// paths those arguments carry. So a card could reference anything and the +/// value went to the host inside a well-formed request — which is precisely +/// the undeclared dependency §4 says is "a defect by construction". +#[test] +fn a_source_argument_path_must_be_declared() { + let report = check_ui_l0_named( + "leak-arg", + "source photo sys.photo(query: secrets.token)\nview root Rule()", + ); + assert!(!report.valid, "`secrets` is not declared"); + + // The plan must not carry it either, for a caller that skips checking. + let plan = splash_core::ui_l0::source_plan( + "source photo sys.photo(query: secrets.token)\nview root Rule()", + ); + assert!( + !plan.diagnostics.is_empty(), + "the plan must report it rather than hand the host an undeclared path" + ); + + // A declared one still works. + assert!( + check_ui_l0_named( + "ok", + "state city { shape: text, initial: \"\" }\n\ + source place sys.geocode(name: city)\nview root Rule()" + ) + .valid + ); +} From 31640afcef8ae2206d570a48eb23d364b56e8354 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:26:40 -0700 Subject: [PATCH 36/63] fix(ui_l0): check the paths a source argument carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Green for 352b7a2. The helper name was constrained by the capability catalog and its arguments were not, so a card could write source photo sys.photo(query: secrets.token) and the undeclared path reached the host inside a well-formed request. §4 calls that a defect by construction; it was ours, and it partly undid the allowlist. Checked in check_ui_l0 and reported by source_plan, matching the allowlist's own belt-and-braces shape. Writing it exposed a convention the reference cards use and nothing had recorded: a source addresses card state with an explicit `state.` prefix — `sys.geocode(name: state.city)` — where a view names its state directly. The check honours that rather than forcing the cards to change, since the cards are the profile's evidence. 307 tests, zero clippy warnings, device suite green. --- crates/splash-core/src/ui_l0.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 647c9fb..632ed60 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -2721,6 +2721,9 @@ fn check_event_batch( /// depended on the host keeping an allowlist that the profile claims to make /// unnecessary. fn validate_sources(card: &Card, sink: &mut Diagnostics) { + // A source may read card state, other sources and copy — the same names a + // view may, minus anything a component introduces. + let scope = &Scope::card(card); for source in &card.sources { let Some(accepted) = catalog::source(&source.helper) else { let mut known: Vec<&str> = catalog::SOURCES.iter().map(|(n, _)| *n).collect(); @@ -2737,7 +2740,24 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { ); continue; }; - for (name, _) in &source.args { + for (name, arg) in &source.args { + // The VALUE is a binding too. Checking only the argument's name let + // a source carry any path to the host — the helper was constrained + // and its arguments were not. + if let SourceArg::Path(path) = arg { + // A source addresses card state with an explicit `state.` + // prefix — `sys.geocode(name: state.city)` — which the view + // scope does not carry, since a view names its state directly. + match path.strip_prefix("state.") { + Some(rest) => { + let root = root_of(rest); + if !card.states.iter().any(|st| root_of(&st.path) == root) { + sink.push(source.line, 1, format!("{root:?} is not a declared state")); + } + } + None => check_path(path, scope, source.line, 1, sink), + } + } if !accepted.contains(&name.as_str()) { sink.push( source.line, From 07f75b0baaa935045296bfd0fe447b8d9d32b79e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:26:56 -0700 Subject: [PATCH 37/63] =?UTF-8?q?test(ui=5Fl0):=20failing=20test=20?= =?UTF-8?q?=E2=80=94=20padding=20hides=20a=20cycle=20from=20the=20acyclici?= =?UTF-8?q?ty=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED ON PURPOSE, committed before the fix. The traversal counts every popped name against a single 1,024 budget, and collect_constructors yields ordinary catalog constructors rather than only graph nodes. Padding a recursive component with enough Rule() calls exhausts the budget before the walk reaches the recursive edge, and the card is reported acyclic. §6's first termination condition is therefore not enforced — and combined with the data-position fixpoint's iteration bound, a cycle also permits literal laundering through recursive parameter hops. --- crates/splash-core/tests/ui_profile_l0.rs | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 61c2d43..1f0e9a1 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3540,3 +3540,30 @@ fn a_source_argument_path_must_be_declared() { .valid ); } + +/// The acyclicity walk counted every popped name against one budget, and +/// `collect_constructors` yields ordinary catalog constructors, not just graph +/// nodes. So padding a component with enough `Rule()`s exhausts the budget +/// before the traversal reaches the recursive edge, and the card is reported +/// acyclic — which means §6's first termination condition is not enforced. +#[test] +fn padding_cannot_hide_a_cycle_from_the_acyclicity_check() { + let padding = "Rule() ".repeat(600); + let source = format!( + "component A() {{ view Col {{ A() Panel {{ {padding} }} Panel {{ {padding} }} }} }}\n\ + view root A()" + ); + let report = check_ui_l0_named("padded", &source); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("recursive")), + "a recursive component must be caught however much noise surrounds it: {:#?}", + report + .diagnostics + .iter() + .map(|d| &d.message) + .collect::>() + ); +} From 247f10e07e56666903d7a22f0769e7285220928a Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:29:23 -0700 Subject: [PATCH 38/63] fix(ui_l0): acyclicity walks the graph, not the whole card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Green for 07f75b0. The traversal counted every popped name against one 1,024 budget, and collect_constructors yields every catalog constructor rather than only graph nodes — so the edge list was proportional to the CARD's size. Padding a recursive component with 1,200 Rule() calls exhausted the budget before the walk reached the recursive edge, and the card was reported acyclic. §6's first termination condition was not enforced. Edges are now filtered to names that are actually nodes of this graph, which makes the budget a backstop rather than the thing deciding the answer. Also widens the data-position fixpoint, which had the same shape of bug from the other side: it iterated `components + 1` times, but a value hops once per PARAM, so a single component chaining a -> b -> c -> data needed three rounds and got two. Bounded by hops now, and still terminating if the graph turns out cyclic — which matters because this check runs before acyclicity is proven. 309 tests, zero clippy warnings, device suite green. --- crates/splash-core/src/ui_l0.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 632ed60..f43ca3f 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -2807,6 +2807,18 @@ fn check_component_acyclicity(card: &Card, sink: &mut Diagnostics) { edges.push((view.name.clone(), refs, 1)); } + // Keep only edges that name a NODE of this graph. `collect_constructors` + // yields every catalog constructor, so an unfiltered edge list is + // proportional to the card's size rather than to its component graph — and + // the traversal budget could then be exhausted by padding before the walk + // reached a real cycle. Filtering makes the budget a backstop rather than + // the thing that decides the answer. + let node_names: Vec = edges.iter().map(|(n, _, _)| n.clone()).collect(); + for (_, refs, _) in edges.iter_mut() { + refs.retain(|r| node_names.contains(r)); + refs.dedup(); + } + let referenced = |name: &str| -> Option<&Vec> { edges.iter().find(|(n, _, _)| n == name).map(|(_, r, _)| r) }; @@ -3317,7 +3329,19 @@ fn check_arg( fn data_position_params(card: &Card) -> BTreeMap> { let mut tainted: BTreeMap> = BTreeMap::new(); - for _ in 0..=card.components.len() { + // Bound by the number of PARAM HOPS a value can take, not by the component + // count. A single component whose params chain a -> b -> c -> data needs + // three rounds, which `N + 1` with N = 1 does not allow — and the loop must + // still terminate if the graph turns out to be cyclic, since this runs + // before acyclicity is proven. + let rounds = card.components.len() + + card + .components + .iter() + .map(|c| c.params.len()) + .sum::() + + 2; + for _ in 0..rounds { let mut changed = false; for component in &card.components { let names: Vec<&str> = component.params.iter().map(|p| p.name.as_str()).collect(); From ca84675ef0794502e5d88013be8b6d579a83de9f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:29:44 -0700 Subject: [PATCH 39/63] =?UTF-8?q?test(ui=5Fl0):=20failing=20tests=20?= =?UTF-8?q?=E2=80=94=20phase=202,=20header=20and=20declaration=20validatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED ON PURPOSE, committed before the fix. a_model_only_duplicate_header_is_still_caught FAILED a_parameter_default_must_fit_its_declared_shape FAILED a_bare_enum_member_is_a_valid_default FAILED A header of only duplicate 'model:' lines never set saw_any, so parse_header returned None and the contradiction went with it. Parameter defaults were accepted regardless of the shape declared beside them, and the '=' scan seeks the first ']' without balancing '[', so an enum default lands wherever the bracket happens to be. Meanwhile the spec's own 'enum[c, f] = c' spelling parsed as no default at all — a bare identifier, which the parser only recognises as a string, number, bool or dotted token. --- crates/splash-core/tests/ui_profile_l0.rs | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 1f0e9a1..3b99c59 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3567,3 +3567,48 @@ fn padding_cannot_hide_a_cycle_from_the_acyclicity_check() { .collect::>() ); } + +/// A header containing ONLY duplicate `model:` lines never set `saw_any`, so +/// `parse_header` returned None and the contradiction vanished with it. +#[test] +fn a_model_only_duplicate_header_is_still_caught() { + let report = check_ui_l0_named("dupmodel", "# model: first\n# model: second\nview root Rule()"); + assert!(!report.valid, "a card names one model"); + assert!( + report.diagnostics.iter().any(|d| d.message.contains("model twice")), + "{:#?}", + report.diagnostics + ); +} + +/// A parameter default was accepted regardless of the shape declared beside it, +/// and the spec's own `enum[c, f] = c` spelling was not parsed at all. +#[test] +fn a_parameter_default_must_fit_its_declared_shape() { + for source in [ + "component F(n: number = \"oops\") { view TextRow(text: n) }\nview root F()", + "component G(m: enum[a, b] = .missing) { view TextRow(text: m) }\nview root G()", + "component H(b: bool = 3) { view Col { when b { Rule() } } }\nview root H()", + ] { + let report = check_ui_l0_named("baddefault", source); + assert!(!report.valid, "should be rejected: {source}"); + } +} + +#[test] +fn a_bare_enum_member_is_a_valid_default() { + // §5.2's own example spells it without the dot. It parsed as nothing, so + // the documented form silently produced no default at all. + let report = check_ui_l0_named( + "bare", + "component F(unit: enum[c, f] = c) { view TextValue(value: unit) }\nview root F()", + ); + assert!(report.valid, "{:#?}", report.diagnostics); + + let out = realize( + "component F(unit: enum[c, f] = c) { view TextRow(text: unit) }\nview root F()", + &serde_json::json!({}), + RealizeLimits::default(), + ); + assert_eq!(texts(&out.root.unwrap()), vec!["c"], "the default must bind"); +} From f9a2308e1e0d4973bf88fa7b34a539e81937f18e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:30:45 -0700 Subject: [PATCH 40/63] =?UTF-8?q?fix(ui=5Fl0):=20phase=202=20=E2=80=94=20h?= =?UTF-8?q?eader=20presence,=20and=20declarations=20checked=20against=20sh?= =?UTF-8?q?ape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Green for ca84675. A header of only duplicate `model:` lines never set `saw_any`, so parse_header returned None and the contradiction disappeared with the header itself. The `=` scan for a parameter default sought the first `]` without balancing `[`, so an enum default landed wherever a bracket happened to be. Now balanced. A bare enum member — `unit: enum[c, f] = c`, which is how §5.2 spells it — parsed as no default at all, because the parser recognised only strings, numbers, bools and dotted tokens. The documented form now works. And a default was accepted regardless of its declared shape. Judged now by `value_fits_shape`, lifted out of the state-initial check so a parameter default and a state initial cannot drift apart on what "fits" means. 312 tests, zero clippy warnings. --- crates/splash-core/src/ui_l0.rs | 82 +++++++++++++++++------ crates/splash-core/tests/ui_profile_l0.rs | 16 ++++- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index f43ca3f..8dbf92f 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -132,14 +132,19 @@ pub fn parse_header(source: &str) -> Option { _ => header.profile = Some(value), } } - "model" => match &header.model { - Some(first) if *first != value => { - header.contradictions.push(format!( - "the header declares the model twice, as {first:?} and {value:?}" - )); + "model" => { + // Presence counts: a header of only `model:` lines used to + // return None, taking any contradiction in it along too. + saw_any = true; + match &header.model { + Some(first) if *first != value => { + header.contradictions.push(format!( + "the header declares the model twice, as {first:?} and {value:?}" + )); + } + _ => header.model = Some(value), } - _ => header.model = Some(value), - }, + } _ => {} } } @@ -1664,11 +1669,21 @@ impl<'a> Parser<'a> { let mut default = None; let mut at = self.at + 3; if matches!(shape, Shape::Enum(_)) { - // Skip `[a, b]`. - while self.tokens.get(at).is_some_and(|t| !t.is_punct("]")) { + // Skip `[…]`, BALANCED. Seeking the first `]` put + // the cursor wherever a bracket happened to be. + let mut depth = 0usize; + while let Some(t) = self.tokens.get(at) { + if t.is_punct("[") { + depth += 1; + } else if t.is_punct("]") { + depth -= 1; + if depth == 0 { + at += 1; + break; + } + } at += 1; } - at += 1; } if self.tokens.get(at).is_some_and(|t| t.is_punct("=")) { if let Some(v) = self.tokens.get(at + 1) { @@ -1683,6 +1698,12 @@ impl<'a> Parser<'a> { Kind::Ident if v.text == "true" || v.text == "false" => { Some(crate::JsonValue::Bool(v.text == "true")) } + // A bare enum member: `unit: enum[c, f] = c`, + // which is how §5.2 spells it and which + // produced no default at all. + Kind::Ident if matches!(shape, Shape::Enum(_)) => { + Some(crate::JsonValue::String(v.text.clone())) + } _ => None, }; } @@ -2423,6 +2444,23 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { check_state_initials(&card.states, sink, 1); for component in &card.components { check_state_initials(&component.states, sink, component.line); + for param in &component.params { + let Some(default) = ¶m.default else { + continue; + }; + if !value_fits_shape(¶m.shape, default) { + sink.push( + component.line, + 1, + format!( + "{}.{} declares {} but a default of {default}", + component.name, + param.name, + shape_name(¶m.shape) + ), + ); + } + } } // A path-valued `initial` is a rendering position by proxy: whatever it @@ -2508,20 +2546,26 @@ fn shape_name(shape: &Shape) -> &'static str { /// Profile §2: a declared `initial` must fit the shape it is declared with. /// Neither was checked against the other, so `shape: number, initial: "oops"` /// mounted a string into a numeric cell before any event ran. +/// Whether a literal agrees with the shape it was declared alongside. Shared by +/// state initials and parameter defaults so the two cannot drift. +fn value_fits_shape(shape: &Shape, value: &crate::JsonValue) -> bool { + match (shape, value) { + (Shape::Text, crate::JsonValue::String(_)) => true, + (Shape::Number, v) => v.is_number(), + (Shape::Bool, crate::JsonValue::Bool(_)) => true, + (Shape::Enum(members), crate::JsonValue::String(s)) => members.iter().any(|m| m == s), + // A shape that names no literal form, or one we could not classify. + (Shape::Record | Shape::Collection | Shape::Event | Shape::Other, _) => true, + _ => false, + } +} + fn check_state_initials(states: &[StateDecl], sink: &mut Diagnostics, line: usize) { for state in states { let Some(initial) = &state.initial else { continue; }; - let fits = match (&state.shape, initial) { - (Shape::Text, crate::JsonValue::String(_)) => true, - (Shape::Number, v) => v.is_number(), - (Shape::Bool, crate::JsonValue::Bool(_)) => true, - (Shape::Enum(members), crate::JsonValue::String(s)) => members.iter().any(|m| m == s), - // A shape that names no literal form, or one we could not classify. - (Shape::Record | Shape::Collection | Shape::Event | Shape::Other, _) => true, - _ => false, - }; + let fits = value_fits_shape(&state.shape, initial); if !fits { sink.push( line, diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 3b99c59..dc5d09a 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3572,10 +3572,16 @@ fn padding_cannot_hide_a_cycle_from_the_acyclicity_check() { /// `parse_header` returned None and the contradiction vanished with it. #[test] fn a_model_only_duplicate_header_is_still_caught() { - let report = check_ui_l0_named("dupmodel", "# model: first\n# model: second\nview root Rule()"); + let report = check_ui_l0_named( + "dupmodel", + "# model: first\n# model: second\nview root Rule()", + ); assert!(!report.valid, "a card names one model"); assert!( - report.diagnostics.iter().any(|d| d.message.contains("model twice")), + report + .diagnostics + .iter() + .any(|d| d.message.contains("model twice")), "{:#?}", report.diagnostics ); @@ -3610,5 +3616,9 @@ fn a_bare_enum_member_is_a_valid_default() { &serde_json::json!({}), RealizeLimits::default(), ); - assert_eq!(texts(&out.root.unwrap()), vec!["c"], "the default must bind"); + assert_eq!( + texts(&out.root.unwrap()), + vec!["c"], + "the default must bind" + ); } From 87bfc263ac1a00a1430326c392890379c1b6943b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:31:54 -0700 Subject: [PATCH 41/63] =?UTF-8?q?test(ui=5Fl0):=20failing=20tests=20?= =?UTF-8?q?=E2=80=94=20phase=203,=20the=20three=20claims=20=C2=A73=20makes?= =?UTF-8?q?=20and=20does=20not=20keep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/tests/ui_profile_l0.rs | 73 +++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index dc5d09a..a8b7f96 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3622,3 +3622,76 @@ fn a_bare_enum_member_is_a_valid_default() { "the default must bind" ); } + +// ─── phase 3: the three claims the spec makes and did not keep ─────────────── + +/// §3: "an event names several transitions, applied atomically". They were +/// applied sequentially straight to the store, so a batch that fails partway +/// leaves the earlier writes standing — a half-applied event. +#[test] +fn a_transition_batch_is_atomic() { + const CARD: &str = "state a { shape: number, initial: 0 }\n\ + state b { shape: number, initial: 0 }\n\ + source cfg sys.movers()\n\ + event both { a: set(1), b: set(cfg.missing) }\n\ + view root Row(on_tap: both) { Rule() }"; + + let mut store = splash_core::ui_l0::InstanceStore::default(); + // `cfg.missing` does not resolve, so the batch cannot complete. + let applied = splash_core::ui_l0::dispatch_with_data( + CARD, + &mut store, + "root", + "both", + None, + &serde_json::json!({"cfg": {}}), + ); + assert!(!applied, "a batch that cannot complete must not report success"); + assert_eq!( + store.get("@card", "a"), + None, + "the first write must not survive a batch that failed on the second" + ); +} + +/// §3: `set($value)` performed no shape check at dispatch, so a string payload +/// entered a number cell — the transition type system stops at the boundary. +#[test] +fn a_payload_must_fit_the_state_it_is_written_to() { + const CARD: &str = "state n { shape: number, initial: 0 }\n\ + event pick { n: set($value) }\n\ + view root Row(on_tap: pick, value: \"x\") { Rule() }"; + let mut store = splash_core::ui_l0::InstanceStore::default(); + let payload = serde_json::json!("not a number"); + let applied = splash_core::ui_l0::dispatch_with(CARD, &mut store, "root", "pick", Some(&payload)); + assert!(!applied, "a payload of the wrong shape must be refused"); + assert_eq!(store.get("@card", "n"), None); +} + +/// §3: `clear` restores "the declared initial". It ignored a path-valued one 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. +#[test] +fn clear_restores_a_path_valued_initial() { + const CARD: &str = "source env.locale sys.locale()\n\ + state units { shape: enum[c, f], initial: env.locale.temp_unit }\n\ + event reset { units: clear }\n\ + event flip { units: cycle(.c, .f) }\n\ + view root Row(on_tap: flip) { Rule() }"; + let data = serde_json::json!({"env": {"locale": {"temp_unit": "f"}}}); + let mut store = splash_core::ui_l0::InstanceStore::default(); + + splash_core::ui_l0::dispatch_with_data(CARD, &mut store, "root", "flip", None, &data); + assert_eq!( + store.get("@card", "units").and_then(|v| v.as_str()), + Some("c"), + "cycle must advance from the host-supplied initial `f`, not from the shape default" + ); + + splash_core::ui_l0::dispatch_with_data(CARD, &mut store, "root", "reset", None, &data); + assert_eq!( + store.get("@card", "units").and_then(|v| v.as_str()), + Some("f"), + "clear must restore the declared initial, path-valued or not" + ); +} From 1e1871faf73a7c1247450aa7fc6bb53ab5c622f3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:33:24 -0700 Subject: [PATCH 42/63] =?UTF-8?q?test(ui=5Fl0):=20failing=20test=20?= =?UTF-8?q?=E2=80=94=20model-copy=20reaches=20the=20screen=20through=20a?= =?UTF-8?q?=20transition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/tests/ui_profile_l0.rs | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index a8b7f96..0480fca 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3695,3 +3695,51 @@ fn clear_restores_a_path_valued_initial() { "clear must restore the declared initial, path-valued or not" ); } + +/// §4 refuses model-authored text in a rendering position. It was enforced only +/// where an element named `copy.x` directly, so any indirection defeated it. +/// These are the routes: through a component prop, through a prop default, and +/// through a transition that writes it into state. +#[test] +fn model_copy_cannot_reach_the_screen_by_any_route() { + const DECL: &str = "copy claim { class: model-copy, en: \"Revenue: $41.2M\" }\n"; + + for (route, source) in [ + ( + "directly", + format!("{DECL}view root TextTitle(text: copy.claim)"), + ), + ( + "through a prop", + format!( + "{DECL}component F(t: text) {{ view TextTitle(text: t) }}\n\ + view root F(t: copy.claim)" + ), + ), + ( + "through a state initial", + format!( + "{DECL}component F() {{ state s {{ shape: text, initial: copy.claim }} \ + view TextTitle(text: s) }}\nview root F()" + ), + ), + ( + "through a transition", + format!( + "{DECL}state s {{ shape: text, initial: \"\" }}\n\ + event e {{ s: set(copy.claim) }}\nview root Row(on_tap: e) {{ Rule() }}" + ), + ), + ] { + assert!( + !check_ui_l0_named("route", &source).valid, + "model-copy must not reach the screen {route}" + ); + } + + // Vocabulary still travels every one of those routes. + let ok = "copy label { class: vocabulary, en: \"Top Stories\" }\n\ + component F(t: text) { view TextTitle(text: t) }\n\ + view root F(t: copy.label)"; + assert!(check_ui_l0_named("vocab", ok).valid); +} From 7239efc9b2bfd487fb161ec31474d73261ef2a7f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:35:50 -0700 Subject: [PATCH 43/63] =?UTF-8?q?feat(ui=5Fl0):=20phase=203=20=E2=80=94=20?= =?UTF-8?q?implement=20the=20three=20claims=20rather=20than=20downgrade=20?= =?UTF-8?q?them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 85 +++++++++++++++++++---- crates/splash-core/tests/ui_profile_l0.rs | 8 ++- docs/ui-profile-l0.md | 16 +++-- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 8dbf92f..51e7ba6 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -2494,6 +2494,7 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { &card.states, &card_readable, &card_events, + &card.copies, sink, ); for component in &card.components { @@ -2524,6 +2525,7 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { &component.states, &readable, &events, + &card.copies, sink, ); } @@ -2580,11 +2582,13 @@ fn check_state_initials(states: &[StateDecl], sink: &mut Diagnostics, line: usiz } } +#[allow(clippy::too_many_arguments)] fn check_event_batch( events: &[EventDecl], states: &[StateDecl], readable: &[String], event_names: &[String], + copies: &[CopyDecl], sink: &mut Diagnostics, ) { for event in events { @@ -2684,6 +2688,30 @@ fn check_event_batch( ); } Form::Set(SetSource::Path(path)) => { + // §4 travels with the value: writing model-authored text + // into state renders it just as surely as naming it in a + // view, one step later. + if let Some(name) = path.strip_prefix("copy.") { + match copies.iter().find(|c| c.name == name) { + Some(decl) if decl.provenance == Provenance::ModelCopy => { + sink.push( + transition.line, + transition.column, + format!( + "copy.{name} is `model-copy` and may not be written \ + into state; only `vocabulary` and `user-copy` may \ + (profile §4)" + ), + ); + } + None => sink.push( + transition.line, + transition.column, + format!("copy.{name} is not declared"), + ), + _ => {} + } + } let root = root_of(path); if event_names.contains(&root) { sink.push( @@ -4909,22 +4937,40 @@ pub fn dispatch_with_data( store.reconcile_schema(component, schema); } - let mut applied = false; + // §3: a batch is applied ATOMICALLY. Writing straight to the store as we go + // left the earlier writes standing when a later one could not be computed — + // a half-applied event, which is the thing atomicity exists to prevent. + // Stage every write first, commit only if the whole batch resolves. + let mut staged: Vec<(String, crate::JsonValue)> = Vec::new(); + for transition in &declared.transitions { let Some(state) = states.iter().find(|s| s.path == transition.target) else { - continue; + return false; }; - let current = store - .get(instance_key, &transition.target) - .cloned() - .or_else(|| state.initial.clone()) + // The declared initial, path-valued or not. `clear` and a first `cycle` + // both fell back to the SHAPE default here, so a card whose initial + // comes from the host started in the wrong state. + let declared_initial = state + .initial + .clone() + .or_else(|| state.initial_path.as_ref().and_then(|p| data_path(data, p))); + + let current = staged + .iter() + .rev() + .find(|(t, _)| *t == transition.target) + .map(|(_, v)| v.clone()) + .or_else(|| store.get(instance_key, &transition.target).cloned()) + .or_else(|| declared_initial.clone()) .unwrap_or_else(|| initial_for(&state.shape)); let next = match (&transition.form, &state.shape) { (Form::Toggle, Shape::Bool) => { crate::JsonValue::Bool(!current.as_bool().unwrap_or(false)) } - (Form::Clear, shape) => state.initial.clone().unwrap_or_else(|| initial_for(shape)), + (Form::Clear, shape) => declared_initial + .clone() + .unwrap_or_else(|| initial_for(shape)), (Form::Cycle, Shape::Enum(members)) if !members.is_empty() => { let at = current .as_str() @@ -4934,26 +4980,37 @@ pub fn dispatch_with_data( } (Form::Set(source), _) => match source { SetSource::Payload => match payload { - Some(v) => v.clone(), + // The one value that arrives from OUTSIDE, so it is the one + // that has to be checked at runtime: every other set form is + // decided against the declared shape at check time. + Some(v) if value_fits_shape(&state.shape, v) => v.clone(), + Some(_) => return false, // No payload is a no-op rather than a silent clear: falling // back to the initial would look like a deliberate reset. - None => continue, + None => return false, }, // A path READS. Treating it as the payload meant // `n: set(config.answer)` wrote whatever the tap carried — or // nothing at all when it carried nothing. SetSource::Path(p) => match data_path(data, p) { - Some(v) => v, - None => continue, + Some(v) if value_fits_shape(&state.shape, &v) => v, + // Unresolvable or ill-shaped: the batch cannot complete, and + // §3 says a batch is all or nothing. + _ => return false, }, SetSource::Token(t) | SetSource::Text(t) => crate::JsonValue::String(t.clone()), SetSource::Num(n) => crate::JsonValue::from(*n), SetSource::Bool(b) => crate::JsonValue::Bool(*b), }, - _ => continue, + _ => return false, }; - store.set(instance_key, &transition.target, next); - applied = true; + staged.push((transition.target.clone(), next)); + } + + // Commit. + let applied = !staged.is_empty(); + for (target, value) in staged { + store.set(instance_key, &target, value); } applied } diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 0480fca..e5cb814 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3646,7 +3646,10 @@ fn a_transition_batch_is_atomic() { None, &serde_json::json!({"cfg": {}}), ); - assert!(!applied, "a batch that cannot complete must not report success"); + assert!( + !applied, + "a batch that cannot complete must not report success" + ); assert_eq!( store.get("@card", "a"), None, @@ -3663,7 +3666,8 @@ fn a_payload_must_fit_the_state_it_is_written_to() { view root Row(on_tap: pick, value: \"x\") { Rule() }"; let mut store = splash_core::ui_l0::InstanceStore::default(); let payload = serde_json::json!("not a number"); - let applied = splash_core::ui_l0::dispatch_with(CARD, &mut store, "root", "pick", Some(&payload)); + let applied = + splash_core::ui_l0::dispatch_with(CARD, &mut store, "root", "pick", Some(&payload)); assert!(!applied, "a payload of the wrong shape must be refused"); assert_eq!(store.get("@card", "n"), None); } diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index bebfc60..ed7c083 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -211,7 +211,14 @@ initial would read as a deliberate reset. Each is total on its declared shape and terminates in constant time. `units: cycle(c, f)` replaces the ternary; `expanded: toggle` replaces the negation. -An event may name several transitions, applied **atomically as one batch**. A single-assignment +An event may name several transitions, applied **atomically as one batch** — every write is +staged and the batch commits only if all of them resolve, so a transition that cannot be +computed leaves the store exactly as it was rather than half-applied. + +**A payload is checked at dispatch.** `set($value)` is the one form whose value arrives from +outside the card, so it is the one the declared shape cannot decide statically; a payload that +does not fit the target state is refused and the batch does not commit. Every other `set` +spelling is decided at check time against the declared shape. A single-assignment rule would be wrong: production code already fires two state writes from one gesture, and the stock reference card's `back` does the same — clearing both `selected` and `range`. @@ -258,7 +265,7 @@ argument is a structural violation, not a judgement call. It is the property tha |---|---| | A `value` argument (`TextHero`, `TextStat`, `TextValue`, `TextCaption`, `Tile`) | Must be bound. A literal — string, number or token — is refused. `TextHero(value: "34 mph")` is rejected | | Any `path`-kind argument (the drawing widgets) | Must be bound. `TempBar(lo: 5)` is rejected | -| `copy.x` in any rendering position | Refused when `x` is declared `model-copy` | +| `copy.x`, by any route | Refused when `x` is declared `model-copy` — named in a view, passed as a prop, used as a state initial, or written by a transition. A `copy.x` that is not declared at all is refused too | | A **raw string literal** in a `text`, `label` or `glyph` position | **Accepted** | The last row is a deliberate decision, not an oversight. A raw literal carries no declared @@ -270,8 +277,9 @@ cards, six are `glyph` symbols (`‹ ↑ ↓ ≈`) that are closed presentation language, and translating them is meaningless. So the guarantee this section provides is narrower than its first paragraph implies. **A -fabricated number cannot reach a numeric position, and fabricated prose can reach a text -position.** The first is the failure mode that produced the six shipped bugs this rule exists +fabricated number cannot reach a numeric position — by any route, including laundered through +a chain of component props — and fabricated prose can reach a text position, but only as a raw +literal.** Text that is *declared* model-authored is refused wherever it tries to surface. The first is the failure mode that produced the six shipped bugs this rule exists for — an invented `condition`, an invented `wmin`/`wmax` — and it is closed. The second is open by choice, and would need retained provenance on every string, not a broader argument category, to close. From 1b0d04af65cb4dea834cbfadaefb57aa5a05f9ee Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:45:40 -0700 Subject: [PATCH 44/63] =?UTF-8?q?test:=20phase=204=20=E2=80=94=20rebuild?= =?UTF-8?q?=20the=20mutation=20harness=20to=20mutate=20behaviour,=20not=20?= =?UTF-8?q?messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/tests/ui_profile_l0.rs | 35 +++ mutate.py | 310 +++++++++++++--------- 2 files changed, 221 insertions(+), 124 deletions(-) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index e5cb814..c7d26c9 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3747,3 +3747,38 @@ fn model_copy_cannot_reach_the_screen_by_any_route() { view root F(t: copy.label)"; assert!(check_ui_l0_named("vocab", ok).valid); } + +/// §6, condition 5 caps nesting depth AND node count, and they are different +/// bounds. The existing deep-source test builds a tree that trips the node cap +/// on the way down, so removing the depth check entirely left it green — the +/// rebuilt mutation harness caught that. +/// +/// This isolates depth: a tree deeper than `max_depth` but far short of +/// `max_nodes`, so only the depth bound can stop it. +#[test] +fn realization_depth_is_bounded_independently_of_node_count() { + // Between the two bounds: under the parser's 128-deep limit so it parses, + // over the realizer's default 64 so only the realization bound can stop it. + let depth = 100; + let source = format!( + "view root {}Rule(){}", + "Col { ".repeat(depth), + " }".repeat(depth) + ); + let limits = RealizeLimits { + max_nodes: 100_000, // far above what this tree needs + max_depth: 64, + max_collection: 512, + }; + let report = realize(&source, &serde_json::json!({}), limits); + assert!( + report.truncated, + "a tree deeper than max_depth must truncate even when node count is fine \ + ({} nodes realized)", + report.nodes + ); + assert!( + report.nodes < 100_000, + "and it must stop well short of the node cap, or this proves nothing" + ); +} diff --git a/mutate.py b/mutate.py index e8c8a10..839e070 100755 --- a/mutate.py +++ b/mutate.py @@ -1,152 +1,214 @@ #!/usr/bin/env python3 """ -Mutation-test the L0 validator: which of its rules does the suite actually hold? - -A passing test proves the code does what the test says. It does NOT prove the -test would notice if the code stopped. Three review rounds on this branch found -guarantees that were false within hours and one test of mine that could not fail -at all, so "the suite is green" has repeatedly meant less than it appeared to. - -This answers the harder question mechanically. For each diagnostic the validator -can emit — each one being some rule the profile claims — suppress it, run the -suite, and see whether anything goes red. - - SURVIVED nothing failed. That rule is unprotected: it could regress - silently, exactly like the `initial` fix whose regression test - turned out to check the wrong artifact. - caught at least one test failed. The rule is genuinely held. - -Suppression happens in Diagnostics::push rather than at each call site, so the -mutation is uniform and cannot accidentally change control flow. - - ./mutate.py # every rule - ./mutate.py # only rules whose message matches +Mutation-test the L0 checker: which of its rules does the suite actually hold? + +A passing suite proves the code does what the tests say. It does not prove a +test would notice if the code stopped. Four review rounds on this branch found +guarantees that were false within hours and two tests of mine that could not +fail, so "the suite is green" has repeatedly meant less than it looked like. + +This answers the harder question mechanically: disable one rule, run the suite, +see whether anything goes red. + + ./mutate.py # every rule + ./mutate.py # only rules whose id matches + ./mutate.py --list # show the rule table without running + +WHAT THIS REPLACED, AND WHY. The first version mutated a diagnostic's TEXT by +filtering it inside Diagnostics::push, and reported "42 rules, 0 unprotected". +That number was not sound, for five reasons a reviewer had to point out: + + - Rules that also set `valid` or the level directly were unaffected by a text + filter, so they reported unprotected while being perfectly well held. + - Its five documented exceptions matched ZERO of the strings it generated, so + relabelling them as "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 of them + flattered all of them. + - With no baseline run, a compile error counted as a rule being caught. + +So each rule below 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 counted as held. A stale patch string is loud, not silent: +that is the property the previous version lacked. """ -import re import subprocess import sys +from dataclasses import dataclass from pathlib import Path -SRC = Path(__file__).parent / "crates/splash-core/src/ui_l0.rs" - -# Rules this harness CANNOT test, verified by hand instead. Suppressing a -# message only disables a rule when the message IS the enforcement; where a rule -# also sets the level or `valid` directly, the mutation is a no-op and the rule -# reports as unprotected while being perfectly well held. -# -# let / while / arithmetic enforced by the LEVEL a card is assigned, which -# `every_construct_outside_l0_raises_the_level` -# asserts directly. -# header duplicates check_header writes report.diagnostics itself -# rather than going through Diagnostics::push, so -# the hook never sees it. Verified by disabling -# `report.valid = false` and watching -# `a_contradictory_duplicate_header_field_is_rejected` -# go red. -# -# Isolating that second one changed the test: the original used `# level: L2` -# then `# level: L0`, and 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. That is the failure this harness exists -# to find, and it found it in a test written the same day. -KNOWN_ARTIFACTS = [ - "`let` is not in L0", - "`while` is not in L0", - "arithmetic is not in L0", - "the header declares level twice", - "the header declares profile twice", +ROOT = Path(__file__).parent +SRC = ROOT / "crates/splash-core/src/ui_l0.rs" + + +@dataclass(frozen=True) +class Rule: + """One enforcement, and an edit that disables it.""" + + ident: str + claim: str + find: str + replace: str + + +RULES = [ + # ── capability confinement (§1, §4) ────────────────────────────────────── + Rule("source-allowlist", "a card may only name a catalogued capability", + "let Some(accepted) = catalog::source(&source.helper) else {", + "let Some(accepted) = catalog::source(&source.helper).or(Some(&[][..])) else {"), + Rule("source-arg-paths", "a source argument's path must be declared", + "None => check_path(path, scope, source.line, 1, sink),", + "None => { let _ = (path, scope); }"), + + # ── the no-facts rule (§4) ─────────────────────────────────────────────── + Rule("data-laundering", "a literal may not reach a data position through a prop", + "if matches!(arg.value, Operand::Str(_) | Operand::Num(_))", + "if false"), + Rule("model-copy-render", "model-authored text may not be rendered", + "if decl.provenance == Provenance::ModelCopy {", + "if false {"), + Rule("model-copy-transition", "model-authored text may not be written into state", + "Some(decl) if decl.provenance == Provenance::ModelCopy => {", + "Some(decl) if false => {"), + + # ── termination (§6) ───────────────────────────────────────────────────── + Rule("acyclic", "the declaration graph is acyclic", + "if next == name.as_str() {", + "if false {"), + Rule("depth-realize", "realization depth is bounded", + "if self.nodes >= self.limits.max_nodes || self.depth >= self.limits.max_depth {", + "if self.nodes >= self.limits.max_nodes {"), + + # ── identity (§5.1, §5.7) ──────────────────────────────────────────────── + Rule("duplicate-keys", "duplicate loop keys are reported and disambiguated", + "let item_key = if seen_keys.contains(&item_key) {", + "let item_key = if false {"), + Rule("key-names-binder", "a loop key names the loop's binder", + "if root != *binder {", + "if false {"), + # Reverts to POSITIONAL numbering — a shared counter across siblings — which + # is what the fix replaced. Dropping only the name prefix does not revert + # anything, because the counter is already per-name. + Rule("sibling-ordinals", "identity survives an insertion above", + """ let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + children + .iter() + .map(|child| { + let n = seen.entry(child.name.as_str()).or_insert(0); + let segment = format!("{}#{}", child.name, *n); + *n += 1; + (segment, child) + }) + .collect()""", + """ let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + let _ = &mut seen; + children + .iter() + .enumerate() + .map(|(i, child)| (format!("{i}"), child)) + .collect()"""), + + # ── transitions (§3) ───────────────────────────────────────────────────── + Rule("payload-shape", "a payload must fit the state it is written to", + "Some(v) if value_fits_shape(&state.shape, v) => v.clone(),", + "Some(v) => v.clone(),"), + Rule("initial-fits", "a declared initial fits its shape", + "let fits = value_fits_shape(&state.shape, initial);", + "let fits = true;"), + Rule("schema-initials", "a changed initial resets live state", + '"{}:{:?}:{:?}:{:?}",\n s.path, s.shape, s.initial, s.initial_path', + '"{}:{:?}",\n s.path, s.shape'), + + # ── component contracts (§5.2) ─────────────────────────────────────────── + Rule("card-state-private", "a component may not read card state", + "if scope.forbidden_state.contains(&root) {", + "if false {"), + + # ── level and header (§7) ──────────────────────────────────────────────── + Rule("header-level", "a declared level must match the derived one", + "if !matches {", + "if false {"), + Rule("header-duplicates", "a header may not contradict itself", + "for message in &header.contradictions {", + "for message in std::iter::empty::<&String>() {"), ] -# The hook: a line inside Diagnostics::push that drops the targeted message. -ANCHOR = """ fn push(&mut self, line: usize, column: usize, message: String) { -""" -INJECT = ' if message.contains(%s) { return; }\n' - - -def messages(): - """Every diagnostic literal, found by WHERE IT IS USED rather than by keyword. - - A keyword list silently missed rules as new ones were added — "declares no - `shape`" and "reaches a data position" were both invisible, so the harness - reported full coverage while not testing them at all. That is the same - failure the harness exists to catch, one level up. - """ - src = SRC.read_text() - found = set() - # Scan forward from each diagnostic call and take the string literals in it. - for m in re.finditer(r"(?:sink\.push|sink\.at|self\.sink\.at|self\.sink\.push)\s*\(", src): - window = src[m.end(): m.end() + 900] - depth, cut = 1, len(window) - for i, ch in enumerate(window): - if ch == "(": - depth += 1 - elif ch == ")": - depth -= 1 - if depth == 0: - cut = i - break - for lit in re.findall(r'"((?:[^"\\\n]|\\.){10,})"', window[:cut]): - found.add(lit) - - # Match on the longest LITERAL run — the text between format placeholders. - out = {} - for t in found: - runs = [r.strip() for r in re.split(r"\{[^}]*\}", t)] - longest = max(runs, key=len) if runs else "" - if len(longest) >= 12: - out.setdefault(longest, t) - return sorted(out.items()) - - -def run_tests(): - """True if the suite passes.""" + +def suite(): + """(built, passed). built=False means the mutation did not compile.""" r = subprocess.run( ["cargo", "test", "-q", "-p", "splash-core", "--test", "ui_profile_l0"], - cwd=SRC.parents[3], capture_output=True, text=True, + cwd=ROOT, capture_output=True, text=True, ) - return r.returncode == 0 + out = r.stdout + r.stderr + built = "error[E" not in out and "could not compile" not in out + return built, r.returncode == 0 def main(): - only = sys.argv[1] if len(sys.argv) > 1 else None + args = [a for a in sys.argv[1:] if not a.startswith("--")] + only = args[0] if args else None + rules = [r for r in RULES if not only or only in r.ident] + + if "--list" in sys.argv: + for r in rules: + print(f" {r.ident:<24} {r.claim}") + return 0 + original = SRC.read_text() - assert ANCHOR in original, "Diagnostics::push signature moved" - targets = [(p, f) for p, f in messages() if not only or only in f] - print(f"{len(targets)} rules to check\n") + # Baseline. Without it a pre-existing failure reads as every rule being + # held — the most flattering possible way to be wrong. + print("baseline… ", end="", flush=True) + built, passed = suite() + if not (built and passed): + print("FAILED — fix the suite first; mutation results would be meaningless") + return 2 + print("green\n") - survived, caught, broken = [], [], [] + held, unprotected, unusable = [], [], [] try: - for i, (prefix, full) in enumerate(targets, 1): - literal = '"%s"' % prefix.replace("\\", "\\\\").replace('"', '\\"') - SRC.write_text(original.replace(ANCHOR, ANCHOR + INJECT % literal, 1)) - ok = run_tests() - label = full[:66] - artifact = any(a in full for a in KNOWN_ARTIFACTS) - if ok and artifact: - caught.append(label) - print(f" [{i:2}/{len(targets)}] held* {label}") - elif ok: - survived.append(label) - print(f" [{i:2}/{len(targets)}] SURVIVED {label}") + for i, rule in enumerate(rules, 1): + hits = original.count(rule.find) + if hits != 1: + unusable.append((rule.ident, f"patch matches {hits} sites, expected 1")) + print(f" [{i:2}/{len(rules)}] STALE {rule.ident} ({hits} matches)") + continue + + SRC.write_text(original.replace(rule.find, rule.replace, 1)) + built, passed = suite() + SRC.write_text(original) + + if not built: + unusable.append((rule.ident, "mutation did not compile")) + print(f" [{i:2}/{len(rules)}] NO-BUILD {rule.ident}") + elif passed: + unprotected.append(rule) + print(f" [{i:2}/{len(rules)}] SURVIVED {rule.ident} — {rule.claim}") else: - caught.append(label) - print(f" [{i:2}/{len(targets)}] caught {label}") + held.append(rule) + print(f" [{i:2}/{len(rules)}] held {rule.ident}") finally: SRC.write_text(original) - print(f"\n{'=' * 72}") - print(f"held by a test: {len(caught)} UNPROTECTED: {len(survived)}") - print("(* held by a level or validity assertion this harness cannot reach;" - " see KNOWN_ARTIFACTS)") - if survived: + print(f"\n{'=' * 74}") + print(f"held: {len(held)} UNPROTECTED: {len(unprotected)} unusable: {len(unusable)}") + print(f"\nThis covers {len(RULES)} named rules. It is NOT a coverage percentage:\n" + "a rule absent from the table is untested by this harness and unmentioned\n" + "by its output, which is how the previous version came to claim full\n" + "coverage while missing the six rules that mattered most.") + if unprotected: print("\nRules no test would notice regressing:\n") - for s in survived: - print(f" - {s}") - if broken: - print(f"\ncompile failures (mutation invalid): {len(broken)}") - return 1 if survived else 0 + for r in unprotected: + print(f" - {r.ident}: {r.claim}") + if unusable: + print("\nMutations that could not be applied. These are NOT held — they are\n" + "untested, and a stale patch string is the likeliest reason:\n") + for ident, why in unusable: + print(f" - {ident}: {why}") + return 1 if (unprotected or unusable) else 0 if __name__ == "__main__": From fb9acbb8037677f2d5ca53c27e46546ea7e5f17d Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:49:35 -0700 Subject: [PATCH 45/63] test+docs: harden two tests that could pass wrongly, and five stale spec lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency-order test compared `Option` 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. --- crates/splash-core/tests/ui_profile_l0.rs | 32 ++++++++++++++++++++--- docs/ui-profile-l0.md | 13 ++++----- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index c7d26c9..941522e 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3357,8 +3357,16 @@ fn a_dotted_source_dependency_is_ordered_correctly() { view root Rule()", ); let order: Vec<&str> = plan.requests.iter().map(|r| r.name.as_str()).collect(); - let locale = order.iter().position(|n| *n == "env.locale"); - let scene = order.iter().position(|n| *n == "scene"); + // Unwrap both: `None < Some(_)` is true in Rust, so comparing the Options + // directly would let an ABSENT prerequisite satisfy the ordering assertion. + let locale = order + .iter() + .position(|n| *n == "env.locale") + .expect("env.locale must be planned"); + let scene = order + .iter() + .position(|n| *n == "scene") + .expect("scene must be planned"); assert!( locale < scene, "a source must be fetched before the one that reads it: {order:?}" @@ -3401,7 +3409,25 @@ fn changing_an_initial_resets_live_component_state() { let root = first.root.unwrap(); let mut rows = Vec::new(); find(&root, "Row", &mut rows); - splash_core::ui_l0::dispatch(BEFORE, &mut store, &rows[0].key.clone(), "bump"); + let applied = splash_core::ui_l0::dispatch(BEFORE, &mut store, &rows[0].key.clone(), "bump"); + assert!( + applied, + "the event must actually apply, or this proves nothing" + ); + + // Prove the caption is PRESENT before the edit. Without this, a dispatch + // regression alone satisfies the assertion below and masks the schema + // regression the test is named for. + let live = + splash_core::ui_l0::realize_with_state(BEFORE, &data, &store, RealizeLimits::default()); + let live_root = live.root.unwrap(); + let mut before_caps = Vec::new(); + find(&live_root, "TextCaption", &mut before_caps); + assert_eq!( + before_caps.len(), + 1, + "state must be live before the schema change" + ); let after = BEFORE.replace("initial: 0", "initial: 7"); let out = diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index ed7c083..4f58706 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -1,7 +1,7 @@ # Splash UI Profile — Level 0 **Status:** **implemented.** Normative producer contract for generated UI source at Level 0. -Parser, validator, realizer and instance store live in `splash-core::ui_l0`; 254 tests, three +Parser, validator, realizer and instance store live in `splash-core::ui_l0`; 317 tests, three reference cards, and a card rendered on a OnePlus 6T through an unmodified host. **Relationship to [Grammar v0.2](grammar.md):** a *sibling* canonical profile, not a subset of the workflow language. `check_syntax` rejects UI source and `eval_vm_compatibility` must not @@ -48,7 +48,7 @@ alone**; §6 states the five conditions that make it hold, and traversal bounds ## 2. Grammar ```ebnf -source = header , { declaration } ; +source = [ header ] , { declaration } ; (* header optional — see §7 *) header = "#" , "ledger" , ident , "@" , semver , { meta-line } ; meta-line = "#" , ident , ":" , value ; (* level, model, profile *) @@ -80,7 +80,7 @@ argument = ident , ":" , operand ; operand = path | literal | predicate ; (* NO expressions *) predicate = path , [ comparator , ( path | literal | token ) ] ; -comparator = "==" | "!=" | "<" | "<=" | ">" | ">=" ; +comparator = "==" | "!=" | "<=" | ">=" ; (* lone < and > are not parsed *) path = root , { "." , ident | "." , integer | "[" , path , "]" } , [ "." , "$state" ] ; (* source lifecycle, §5.9 *) @@ -487,7 +487,8 @@ Realization reports every instance key it produced. A host passes them to `prune pass; without it the store grows for the life of the app, and a key that reappears inherits a stale value. -**Duplicate keys are an error at realization**, surfaced as a diagnostic — never coalesced. +**Duplicate keys are reported at realization, and the colliding instances are given distinct +identity rather than sharing one cell. Duplicate keys are an error**, surfaced as a diagnostic — never coalesced. Silent coalescing would make two rows share one state cell, which is the failure mode keys exist to prevent. A missing key on an iteration is a grammar error. @@ -649,8 +650,8 @@ a false repin. And it is 64-bit FNV-1a, which is a change *detector* and not an boundary: it is fit for noticing that a definition moved, and unfit for resisting a chosen collision. Pinning is also report material — nothing in the runtime yet stores or compares it. -It also omits a loop's `key` expression, whether an element is a view reference, parameter -defaults, and the bodies of views a component references — so a card can change what it renders +It also omits a loop's `key` expression, whether an element is a view reference, and the bodies +of views a component references — so a card can change what it renders without the digest moving. Parameter and state *shapes* are covered as of the change that made prop shapes survive parsing; before that `x: number` → `x: text` was invisible to both the digest and §5.8's schema id, so a live string could survive into number-shaped state. From 8b7c442d6bd0b81d53c5ee9d4436c0faea25c7a5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:03:35 -0700 Subject: [PATCH 46/63] docs: propose a Splash language contract for scoped state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/scoped-state.md | 216 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/scoped-state.md diff --git a/docs/scoped-state.md b/docs/scoped-state.md new file mode 100644 index 0000000..ffa34c0 --- /dev/null +++ b/docs/scoped-state.md @@ -0,0 +1,216 @@ +# Splash Language Contract: Scoped State + +**Status:** proposed. Nothing here is implemented. + +`UPSTREAM.md` permits a behavioural change to the vendored VM "only when [it] +implements a published Splash language contract". This is that contract, written +so the VM patch has something to be checked against. It is deliberately narrow: +it publishes the smallest state mechanism that lets an interaction be handled +locally, and defers everything that can be deferred. + +--- + +## 1. What Splash has today, and why it is not enough + +Splash has no state in the VM. What looks like state is two separate mechanisms +that never meet: + +| | mechanism | where it lives | +|---|---|---| +| read | `{{state.q}}` | textual substitution when the card is seeded | +| write | `agent.notify("set", {key: "sel", value: "1"})` | a host callback out of the VM | + +So a tap leaves the VM, reaches the host, and the host re-seeds the entire card. +Nothing in the program holds a value between those two points. `{{state.q}}` is +not a binding — by the time the VM sees the source, the interpolation has already +happened and the text is a literal. + +Three consequences follow, and each is a reason for this contract: + +**Every interaction costs a whole card.** Toggling a row rebuilds and re-seeds +the program. That is the correct cost for "the user asked for something new" and +a poor one for "the user expanded a row". + +**State cannot be per-instance.** The namespace is flat, so seven rows each +needing an `expanded` flag must synthesise seven distinct names — the manual +keying that component models exist to remove. A card author who gets the keying +wrong gets two rows sharing one flag, silently. + +**Every render backend must re-solve it.** Splash lowers to three backends +(`splash-oh`, `splash-android`, `splash-makepad`). State handled above the DSL is +handled three times; state handled in the DSL is handled once. + +--- + +## 2. What this contract publishes + +Exactly three things: a way to declare a cell, a way to read it, and a way to +write it. Everything else — persistence, migration, reactivity beyond redraw — is +out of scope and named as such in §7. + +### 2.1 Declaration + +``` +state : = +``` + +`shape` is one of `text`, `number`, `bool`. A declaration is a *statement*, valid +where statements are valid, and its position determines the cell's scope (§3). + +A shape is required. It is what makes a write checkable and what lets a host +reject a payload that does not fit, which is the one check that cannot be done +statically. + +### 2.2 Read + +A declared name reads as an ordinary value: + +``` +state open: bool = false +... +if open { ... } +``` + +This is a real binding, not interpolation. `{{state.q}}` keeps its current +meaning — seed-time substitution against host-supplied values — and the two do +not interact. A program may use both; they are different namespaces and this +contract does not merge them. + +### 2.3 Write + +``` +state.set(, ) +state.toggle() +``` + +A write updates the cell and marks its scope for redraw. It does not call the +host. Nothing else in this contract writes. + +`set` requires the value to fit the declared shape. A mismatch is an error at the +write, not a coercion: coercing is how a string reaches a numeric cell and is +displayed as though it had always been a number. + +--- + +## 3. Scope, and what identifies a cell + +**A cell belongs to the innermost enclosing instance.** Splash's existing flat +`state.` namespace is global by construction; this one is not, and the difference +is the point of the contract. + +An instance is named by an **opaque key the emitter supplies**: + +``` +View { l0_key: "root/for#0[NVDA]/Rowy#0" ... } +``` + +The VM does not interpret the key. It compares it. Everything the key must +express — declaration path, enclosing loop keys, stability across an insertion — +is decided by whatever generated the program, and the VM's only obligation is +that two different keys never share a cell and one key always finds its own. + +This is deliberate. Identity is a hard problem with a settled answer one layer +up: `ui_l0` already computes `(declaration path, enclosing loop keys)` and +already emits `l0_key` on every tappable node. Re-deciding it in the VM would +create a second identity model to keep aligned with the first. + +A program that supplies no key gets one global scope, which is today's +behaviour. + +### 3.1 Collision is an error, not a merge + +Two instances presenting the same key is a defect in the emitter. The VM reports +it rather than silently sharing a cell — the failure mode is otherwise two rows +that toggle together, which looks like a rendering bug and is not. + +--- + +## 4. What a write triggers + +A write marks its scope dirty and returns. Redraw is the host's, on its own +schedule. + +This contract does **not** publish reactivity. There is no dependency tracking, +no computed value, no watcher, no effect. A cell changed; something should draw +again. That is all, and it is all that is needed to handle a tap locally. + +Anything more should be a separate contract, argued separately. + +--- + +## 5. Lifetime + +**A cell lives as long as its instance is on screen.** An instance that +disappears loses its cells. + +**No cell survives the program being re-seeded.** Re-seeding is how a card is +replaced; replacing a card discards its state. + +**Nothing is persisted.** There is no serialization in this contract, and this is +the limitation most likely to be felt: card-level state such as "which city" or +"which stock" arguably *should* survive a restart, and under this contract it +does not. + +That gap is named rather than closed because persistence needs a storage +contract, a migration story, and a decision about what a user is entitled to get +back — none of which should be smuggled in under a state mechanism. + +--- + +## 6. What this contract deliberately does not do + +**Schema versioning stays above the DSL.** `ui-profile-l0.md` §5.8 resets a cell +when its component's state schema changes, and preserves it under `keep` only +while that field's shape is unchanged. That needs declared shapes, an initial, +and a schema id — none of which the DSL carries. The VM holds cells and compares +keys; the layer that knows what a component *is* decides when to discard them. + +**Expressions are unaffected.** Splash already has them. This contract adds no +evaluation, no operators, and no new call forms. + +**`{{state.q}}` is untouched.** It keeps working exactly as it does, including +its round-trip through `agent.notify`. A program that never declares a `state` +behaves identically before and after this contract. + +--- + +## 7. Consequences worth stating plainly + +**For L0.** Its lowering currently resolves state away before emitting, so the +DSL never sees it. Under this contract, lowering would emit declarations and +transitions instead, and `InstanceStore` would shrink to whatever §5.8 still +needs. L0's transitions would then execute in the VM — as fixed shapes emitted by +trusted lowering, but executing. `ui-profile-l0.md` §1 must be narrowed to say +so, precisely, rather than claiming no execution machinery is in the path. + +**For the three backends.** Each must redraw a dirty scope. That is the one +obligation this contract places on them, and it is the reason to put state here +rather than above. + +**For the agent.** Interactions that are pure state changes stop reaching it. It +keeps every interaction that genuinely needs new content. + +--- + +## 8. Open questions + +| # | Question | +|---|---| +| 1 | Should `state.set` be an expression returning the new value, or a statement? A statement is narrower; an expression composes | +| 2 | Do `record` and `collection` shapes belong here, or only the three scalars? The scalars cover every reference card | +| 3 | What happens to a cell whose instance is off-screen but not destroyed — a scrolled-away row? Kept or dropped is a real UX difference | +| 4 | Should the VM expose cells for inspection, for a debugger? | +| 5 | Is a key collision fatal to the program, or reported and disambiguated as `ui_l0` currently does for duplicate loop keys? | + +--- + +## 9. What would make this contract wrong + +Written down so it can be checked rather than defended: + +- If a walking skeleton — one card, one component, one bool, one toggle, on + device — cannot be built against it, the contract is wrong. +- If per-instance identity cannot be expressed by an opaque emitter-supplied key, + §3 is wrong and the VM needs its own identity model. +- If redraw-on-write is insufficient to make an interaction feel local, §4 is + wrong and reactivity is not separable after all. From b899b8130fc79cb2cda9aa5baa28e3b9a4956cd4 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:13:28 -0700 Subject: [PATCH 47/63] docs(scoped-state): two implementations, so conformance is the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/scoped-state.md | 94 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/docs/scoped-state.md b/docs/scoped-state.md index ffa34c0..3d9ba49 100644 --- a/docs/scoped-state.md +++ b/docs/scoped-state.md @@ -3,10 +3,21 @@ **Status:** proposed. Nothing here is implemented. `UPSTREAM.md` permits a behavioural change to the vendored VM "only when [it] -implements a published Splash language contract". This is that contract, written -so the VM patch has something to be checked against. It is deliberately narrow: -it publishes the smallest state mechanism that lets an interaction be handled -locally, and defers everything that can be deferred. +implements a published Splash language contract". This is that contract. It is +deliberately narrow: it publishes the smallest state mechanism that lets an +interaction be handled locally, and defers everything that can be deferred. + +**This contract has more than one implementer.** Splash's vendored VM +(`vendor/makepad/platform/script`, pinned to upstream `4f9ce7a8`) and the VM that +ships on device in octos-one (`makepad/platform/script`) are independent +lineages — neither contains the other's history. That is a deliberate position, +not an accident awaiting a merge, and it has one consequence that governs +everything below: **a contract is only as real as the conformance that holds its +implementations together.** §9 is therefore not an appendix. It is the part +that makes the rest true. + +Nothing in §2–§6 names a VM, a crate or a language. If a sentence here can only +be satisfied by one implementation, that sentence is a defect. --- @@ -204,7 +215,76 @@ keeps every interaction that genuinely needs new content. --- -## 9. What would make this contract wrong +## 9. Conformance + +Two independent implementations agree only if something checks that they do. +Prose does not check. The mechanism is a **corpus of cases, in a form both +implementations execute**, and a rule that a case is normative rather than +illustrative. + +### 10.1 Shape of a case + +A case is a program, an ordered list of events, and the expected cell values +after each. It says nothing about rendering, because rendering is where the +implementations legitimately differ: + +```toml +[[case]] +id = "toggle-is-per-instance" +program = """ + state open: bool = false + View { l0_key: "row[a]" Button{ on_click: || state.toggle(open) } } + View { l0_key: "row[b]" Button{ on_click: || state.toggle(open) } } +""" +events = [ { key = "row[a]", click = 0 } ] +expect = [ { key = "row[a]", name = "open", value = true }, + { key = "row[b]", name = "open", value = false } ] +``` + +That single case is the whole point of the contract: without per-instance +scoping, `row[b]` reads `true` and the case fails. A case that both +implementations pass before the feature exists is not a conformance case. + +### 10.2 What the corpus must cover + +Every normative statement in §2–§6, and specifically the ones most likely to +drift because they are easy to implement *almost* right: + +- a write updates only its own scope (§3) +- an absent key means one global scope, i.e. today's behaviour (§3) +- two instances presenting the same key is an error, not a merge (§3.1) +- `set` refuses a value that does not fit the declared shape (§2.3) +- a cell dies with its instance (§5) +- re-seeding discards every cell (§5) +- `{{state.q}}` and `agent.notify` behave identically to before (§6) + +That last one deserves its own emphasis. **The most valuable cases are the ones +asserting nothing changed.** A state mechanism that quietly alters interpolation +or the host callback breaks every existing card, and neither implementation +would notice from cases that only exercise the new feature. + +### 10.3 Where the corpus lives + +In this repository, beside the contract, because the contract is the shared +artifact. Each implementation vendors or fetches it and runs it in its own test +suite. A case is added here first and fails in both implementations until each +lands the behaviour — which makes divergence visible as a failing case rather +than as a device-only bug six weeks later. + +### 10.4 What conformance does not buy + +It checks behaviour the corpus names. It cannot check behaviour nobody thought +to write down, and a case absent from the corpus is untested *and* unmentioned — +the same failure the L0 mutation harness had, where selecting rules by keyword +silently missed the six that mattered most. + +So the corpus must be treated as the specification's tests, not as a smoke +screen: when an implementation finds a behaviour the contract does not settle, +the fix is a new case here, not a local decision there. + +--- + +## 10. What would make this contract wrong Written down so it can be checked rather than defended: @@ -214,3 +294,7 @@ Written down so it can be checked rather than defended: §3 is wrong and the VM needs its own identity model. - If redraw-on-write is insufficient to make an interaction feel local, §4 is wrong and reactivity is not separable after all. +- If a conformance case cannot be written for a normative statement in §2–§6, + that statement is not testable and should be cut or restated until it is. +- If the two implementations pass the corpus and still behave differently in a + way a card can observe, the corpus is incomplete and §9.2 is missing a case. From 584a4fcf5feb0bdae39599084851306a60c2d0f4 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:53:23 -0700 Subject: [PATCH 48/63] =?UTF-8?q?docs(l0):=20a=20card=20names=20roles;=20t?= =?UTF-8?q?he=20theme=20decides=20appearance=20(=C2=A71.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//*.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. --- crates/splash-core/examples/checkgen.rs | 7 +++ docs/ui-l0-constructors.toml | 9 ++- docs/ui-profile-l0.md | 49 +++++++++++++++- flight.tsx | 54 +++++++++++++++++ l0.d.ts | 77 +++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 crates/splash-core/examples/checkgen.rs create mode 100644 flight.tsx create mode 100644 l0.d.ts diff --git a/crates/splash-core/examples/checkgen.rs b/crates/splash-core/examples/checkgen.rs new file mode 100644 index 0000000..bc6b03a --- /dev/null +++ b/crates/splash-core/examples/checkgen.rs @@ -0,0 +1,7 @@ +use splash_core::ui_l0::*; +fn main() { + let src = std::fs::read_to_string(std::env::args().nth(1).unwrap()).unwrap(); + let r = check_ui_l0_named("generated", &src); + println!("valid={} level={:?} diagnostics={}", r.valid, r.level, r.diagnostics.len()); + for d in r.diagnostics.iter().take(10) { println!(" {}:{} {}", d.line, d.column, d.message); } +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index f4002a2..80b678e 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -55,9 +55,14 @@ cols = { kind = "number" } [Rule] # ─── text roles ─────────────────────────────────────────────────────────────── -# Seven roles, each carrying weight, size and the complete glyph fallback chain. -# The chain is why a role is a constructor rather than a `font` argument: a +# Seven ROLES. A role says what a piece of text is FOR; the active theme decides +# what it looks like (profile §1.1). `TextHero` is "the one number this card +# exists to show", not white-at-62pt — a different theme answers the same role +# differently, and a card written today should survive that. +# +# A role is a constructor rather than a `font` argument for the same reason: a # model-authored font_family once dropped CJK coverage and rendered 上海 as tofu. +# Naming the role lets the theme carry the whole glyph fallback chain. [TextHero] text = { kind = "text" } diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 4f58706..3ee3b9e 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -26,6 +26,47 @@ wrong in an interesting direction: **L0 has no expression form, so there is noth evaluate.** Realization is a pure walk over the parsed tree with data substituted. The reference implementation contains no reference to the VM whatsoever. +### 1.1 A card names roles, not appearance + +**L0 decides what a thing IS. It does not decide what it looks like, and it never names a +render backend.** `TextHero` means "the one number this card exists to show". It does not mean +white, or 62pt, or any particular font — those are a *theme's* answer to the role, and a +different theme answers differently. + +Three layers, each knowing one thing: + +``` +L0 card names roles TextHero, Panel, Chip, TempBar + ↓ +theme kit role → presentation components//*.splash, authored as Splash DSL + ↓ +splash-render DSL → UiNode evaluated in the VM, renderer-free + ↓ +backend UiNode → native splash-makepad · Splash-OH · Splash-Android +``` + +A card therefore lowers to **semantic invocations of the active theme's component kit**, never +to a backend's own widget dialect: + +``` +card(title) not SolidView{ draw_bg.color: #0a0e14 … } +filled_button(copy.open) not RoundedView{ draw_bg.color: #ffffff12 … } +``` + +**Why this is a rule and not a preference.** A card is a ledger record that outlives the +device it was written on. If a card carries `#0a0e14`, then dark mode, a platform's native +look, and any later theme are all edits to every card ever written. Roles survive that; hex +codes do not. + +It also keeps the three backends honest. The branch point is `UiNode`; anything that lowers +past it reaches one platform and silently abandons the other two. + +**The implementation does not yet do this.** `splash-core`'s `makepad::lower` emits makepad's +widget dialect directly, with ten hardcoded colours and a font-size ramp — so it bypasses the +DSL, the VM, `UiNode` and two backends, and it puts a theme inside the crate whose job is +deciding whether a card is safe. That is a defect measured against this section, and §9 lists +it as such. + What that changes: | | Earlier claim | Actual | @@ -683,8 +724,12 @@ All seven are now settled. What replaces them is not a shorter list of questions different kind of work: the profile is specified and implemented, and the remaining gaps are in the surrounding system rather than in the language. -**Known gaps, stated plainly.** `makepad::lower` still lives in `splash-core` and belongs in a -backend crate. `StockPlot` and `AqiContour` lower without their data arrays. L1 and L2 are +**Known gaps, stated plainly.** `makepad::lower` violates §1.1 and does so in three ways at +once: it lowers to a *backend dialect* rather than to the theme's component kit, it decides +*presentation* (ten hardcoded colours, a font-size ramp) which 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. The fix is not to move it to a backend crate — it is to +retarget it at the component kit and delete the colour table. `StockPlot` and `AqiContour` lower without their data arrays. L1 and L2 are specified only as "what L0 excludes" — neither is implemented, and the level classifier can therefore name them but not check them. diff --git a/flight.tsx b/flight.tsx new file mode 100644 index 0000000..9d5ef30 --- /dev/null +++ b/flight.tsx @@ -0,0 +1,54 @@ +// ledger flight@1.0.0 · level L0 · profile ui/l0 + +export const current = source.news({ + count: 1, + fields: ["id", "number", "from", "to", "sched_dep", "act_dep", "status"], +}); +export const upcoming = source.news({ count: 7, offset: 1, fields: ["id", "number", "from", "to"] }); + +export const selected = state.text(""); + +export const select_flight = event({ selected: set.payload }); +export const back = event({ selected: clear }); + +export const copy = { + flight: { class: vocabulary, en: "FLIGHT", zh: "航班" }, + departure: { class: vocabulary, en: "DEPARTURE", zh: "出发" }, +}; + +export function FlightRow({ flight, position, onOpen }: Props<{ + flight: record; + position: number; + onOpen: event; +}>) { + return ( + + + + + + + + + ); +} + +export function Root() { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/l0.d.ts b/l0.d.ts new file mode 100644 index 0000000..d531f19 --- /dev/null +++ b/l0.d.ts @@ -0,0 +1,77 @@ +// Ambient declarations for L0-as-TSX. +// +// These exist so an editor can type-check a card without any of it being real: +// nothing here is executed, and the shapes are the ones §2 declares. If this +// file and the canonical grammar ever disagree, the grammar wins. + +declare type record = { [k: string]: unknown }; +declare type collection = record[]; +declare type event = { readonly __event: unique symbol }; +declare type text = string; + +declare type Props = T; + +// ── declarations ───────────────────────────────────────────────────────────── +declare const source: { + news(a: { count?: number; offset?: number; fields?: string[] }): collection; + news_item(a: { id: unknown; fields?: string[] }): record; + weather(a: { lat: unknown; lon: unknown; days?: number; fields?: string[] }): record; + movers(a: { count?: number; fields?: string[] }): collection; + quote(a: { ticker: unknown; fields?: string[] }): record; + geocode(a: { name: unknown }): record; + locale(): record; +}; + +declare const state: { + text(initial: string): text; + number(initial: number): number; + bool(initial: boolean): boolean; + enumOf(members: readonly T[], initial: T): T; +}; + +declare function event(transitions: Record): event; +declare const set: { payload: unknown; to(v: unknown): unknown }; +declare const clear: unknown; +declare const toggle: unknown; +declare const vocabulary: unique symbol; +declare const user_copy: unique symbol; +declare const model_copy: unique symbol; + +// ── constructors (the closed catalog) ──────────────────────────────────────── +// +// Declared as VALUES, not JSX.IntrinsicElements. A capitalised tag in JSX is a +// variable reference — `IntrinsicElements` only ever covers lowercase tags — so +// `` looks for a binding named `Col`. Keeping L0's capitalisation means +// declaring each constructor, which is also how React treats a component. + +declare type Node = { readonly __node: unique symbol }; +declare type Ctor

= (props: P) => Node; + +declare namespace JSX { + type Element = Node; + interface ElementChildrenAttribute { children: {} } +} + +type Kids = { children?: unknown }; + +declare const Surface: Ctor<{ pad?: string } & Kids>; +declare const Panel: Ctor; +declare const Card: Ctor<{ onTap?: event; value?: unknown } & Kids>; +declare const Col: Ctor<{ gap?: number; align?: string } & Kids>; +declare const Row: Ctor<{ gap?: number; align?: string; onTap?: event; value?: unknown } & Kids>; +declare const Grid: Ctor<{ cols?: number } & Kids>; +declare const Rule: Ctor<{}>; +declare const TextHero: Ctor<{ text?: unknown; value?: unknown; unit?: string; format?: string }>; +declare const TextTitle: Ctor<{ text?: unknown; width?: string }>; +declare const TextBody: Ctor<{ text?: unknown; width?: string }>; +declare const TextRow: Ctor<{ text?: unknown; width?: string }>; +declare const TextCaption: Ctor<{ text?: unknown; value?: unknown; glyph?: string; suffix?: unknown; width?: string }>; +declare const TextValue: Ctor<{ value?: unknown; unit?: string; format?: string; tint?: unknown }>; +declare const TextStat: Ctor<{ value?: unknown; format?: string; tint?: unknown }>; +declare const Tile: Ctor<{ label?: unknown; value?: unknown; unit?: string; format?: string }>; +declare const Chip: Ctor<{ text?: unknown; onTap?: event; value?: unknown; active?: boolean }>; + +// structural forms +declare const For: Ctor<{ each: unknown; key: string; as: string; index?: string } & Kids>; +declare const When: Ctor<{ path: unknown; eq?: string; neq?: string } & Kids>; +declare const Slot: Ctor<{ name?: string }>; From eb86ab37f3d1239ac9db5ae494dbe3de9d1e1c10 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:31:29 -0700 Subject: [PATCH 49/63] feat(ui_l0): a Splash-grammar surface, projected by a positive recogniser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/examples/checkgen.rs | 7 - crates/splash-core/examples/r3.rs | 57 --- crates/splash-core/src/ui_l0.rs | 497 ++++++++++++++++++++++ crates/splash-core/tests/ui_profile_l0.rs | 94 ++++ 4 files changed, 591 insertions(+), 64 deletions(-) delete mode 100644 crates/splash-core/examples/checkgen.rs delete mode 100644 crates/splash-core/examples/r3.rs diff --git a/crates/splash-core/examples/checkgen.rs b/crates/splash-core/examples/checkgen.rs deleted file mode 100644 index bc6b03a..0000000 --- a/crates/splash-core/examples/checkgen.rs +++ /dev/null @@ -1,7 +0,0 @@ -use splash_core::ui_l0::*; -fn main() { - let src = std::fs::read_to_string(std::env::args().nth(1).unwrap()).unwrap(); - let r = check_ui_l0_named("generated", &src); - println!("valid={} level={:?} diagnostics={}", r.valid, r.level, r.diagnostics.len()); - for d in r.diagnostics.iter().take(10) { println!(" {}:{} {}", d.line, d.column, d.message); } -} diff --git a/crates/splash-core/examples/r3.rs b/crates/splash-core/examples/r3.rs deleted file mode 100644 index 52f0e6b..0000000 --- a/crates/splash-core/examples/r3.rs +++ /dev/null @@ -1,57 +0,0 @@ -use splash_core::ui_l0::*; -fn dump(n: &UiNode, d: usize) { - println!("{}{} {:?}", " ".repeat(d), n.kind, n.args); - for c in &n.children { - dump(c, d + 1); - } -} -fn show(label: &str, src: &str) { - let r = check_ui_l0_named("p", src); - println!(" accepted={:<5} {label}", r.valid); - for d in r.diagnostics.iter().take(1) { - println!(" {}", d.message); - } -} -fn main() { - println!("#3 — does a literal reach a numeric position via a prop?"); - show("Bar(n: 41.2) -> TempBar(lo: n)", - "component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) }\nview root Bar(n: 41.2)"); - - println!("\n#1 — copy with NO class at all:"); - show( - "copy claim { en: \"Revenue: $41.2M\" }", - "copy claim { en: \"Revenue: $41.2M\" }\nview root TextTitle(text: copy.claim)", - ); - - println!("\n#2 — model-copy laundered through a state initial:"); - show("state s { initial: copy.claim }", - "copy claim { class: model-copy, en: \"Revenue: $41.2M\" }\ncomponent F() { state s { shape: text, initial: copy.claim } view TextTitle(text: s) }\nview root F()"); - - println!("\n#6 — does an enum member overwrite the shape?"); - show("shape: enum[a, text] then set(\"arbitrary\")", - "state mode { shape: enum[a, text], initial: .a }\nevent e { mode: set(\"arbitrary\") }\nview root Rule()"); - - println!("\n#7 — misspelled shape bypasses set checking:"); - show( - "shape: nubmer, set(\"oops\")", - "state n { shape: nubmer, initial: 0 }\nevent e { n: set(\"oops\") }\nview root Rule()", - ); - - println!("\n#8 — token set() into a non-enum:"); - show( - "shape: number, set(.c)", - "state n { shape: number, initial: 0 }\nevent e { n: set(.c) }\nview root Rule()", - ); - - println!("\n#9 — dotted source authorizes its whole root:"); - show( - "source env.locale, read env.theme.secret", - "source env.locale sys.locale()\nview root TextRow(text: env.theme.secret)", - ); - - println!("\n#4 — enum token prop keeps its dot?"); - let src = "component F(unit: enum[c, f]) { view Col { when unit == .c { TextRow(text: \"MATCHED\") } } }\nview root F(unit: .c)"; - let out = realize(src, &serde_json::json!({}), RealizeLimits::default()); - print!(" "); - dump(out.root.as_ref().unwrap(), 0); -} diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 51e7ba6..f77df7e 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -5359,3 +5359,500 @@ fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec ProjectionReport { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return ProjectionReport { + sources: Vec::new(), + states: Vec::new(), + events: Vec::new(), + copies: Vec::new(), + diagnostics: sink.items, + }; + }; + + let mut p = Projector { + t: &tokens, + at: 0, + sink: &mut sink, + }; + let mut sources = Vec::new(); + let mut states = Vec::new(); + let mut events = Vec::new(); + let mut copies = Vec::new(); + + while p.at < p.t.len() { + if !p.eat_ident("let") { + p.at += 1; + continue; + } + match p.let_binding() { + Some(Recognised::Source(d)) => sources.push(d), + Some(Recognised::State(d)) => states.push(d), + Some(Recognised::Event(d)) => events.push(d), + Some(Recognised::Copy(d)) => copies.push(d), + None => {} + } + } + + ProjectionReport { + sources, + states, + events, + copies, + diagnostics: sink.items, + } + } + + /// What the projector recognised. Names are exposed for tests and tooling; + /// the declarations themselves stay crate-private, like the bespoke + /// surface's, so the two cannot drift into different public shapes. + pub struct ProjectionReport { + pub(super) sources: Vec, + pub(super) states: Vec, + pub(super) events: Vec, + pub(super) copies: Vec, + pub diagnostics: Vec, + } + + impl ProjectionReport { + pub fn is_clean(&self) -> bool { + self.diagnostics.is_empty() + } + pub fn source_names(&self) -> Vec { + self.sources.iter().map(|d| d.name.clone()).collect() + } + pub fn source_helpers(&self) -> Vec { + self.sources.iter().map(|d| d.helper.clone()).collect() + } + pub fn state_names(&self) -> Vec { + self.states.iter().map(|d| d.path.clone()).collect() + } + pub fn event_names(&self) -> Vec { + self.events.iter().map(|d| d.name.clone()).collect() + } + pub fn copy_names(&self) -> Vec { + self.copies.iter().map(|d| d.name.clone()).collect() + } + } + + struct Projector<'a> { + t: &'a [Token], + at: usize, + sink: &'a mut Diagnostics, + } + + impl Projector<'_> { + fn eat_ident(&mut self, want: &str) -> bool { + let hit = self + .t + .get(self.at) + .is_some_and(|t| t.kind == super::Kind::Ident && t.text == want); + if hit { + self.at += 1; + } + hit + } + + fn eat_punct(&mut self, want: &str) -> bool { + let hit = self.t.get(self.at).is_some_and(|t| t.is_punct(want)); + if hit { + self.at += 1; + } + hit + } + + fn ident(&mut self) -> Option { + let t = self.t.get(self.at)?; + (t.kind == super::Kind::Ident).then(|| { + self.at += 1; + t.text.clone() + }) + } + + fn string(&mut self) -> Option { + let t = self.t.get(self.at)?; + (t.kind == super::Kind::Str).then(|| { + self.at += 1; + t.text.clone() + }) + } + + fn line(&self) -> usize { + self.t.get(self.at).map(|t| t.line).unwrap_or(1) + } + + fn reject(&mut self, message: String) { + let line = self.line(); + let column = self.t.get(self.at).map(|t| t.column).unwrap_or(1); + self.sink.push(line, column, message); + } + + /// `let = (…)`. + /// + /// The name and `=` are consumed before dispatch, so an unrecognised + /// form reports what it saw rather than "unexpected token". + fn let_binding(&mut self) -> Option { + let line = self.line(); + let name = self.ident()?; + if !self.eat_punct("=") { + return None; + } + let form = match self.ident() { + Some(f) => f, + None => { + self.reject(format!( + "`let {name}` must bind one of source(…), state(…), event(…) or \ + copy(…) — L0 admits no other binding (profile §2)" + )); + return None; + } + }; + if !self.eat_punct("(") { + self.reject(format!("expected `(` after {form:?}")); + return None; + } + match form.as_str() { + "source" => self.source(name, line).map(Recognised::Source), + "state" => self.state(name, line).map(Recognised::State), + "event" => self.event(name).map(Recognised::Event), + "copy" => self.copy(name).map(Recognised::Copy), + other => { + self.reject(format!( + "{other:?} is not an L0 declaration; L0 admits source, state, event \ + and copy, and nothing else" + )); + None + } + } + } + + /// `source("sys.news", {count: 1, fields: ["id", "title"]})` + fn source(&mut self, name: String, line: usize) -> Option { + let Some(helper) = self.string() else { + self.reject( + "a source names its capability as a string: source(\"sys.news\", {…})".into(), + ); + return None; + }; + let mut args = Vec::new(); + if self.eat_punct(",") && self.eat_punct("{") { + while !self.eat_punct("}") { + if self.at >= self.t.len() { + break; + } + let Some(key) = self.ident() else { + self.at += 1; + continue; + }; + if !self.eat_punct(":") { + continue; + } + if let Some(value) = self.source_arg() { + args.push((key, value)); + } + let _ = self.eat_punct(","); + } + } + let _ = self.eat_punct(")"); + Some(SourceDecl { + name, + helper, + args, + line, + }) + } + + fn source_arg(&mut self) -> Option { + let t = self.t.get(self.at)?.clone(); + match t.kind { + super::Kind::Str => { + self.at += 1; + Some(SourceArg::Text(t.text)) + } + super::Kind::Num => { + self.at += 1; + Some(SourceArg::Number(t.text.parse().unwrap_or(0.0))) + } + super::Kind::Ident => { + // A dotted path: `st.selected`, `place.lat`. + let mut path = t.text.clone(); + self.at += 1; + while self.t.get(self.at).is_some_and(|n| n.is_punct(".")) + && self + .t + .get(self.at + 1) + .is_some_and(|n| n.kind == super::Kind::Ident) + { + path.push('.'); + path.push_str(&self.t[self.at + 1].text); + self.at += 2; + } + Some(SourceArg::Path(path)) + } + _ if t.is_punct("[") => { + self.at += 1; + let mut fields = Vec::new(); + while !self.eat_punct("]") { + if self.at >= self.t.len() { + break; + } + if let Some(f) = self.string().or_else(|| self.ident()) { + fields.push(f); + } else { + self.at += 1; + } + let _ = self.eat_punct(","); + } + Some(SourceArg::List(fields)) + } + _ => { + self.reject( + "a source argument is a string, number, path or list of fields".into(), + ); + self.at += 1; + None + } + } + } + + /// `state("text", "")` / `state("enum", ["c", "f"], "c")` + fn state(&mut self, path: String, _line: usize) -> Option { + let Some(shape_name) = self.string() else { + self.reject("a state names its shape as a string: state(\"text\", \"\")".into()); + return None; + }; + let mut members = Vec::new(); + if shape_name == "enum" { + let _ = self.eat_punct(","); + if self.eat_punct("[") { + while !self.eat_punct("]") { + if self.at >= self.t.len() { + break; + } + if let Some(m) = self.string().or_else(|| self.ident()) { + members.push(m); + } else { + self.at += 1; + } + let _ = self.eat_punct(","); + } + } + } + let shape = match shape_name.as_str() { + "text" => Shape::Text, + "number" => Shape::Number, + "bool" => Shape::Bool, + "record" => Shape::Record, + "collection" => Shape::Collection, + "event" => Shape::Event, + "enum" => Shape::Enum(members), + other => { + self.reject(format!( + "{other:?} is not an L0 shape; L0 has text, number, bool, enum, \ + record, collection and event" + )); + Shape::Other + } + }; + + let _ = self.eat_punct(","); + let initial = self.t.get(self.at).cloned().and_then(|t| match t.kind { + super::Kind::Str => { + self.at += 1; + Some(crate::JsonValue::String(t.text)) + } + super::Kind::Num => { + self.at += 1; + t.text.parse::().ok().map(crate::JsonValue::from) + } + super::Kind::Ident if t.text == "true" || t.text == "false" => { + self.at += 1; + Some(crate::JsonValue::Bool(t.text == "true")) + } + _ => None, + }); + let _ = self.eat_punct(")"); + Some(StateDecl { + path, + shape, + initial, + initial_path: None, + keep: false, + }) + } + + /// `event({selected: set_payload()})` — a batch of total forms. + fn event(&mut self, name: String) -> Option { + let mut transitions = Vec::new(); + if self.eat_punct("{") { + while !self.eat_punct("}") { + if self.at >= self.t.len() { + break; + } + let line = self.line(); + let column = self.t.get(self.at).map(|t| t.column).unwrap_or(1); + let Some(target) = self.ident() else { + self.at += 1; + continue; + }; + if !self.eat_punct(":") { + continue; + } + let Some(form_name) = self.ident() else { + self.reject("a transition names a total form".into()); + continue; + }; + let _ = self.eat_punct("("); + let (form, tokens) = match form_name.as_str() { + "toggle" => (super::Form::Toggle, Vec::new()), + "clear" => (super::Form::Clear, Vec::new()), + "set_payload" => (super::Form::Set(super::SetSource::Payload), Vec::new()), + "set" => { + let src = match self.t.get(self.at).cloned() { + Some(t) if t.kind == super::Kind::Str => { + self.at += 1; + super::SetSource::Text(t.text) + } + Some(t) if t.kind == super::Kind::Num => { + self.at += 1; + super::SetSource::Num(t.text.parse().unwrap_or(0.0)) + } + Some(t) if t.kind == super::Kind::Ident => { + self.at += 1; + super::SetSource::Path(t.text) + } + _ => super::SetSource::Payload, + }; + (super::Form::Set(src), Vec::new()) + } + "cycle" => { + let mut members = Vec::new(); + while !self.t.get(self.at).is_some_and(|t| t.is_punct(")")) { + if self.at >= self.t.len() { + break; + } + if let Some(m) = self.string().or_else(|| self.ident()) { + members.push(m); + } else { + self.at += 1; + } + let _ = self.eat_punct(","); + } + (super::Form::Cycle, members) + } + other => { + self.reject(format!( + "{other:?} is not a total form; L0 admits set, set_payload, \ + toggle, cycle and clear (profile §3)" + )); + (super::Form::NotTotal(other.to_string()), Vec::new()) + } + }; + let _ = self.eat_punct(")"); + let _ = self.eat_punct(","); + transitions.push(super::Transition { + target, + form, + tokens, + line, + column, + }); + } + } + let _ = self.eat_punct(")"); + Some(EventDecl { name, transitions }) + } + + /// `copy("vocabulary", {en: "pts", zh: "分"})` + fn copy(&mut self, name: String) -> Option { + let provenance = match self.string().as_deref() { + Some("vocabulary") => Provenance::Vocabulary, + Some("user-copy") => Provenance::UserCopy, + Some("model-copy") => Provenance::ModelCopy, + Some(other) => { + self.reject(format!( + "{other:?} is not a copy class; L0 has vocabulary, user-copy and \ + model-copy (profile §4)" + )); + Provenance::ModelCopy + } + None => { + // Absent provenance is NOT vocabulary — §4 fails closed. + self.reject(format!( + "copy {name:?} declares no class; L0 needs one of vocabulary, \ + user-copy or model-copy (profile §4)" + )); + Provenance::ModelCopy + } + }; + let mut locales = Vec::new(); + if self.eat_punct(",") && self.eat_punct("{") { + while !self.eat_punct("}") { + if self.at >= self.t.len() { + break; + } + let Some(lang) = self.ident() else { + self.at += 1; + continue; + }; + if !self.eat_punct(":") { + continue; + } + if let Some(text) = self.string() { + locales.push((lang, text)); + } + let _ = self.eat_punct(","); + } + } + let _ = self.eat_punct(")"); + Some(CopyDecl { + name, + provenance, + locales, + }) + } + } +} diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 941522e..8c6710d 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3808,3 +3808,97 @@ fn realization_depth_is_bounded_independently_of_node_count() { "and it must stop well short of the node cap, or this proves nothing" ); } + +// ─── the Splash-surface projector ──────────────────────────────────────────── +// +// L0 has its own lexer and parser and imports one type from the rest of the +// crate, so "a Splash profile" has described a philosophy rather than an +// implementation. These tests cover the second surface: a card written in +// Splash's own grammar, projected into the same declarations. + +use splash_core::ui_l0::splash_surface::project_declarations; + +const AS_SPLASH: &str = r#" +let lead = source("sys.news", {count: 1, fields: ["id", "title", "author"]}) +let feed = source("sys.news", {count: 7, offset: 1, fields: ["id", "title"]}) +let article = source("sys.news_item", {id: st.selected, fields: ["title"]}) +let selected = state("text", "") +let open_story = event({selected: set_payload()}) +let back = event({selected: clear()}) +let c_pts = copy("vocabulary", {en: "pts", zh: "分"}) +"#; + +#[test] +fn a_card_written_as_splash_projects_its_declarations() { + let report = project_declarations(AS_SPLASH); + assert!(report.is_clean(), "{:#?}", report.diagnostics); + + assert_eq!(report.source_names(), vec!["lead", "feed", "article"]); + assert_eq!( + report.source_helpers(), + vec!["sys.news", "sys.news", "sys.news_item"] + ); + assert_eq!(report.state_names(), vec!["selected"]); + assert_eq!(report.event_names(), vec!["open_story", "back"]); + assert_eq!(report.copy_names(), vec!["c_pts"]); +} + +/// The carrier and the profile are separate checks and neither subsumes the +/// other: the first says the VM could run it, the second says L0 will admit it. +#[test] +fn the_splash_surface_is_also_valid_splash() { + let report = splash_core::check_vm_compatibility(AS_SPLASH).expect("checker runs"); + assert!( + report.diagnostics.is_empty(), + "the surface must be real Splash: {:#?}", + report.diagnostics + ); +} + +/// A positive recogniser, not a denylist: a `let` bound to anything outside the +/// four declaration forms is refused rather than skipped. Skipping is how a card +/// declares a source the runtime never fetches. +#[test] +fn an_unrecognised_binding_is_refused() { + for (source, why) in [ + ("let x = compute(1, 2)", "an arbitrary call"), + ("let x = 41.2", "a bare literal"), + ("let x = sys.news(1)", "a helper called directly"), + ] { + let report = project_declarations(source); + assert!( + !report.is_clean(), + "{why} must be refused, not ignored: {source}" + ); + } +} + +#[test] +fn the_projector_refuses_a_non_total_transition() { + let report = project_declarations("let e = event({n: increment()})"); + assert!(!report.is_clean()); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("not a total form")), + "{:#?}", + report.diagnostics + ); +} + +/// §4 fails closed on the Splash surface too — a copy with no class is refused +/// rather than trusted, which is the defect the bespoke surface shipped with. +#[test] +fn the_projector_refuses_copy_without_a_class() { + let report = project_declarations(r#"let c = copy({en: "Revenue: $41.2M"})"#); + assert!(!report.is_clean()); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("class")), + "{:#?}", + report.diagnostics + ); +} From b6632fac0b47996423584408c889ff4a1ae37629 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:37:55 -0700 Subject: [PATCH 50/63] feat(ui_l0): project views and components from the Splash surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` — 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. --- crates/splash-core/src/ui_l0.rs | 354 +++++++++++++++++++++- crates/splash-core/tests/ui_profile_l0.rs | 92 ++++++ 2 files changed, 436 insertions(+), 10 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index f77df7e..981a26e 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -159,6 +159,20 @@ pub const MAX_UI_L0_DIAGNOSTICS: usize = 64; /// Maximum declarations retained from one card. pub const MAX_UI_L0_DECLARATIONS: usize = 1_024; +/// Parse the BESPOKE surface into the same report shape the Splash surface +/// produces, so the two can be compared without either knowing about the other. +/// +/// Exists for the equality gate: two surfaces are a second *surface* only while +/// they project the same tree, and a second *language* the moment they stop. +pub fn project_bespoke(source: &str) -> splash_surface::ProjectionReport { + let mut sink = Diagnostics::default(); + let card = match lex(source, &mut sink) { + Some(tokens) => Parser::new(&tokens, &mut sink).parse_card(), + None => Card::default(), + }; + splash_surface::ProjectionReport::from_card(card, sink.items) +} + /// Check source against the L0 profile. pub fn check_ui_l0(source: &str) -> UiL0Report { check_ui_l0_named("card.l0", source) @@ -4795,6 +4809,24 @@ impl InstanceStore { /// A component's state schema: its declarations, order-independent. Changing a /// shape, name or initial changes this; changing the view does not. +/// A tree as `Kind(child, child)` — structure without spans, so two surfaces can +/// be compared on the thing that matters rather than byte-for-byte. Codex made +/// exactly this point about "byte-identical Card": spans differ whenever the +/// formatting does, so they cannot be part of an equality test between surfaces. +fn shape_of(e: &Element) -> String { + let kids: Vec = e.children.iter().map(shape_of).collect(); + let head = if e.name.is_empty() { + "_" + } else { + e.name.as_str() + }; + if kids.is_empty() { + head.to_string() + } else { + format!("{head}({})", kids.join(", ")) + } +} + fn schema_id(component: &Component) -> u64 { let mut acc: u64 = 0xcbf2_9ce4_8422_2325; let mut fields: Vec = component @@ -5383,8 +5415,8 @@ fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec sources.push(d), + Some(Recognised::State(d)) => states.push(d), + Some(Recognised::Event(d)) => events.push(d), + Some(Recognised::Copy(d)) => copies.push(d), + None => {} + } continue; } - match p.let_binding() { - Some(Recognised::Source(d)) => sources.push(d), - Some(Recognised::State(d)) => states.push(d), - Some(Recognised::Event(d)) => events.push(d), - Some(Recognised::Copy(d)) => copies.push(d), - None => {} + if p.eat_ident("fn") { + // Capitalised is a component, lowercase a view — the same + // convention the bespoke surface already uses (`component + // StoryRow`, `view stream`), so a card reads the same either way. + match p.function() { + Some(Declared::AsView(v)) => views.push(v), + Some(Declared::AsComponent(c)) => components.push(c), + None => {} + } + continue; } + p.at += 1; } ProjectionReport { @@ -5442,6 +5495,8 @@ pub mod splash_surface { states, events, copies, + views, + components, diagnostics: sink.items, } } @@ -5454,10 +5509,27 @@ pub mod splash_surface { pub(super) states: Vec, pub(super) events: Vec, pub(super) copies: Vec, + pub(super) views: Vec, + pub(super) components: Vec, pub diagnostics: Vec, } impl ProjectionReport { + pub(super) fn from_card( + card: super::Card, + diagnostics: Vec, + ) -> Self { + Self { + sources: card.sources, + states: card.states, + events: card.events, + copies: card.copies, + views: card.views, + components: card.components, + diagnostics, + } + } + pub fn is_clean(&self) -> bool { self.diagnostics.is_empty() } @@ -5476,6 +5548,26 @@ pub mod splash_surface { pub fn copy_names(&self) -> Vec { self.copies.iter().map(|d| d.name.clone()).collect() } + pub fn view_names(&self) -> Vec { + self.views.iter().map(|v| v.name.clone()).collect() + } + pub fn component_names(&self) -> Vec { + self.components.iter().map(|c| c.name.clone()).collect() + } + /// The projected tree of a view, as `Kind(child, child)`, for tests + /// that must compare structure rather than eyeball it. + pub fn view_shape(&self, name: &str) -> Option { + self.views + .iter() + .find(|v| v.name == name) + .map(|v| super::shape_of(&v.body)) + } + pub fn component_shape(&self, name: &str) -> Option { + self.components + .iter() + .find(|c| c.name == name) + .map(|c| super::shape_of(&c.body)) + } } struct Projector<'a> { @@ -5806,6 +5898,248 @@ pub mod splash_surface { Some(EventDecl { name, transitions }) } + /// `fn name() { return }` — a view, or a component when the + /// name is capitalised and it takes parameters. + fn function(&mut self) -> Option { + let line = self.line(); + let name = self.ident()?; + let mut params = Vec::new(); + if self.eat_punct("(") { + while !self.eat_punct(")") { + if self.at >= self.t.len() { + break; + } + if let Some(p) = self.ident() { + // A Splash `fn` parameter carries no type, so the shape + // is unknown here. The bespoke surface declares one, and + // §5.2's checks depend on it — see the note in + // `projection` about what this surface cannot yet say. + params.push(Param { + name: p, + shape: Shape::Other, + default: None, + }); + } else { + self.at += 1; + } + let _ = self.eat_punct(","); + } + } + if !self.eat_punct("{") { + self.reject(format!("`fn {name}` needs a body")); + return None; + } + if !self.eat_ident("return") { + self.reject(format!( + "`fn {name}` must be a single `return `; L0 admits no statements \ + in a view (profile §2)" + )); + return None; + } + let body = self.element()?; + let _ = self.eat_punct("}"); + + let capitalised = name.chars().next().is_some_and(|c| c.is_uppercase()); + if capitalised { + let mut instantiates = Vec::new(); + collect_constructors(&body, &mut instantiates); + Some(Declared::AsComponent(Component { + name, + params, + states: Vec::new(), + events: Vec::new(), + body, + line, + instantiates, + })) + } else { + Some(Declared::AsView(View { name, body })) + } + } + + /// An element: `Ctor({args}, [children])`, with `{args}` and + /// `[children]` each optional, plus the three structural forms. + fn element(&mut self) -> Option { + let line = self.line(); + let column = self.t.get(self.at).map(|t| t.column).unwrap_or(1); + let name = self.ident()?; + + // Structural forms first — they are recognised by name, so a card + // cannot reach them by accident and cannot spell them another way. + match name.as_str() { + "each" => return self.each(line, column), + "when_is" | "when_not" => return self.guard(&name, line, column), + _ => {} + } + + if !self.eat_punct("(") { + // A bare name is a reference to another view. + return Some(Element { + name, + line, + column, + is_reference: true, + ..Default::default() + }); + } + + let mut args = Vec::new(); + let mut children = Vec::new(); + + // `{...}` arguments, then `[...]` children, in that order. + if self.eat_punct("{") { + while !self.eat_punct("}") { + if self.at >= self.t.len() { + break; + } + let aline = self.line(); + let acol = self.t.get(self.at).map(|t| t.column).unwrap_or(1); + let Some(key) = self.ident() else { + self.at += 1; + continue; + }; + if !self.eat_punct(":") { + continue; + } + if let Some(value) = self.operand() { + args.push(Arg { + name: key, + value, + line: aline, + column: acol, + }); + } + let _ = self.eat_punct(","); + } + let _ = self.eat_punct(","); + } + if self.eat_punct("[") { + while !self.eat_punct("]") { + if self.at >= self.t.len() { + break; + } + match self.element() { + Some(child) => children.push(child), + None => self.at += 1, + } + let _ = self.eat_punct(","); + } + } + let _ = self.eat_punct(")"); + + // A capitalised call with no catalogue entry is a component + // instantiation; the validator decides which, exactly as it does on + // the bespoke surface. + Some(Element { + name, + args, + children, + line, + column, + ..Default::default() + }) + } + + /// `each(feed, "id", StoryRow)` — collection, key field, body component. + fn each(&mut self, line: usize, column: usize) -> Option { + if !self.eat_punct("(") { + return None; + } + let path = self.dotted()?; + let _ = self.eat_punct(","); + let Some(key_field) = self.string() else { + self.reject("each names its key field as a string: each(xs, \"id\", Row)".into()); + return None; + }; + let _ = self.eat_punct(","); + let body = self.element()?; + let _ = self.eat_punct(")"); + + // The binder is implicit and named for the body it feeds, so a key + // path can be reconstructed the way the bespoke surface writes it. + let binder = "it".to_string(); + Some(Element { + name: "for".into(), + args: vec![Arg { + name: "collection".into(), + value: Operand::Path(path), + line, + column, + }], + children: vec![body], + binders: vec![binder.clone(), "i".into()], + key_path: Some(format!("{binder}.{key_field}")), + line, + column, + ..Default::default() + }) + } + + /// `when_is(path, "value", child)` / `when_not(...)` + fn guard(&mut self, form: &str, line: usize, column: usize) -> Option { + if !self.eat_punct("(") { + return None; + } + let path = self.dotted()?; + let _ = self.eat_punct(","); + let rhs = self.operand(); + let _ = self.eat_punct(","); + let child = self.element()?; + let _ = self.eat_punct(")"); + Some(Element { + name: "when".into(), + args: vec![Arg { + name: "predicate".into(), + value: Operand::Path(path), + line, + column, + }], + cmp: Some(if form == "when_is" { "==" } else { "!=" }.into()), + rhs, + children: vec![child], + line, + column, + ..Default::default() + }) + } + + fn dotted(&mut self) -> Option { + let mut path = self.ident()?; + while self.t.get(self.at).is_some_and(|n| n.is_punct(".")) + && self + .t + .get(self.at + 1) + .is_some_and(|n| n.kind == super::Kind::Ident) + { + path.push('.'); + path.push_str(&self.t[self.at + 1].text); + self.at += 2; + } + Some(path) + } + + /// An argument value. A string beginning with `.` is a TOKEN, keeping + /// §2.2's lexical marking on a surface whose carrier has no token form — + /// the identity codex flagged as lossy if both spell as plain strings. + fn operand(&mut self) -> Option { + let t = self.t.get(self.at)?.clone(); + match t.kind { + super::Kind::Str => { + self.at += 1; + Some(match t.text.strip_prefix('.') { + Some(bare) => Operand::Token(bare.to_string()), + None => Operand::Str(t.text), + }) + } + super::Kind::Num => { + self.at += 1; + Some(Operand::Num(t.text.parse().unwrap_or(0.0))) + } + super::Kind::Ident => self.dotted().map(Operand::Path), + _ => None, + } + } + /// `copy("vocabulary", {en: "pts", zh: "分"})` fn copy(&mut self, name: String) -> Option { let provenance = match self.string().as_deref() { diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 8c6710d..243fa2f 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3902,3 +3902,95 @@ fn the_projector_refuses_copy_without_a_class() { report.diagnostics ); } + +// ─── views and components on the Splash surface ────────────────────────────── + +const VIEWS_AS_SPLASH: &str = r#" +let feed = source("sys.news", {count: 7, fields: ["id", "title"]}) +let selected = state("text", "") +let open = event({selected: set_payload()}) + +fn StoryRow(story, position, on_open) { + return Col([ + Row({align: ".center", on_tap: on_open, value: story.id}, [ + TextRow({text: position, width: ".rank"}), + TextRow({text: story.title, width: ".fill"}) + ]), + Rule() + ]) +} + +fn latest() { + return Panel([ + each(feed, "id", StoryRow) + ]) +} + +fn root() { + return Surface({pad: ".page"}, [ + when_is(selected, "", latest), + when_not(selected, "", latest) + ]) +} +"#; + +#[test] +fn views_and_components_project_from_the_splash_surface() { + let report = project_declarations(VIEWS_AS_SPLASH); + assert!(report.is_clean(), "{:#?}", report.diagnostics); + + assert_eq!(report.view_names(), vec!["latest", "root"]); + assert_eq!(report.component_names(), vec!["StoryRow"]); +} + +/// The gate that decides whether this is a second surface or a second language: +/// the same card, written both ways, must project to the same TREE. Compared by +/// shape rather than byte-for-byte, because spans differ whenever the formatting +/// does — the point codex made about "byte-identical Card" not being a usable +/// criterion. +#[test] +fn both_surfaces_project_the_same_tree() { + const BESPOKE: &str = r#" +source feed sys.news(count: 7, fields: [id, title]) +state selected { shape: text, initial: "" } +event open { selected: set($value) } + +component StoryRow(story: record, position: number, on_open: event) { + view Col { + Row(align: .center, on_tap: on_open, value: story.id) { + TextRow(text: position, width: .rank) + TextRow(text: story.title, width: .fill) + } + Rule() + } +} + +view latest Panel { + for it, i in feed key it.id { + StoryRow(story: it, position: i, on_open: open) + } + } + +view root Surface(pad: .page) { + when selected == "" { latest } + when selected != "" { latest } + } +"#; + + let splash = project_declarations(VIEWS_AS_SPLASH); + assert!(splash.is_clean(), "{:#?}", splash.diagnostics); + let bespoke = splash_core::ui_l0::project_bespoke(BESPOKE); + + for name in ["latest", "root"] { + assert_eq!( + splash.view_shape(name), + bespoke.view_shape(name), + "view {name:?} projects differently on the two surfaces" + ); + } + assert_eq!( + splash.component_shape("StoryRow"), + bespoke.component_shape("StoryRow"), + "the component projects differently on the two surfaces" + ); +} From bfe5188f89c18a1036d0de8b5c7e49675a770948 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:50:41 -0700 Subject: [PATCH 51/63] feat(ui_l0): props declare their shapes, and the Splash surface runs the checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 168 +++++++++++++++++++++- crates/splash-core/tests/ui_profile_l0.rs | 114 +++++++++++++++ 2 files changed, 280 insertions(+), 2 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 981a26e..9ca9951 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -159,6 +159,30 @@ pub const MAX_UI_L0_DIAGNOSTICS: usize = 64; /// Maximum declarations retained from one card. pub const MAX_UI_L0_DECLARATIONS: usize = 1_024; +/// Check a card written on the **Splash surface** against the L0 profile. +/// +/// Projection alone is not checking. `project_declarations` recognises shapes; +/// this runs the same `validate()` the bespoke surface runs, so §5.2's prop +/// rules, §4's no-facts rule, §6's termination conditions and every other check +/// apply identically. Without it a prop's declared shape is *retained and never +/// consulted* — the mirror of the "specified but not retained" defect this +/// surface exists to avoid repeating. +/// +/// The carrier is checked separately by `check_vm_compatibility`: that says the +/// VM could run it, this says L0 admits it, and neither subsumes the other. +pub fn check_ui_l0_splash(source: &str) -> UiL0Report { + let projection = splash_surface::project_declarations(source); + let mut sink = Diagnostics::default(); + for d in &projection.diagnostics { + sink.push(d.line, d.column, d.message.clone()); + } + let card = projection.into_card(); + validate(&card, &mut sink); + let mut report = sink.into_report(Level::L0); + report.closure = component_closure(&card); + report +} + /// Parse the BESPOKE surface into the same report shape the Splash surface /// produces, so the two can be compared without either knowing about the other. /// @@ -2545,6 +2569,22 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { } } +/// A shape's canonical spelling — what a card writes. Distinct from +/// `shape_name`, which is prose for a diagnostic: a test comparing shapes should +/// not be coupled to how an error message reads. +fn shape_spelling(shape: &Shape) -> &'static str { + match shape { + Shape::Text => "text", + Shape::Number => "number", + Shape::Bool => "bool", + Shape::Enum(_) => "enum", + Shape::Record => "record", + Shape::Collection => "collection", + Shape::Event => "event", + Shape::Other => "?", + } +} + /// A shape's name, for diagnostics. fn shape_name(shape: &Shape) -> &'static str { match shape { @@ -5530,6 +5570,19 @@ pub mod splash_surface { } } + /// The projected declarations as a `Card`, so the shared validator can + /// run over them. + pub(super) fn into_card(self) -> super::Card { + super::Card { + sources: self.sources, + states: self.states, + events: self.events, + copies: self.copies, + views: self.views, + components: self.components, + } + } + pub fn is_clean(&self) -> bool { self.diagnostics.is_empty() } @@ -5562,6 +5615,19 @@ pub mod splash_surface { .find(|v| v.name == name) .map(|v| super::shape_of(&v.body)) } + /// A component's props with their declared shapes, in order. + pub fn prop_shapes(&self, name: &str) -> Vec<(String, String)> { + self.components + .iter() + .find(|c| c.name == name) + .map(|c| { + c.params + .iter() + .map(|p| (p.name.clone(), super::shape_spelling(&p.shape).to_string())) + .collect() + }) + .unwrap_or_default() + } pub fn component_shape(&self, name: &str) -> Option { self.components .iter() @@ -5929,16 +5995,64 @@ pub mod splash_surface { self.reject(format!("`fn {name}` needs a body")); return None; } + // `props({...})` is the ONE statement admitted before the return. + // §5.2's checks — event-vs-value, enum membership, defaults, the + // readable/event scope split — all consult a prop's declared shape, + // and a Splash `fn` parameter carries none. Declaring it beats + // inferring it from a name: `online_count` starts with `on`. + let declared = if self.peek_ident("props") { + self.at += 1; + Some(self.props()) + } else { + None + }; + if !self.eat_ident("return") { self.reject(format!( - "`fn {name}` must be a single `return `; L0 admits no statements \ - in a view (profile §2)" + "`fn {name}` must be a single `return `, optionally preceded by \ + `props(…)`; L0 admits no other statement in a view (profile §2)" )); return None; } let body = self.element()?; let _ = self.eat_punct("}"); + // Reconcile. The bespoke surface cannot have this check, because + // there the parameter list IS the declaration; here they are + // separate, so redundancy that drifts must become redundancy that + // is checked. + if !params.is_empty() { + match declared { + None => self.reject(format!( + "`fn {name}` takes parameters but declares no `props(…)`; a prop with \ + no shape cannot be checked against profile §5.2" + )), + Some(shapes) => { + for p in ¶ms { + if !shapes.iter().any(|(n, _)| *n == p.name) { + self.reject(format!( + "`{name}` declares no shape for parameter {:?}", + p.name + )); + } + } + for (n, _) in &shapes { + if !params.iter().any(|p| p.name == *n) { + self.reject(format!( + "`{name}` declares a shape for {n:?}, which is not one of \ + its parameters" + )); + } + } + for p in &mut params { + if let Some((_, shape)) = shapes.iter().find(|(n, _)| *n == p.name) { + p.shape = shape.clone(); + } + } + } + } + } + let capitalised = name.chars().next().is_some_and(|c| c.is_uppercase()); if capitalised { let mut instantiates = Vec::new(); @@ -5957,6 +6071,56 @@ pub mod splash_surface { } } + fn peek_ident(&self, want: &str) -> bool { + self.t + .get(self.at) + .is_some_and(|t| t.kind == super::Kind::Ident && t.text == want) + } + + /// `props({story: "record", on_open: "event"})` + fn props(&mut self) -> Vec<(String, Shape)> { + let mut out = Vec::new(); + if !self.eat_punct("(") || !self.eat_punct("{") { + self.reject("props takes an object: props({name: \"shape\", …})".into()); + return out; + } + while !self.eat_punct("}") { + if self.at >= self.t.len() { + break; + } + let Some(name) = self.ident() else { + self.at += 1; + continue; + }; + if !self.eat_punct(":") { + continue; + } + let shape = match self.string().as_deref() { + Some("text") => Shape::Text, + Some("number") => Shape::Number, + Some("bool") => Shape::Bool, + Some("record") => Shape::Record, + Some("collection") => Shape::Collection, + Some("event") => Shape::Event, + Some(other) => { + self.reject(format!( + "{other:?} is not an L0 shape; L0 has text, number, bool, enum, \ + record, collection and event" + )); + Shape::Other + } + None => { + self.reject(format!("prop {name:?} declares no shape")); + Shape::Other + } + }; + out.push((name, shape)); + let _ = self.eat_punct(","); + } + let _ = self.eat_punct(")"); + out + } + /// An element: `Ctor({args}, [children])`, with `{args}` and /// `[children]` each optional, plus the three structural forms. fn element(&mut self) -> Option { diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 243fa2f..645788f 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3911,6 +3911,7 @@ let selected = state("text", "") let open = event({selected: set_payload()}) fn StoryRow(story, position, on_open) { + props({story: "record", position: "number", on_open: "event"}) return Col([ Row({align: ".center", on_tap: on_open, value: story.id}, [ TextRow({text: position, width: ".rank"}), @@ -3994,3 +3995,116 @@ view root Surface(pad: .page) { "the component projects differently on the two surfaces" ); } + +/// §5.2 depends on a prop's declared shape — event-vs-value confusion, enum +/// membership, defaults, and the readable/event scope split all consult it. A +/// Splash `fn` parameter carries no type, so the surface needs a declaration: +/// meaning is declared here, never inferred from a name. +#[test] +fn props_declare_their_shapes_on_the_splash_surface() { + let report = project_declarations( + r#" +fn StoryRow(story, position, on_open) { + props({story: "record", position: "number", on_open: "event"}) + return Col([ TextRow({text: story.title}) ]) +} +"#, + ); + assert!(report.is_clean(), "{:#?}", report.diagnostics); + assert_eq!( + report.prop_shapes("StoryRow"), + vec![ + ("story".to_string(), "record".to_string()), + ("position".to_string(), "number".to_string()), + ("on_open".to_string(), "event".to_string()), + ], + "each prop keeps the shape it declared" + ); +} + +/// The 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 must be caught — redundancy that is checked is a different thing +/// from redundancy that drifts. +#[test] +fn props_must_match_the_parameters_they_describe() { + for (source, why) in [ + ( + r#"fn F(a, b) { props({a: "text"}) return Rule() }"#, + "a parameter with no declared shape", + ), + ( + r#"fn F(a) { props({a: "text", b: "number"}) return Rule() }"#, + "a declared shape for no parameter", + ), + ] { + let report = project_declarations(source); + assert!(!report.is_clean(), "{why} must be refused: {source}"); + } +} + +#[test] +fn a_component_without_props_is_refused_when_it_takes_parameters() { + // Silence is not a declaration. A component with untyped parameters cannot + // be checked against §5.2 at all, so it is refused rather than admitted + // with unknown shapes — the same fail-closed rule as `copy` without a class. + let report = project_declarations(r#"fn F(a) { return TextRow({text: a}) }"#); + assert!(!report.is_clean()); + assert!( + report + .diagnostics + .iter() + .any(|d| d.message.contains("props")), + "{:#?}", + report.diagnostics + ); + + // A component with no parameters needs none. + let none = project_declarations(r#"fn F() { return Rule() }"#); + assert!(none.is_clean(), "{:#?}", none.diagnostics); +} + +/// Projection is not checking. `project_declarations` recognises shapes; the +/// SAME `validate()` must then run, or a declared prop shape is retained and +/// never consulted — the mirror of "specified but not retained". +/// +/// Caught because a deliberately wrong shape passed 180 tests: declaring +/// `on_open: "text"` and handing it to `on_tap` is exactly what §5.2 refuses, +/// and nothing was looking. +#[test] +fn the_splash_surface_runs_the_same_checks() { + let wrong = splash_core::ui_l0::check_ui_l0_splash( + r#" +fn F(on_open) { + props({on_open: "text"}) + return Row({on_tap: on_open}, [ Rule() ]) +} +"#, + ); + assert!( + !wrong.valid, + "a text prop handed to on_tap must be refused (§5.2)" + ); + + let right = splash_core::ui_l0::check_ui_l0_splash( + r#" +let go = event({n: clear()}) +let n = state("number", 0) +fn F(on_open) { + props({on_open: "event"}) + return Row({on_tap: on_open}, [ Rule() ]) +} +fn root() { return Surface({pad: ".page"}, [ F({on_open: go}) ]) } +"#, + ); + assert!(right.valid, "{:#?}", right.diagnostics); +} + +/// §4 travels to this surface too, unchanged. +#[test] +fn the_splash_surface_refuses_a_literal_in_a_data_position() { + let report = splash_core::ui_l0::check_ui_l0_splash( + r#"fn root() { return TempBar({lo: 5, hi: 9, min: 0, max: 10}) }"#, + ); + assert!(!report.valid, "an authored number must not reach TempBar"); +} From 24814970cf665e95959e4481fe095b6faa2f48be Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:01:23 -0700 Subject: [PATCH 52/63] fix(weather.card): show the low as well as the high on a daily row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%. --- crates/splash-core/tests/fixtures/weather.card | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/splash-core/tests/fixtures/weather.card b/crates/splash-core/tests/fixtures/weather.card index be0217c..01cf8b5 100644 --- a/crates/splash-core/tests/fixtures/weather.card +++ b/crates/splash-core/tests/fixtures/weather.card @@ -81,12 +81,12 @@ component ForecastRow(item: record, unit: enum[c, f]) { Row(align: .center, on_tap: toggle) { TextRow(text: item.dayname, width: .day) WeatherIcon(cond: item.cond, size: .row) + TextValue(value: item.lo, unit: unit) TempBar(lo: item.lo, hi: item.hi, min: week.min_lo, max: week.max_hi) TextValue(value: item.hi, unit: unit) } when expanded { Row(gap: 8) { - TextCaption(glyph: "↓", value: item.lo, unit: unit) TextCaption(text: item.cond) } } From b3cf51fb1b8bf63f8223e3a61e66cddd05289654 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:11:49 -0700 Subject: [PATCH 53/63] docs+test(l0): settle the six data-visualisation roles as backend widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/tests/ui_profile_l0.rs | 81 +++++++++++++++++++++++ docs/ui-l0-constructors.toml | 11 ++- docs/ui-profile-l0.md | 34 ++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 645788f..17aed8d 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -4108,3 +4108,84 @@ fn the_splash_surface_refuses_a_literal_in_a_data_position() { ); assert!(!report.valid, "an authored number must not reach TempBar"); } + +// ─── §1.1: a card lowers to roles, never to a backend's colours ────────────── + +/// The split §1.1 settles: 16 roles are satisfied by the theme's component kit, +/// 6 are backend widgets because a fragment shader cannot be authored in the +/// DSL, and `Photo` is an image. Pinned as a test so the retarget has a target +/// and so the two groups cannot quietly merge. +#[test] +fn every_role_is_classified_for_lowering() { + const BACKEND_WIDGETS: &[&str] = &[ + "WeatherIcon", + "TempBar", + "SunArc", + "MoonPhase", + "AqiContour", + "StockPlot", + ]; + const IMAGE: &[&str] = &["Photo"]; + + let all: Vec<&str> = catalog::CONSTRUCTORS.iter().map(|(n, _)| *n).collect(); + let themed: Vec<&str> = all + .iter() + .copied() + .filter(|n| !BACKEND_WIDGETS.contains(n) && !IMAGE.contains(n)) + .collect(); + + assert_eq!( + themed.len() + BACKEND_WIDGETS.len() + IMAGE.len(), + all.len(), + "every catalogued role must fall in exactly one group" + ); + assert_eq!( + BACKEND_WIDGETS.len(), + 6, + "the shader-backed set is closed; adding one is a porting cost on every backend" + ); + + // A backend widget takes only bound paths. That is what keeps §1.1 true for + // them: the card supplies data, the backend supplies every pixel. + for name in BACKEND_WIDGETS { + let args = catalog::lookup(name).unwrap_or_else(|| panic!("{name} is not catalogued")); + for (arg, kind) in args { + assert!( + matches!( + kind, + catalog::ArgKind::Path | catalog::ArgKind::Token(_) | catalog::ArgKind::Data + ), + "{name}.{arg} must be bound data or a token, not authored presentation" + ); + } + } +} + +/// §1.1's actual prohibition, as a test rather than a paragraph: a card must +/// have no way to state a colour, a size in pixels, or a font. +#[test] +fn no_role_accepts_presentation() { + for (ctor, args) in catalog::CONSTRUCTORS { + for (arg, _) in *args { + assert!( + !matches!( + *arg, + "color" + | "colour" + | "bg" + | "fill" + | "font" + | "font_size" + | "weight" + | "radius" + | "elevation" + | "shadow" + | "padding" + | "margin" + ), + "{ctor}.{arg} is presentation; §1.1 says a card names roles and the theme \ + decides appearance" + ); + } + } +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 80b678e..0f11763 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -114,10 +114,19 @@ on_tap = { kind = "event" } value = { kind = "any" } active = { kind = "bool" } # a predicate is a bool operand -# ─── drawing widgets ────────────────────────────────────────────────────────── +# ─── data-visualisation roles ───────────────────────────────────────────────── # Every argument is a path. These render live data and hold no authored values — # the class of bug that put an invented `condition` and invented `wmin`/`wmax` # into shipped cards. +# +# These six are BACKEND WIDGETS, not theme compositions (profile §1.1). A +# gradient bar or a sun arc is a fragment shader parameterised by data, and +# `splash-render` notes that a DSL node cannot carry shader source — MPSL +# compiles at build time. So no theme file could contain one, and each backend +# implements them, exactly as it already does for `Map`. +# +# The card still names only a role and its data. Colour, thickness and cap shape +# remain the backend's, so §1.1 holds here as everywhere else. [WeatherIcon] cond = { kind = "path" } diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 3ee3b9e..b8303d6 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -61,12 +61,46 @@ codes do not. It also keeps the three backends honest. The branch point is `UiNode`; anything that lowers past it reaches one platform and silently abandons the other two. +**Some roles are a backend widget, not a theme composition.** Six of the catalogue — +`WeatherIcon`, `TempBar`, `SunArc`, `MoonPhase`, `AqiContour`, `StockPlot` — are small data +visualisations, and they cannot be authored in the DSL at all. `splash-render` states the +reason plainly: *a DSL node cannot carry shader source — MPSL compiles at build time — but it +can select a shader that was compiled.* A gradient bar or a sun arc is a fragment shader +parameterised by data, not a composition of boxes and text, so there is no theme file that +could contain one. + +They are therefore **backend widgets named semantically**, exactly as `splash-render` already +treats `Map` — *"a real map is not a platform view here, it is a widget"* — and `Shader`, +`Sdf`, `Fab` and `Segmented`. This is not an exception carved out for L0; it is the category +the renderer already has. + +§1.1 still holds for them, and holds without apology. A card writes + +``` +TempBar(lo: item.lo, hi: item.hi, min: week.min_lo, max: week.max_hi) +``` + +which is four bound paths and no presentation whatsoever. What colour the gradient runs, how +thick the bar is, whether its caps are rounded — every one of those stays the backend's. The +card names a role. That role simply happens to be one only a shader can fill. + +**The cost, stated rather than buried:** every backend must implement all six, or a card using +one renders blank on that platform. That is a real porting burden, and it is the strongest +argument against admitting them. It is accepted because the alternative is worse: pre-rendering +them to images server-side would make a live value into a fact fetched once, which is precisely +what §4 exists to prevent. A backend that has not implemented a role should fail visibly rather +than draw nothing. + **The implementation does not yet do this.** `splash-core`'s `makepad::lower` emits makepad's widget dialect directly, with ten hardcoded colours and a font-size ramp — so it bypasses the DSL, the VM, `UiNode` and two backends, and it puts a theme inside the crate whose job is deciding whether a card is safe. That is a defect measured against this section, and §9 lists it as such. +The retarget it implies is therefore a clean split rather than a judgement call: **16 roles +lower to the theme's component kit, 6 lower to a backend widget node, and `Photo` is already an +image.** + What that changes: | | Earlier claim | Actual | From c15301bb48dd6ac343e912751c620e4ac35dbc18 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:19:14 -0700 Subject: [PATCH 54/63] feat(ui_l0): lower to Splash DSL, so a card carries roles and no theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 113 +++++++++++++++++++++- crates/splash-core/tests/ui_profile_l0.rs | 95 ++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 9ca9951..c22a9b3 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -4389,7 +4389,7 @@ pub mod makepad { } } - fn trim_num(n: f64) -> String { + pub(super) fn trim_num(n: f64) -> String { if (n - n.round()).abs() < f64::EPSILON { format!("{}", n as i64) } else { @@ -6354,3 +6354,114 @@ pub mod splash_surface { } } } + +// ─────────────────────────────────────────────────────────── DSL lowering (§1.1) ── + +/// Lower a realized card to **Splash DSL**, the data form `splash-render` +/// evaluates into a `UiNode` and every backend renders from. +/// +/// This is what §1.1 requires and what `makepad::lower` does not do. That one +/// emits makepad's widget dialect with ten hardcoded colours and a font-size +/// ramp, which reaches one backend of three and puts a theme inside the crate +/// whose job is deciding whether a card is safe. +/// +/// The split §1.1 settles: +/// +/// - **16 roles** become an existing node kind carrying their data. 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. +/// - **6 roles** keep their own kind. They are fragment shaders parameterised by +/// data and cannot be composed from boxes and text, so each backend implements +/// them — the category `splash-render` already has for `Map`. +/// - **`Photo`** is an image. +/// +/// No colour, size, weight or radius is emitted. If one appears here, §1.1 has +/// been broken and `lowering_emits_splash_dsl_not_a_backend_dialect` will say so. +pub fn lower_dsl(root: &UiNode) -> String { + let mut out = String::new(); + out.push_str("// REALIZED from an L0 ledger — do not edit.\n"); + emit(root, 0, &mut out); + out.push('\n'); + out +} + +/// The node kind a role lowers to, and the role name it carries when several +/// roles share a kind. +fn kind_of(role: &str) -> (&'static str, Option<&'static str>) { + match role { + // ── containers and structure ──────────────────────────────────────── + "Surface" => ("column", Some("surface")), + "Panel" => ("card", Some("panel")), + "Card" => ("card", Some("elevated")), + "Col" => ("column", None), + "Row" => ("row", None), + "Grid" => ("grid", None), + "Rule" => ("divider", None), + "Tile" => ("listitem", None), + "Chip" => ("chip", None), + "Photo" => ("image", None), + + // ── text roles: one kind, seven roles ─────────────────────────────── + "TextHero" => ("text", Some("hero")), + "TextTitle" => ("text", Some("title")), + "TextBody" => ("text", Some("body")), + "TextStat" => ("text", Some("stat")), + "TextRow" => ("text", Some("row")), + "TextCaption" => ("text", Some("caption")), + "TextValue" => ("text", Some("value")), + + // ── data visualisations: backend widgets (§1.1) ───────────────────── + "WeatherIcon" => ("weathericon", None), + "TempBar" => ("tempbar", None), + "SunArc" => ("sunarc", None), + "MoonPhase" => ("moonphase", None), + "AqiContour" => ("aqicontour", None), + "StockPlot" => ("stockplot", None), + + // An unmapped role is emitted as itself. A backend that does not know it + // should fail visibly; silently dropping a section is how a card looks + // finished and is not. + other => (Box::leak(other.to_lowercase().into_boxed_str()), None), + } +} + +fn emit(node: &UiNode, depth: usize, out: &mut String) { + use std::fmt::Write as _; + let pad = " ".repeat(depth); + let (kind, role) = kind_of(&node.kind); + + let _ = write!(out, "{pad}{{t: {kind:?}"); + if let Some(role) = role { + let _ = write!(out, ", role: {role:?}"); + } + + for (name, value) in &node.args { + // `on_tap` and its payload are interaction, not presentation, and the + // key is what lets a tap name WHICH instance was hit. + let _ = match value { + NodeValue::Text(s) => write!(out, ", {name}: {s:?}"), + NodeValue::Number(n) => write!(out, ", {name}: {}", makepad::trim_num(*n)), + NodeValue::Bool(b) => write!(out, ", {name}: {b}"), + NodeValue::Token(t) => write!(out, ", {name}: {t:?}"), + NodeValue::Event(e) => write!(out, ", {name}: {e:?}"), + NodeValue::Status(s) => write!(out, ", {name}: {:?}", s.as_token()), + // An unresolved binding is emitted as such rather than as an em + // dash: the theme decides how absence looks, and a dash baked in + // here would be presentation. + NodeValue::Missing => write!(out, ", {name}: null"), + }; + } + if !node.key.is_empty() { + let _ = write!(out, ", key: {:?}", node.key); + } + + if node.children.is_empty() { + out.push_str("}\n"); + return; + } + out.push_str(", c: [\n"); + for child in &node.children { + emit(child, depth + 1, out); + } + let _ = writeln!(out, "{pad}]}}"); +} diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index 17aed8d..d1f16ed 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -4189,3 +4189,98 @@ fn no_role_accepts_presentation() { } } } + +// ─── §1.1 lowering: a card becomes Splash DSL, not a backend dialect ───────── + +/// The retarget. A card lowers to the DSL data form `{t: "…", …}` that +/// `splash-render` evaluates into a `UiNode`, from which all three backends +/// render. It does NOT lower to makepad's widget dialect, which reaches one +/// backend and carries a theme. +#[test] +fn lowering_emits_splash_dsl_not_a_backend_dialect() { + let dsl = splash_core::ui_l0::lower_dsl( + &realize( + NEWS, + &serde_json::json!({ + "lead": [{"id": "a", "title": "T", "author": "A", "points": 1, "comments": 2}], + "feed": [{"id": "b", "title": "U", "author": "B", "points": 3}], + "article": {}, "selected": "", "env": {"locale": {}} + }), + RealizeLimits::default(), + ) + .root + .unwrap(), + ); + + assert!( + dsl.contains("{t: \""), + "the output must be the DSL data form: {}", + &dsl[..dsl.len().min(200)] + ); + for backend_ism in [ + "SolidView", + "RoundedView", + "draw_bg.color", + "draw_text.color", + ] { + assert!( + !dsl.contains(backend_ism), + "{backend_ism:?} is makepad's dialect; §1.1 says a card names roles" + ); + } + // No colour may appear — that was the ten-constant table. Matched as a hex + // literal rather than a bare '#', because an instance key legitimately + // contains one: `root/for#0[b]/StoryRow#0`. + let colour = dsl + .match_indices('#') + .find(|(i, _)| dsl[i + 1..].chars().take(6).all(|c| c.is_ascii_hexdigit())); + assert!( + colour.is_none(), + "a lowered card must carry no colour: §1.1 leaves that to the theme ({colour:?})" + ); +} + +/// Seven text roles collapse onto one node kind carrying the role, because the +/// theme decides what a hero looks like. The role must survive, or the theme +/// has nothing to decide with. +#[test] +fn text_roles_lower_to_text_carrying_the_role() { + let dsl = splash_core::ui_l0::lower_dsl( + &realize( + "view root Col { TextHero(text: \"x\") TextCaption(text: \"y\") }", + &serde_json::json!({}), + RealizeLimits::default(), + ) + .root + .unwrap(), + ); + assert!(dsl.contains("t: \"text\""), "{dsl}"); + assert!( + dsl.contains("role: \"hero\""), + "the role must survive: {dsl}" + ); + assert!(dsl.contains("role: \"caption\""), "{dsl}"); +} + +/// The six shader-backed roles keep their own node kind — they are backend +/// widgets, and their data must reach the backend intact. +#[test] +fn a_data_visualisation_lowers_to_its_own_kind_with_its_data() { + let dsl = splash_core::ui_l0::lower_dsl( + &realize( + "source w sys.weather(lat: 1.0, lon: 2.0)\n\ + view root TempBar(lo: w.lo, hi: w.hi, min: w.min, max: w.max)", + &serde_json::json!({"w": {"lo": 12, "hi": 21, "min": 10, "max": 24}}), + RealizeLimits::default(), + ) + .root + .unwrap(), + ); + assert!(dsl.contains("t: \"tempbar\""), "{dsl}"); + for v in ["12", "21", "10", "24"] { + assert!( + dsl.contains(v), + "the bound value {v} must reach the backend: {dsl}" + ); + } +} From 0db100d39a31d0ec313db34857b01155bf3eaeb3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:30:01 -0700 Subject: [PATCH 55/63] chore: remove the TSX proposal artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- flight.tsx | 54 -------------------------------------- l0.d.ts | 77 ------------------------------------------------------ 2 files changed, 131 deletions(-) delete mode 100644 flight.tsx delete mode 100644 l0.d.ts diff --git a/flight.tsx b/flight.tsx deleted file mode 100644 index 9d5ef30..0000000 --- a/flight.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// ledger flight@1.0.0 · level L0 · profile ui/l0 - -export const current = source.news({ - count: 1, - fields: ["id", "number", "from", "to", "sched_dep", "act_dep", "status"], -}); -export const upcoming = source.news({ count: 7, offset: 1, fields: ["id", "number", "from", "to"] }); - -export const selected = state.text(""); - -export const select_flight = event({ selected: set.payload }); -export const back = event({ selected: clear }); - -export const copy = { - flight: { class: vocabulary, en: "FLIGHT", zh: "航班" }, - departure: { class: vocabulary, en: "DEPARTURE", zh: "出发" }, -}; - -export function FlightRow({ flight, position, onOpen }: Props<{ - flight: record; - position: number; - onOpen: event; -}>) { - return ( - - - - - - - - - ); -} - -export function Root() { - return ( - - - - - - - - - - - - - - - - ); -} diff --git a/l0.d.ts b/l0.d.ts deleted file mode 100644 index d531f19..0000000 --- a/l0.d.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Ambient declarations for L0-as-TSX. -// -// These exist so an editor can type-check a card without any of it being real: -// nothing here is executed, and the shapes are the ones §2 declares. If this -// file and the canonical grammar ever disagree, the grammar wins. - -declare type record = { [k: string]: unknown }; -declare type collection = record[]; -declare type event = { readonly __event: unique symbol }; -declare type text = string; - -declare type Props = T; - -// ── declarations ───────────────────────────────────────────────────────────── -declare const source: { - news(a: { count?: number; offset?: number; fields?: string[] }): collection; - news_item(a: { id: unknown; fields?: string[] }): record; - weather(a: { lat: unknown; lon: unknown; days?: number; fields?: string[] }): record; - movers(a: { count?: number; fields?: string[] }): collection; - quote(a: { ticker: unknown; fields?: string[] }): record; - geocode(a: { name: unknown }): record; - locale(): record; -}; - -declare const state: { - text(initial: string): text; - number(initial: number): number; - bool(initial: boolean): boolean; - enumOf(members: readonly T[], initial: T): T; -}; - -declare function event(transitions: Record): event; -declare const set: { payload: unknown; to(v: unknown): unknown }; -declare const clear: unknown; -declare const toggle: unknown; -declare const vocabulary: unique symbol; -declare const user_copy: unique symbol; -declare const model_copy: unique symbol; - -// ── constructors (the closed catalog) ──────────────────────────────────────── -// -// Declared as VALUES, not JSX.IntrinsicElements. A capitalised tag in JSX is a -// variable reference — `IntrinsicElements` only ever covers lowercase tags — so -// `` looks for a binding named `Col`. Keeping L0's capitalisation means -// declaring each constructor, which is also how React treats a component. - -declare type Node = { readonly __node: unique symbol }; -declare type Ctor

= (props: P) => Node; - -declare namespace JSX { - type Element = Node; - interface ElementChildrenAttribute { children: {} } -} - -type Kids = { children?: unknown }; - -declare const Surface: Ctor<{ pad?: string } & Kids>; -declare const Panel: Ctor; -declare const Card: Ctor<{ onTap?: event; value?: unknown } & Kids>; -declare const Col: Ctor<{ gap?: number; align?: string } & Kids>; -declare const Row: Ctor<{ gap?: number; align?: string; onTap?: event; value?: unknown } & Kids>; -declare const Grid: Ctor<{ cols?: number } & Kids>; -declare const Rule: Ctor<{}>; -declare const TextHero: Ctor<{ text?: unknown; value?: unknown; unit?: string; format?: string }>; -declare const TextTitle: Ctor<{ text?: unknown; width?: string }>; -declare const TextBody: Ctor<{ text?: unknown; width?: string }>; -declare const TextRow: Ctor<{ text?: unknown; width?: string }>; -declare const TextCaption: Ctor<{ text?: unknown; value?: unknown; glyph?: string; suffix?: unknown; width?: string }>; -declare const TextValue: Ctor<{ value?: unknown; unit?: string; format?: string; tint?: unknown }>; -declare const TextStat: Ctor<{ value?: unknown; format?: string; tint?: unknown }>; -declare const Tile: Ctor<{ label?: unknown; value?: unknown; unit?: string; format?: string }>; -declare const Chip: Ctor<{ text?: unknown; onTap?: event; value?: unknown; active?: boolean }>; - -// structural forms -declare const For: Ctor<{ each: unknown; key: string; as: string; index?: string } & Kids>; -declare const When: Ctor<{ path: unknown; eq?: string; neq?: string } & Kids>; -declare const Slot: Ctor<{ name?: string }>; From 55f59d6ea7033ee3229abd78c5425784482765dc Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:21:49 -0700 Subject: [PATCH 56/63] =?UTF-8?q?revert(ui=5Fl0):=20remove=20the=20Splash-?= =?UTF-8?q?grammar=20surface=20=E2=80=94=20it=20was=20not=20what=20it=20cl?= =?UTF-8?q?aimed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 997 ---------------------- crates/splash-core/tests/ui_profile_l0.rs | 381 --------- 2 files changed, 1378 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index c22a9b3..9622a01 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -159,44 +159,6 @@ pub const MAX_UI_L0_DIAGNOSTICS: usize = 64; /// Maximum declarations retained from one card. pub const MAX_UI_L0_DECLARATIONS: usize = 1_024; -/// Check a card written on the **Splash surface** against the L0 profile. -/// -/// Projection alone is not checking. `project_declarations` recognises shapes; -/// this runs the same `validate()` the bespoke surface runs, so §5.2's prop -/// rules, §4's no-facts rule, §6's termination conditions and every other check -/// apply identically. Without it a prop's declared shape is *retained and never -/// consulted* — the mirror of the "specified but not retained" defect this -/// surface exists to avoid repeating. -/// -/// The carrier is checked separately by `check_vm_compatibility`: that says the -/// VM could run it, this says L0 admits it, and neither subsumes the other. -pub fn check_ui_l0_splash(source: &str) -> UiL0Report { - let projection = splash_surface::project_declarations(source); - let mut sink = Diagnostics::default(); - for d in &projection.diagnostics { - sink.push(d.line, d.column, d.message.clone()); - } - let card = projection.into_card(); - validate(&card, &mut sink); - let mut report = sink.into_report(Level::L0); - report.closure = component_closure(&card); - report -} - -/// Parse the BESPOKE surface into the same report shape the Splash surface -/// produces, so the two can be compared without either knowing about the other. -/// -/// Exists for the equality gate: two surfaces are a second *surface* only while -/// they project the same tree, and a second *language* the moment they stop. -pub fn project_bespoke(source: &str) -> splash_surface::ProjectionReport { - let mut sink = Diagnostics::default(); - let card = match lex(source, &mut sink) { - Some(tokens) => Parser::new(&tokens, &mut sink).parse_card(), - None => Card::default(), - }; - splash_surface::ProjectionReport::from_card(card, sink.items) -} - /// Check source against the L0 profile. pub fn check_ui_l0(source: &str) -> UiL0Report { check_ui_l0_named("card.l0", source) @@ -2569,22 +2531,6 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { } } -/// A shape's canonical spelling — what a card writes. Distinct from -/// `shape_name`, which is prose for a diagnostic: a test comparing shapes should -/// not be coupled to how an error message reads. -fn shape_spelling(shape: &Shape) -> &'static str { - match shape { - Shape::Text => "text", - Shape::Number => "number", - Shape::Bool => "bool", - Shape::Enum(_) => "enum", - Shape::Record => "record", - Shape::Collection => "collection", - Shape::Event => "event", - Shape::Other => "?", - } -} - /// A shape's name, for diagnostics. fn shape_name(shape: &Shape) -> &'static str { match shape { @@ -4847,26 +4793,6 @@ impl InstanceStore { } } -/// A component's state schema: its declarations, order-independent. Changing a -/// shape, name or initial changes this; changing the view does not. -/// A tree as `Kind(child, child)` — structure without spans, so two surfaces can -/// be compared on the thing that matters rather than byte-for-byte. Codex made -/// exactly this point about "byte-identical Card": spans differ whenever the -/// formatting does, so they cannot be part of an equality test between surfaces. -fn shape_of(e: &Element) -> String { - let kids: Vec = e.children.iter().map(shape_of).collect(); - let head = if e.name.is_empty() { - "_" - } else { - e.name.as_str() - }; - if kids.is_empty() { - head.to_string() - } else { - format!("{head}({})", kids.join(", ")) - } -} - fn schema_id(component: &Component) -> u64 { let mut acc: u64 = 0xcbf2_9ce4_8422_2325; let mut fields: Vec = component @@ -5432,929 +5358,6 @@ fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec ProjectionReport { - let mut sink = Diagnostics::default(); - let Some(tokens) = lex(source, &mut sink) else { - return ProjectionReport { - sources: Vec::new(), - states: Vec::new(), - events: Vec::new(), - copies: Vec::new(), - views: Vec::new(), - components: Vec::new(), - diagnostics: sink.items, - }; - }; - - let mut p = Projector { - t: &tokens, - at: 0, - sink: &mut sink, - }; - let mut sources = Vec::new(); - let mut states = Vec::new(); - let mut events = Vec::new(); - let mut copies = Vec::new(); - - let mut views = Vec::new(); - let mut components = Vec::new(); - - while p.at < p.t.len() { - if p.eat_ident("let") { - match p.let_binding() { - Some(Recognised::Source(d)) => sources.push(d), - Some(Recognised::State(d)) => states.push(d), - Some(Recognised::Event(d)) => events.push(d), - Some(Recognised::Copy(d)) => copies.push(d), - None => {} - } - continue; - } - if p.eat_ident("fn") { - // Capitalised is a component, lowercase a view — the same - // convention the bespoke surface already uses (`component - // StoryRow`, `view stream`), so a card reads the same either way. - match p.function() { - Some(Declared::AsView(v)) => views.push(v), - Some(Declared::AsComponent(c)) => components.push(c), - None => {} - } - continue; - } - p.at += 1; - } - - ProjectionReport { - sources, - states, - events, - copies, - views, - components, - diagnostics: sink.items, - } - } - - /// What the projector recognised. Names are exposed for tests and tooling; - /// the declarations themselves stay crate-private, like the bespoke - /// surface's, so the two cannot drift into different public shapes. - pub struct ProjectionReport { - pub(super) sources: Vec, - pub(super) states: Vec, - pub(super) events: Vec, - pub(super) copies: Vec, - pub(super) views: Vec, - pub(super) components: Vec, - pub diagnostics: Vec, - } - - impl ProjectionReport { - pub(super) fn from_card( - card: super::Card, - diagnostics: Vec, - ) -> Self { - Self { - sources: card.sources, - states: card.states, - events: card.events, - copies: card.copies, - views: card.views, - components: card.components, - diagnostics, - } - } - - /// The projected declarations as a `Card`, so the shared validator can - /// run over them. - pub(super) fn into_card(self) -> super::Card { - super::Card { - sources: self.sources, - states: self.states, - events: self.events, - copies: self.copies, - views: self.views, - components: self.components, - } - } - - pub fn is_clean(&self) -> bool { - self.diagnostics.is_empty() - } - pub fn source_names(&self) -> Vec { - self.sources.iter().map(|d| d.name.clone()).collect() - } - pub fn source_helpers(&self) -> Vec { - self.sources.iter().map(|d| d.helper.clone()).collect() - } - pub fn state_names(&self) -> Vec { - self.states.iter().map(|d| d.path.clone()).collect() - } - pub fn event_names(&self) -> Vec { - self.events.iter().map(|d| d.name.clone()).collect() - } - pub fn copy_names(&self) -> Vec { - self.copies.iter().map(|d| d.name.clone()).collect() - } - pub fn view_names(&self) -> Vec { - self.views.iter().map(|v| v.name.clone()).collect() - } - pub fn component_names(&self) -> Vec { - self.components.iter().map(|c| c.name.clone()).collect() - } - /// The projected tree of a view, as `Kind(child, child)`, for tests - /// that must compare structure rather than eyeball it. - pub fn view_shape(&self, name: &str) -> Option { - self.views - .iter() - .find(|v| v.name == name) - .map(|v| super::shape_of(&v.body)) - } - /// A component's props with their declared shapes, in order. - pub fn prop_shapes(&self, name: &str) -> Vec<(String, String)> { - self.components - .iter() - .find(|c| c.name == name) - .map(|c| { - c.params - .iter() - .map(|p| (p.name.clone(), super::shape_spelling(&p.shape).to_string())) - .collect() - }) - .unwrap_or_default() - } - pub fn component_shape(&self, name: &str) -> Option { - self.components - .iter() - .find(|c| c.name == name) - .map(|c| super::shape_of(&c.body)) - } - } - - struct Projector<'a> { - t: &'a [Token], - at: usize, - sink: &'a mut Diagnostics, - } - - impl Projector<'_> { - fn eat_ident(&mut self, want: &str) -> bool { - let hit = self - .t - .get(self.at) - .is_some_and(|t| t.kind == super::Kind::Ident && t.text == want); - if hit { - self.at += 1; - } - hit - } - - fn eat_punct(&mut self, want: &str) -> bool { - let hit = self.t.get(self.at).is_some_and(|t| t.is_punct(want)); - if hit { - self.at += 1; - } - hit - } - - fn ident(&mut self) -> Option { - let t = self.t.get(self.at)?; - (t.kind == super::Kind::Ident).then(|| { - self.at += 1; - t.text.clone() - }) - } - - fn string(&mut self) -> Option { - let t = self.t.get(self.at)?; - (t.kind == super::Kind::Str).then(|| { - self.at += 1; - t.text.clone() - }) - } - - fn line(&self) -> usize { - self.t.get(self.at).map(|t| t.line).unwrap_or(1) - } - - fn reject(&mut self, message: String) { - let line = self.line(); - let column = self.t.get(self.at).map(|t| t.column).unwrap_or(1); - self.sink.push(line, column, message); - } - - /// `let = (…)`. - /// - /// The name and `=` are consumed before dispatch, so an unrecognised - /// form reports what it saw rather than "unexpected token". - fn let_binding(&mut self) -> Option { - let line = self.line(); - let name = self.ident()?; - if !self.eat_punct("=") { - return None; - } - let form = match self.ident() { - Some(f) => f, - None => { - self.reject(format!( - "`let {name}` must bind one of source(…), state(…), event(…) or \ - copy(…) — L0 admits no other binding (profile §2)" - )); - return None; - } - }; - if !self.eat_punct("(") { - self.reject(format!("expected `(` after {form:?}")); - return None; - } - match form.as_str() { - "source" => self.source(name, line).map(Recognised::Source), - "state" => self.state(name, line).map(Recognised::State), - "event" => self.event(name).map(Recognised::Event), - "copy" => self.copy(name).map(Recognised::Copy), - other => { - self.reject(format!( - "{other:?} is not an L0 declaration; L0 admits source, state, event \ - and copy, and nothing else" - )); - None - } - } - } - - /// `source("sys.news", {count: 1, fields: ["id", "title"]})` - fn source(&mut self, name: String, line: usize) -> Option { - let Some(helper) = self.string() else { - self.reject( - "a source names its capability as a string: source(\"sys.news\", {…})".into(), - ); - return None; - }; - let mut args = Vec::new(); - if self.eat_punct(",") && self.eat_punct("{") { - while !self.eat_punct("}") { - if self.at >= self.t.len() { - break; - } - let Some(key) = self.ident() else { - self.at += 1; - continue; - }; - if !self.eat_punct(":") { - continue; - } - if let Some(value) = self.source_arg() { - args.push((key, value)); - } - let _ = self.eat_punct(","); - } - } - let _ = self.eat_punct(")"); - Some(SourceDecl { - name, - helper, - args, - line, - }) - } - - fn source_arg(&mut self) -> Option { - let t = self.t.get(self.at)?.clone(); - match t.kind { - super::Kind::Str => { - self.at += 1; - Some(SourceArg::Text(t.text)) - } - super::Kind::Num => { - self.at += 1; - Some(SourceArg::Number(t.text.parse().unwrap_or(0.0))) - } - super::Kind::Ident => { - // A dotted path: `st.selected`, `place.lat`. - let mut path = t.text.clone(); - self.at += 1; - while self.t.get(self.at).is_some_and(|n| n.is_punct(".")) - && self - .t - .get(self.at + 1) - .is_some_and(|n| n.kind == super::Kind::Ident) - { - path.push('.'); - path.push_str(&self.t[self.at + 1].text); - self.at += 2; - } - Some(SourceArg::Path(path)) - } - _ if t.is_punct("[") => { - self.at += 1; - let mut fields = Vec::new(); - while !self.eat_punct("]") { - if self.at >= self.t.len() { - break; - } - if let Some(f) = self.string().or_else(|| self.ident()) { - fields.push(f); - } else { - self.at += 1; - } - let _ = self.eat_punct(","); - } - Some(SourceArg::List(fields)) - } - _ => { - self.reject( - "a source argument is a string, number, path or list of fields".into(), - ); - self.at += 1; - None - } - } - } - - /// `state("text", "")` / `state("enum", ["c", "f"], "c")` - fn state(&mut self, path: String, _line: usize) -> Option { - let Some(shape_name) = self.string() else { - self.reject("a state names its shape as a string: state(\"text\", \"\")".into()); - return None; - }; - let mut members = Vec::new(); - if shape_name == "enum" { - let _ = self.eat_punct(","); - if self.eat_punct("[") { - while !self.eat_punct("]") { - if self.at >= self.t.len() { - break; - } - if let Some(m) = self.string().or_else(|| self.ident()) { - members.push(m); - } else { - self.at += 1; - } - let _ = self.eat_punct(","); - } - } - } - let shape = match shape_name.as_str() { - "text" => Shape::Text, - "number" => Shape::Number, - "bool" => Shape::Bool, - "record" => Shape::Record, - "collection" => Shape::Collection, - "event" => Shape::Event, - "enum" => Shape::Enum(members), - other => { - self.reject(format!( - "{other:?} is not an L0 shape; L0 has text, number, bool, enum, \ - record, collection and event" - )); - Shape::Other - } - }; - - let _ = self.eat_punct(","); - let initial = self.t.get(self.at).cloned().and_then(|t| match t.kind { - super::Kind::Str => { - self.at += 1; - Some(crate::JsonValue::String(t.text)) - } - super::Kind::Num => { - self.at += 1; - t.text.parse::().ok().map(crate::JsonValue::from) - } - super::Kind::Ident if t.text == "true" || t.text == "false" => { - self.at += 1; - Some(crate::JsonValue::Bool(t.text == "true")) - } - _ => None, - }); - let _ = self.eat_punct(")"); - Some(StateDecl { - path, - shape, - initial, - initial_path: None, - keep: false, - }) - } - - /// `event({selected: set_payload()})` — a batch of total forms. - fn event(&mut self, name: String) -> Option { - let mut transitions = Vec::new(); - if self.eat_punct("{") { - while !self.eat_punct("}") { - if self.at >= self.t.len() { - break; - } - let line = self.line(); - let column = self.t.get(self.at).map(|t| t.column).unwrap_or(1); - let Some(target) = self.ident() else { - self.at += 1; - continue; - }; - if !self.eat_punct(":") { - continue; - } - let Some(form_name) = self.ident() else { - self.reject("a transition names a total form".into()); - continue; - }; - let _ = self.eat_punct("("); - let (form, tokens) = match form_name.as_str() { - "toggle" => (super::Form::Toggle, Vec::new()), - "clear" => (super::Form::Clear, Vec::new()), - "set_payload" => (super::Form::Set(super::SetSource::Payload), Vec::new()), - "set" => { - let src = match self.t.get(self.at).cloned() { - Some(t) if t.kind == super::Kind::Str => { - self.at += 1; - super::SetSource::Text(t.text) - } - Some(t) if t.kind == super::Kind::Num => { - self.at += 1; - super::SetSource::Num(t.text.parse().unwrap_or(0.0)) - } - Some(t) if t.kind == super::Kind::Ident => { - self.at += 1; - super::SetSource::Path(t.text) - } - _ => super::SetSource::Payload, - }; - (super::Form::Set(src), Vec::new()) - } - "cycle" => { - let mut members = Vec::new(); - while !self.t.get(self.at).is_some_and(|t| t.is_punct(")")) { - if self.at >= self.t.len() { - break; - } - if let Some(m) = self.string().or_else(|| self.ident()) { - members.push(m); - } else { - self.at += 1; - } - let _ = self.eat_punct(","); - } - (super::Form::Cycle, members) - } - other => { - self.reject(format!( - "{other:?} is not a total form; L0 admits set, set_payload, \ - toggle, cycle and clear (profile §3)" - )); - (super::Form::NotTotal(other.to_string()), Vec::new()) - } - }; - let _ = self.eat_punct(")"); - let _ = self.eat_punct(","); - transitions.push(super::Transition { - target, - form, - tokens, - line, - column, - }); - } - } - let _ = self.eat_punct(")"); - Some(EventDecl { name, transitions }) - } - - /// `fn name() { return }` — a view, or a component when the - /// name is capitalised and it takes parameters. - fn function(&mut self) -> Option { - let line = self.line(); - let name = self.ident()?; - let mut params = Vec::new(); - if self.eat_punct("(") { - while !self.eat_punct(")") { - if self.at >= self.t.len() { - break; - } - if let Some(p) = self.ident() { - // A Splash `fn` parameter carries no type, so the shape - // is unknown here. The bespoke surface declares one, and - // §5.2's checks depend on it — see the note in - // `projection` about what this surface cannot yet say. - params.push(Param { - name: p, - shape: Shape::Other, - default: None, - }); - } else { - self.at += 1; - } - let _ = self.eat_punct(","); - } - } - if !self.eat_punct("{") { - self.reject(format!("`fn {name}` needs a body")); - return None; - } - // `props({...})` is the ONE statement admitted before the return. - // §5.2's checks — event-vs-value, enum membership, defaults, the - // readable/event scope split — all consult a prop's declared shape, - // and a Splash `fn` parameter carries none. Declaring it beats - // inferring it from a name: `online_count` starts with `on`. - let declared = if self.peek_ident("props") { - self.at += 1; - Some(self.props()) - } else { - None - }; - - if !self.eat_ident("return") { - self.reject(format!( - "`fn {name}` must be a single `return `, optionally preceded by \ - `props(…)`; L0 admits no other statement in a view (profile §2)" - )); - return None; - } - let body = self.element()?; - let _ = self.eat_punct("}"); - - // Reconcile. The bespoke surface cannot have this check, because - // there the parameter list IS the declaration; here they are - // separate, so redundancy that drifts must become redundancy that - // is checked. - if !params.is_empty() { - match declared { - None => self.reject(format!( - "`fn {name}` takes parameters but declares no `props(…)`; a prop with \ - no shape cannot be checked against profile §5.2" - )), - Some(shapes) => { - for p in ¶ms { - if !shapes.iter().any(|(n, _)| *n == p.name) { - self.reject(format!( - "`{name}` declares no shape for parameter {:?}", - p.name - )); - } - } - for (n, _) in &shapes { - if !params.iter().any(|p| p.name == *n) { - self.reject(format!( - "`{name}` declares a shape for {n:?}, which is not one of \ - its parameters" - )); - } - } - for p in &mut params { - if let Some((_, shape)) = shapes.iter().find(|(n, _)| *n == p.name) { - p.shape = shape.clone(); - } - } - } - } - } - - let capitalised = name.chars().next().is_some_and(|c| c.is_uppercase()); - if capitalised { - let mut instantiates = Vec::new(); - collect_constructors(&body, &mut instantiates); - Some(Declared::AsComponent(Component { - name, - params, - states: Vec::new(), - events: Vec::new(), - body, - line, - instantiates, - })) - } else { - Some(Declared::AsView(View { name, body })) - } - } - - fn peek_ident(&self, want: &str) -> bool { - self.t - .get(self.at) - .is_some_and(|t| t.kind == super::Kind::Ident && t.text == want) - } - - /// `props({story: "record", on_open: "event"})` - fn props(&mut self) -> Vec<(String, Shape)> { - let mut out = Vec::new(); - if !self.eat_punct("(") || !self.eat_punct("{") { - self.reject("props takes an object: props({name: \"shape\", …})".into()); - return out; - } - while !self.eat_punct("}") { - if self.at >= self.t.len() { - break; - } - let Some(name) = self.ident() else { - self.at += 1; - continue; - }; - if !self.eat_punct(":") { - continue; - } - let shape = match self.string().as_deref() { - Some("text") => Shape::Text, - Some("number") => Shape::Number, - Some("bool") => Shape::Bool, - Some("record") => Shape::Record, - Some("collection") => Shape::Collection, - Some("event") => Shape::Event, - Some(other) => { - self.reject(format!( - "{other:?} is not an L0 shape; L0 has text, number, bool, enum, \ - record, collection and event" - )); - Shape::Other - } - None => { - self.reject(format!("prop {name:?} declares no shape")); - Shape::Other - } - }; - out.push((name, shape)); - let _ = self.eat_punct(","); - } - let _ = self.eat_punct(")"); - out - } - - /// An element: `Ctor({args}, [children])`, with `{args}` and - /// `[children]` each optional, plus the three structural forms. - fn element(&mut self) -> Option { - let line = self.line(); - let column = self.t.get(self.at).map(|t| t.column).unwrap_or(1); - let name = self.ident()?; - - // Structural forms first — they are recognised by name, so a card - // cannot reach them by accident and cannot spell them another way. - match name.as_str() { - "each" => return self.each(line, column), - "when_is" | "when_not" => return self.guard(&name, line, column), - _ => {} - } - - if !self.eat_punct("(") { - // A bare name is a reference to another view. - return Some(Element { - name, - line, - column, - is_reference: true, - ..Default::default() - }); - } - - let mut args = Vec::new(); - let mut children = Vec::new(); - - // `{...}` arguments, then `[...]` children, in that order. - if self.eat_punct("{") { - while !self.eat_punct("}") { - if self.at >= self.t.len() { - break; - } - let aline = self.line(); - let acol = self.t.get(self.at).map(|t| t.column).unwrap_or(1); - let Some(key) = self.ident() else { - self.at += 1; - continue; - }; - if !self.eat_punct(":") { - continue; - } - if let Some(value) = self.operand() { - args.push(Arg { - name: key, - value, - line: aline, - column: acol, - }); - } - let _ = self.eat_punct(","); - } - let _ = self.eat_punct(","); - } - if self.eat_punct("[") { - while !self.eat_punct("]") { - if self.at >= self.t.len() { - break; - } - match self.element() { - Some(child) => children.push(child), - None => self.at += 1, - } - let _ = self.eat_punct(","); - } - } - let _ = self.eat_punct(")"); - - // A capitalised call with no catalogue entry is a component - // instantiation; the validator decides which, exactly as it does on - // the bespoke surface. - Some(Element { - name, - args, - children, - line, - column, - ..Default::default() - }) - } - - /// `each(feed, "id", StoryRow)` — collection, key field, body component. - fn each(&mut self, line: usize, column: usize) -> Option { - if !self.eat_punct("(") { - return None; - } - let path = self.dotted()?; - let _ = self.eat_punct(","); - let Some(key_field) = self.string() else { - self.reject("each names its key field as a string: each(xs, \"id\", Row)".into()); - return None; - }; - let _ = self.eat_punct(","); - let body = self.element()?; - let _ = self.eat_punct(")"); - - // The binder is implicit and named for the body it feeds, so a key - // path can be reconstructed the way the bespoke surface writes it. - let binder = "it".to_string(); - Some(Element { - name: "for".into(), - args: vec![Arg { - name: "collection".into(), - value: Operand::Path(path), - line, - column, - }], - children: vec![body], - binders: vec![binder.clone(), "i".into()], - key_path: Some(format!("{binder}.{key_field}")), - line, - column, - ..Default::default() - }) - } - - /// `when_is(path, "value", child)` / `when_not(...)` - fn guard(&mut self, form: &str, line: usize, column: usize) -> Option { - if !self.eat_punct("(") { - return None; - } - let path = self.dotted()?; - let _ = self.eat_punct(","); - let rhs = self.operand(); - let _ = self.eat_punct(","); - let child = self.element()?; - let _ = self.eat_punct(")"); - Some(Element { - name: "when".into(), - args: vec![Arg { - name: "predicate".into(), - value: Operand::Path(path), - line, - column, - }], - cmp: Some(if form == "when_is" { "==" } else { "!=" }.into()), - rhs, - children: vec![child], - line, - column, - ..Default::default() - }) - } - - fn dotted(&mut self) -> Option { - let mut path = self.ident()?; - while self.t.get(self.at).is_some_and(|n| n.is_punct(".")) - && self - .t - .get(self.at + 1) - .is_some_and(|n| n.kind == super::Kind::Ident) - { - path.push('.'); - path.push_str(&self.t[self.at + 1].text); - self.at += 2; - } - Some(path) - } - - /// An argument value. A string beginning with `.` is a TOKEN, keeping - /// §2.2's lexical marking on a surface whose carrier has no token form — - /// the identity codex flagged as lossy if both spell as plain strings. - fn operand(&mut self) -> Option { - let t = self.t.get(self.at)?.clone(); - match t.kind { - super::Kind::Str => { - self.at += 1; - Some(match t.text.strip_prefix('.') { - Some(bare) => Operand::Token(bare.to_string()), - None => Operand::Str(t.text), - }) - } - super::Kind::Num => { - self.at += 1; - Some(Operand::Num(t.text.parse().unwrap_or(0.0))) - } - super::Kind::Ident => self.dotted().map(Operand::Path), - _ => None, - } - } - - /// `copy("vocabulary", {en: "pts", zh: "分"})` - fn copy(&mut self, name: String) -> Option { - let provenance = match self.string().as_deref() { - Some("vocabulary") => Provenance::Vocabulary, - Some("user-copy") => Provenance::UserCopy, - Some("model-copy") => Provenance::ModelCopy, - Some(other) => { - self.reject(format!( - "{other:?} is not a copy class; L0 has vocabulary, user-copy and \ - model-copy (profile §4)" - )); - Provenance::ModelCopy - } - None => { - // Absent provenance is NOT vocabulary — §4 fails closed. - self.reject(format!( - "copy {name:?} declares no class; L0 needs one of vocabulary, \ - user-copy or model-copy (profile §4)" - )); - Provenance::ModelCopy - } - }; - let mut locales = Vec::new(); - if self.eat_punct(",") && self.eat_punct("{") { - while !self.eat_punct("}") { - if self.at >= self.t.len() { - break; - } - let Some(lang) = self.ident() else { - self.at += 1; - continue; - }; - if !self.eat_punct(":") { - continue; - } - if let Some(text) = self.string() { - locales.push((lang, text)); - } - let _ = self.eat_punct(","); - } - } - let _ = self.eat_punct(")"); - Some(CopyDecl { - name, - provenance, - locales, - }) - } - } -} - // ─────────────────────────────────────────────────────────── DSL lowering (§1.1) ── /// Lower a realized card to **Splash DSL**, the data form `splash-render` diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index d1f16ed..c8af77b 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3809,387 +3809,6 @@ fn realization_depth_is_bounded_independently_of_node_count() { ); } -// ─── the Splash-surface projector ──────────────────────────────────────────── -// -// L0 has its own lexer and parser and imports one type from the rest of the -// crate, so "a Splash profile" has described a philosophy rather than an -// implementation. These tests cover the second surface: a card written in -// Splash's own grammar, projected into the same declarations. - -use splash_core::ui_l0::splash_surface::project_declarations; - -const AS_SPLASH: &str = r#" -let lead = source("sys.news", {count: 1, fields: ["id", "title", "author"]}) -let feed = source("sys.news", {count: 7, offset: 1, fields: ["id", "title"]}) -let article = source("sys.news_item", {id: st.selected, fields: ["title"]}) -let selected = state("text", "") -let open_story = event({selected: set_payload()}) -let back = event({selected: clear()}) -let c_pts = copy("vocabulary", {en: "pts", zh: "分"}) -"#; - -#[test] -fn a_card_written_as_splash_projects_its_declarations() { - let report = project_declarations(AS_SPLASH); - assert!(report.is_clean(), "{:#?}", report.diagnostics); - - assert_eq!(report.source_names(), vec!["lead", "feed", "article"]); - assert_eq!( - report.source_helpers(), - vec!["sys.news", "sys.news", "sys.news_item"] - ); - assert_eq!(report.state_names(), vec!["selected"]); - assert_eq!(report.event_names(), vec!["open_story", "back"]); - assert_eq!(report.copy_names(), vec!["c_pts"]); -} - -/// The carrier and the profile are separate checks and neither subsumes the -/// other: the first says the VM could run it, the second says L0 will admit it. -#[test] -fn the_splash_surface_is_also_valid_splash() { - let report = splash_core::check_vm_compatibility(AS_SPLASH).expect("checker runs"); - assert!( - report.diagnostics.is_empty(), - "the surface must be real Splash: {:#?}", - report.diagnostics - ); -} - -/// A positive recogniser, not a denylist: a `let` bound to anything outside the -/// four declaration forms is refused rather than skipped. Skipping is how a card -/// declares a source the runtime never fetches. -#[test] -fn an_unrecognised_binding_is_refused() { - for (source, why) in [ - ("let x = compute(1, 2)", "an arbitrary call"), - ("let x = 41.2", "a bare literal"), - ("let x = sys.news(1)", "a helper called directly"), - ] { - let report = project_declarations(source); - assert!( - !report.is_clean(), - "{why} must be refused, not ignored: {source}" - ); - } -} - -#[test] -fn the_projector_refuses_a_non_total_transition() { - let report = project_declarations("let e = event({n: increment()})"); - assert!(!report.is_clean()); - assert!( - report - .diagnostics - .iter() - .any(|d| d.message.contains("not a total form")), - "{:#?}", - report.diagnostics - ); -} - -/// §4 fails closed on the Splash surface too — a copy with no class is refused -/// rather than trusted, which is the defect the bespoke surface shipped with. -#[test] -fn the_projector_refuses_copy_without_a_class() { - let report = project_declarations(r#"let c = copy({en: "Revenue: $41.2M"})"#); - assert!(!report.is_clean()); - assert!( - report - .diagnostics - .iter() - .any(|d| d.message.contains("class")), - "{:#?}", - report.diagnostics - ); -} - -// ─── views and components on the Splash surface ────────────────────────────── - -const VIEWS_AS_SPLASH: &str = r#" -let feed = source("sys.news", {count: 7, fields: ["id", "title"]}) -let selected = state("text", "") -let open = event({selected: set_payload()}) - -fn StoryRow(story, position, on_open) { - props({story: "record", position: "number", on_open: "event"}) - return Col([ - Row({align: ".center", on_tap: on_open, value: story.id}, [ - TextRow({text: position, width: ".rank"}), - TextRow({text: story.title, width: ".fill"}) - ]), - Rule() - ]) -} - -fn latest() { - return Panel([ - each(feed, "id", StoryRow) - ]) -} - -fn root() { - return Surface({pad: ".page"}, [ - when_is(selected, "", latest), - when_not(selected, "", latest) - ]) -} -"#; - -#[test] -fn views_and_components_project_from_the_splash_surface() { - let report = project_declarations(VIEWS_AS_SPLASH); - assert!(report.is_clean(), "{:#?}", report.diagnostics); - - assert_eq!(report.view_names(), vec!["latest", "root"]); - assert_eq!(report.component_names(), vec!["StoryRow"]); -} - -/// The gate that decides whether this is a second surface or a second language: -/// the same card, written both ways, must project to the same TREE. Compared by -/// shape rather than byte-for-byte, because spans differ whenever the formatting -/// does — the point codex made about "byte-identical Card" not being a usable -/// criterion. -#[test] -fn both_surfaces_project_the_same_tree() { - const BESPOKE: &str = r#" -source feed sys.news(count: 7, fields: [id, title]) -state selected { shape: text, initial: "" } -event open { selected: set($value) } - -component StoryRow(story: record, position: number, on_open: event) { - view Col { - Row(align: .center, on_tap: on_open, value: story.id) { - TextRow(text: position, width: .rank) - TextRow(text: story.title, width: .fill) - } - Rule() - } -} - -view latest Panel { - for it, i in feed key it.id { - StoryRow(story: it, position: i, on_open: open) - } - } - -view root Surface(pad: .page) { - when selected == "" { latest } - when selected != "" { latest } - } -"#; - - let splash = project_declarations(VIEWS_AS_SPLASH); - assert!(splash.is_clean(), "{:#?}", splash.diagnostics); - let bespoke = splash_core::ui_l0::project_bespoke(BESPOKE); - - for name in ["latest", "root"] { - assert_eq!( - splash.view_shape(name), - bespoke.view_shape(name), - "view {name:?} projects differently on the two surfaces" - ); - } - assert_eq!( - splash.component_shape("StoryRow"), - bespoke.component_shape("StoryRow"), - "the component projects differently on the two surfaces" - ); -} - -/// §5.2 depends on a prop's declared shape — event-vs-value confusion, enum -/// membership, defaults, and the readable/event scope split all consult it. A -/// Splash `fn` parameter carries no type, so the surface needs a declaration: -/// meaning is declared here, never inferred from a name. -#[test] -fn props_declare_their_shapes_on_the_splash_surface() { - let report = project_declarations( - r#" -fn StoryRow(story, position, on_open) { - props({story: "record", position: "number", on_open: "event"}) - return Col([ TextRow({text: story.title}) ]) -} -"#, - ); - assert!(report.is_clean(), "{:#?}", report.diagnostics); - assert_eq!( - report.prop_shapes("StoryRow"), - vec![ - ("story".to_string(), "record".to_string()), - ("position".to_string(), "number".to_string()), - ("on_open".to_string(), "event".to_string()), - ], - "each prop keeps the shape it declared" - ); -} - -/// The 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 must be caught — redundancy that is checked is a different thing -/// from redundancy that drifts. -#[test] -fn props_must_match_the_parameters_they_describe() { - for (source, why) in [ - ( - r#"fn F(a, b) { props({a: "text"}) return Rule() }"#, - "a parameter with no declared shape", - ), - ( - r#"fn F(a) { props({a: "text", b: "number"}) return Rule() }"#, - "a declared shape for no parameter", - ), - ] { - let report = project_declarations(source); - assert!(!report.is_clean(), "{why} must be refused: {source}"); - } -} - -#[test] -fn a_component_without_props_is_refused_when_it_takes_parameters() { - // Silence is not a declaration. A component with untyped parameters cannot - // be checked against §5.2 at all, so it is refused rather than admitted - // with unknown shapes — the same fail-closed rule as `copy` without a class. - let report = project_declarations(r#"fn F(a) { return TextRow({text: a}) }"#); - assert!(!report.is_clean()); - assert!( - report - .diagnostics - .iter() - .any(|d| d.message.contains("props")), - "{:#?}", - report.diagnostics - ); - - // A component with no parameters needs none. - let none = project_declarations(r#"fn F() { return Rule() }"#); - assert!(none.is_clean(), "{:#?}", none.diagnostics); -} - -/// Projection is not checking. `project_declarations` recognises shapes; the -/// SAME `validate()` must then run, or a declared prop shape is retained and -/// never consulted — the mirror of "specified but not retained". -/// -/// Caught because a deliberately wrong shape passed 180 tests: declaring -/// `on_open: "text"` and handing it to `on_tap` is exactly what §5.2 refuses, -/// and nothing was looking. -#[test] -fn the_splash_surface_runs_the_same_checks() { - let wrong = splash_core::ui_l0::check_ui_l0_splash( - r#" -fn F(on_open) { - props({on_open: "text"}) - return Row({on_tap: on_open}, [ Rule() ]) -} -"#, - ); - assert!( - !wrong.valid, - "a text prop handed to on_tap must be refused (§5.2)" - ); - - let right = splash_core::ui_l0::check_ui_l0_splash( - r#" -let go = event({n: clear()}) -let n = state("number", 0) -fn F(on_open) { - props({on_open: "event"}) - return Row({on_tap: on_open}, [ Rule() ]) -} -fn root() { return Surface({pad: ".page"}, [ F({on_open: go}) ]) } -"#, - ); - assert!(right.valid, "{:#?}", right.diagnostics); -} - -/// §4 travels to this surface too, unchanged. -#[test] -fn the_splash_surface_refuses_a_literal_in_a_data_position() { - let report = splash_core::ui_l0::check_ui_l0_splash( - r#"fn root() { return TempBar({lo: 5, hi: 9, min: 0, max: 10}) }"#, - ); - assert!(!report.valid, "an authored number must not reach TempBar"); -} - -// ─── §1.1: a card lowers to roles, never to a backend's colours ────────────── - -/// The split §1.1 settles: 16 roles are satisfied by the theme's component kit, -/// 6 are backend widgets because a fragment shader cannot be authored in the -/// DSL, and `Photo` is an image. Pinned as a test so the retarget has a target -/// and so the two groups cannot quietly merge. -#[test] -fn every_role_is_classified_for_lowering() { - const BACKEND_WIDGETS: &[&str] = &[ - "WeatherIcon", - "TempBar", - "SunArc", - "MoonPhase", - "AqiContour", - "StockPlot", - ]; - const IMAGE: &[&str] = &["Photo"]; - - let all: Vec<&str> = catalog::CONSTRUCTORS.iter().map(|(n, _)| *n).collect(); - let themed: Vec<&str> = all - .iter() - .copied() - .filter(|n| !BACKEND_WIDGETS.contains(n) && !IMAGE.contains(n)) - .collect(); - - assert_eq!( - themed.len() + BACKEND_WIDGETS.len() + IMAGE.len(), - all.len(), - "every catalogued role must fall in exactly one group" - ); - assert_eq!( - BACKEND_WIDGETS.len(), - 6, - "the shader-backed set is closed; adding one is a porting cost on every backend" - ); - - // A backend widget takes only bound paths. That is what keeps §1.1 true for - // them: the card supplies data, the backend supplies every pixel. - for name in BACKEND_WIDGETS { - let args = catalog::lookup(name).unwrap_or_else(|| panic!("{name} is not catalogued")); - for (arg, kind) in args { - assert!( - matches!( - kind, - catalog::ArgKind::Path | catalog::ArgKind::Token(_) | catalog::ArgKind::Data - ), - "{name}.{arg} must be bound data or a token, not authored presentation" - ); - } - } -} - -/// §1.1's actual prohibition, as a test rather than a paragraph: a card must -/// have no way to state a colour, a size in pixels, or a font. -#[test] -fn no_role_accepts_presentation() { - for (ctor, args) in catalog::CONSTRUCTORS { - for (arg, _) in *args { - assert!( - !matches!( - *arg, - "color" - | "colour" - | "bg" - | "fill" - | "font" - | "font_size" - | "weight" - | "radius" - | "elevation" - | "shadow" - | "padding" - | "margin" - ), - "{ctor}.{arg} is presentation; §1.1 says a card names roles and the theme \ - decides appearance" - ); - } - } -} - // ─── §1.1 lowering: a card becomes Splash DSL, not a backend dialect ───────── /// The retarget. A card lowers to the DSL data form `{t: "…", …}` that From 51c6d2a9af91eb432afaae0c33ff0ff50556cdf5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:33:49 -0700 Subject: [PATCH 57/63] =?UTF-8?q?revert(ui=5Fl0):=20remove=20lower=5Fdsl?= =?UTF-8?q?=20=E2=80=94=20it=20asserted=20a=20contract=20nobody=20had=20ch?= =?UTF-8?q?ecked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-core/src/ui_l0.rs | 111 -------------------- crates/splash-core/tests/ui_profile_l0.rs | 120 +++++----------------- docs/ui-l0-constructors.toml | 15 ++- docs/ui-profile-l0.md | 76 ++++++++------ 4 files changed, 83 insertions(+), 239 deletions(-) diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-core/src/ui_l0.rs index 9622a01..9b61087 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-core/src/ui_l0.rs @@ -5357,114 +5357,3 @@ fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec String { - let mut out = String::new(); - out.push_str("// REALIZED from an L0 ledger — do not edit.\n"); - emit(root, 0, &mut out); - out.push('\n'); - out -} - -/// The node kind a role lowers to, and the role name it carries when several -/// roles share a kind. -fn kind_of(role: &str) -> (&'static str, Option<&'static str>) { - match role { - // ── containers and structure ──────────────────────────────────────── - "Surface" => ("column", Some("surface")), - "Panel" => ("card", Some("panel")), - "Card" => ("card", Some("elevated")), - "Col" => ("column", None), - "Row" => ("row", None), - "Grid" => ("grid", None), - "Rule" => ("divider", None), - "Tile" => ("listitem", None), - "Chip" => ("chip", None), - "Photo" => ("image", None), - - // ── text roles: one kind, seven roles ─────────────────────────────── - "TextHero" => ("text", Some("hero")), - "TextTitle" => ("text", Some("title")), - "TextBody" => ("text", Some("body")), - "TextStat" => ("text", Some("stat")), - "TextRow" => ("text", Some("row")), - "TextCaption" => ("text", Some("caption")), - "TextValue" => ("text", Some("value")), - - // ── data visualisations: backend widgets (§1.1) ───────────────────── - "WeatherIcon" => ("weathericon", None), - "TempBar" => ("tempbar", None), - "SunArc" => ("sunarc", None), - "MoonPhase" => ("moonphase", None), - "AqiContour" => ("aqicontour", None), - "StockPlot" => ("stockplot", None), - - // An unmapped role is emitted as itself. A backend that does not know it - // should fail visibly; silently dropping a section is how a card looks - // finished and is not. - other => (Box::leak(other.to_lowercase().into_boxed_str()), None), - } -} - -fn emit(node: &UiNode, depth: usize, out: &mut String) { - use std::fmt::Write as _; - let pad = " ".repeat(depth); - let (kind, role) = kind_of(&node.kind); - - let _ = write!(out, "{pad}{{t: {kind:?}"); - if let Some(role) = role { - let _ = write!(out, ", role: {role:?}"); - } - - for (name, value) in &node.args { - // `on_tap` and its payload are interaction, not presentation, and the - // key is what lets a tap name WHICH instance was hit. - let _ = match value { - NodeValue::Text(s) => write!(out, ", {name}: {s:?}"), - NodeValue::Number(n) => write!(out, ", {name}: {}", makepad::trim_num(*n)), - NodeValue::Bool(b) => write!(out, ", {name}: {b}"), - NodeValue::Token(t) => write!(out, ", {name}: {t:?}"), - NodeValue::Event(e) => write!(out, ", {name}: {e:?}"), - NodeValue::Status(s) => write!(out, ", {name}: {:?}", s.as_token()), - // An unresolved binding is emitted as such rather than as an em - // dash: the theme decides how absence looks, and a dash baked in - // here would be presentation. - NodeValue::Missing => write!(out, ", {name}: null"), - }; - } - if !node.key.is_empty() { - let _ = write!(out, ", key: {:?}", node.key); - } - - if node.children.is_empty() { - out.push_str("}\n"); - return; - } - out.push_str(", c: [\n"); - for child in &node.children { - emit(child, depth + 1, out); - } - let _ = writeln!(out, "{pad}]}}"); -} diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-core/tests/ui_profile_l0.rs index c8af77b..0f4b89f 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-core/tests/ui_profile_l0.rs @@ -3809,97 +3809,33 @@ fn realization_depth_is_bounded_independently_of_node_count() { ); } -// ─── §1.1 lowering: a card becomes Splash DSL, not a backend dialect ───────── - -/// The retarget. A card lowers to the DSL data form `{t: "…", …}` that -/// `splash-render` evaluates into a `UiNode`, from which all three backends -/// render. It does NOT lower to makepad's widget dialect, which reaches one -/// backend and carries a theme. -#[test] -fn lowering_emits_splash_dsl_not_a_backend_dialect() { - let dsl = splash_core::ui_l0::lower_dsl( - &realize( - NEWS, - &serde_json::json!({ - "lead": [{"id": "a", "title": "T", "author": "A", "points": 1, "comments": 2}], - "feed": [{"id": "b", "title": "U", "author": "B", "points": 3}], - "article": {}, "selected": "", "env": {"locale": {}} - }), - RealizeLimits::default(), - ) - .root - .unwrap(), - ); - - assert!( - dsl.contains("{t: \""), - "the output must be the DSL data form: {}", - &dsl[..dsl.len().min(200)] - ); - for backend_ism in [ - "SolidView", - "RoundedView", - "draw_bg.color", - "draw_text.color", - ] { - assert!( - !dsl.contains(backend_ism), - "{backend_ism:?} is makepad's dialect; §1.1 says a card names roles" - ); - } - // No colour may appear — that was the ten-constant table. Matched as a hex - // literal rather than a bare '#', because an instance key legitimately - // contains one: `root/for#0[b]/StoryRow#0`. - let colour = dsl - .match_indices('#') - .find(|(i, _)| dsl[i + 1..].chars().take(6).all(|c| c.is_ascii_hexdigit())); - assert!( - colour.is_none(), - "a lowered card must carry no colour: §1.1 leaves that to the theme ({colour:?})" - ); -} - -/// Seven text roles collapse onto one node kind carrying the role, because the -/// theme decides what a hero looks like. The role must survive, or the theme -/// has nothing to decide with. -#[test] -fn text_roles_lower_to_text_carrying_the_role() { - let dsl = splash_core::ui_l0::lower_dsl( - &realize( - "view root Col { TextHero(text: \"x\") TextCaption(text: \"y\") }", - &serde_json::json!({}), - RealizeLimits::default(), - ) - .root - .unwrap(), - ); - assert!(dsl.contains("t: \"text\""), "{dsl}"); - assert!( - dsl.contains("role: \"hero\""), - "the role must survive: {dsl}" - ); - assert!(dsl.contains("role: \"caption\""), "{dsl}"); -} - -/// The six shader-backed roles keep their own node kind — they are backend -/// widgets, and their data must reach the backend intact. -#[test] -fn a_data_visualisation_lowers_to_its_own_kind_with_its_data() { - let dsl = splash_core::ui_l0::lower_dsl( - &realize( - "source w sys.weather(lat: 1.0, lon: 2.0)\n\ - view root TempBar(lo: w.lo, hi: w.hi, min: w.min, max: w.max)", - &serde_json::json!({"w": {"lo": 12, "hi": 21, "min": 10, "max": 24}}), - RealizeLimits::default(), - ) - .root - .unwrap(), - ); - assert!(dsl.contains("t: \"tempbar\""), "{dsl}"); - for v in ["12", "21", "10", "24"] { - assert!( - dsl.contains(v), - "the bound value {v} must reach the backend: {dsl}" - ); +/// §1.1 as a check rather than a paragraph: a card must have no way to state a +/// colour, a size in pixels, or a font. The principle survives the revert of the +/// lowering that was meant to implement it — a card naming roles rather than +/// appearance is right regardless of what consumes the result. +#[test] +fn no_role_accepts_presentation() { + for (ctor, args) in catalog::CONSTRUCTORS { + for (arg, _) in *args { + assert!( + !matches!( + *arg, + "color" + | "colour" + | "bg" + | "fill" + | "font" + | "font_size" + | "weight" + | "radius" + | "elevation" + | "shadow" + | "padding" + | "margin" + ), + "{ctor}.{arg} is presentation; §1.1 says a card names roles and the theme \ + decides appearance" + ); + } } } diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 0f11763..4808e2b 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -119,11 +119,16 @@ active = { kind = "bool" } # a predicate is a bool operand # the class of bug that put an invented `condition` and invented `wmin`/`wmax` # into shipped cards. # -# These six are BACKEND WIDGETS, not theme compositions (profile §1.1). A -# gradient bar or a sun arc is a fragment shader parameterised by data, and -# `splash-render` notes that a DSL node cannot carry shader source — MPSL -# compiles at build time. So no theme file could contain one, and each backend -# implements them, exactly as it already does for `Map`. +# These six cannot be theme compositions (profile §1.1): a gradient bar or a sun +# arc is a fragment shader parameterised by data, and `splash-render` notes that +# a DSL node cannot carry shader source, because MPSL compiles at build time. +# +# HOW they reach a backend is unsettled. An earlier version of this comment said +# each backend implements them "exactly as it already does for Map", which +# asserted a contract that does not exist: five of the six are absent from +# splash-render's tag table, and its fallback drops the node. Six of six are +# implemented in octos-one today and one in Splash-Makepad, so the pipeline +# should be proven there before the vocabulary is fixed. # # The card still names only a role and its data. Colour, thickness and cap shape # remain the backend's, so §1.1 holds here as everywhere else. diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index b8303d6..223de5a 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -61,35 +61,35 @@ codes do not. It also keeps the three backends honest. The branch point is `UiNode`; anything that lowers past it reaches one platform and silently abandons the other two. -**Some roles are a backend widget, not a theme composition.** Six of the catalogue — +**Some roles cannot be a theme composition.** Six of the catalogue — `WeatherIcon`, `TempBar`, `SunArc`, `MoonPhase`, `AqiContour`, `StockPlot` — are small data -visualisations, and they cannot be authored in the DSL at all. `splash-render` states the -reason plainly: *a DSL node cannot carry shader source — MPSL compiles at build time — but it -can select a shader that was compiled.* A gradient bar or a sun arc is a fragment shader -parameterised by data, not a composition of boxes and text, so there is no theme file that -could contain one. - -They are therefore **backend widgets named semantically**, exactly as `splash-render` already -treats `Map` — *"a real map is not a platform view here, it is a widget"* — and `Shader`, -`Sdf`, `Fab` and `Segmented`. This is not an exception carved out for L0; it is the category -the renderer already has. - -§1.1 still holds for them, and holds without apology. A card writes - -``` -TempBar(lo: item.lo, hi: item.hi, min: week.min_lo, max: week.max_hi) -``` - -which is four bound paths and no presentation whatsoever. What colour the gradient runs, how -thick the bar is, whether its caps are rounded — every one of those stays the backend's. The -card names a role. That role simply happens to be one only a shader can fill. - -**The cost, stated rather than buried:** every backend must implement all six, or a card using -one renders blank on that platform. That is a real porting burden, and it is the strongest -argument against admitting them. It is accepted because the alternative is worse: pre-rendering -them to images server-side would make a live value into a fact fetched once, which is precisely -what §4 exists to prevent. A backend that has not implemented a role should fail visibly rather -than draw nothing. +visualisations. `splash-render` states why they cannot be authored in the DSL: *a DSL node +cannot carry shader source — MPSL compiles at build time — but it can select a shader that was +compiled.* A gradient bar is a fragment shader parameterised by data, not a composition of +boxes and text. + +**How they reach a backend is unsettled, and a previous version of this section settled it +wrongly.** It declared six new node kinds and asserted that `splash-render` would route them. +Five of the six are not in that consumer's tag table, and its fallback is to return `None` — +so a card's temperature bars, sun arc, moon phase and air-quality field would have been +dropped without a diagnostic. The section asserted a cross-repository contract that nobody had +checked, in the same breath as claiming an unknown role should "fail visibly". + +What is true and what is not: + +- **True:** these six are widget-layer concerns, and each backend's widget layer is its own. + `TempBar` is a makepad widget with an MPSL shader; an OpenHarmony equivalent is an ArkUI + custom component; an Android one is a Canvas view. There is no shared implementation to + unify — only a shared name and data contract. +- **Not settled:** what that contract is. `Shader`/`Sdf` with a variant is the mechanism the + consumer already documents and was dismissed too quickly; adding kinds is the alternative + and obliges every backend at once. + +**The order this implies.** Prove the pipeline end to end on the backend that already +implements these widgets — six of six exist and ship in octos-one today, against one in +`Splash-Makepad` — and let the vocabulary be whatever that requires. Then port to the others +against a contract that has shipped once. Defining the contract first is defining it for three +implementations, two of which do not exist, which is how the previous attempt went wrong. **The implementation does not yet do this.** `splash-core`'s `makepad::lower` emits makepad's widget dialect directly, with ten hardcoded colours and a font-size ramp — so it bypasses the @@ -97,9 +97,23 @@ DSL, the VM, `UiNode` and two backends, and it puts a theme inside the crate who deciding whether a card is safe. That is a defect measured against this section, and §9 lists it as such. -The retarget it implies is therefore a clean split rather than a judgement call: **16 roles -lower to the theme's component kit, 6 lower to a backend widget node, and `Photo` is already an -image.** +**A first attempt at this retarget was reverted**, and its failures are recorded here because +they are the specification's failures rather than the code's. It mapped roles to consumer tags +that were never checked against the consumer: five of 23 did not exist, `role:` was emitted +where the consumer reads `variant`, `Photo` became an image node although the weather card uses +it as the *container* around the whole card, `Tile` became a `listitem` whose fields are +label/text rather than value/unit, and string tokens landed in numeric properties as zero. The +consumer holds a fixed attribute struct rather than an open bag, so roughly twenty emitted +attributes were simply unread. + +Two lessons belong in the profile rather than in a commit message. **A lowering target is a +contract with a consumer, and this repository has no dependency on that consumer** — so +nothing prevents the two schemas drifting, and they did, immediately. And **a lowering must +carry semantics forward, not merely drop presentation**: the reverted attempt correctly removed +a colour palette and a font-size ramp, and incorrectly removed the interaction contract, a +chip's selected state, and formatting intent. `tint` is the instructive case — red-versus-green +is presentation, but *"this value is negative"* is meaning, and deleting the attribute lost +both. What that changes: From a3de3e7214063b6a6ce6c68d5f1845e5246c61e0 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:16:56 -0700 Subject: [PATCH 58/63] =?UTF-8?q?refactor:=20extract=20splash-ui-l0=20?= =?UTF-8?q?=E2=80=94=20L0=20no=20longer=20needs=20a=20VM=20to=20be=20adopt?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 8 + Cargo.toml | 1 + crates/splash-core/Cargo.toml | 1 + crates/splash-core/src/lib.rs | 9 +- crates/splash-ui-l0/Cargo.toml | 21 ++ .../examples/dirty_set.rs | 2 +- .../examples/lower_l0.rs | 2 +- .../examples/source_plan.rs | 2 +- .../examples/tap_l0.rs | 2 +- .../src/ui_l0.rs => splash-ui-l0/src/lib.rs} | 186 +++++++++++------- .../tests/fixtures/news.card | 0 .../tests/fixtures/stock.card | 0 .../tests/fixtures/trip_planner.splash | 0 .../tests/fixtures/weather.card | 0 .../tests/profile.rs} | 180 +++++++---------- mutate.py | 4 +- 16 files changed, 232 insertions(+), 186 deletions(-) create mode 100644 crates/splash-ui-l0/Cargo.toml rename crates/{splash-core => splash-ui-l0}/examples/dirty_set.rs (93%) rename crates/{splash-core => splash-ui-l0}/examples/lower_l0.rs (95%) rename crates/{splash-core => splash-ui-l0}/examples/source_plan.rs (97%) rename crates/{splash-core => splash-ui-l0}/examples/tap_l0.rs (98%) rename crates/{splash-core/src/ui_l0.rs => splash-ui-l0/src/lib.rs} (96%) rename crates/{splash-core => splash-ui-l0}/tests/fixtures/news.card (100%) rename crates/{splash-core => splash-ui-l0}/tests/fixtures/stock.card (100%) rename crates/{splash-core => splash-ui-l0}/tests/fixtures/trip_planner.splash (100%) rename crates/{splash-core => splash-ui-l0}/tests/fixtures/weather.card (100%) rename crates/{splash-core/tests/ui_profile_l0.rs => splash-ui-l0/tests/profile.rs} (95%) diff --git a/Cargo.lock b/Cargo.lock index 2694b69..4e15d13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -734,6 +734,7 @@ version = "0.1.0" dependencies = [ "makepad-script", "serde_json", + "splash-ui-l0", ] [[package]] @@ -812,6 +813,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "splash-ui-l0" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "splash-worker" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8b28598..9b244fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/splash-core", + "crates/splash-ui-l0", "crates/splash-linux-seccomp", "crates/splash-linux-project-quota", "crates/splash-capabilities", diff --git a/crates/splash-core/Cargo.toml b/crates/splash-core/Cargo.toml index 9e6ec54..8ef596c 100644 --- a/crates/splash-core/Cargo.toml +++ b/crates/splash-core/Cargo.toml @@ -10,5 +10,6 @@ description = "Bounded, host-neutral Splash VM runtime" workspace = true [dependencies] +splash-ui-l0 = { path = "../splash-ui-l0" } makepad-script = { path = "../../vendor/makepad/platform/script" } serde_json = "1.0.150" diff --git a/crates/splash-core/src/lib.rs b/crates/splash-core/src/lib.rs index a6a078c..c8fedf5 100644 --- a/crates/splash-core/src/lib.rs +++ b/crates/splash-core/src/lib.rs @@ -8,7 +8,14 @@ //! Rust code. mod profile; -pub mod ui_l0; +/// The UI profile, re-exported so existing paths keep working. +/// +/// It lives in `splash-ui-l0` now: it depends on `serde_json` alone, and an +/// application carrying a different makepad lineage cannot depend on THIS crate +/// at all — Cargo refuses the lockfile, since both ship `makepad-error-log +/// v1.0.0` at different paths. L0 never needed the vendored VM; it inherited it +/// by living here. +pub use splash_ui_l0 as ui_l0; use std::any::Any; use std::cell::Cell; diff --git a/crates/splash-ui-l0/Cargo.toml b/crates/splash-ui-l0/Cargo.toml new file mode 100644 index 0000000..9bc3a25 --- /dev/null +++ b/crates/splash-ui-l0/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "splash-ui-l0" +version = "0.1.0" +edition = "2021" +description = "UI Profile Level 0: check, realize and dispatch LLM-authored Splash cards" +license = "MIT OR Apache-2.0" + +# serde_json and nothing else — deliberately. +# +# L0 has no expression form, so there is nothing to evaluate and no VM in the +# path. Keeping the dependency list at one crate is what makes that claim +# checkable rather than asserted, and what lets a host adopt L0 without adopting +# a runtime: `splash-core` depends on a vendored makepad-script, and any app +# already carrying a different makepad lineage cannot depend on it at all — +# Cargo rejects the lockfile, because both ship `makepad-error-log v1.0.0` at +# different paths. That collision is the reason this crate exists. +[dependencies] +serde_json = "1" + +[dev-dependencies] +serde_json = "1" diff --git a/crates/splash-core/examples/dirty_set.rs b/crates/splash-ui-l0/examples/dirty_set.rs similarity index 93% rename from crates/splash-core/examples/dirty_set.rs rename to crates/splash-ui-l0/examples/dirty_set.rs index ed65217..e56475d 100644 --- a/crates/splash-core/examples/dirty_set.rs +++ b/crates/splash-ui-l0/examples/dirty_set.rs @@ -2,7 +2,7 @@ //! //! Usage: dirty_set … -use splash_core::ui_l0::{dirty_records, patch_points, record_dependencies}; +use splash_ui_l0::{dirty_records, patch_points, record_dependencies}; fn main() { let mut a = std::env::args().skip(1); diff --git a/crates/splash-core/examples/lower_l0.rs b/crates/splash-ui-l0/examples/lower_l0.rs similarity index 95% rename from crates/splash-core/examples/lower_l0.rs rename to crates/splash-ui-l0/examples/lower_l0.rs index 5fcf55e..01c8b00 100644 --- a/crates/splash-core/examples/lower_l0.rs +++ b/crates/splash-ui-l0/examples/lower_l0.rs @@ -5,7 +5,7 @@ //! The data stands in for what the runtime would have fetched. Realization is a //! tree walk, so this never evaluates anything and reaches nothing. -use splash_core::ui_l0::{makepad, realize, RealizeLimits}; +use splash_ui_l0::{makepad, realize, RealizeLimits}; fn main() { let mut args = std::env::args().skip(1); diff --git a/crates/splash-core/examples/source_plan.rs b/crates/splash-ui-l0/examples/source_plan.rs similarity index 97% rename from crates/splash-core/examples/source_plan.rs rename to crates/splash-ui-l0/examples/source_plan.rs index a1cb3ff..a993c92 100644 --- a/crates/splash-core/examples/source_plan.rs +++ b/crates/splash-ui-l0/examples/source_plan.rs @@ -5,7 +5,7 @@ //! splash-core says WHAT to fetch and in what order; it never fetches. Only a //! host knows what answers `sys.weather`. -use splash_core::ui_l0::{source_plan, SourceArg}; +use splash_ui_l0::{source_plan, SourceArg}; fn arg_json(a: &SourceArg) -> String { match a { diff --git a/crates/splash-core/examples/tap_l0.rs b/crates/splash-ui-l0/examples/tap_l0.rs similarity index 98% rename from crates/splash-core/examples/tap_l0.rs rename to crates/splash-ui-l0/examples/tap_l0.rs index 28bd079..a8e5571 100644 --- a/crates/splash-core/examples/tap_l0.rs +++ b/crates/splash-ui-l0/examples/tap_l0.rs @@ -5,7 +5,7 @@ //! Proves the loop end to end without a UI: state lives in the store, the event //! is a declared name, and the second realization reflects the write. -use splash_core::ui_l0::{ +use splash_ui_l0::{ dispatch_with, makepad, realize_with_state, InstanceStore, RealizeLimits, }; diff --git a/crates/splash-core/src/ui_l0.rs b/crates/splash-ui-l0/src/lib.rs similarity index 96% rename from crates/splash-core/src/ui_l0.rs rename to crates/splash-ui-l0/src/lib.rs index 9b61087..7732b90 100644 --- a/crates/splash-core/src/ui_l0.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -1,3 +1,26 @@ +//! **UI Profile Level 0** — a canonical Splash profile for LLM-authored UI. +//! +//! A generated card is untrusted source. L0 admits UI constructs while admitting +//! nothing that can reach a capability: constructors, bindings, keyed loops, +//! guarded branches, and components with declared local state, and **no +//! expression form at all**. With nothing to evaluate, realization is a walk +//! over the parsed tree with data substituted, and this crate contains no +//! reference to a VM. Confinement is structural rather than sandboxed. +//! +//! The normative profile is `docs/ui-profile-l0.md`; the closed constructor and +//! capability catalog is `docs/ui-l0-constructors.toml`. Where this code and +//! those documents disagree, the documents win — and a test asserts the catalog +//! agrees with the Rust table in both directions. +//! +//! # Why this is its own crate +//! +//! It depends on `serde_json` and nothing else, so any host can adopt L0 without +//! adopting a runtime. That is not a stylistic preference: `splash-core` carries +//! a vendored `makepad-script`, and an application already using a different +//! makepad lineage cannot depend on it — Cargo refuses the lockfile, because +//! both ship `makepad-error-log v1.0.0` at different paths. L0 never needed +//! that dependency; it inherited it by living in the same crate. +//! //! Level 0 of the UI profile — parser and validator for generated card source. //! //! See [`docs/ui-profile-l0.md`](../../../docs/ui-profile-l0.md) for the normative @@ -20,7 +43,20 @@ //! //! Everything is effect-free and bounded: no evaluation, no imports, no host access. -use crate::{SyntaxDiagnostic, DEFAULT_MAX_SOURCE_BYTES, DEFAULT_MAX_SYNTAX_NESTING}; +/// One one-based location. Mirrors `splash_core::SyntaxDiagnostic` in shape; +/// separate so this crate stands alone. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SyntaxDiagnostic { + pub line: usize, + pub column: usize, + pub message: String, +} +/// Largest source this profile will read. Matches `splash-core`'s bound, so a +/// card refused there is refused here for the same reason. +pub const DEFAULT_MAX_SOURCE_BYTES: usize = 256 * 1024; +/// Maximum delimiter nesting. Bounds parse recursion — see the deep-path tests, +/// which exist because unbounded recursion aborted the process on a 60 KB card. +pub const DEFAULT_MAX_SYNTAX_NESTING: usize = 128; /// The narrowest profile a source belongs to. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -812,7 +848,7 @@ struct Param { /// at all and lookup fell through to top-level injected data — so an /// unfilled prop could capture whatever the host happened to supply under /// the same name. - default: Option, + default: Option, } #[derive(Debug)] @@ -822,7 +858,7 @@ struct StateDecl { /// The declared `initial`. Parsed rather than discarded: without it a guard /// on a freshly-mounted state compares against null and takes the wrong /// branch on first render. - initial: Option, + initial: Option, /// A path-valued `initial`, e.g. `initial: env.locale.temp_unit`. Held /// separately because it resolves against injected data at realization, /// not at parse. Dropping it made weather.card fall back to the enum's @@ -1366,13 +1402,15 @@ impl<'a> Parser<'a> { "initial" => { if let Some(v) = value { initial = match v.kind { - Kind::Str => Some(crate::JsonValue::String(v.text.clone())), - Kind::Token => Some(crate::JsonValue::String( + Kind::Str => Some(serde_json::Value::String(v.text.clone())), + Kind::Token => Some(serde_json::Value::String( v.text.trim_start_matches('.').to_string(), )), - Kind::Num => v.text.parse::().ok().map(crate::JsonValue::from), + Kind::Num => { + v.text.parse::().ok().map(serde_json::Value::from) + } Kind::Ident if v.text == "true" || v.text == "false" => { - Some(crate::JsonValue::Bool(v.text == "true")) + Some(serde_json::Value::Bool(v.text == "true")) } Kind::Ident => { let mut path = v.text.clone(); @@ -1688,21 +1726,21 @@ impl<'a> Parser<'a> { if self.tokens.get(at).is_some_and(|t| t.is_punct("=")) { if let Some(v) = self.tokens.get(at + 1) { default = match v.kind { - Kind::Str => Some(crate::JsonValue::String(v.text.clone())), - Kind::Token => Some(crate::JsonValue::String( + Kind::Str => Some(serde_json::Value::String(v.text.clone())), + Kind::Token => Some(serde_json::Value::String( v.text.trim_start_matches('.').to_string(), )), Kind::Num => { - v.text.parse::().ok().map(crate::JsonValue::from) + v.text.parse::().ok().map(serde_json::Value::from) } Kind::Ident if v.text == "true" || v.text == "false" => { - Some(crate::JsonValue::Bool(v.text == "true")) + Some(serde_json::Value::Bool(v.text == "true")) } // A bare enum member: `unit: enum[c, f] = c`, // which is how §5.2 spells it and which // produced no default at all. Kind::Ident if matches!(shape, Shape::Enum(_)) => { - Some(crate::JsonValue::String(v.text.clone())) + Some(serde_json::Value::String(v.text.clone())) } _ => None, }; @@ -2068,18 +2106,18 @@ impl<'a> Parser<'a> { /// Follow a dotted path into injected data. Used for a path-valued `initial`, /// which resolves against what the host supplied rather than at parse time. /// A literal operand's value. -fn literal_of(operand: &Operand) -> crate::JsonValue { +fn literal_of(operand: &Operand) -> serde_json::Value { match operand { - Operand::Str(t) => crate::JsonValue::String(t.clone()), - Operand::Num(n) => crate::JsonValue::from(*n), - Operand::Token(t) => crate::JsonValue::String(t.trim_start_matches('.').to_string()), + Operand::Str(t) => serde_json::Value::String(t.clone()), + Operand::Num(n) => serde_json::Value::from(*n), + Operand::Token(t) => serde_json::Value::String(t.trim_start_matches('.').to_string()), Operand::Path(p) | Operand::Predicate { path: p, .. } => { - crate::JsonValue::String(p.clone()) + serde_json::Value::String(p.clone()) } } } -fn data_path(data: &crate::JsonValue, path: &str) -> Option { +fn data_path(data: &serde_json::Value, path: &str) -> Option { let mut current = data.clone(); for segment in path.split('.') { current = match segment.parse::() { @@ -2090,14 +2128,14 @@ fn data_path(data: &crate::JsonValue, path: &str) -> Option { Some(current) } -fn scope_value(scope: &ValueScope, operand: &Operand) -> Option { +fn scope_value(scope: &ValueScope, operand: &Operand) -> Option { match operand { Operand::Path(p) => scope.lookup(p), - Operand::Token(t) => Some(crate::JsonValue::String( + Operand::Token(t) => Some(serde_json::Value::String( t.trim_start_matches('.').to_string(), )), - Operand::Str(s) => Some(crate::JsonValue::String(s.clone())), - Operand::Num(n) => Some(crate::JsonValue::from(*n)), + Operand::Str(s) => Some(serde_json::Value::String(s.clone())), + Operand::Num(n) => Some(serde_json::Value::from(*n)), // A nested comparison is not in the grammar. Operand::Predicate { .. } => None, } @@ -2105,7 +2143,7 @@ fn scope_value(scope: &ValueScope, operand: &Operand) -> Option, cmp: &str, right: Option) -> bool { +fn compare(left: Option, cmp: &str, right: Option) -> bool { let (Some(left), Some(right)) = (left, right) else { // An operand that did not resolve cannot be compared. `!=` used to // return true here, so a guard against an UNDECLARED name took its @@ -2550,12 +2588,12 @@ fn shape_name(shape: &Shape) -> &'static str { /// mounted a string into a numeric cell before any event ran. /// Whether a literal agrees with the shape it was declared alongside. Shared by /// state initials and parameter defaults so the two cannot drift. -fn value_fits_shape(shape: &Shape, value: &crate::JsonValue) -> bool { +fn value_fits_shape(shape: &Shape, value: &serde_json::Value) -> bool { match (shape, value) { - (Shape::Text, crate::JsonValue::String(_)) => true, + (Shape::Text, serde_json::Value::String(_)) => true, (Shape::Number, v) => v.is_number(), - (Shape::Bool, crate::JsonValue::Bool(_)) => true, - (Shape::Enum(members), crate::JsonValue::String(s)) => members.iter().any(|m| m == s), + (Shape::Bool, serde_json::Value::Bool(_)) => true, + (Shape::Enum(members), serde_json::Value::String(s)) => members.iter().any(|m| m == s), // A shape that names no literal form, or one we could not classify. (Shape::Record | Shape::Collection | Shape::Event | Shape::Other, _) => true, _ => false, @@ -2854,8 +2892,10 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { /// This walks COMPONENTS AND VIEWS as one graph. Walking components alone /// missed two shapes that both recurse forever at realization: /// -/// view a b view b a (two views referencing each other) -/// component A { view v } view v { A() } (a component via a view) +/// ```text +/// view a b view b a two views referencing each other +/// component A { view v } view v { A() } a component via a view +/// ``` /// /// Both passed the old check and recursed until the node cap tripped, which is /// a resource bound catching a structural error — and only because the caller @@ -3393,8 +3433,10 @@ fn check_arg( /// Without this, §4 is enforced only at the literal's immediate call site, and /// laundering it through one prop defeats it entirely: /// -/// component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) } -/// view root Bar(n: 41.2) +/// ```text +/// component Bar(n: number) { view TempBar(lo: n, hi: n, min: n, max: n) } +/// view root Bar(n: 41.2) +/// ``` /// /// Computed to a fixpoint so passing the value through a chain of components is /// no better a hiding place than passing it directly. @@ -3692,13 +3734,13 @@ pub struct RealizeReport { /// values and copy, keyed by declaration name. **No capability is reachable from /// here** — not because one is blocked, but because realization is a tree walk /// and there is no evaluator to reach anything with. -pub fn realize(source: &str, data: &crate::JsonValue, limits: RealizeLimits) -> RealizeReport { +pub fn realize(source: &str, data: &serde_json::Value, limits: RealizeLimits) -> RealizeReport { realize_inner(source, data, None, limits) } fn realize_inner( source: &str, - data: &crate::JsonValue, + data: &serde_json::Value, store: Option<&InstanceStore>, limits: RealizeLimits, ) -> RealizeReport { @@ -3755,7 +3797,7 @@ fn realize_inner( // then its declared initial. Without this, an event that wrote card state // would apply and be invisible at the next realization — and a guard on a // state with no value at all would take the wrong branch on first render. - let mut frames: Vec<(String, crate::JsonValue)> = Vec::new(); + let mut frames: Vec<(String, serde_json::Value)> = Vec::new(); for state in &card.states { let value = store .and_then(|s| s.get(CARD_STATE_KEY, &state.path)) @@ -3788,13 +3830,13 @@ fn realize_inner( /// Bindings introduced by loops and component props, innermost last. struct ValueScope<'a> { - frames: Vec<(String, crate::JsonValue)>, - data: &'a crate::JsonValue, + frames: Vec<(String, serde_json::Value)>, + data: &'a serde_json::Value, copies: &'a [CopyDecl], } impl ValueScope<'_> { - fn lookup(&self, path: &str) -> Option { + fn lookup(&self, path: &str) -> Option { let mut segments = path.split('.'); let root = segments.next()?; @@ -3819,7 +3861,7 @@ impl ValueScope<'_> { "pending" } }); - return Some(crate::JsonValue::String(status.to_string())); + return Some(serde_json::Value::String(status.to_string())); } // `copy.` is authored in the ledger, not fetched. Resolve it from @@ -3842,7 +3884,7 @@ impl ValueScope<'_> { .find(|(k, _)| k == lang) .or_else(|| entry.locales.iter().find(|(k, _)| k == "en")) .or_else(|| entry.locales.first())?; - return Some(crate::JsonValue::String(text.1.clone())); + return Some(serde_json::Value::String(text.1.clone())); } let mut current = match self.frames.iter().rev().find(|(n, _)| n == root) { @@ -3854,7 +3896,7 @@ impl ValueScope<'_> { if let Some(inner) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { let key = self.lookup(inner)?; current = match &key { - crate::JsonValue::String(k) => current.get(k.as_str())?.clone(), + serde_json::Value::String(k) => current.get(k.as_str())?.clone(), v => match v.as_u64() { Some(i) => current.get(i as usize)?.clone(), None => return None, @@ -4019,24 +4061,24 @@ impl Realizer<'_> { // An event names itself. It has no value in data, and looking // one up is how the wrong event came to fire. (Some(arg), Shape::Event) => match &arg.value { - Operand::Path(p) => crate::JsonValue::String(p.clone()), + Operand::Path(p) => serde_json::Value::String(p.clone()), other => literal_of(other), }, // A token is a bare member; the dot is lexical marking, not // part of the value. (Some(arg), _) => match &arg.value { Operand::Token(t) => { - crate::JsonValue::String(t.trim_start_matches('.').to_string()) + serde_json::Value::String(t.trim_start_matches('.').to_string()) } Operand::Path(p) | Operand::Predicate { path: p, .. } => scope .lookup(p) - .unwrap_or_else(|| crate::JsonValue::String(p.clone())), + .unwrap_or_else(|| serde_json::Value::String(p.clone())), other => literal_of(other), }, // Omitted: the declared default, or nothing. Binding a frame // either way is what stops lookup falling through to injected // data and letting the host capture an unfilled prop. - (None, _) => param.default.clone().unwrap_or(crate::JsonValue::Null), + (None, _) => param.default.clone().unwrap_or(serde_json::Value::Null), }; scope.frames.push((param.name.clone(), value)); bound += 1; @@ -4162,7 +4204,7 @@ impl Realizer<'_> { if let Some(index_binder) = element.binders.get(1) { scope .frames - .push((index_binder.clone(), crate::JsonValue::from(index + 1))); + .push((index_binder.clone(), serde_json::Value::from(index + 1))); bound += 1; } @@ -4188,7 +4230,7 @@ impl Realizer<'_> { let Some(cmp) = element.cmp.as_deref() else { // Bare boolean guard — amendment #10. - return matches!(left, Some(crate::JsonValue::Bool(true))); + return matches!(left, Some(serde_json::Value::Bool(true))); }; let right = element.rhs.as_ref().and_then(|r| scope_value(scope, r)); compare(left, cmp, right) @@ -4212,7 +4254,7 @@ impl Realizer<'_> { // Through a component prop the name arrives bound; directly it // is the path itself. return match scope.lookup(p) { - Some(crate::JsonValue::String(name)) => NodeValue::Event(name), + Some(serde_json::Value::String(name)) => NodeValue::Event(name), _ => NodeValue::Event(p.clone()), }; } @@ -4225,8 +4267,8 @@ impl Realizer<'_> { NodeValue::Bool(compare(scope.lookup(path), cmp, scope_value(scope, rhs))) } Operand::Path(p) => match scope.lookup(p) { - Some(crate::JsonValue::String(s)) => NodeValue::Text(s), - Some(crate::JsonValue::Bool(b)) => NodeValue::Bool(b), + Some(serde_json::Value::String(s)) => NodeValue::Text(s), + Some(serde_json::Value::Bool(b)) => NodeValue::Bool(b), Some(v) => match v.as_f64() { Some(n) => NodeValue::Number(n), None => NodeValue::Missing, @@ -4237,9 +4279,9 @@ impl Realizer<'_> { } } -fn json_to_key(value: &crate::JsonValue) -> String { +fn json_to_key(value: &serde_json::Value) -> String { match value { - crate::JsonValue::String(s) => s.clone(), + serde_json::Value::String(s) => s.clone(), other => other.to_string(), } } @@ -4696,7 +4738,7 @@ pub const CARD_STATE_KEY: &str = "@card"; #[derive(Clone, Debug, Default)] pub struct InstanceStore { /// `instance-key` → field → value. - cells: BTreeMap>, + cells: BTreeMap>, /// Component name → the schema id its live cells were written under. schemas: BTreeMap, /// Component name → state path → the shape that cell was written under. @@ -4706,11 +4748,11 @@ pub struct InstanceStore { } impl InstanceStore { - pub fn get(&self, key: &str, field: &str) -> Option<&crate::JsonValue> { + pub fn get(&self, key: &str, field: &str) -> Option<&serde_json::Value> { self.cells.get(key)?.get(field) } - fn set(&mut self, key: &str, field: &str, value: crate::JsonValue) { + fn set(&mut self, key: &str, field: &str, value: serde_json::Value) { self.cells .entry(key.to_string()) .or_default() @@ -4813,16 +4855,16 @@ fn schema_id(component: &Component) -> u64 { acc } -fn initial_for(shape: &Shape) -> crate::JsonValue { +fn initial_for(shape: &Shape) -> serde_json::Value { match shape { - Shape::Bool => crate::JsonValue::Bool(false), + Shape::Bool => serde_json::Value::Bool(false), Shape::Enum(members) => members .first() - .map(|m| crate::JsonValue::String(m.clone())) - .unwrap_or(crate::JsonValue::Null), - Shape::Text => crate::JsonValue::String(String::new()), - Shape::Number => crate::JsonValue::from(0.0), - Shape::Record | Shape::Collection | Shape::Event | Shape::Other => crate::JsonValue::Null, + .map(|m| serde_json::Value::String(m.clone())) + .unwrap_or(serde_json::Value::Null), + Shape::Text => serde_json::Value::String(String::new()), + Shape::Number => serde_json::Value::from(0.0), + Shape::Record | Shape::Collection | Shape::Event | Shape::Other => serde_json::Value::Null, } } @@ -4832,7 +4874,7 @@ fn initial_for(shape: &Shape) -> crate::JsonValue { /// identically either way. pub fn realize_with_state( source: &str, - data: &crate::JsonValue, + data: &serde_json::Value, store: &InstanceStore, limits: RealizeLimits, ) -> RealizeReport { @@ -4858,7 +4900,7 @@ pub fn dispatch_with( store: &mut InstanceStore, instance_key: &str, event: &str, - payload: Option<&crate::JsonValue>, + payload: Option<&serde_json::Value>, ) -> bool { dispatch_with_data( source, @@ -4866,7 +4908,7 @@ pub fn dispatch_with( instance_key, event, payload, - &crate::JsonValue::Null, + &serde_json::Value::Null, ) } @@ -4881,8 +4923,8 @@ pub fn dispatch_with_data( store: &mut InstanceStore, instance_key: &str, event: &str, - payload: Option<&crate::JsonValue>, - data: &crate::JsonValue, + payload: Option<&serde_json::Value>, + data: &serde_json::Value, ) -> bool { let mut sink = Diagnostics::default(); let Some(tokens) = lex(source, &mut sink) else { @@ -4939,7 +4981,7 @@ pub fn dispatch_with_data( // left the earlier writes standing when a later one could not be computed — // a half-applied event, which is the thing atomicity exists to prevent. // Stage every write first, commit only if the whole batch resolves. - let mut staged: Vec<(String, crate::JsonValue)> = Vec::new(); + let mut staged: Vec<(String, serde_json::Value)> = Vec::new(); for transition in &declared.transitions { let Some(state) = states.iter().find(|s| s.path == transition.target) else { @@ -4964,7 +5006,7 @@ pub fn dispatch_with_data( let next = match (&transition.form, &state.shape) { (Form::Toggle, Shape::Bool) => { - crate::JsonValue::Bool(!current.as_bool().unwrap_or(false)) + serde_json::Value::Bool(!current.as_bool().unwrap_or(false)) } (Form::Clear, shape) => declared_initial .clone() @@ -4974,7 +5016,7 @@ pub fn dispatch_with_data( .as_str() .and_then(|s| members.iter().position(|m| m == s)) .unwrap_or(0); - crate::JsonValue::String(members[(at + 1) % members.len()].clone()) + serde_json::Value::String(members[(at + 1) % members.len()].clone()) } (Form::Set(source), _) => match source { SetSource::Payload => match payload { @@ -4996,9 +5038,9 @@ pub fn dispatch_with_data( // §3 says a batch is all or nothing. _ => return false, }, - SetSource::Token(t) | SetSource::Text(t) => crate::JsonValue::String(t.clone()), - SetSource::Num(n) => crate::JsonValue::from(*n), - SetSource::Bool(b) => crate::JsonValue::Bool(*b), + SetSource::Token(t) | SetSource::Text(t) => serde_json::Value::String(t.clone()), + SetSource::Num(n) => serde_json::Value::from(*n), + SetSource::Bool(b) => serde_json::Value::Bool(*b), }, _ => return false, }; diff --git a/crates/splash-core/tests/fixtures/news.card b/crates/splash-ui-l0/tests/fixtures/news.card similarity index 100% rename from crates/splash-core/tests/fixtures/news.card rename to crates/splash-ui-l0/tests/fixtures/news.card diff --git a/crates/splash-core/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card similarity index 100% rename from crates/splash-core/tests/fixtures/stock.card rename to crates/splash-ui-l0/tests/fixtures/stock.card diff --git a/crates/splash-core/tests/fixtures/trip_planner.splash b/crates/splash-ui-l0/tests/fixtures/trip_planner.splash similarity index 100% rename from crates/splash-core/tests/fixtures/trip_planner.splash rename to crates/splash-ui-l0/tests/fixtures/trip_planner.splash diff --git a/crates/splash-core/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card similarity index 100% rename from crates/splash-core/tests/fixtures/weather.card rename to crates/splash-ui-l0/tests/fixtures/weather.card diff --git a/crates/splash-core/tests/ui_profile_l0.rs b/crates/splash-ui-l0/tests/profile.rs similarity index 95% rename from crates/splash-core/tests/ui_profile_l0.rs rename to crates/splash-ui-l0/tests/profile.rs index 0f4b89f..4eb8910 100644 --- a/crates/splash-core/tests/ui_profile_l0.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -4,7 +4,7 @@ //! that the grammar lacks is a defect in `docs/ui-profile-l0.md`, not in the //! cards. They are the same files as `octos-one/docs/l0/*.card`. -use splash_core::ui_l0::{catalog, check_ui_l0_named, Level}; +use splash_ui_l0::{catalog, check_ui_l0_named, Level}; const WEATHER: &str = include_str!("fixtures/weather.card"); const NEWS: &str = include_str!("fixtures/news.card"); @@ -286,7 +286,7 @@ fn diagnostics_are_bounded() { source.push('}'); let report = check_ui_l0_named("many", &source); assert!(!report.valid); - assert!(report.diagnostics.len() <= splash_core::ui_l0::MAX_UI_L0_DIAGNOSTICS); + assert!(report.diagnostics.len() <= splash_ui_l0::MAX_UI_L0_DIAGNOSTICS); assert!(report.diagnostics_truncated); } @@ -415,7 +415,7 @@ fn an_untotal_transition_form_is_rejected() { // ────────────────────────────────────────────────────────────────── realization ── -use splash_core::ui_l0::{realize, NodeValue, RealizeLimits}; +use splash_ui_l0::{realize, NodeValue, RealizeLimits}; fn weather_data() -> serde_json::Value { serde_json::json!({ @@ -444,11 +444,7 @@ fn weather_data() -> serde_json::Value { }) } -fn find<'a>( - node: &'a splash_core::ui_l0::UiNode, - kind: &str, - out: &mut Vec<&'a splash_core::ui_l0::UiNode>, -) { +fn find<'a>(node: &'a splash_ui_l0::UiNode, kind: &str, out: &mut Vec<&'a splash_ui_l0::UiNode>) { if node.kind == kind { out.push(node); } @@ -639,7 +635,7 @@ fn the_other_two_cards_realize_too() { // ───────────────────────────────────────────────────────────── makepad lowering ── -use splash_core::ui_l0::makepad; +use splash_ui_l0::makepad; #[test] fn a_realized_card_lowers_to_renderable_dsl() { @@ -681,7 +677,7 @@ fn a_realized_card_lowers_to_renderable_dsl() { /// warning instead of vanishing. #[test] fn an_unmapped_constructor_is_visible_rather_than_dropped() { - let node = splash_core::ui_l0::UiNode { + let node = splash_ui_l0::UiNode { kind: "Hologram".into(), key: "root".into(), args: vec![], @@ -724,11 +720,10 @@ fn two_rows_data() -> serde_json::Value { /// other — the property the whole component model exists to provide. #[test] fn local_state_is_per_instance() { - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); let data = two_rows_data(); - let first = - splash_core::ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let first = splash_ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); let root = first.root.expect("root"); let mut rows = Vec::new(); find(&root, "Row", &mut rows); @@ -736,11 +731,11 @@ fn local_state_is_per_instance() { // Open only the first row. let key = rows[0].key.clone(); - let applied = splash_core::ui_l0::dispatch(TWO_ROWS, &mut store, &key, "flip"); + let applied = splash_ui_l0::dispatch(TWO_ROWS, &mut store, &key, "flip"); assert!(applied, "flip should have applied at {key}"); let second = - splash_core::ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + splash_ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); let root = second.root.expect("root"); let mut captions = Vec::new(); find(&root, "TextCaption", &mut captions); @@ -762,22 +757,20 @@ fn local_state_is_per_instance() { /// that old values still fit. #[test] fn a_schema_change_resets_instance_state() { - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); let data = two_rows_data(); - let first = - splash_core::ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let first = splash_ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); let mut rows = Vec::new(); let first_root = first.root.unwrap(); find(&first_root, "Row", &mut rows); - splash_core::ui_l0::dispatch(TWO_ROWS, &mut store, &rows[0].key, "flip"); + splash_ui_l0::dispatch(TWO_ROWS, &mut store, &rows[0].key, "flip"); // The component gains a field: same name, different state schema. let edited = TWO_ROWS.replace( "state open { shape: bool, initial: false }", "state open { shape: bool, initial: false }\n state seen { shape: bool, initial: false }", ); - let after = - splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); let mut captions = Vec::new(); let after_root = after.root.unwrap(); find(&after_root, "TextCaption", &mut captions); @@ -806,23 +799,20 @@ view root Panel { /// both depend on. #[test] fn set_applies_the_event_payload() { - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); let data = serde_json::json!({ "items": [{"id":"a","name":"A"},{"id":"b","name":"B"}] }); - let before = - splash_core::ui_l0::realize_with_state(SETTER, &data, &store, RealizeLimits::default()); + let before = splash_ui_l0::realize_with_state(SETTER, &data, &store, RealizeLimits::default()); let root = before.root.unwrap(); let mut rows = Vec::new(); find(&root, "Row", &mut rows); assert_eq!(rows.len(), 2, "the list should show both items"); let payload = serde_json::json!("b"); - let applied = - splash_core::ui_l0::dispatch_with(SETTER, &mut store, "root", "choose", Some(&payload)); + let applied = splash_ui_l0::dispatch_with(SETTER, &mut store, "root", "choose", Some(&payload)); assert!(applied, "choose should apply"); - let after = - splash_core::ui_l0::realize_with_state(SETTER, &data, &store, RealizeLimits::default()); + let after = splash_ui_l0::realize_with_state(SETTER, &data, &store, RealizeLimits::default()); let root = after.root.unwrap(); let mut titles = Vec::new(); find(&root, "TextTitle", &mut titles); @@ -871,23 +861,21 @@ fn slot_realizes_the_children_passed_at_the_call_site() { /// stale value. #[test] fn unmounted_instances_are_pruned() { - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); let two = serde_json::json!({ "items": [{"id":"a","name":"A"},{"id":"b","name":"B"}] }); - let report = - splash_core::ui_l0::realize_with_state(TWO_ROWS, &two, &store, RealizeLimits::default()); + let report = splash_ui_l0::realize_with_state(TWO_ROWS, &two, &store, RealizeLimits::default()); let root = report.root.unwrap(); let mut rows = Vec::new(); find(&root, "Row", &mut rows); for row in &rows { - splash_core::ui_l0::dispatch(TWO_ROWS, &mut store, &row.key, "flip"); + splash_ui_l0::dispatch(TWO_ROWS, &mut store, &row.key, "flip"); } assert_eq!(store.len(), 2, "both rows hold state"); // `b` goes away. let one = serde_json::json!({ "items": [{"id":"a","name":"A"}] }); - let report = - splash_core::ui_l0::realize_with_state(TWO_ROWS, &one, &store, RealizeLimits::default()); + let report = splash_ui_l0::realize_with_state(TWO_ROWS, &one, &store, RealizeLimits::default()); store.prune(&report.live_keys); assert_eq!(store.len(), 1, "the departed instance's cell is dropped"); } @@ -1041,7 +1029,7 @@ fn copy_selects_the_reported_locale() { // ───────────────────────────────────────────────────────────────── source plan ── -use splash_core::ui_l0::{source_plan, SourceArg}; +use splash_ui_l0::{source_plan, SourceArg}; /// A card declares what data it needs; the plan is what a host fetches. Without /// it the declaration was inert — the parser skipped the helper and every @@ -1125,7 +1113,7 @@ fn every_reference_card_yields_a_resolvable_plan() { // ─────────────────────────────────────────────────────────────── reconciliation ── -use splash_core::ui_l0::{dirty_records, record_dependencies}; +use splash_ui_l0::{dirty_records, record_dependencies}; /// L0 needs no dynamic read-tracking: no expression form means every binding is /// structurally visible, so the dependency set is computable without evaluating @@ -1213,7 +1201,7 @@ fn an_unrelated_write_dirties_nothing() { assert!(dirty_records(WEATHER, &["nothing_reads_this"]).is_empty()); } -use splash_core::ui_l0::patch_points; +use splash_ui_l0::patch_points; /// `dirty_records` is transitive, so `root` appears for almost any write — it /// splices everything, so it reads everything. Patching root rebuilds the tree, @@ -1265,8 +1253,8 @@ fn an_unrelated_write_patches_nothing() { // ─── named slots (§5.5, open question 5) ───────────────────────────────────── /// Every `text` argument in the tree, in render order. -fn texts(root: &splash_core::ui_l0::UiNode) -> Vec { - fn walk(n: &splash_core::ui_l0::UiNode, out: &mut Vec) { +fn texts(root: &splash_ui_l0::UiNode) -> Vec { + fn walk(n: &splash_ui_l0::UiNode, out: &mut Vec) { for (name, v) in &n.args { if name == "text" { if let NodeValue::Text(t) = v { @@ -1546,14 +1534,13 @@ component Rowy(item: record) { } view root Panel { for it in items key it.id { Rowy(item: it) } } "#; - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); let data = two_rows_data(); - let first = - splash_core::ui_l0::realize_with_state(KEPT, &data, &store, RealizeLimits::default()); + let first = splash_ui_l0::realize_with_state(KEPT, &data, &store, RealizeLimits::default()); let mut rows = Vec::new(); let first_root = first.root.unwrap(); find(&first_root, "Row", &mut rows); - splash_core::ui_l0::dispatch(KEPT, &mut store, &rows[0].key, "flip"); + splash_ui_l0::dispatch(KEPT, &mut store, &rows[0].key, "flip"); // An unrelated field is added: the schema id changes, but `open` itself is // untouched and asked to survive. @@ -1561,8 +1548,7 @@ view root Panel { for it in items key it.id { Rowy(item: it) } } "event flip", "state seen { shape: bool, initial: false }\n event flip", ); - let after = - splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); let mut captions = Vec::new(); let after_root = after.root.unwrap(); find(&after_root, "TextCaption", &mut captions); @@ -1590,14 +1576,13 @@ component Rowy(item: record) { } view root Panel { for it in items key it.id { Rowy(item: it) } } "#; - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); let data = two_rows_data(); - let first = - splash_core::ui_l0::realize_with_state(KEPT, &data, &store, RealizeLimits::default()); + let first = splash_ui_l0::realize_with_state(KEPT, &data, &store, RealizeLimits::default()); let mut rows = Vec::new(); let first_root = first.root.unwrap(); find(&first_root, "Row", &mut rows); - splash_core::ui_l0::dispatch(KEPT, &mut store, &rows[0].key, "flip"); + splash_ui_l0::dispatch(KEPT, &mut store, &rows[0].key, "flip"); // `open` is retyped bool → enum. The opt-in does not apply to a field whose // own shape moved. @@ -1610,8 +1595,7 @@ view root Panel { for it in items key it.id { Rowy(item: it) } } "event flip { open: toggle }", "event flip { open: cycle(.shut, .ajar) }", ); - let after = - splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); let mut captions = Vec::new(); let after_root = after.root.unwrap(); find(&after_root, "TextCaption", &mut captions); @@ -1627,21 +1611,19 @@ view root Panel { for it in items key it.id { Rowy(item: it) } } fn without_keep_a_schema_change_still_resets() { // This is `a_schema_change_resets_instance_state` restated as the control // for the two tests above — the opt-in must not have become the default. - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); let data = two_rows_data(); - let first = - splash_core::ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); + let first = splash_ui_l0::realize_with_state(TWO_ROWS, &data, &store, RealizeLimits::default()); let mut rows = Vec::new(); let first_root = first.root.unwrap(); find(&first_root, "Row", &mut rows); - splash_core::ui_l0::dispatch(TWO_ROWS, &mut store, &rows[0].key, "flip"); + splash_ui_l0::dispatch(TWO_ROWS, &mut store, &rows[0].key, "flip"); let edited = TWO_ROWS.replace( "event flip", "state seen { shape: bool, initial: false }\n event flip", ); - let after = - splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); let mut captions = Vec::new(); let after_root = after.root.unwrap(); find(&after_root, "TextCaption", &mut captions); @@ -1734,7 +1716,7 @@ state mode { shape: enum[low, mid, high], initial: .low }\n\ event step { mode: cycle(.low, .mid, .high) }\n\ view root Panel { Row(on_tap: step) { Rule() } }\n"; - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); let report = realize(TRI, &serde_json::json!({}), RealizeLimits::default()); let mut rows = Vec::new(); let root = report.root.unwrap(); @@ -1742,7 +1724,7 @@ view root Panel { Row(on_tap: step) { Rule() } }\n"; let key = rows[0].key.clone(); for expected in ["mid", "high", "low"] { - splash_core::ui_l0::dispatch(TRI, &mut store, &key, "step"); + splash_ui_l0::dispatch(TRI, &mut store, &key, "step"); assert_eq!( store.get("@card", "mode").and_then(|v| v.as_str()), Some(expected), @@ -1798,15 +1780,14 @@ view root Panel { Toggly() } #[test] fn state_survives_an_element_inserted_above() { let data = serde_json::json!({}); - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); - let first = - splash_core::ui_l0::realize_with_state(TOGGLY, &data, &store, RealizeLimits::default()); + let first = splash_ui_l0::realize_with_state(TOGGLY, &data, &store, RealizeLimits::default()); let root = first.root.unwrap(); let mut rows = Vec::new(); find(&root, "Row", &mut rows); let key = rows[0].key.clone(); - splash_core::ui_l0::dispatch(TOGGLY, &mut store, &key, "flip"); + splash_ui_l0::dispatch(TOGGLY, &mut store, &key, "flip"); // The same card with a Rule inserted ABOVE the component. let edited = TOGGLY.replace( @@ -1815,8 +1796,7 @@ fn state_survives_an_element_inserted_above() { ); assert_ne!(edited, TOGGLY, "the edit must actually apply"); - let after = - splash_core::ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); + let after = splash_ui_l0::realize_with_state(&edited, &data, &store, RealizeLimits::default()); let after_root = after.root.unwrap(); let mut rows_after = Vec::new(); find(&after_root, "Row", &mut rows_after); @@ -1843,10 +1823,9 @@ fn sibling_instances_of_one_component_keep_separate_state() { "view root Panel { Toggly() Toggly() }", ); let data = serde_json::json!({}); - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); - let first = - splash_core::ui_l0::realize_with_state(&two, &data, &store, RealizeLimits::default()); + let first = splash_ui_l0::realize_with_state(&two, &data, &store, RealizeLimits::default()); let root = first.root.unwrap(); let mut rows = Vec::new(); find(&root, "Row", &mut rows); @@ -1854,9 +1833,8 @@ fn sibling_instances_of_one_component_keep_separate_state() { assert_ne!(rows[0].key, rows[1].key, "two instances need two keys"); // Flip only the first. - splash_core::ui_l0::dispatch(&two, &mut store, &rows[0].key.clone(), "flip"); - let after = - splash_core::ui_l0::realize_with_state(&two, &data, &store, RealizeLimits::default()); + splash_ui_l0::dispatch(&two, &mut store, &rows[0].key.clone(), "flip"); + let after = splash_ui_l0::realize_with_state(&two, &data, &store, RealizeLimits::default()); let after_root = after.root.unwrap(); let mut caps = Vec::new(); find(&after_root, "TextCaption", &mut caps); @@ -1891,7 +1869,7 @@ fn an_uncatalogued_source_helper_is_rejected() { fn an_uncatalogued_helper_produces_no_source_request() { // Belt and braces: even if a caller skips checking, the plan must not carry // the request onward to a host that trusts it. - let plan = splash_core::ui_l0::source_plan( + let plan = splash_ui_l0::source_plan( "source leak sys.shell(command: \"cat /etc/passwd\")\nview root Rule()", ); assert!( @@ -1995,7 +1973,7 @@ fn a_predicate_argument_evaluates_to_a_boolean() { let mut chips = Vec::new(); find(&root, "Chip", &mut chips); assert_eq!(chips.len(), 2); - let active = |c: &splash_core::ui_l0::UiNode| { + let active = |c: &splash_ui_l0::UiNode| { c.args .iter() .find(|(n, _)| n == "active") @@ -2093,15 +2071,15 @@ fn every_set_form_writes_what_it_names() { event yes { ok: set(true) }\n\ view root Row(on_tap: bump) { Rule() }"; let data = serde_json::json!({"movers": {"answer": 7}}); - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); - splash_core::ui_l0::dispatch_with_data(card, &mut store, "root", "bump", None, &data); + splash_ui_l0::dispatch_with_data(card, &mut store, "root", "bump", None, &data); assert_eq!(store.get("@card", "n").and_then(|v| v.as_f64()), Some(1.0)); - splash_core::ui_l0::dispatch_with_data(card, &mut store, "root", "frompath", None, &data); + splash_ui_l0::dispatch_with_data(card, &mut store, "root", "frompath", None, &data); assert_eq!(store.get("@card", "n").and_then(|v| v.as_f64()), Some(7.0)); - splash_core::ui_l0::dispatch_with_data(card, &mut store, "root", "yes", None, &data); + splash_ui_l0::dispatch_with_data(card, &mut store, "root", "yes", None, &data); assert_eq!( store.get("@card", "ok").and_then(|v| v.as_bool()), Some(true) @@ -2109,14 +2087,7 @@ fn every_set_form_writes_what_it_names() { // A payload must not hijack a transition that names a path to read. let stray = serde_json::json!("STRAY"); - splash_core::ui_l0::dispatch_with_data( - card, - &mut store, - "root", - "frompath", - Some(&stray), - &data, - ); + splash_ui_l0::dispatch_with_data(card, &mut store, "root", "frompath", Some(&stray), &data); assert_eq!( store.get("@card", "n").and_then(|v| v.as_f64()), Some(7.0), @@ -2156,7 +2127,7 @@ fn lowering_emits_the_event_and_the_instance_key() { "quote": {}, "series": {}, "selected": "", "range": "m1", "env": { "locale": {} }, "copy": { "movers": "Top Movers" } }); - let dsl = splash_core::ui_l0::makepad::lower( + let dsl = splash_ui_l0::makepad::lower( &realize(STOCK, &data, RealizeLimits::default()) .root .unwrap(), @@ -2182,7 +2153,7 @@ fn lowering_distinguishes_an_active_chip() { let source = "state range { shape: enum[d1, w1], initial: .d1 }\n\ view root Col { Chip(text: \"1D\", active: range == .d1) \ Chip(text: \"1W\", active: range == .w1) }"; - let dsl = splash_core::ui_l0::makepad::lower( + let dsl = splash_ui_l0::makepad::lower( &realize(source, &serde_json::json!({}), RealizeLimits::default()) .root .unwrap(), @@ -3351,7 +3322,7 @@ fn a_dotted_source_does_not_authorise_its_whole_root() { /// then emitted the dependent BEFORE the thing it depends on. #[test] fn a_dotted_source_dependency_is_ordered_correctly() { - let plan = splash_core::ui_l0::source_plan( + let plan = splash_ui_l0::source_plan( "source env.locale sys.locale()\n\ source scene sys.photo(query: env.locale.lang)\n\ view root Rule()", @@ -3403,13 +3374,12 @@ fn changing_an_initial_resets_live_component_state() { view root Panel { for it in items key it.id { Rowy(item: it) } }"; let data = serde_json::json!({"items": [{"id": "a", "name": "A"}]}); - let mut store = splash_core::ui_l0::InstanceStore::default(); - let first = - splash_core::ui_l0::realize_with_state(BEFORE, &data, &store, RealizeLimits::default()); + let mut store = splash_ui_l0::InstanceStore::default(); + let first = splash_ui_l0::realize_with_state(BEFORE, &data, &store, RealizeLimits::default()); let root = first.root.unwrap(); let mut rows = Vec::new(); find(&root, "Row", &mut rows); - let applied = splash_core::ui_l0::dispatch(BEFORE, &mut store, &rows[0].key.clone(), "bump"); + let applied = splash_ui_l0::dispatch(BEFORE, &mut store, &rows[0].key.clone(), "bump"); assert!( applied, "the event must actually apply, or this proves nothing" @@ -3418,8 +3388,7 @@ fn changing_an_initial_resets_live_component_state() { // Prove the caption is PRESENT before the edit. Without this, a dispatch // regression alone satisfies the assertion below and masks the schema // regression the test is named for. - let live = - splash_core::ui_l0::realize_with_state(BEFORE, &data, &store, RealizeLimits::default()); + let live = splash_ui_l0::realize_with_state(BEFORE, &data, &store, RealizeLimits::default()); let live_root = live.root.unwrap(); let mut before_caps = Vec::new(); find(&live_root, "TextCaption", &mut before_caps); @@ -3430,8 +3399,7 @@ fn changing_an_initial_resets_live_component_state() { ); let after = BEFORE.replace("initial: 0", "initial: 7"); - let out = - splash_core::ui_l0::realize_with_state(&after, &data, &store, RealizeLimits::default()); + let out = splash_ui_l0::realize_with_state(&after, &data, &store, RealizeLimits::default()); let out_root = out.root.unwrap(); let mut caps = Vec::new(); find(&out_root, "TextCaption", &mut caps); @@ -3548,9 +3516,8 @@ fn a_source_argument_path_must_be_declared() { assert!(!report.valid, "`secrets` is not declared"); // The plan must not carry it either, for a caller that skips checking. - let plan = splash_core::ui_l0::source_plan( - "source photo sys.photo(query: secrets.token)\nview root Rule()", - ); + let plan = + splash_ui_l0::source_plan("source photo sys.photo(query: secrets.token)\nview root Rule()"); assert!( !plan.diagnostics.is_empty(), "the plan must report it rather than hand the host an undeclared path" @@ -3662,9 +3629,9 @@ fn a_transition_batch_is_atomic() { event both { a: set(1), b: set(cfg.missing) }\n\ view root Row(on_tap: both) { Rule() }"; - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); // `cfg.missing` does not resolve, so the batch cannot complete. - let applied = splash_core::ui_l0::dispatch_with_data( + let applied = splash_ui_l0::dispatch_with_data( CARD, &mut store, "root", @@ -3690,10 +3657,9 @@ fn a_payload_must_fit_the_state_it_is_written_to() { const CARD: &str = "state n { shape: number, initial: 0 }\n\ event pick { n: set($value) }\n\ view root Row(on_tap: pick, value: \"x\") { Rule() }"; - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); let payload = serde_json::json!("not a number"); - let applied = - splash_core::ui_l0::dispatch_with(CARD, &mut store, "root", "pick", Some(&payload)); + let applied = splash_ui_l0::dispatch_with(CARD, &mut store, "root", "pick", Some(&payload)); assert!(!applied, "a payload of the wrong shape must be refused"); assert_eq!(store.get("@card", "n"), None); } @@ -3709,16 +3675,16 @@ fn clear_restores_a_path_valued_initial() { event flip { units: cycle(.c, .f) }\n\ view root Row(on_tap: flip) { Rule() }"; let data = serde_json::json!({"env": {"locale": {"temp_unit": "f"}}}); - let mut store = splash_core::ui_l0::InstanceStore::default(); + let mut store = splash_ui_l0::InstanceStore::default(); - splash_core::ui_l0::dispatch_with_data(CARD, &mut store, "root", "flip", None, &data); + splash_ui_l0::dispatch_with_data(CARD, &mut store, "root", "flip", None, &data); assert_eq!( store.get("@card", "units").and_then(|v| v.as_str()), Some("c"), "cycle must advance from the host-supplied initial `f`, not from the shape default" ); - splash_core::ui_l0::dispatch_with_data(CARD, &mut store, "root", "reset", None, &data); + splash_ui_l0::dispatch_with_data(CARD, &mut store, "root", "reset", None, &data); assert_eq!( store.get("@card", "units").and_then(|v| v.as_str()), Some("f"), diff --git a/mutate.py b/mutate.py index 839e070..4bca13c 100755 --- a/mutate.py +++ b/mutate.py @@ -40,7 +40,7 @@ from pathlib import Path ROOT = Path(__file__).parent -SRC = ROOT / "crates/splash-core/src/ui_l0.rs" +SRC = ROOT / "crates/splash-ui-l0/src/lib.rs" @dataclass(frozen=True) @@ -139,7 +139,7 @@ class Rule: def suite(): """(built, passed). built=False means the mutation did not compile.""" r = subprocess.run( - ["cargo", "test", "-q", "-p", "splash-core", "--test", "ui_profile_l0"], + ["cargo", "test", "-q", "-p", "splash-ui-l0", "--test", "profile"], cwd=ROOT, capture_output=True, text=True, ) out = r.stdout + r.stderr From bc2adbec8f902f0a106e9eaaed419f93d66bebe2 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:35:01 -0700 Subject: [PATCH 59/63] feat(ui_l0): apply reconciliation, and align the plot widgets with reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-ui-l0/examples/tap_l0.rs | 4 +- crates/splash-ui-l0/src/lib.rs | 149 ++++++++++++++++-- crates/splash-ui-l0/tests/fixtures/stock.card | 3 +- .../splash-ui-l0/tests/fixtures/weather.card | 2 +- crates/splash-ui-l0/tests/profile.rs | 136 ++++++++++++++++ docs/ui-l0-constructors.toml | 21 ++- 6 files changed, 287 insertions(+), 28 deletions(-) diff --git a/crates/splash-ui-l0/examples/tap_l0.rs b/crates/splash-ui-l0/examples/tap_l0.rs index a8e5571..b47328d 100644 --- a/crates/splash-ui-l0/examples/tap_l0.rs +++ b/crates/splash-ui-l0/examples/tap_l0.rs @@ -5,9 +5,7 @@ //! Proves the loop end to end without a UI: state lives in the store, the event //! is a declared name, and the second realization reflects the write. -use splash_ui_l0::{ - dispatch_with, makepad, realize_with_state, InstanceStore, RealizeLimits, -}; +use splash_ui_l0::{dispatch_with, makepad, realize_with_state, InstanceStore, RealizeLimits}; fn main() { let a: Vec = std::env::args().skip(1).collect(); diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 7732b90..56bb157 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2365,16 +2365,11 @@ pub mod catalog { ("MoonPhase", &[("phase", Path), ("illum", Path)]), ( "AqiContour", - &[("field", Path), ("index", Path), ("band", Path)], + &[("lat", Path), ("lon", Path), ("span", Number)], ), ( "StockPlot", - &[ - ("points", Path), - ("min", Path), - ("max", Path), - ("tint", Path), - ], + &[("symbol", Path), ("range", TokenOrPath(UNIT))], ), ]; @@ -3726,6 +3721,9 @@ pub struct RealizeReport { /// without it the store grows for the life of the app, and a key that /// reappears inherits a stale value (§5.7, unmount). pub live_keys: Vec, + /// Nodes carried over from a previous tree by [`realize_patch`] rather than + /// rebuilt. Zero for a full realization. + pub reused: usize, } /// Realize a card against resolved data. @@ -3754,6 +3752,7 @@ fn realize_inner( nodes: 0, truncated: false, live_keys: Vec::new(), + reused: 0, }; } @@ -3766,6 +3765,7 @@ fn realize_inner( nodes: 0, truncated: false, live_keys: Vec::new(), + reused: 0, } } }; @@ -3779,6 +3779,7 @@ fn realize_inner( nodes: 0, truncated: false, live_keys: Vec::new(), + reused: 0, }; }; @@ -3821,6 +3822,7 @@ fn realize_inner( let live_keys = std::mem::take(&mut ctx.live); RealizeReport { root: out.into_iter().next(), + reused: 0, diagnostics: sink.items, nodes, truncated, @@ -4627,14 +4629,26 @@ pub mod makepad { ); } "AqiContour" => { + // lat/lon/span, not a field: the widget fetches its own data. + // Emitting `draw_bg.idx` was writing a GPU uniform from the + // card, which is the ABI leak the widget was rewritten to end. let _ = writeln!( out, - "{p}AqiContour{{ width: Fill height: 190 draw_bg.have: 1.0 draw_bg.idx: {} }}", - trim_num(num_of(arg(node, "index"))) + "{p}AqiContour{{ width: Fill height: 190 lat: {} lon: {} span: {} }}", + trim_num(num_of(arg(node, "lat"))), + trim_num(num_of(arg(node, "lon"))), + trim_num(num_of(arg(node, "span"))), ); } "StockPlot" => { - let _ = writeln!(out, "{p}StockPlot{{ width: Fill height: 180 }}"); + // The card names WHICH series; the widget fetches it. Emitting + // neither left the chart empty on every card that used one. + let _ = writeln!( + out, + "{p}StockPlot{{ width: Fill height: 180 symbol: {} range: {} }}", + text_of(arg(node, "symbol")), + text_of(arg(node, "range")), + ); } "Chip" => { // `active` selects the fill. Dropping it made every range chip @@ -5272,17 +5286,33 @@ pub fn record_dependencies(source: &str) -> Vec { /// avoid. A record is a **patch point** only when its OWN bindings read a /// changed path; an ancestor that merely contains it is handled by patching the /// descendant. -pub fn patch_points(source: &str, changed: &[&str]) -> Vec { +/// Which sources a change invalidates, following the cascade. +/// +/// Re-fetching is the expensive half of reconciliation — realization walks a +/// ninety-node tree, a source is a network round trip — so this is what a host +/// needs after an event. `patch_points` computed it internally and discarded it, +/// which left the cheap half exposed and the costly half unreachable. +/// +/// The cascade matters: `city` feeds `place`, `place` feeds `now`. A host that +/// refetched only the direct dependent would read a fresh coordinate against a +/// stale forecast. +pub fn stale_sources(source: &str, changed: &[&str]) -> Vec { let mut sink = Diagnostics::default(); let Some(tokens) = lex(source, &mut sink) else { return Vec::new(); }; let card = Parser::new(&tokens, &mut sink).parse_card(); - let roots: Vec = changed.iter().map(root_of).collect(); + let declared: Vec<&str> = card.sources.iter().map(|s| s.name.as_str()).collect(); + invalidated_by(&card, changed) + .into_iter() + .filter(|n| declared.iter().any(|d| *d == n)) + .collect() +} - // A source reading changed state must be re-fetched, and anything binding - // its result is then stale. - let mut invalidated: Vec = roots.clone(); +/// The transitive closure of what `changed` invalidates: the changed roots +/// themselves, plus every source that reads one, to a fixpoint. +fn invalidated_by(card: &Card, changed: &[&str]) -> Vec { + let mut invalidated: Vec = changed.iter().map(root_of).collect(); let mut steps = 0usize; loop { steps += 1; @@ -5314,6 +5344,95 @@ pub fn patch_points(source: &str, changed: &[&str]) -> Vec { break; } } + invalidated +} + +/// Realize, reusing the subtrees a change cannot have touched. +/// +/// This is what makes `patch_points` load-bearing rather than advisory. It +/// computed which records a change reaches and nothing consumed the answer, so +/// every event rebuilt the whole card. +/// +/// The reuse rule is deliberately conservative: a subtree is carried over only +/// when its record is **absent** from the patch set, so a record whose +/// dependencies could not be determined is rebuilt. Being wrong in that +/// direction costs work; being wrong in the other renders a stale value, and a +/// stale value on screen is indistinguishable from a correct one. +/// +/// The result must equal a full realization. A test asserts that on the same +/// card, because an optimisation that changes what is rendered is not an +/// optimisation. +pub fn realize_patch( + source: &str, + data: &serde_json::Value, + store: Option<&InstanceStore>, + previous: &UiNode, + changed: &[&str], + limits: RealizeLimits, +) -> RealizeReport { + let dirty = patch_points(source, changed); + let mut report = realize_inner(source, data, store, limits); + + if let Some(root) = report.root.as_mut() { + report.reused = carry_over(root, previous, &dirty); + } + report +} + +/// Copy nodes from `previous` into `next` wherever the record they belong to is +/// not dirty, and count what was carried. +/// +/// Identity is the instance key, which already encodes the declaration path and +/// enclosing loop keys — the same key that decides state ownership, so reuse and +/// state agree by construction rather than by a second rule. +fn carry_over(next: &mut UiNode, previous: &UiNode, dirty: &[String]) -> usize { + // A key names the records it passes through: `root/when#0/w:stream#0/stream`. + let touches_dirty = |key: &str| { + dirty + .iter() + .any(|d| key.split(['/', '#', '[']).any(|seg| seg == d)) + }; + + // A subtree is reusable only if NOTHING inside it is dirty. Checking the + // node's own key alone lets the root short-circuit the whole tree, because + // `patch_points` deliberately excludes ancestors that merely *contain* a + // change — so a clean parent above a dirty child is the normal case, not an + // edge one. + fn subtree_is_clean(n: &UiNode, touches_dirty: &dyn Fn(&str) -> bool) -> bool { + !touches_dirty(&n.key) + && n.children + .iter() + .all(|c| subtree_is_clean(c, touches_dirty)) + } + + if next.key == previous.key + && next.kind == previous.kind + && subtree_is_clean(next, &touches_dirty) + { + *next = previous.clone(); + return count_nodes(previous); + } + + let mut reused = 0; + for child in next.children.iter_mut() { + if let Some(was) = previous.children.iter().find(|p| p.key == child.key) { + reused += carry_over(child, was, dirty); + } + } + reused +} + +fn count_nodes(n: &UiNode) -> usize { + 1 + n.children.iter().map(count_nodes).sum::() +} + +pub fn patch_points(source: &str, changed: &[&str]) -> Vec { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return Vec::new(); + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + let invalidated = invalidated_by(&card, changed); let mut out = Vec::new(); for view in &card.views { diff --git a/crates/splash-ui-l0/tests/fixtures/stock.card b/crates/splash-ui-l0/tests/fixtures/stock.card index 7bb42a4..33b90ec 100644 --- a/crates/splash-ui-l0/tests/fixtures/stock.card +++ b/crates/splash-ui-l0/tests/fixtures/stock.card @@ -71,8 +71,7 @@ view header Col { } } -view chart StockPlot(points: series.points, min: series.min, max: series.max, - tint: quote.change) +view chart StockPlot(symbol: selected, range: range) # Range switching is a set() over a declared enum — no expression, and the # selected range is a guard rather than a computed style. diff --git a/crates/splash-ui-l0/tests/fixtures/weather.card b/crates/splash-ui-l0/tests/fixtures/weather.card index 01cf8b5..01a6c38 100644 --- a/crates/splash-ui-l0/tests/fixtures/weather.card +++ b/crates/splash-ui-l0/tests/fixtures/weather.card @@ -101,7 +101,7 @@ view forecast Panel { view airfield Panel { TextCaption(text: copy.air) - AqiContour(field: aqi.grid, index: aqi.index, band: aqi.band) + AqiContour(lat: place.lat, lon: place.lon, span: 1.6) } view sunmoon Panel { diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 4eb8910..b1af66d 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -3805,3 +3805,139 @@ fn no_role_accepts_presentation() { } } } + +// ─── reconciliation, applied ───────────────────────────────────────────────── + +/// `patch_points` computes which SOURCES a change invalidates and then throws +/// that away, returning only the records to re-realize. Re-fetching is the +/// expensive half — realization is a tree walk over ~90 nodes, a source is a +/// network round trip — so the invalidation set is what a host actually needs. +#[test] +fn a_state_change_names_the_sources_that_must_be_refetched() { + const CARD: &str = r#" +state city { shape: text, initial: "" } +state units { shape: enum[c, f], initial: .c } +source place sys.geocode(name: state.city) +source now sys.weather(lat: place.lat, lon: place.lon) +source moon sys.moonphase() +view root Col { TextRow(text: place.name) TextValue(value: now.temp, unit: units) } +"#; + + // `city` feeds `place`, and `place` feeds `now` — the cascade must be + // followed, or a host refetches a coordinate and reads a stale forecast. + let stale = splash_ui_l0::stale_sources(CARD, &["city"]); + assert!(stale.contains(&"place".to_string()), "{stale:?}"); + assert!( + stale.contains(&"now".to_string()), + "the cascade must be followed: {stale:?}" + ); + assert!( + !stale.contains(&"moon".to_string()), + "the moon does not depend on the city: {stale:?}" + ); + + // A display-only unit change costs no fetch at all. This is the case the + // whole mechanism exists for. + assert!( + splash_ui_l0::stale_sources(CARD, &["units"]).is_empty(), + "changing a display unit must not refetch anything" + ); +} + +/// The second half: realization reuses the subtrees a change cannot have +/// touched, instead of rebuilding the card. +#[test] +fn realization_reuses_the_records_a_change_did_not_touch() { + const CARD: &str = r#" +state units { shape: enum[c, f], initial: .c } +source now sys.weather(lat: 1.0, lon: 2.0) +source news sys.news(count: 1, fields: [title]) +view root Col { temps headline } +view temps Col { TextValue(value: now.temp, unit: units) } +view headline Col { TextRow(text: news.0.title) } +"#; + let data = serde_json::json!({ + "now": {"temp": 21.0}, "news": [{"title": "unchanged"}], "units": "c" + }); + + let full = realize(CARD, &data, RealizeLimits::default()); + let before = full.root.expect("a tree"); + + let patched = splash_ui_l0::realize_patch( + CARD, + &data, + None, + &before, + &["units"], + RealizeLimits::default(), + ); + let after = patched.root.expect("a tree"); + + assert_eq!( + shape_signature(&before), + shape_signature(&after), + "a patch must produce the same tree as a full realize" + ); + assert!( + patched.reused > 0, + "the headline reads no changed path and should have been reused" + ); + assert!( + patched.reused < patched.nodes, + "and the temperature record must have been rebuilt, not reused" + ); +} + +fn shape_signature(n: &splash_ui_l0::UiNode) -> String { + let kids: Vec = n.children.iter().map(shape_signature).collect(); + format!("{}[{}]", n.kind, kids.join(",")) +} + +/// The catalog described an interface these widgets abandoned. `AqiContour` +/// took `field`/`index`/`band` and `StockPlot` took `points`/`min`/`max` — but +/// both fetch their own data now, and the widget's own comment says why: +/// 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 a card names a LOCATION or a SERIES, and the arguments it names must +/// reach the widget. +#[test] +fn a_contour_carries_the_location_it_was_given() { + let dsl = splash_ui_l0::makepad::lower( + &realize( + "source p sys.geocode(name: \"Kyoto\")\n\ + view root Col { AqiContour(lat: p.lat, lon: p.lon, span: 1.6) }", + &serde_json::json!({"p": {"lat": 35.0, "lon": 135.7}}), + RealizeLimits::default(), + ) + .root + .unwrap(), + ); + assert!(dsl.contains("AqiContour"), "{dsl}"); + for v in ["35", "135.7", "1.6"] { + assert!( + dsl.contains(v), + "the bound {v} must reach the widget, or it samples the wrong place: {dsl}" + ); + } +} + +#[test] +fn a_plot_carries_the_series_it_was_given() { + let dsl = splash_ui_l0::makepad::lower( + &realize( + "state sel { shape: text, initial: \"NVDA\" }\n\ + view root Col { StockPlot(symbol: sel, range: .money) }", + &serde_json::json!({}), + RealizeLimits::default(), + ) + .root + .unwrap(), + ); + assert!(dsl.contains("StockPlot"), "{dsl}"); + assert!( + dsl.contains("NVDA"), + "the symbol must reach the widget, or it plots nothing: {dsl}" + ); +} diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 4808e2b..f8d4271 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -152,16 +152,23 @@ now = { kind = "path" } phase = { kind = "path" } illum = { kind = "path" } +# The card supplies a LOCATION, never the field. An earlier version took +# `field`/`index`/`band`, which described an interface the widget deliberately +# abandoned: supplying the field meant sixteen scalar uniforms in the card, and +# as the widget's own comment puts it, "that put a GPU uniform layout into the +# authoring language — it is not a widget contract, it is an ABI". The widget +# fetches its own field now, so a card cannot express one wrongly. [AqiContour] -field = { kind = "path" } -index = { kind = "path" } -band = { kind = "path" } +lat = { kind = "path" } +lon = { kind = "path" } +span = { kind = "number" } +# Likewise: the card names WHICH series, and the widget fetches it. Passing +# `points`/`min`/`max` made the card responsible for a scaled series it has no +# way to compute — §4's no-facts rule with the arithmetic hidden in the host. [StockPlot] -points = { kind = "path" } -min = { kind = "path" } -max = { kind = "path" } -tint = { kind = "path" } +symbol = { kind = "path" } +range = { kind = "unit" } # ─── shared token sets ──────────────────────────────────────────────────────── # Named kinds referenced above, so a token set is defined once rather than From 690449138f73ff7c3c294630d918a6fadf0bfb7b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:42:37 -0700 Subject: [PATCH 60/63] feat(ui_l0): a DSL lowering checked against the renderer that consumes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/splash-ui-l0/src/lib.rs | 118 +++++++++++++++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 86 +++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 56bb157..ae0358c 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -5518,3 +5518,121 @@ fn collect_reads(element: &Element, reads: &mut Vec, pulls: &mut Vec, +} + +/// Lower to the Splash DSL data form, reporting what could not be mapped. +/// +/// The kinds and attribute names below are the consumer's, established by +/// running `splash_render::build` rather than by reading about it. Three facts +/// shaped this and each cost a revert to learn: +/// +/// - `splash-render`'s tag table is closed. An unknown kind at the root rejects +/// the whole document; as a child it is dropped with no diagnostic at all. +/// - Its `Attrs` is a fixed 37-field struct, not an open bag, 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. A theme function call in that position +/// fails inside the VM, so a card's outermost role cannot lower to one. +/// +/// Six roles have no kind here — the data visualisations. They are reported, +/// not emitted: a card that loses its temperature bars should say so. +pub fn lower_dsl_checked(root: &UiNode) -> DslReport { + let mut out = String::from("// REALIZED from an L0 ledger — do not edit.\n"); + let mut diagnostics = Vec::new(); + emit_dsl(root, 0, &mut out, &mut diagnostics); + out.push('\n'); + DslReport { + dsl: out, + diagnostics, + } +} + +/// The DSL text alone, for callers that have already checked. +pub fn lower_dsl(root: &UiNode) -> String { + lower_dsl_checked(root).dsl +} + +/// A role's kind in the consumer's table, or `None` if it has none. +/// +/// Deliberately not a fallback: an unmapped role must reach the caller as a +/// diagnostic. Guessing a kind is what produced five names the consumer had +/// never heard of. +fn dsl_kind(role: &str) -> Option<&'static str> { + Some(match role { + "Surface" | "Col" => "column", + "Row" => "row", + "Grid" => "grid", + "Panel" | "Card" => "card", + "Rule" => "divider", + "Tile" => "listitem", + "Chip" => "chip", + "Photo" => "image", + "WeatherIcon" => "weathericon", + role if role.starts_with("Text") => "text", + // TempBar, SunArc, MoonPhase, AqiContour, StockPlot — no kind exists. + _ => return None, + }) +} + +/// Attribute names the consumer's `Attrs` actually reads. Anything else is +/// dropped on arrival, so emitting it only hides the loss. +fn dsl_attr(name: &str) -> Option<&'static str> { + Some(match name { + "text" => "text", + "label" => "label", + "src" => "src", + "gap" => "spacing", + "cols" => "w", + "pad" => "pad", + _ => return None, + }) +} + +fn emit_dsl(node: &UiNode, depth: usize, out: &mut String, sink: &mut Vec) { + use std::fmt::Write as _; + let pad = " ".repeat(depth); + + let Some(kind) = dsl_kind(&node.kind) else { + sink.push(SyntaxDiagnostic { + line: 1, + column: 1, + message: format!( + "{} has no kind in the renderer's table, so it cannot be lowered — a card \ + using it would render without it and look complete (profile §1.1)", + node.kind + ), + }); + return; + }; + + let _ = write!(out, "{pad}{{t: {kind:?}"); + for (name, value) in &node.args { + let Some(attr) = dsl_attr(name) else { continue }; + let _ = match value { + NodeValue::Text(s) => write!(out, ", {attr}: {s:?}"), + NodeValue::Number(n) => write!(out, ", {attr}: {}", makepad::trim_num(*n)), + NodeValue::Token(t) => write!(out, ", {attr}: {t:?}"), + _ => Ok(()), + }; + } + + if node.children.is_empty() { + out.push_str("}\n"); + return; + } + out.push_str(", c: [\n"); + for child in &node.children { + emit_dsl(child, depth + 1, out, sink); + } + let _ = writeln!(out, "{pad}]}}"); +} diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index b1af66d..b86a323 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -3941,3 +3941,89 @@ fn a_plot_carries_the_series_it_was_given() { "the symbol must reach the widget, or it plots nothing: {dsl}" ); } + +// ─── DSL lowering, verified against the real consumer ──────────────────────── + +/// A previous attempt at this was reverted because it asserted a contract with +/// `splash-render` that nobody had opened: five of 23 kinds did not exist, the +/// consumer reads `variant` where it emitted `role`, and its `Attrs` is a fixed +/// 37-field struct rather than an open bag, so about twenty attributes were +/// unread. All of that was one `grep` away. +/// +/// So this lowering emits ONLY what the consumer accepts, and refuses loudly +/// where it cannot. The constraints below were found by running +/// `splash_render::build`, not by reading about it: +/// +/// - the root must be a literal object; a function call there fails +/// - an unknown kind at the root rejects the document, and as a child is +/// dropped without a diagnostic +#[test] +fn dsl_lowering_emits_only_kinds_the_consumer_accepts() { + const ACCEPTED: &[&str] = &[ + "column", + "row", + "stack", + "scroll", + "list", + "grid", + "text", + "image", + "button", + "card", + "listitem", + "divider", + "spacer", + "chip", + "weathericon", + ]; + let dsl = splash_ui_l0::lower_dsl( + &realize(NEWS, &news_fixture(), RealizeLimits::default()) + .root + .unwrap(), + ); + for kind in dsl + .split("{t: \"") + .skip(1) + .filter_map(|s| s.split('"').next()) + { + assert!( + ACCEPTED.contains(&kind), + "{kind:?} is not in splash-render's tag table; as a child it would be \ + dropped without a diagnostic and as a root it would reject the card" + ); + } +} + +/// A role with no accepted kind must produce a DIAGNOSTIC, not a node the +/// consumer silently discards. Silent dropping is how a card renders with four +/// of its five visualisations missing and still looks finished. +#[test] +fn an_unmappable_role_is_reported_rather_than_dropped() { + let report = splash_ui_l0::lower_dsl_checked( + &realize( + "source w sys.weather(lat: 1.0, lon: 2.0)\n\ + view root Col { TempBar(lo: w.lo, hi: w.hi, min: w.min, max: w.max) }", + &serde_json::json!({"w": {"lo": 1.0, "hi": 2.0, "min": 0.0, "max": 3.0}}), + RealizeLimits::default(), + ) + .root + .unwrap(), + ); + assert!( + !report.diagnostics.is_empty(), + "TempBar has no kind in splash-render, so lowering it must say so" + ); + assert!( + report.diagnostics[0].message.contains("TempBar"), + "{:#?}", + report.diagnostics + ); +} + +fn news_fixture() -> serde_json::Value { + serde_json::json!({ + "lead": [{"id": "a", "title": "T", "author": "A", "points": 1, "comments": 2}], + "feed": [{"id": "b", "title": "U", "author": "B", "points": 3}], + "article": {}, "selected": "", "env": {"locale": {}} + }) +} From 7b7ed58641f8f71262a58c7a05f254e8c2b1165f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:02:52 -0700 Subject: [PATCH 61/63] docs(ui_l0): classify state by owner, and stop claiming the layers can be cut apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/splash-ui-l0/tests/profile.rs | 42 ++++++++++ docs/scoped-state.md | 13 +++ docs/ui-profile-l0.md | 118 +++++++++++++++++++++++++-- mutate.py | 12 +++ 4 files changed, 176 insertions(+), 9 deletions(-) diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index b86a323..099f3a6 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -3893,6 +3893,48 @@ fn shape_signature(n: &splash_ui_l0::UiNode) -> String { format!("{}[{}]", n.kind, kids.join(",")) } +/// §5.10.1's premise, on the real weather card rather than a synthetic one. +/// +/// The argument there is that component-local state cannot simply be moved down +/// to the component kit, because L0 keeps deciding when a subtree is rebuilt and +/// would no longer know the state exists. That argument is only worth making if +/// the rebuild actually happens — so this asserts the premise, not the +/// conclusion: a source refresh really does replace the subtrees that read it. +/// +/// If this ever goes green in the other direction — forecast rows surviving a +/// `week` refresh — then §5.10.1 is wrong and the move is cheaper than stated. +#[test] +fn a_source_refresh_replaces_the_subtrees_that_read_it() { + let data = weather_data(); + let store = splash_ui_l0::InstanceStore::default(); + let limits = RealizeLimits::default(); + + let before = splash_ui_l0::realize_with_state(WEATHER, &data, &store, limits) + .root + .expect("the reference card realizes"); + + let patch = |changed: &str| { + splash_ui_l0::realize_patch(WEATHER, &data, Some(&store), &before, &[changed], limits) + }; + + // `week` feeds the forecast rows, which is where `ForecastRow.expanded` + // lives. Rebuilding them is what would discard a kit-owned flag. + let week = patch("week"); + assert!( + week.reused < week.nodes, + "a week refresh must rebuild the records that read it, not reuse everything" + ); + + // The counterpart: a name no source or state declares dirties nothing, so + // the whole tree carries over. Without this, the assertion above would also + // pass for an implementation that never reuses anything at all. + let untouched = patch("no_such_record"); + assert_eq!( + untouched.reused, untouched.nodes, + "a change touching no declared record must reuse the entire tree" + ); +} + /// The catalog described an interface these widgets abandoned. `AqiContour` /// took `field`/`index`/`band` and `StockPlot` took `points`/`min`/`max` — but /// both fetch their own data now, and the widget's own comment says why: diff --git a/docs/scoped-state.md b/docs/scoped-state.md index 3d9ba49..b1ffeb3 100644 --- a/docs/scoped-state.md +++ b/docs/scoped-state.md @@ -2,6 +2,19 @@ **Status:** proposed. Nothing here is implemented. +**Superseded in part — read this first.** `ui-profile-l0.md` §5.10 classifies state by owner and +reaches a different arrangement than §7 below assumes. §7 has L0's lowering emit its own state +declarations and transitions into the VM. Under §5.10 the state that would live in the VM is +kind 2 — component-local — which is a candidate for belonging to the *component kit*, declared +there rather than in an L0 card. If that move happens, the cells here are the kit's and L0 never +mentions them. + +What survives regardless: §3's per-instance cell keyed by an opaque emitter-supplied key, §3.1's +collision rule, §4's redraw-on-write, and §5's lifetime. What needs rewriting is §7, and the +question of *who emits* the declaration. §5.10.1 adds an obligation this document does not +carry: whoever owns the cells must be handed instance identity, because L0 rebuilds subtrees on +data changes and a cell that cannot be re-found is a cell that is lost. + `UPSTREAM.md` permits a behavioural change to the vendored VM "only when [it] implements a published Splash language contract". This is that contract. It is deliberately narrow: it publishes the smallest state mechanism that lets an diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 223de5a..3a2f641 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -414,8 +414,10 @@ Recursive by construction, because a static declaration path is not enough: two instances each containing an unkeyed `Toggle` must not share one state cell. **This is not React's rule.** React associates state with position in the realized tree, where -component type and keys both affect preservation. L0 binds to a declared path so that -identity survives a ledger edit that inserts an element above. +component type and keys both affect preservation. L0 binds to a declared path, so identity +survives a ledger edit that inserts a **differently named** element above — and only that. +§5.1's closing paragraph lists exactly what does and does not move the path; read it before +relying on this sentence, which states the motivation rather than the guarantee. **Neither the card namespace nor the version is currently a key segment.** Keys begin at the root view and append component names; the schema id is held in a separate map keyed by component @@ -584,7 +586,12 @@ exist to prevent. A missing key on an iteration is a grammar error. ### 5.8 State schema versioning A component's **state schema id** is derived from its `state` declarations — names, shapes and -initials — and forms part of instance identity (§5.1). +initials. + +**It is not part of the instance key**, and an earlier version of this section said it was. The +key is `{parent}/{component}`; the schema id is held separately, per component name, and a +schema change reconciles the live cells *in place* rather than minting a new identity. The +observable behaviour below is what the implementation does; the mechanism is not identity. | Definition change | Effect | |---|---| @@ -614,8 +621,12 @@ opted in for. **Consequence worth stating plainly:** folding the ledger reconstructs the *program*, not the visible application state. Local state is disposable UI state — scroll positions, expansion -flags. Anything that must survive a definition change belongs in a `source` or in card state -with a declared migration, not in a component. +flags. Anything that must survive a definition change belongs in a `source`, not in a component. + +An earlier version of this paragraph offered card state "with a declared migration" as the other +home for durable values. **There is no card-state migration** — no syntax, no implementation. +Card state is reset like any other, and §8 question 8 records this as an open question rather +than a mechanism, because durability needs a storage contract this profile does not have. ### 5.9 Source lifecycle @@ -663,6 +674,74 @@ derivable under the invariant above. needs; how often `sys.weather` is worth re-fetching is a property of weather, and duplicating it per card is how two cards on one screen disagree about how old their data is. +### 5.10 Who owns state, and what L1 must be told + +L0 does not hold all the state a running card has. Three owners exist, and confusing them is +how a layer boundary gets drawn in the wrong place: + +| # | State | Owner | Example | May a source depend on it? | +|---|---|---|---|---| +| 1 | **Card state** | L0 | `selected`, `city`, `units` | Yes — `sys.quote(ticker: state.selected)` | +| 2 | **Component-local state** | L0 today | `ForecastRow.expanded` | No — §5.3 keeps it out of source arguments | +| 3 | **Source lifecycle** | the host | `weather.$state` | Not applicable — it *is* the fetch's status | + +**Kind 1 cannot be pushed below L0.** It is the state composition creates: `selected` is read by +the list, the detail, the header, the chart, and by two sources. It is not any component's +state, it is what *relates* them, so only the layer that composes can declare it. A component +kit cannot supply it however complete the kit is. + +**Kind 2 is a candidate for moving down, and that move is not yet justified.** The argument for +it is that expansion flags and scroll offsets are widget business the composer should not have +to mention — and the reference corpus agrees, containing six kind-1 declarations and exactly +one of kind 2. The argument against is §5.10.1. + +**Kind 3 was missed by an earlier draft of this section**, which offered a two-way split. It +fits neither: the host owns it, several components read it, it drives guards and refetch, and it +outlives any widget instance. + +Ownership is also not the only axis. A cell has a **visibility** (private, card-shared), +an **effect** (render-only, layout, source query, navigation), and a **lifetime** (instance, +card, session). `selected` is card-shared by visibility but *navigation* by effect; `units` +looks like a durable preference. Classifying by owner alone answers where a value lives, not +how long it should, which is why question 8 in §8 stays open. + +#### 5.10.1 Moving kind 2 down requires a protocol, not just a move + +Suppose component-local state moved to the component kit. L0 would no longer know `expanded` +exists — and L0 still decides when a subtree is rebuilt. Measured on the weather card: + +``` +week changes -> 28 of 79 nodes reused forecast rows are REPLACED +city changes -> 0 of 79 nodes reused whole tree rebuilt +units changes -> 13 of 79 nodes reused +``` + +The forecast rows read `week`. So when forecast data refreshes, L0 rebuilds those subtrees. +Today that is harmless: `expanded` lives in the instance store, keyed by instance identity and +held *outside* the tree, so it survives. Move the value down without moving identity down and a +row the user expanded collapses whenever data arrives. + +**So the layers do not need a shared value store, but they do need a shared protocol** — +instance identity, mount and unmount, what survives a rebuild, and what invalidates. Precisely +*because* L0 would not own kind 2, it must hand identity down so the owner can preserve it. + +That protocol does not exist. `docs/scoped-state.md` is the nearest thing and is written for +the opposite arrangement — it has L0 lowering emit its own state declarations into the VM — so +it needs rewriting around kit-owned state before it describes this. + +**The identity that would have to cross is not the renderer's `key`.** Three distinct things +currently share the name: + +| Name | What it is | +|---|---| +| `UiNode.key` | L0's declaration-and-loop path, used for realization and patch reuse | +| emitted `l0_key` | that same path, written as a backend attribute for tap routing | +| `Attrs.key` in `splash-render` | a kit-selected name for a slot in a process-global `BTreeMap` — no instance scoping, no pruning | + +The third is not the first. Reusing its name for instance identity would put per-instance cells +into a global map keyed by whatever the kit chose, which is the collision §5.7 exists to +prevent. + --- ## 6. What makes L0 terminate @@ -767,20 +846,41 @@ construct and keep the widest. | ~~5~~ | ~~Named slots~~ — **settled in §5.5.** `slot name` in the component, `into name { … }` at the call site, anonymous slot unchanged as the default. An `into` naming a slot that does not exist is rejected rather than silently dropping its children | | ~~6~~ | ~~An explicit state migration~~ — **settled in §5.8.** `keep: true` opts a cell out of the schema-change reset, and only while that field's own shape is unchanged | | ~~7~~ | ~~The closed token sets per constructor argument~~ — **settled**: `docs/ui-l0-constructors.toml` is the normative catalog, and tests assert the two agree in both directions — constructor names and shared token sets, and for source capabilities the arguments too | +| **8** | **What, if anything, is durable.** Card state resets with the card; §5.8 previously claimed a card-state migration that does not exist. `units` and `city` look like preferences a user expects to survive a restart. Answering needs a storage contract, a migration story, and a decision about what a user is entitled to get back — none of which belongs under a state mechanism | +| **9** | **Whether component-local state (§5.10 kind 2) moves to the component kit.** The corpus says it would cost little — six kind-1 declarations against one kind-2. §5.10.1 says it cannot move alone: identity, mount/unmount and invalidation must move with it, and that protocol is unwritten. Open until the protocol exists, not until the split looks tidy | -All seven are now settled. What replaces them is not a shorter list of questions but a -different kind of work: the profile is specified and implemented, and the remaining gaps are -in the surrounding system rather than in the language. +Questions 1–7 are settled; 8 and 9 were opened by working through where state lives. What +replaces the original seven is not a shorter list but a different kind of work: the profile is +specified and implemented, and the remaining gaps are in the surrounding system rather than in +the language. **Known gaps, stated plainly.** `makepad::lower` violates §1.1 and does so in three ways at once: it lowers to a *backend dialect* rather than to the theme's component kit, it decides *presentation* (ten hardcoded colours, a font-size ramp) which 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. The fix is not to move it to a backend crate — it is to -retarget it at the component kit and delete the colour table. `StockPlot` and `AqiContour` lower without their data arrays. L1 and L2 are +retarget it at the component kit and delete the colour table. L1 and L2 are specified only as "what L0 excludes" — neither is implemented, and the level classifier can therefore name them but not check them. +An earlier version of this paragraph said `StockPlot` and `AqiContour` "lower without their data +arrays". They have no data arrays: both name what to plot and fetch it themselves, and the +catalog claiming otherwise was the defect. + +**Three defects found by reviewing §5.10, not yet fixed.** Each is a checker gap rather than a +language question: + +- **A `source` and a card `state` may share a name**, and nothing rejects the collision. The + component then reads the source — §5.3 is not violated and card state does not leak — but + silent shadowing is not a defensible way to resolve two namespaces that should be disjoint. +- **A declared source that nothing reads is not reported.** `stock.card` shipped one: `series` + became unread when `StockPlot` started fetching its own, and no test noticed. An unread source + is a fetch the host performs for nothing. +- **Nothing connects a state write to a refetch.** `stale_sources` computes what a change + invalidates and `source_plan` lists what to request; both are called only from tests. + `dispatch_with_data` returns a bare `bool` and discards which fields changed. The pieces of + §5.9's invalidation story exist and are individually tested; the loop between them does not. + Component contracts were question 1 and are now specified in §5.2–§5.8, validated by adding components to two reference cards. That exercise produced amendment #9, which nothing had surfaced before — props are the first bindings a card author names freely, so the diff --git a/mutate.py b/mutate.py index 4bca13c..fa69a09 100755 --- a/mutate.py +++ b/mutate.py @@ -126,6 +126,18 @@ class Rule: "if scope.forbidden_state.contains(&root) {", "if false {"), + # ── reconciliation (§5.10.1) ───────────────────────────────────────────── + # Mutating to `true` (reuse everything) rather than `false` because that is + # the direction the first implementation got wrong: it checked a node's own + # key and let a clean root short-circuit its dirty children. The suite must + # notice a patch that reuses a subtree a change did touch. + Rule("patch-reuse", "a subtree is reused only when nothing under it is dirty", + """ !touches_dirty(&n.key) + && n.children + .iter() + .all(|c| subtree_is_clean(c, touches_dirty))""", + " let _ = (n, touches_dirty);\n true"), + # ── level and header (§7) ──────────────────────────────────────────────── Rule("header-level", "a declared level must match the derived one", "if !matches {", From 85ef15aa5d6168b5eea73c150adc226bbd1d3671 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:16:09 -0700 Subject: [PATCH 62/63] feat(ui_l0): lower a declared tap into a hit target that can actually receive it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- crates/splash-ui-l0/src/lib.rs | 50 +++++++++++++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 55 ++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index ae0358c..9186c55 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -4476,7 +4476,57 @@ pub mod makepad { } } + /// A node that declares `on_tap`, wrapped so the tap can actually be + /// received. + /// + /// `tap_binding` alone emits `l0_event`/`l0_key`/`l0_value` as attributes, + /// and nothing reads them: the VM has no hit-testing for an arbitrary + /// attribute, so every one of those cards rendered inert. The mechanism that + /// DOES work on device is the one hand-written cards already use — a + /// transparent `Button` overlaid on the content, calling `agent.notify`, + /// which the host receives as `SplashAction::Notify`. + /// + /// So this wraps a tappable element in `flow: Overlay` and lays that button + /// over it. The attributes stay: they cost nothing, they are what the tests + /// assert, and they are how a lowered card can be read back. fn element(node: &UiNode, depth: usize, out: &mut String) { + let Some(NodeValue::Event(event)) = arg(node, "on_tap") else { + return element_body(node, depth, out); + }; + let p = pad(depth); + // The wrapper must size 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. Text roles are likewise intrinsic. + let fill = matches!(node.kind.as_str(), "Row" | "Panel" | "Card"); + let w = if fill { "Fill" } else { "Fit" }; + let _ = writeln!(out, "{p}View{{ width: {w} height: Fit flow: Overlay"); + element_body(node, depth + 1, out); + let _ = writeln!(out, "{p} {}", tap_button(node, event)); + let _ = writeln!(out, "{p}}}"); + } + + /// The transparent hit target. `agent.notify`'s first argument must be a + /// LITERAL for the host's `tag_notify_calls` to prefix it with the card id — + /// an untagged event is discarded as unattributable — so the event name + /// travels in the payload rather than in the channel name. + fn tap_button(node: &UiNode, event: &str) -> String { + let value = match arg(node, "value") { + Some(NodeValue::Text(t)) => t.clone(), + Some(NodeValue::Number(n)) => trim_num(*n), + Some(NodeValue::Token(t)) => t.clone(), + _ => String::new(), + }; + format!( + "Button{{ width: Fill height: Fill draw_bg.color: #00000000 \ + draw_bg.color_hover: #ffffff08 draw_bg.color_focus: #00000000 \ + draw_bg.color_down: #ffffff12 draw_bg.border_size: 0.0 \ + draw_bg.border_radius: 10 text: \"\" \ + on_click: || agent.notify(\"l0\", {{key: {:?}, event: {event:?}, value: {value:?}}}) }}", + node.key + ) + } + + fn element_body(node: &UiNode, depth: usize, out: &mut String) { let p = pad(depth); match node.kind.as_str() { "Surface" => { diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 099f3a6..36be672 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -1007,6 +1007,61 @@ fn copy_resolves_from_the_card_not_the_data() { ); } +/// A tap must be RECEIVABLE, not merely described. +/// +/// Lowering emitted `l0_event`/`l0_key`/`l0_value` as attributes and nothing +/// read them — the VM does not hit-test an arbitrary attribute — so every card +/// that declared `on_tap` rendered inert on device while these tests passed. +/// The mechanism that works is the one hand-written cards use: a transparent +/// `Button` over the content calling `agent.notify`, which the host receives. +#[test] +fn a_tappable_node_lowers_to_a_reachable_notify() { + let data = serde_json::json!({ + "movers": [{ "ticker": "NVDA", "name": "Nvidia", "last": 1.0, "change": 0.5, "pct": 2.0 }], + "quote": { "name": "Nvidia", "last": 1.0, "change": 0.5, "pct": 2.0, "open": 1.0, + "high": 2.0, "low": 0.5, "volume": 10.0, "mktcap": 99.0, "pe": 12.0 }, + "selected": "", "range": "m1", "env": { "locale": {} }, + "copy": { "movers": "Top Movers", "open": "Open", "high": "High", "low": "Low", + "volume": "Volume", "mktcap": "Mkt Cap", "pe": "P/E" } + }); + let report = realize(STOCK, &data, RealizeLimits::default()); + let dsl = makepad::lower(&report.root.expect("root")); + + // The row that opens a quote must carry all three: which event, which + // instance, and what payload. Missing the key means a tap cannot say WHICH + // row was hit, which is the whole basis of §5.1 identity. + let notify = dsl + .lines() + .find(|l| l.contains("agent.notify") && l.contains("open_quote")) + .unwrap_or_else(|| panic!("no reachable tap for open_quote:\n{dsl}")); + assert!(notify.contains("event: \"open_quote\""), "event:\n{notify}"); + assert!(notify.contains("value: \"NVDA\""), "payload:\n{notify}"); + assert!( + notify.contains("key: \"root/when#0/w:list#0/list/Panel#0/for#0[NVDA]/Row#0\""), + "instance key:\n{notify}" + ); + + // `agent.notify`'s first argument must stay a LITERAL. The host's + // `tag_notify_calls` only rewrites literals, and an untagged event is + // discarded as unattributable — so a computed channel name would render the + // card inert again, silently and in exactly the same way. + assert!( + notify.contains("agent.notify(\"l0\","), + "the channel must be a literal:\n{notify}" + ); + + // The discriminating half: buttons appear ONLY where a tap was declared. + // Wrapping every node would make the whole card a hit target, and the + // assertions above would still pass. + let buttons = dsl.matches("agent.notify").count(); + let taps = dsl.matches("l0_event:").count(); + assert_eq!( + buttons, taps, + "one hit target per declared tap, no more:\n{dsl}" + ); + assert!(taps > 0, "the stock card declares taps"); +} + /// The declared language is chosen when the runtime reports one. #[test] fn copy_selects_the_reported_locale() { From a66fee135d7fb55a0e4b05cb329a8307085573d8 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:32:26 -0700 Subject: [PATCH 63/63] fix(ci): the two failures the extraction left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/splash-ui-l0/src/lib.rs | 5 +---- fuzz/Cargo.lock | 8 ++++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 9186c55..7a446bc 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -3899,10 +3899,7 @@ impl ValueScope<'_> { let key = self.lookup(inner)?; current = match &key { serde_json::Value::String(k) => current.get(k.as_str())?.clone(), - v => match v.as_u64() { - Some(i) => current.get(i as usize)?.clone(), - None => return None, - }, + v => current.get(v.as_u64()? as usize)?.clone(), }; continue; } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index fc362cb..7a2e4f5 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -591,6 +591,7 @@ version = "0.1.0" dependencies = [ "makepad-script", "serde_json", + "splash-ui-l0", ] [[package]] @@ -670,6 +671,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "splash-ui-l0" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "splash-worker" version = "0.1.0"