Skip to content

L0 lowering fidelity, L1 as a specified level, and the tests that find this defect class - #4

Merged
ymote merged 97 commits into
mainfrom
l0-visualisations
Aug 9, 2026
Merged

L0 lowering fidelity, L1 as a specified level, and the tests that find this defect class#4
ymote merged 97 commits into
mainfrom
l0-visualisations

Conversation

@ymote

@ymote ymote commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

37 commits. The theme is one defect shape, found repeatedly: the card is
correct, the checker accepts it, and the screen is confidently wrong.

Verified on a OnePlus 6 throughout — weather, stock, news, a composed L1 app and
nav all render live — and most of what follows was found by looking at a phone
rather than at a test.

L1 is now a specified level

It was implemented ahead of its specification: the checker admitted
# level: L1 and gave it arithmetic while the profile defined L1 only as "what L0
excludes". §9 now specifies exactly the one construct that exists, and says that
is all it specifies.

Every hole is closed, and none of them fail silently any more:

  • a comparison binds looser than arithmeticx == a + b parsed as
    (x == a) + b, evaluated to missing, and nothing rejected it
  • grouping and a negative coefficient, so (a + b) * c and n * -1 are writable
  • §9.3 gained a second rule: an expression's answer must MOVE when its inputs
    move, so last * 0 + 1547 is refused. §9.8 had recorded that as needing an
    argument rather than a patch — the argument is that a formula depends on what it reads
  • a guard's right-hand side is checked and dependency-tracked like any other operand

Lowering fidelity: ~15 defects of one shape

A value reached the screen as whatever realization happened to see — right by
accident against a seed blob, frozen or absent on a live card:

tint a rising stock rendered red — tint from the seed, number live
every scalar seven forecast days drew one seeded icon
value: / tap payloads a row said ATKR and the tap carried ""
L1 operands sys.* answers strings, so arithmetic was NaN on device
viz parameters TempBar got 0 for all four — seven identical flat bars
AqiContour lat/lon 0, so it drew a real contour for the Gulf of Guinea under your city's name

Plus a missing-or-wrong table entry each time: sys.dayname called with three of
four arguments (every row said "Today"), min_lo/max_hi with no arm,
sys.search demanding an index, Satellite absent from the catalog entirely,
Grid.cols honoured by one backend only, a week's row count never found (one day
instead of seven), sys.gps/sys.search untranslated.

§5.12, and Map

Durable collections as sources — append/remove, references never facts,
verified across a force-stop. And Map lowered through neither backend, so
nav.card — which §1.0 cited as settling its central argument — drew an error box
where the map goes. It now draws a live route through both.

The tests that would have caught most of this

Three, all differential, all verified against deliberate regressions:

  • changing an attribute must change the lowering — the catalog, made executable
  • a bound attribute must lower to a live call — my first version passed with
    tint deliberately broken, which is why it is differential now
  • every offered field must have a translation — the conformance test §4 has been
    owing since it was written. It found 35 unanswered fields on its first run,
    explaining screens I had already looked at and failed to diagnose

Honest state

  • §1.0 is corrected: nav's declarative screens are settled for L0; the drive screen
    is L2 as written and not by necessity
  • docs/roadmap.md records reconciliation as deliberately deferred with a number
    a full rebuild is not measurable against input tap on device
  • Three allowlists (INERT, STALE, UNANSWERED) record what is still broken and
    may only shrink

222 tests in splash-ui-l0, workspace green, fmt and clippy clean.

Dependent changes in octos-one, Splash-Makepad and the makepad submodule are
not in this PR and remain uncommitted locally — including a Java fix for a
location crash (LocationListener$-CC) that killed the app on the first GPS fix.

🤖 Generated with Claude Code

ymote and others added 6 commits August 2, 2026 16:09
§1.1 left this unsettled: six roles are small data visualisations rather than
compositions of boxes and text, and how they reach a backend had no answer.
Five of them lowered to a named marker — visible, so a card could not silently
lose its temperature bars, but not a chart.

They lower to kit calls now, carrying their declared arguments in the catalog's
order. Every one of L0's 23 roles has a kit answer; `l0_unsupported` keeps its
place for the role that has none on the day it is added.

A missing argument becomes `0` rather than an omission, because these kit
functions have fixed arity and a card short of one would otherwise fail to parse.
Zero draws something visibly wrong; a parse error takes the whole card down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five roles may carry a `tint`, and the stock LIST tints a `TextValue` while the
detail tints a `TextStat`. Lowering only the latter left every percentage on the
list rendering white — "this one fell" stopped being said at all.

`tint` is §1.1's instructive case: red-versus-green is presentation and belongs
to the theme, but the SIGN is meaning and belongs in the lowering. The kit
composes `l0_tinted` around a role rather than taking a direction on all seven
text functions, six of which would pass 0 forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Weather, news and stock shaped every role and capability this profile has, so
their acceptance proves close to nothing. Two cards it was NOT designed against
say more.

`activity` GENERALISES. From a spec written for another framework, it needed one
catalog entry (`sys.places`) and no profile change. Two requirements came out
better than the source: the loading guard is `when parks.$state == .pending`
rather than a `-9999` sentinel that also has to mean "no data", and its venue
list is a declared collection rather than indexed capability calls.

It also found a real defect on its first run. `TextCaption(text: …, suffix: …)`
DROPPED the suffix — every row read "300 m" where the card said "300 m away ·
quiet green space". Nothing caught it because every `suffix` in the original
three pairs with `value:`, and only that path decorated. The catalog declared the
argument on the role and one of its two paths ignored it: specified but not
retained, again, and only an out-of-corpus card could surface it. Fixed in three
places, red test first.

`nav` DOES NOT, AND CANNOT. The classifier puts its shipping card at L2 and
inspection says the same more bluntly: 30 `let` bindings, 83 assignments, 128
conditionals, 606 arithmetic and concatenation operators, 190 capability calls,
and a `fn tick()` that recomputes route geometry every frame — reassigning
coordinates, rebuilding an OSRM waypoint string, re-resolving an origin whose
search has not landed.

That is a program, not a card, and the gap is NOT a missing role. Adding `Map`
and eight `sys.*` entries would not close it. `nav` computes, and L0 has no
expression form — the single property everything else here is built on. §1.0
records the boundary and says plainly that a card of that kind is L1 or L2 by the
§7 classifier, not a reason to widen L0.

Both are asserted rather than described: a program must classify as L2 and name
`let` as the reason, and `activity` must be admitted at L0.

One gap surfaced and left open. `activity`'s spec wants an empty state and L0
cannot say it — no length, no count, no emptiness predicate, so a guard cannot
tell an empty collection from a full one. Every list-shaped card wants this;
it is §8 question 10, with a note that the fix is a predicate and NOT a count,
since a number in a card is one operator away from arithmetic.

  843 tests, 19 mutation rules held, clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clippy under -D warnings, which CI runs and my last commit did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§1.0 said `nav` proved a structural limit and that a card like it is "L1 or L2,
not a reason to widen L0". That was too strong, and wrong in the direction that
stops work: it read one card's accumulated workarounds as evidence about the
language.

WHAT THE CARD ACTUALLY DOES. Of `tick()`'s 157 lines, 61 re-resolve values after
a fetch lands and 32 build a URL parameter by concatenation. Its own comments say
why, seven times — "top-level dlat/dlon freeze at build", "the top-level origin
resolution FREEZES at build time — before oq's search lands", "GPS may land AFTER
build, so (re)resolve it here each tick". It recomputes everything every frame
because it cannot say THIS VALUE DEPENDS ON THAT FETCH. Three more lines
disambiguate a `-9999` sentinel meaning loading *or* failed.

L0 already supplies all of that: declared sources with a dependency graph,
`$state` as four distinct states, and source arguments the host assembles.

AND THE MAP IS COMPOSABLE. Its card-facing surface is already declarative — ten
parameters, no callbacks — and `nav_period` means the widget owns its animation,
so the moving vehicle is not the card's concern. What the card does wrongly is
call `sys.navroute` itself, hand-build a marker string and push both in through
imperative setters. That is the card doing the WIDGET's job, and it is the
identical mistake `AqiContour` and `StockPlot` were corrected for — the catalog's
note on that correction says supplying the data "put a GPU uniform layout into
the authoring language — it is not a widget contract, it is an ABI".

So `Map` takes a TRIP — mode, from, to, via, zoom — and the widget fetches its
route. The mode token set is closed, so a card cannot ask for a camera behaviour
the widget has no answer for; the test asserts an invented one is refused.

`Field` is the other half. A card with no way to receive typed text cannot have a
search box, which was the rest of why nav could not be written here. The typed
value reaches declared state through a declared transition, so it arrives by the
same total path a tap does, and §4's `user-copy` class already names what it is.

§1.0 now says what is decided and what is not: whether `nav` rewritten against
declared sources and a `Map` role fits inside L0 is a CARD TO WRITE, not an
argument to have.

  846 tests, 19 mutation rules held, clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§1.0 said whether nav fits is a card to write rather than an argument to have.
Written: `tests/fixtures/nav.card` is the same screen as the 664-line exemplar —
origin, destination, live search results, route and ETA — in 54 lines, admitted
at L0.

That settles what the earlier §1.0 got wrong. nav's complexity was mostly
compensation, and each piece had a declared answer already:

  - a `tick()` re-resolving values every frame because a top-level `let` freezes
    before the fetch lands            -> a `source` is a declared dependency
  - a hand-built OSRM waypoint string -> a source argument the host assembles
  - `-9999` meaning loading OR failed -> `$state`, four distinct states
  - a polyline the card fetched and
    pushed into the widget            -> `Map` takes a TRIP and fetches its route

`sys.gps`, `sys.search` and `sys.route` join the catalog. `sys.route` returns the
trip's FACTS — duration, distance — while the route itself stays the widget's to
fetch: asking twice is cheaper than making a card carry a polyline.

FOUR GAPS FOUND, none structural, all recorded in §1.0 rather than patched:

  - A card cannot accumulate a list. `collection` is a PROP shape, not a state
    shape, and there is no append — so waypoints, favourites and any multi-select
    are inexpressible. The planner lost its "add a stop". This is the sharpest of
    the four, because it is a whole interaction class rather than one argument.
  - The text roles' argument sets are an artefact of the corpus: `suffix` and
    `glyph` only on `TextCaption`, `format` on Hero/Stat/Value but not Caption,
    `tint` on Stat/Value but not Hero. Each role has exactly what the original
    three cards happened to use on it.
  - No duration format, so a trip time cannot be written as one.
  - `on_tap` is absent from the text roles, so a tappable label needs a `Row`
    around it. Possibly right; not written down as a decision.

The test asserts the card uses both new roles and that it stays SMALL — the claim
is not merely that L0 can express this screen, and a 600-line L0 card would
disprove it as surely as a rejection would.

  847 tests, clippy clean.

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

r9s-codereview Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 AI Code Review

Claude CLI exited with code 1:

✅ No significant issues found.


Tokens used: 0 | Powered by r9s.ai

ymote and others added 21 commits August 2, 2026 23:39
§1.0 measured L0 against every app this system ships and counted `youtube` and
`web` as cards it could not express. They are not cards. A person authored their
UI once and the model supplies only an INTENT — which video, which song, which
query — so authoring UI is the wrong tool for them and their absence from L0 is
not a limitation of it. The AMA routes the intent; the app resolves it.

So the corpus divides two ways, and only one row is L0's business. FIVE OF THE
SIX LLM-authored cards are written and admitted at L0. The sixth,
`weather-activity`, is a composition of two that already are and names no
uncatalogued capability — its `sys.weathernum` / `sys.placesnum` / `sys.aqinum`
are the index-and-count companions a declared collection and `$state` replace —
but it is not counted as proven, because it has not been written.

"Five of eight" would have counted two fixed apps as failures of a language they
were never meant to use.

AND THE SPLIT IS WHAT §4 WANTS ANYWAY. `apps/youtube/app.md` currently tells the
model "YOU choose the videos — the card cannot search YouTube by itself", so it
emits video IDs from memory. Those are FACTS, and a wrong one is a dead embed or
the wrong upload — precisely what the no-facts rule exists to prevent, and the
same failure `activity`'s "never invent a venue" and `StockPlot`'s
symbol-not-a-series both avoid. An app that takes an intent and resolves it
against the real service cannot assert a fact that is not true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inside a `for`, a path is rooted at the BINDER — `m.ticker`, not
`movers.0.ticker` — so `source_binding` did not recognise it and no row of a
list ever lowered to a live call. The stock card showed this plainly and I read
it as normal: the DETAIL view went live while the LIST beside it kept the seeded
values, which looks like a stale list rather than a missing feature.

A loop frame now records which collection it iterates and at what index, and the
lowering rewrites through it. `l0_row_text(sys.movers(0, "symbol"))`, per row,
per index.

`sys.movers` joins the translation table. Every field in it was checked against a
live screener response rather than against the helper's accepted-key list —
which is the distinction that produced the `open` bug in `sys.quote`, where the
key is accepted and the value is absent from the payload, so emitting the call
drew `$—` over a real price.

The test asserts row 0 and row 1 each ask for their own index, and that the
seeded tickers are GONE. One call reused, or an index that does not advance,
would otherwise pass — and a card that shows a stale value beside a live one says
nothing about which is which.

  847 tests, 19 mutation rules held, clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clippy was right that a four-deep nested tuple in a signature stops being
readable once it grows a third element.

`ItemOrigin`, not `Provenance` — that name is taken by §4's copy class, and two
unrelated meanings for one word in the same file is how a reader ends up tracing
the wrong thing.

  848 tests, clippy clean under -D warnings.

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

A card rendered with NO data blob had no rows at all: `for m, i in movers`
iterates what the data holds, and with nothing there the loop produced nothing,
so no row was ever lowered and a backend that could have answered every field
was never asked.

The count is the one thing that backend cannot infer. It can answer field 0 and
field 1; it has no way to know how many to ask for. But the card already said:
`sys.movers(count: 10)` is a declared row count, so ten placeholder items is what
it asked for and the live calls fill them in.

Bounded by the realization limit, because the count comes from a generated card
and one asking for ten thousand rows should get the cap rather than the request.

This is what makes a GENERATED card work at all — a model writes a ledger with no
data attached, and until now that rendered as a page of headings with the lists
missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight defects with one shape: the card was right, the profile accepted
it, the DSL was well-formed, and the screen showed the seed.

- A payload bound to a source lowers to a live CALL. The row's text went
  live while its `value:` resolved at realize time, so the two disagreed
  — with no seed blob the tap carried `""` and was refused, and with a
  stale one it carried a different company from the one on screen.
- `width`, `align` and a numeric `cond` reach the kit. All three were in
  the catalog, accepted by the checker, and dropped by the lowering:
  headlines clipped mid-word, a centred header rendered hard left, and
  seven forecast rows drew one icon over a week that was not the same
  every day.
- A loop binder passed into a component keeps its provenance, so
  `for s in feed { StoryRow(story: s) }` — the idiomatic way to write a
  list — stops falling back to the blob for every row.
- A source argument that names another source emits the parent's own
  call, so a dependent chain does not die at the first hop.
- `sys.stock` reads the open from the bar series rather than a key the
  chart response does not carry. Excluding it left a seeded $181 open
  under a live $207 price on a +3% day.
- `signed_money` redirects to the field that returns the whole string:
  the currency sits inside the sign, and a prefix cannot express that.
- A `value:` the backend can answer goes live, exactly as `text:` does —
  unless the card declares a `format:`, which is applied to a realized
  number and would be silently dropped by a call.
- `sys.movers` carries `symbols`, so a themed request ranks the universe
  it named instead of the whole market under a themed title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A card already says what it needs — `fields: [ticker, name, last]` — and
nothing compared the two halves of the card against each other. Both
ways of getting it wrong passed: misspell a field, or read one that is
real but was never requested. Each renders an em dash, which on screen
is indistinguishable from a value still in flight.

So each capability declares what it can ANSWER, and a `fields:` list is
checked against it, as is every read off the root it backs. A loop
binder inherits its collection's vocabulary, so `d.dayname` is checked
the same way `week.days` is.

`aggregate:` pools into the same set. It describes a different level —
`week.min_lo` is a property of the week, not of a day — and separating
them needs a per-capability schema this does not have. Pooling accepts a
read at the wrong level and still rejects a name the card never asked
for, which is the defect that ships.

The vocabularies live in the TOML as well as in Rust, checked both ways.
The TOML is what the agent-facing catalog is generated from, so a
vocabulary that lived only in Rust would be one the model writing cards
never sees — and it would be refused for naming a field the
documentation never offered it.

What this does not catch: a field can be declared, accepted, and still
unanswerable by a given backend. Closing that needs a conformance test
per backend. This table is what such a test would check against; it is
not the test.

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

Profile §5.12. State is a CURSOR into data rather than data itself:
`selected = "NVDA"` is which entity the card is looking at. Once that is
clear, a watchlist is a persisted set of the same thing `selected` holds
one of — not a new kind of storage, the same kind of reference at a
different lifetime. That is the fourth value §5.10's lifetime axis was
missing, and the reason §8 question 8 could not be answered under a
state mechanism.

What is stored is REFERENCES, never facts. A watchlist is `["NVDA"]`,
not a list of rows carrying names and prices: a stored price is wrong
within a second of being written, and a stale number that still looks
live is precisely what §4 exists to prevent. The host joins the stored
references to live quotes and returns the rows, so the card never sees
the store and L0 needs no join operator.

One grammar addition: an event may target a source rather than a state.
`append($value)` and `remove($value)` are total for the reason `cycle`
is — the card names the operation and the runtime performs it. Dispatch
reports the write instead of performing it, and the source goes stale so
§5.9's lifecycle re-fetches it. The confinement argument is untouched:
L0 is safe because it has no expression form to evaluate, not because
taps are inert.

A capability absent from the writable table is read-only, which is why
`sys.prefs` is. A preference write must name WHICH preference and a
transition targets a bare source name, so `prefs: set($value)` cannot
say `units`. That needs a dotted target, which is grammar this does not
have; declaring the capability writable first would ship a write nobody
could aim.

Reorder is absent for the same kind of reason — `move(ticker, position)`
needs two payloads and `value:` carries one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§7 says a record needing a wider grammar is rejected "until the level is
explicitly raised", so raising it explicitly is the whole point. Without
this the classifier could name L1 and never admit one — which is what
the roadmap meant by "can name them but cannot check them". A card
declaring `level: L1` is parsed; L2 stays refused before parsing,
because imperative widget commands are a different grammar rather than a
wider one, and nothing below parses them.

The expression form is arithmetic over already-declared values, with the
usual precedence. The model supplies the FORMULA and the runtime
computes it, which is what keeps §4's no-facts rule true one level up: a
card may combine facts it declared and still cannot state one it never
observed. L0 enforces that structurally by refusing a literal in a value
position; L1 cannot, because a coefficient is a legitimate literal —
`temp * 9 / 5 + 32` is a formula — so the rule becomes that an
expression must READ something. `1547 * 3.2` reads nothing and is a
fabricated fact wearing arithmetic.

An unresolved operand, a division by zero and a non-finite result all
render as the em dash a missing binding already does. A zero would be a
fabricated number, which is the failure this level is closest to.

Lowering carries the SHAPE rather than the computed number: a value
computed at realization is the answer for whatever the host seeded, and
a live card is seeded with nothing. Every operand of an expression is
registered as a dependency, or reconciliation would under-approximate
the patch set and show stale data.

`valid` now means valid AT ITS LEVEL. It read `level == Level::L0`,
which was right while L0 was the only level admitted — an L1 card
reached the end with no diagnostics and was still reported invalid, with
nothing to say why.

The normative profile does not yet define L1; §7 licenses the admission
and no section states the grammar. That is a documentation gap, and it
is deliberate to leave it to a spec pass rather than invent it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three entries had gone stale against the branch.

The test count was 85 and is 203.

`StockPlot` and `AqiContour` no longer lower without their data arrays —
the five visualisations carry their arguments as of the commit that
stopped lowering them as markers.

"L1 and L2 are unimplemented" is the sentence the L1 work quotes as what
it was fixing, so it cannot also describe the result. Split in two: L1 is
implemented, and implemented AHEAD OF ITS SPECIFICATION, which is the
part worth recording — the checker admits a level the normative profile
does not define. L2 is still unimplemented, and refused before parsing
rather than merely unhandled.

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

Two things, both consequences of the checker having grown a construct
while the document that defines it did not.

§7's kit paragraph still said L1 and L2 "remain specified only as what
L0 excludes. Neither is implemented, and the classifier can name them
but not check them." That was true when written and is now false in half
— it contradicted both the implementation and the roadmap. Split: L2 is
still exactly that; L1 is not.

§9 specifies the one construct L1 admits and deliberately nothing else,
so a second construct needs its own pass before it is accepted. It was
written after the implementation, which is the wrong order, and §9.8 is
the length it is because of that.

Writing it found three things the code had not said out loud:

- A guard's right-hand side escapes both checks. `when n == nosuch * 2`
  is accepted — an expression there is checked against no declared name,
  is not subject to the must-read rule, and is not registered as a
  dependency. The same guard with a bare path is correctly refused, so
  the hole is the expression form specifically, and realization
  evaluates it anyway. Recorded as a defect rather than a limit.
- The no-facts rule bounds an expression's OPERANDS, not its result.
  `quote.last * 0 + 1547` reads a source, computes, and produces a
  fabricated number. L0's version of §4 is structural; L1's is a
  predicate on the operand set, and the difference is not cosmetic.
- There is no grouping and no unary minus, so precedence is fixed and
  `(a + b) * c` is inexpressible. Both are limits of what was written
  rather than decisions.

§9.7 says what the confinement argument costs, because this is the part
that must not be inherited by accident: L0's claim is that there is no
evaluator in the path, and L1 has one. What replaces it — a closed
arithmetic evaluator over already-resolved values, no name resolution,
no reachable capability, over a tree bounded at parse — is still strong
and is a DIFFERENT claim. §1 now says so at the point where the stronger
version is stated, so it cannot be read as covering both levels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A host rebuilds a card on any non-empty dispatch outcome, and every
transition was reported as a write whether or not the value moved. So
tapping the chip that is already selected cost a full realize, a full
lowering, a full VM pass over every live call on the card, and a widget
rebuild — to arrive at the identical screen. On the stock card that is
11 nodes and every `sys.movers` call re-issued for nothing; the weather
card is 62 nodes and 28 calls. Tapping the selected chip is the ordinary
way to hit this, and every card with a range or filter row has one.

The comparison is against the EFFECTIVE current value, which the
function already computed for `toggle` and `cycle`: an earlier write in
the same batch, else the stored cell, else the declared initial. So the
first tap on a chip that matches the initial is a no-op too, before any
cell exists to compare against.

Storage is unchanged — the cell is still written, and `changed` now
means "moved" rather than "was assigned". That is the field a host reads
to decide whether to rebuild, and the one `stale_sources` reads to
decide what to refetch; neither wants a target that holds what it held.
§3's atomicity is untouched: the batch is staged and validated in full
before any of this, and a batch that moves one cell and restates another
still applies and reports the one that moved.

This does not touch the other half. A tap that DOES change something
still rebuilds the whole card, because `record_dependencies`,
`dirty_records` and `patch_points` compute the patch set and nothing
calls them. That is the roadmap's "reconciliation is derived but not
applied", and it stays deferred deliberately: a full rebuild is always
correct, and patching is correct only once the dependency tracking is
complete enough to trust — the profile is explicit that
under-approximating it shows stale data on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reconciliation is deferred because a full rebuild is always correct and
patching is correct only if the dependency set is complete. Measuring
that set found it was not: three under-approximations, the direction the
profile is explicit about, because each one puts a stale value on screen
while the card looks fine.

- **A comparison's right operand.** `active: a == b` reads `b`, and the
  scan matched only the left path, so a chip's selected state never
  re-realized when the thing it compares against moved.
- **A guard's right operand beyond a bare path.** `when a == b * 2`
  reported nothing. Same cause: operand forms matched one at a time, and
  the list was written before the form existed.
- **A state reaching a view only through a source argument.**
  `dirty_records` filtered on the changed names, so `sel` dirtied nothing
  for `sys.quote(ticker: sel)` read as `q.last` — no record reads `sel`,
  they read `q`. That is the stock card's exact shape. `patch_points`
  already followed the source cascade and this did not; two functions
  answering one question differently is worse than either being wrong,
  because the coarse one is what a host reaches for first.

The scan now walks every operand with `expr_paths` — the same function
§4's must-read rule and the checker use — so a form it learns is picked
up here rather than needing a third arm in a third place.

The same guard right-hand side also escaped CHECKING, recorded as a
defect in §9.8: an undeclared name reached evaluation through the one
position that did not look inside its operand, and §4's must-read rule
was skipped with it. Both now apply there. §9.8 is updated rather than
left to contradict the code.

`patch_points` is unchanged and its exclusion of ancestors is not a gap:
patching a descendant suffices, and patching `root` IS the full rebuild
this exists to avoid.

This does not turn patching on. It makes the set the profile says
patching requires trustworthy enough to turn on, which is the part that
belongs in this crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The item read as an unfinished optimisation. It is a decided one, and
the deciding evidence was missing: nobody had measured the rebuild it
exists to avoid.

Measured on a OnePlus 6. Thirty taps each triggering a complete rebuild
of the weather card — 62 nodes, 28 live calls, the whole kit script
re-evaluated, the widget tree discarded and rebuilt — take the same wall
clock as thirty taps on a dead region. The difference sits inside the
±25 ms noise of the method and came out negative on one of two runs.

The parts that sounded expensive are not. Realize plus lower is
0.14–0.22 ms. The `sys.*` helpers are URL-cached, so a rebuild's live
calls are cache hits and not fetches. And an 11-node card costs the same
per tap as a 62-node one, which is the tell: the fixed overhead
dominates and the tree work is free at these sizes.

So patching would save nothing measurable, while costing the renderer a
per-record addressing scheme it does not have. That is a better reason
to defer than the one previously recorded, which was only that a full
rebuild is always correct.

Two things WERE worth doing and are done. Work that accomplished nothing
— a transition writing the value already in the cell reported a change,
so tapping the selected chip rebuilt the card to redraw the identical
screen. And the dependency tracking, which had three
under-approximations that would each have put a stale value on screen;
those were live defects in `dirty_records` and in the checker on their
own merits, independent of whether patching ever ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FOUND ON A PHONE. The top-movers list drew four positive rows and
coloured two of them red — `+29.45%` red, `+29.20%` green, `+26.47%`
red. The percentages were live calls and the tints were resolved from
the seeded blob, whose signs ran `+ - + -`. That is the pattern that was
on the screen, exactly. Two numbers describing one move, side by side,
contradicting each other, and nothing on screen saying which to believe.

Not introduced by making values live — REVEALED by it. Before that both
halves were seeded, so both were stale together and agreed. Half a fix
is what turned a hidden staleness into a visible contradiction, and that
is the general lesson worth keeping: a value and its decoration must
resolve from the same place, or fixing one of them makes the card worse.

`direction` read the realized value and emitted a literal, never
consulting the bindings, so a `tint:` bound to a source was never a call.
It is now, at the row's own index, so the tint and the value cannot come
from different rows.

No kit change is needed, which is why this is small: `l0_tint` branches
on `dir > 0` / `dir < 0`, so it takes the SIGN of whatever it is handed
and a raw change value works exactly as `1` and `-1` did.

A declared tint the backend cannot answer still falls back to the
realized sign — the same choice every other binding makes. `None` now
means "no tint declared" rather than "the seed said zero", so a live
tint wraps even where the seeded sign was neutral.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The third place that read a realized value and emitted a literal without
consulting the bindings. `WeatherIcon(cond: d.cond)` lowered the weather
code realization happened to see: right by accident against a seed blob,
and on a live card — which carries no blob — every icon in a seven-day
forecast fell back to the same default.

This is the icon, so it is the hardest of the three to notice. A wrong
number can be checked against another number on the same screen, which
is how the tint was caught. A wrong icon looks exactly like a right one.

Found by writing a card that had not existed before: a composed
city-picks app reading `sys.cities`, where the whole row is live and the
icon was the one element still seeded.

The weather card's hero condition now lowers to `sys.weatherword(…)`
rather than to its realized code, and the test that pinned the literal
is updated to pin the call — the size still has to travel with it, which
was that test's original defect and is unrelated to where the value
comes from. A loop row whose per-index condition the backend cannot
answer still falls back, which is the same choice every other binding
makes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FOUND ON A PHONE, and invisible to the whole crate. Every `sys.*` helper
answers with a STRING — that is what a card renders and what
concatenation composes, so `"$" + sys.stock(…)` is how every live value
reaches the screen. Arithmetic needs the other thing, and the VM
subtracts strings to NaN: the composed city card drew `≈NaN°` in every
row while the temperature and humidity beside it, from the same
capability and the same row, were correct.

So §9.5's claim was half true. The backend did receive the SHAPE and did
emit the arithmetic; the arithmetic just could not run. Nothing in this
crate could have caught it — the DSL was well-formed, the operands were
the right calls at the right indices, and 208 tests were green.

`render_expr` now wraps each live operand in `sys.num(…)`, a coercion
added alongside the helpers rather than folded into them: `geocodenum`
and `aqinum` exist because two specific fields were needed as numbers by
other calls, whereas L1 can ask for arithmetic over any numeric field of
any capability. The coercion belongs at the value, not at the source.

A constant is not wrapped — it is already a number in the DSL — and a
non-numeric value yields NaN rather than zero, because a zero is a
fabricated number and arithmetic that treats missing data as nothing is
the failure §4 exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalog says what a card MAY say. Nothing connected it to what a
backend DOES with what was said, so "the profile accepts it" and "the
screen shows it" were independent facts — and the gap was invisible,
because a test asserts what the code does and the code was the thing
dropping the attribute.

Measured cost of relying on discipline instead: `width`, `align`, a
numeric `cond`, `tint`, every scalar argument, an L1 expression and the
whole `Map` role were each admitted and then dropped or frozen. Six were
found by looking at a phone. The design named the generator — the kit's
own comment says "each attribute the catalog admits needs an entry HERE
or it is accepted by the profile and silently discarded, which is this
layer's recurring defect" — and answered it with care rather than with
structure.

Two conformance tests over `catalog::CONSTRUCTORS`, both DIFFERENTIAL,
because that is the only formulation that survives contact:

- **Reachability.** Two cards differing in one attribute's value must
  lower differently. A marker-based first attempt reported nine false
  positives, because `unit: .money` reaches the output as `$` and `tint:`
  as a direction rather than as the value.
- **Liveness.** The same attribute bound once to a source the backend
  answers and once to a state cell holding the same number must lower
  differently. My first attempt asserted only that SOME call appeared
  anywhere — with every argument bound that passed even with `tint`
  deliberately broken, so I checked both tests against deliberate
  regressions before trusting them. Both now catch them.

`Grid.cols` is fixed: the column count was hardcoded to two, which is
what every card in the corpus asks for, so a `Grid(cols: 3)` rendered as
pairs with nothing saying it had been overruled.

Two allowlists record what is still broken, and may only shrink. The
honest contents:

- **`Map` is lowered by neither backend.** So `tests/fixtures/nav.card`
  — which §1.0 cites as settling its central argument, "the same screen
  as the 664-line L2 exemplar in 54 lines" — draws an error box where the
  map goes. Admitted at L0 is true; the same screen is not.
- **`unit` reaches neither backend as a token.** `.c` and `.f` both lower
  to `value + "°"`, byte for byte, so the weather card's units toggle —
  the one interaction it advertises, wired through state, dispatch and
  re-render — changes nothing, and temperatures are always Celsius. The
  spec says "do not convert: the runtime formats by the unit token"; the
  runtime never receives the token. Closing it is a design decision, not
  a patch.
- `Surface`/`Photo` `pad`, and `width` on a `Field`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Map` was admitted by the catalog and lowered by NEITHER backend, so
`tests/fixtures/nav.card` — which §1.0 cites as settling its central
argument, "the same screen as the 664-line L2 exemplar in 54 lines" —
drew "no makepad lowering for Map" where the map goes. Admitted at L0
was true; the same screen was not. The conformance test found it; no
card would have, because the card is correct.

Three things, and the order matters.

**`sys.gps` and `sys.search` gained `vm_call` arms.** Both exist in the
VM — `gps`, `search` and `searchnum` are all registered — and neither was
translated, so every coordinate a nav card read fell back to the seed. I
had first concluded the VM lacked them, from a grep that only matched
string literals and missed every `id_lut!` registration; the helpers were
there the whole time.

**`Map` lowers to a real `MapView`.** The catalog note says the widget
fetches its own route. It does not: `nav_polyline` is a live field
`MapView` renders and never populates. But `sys.navroute` answers the
polyline, and the helper's own comment prescribes this exact pairing — so
the fetch is the card's declared source resolved into a call, the shape
every other live value already takes. A trip endpoint names a SOURCE
rather than coordinates, so each is asked for its own axis:
`from: here` becomes `sys.gps("lat")` and `sys.gps("lon")`.

    MapView{ nav_mode: "3d" zoom: 16
             center_lat: sys.gps("lat") center_lon: sys.gps("lon")
             nav_polyline: sys.navroute(sys.gps("lat"), sys.gps("lon"),
                 sys.searchnum("SFO", 0, "lat"),
                 sys.searchnum("SFO", 0, "lon"), "polyline") }

**A test that pins it**, including that none of the seeded coordinates
appear as literals — the failure mode here is a plausible map centred on
last week's position, not a blank one.

What is NOT closed, and is recorded rather than guessed:

- **The kit still emits `l0_unsupported("Map")`.** The device renders
  through the kit, so nav's map is still a placeholder ON DEVICE. Closing
  it needs an `l0_map` in `_kit.splash` and a `NodeKind::Map` arm in the
  host's widget emitter — both in other repos. `NodeKind::Map` and
  `NavMap` already exist and already parse, so the node half is done.
- **`via` is not emitted.** The helper carries it in a sixth argument and
  threading a card's list through needs that list rendered the way
  `sys.route` renders it.
- **`sys.gps` is fed by the Android LocationListener**, which is the path
  that currently crashes the shipping activity on a missing
  `LocationListener$-CC` desugaring class. This arm makes the card
  correct; it does not make GPS usable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixing only `makepad::lower` left the error box exactly where it was on a
phone. The device renders through the KIT — `l0_card.rs` calls
`kit::lower` — so the half that shipped was the half still broken, and a
green suite would have hidden it. That is the same asymmetry that let
`width`, `align` and a numeric `cond` reach one backend and not the
other.

`l0_map(mode, zoom, lat, lon, poly)` in the kit, and the two lowerings now
share `map_mode` and `map_route` rather than each deciding what `.drive`
means. Two backends answering that question independently is the shape of
every defect this profile keeps finding, so it is answered once.

The kit emits:

    l0_map("3d", 16, sys.gps("lat"), sys.gps("lon"),
           sys.navroute(sys.gps("lat"), sys.gps("lon"),
                        sys.searchnum("SFO", 0, "lat"),
                        sys.searchnum("SFO", 0, "lon"), "polyline"))

Every coordinate live, and the route fetched when the card draws.

The test now asserts BOTH paths, including that no seeded coordinate is
lowered as a literal — the failure mode here is a plausible map centred
on somewhere the user is not, which looks like a working map.

The host side lands in the two dependent repos, additively: a
`polyline: Option<String>` on `Attrs` (the tree carries geometry, not a
request — a backend that re-fetched from an origin and destination would
fetch again on every rebuild), an `l0_map` in `_kit.splash`, the property
read in the host's tree-walk, and a `MapView` arm in its widget emitter.
`NodeKind::Map`/`NavMap` already existed and already parsed, so the node
half needed nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map lowered and drew a correct map in a letterbox, slowly. All three
causes were already written down in `a2app/apps/nav/app.md`, which is a
working four-map reference with a "MANDATORY rules" section, and I had
invented parameters instead of reading it.

- **Height must be a large FIXED pixel value.** That spec says `Fill`/
  `Fit` "resolve to 0 and hide the map", and names 812 for a full-bleed
  screen and 452/384 for a panel. The kit asked for 240.
- **`min_zoom`/`max_zoom` must be widened.** The widget defaults to
  11..17 and CLAMPS into it, so a card asking to see a whole city
  silently got a street.
- **`use_network: true`** alongside `use_local_mbtiles: false`, and
  `nav_route_width` — 14 from the car, 40 for a whole-route preview,
  because the ribbon is drawn in ground metres.

Verified on device: the nav card now draws the 2.5D chase view with the
route ribbon, the vehicle puck and live tiles, where it drew an error box
two commits ago and a letterbox one commit ago.

This also corrects something I claimed earlier. I measured a full card
rebuild as costing nothing worth recovering and recorded that in the
roadmap. That holds for the cards I measured and NOT for a live map: the
nav spec says a rebuild "would tear down the live `MapView`", which is
why its drive screen is a no-rebuild `fn tick()` card. So the
reconciliation deferral is still right for the corpus and wrong for any
card holding a map, and an L0 `Map` inside a rebuilding card pays that
cost on every tap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both complaints were real and I had caused half of one of them.

NOT FULL SCREEN. The kit asked for 452 — the shipping card's PANEL size,
for a screen with content above the map. A card that has a map is a card
where the map is the screen, so it gets 812, that card's full-bleed size.

SEVERE STUTTER. Measured, and the cause was not what I assumed:

    stock card, no map          0%       229 MB   flat
    nav card, map, 3d mode      69%      1.9 GB
    nav card, map, plan mode    96-441%  3.0 GB   and climbing
    trip card, real coordinates 0-3.3%   0.9 GB   flat

The mode was irrelevant. `sys.gps` answers **-9999** with no fix, so the
map was centred on an impossible latitude — and I had widened the zoom
clamp to the shipping card's 3..19, which let it fit a garbage extent and
load tiles at world scale. That card has valid coordinates; a lowering
cannot guarantee them. The widening is reverted and the widget's own
11..17 clamp is what bounds the damage.

The card now guards: `when here.ok == 1 { Map(…) }`. §5.9 exists for
exactly this — "no fix yet" is a state to branch on, not a number to
render — and `sys.gps` answers `ok` precisely so a card can ask. An
unguarded map is a map of nowhere, which is worse than no map.

Verified on device with two searched places and no GPS: the route draws
full-bleed along the real road network at 0% CPU and stable memory.

`.drive` still lowers to the static preview, and that change is a §4 one
rather than a performance one — it did nothing for the numbers above. A
chase camera follows a vehicle; following needs a position updated every
frame; L0 has no loop to supply one, so the widget animates along the
polyline on a timer and draws motion the user is not making.

The lesson is the one behind the complaint: the working nav app had every
answer, and where I read it I got it right, and where I extrapolated from
it — the zoom clamp — I made things worse than before I started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ymote and others added 29 commits August 6, 2026 21:21
Third attempt, third distinct failure, each one only visible on the phone.

Referencing the fix makes the route chase the driver and pins progress at zero.
Capturing with `initial:` fires before the first fix and freezes -9999. Capturing on
the Go tap works from the tap onward — but before it, the cell is unwritten, so
`initial:` re-resolves every realize, the origin follows the moving device, and the
route request changes before it can complete. The summary never settles. On a parked
phone all three would have looked fine.

What is missing is not a fetch. It is a way for a card to name the INSTANT a value is
taken — "where I was when I started". `initial:` is the wrong instant and a source is
no instant at all.

The size bound goes back to 200 with the feature, for the second time. A bound left
high after its feature is removed measures nothing: 664 lines at L2 against 165 here.

KEPT, because it is verified and general: the host answers fetched values into a
card's data. A card with an empty blob now captures the device's position, which is
§5.9's write-back and the thing R2.1's results list needed.
`on_change` beside `on_commit`, because they are different questions. A keystroke
asks "what am I looking for"; a return says "this is where I am going". Collapsing
them loses one or the other: commit-only means no results until you press return,
change-only means every character sets a destination and routes to it.

`TextInput` has called both back all along. Only the catalog was short one, which is
the same shape as `label` and `query` on `sys.search` — the backend answering more
than a card was allowed to ask for.

I nearly rejected this on a remembered number. A card re-resolve per keystroke
sounded like the 327 ms map rebuild, which would have made the feature correct and
unusable. That figure is the DRIVE screen, with a follow camera and a route to
re-tessellate. Measured on the planning screen it is 18-19 ms — so I measured before
deciding, and the feature is fine.

Device: the partial word "Stanf" lists "Nelson Road, Stanford, California" and
"Stanford, Kentucky" with the destination still empty and no route drawn, which is
exactly the distinction the two events exist for.
R4.5. The first attempt was reverted because `Map(via:)` named ONE source: the line
went through one waypoint while the duration beside it was for a trip through two.
Both are plausible lines on a map, which is why it took counting separators to see.

A second named slot rather than a list. Role arguments route through the expression
grammar, so admitting `[a, b]` there is a change to the whole grammar for one
argument — and the app being replaced has exactly two waypoint slots, `wp1` and
`wp2`, and hides "add stop" when both are full. `sys.route`'s `via:` already carried
N pairs; only the map's did not.

The test counts separators inside the route call ALONE. My first version took a
fixed-width window, swept in the pin string — which legitimately carries three
separators for origin, two stops and destination — and reported a correct lowering as
four waypoints. Verified by reverting: dropping the second slot fails it.

`("Map", "via2")` joins `via` in the INERT list, with the stale note removed: `via`
is emitted, reaches both the polyline and the pins, and has been verified on device.

Device: Saratoga → Cupertino → Mountain View → Stanford, 38 min / 28.7 km against
30 min / 27.6 km direct, with two blue stop pins and the line detouring through both.
…te it down

R11.3, on the fourth attempt. The three failures were the same question in three
disguises: WHEN is the value taken.

Referencing the fix makes the route chase the driver, and `sys.step` then compares
the route's start against the device's position when they are the same expression —
progress pinned at zero for the whole drive. `initial:` alone fires before the first
fix and freezes the -9999 sentinel. Capturing on the Go tap is right after the tap
and wrong before it: the cell is unwritten, so the initial re-resolves every
realization and the origin follows the device — the route request changes before it
can answer, and the summary never settles.

The rule was small once named. Realization REPORTS what it took from a source
(`RealizeReport::captured`) because it owns the precedence; the host writes it once
because it owns the store. Until that write, an initial is not an initial — it is a
subscription.

`InstanceStore::set_cell` is public for exactly that, and for nothing else.

Also: the host no longer answers `sys.gps` into a card's data when there is no fix.
Absent is the honest answer and it is what stops a card capturing a coordinate nobody
has — the second failure could not have happened with this in place.

The card's bound goes to 230 for the two route sources and eight branches this costs.
It went 200 -> 260 -> 200 -> 230 across the four attempts, which is the discipline
working: the bound follows the feature, and a feature that does not work does not
keep its allowance. 664 lines at L2 against 218 here.

Device, stationary — which is what planning a trip looks like: FROM empty, TO
"Stanford University", 30 min / 27.6 km away, route drawn from the device's own
position with both pins on it.
`zoom: 15`, not 17. The app being replaced uses 15 for its 3D chase view and I had
picked 17 without checking, which is the entire memory gap this parity document has
been reporting for two days.

A zoom-17 tile covers a quarter the ground of a zoom-15 one, so the same view needs
about four times as many. Measured on a OnePlus 6, alternating runs so tile-cache
drift cannot favour either card:

  L2 nav.navigate   77.3% CPU   1.70 GB
  L0 at zoom 17     76.2% CPU   1.89 GB   (reported as "10% heavier")
  L0 at zoom 15     79.0% CPU   1.17 GB   (31% LESS)

The breakdown is what found it: the difference was entirely in native heap, with
graphics moving the other way. Native heap is the tile store, and the tile store is a
function of zoom. Nothing about the L0 pipeline was costing anything.

Also worth recording: one measurement pair had the two 35 MB apart and would have
been written up as parity. Alternating runs showed a consistent 190 MB. A single
sample of a tile cache measures how much of the map you happened to have loaded.
Another member of the slot-width vocabulary, beside `.rank`, `.day` and `.temp`, and
for the same reason those exist: fitting each label to its own text ragged the column.
"FROM" is wider than "TO", so every row started its field at a different x.

The card says WHICH slot it is and the theme says how wide, which is the same split
the pixel counts already live under. A card cannot say 64.
R8.3's on-map 2D/3D switch. `view:` admits a PATH as well as a token now — like
`unit` and `width` already do — so `Map(view: view)` follows a declared
`state view { shape: enum[tilted, flat] }`.

The card-size bound is what found this. Written the only way the catalog allowed —
one `Map` guarded per value — the toggle multiplied with the `origin` branches the
drive screen already carries: four maps, 18 lines, and the card went to 236 against a
230 bound. That is the bound doing its job rather than being in the way. Admitting a
path costs three lines and no branches, and the card lands at 224.

Realization resolves the path to a token before `map_mode` reads it, so nothing
downstream changes and `follow`/`follow3d` are chosen exactly as before.

Device: the chip reads "2D" over a tilted map and "3D" over a flat one — it names the
view it would switch TO, which is what the button on the card being replaced is
labelled — and tapping it flips both.
A row that FILLS cannot be centred — it already spans — so the travel modes and the
actions hug their content and a column centres them. My first attempt centred the
rows and changed nothing on screen, which is what said the width was the problem.

`Add a stop` loses its permanent row. It cost a whole line of a sheet that sits over
a map, to offer something most trips never want; `+` opens it and `Remove` closes it.
The test that guarded three always-visible fields now guards two on the resting sheet
and three after `add_stop` — hiding a control risks hiding it for good, and this
particular field has already been unreachable once.

`tone: .primary` beside `.normal` and `.danger`: the card says which action the
screen is FOR and the theme decides that means 20pt on a round target.

The onward arrow is `»`, not `→`. U+2192 has no glyph in the bundled Roboto and drew
a tofu box on the first build — the second time this session a card named a character
the font does not have. Latin-1 is the safe range.
Reproducing the reference screenshot, in the parts L0 can say.

A title, so a glance tells the sheet from the map behind it. The travel modes ABOVE
the endpoints, because how you are travelling decides what the endpoints mean. The
endpoints, the stop and `Add Stop` in ONE grouped card with hairlines between rows —
`Add Stop` reads as a row of the group rather than a button floating beside it, and
the stop's own row joins the same group when it opens. Then the decision and the one
button that acts on it: duration over distance on the left, GO on the right, which is
the order you read them in.

What is NOT here, and why: route alternatives with their time bubbles are R11.2,
deferred in the L2 app too; transit and rideshare have no backend behind them; the
drag handles reorder a list this card does not have; and the arrival CLOCK time —
"22:57 ETA" — needs a helper that answers a time of day, where `sys.step` answers
minutes remaining. The green GO is a `tone`, not a colour the card names.
iOS Maps labels each line it draws with the time that line takes, and a sheet can only
name one. `Map(summary: trip)` puts the duration and distance in a bubble on the path
and takes them out of the sheet, leaving it the one button that acts on them.

`summary` names the SAME source the sheet used to read. That is the point rather than
a convenience: the bubble and the line have to describe one journey, and two
separately-bound numbers are two chances to disagree — which is the defect this
profile keeps finding, wearing a map annotation this time.

GO is centred by the two halves that centring always needs here: a row that FILLS
cannot be centred because it already spans, so the row hugs its content and a column
centres it. One chip left-aligned in a full-width row is what it looked like before.

`("Map", "summary")` joins `via` and `via2` in the INERT list — it names a source, so
the differential probe's state-bound number is correctly ignored, and its liveness is
asserted by `a_map_labels_the_route_with_what_it_costs` instead.
The nav card's endpoints become tap-to-search editors, and getting there
surfaced defects well beyond the card. Each fix carries a test.

The card (fixtures/nav.card, 298 lines, level L0):
- FROM/TO/VIA are tappable rows; only the row being edited is a Field. The
  editor binds QUERY, not the committed state, and opens EMPTY with the current
  name as placeholder — the L2 app's `empty_text` pattern. Bound to state, every
  keystroke's re-realize snapped the pill back to the old name and the cursor
  landed mid-word ("Saratoga High XSchool"); bound to query the cursor has
  nowhere to be but 0 and each rebuild redraws exactly what was typed.
- "+ Add Stop" opens the via editor directly. The stop had been the last
  always-live Field: echoed, never searched.
- The three result lists fold into one §5 component — Hit(f: record,
  pick: event) — which is the first L0 component to ship on a device, and what
  holds the card under the 300-line bound the third endpoint broke.
- Result lists gate on `editing`, so two lists never answer one query (a tap
  could fill the endpoint you were not editing).

The checker/lowering (lib.rs):
- token_arg: realize erases whether a TokenOrPath argument was written
  (`view: .tilted` → Token) or followed (`view: view` → Text), and every reader
  matched Token alone — so the 2D/3D switch relabelled while the camera never
  moved. One helper, read by every TokenOrPath site (view/unit/width/controls/
  range), because the bug was the class.
- A map's controls are laid out by the SURFACE (with has_side), so the control
  column clears the banner and the card's side-docked chips get the theme's
  dark backing.
- Four catalog capabilities lowered to nothing and rendered em dashes with no
  diagnostic: sys.locale, sys.news_item, sys.prefs, sys.series now emit calls
  (series loses `points` — nothing can deliver a series as a value). The
  UNANSWERED allowlist drains rather than parks.
- `initial: here.lat * 2` was parsed as `here.lat` and the operator DISCARDED —
  a third unspecified expression position, accepted at L1, answering something
  the card did not ask. Refused at both levels.

The profile (docs/ui-profile-l0.md):
- §5.13 specifies the captured-initial mechanism that had shipped unspecified
  (write-once, host-owned, the only cell write outside a transition).
- §1.1's two stale claims corrected: both reasons for keeping makepad::lower
  are gone and it has no production consumer; all six data visualisations are
  in the consumer's tag table.
- §9.8 records the initial-expression hole as fixed.

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

- The preview screen is cut on request: `go` cycles plan → drive, because the
  plan screen already frames the route with its duration and distance, so the
  confirmation step asked the same question twice. The card drops to 263 lines.
- A side-docked Chip on a map emits as `l0_mapchip` — the recenter ring's
  38x38 spec — instead of a sheet chip; the eye-test asked for the 2D/3D
  switch and the location button to be the same size. The tap target is
  emitted UNQUOTED like the ordinary hit path: `tap_target` returns a DSL
  expression carrying its own quoting, and Debug-quoting it turned the
  dispatch into an empty event "applied to nothing".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two live generation runs produced checker-refused cards that rendered as
blank screens, and both refusals were bare expected/found lines that the
repair loop fed back to the model verbatim — teaching it nothing:

  line 75:  expected ")", found "when"   — a `when` guard nested inside a
                                           constructor's argument list
  line 124: expected ":", found ","      — a comma where an argument's
                                           `key: value` colon belongs
                                           (cascading into `expected ")"`,
                                           `expected an element`)

The diagnostic text IS the repair prompt, so it now says what the
construct is for:

- `parse_args` checks for a `when`/`for` keyword both at argument-name
  position (the comma form, where `ident()` used to eat `when` as an
  argument name and refuse with an unrelated `expected ":"`) and at the
  would-be `)` (the no-comma form). Either way it emits "a `when` guard
  cannot appear inside an argument list; guards wrap elements — close
  the constructor's `(…)` first…" and leaves the keyword unconsumed, so
  the enclosing block still parses the guard and checks its contents.
- A missing `:` after an argument name now names the argument, states
  the `name: value`-separated-by-commas form, and recovers to the
  closing paren. The comma-for-colon repro went from five diagnostics
  (four of them noise) to exactly one; the nested-guard repro from nine
  to two.

New profile test `syntax_the_model_gets_wrong_is_refused_with_a_teaching_
diagnostic` pins both classes: the teaching text must appear, the bare
`expected ")", found "when"` must not, and the comma-for-colon case must
produce exactly one diagnostic. Suite: 249 green (was 248).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s (exemplar sync)

Byte-identical sync from octos-one/a2app-l0/apps/*/exemplar.card
(octos-one 266328e: the three exemplars say "loading" and "can't
reach"). Each card now branches on its primary source's lifecycle with
declared copy — `when <src>.$state == .pending/.failed` — and weather
drops the dead `copy visibility` whose tile was removed. All three pass
the checker at L0; suite 249 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The kit's tick stamp (`live_call_of` → `l0_live`) composed only
glyph/unit/suffix around the live call, via `decorated` — never the
`format:` that `live_valued` applies at first draw. Three faces of the
one defect, all found in review:

- every `.money` price drew as `$184.20` and lost its `$` on the first
  tick;
- a `.signed_money` change ticked the raw `change` field, dropping the
  changemoney redirect that returns sign and symbol already ordered
  (`+$3.10`), the exact composition problem the redirect exists for;
- a `.compact`/`.ratio` value — which cannot go live at all, the draw
  keeps the seeded formatted literal — was still stamped with the raw
  call, so the first tick overwrote "41.2M" with 41200000.

The fix makes the stamp THE drawn composition: a bound `value:` now
stamps `live_valued(node)` — same prefix, same redirect, and the same
refusals, so where the draw kept the seeded value the tick now leaves
it alone (no stamp). A bound `text:` keeps the decorated bare call as
before; text has no `format:` semantics. `live_valued` becomes
pub(super) for the kit, with the contract in its doc.

New profile test `a_money_format_survives_the_tick` is differential
against the drawn form (stamp contents are debug-quoted, so the escaped
quotes distinguish stamp from draw): the `$` must appear inside the
stamp, changemoney must and raw change must not, and no stamp may name
the compact volume while the seeded "41.2M" stands. Verified to FAIL
against the previous live_call_of. Suite: 250 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s the catalog

`unit: .speed`, `.pressure` and `.index` were catalog-legal and
`decoration_of` mapped only c/f/pct/duration — so the weather detail
tiles drew "12.5" and "1013" beside their labels: bare numbers in
whatever unit the reader assumed, silently, with the checker's blessing.
The recurring defect shape (catalog admits, lowering ignores).

- `.speed` → " km/h" and `.pressure` → " hPa" — what the backend
  actually answers (open-meteo serves wind_speed_10m in km/h and
  surface_pressure in hPa; no fetch overrides the defaults) and exactly
  the suffixes the L2 reference card showed. One shared table
  (`decoration_of`), so both lowerings and the tick agree.
- `.index` is REMOVED from the TOML and `catalog::UNIT` rather than
  ignored: an index is dimensionless, there is no honest suffix, and
  the tile's LABEL already says which index it is. A card writing
  `unit: .index` is now refused with the legal-token list instead of
  rendering nothing.
- weather fixture: the UV tile drops `unit: .index` (synced
  byte-identical from the octos-one exemplar).

New test `a_dimensioned_unit_token_renders_its_dimension` is
differential per token and per backend (makepad + kit), and pins the
`.index` refusal naming the legal set. Suite: 251 green.
octos-one side: exemplar synced and framework/catalog.md regenerated
from the TOML (diff is exactly the one-token removal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ts key

sys.prefs joins the mutable capabilities with set/clear — resolving the note
that sat in MUTABLE saying a write couldn't name WHICH preference without
dotted-target grammar. It can: the declaration already names it. A written
prefs source must declare exactly one field, and that field is the key the
write lands under — `source home_pref sys.prefs(fields: [home])` plus
`home_pref: set($value)` writes `home`, and the checker refuses a write
through a multi-field source with a teaching diagnostic. CollectionWrite
carries the field so the host can key the store without re-parsing.

The nav card uses it for the user's own layer: the travel mode is CAPTURED
from the stored preference (§5.13; the host guarantees "drive" until the user
ever picks — measured, an empty capture into an enum leaves junk every guard
fails against), and HOME/WORK become saved places: ☆ rows in each endpoint
editor store the current endpoint's name (identity, never coordinates), and
saved ones appear as labelled quick-picks. One `Quick` component carries all
four row shapes. prefs answers grow home/work/mode, with translations.

Device-verified on the OnePlus 6: pick Walk → force-stop → relaunch → Walk
active from the store; ☆ HOME → relaunch → HOME appears as a pick row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The card half of this — the saved strip, the add-a-city editor, the
composed pick_city — is in the weather fixture, which landed one commit
back (d2cc719 swept the in-progress fixture up beside the prefs work; the
content is exactly this feature). This commit is the differential proof.

The layer, for the record: a strip of saved rows between the hero and the
forecast, each the STORED name beside a LIVE sys.cities temperature,
tappable to re-point the whole card (open_city writes the same `city`
state everything hangs off); a remove chip per row fires
`cities: remove($value)` with the row's own name; nav's editor pattern
for the add flow — a tappable row until tapped, a Field bound to `query`
(on_change: typing) only while `editing == .add`, results as bare rows
over sys.search gated on `query != ""`, never an empty panel. A result's
payload is `f.query` — name plus label, the text that finds the hit again
— measured against both ends: photon's label for a city-class hit is
"State, Country" (no county, deduped), and open-meteo's gazetteer
resolves "Berkeley, California, United States" while a county-bearing
string resolves to nothing.

`pick_city` is the composed transition the profile said dispatch supports
and no card exercised: one durable write beside three state writes —
city: set($value), cities: append($value), query: clear,
editing: set(.none). The checker admits the compose unchanged.

The test drives the flow as the user does — open, type, pick, drop — and
asserts what MOVED at each step (changed, writes, stale, the city cell),
not that some call appears somewhere: the composed event must commit all
three state writes beside the append, `typing` must stale `found`, and
the append must stale both `cities` (the new row appears) and `place`
(the card re-points). 252 tests.

Both evaluators report 72 nodes for the grown card; the shared
conformance count moves in Splash-Makepad beside this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tapping a saved city's × dead-centre fired open_city — the whole card
re-pointed to the city the user was trying to remove. Measured on the
6T, and structural: a tappable row lowers to a transparent hit target
drawn OVER its whole content, so a chip inside one is covered and can
never be hit. The same shape a marker-presence test would have passed —
the chip renders, the event exists, and the two cannot meet.

The fix stays in the card: the remove chip becomes a SIBLING of the
tappable area. The row wraps a filling inner row (name + live temp,
on_tap: open_city) and the chip; the two hit targets no longer overlap,
and both taps land — verified on the 6T, drop_city applied and the row
gone from the strip.

Exemplar synced in octos-one. Node counts unchanged: the conformance
data's empty store realizes no strip rows, so both evaluators still
report 72.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sys.reading joins the catalog (answers id/title/author/points/comments/
url, writes append/remove); its vm_call arm indexes store rows like the
other durable collections. The news fixture saves stories by id, the
stock fixture stars tickers and persists its range through sys.prefs.

Chips that act on a row live beside its tappable half, not inside it —
an inner chip is unreachable under the row's own hit target. The
reachable-notify test follows the mover row one level down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalog gains the followed-topics durable collection (answers
name/top_title/top_points/top_id, writes append/remove); its vm_call
arm indexes store rows like sys.reading. The news fixture grows the
TOPICS block: suggestion chips whose values are the stored words, and
per-topic rows in the sibling-chip shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reader capability joins the catalog: answers url (the page the
host's overlay has open, "" when closed), writes set/clear. A card
opens a page by writing a url it already holds; how pages are shown
belongs to the host.

The never-read diagnostic now counts an event WRITE as a use — a
write-only actuator source like sys.link is not dead, it is the write's
target, and dispatch resolves against the declaration to learn which
capability it drives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two new total forms beside cycle: `city: next(cities.name)` moves a
text cell to the next/prev value of a collection field, wrapping; a
value not in the list lands on the first (next) or last (prev) row.
Checked like everything else — the path must name a declared source's
field, the cell must be text — and dispatched from the data the host
hands in, which now carries the durable rows.

The weather fixture pages its saved cities with a swipe and moves its
add affordance to a + chip in the title bar; news and stock grow a
worded back row (the lone glyph measured ~20px of hit target).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The weather redesign's grammar half, shared by stock: tapping a search
result only PREVIEWS (a state write), the explicit Add is the one
composed event that stores durably, the explicit close stores nothing.
The rewritten dispatch test drives exactly that and asserts writes stay
empty until Add.

Chip gains a width token: `.fit` on a danger chip names the compact
row-scoped variant — the tap pass strips value/on_tap before the kit
lowering runs, so the payload could not be the discriminator — and its
hit target fits it; without it a danger chip is still the screen action
that spans (nav's Stop). TOML agrees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Saving is the add flow's explicit Add now, so the per-row shortcut and
its sibling wrapper leave the movers, and the reachable-notify key
walks back up a level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The saved row's Remove is a Reveal beside the tappable half — widget
visibility the row's own swipe drives, replacing the card-level manage
mode whose buttons appeared far from the row the finger was on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same gesture on every stock row, answering with what THIS row can
do: a saved row reveals its red Remove, a mover reveals Add. The
reachable-notify key follows the tap row into its wrapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No swipe menu on the market's own list: a mover row is the tap that
opens its quote, and the quote page's top-right Add is where a stock
is kept (append is idempotent, a kept one re-taps to nothing). The
reachable-notify key follows the row back out of its wrapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`sys.watchlist(ticker: state.selected, fields: [has])` answers "1"/
"0" for THAT ticker. The stock fixture's quote page flips on it: kept
shows a check and a red Remove, unkept shows Add — and for the beat
after a remove, the word Removed, carried by a last_act state the
keep/forget events set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ymote
ymote merged commit 11f00e9 into main Aug 9, 2026
6 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant