refactor(panel): hand every control its own closure, delete the handler-name scheme #13
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "upgrade/u3-closures"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
A
ui.*callback prop takes a function value at this plugin API, so no handler needs aglobal to name. The twenty-seven remaining
function onX()globals go, and the numberedfamilies that only existed because a name had to be computed go with them.
Gates
1. The boundary: what the shell calls by name
Grepped, not assumed. Every by-name entry point routes through
LuauHost::pushCallback,which sends any name without the
@ui:prefix tolua_getglobal(
luau_host.cpp:3077-3080); the producers areScriptRuntime::enqueueCall*and the twosites inside
script_runtime.cppitself. Every one of them is gated onhasGlobalfirst(
script_runtime.cpp:690-712), so an absent global is a silent skip, never an error.The complete set for a
[[panel]]entry:onOpenplugin_panel.cpp:237,:522onCloseplugin_panel.cpp:256onIpcplugin_panel.cpp:320, probescript_runtime.cpp:825noctalia msg plugin …updatescript_runtime.cpp:818-820; producerplugin_panel.cpp:299-308setWantsSecondTicks(true)and openonKeyplugin_panel.cpp:112, probe:830capture_keyschordonFrameTickplugin_panel.cpp:273-275setNeedsFrameTick(true)onConfigChangedscript_runtime.cpp:690-695onExitscript_runtime.cpp:851-869plugin_bindings.cpp:626-650,plugin_panel.cpp:406-423panel.openContextMenu{onActivate="fnName"}item activatedNot in the set, checked rather than assumed:
onEnableandonOutputsChangedare[[service]]-only (plugin_service_host.cpp:239,:246).onClick,onRightClick,onMiddleClick,onHover,onScroll,onAudioSpectrumare bar-widget globals(
plugin_widget.cpp:162-197,:322) — noteonScrollis a bar global and has nothingto do with
ui.scroll, whoseonScrollis an ordinary prop.onQueryand the launcher'sonActivateare launcher-provider only (plugin_launcher_provider.cpp:116,:184).onLoad,onUnload,onDisable,init,onTickdo not exist anywhere in the shell, andrenderis never called by the shell — it is the library method the script calls.No manifest key names a Lua function:
[widget.actions]values are bar-action grammarstrings parsed in
widget_action.cpp:31-60, never globals.timew/plugin.tomldeclares one[[widget]]and one[[panel]], nocapture_keys, noservice, no launcher. The panel asks for second ticks and not frame ticks, and U5 has not
added the context menu yet. So
panel.luauneeds exactly three of those names, anddefines exactly three:
onOpen,onIpc,update.Gate arithmetic.
grep -cE "^function on[A-Z]" timew/panel.luau→ 2, not 3: thepattern matches
on-prefixed names andupdateis not one. Both survivors it does countare shell entry points, and the third survivor it cannot see is too.
And nothing else in the file is global.
render,viewModel,loadDayandfetchAllare forward-declared as locals at
timew/panel.luau:362-365, sofunction loadDay(…)at:869isf = function…under normal lexical scoping and assigns that local — likewisefetchAll:893,viewModel:1095,render:1576. Enumerated rather than reasoned: dumpinggetfenv(panel.Fmt.clock)after the suite's last state prints exactlyonOpen,onIpcandupdateplus the three stubs the suite injects. The in-file comment at:987-990— "whatstays global is what the shell calls by name, and only that … and nothing else in this file
does" — is literally true.
2. The two generated families
onTag1..4existed becauseRECENT_MAXwas 4 andonDay1..14becauseDAYSwas 14: eachname a loop index spelled out, each constant one forgotten hand-edit away from a prop
naming a function that does not exist — a click silently dead, nothing logged
(
luau_host.cpp:3082-3083). Both builders capture their own index now, the waybuildListalready did.
Both constants are now pure bounds, and neither can go.
DAYS = 14— display and cost: the chart's width in bars (RIGHT_W = BAR_W * DAYS + BAR_GAP * (DAYS - 1)), and the loop count in the day-totals shell script, so onetimew summaryper bar. Raise it and the extra bars click.RECENT_MAX = 4— display only: how many chips the left column holds.fetchTagscapsthere; the cap survives because the chip row is beside the scroll, not inside it.
The view model stopped carrying handler names with them. A bar keeps the state index it
selects as a plain number — the chart is reversed, so a bar's position is not its day —
and
m.recentcollapses from{{tag, handler}}to the tag list itself, by reference likematchedRows, since a chip's position is its index instate.recent. That is the onemodel-shape change in this PR.
3. The single-purpose handlers
A
local functionwhere two sites share one, an inline closure where a builder uses oneonce:
onScopeDay/onScopeAlllocal scopeDay/scopeAllonIpc("scope", …)onSearchSubmitlocal submitSearchonDraftSubmit+onStartDraftlocal submitDraftonStoplocal stopTrackingonIpc("stop")onClearFilterfunction() setFilter("") endonSearchChange/onDraftChangefunction(value) apply{…} endonStartDraft's entire body wasonDraftSubmit()— a second name for one behaviour thatexisted only because a prop could carry a name and not a function. The idle action button
and the tag field's Enter share the one local.
onIpc("stop")also stopped re-spelling{"timew", "stop"}, so the file has one stop command instead of two.4. Key stability for the two identity-sensitive nodes
A closure's registered name is
prefix .. path .. "#" .. prop, andpathis the chain ofancestor keys falling back to the child's position for an unkeyed node (
uiNodePath,plugin_bindings.cpp:454-458;registerUiHandler:463-470). Children count from zero(
i - 1,:555) and so does the root (readUiTreeNode(L, 1, tree, 0, read, {}, 0),:579). That same path is a node's identity across a repaint — an input's focus and caret,a
ScrollView's offset — which is what this section had to check rather than assume.ui.input"search-" .. searchKey/0/2/3/search-N→ unchangedui.input"tag-" .. draftKey/0/0/1/0/tag-N→ unchangedui.scroll/0/2/5→ unchangedNothing here gained or lost a key, and no node moved position: the right column is still
trend, chart, separator, search, scope, scroll. What did change on these nodes is only a
prop value — the two inputs'
onChange/onSubmitwent from a constant global name toa closure, and therefore to a path-derived name. Since both are keyed and their ancestor
positions are fixed, the derived name moves exactly when the key moves, which is exactly
when the field is meant to be a new node. The scroll carries no handler at all, so nothing
about it changed.
Asserted, not argued.
test.luaureimplementspathOfwith the shell's zero-basedarithmetic and pins all three literals, then pins them across a repaint (
updatewhiletracking), across a keystroke, and across a landing that shrinks the list to its
placeholder. And it pins that the keys are load-bearing rather than constant — the
search field's path must change on
setFilter, which bumpssearchKeyon purposebecause the field's text was replaced, and the tag field's must change on submit, which
empties it. Mutating
key = "search-" .. m.searchKeyto a constant fails the suite.5. Silent-drop enumeration
Every conditional in every builder, and what it puts on screen:
buildCardm.isActivebuildCardui.inputonChange+onSubmit✓buildCardm.isActiveui.buttonstopTracking✓buildCardui.buttonsubmitDraft✓buildRecentui.buttoni✓buildRecent#out == 0ui.labelbuildChartui.boxday.index✓buildChart#out == 0ui.labelbuildSearchui.inputonChange+onSubmit✓buildScopeui.buttonscopeDay/scopeAll✓buildScopem.filter ~= ""ui.buttonsetFilter("")✓buildListui.box+ui.buttoni✓buildList#out == 0ui.labelrenderNo branch produces a visible-but-dead control. Every branch that emits an interactive
node emits it wired; the three empty states are plain labels, and
ui.separator/ui.spacerare not controls.Mechanised rather than left as prose:
compare()now sweeps every state's tree and asserts(a) every
box/button/inputcarries a function-valuedonClick/onChange/onSubmit,(b) no
on*prop anywhere is a string — the handler-name scheme asserted gone, notmerely unused, since
pushCallbackwould happily resolve a leftover string against aglobal — and (c) the control count equals
2·#days + 2·#rows + #recent + 4 + (idle) + (filter), derived from the model, so a branch that drops a control fails even wheneverything it left behind is wired. It runs in all twelve model states.
6. Suite
panel.onDay3()proved a global namedonDay3existed and said nothing about whether thethird bar carried it. Every handler is now driven through the prop the tree holds, via
fire(node, prop, …), which refuses a nil handler — so the suite fails loudly where theshell would drop the click in silence. The
ROW = "^function"selector is gone (with everyclickable a closure it no longer discriminates); rows and chips are read out of their own
containers, which also proves a row is inside the viewport and a chip is not.
New coverage: all fourteen bars clicked one at a time against the date each must fetch
(
takeJobraises otherwise) plus the invisible hit area; all four chips against the tageach must start; both inputs' change and submit; both scope buttons and the chip; both
branches of the shared action button.
Mutation proof — 16 mutations, all caught:
loadDay(day.index)→loadDay(1)2026-08-29onClickdays[1]: the hit area shares the bar's closure, but its onClick is a nil -- false vs trueindex = i→index = 1days[1].index is the state index the reversal puts here -- 1 vs 3days[1]: the hit area shares the bar's closure, but its onClick is a function -- false vs truestartRecent(i)→startRecent(1)chip 2 starts the tag it is labelled with: argv[3] is "deep work", expected "focus"startRecent(i)→startRecent(i + 1)chip 1 … argv[3] is "focus", expected "deep work"onClickchip 1 carries no handlerfilterFromRow(i)→filterFromRow(1)row 2 closure filters by its own tag, got tag01setFilter("")→setFilter("x")the chip in position 3 is what clears the tagscopeDay/scopeAllswappedscope button 1 is this dayonSubmitthe search field: onSubmit is a nil, not a handler the shell can callapply→commita keystroke in the search field painted 1 times, not 0the tracking action button is one command, not 0onSubmitthe tag field: onSubmit is a nil, not a handler the shell can callkey→ constantsearchKey -- "search" vs "search-6"with no days fetched the scope button shows the -- placeholderAt least one from each family: bars (M1/M2/M14/M16), chips (M3/M4/M15), rows (M12),
single-purpose (M5–M10), key stability (M11/M13).
Line delta
timew/panel.luautest.luau27 handler definitions out of
panel.luau, the prose that explains the API contract in;the code shrinks and the file length barely moves.
test.luaugrows because it now driveseighteen controls through the tree that it used to call by name, and carries the sweep, the
node-path recomputation and the fourteen-bar/four-chip loops.
What to challenge hardest
ui.scrollstill has no key. Its identity is its position — sixth of six in theright column. I left it unkeyed deliberately: the position is asserted (
#rightKids == 6,rightKids[6].kind == "scroll", and the recomputed path/0/2/5), so a reorder failsloudly rather than silently resetting the offset, and adding
key = "history"wouldchange the derived path of the scroll and every row under it for no behavioural gain.
If you would rather the offset not depend on a positional assert, say so and it is one
prop — but it is a change to node identity, not a no-op.
m.recentis nowstate.recentby reference. The model hands out a live table. Itis the same trade
matchedRowsalready makes and nothing downstream writes it, but itis a purity surface, and
compareasserts the identity (m.recent == panel.state.recent)which locks the decision in. A per-repaint copy is one
table.moveif you disagree.box/button/inputon the tree iswired, which holds only because this panel has no decorative box and no label-shaped
button. That is true today and is the point — a future decorative node has to come to
that assert and justify itself — but it is a rule that will eventually need an exception.
/0/2/3/search-N,/0/0/1/0/tag-N,/0/2/5). They mirror the shell's arithmetic rather than calling it, so a change inuiNodePathwould leave the suite green and the claim stale. The citations are in thecomment; the alternative is no check at all.
A `ui.*` callback prop takes a function value at this plugin API: `readUiTreeNode` moves any function-valued prop into the render's own handler table and puts the generated name on the node in its place (plugin_bindings.cpp:523-524, registerUiHandler at :463-471), and a click resolves that name out of the table the newest render installed (luau_host.cpp:3084-3090). No prop needs a global to name. So the twenty-seven remaining `function onX()` globals go, and with them the two numbered families that only existed because a name had to be computed. `onTag1..4` was RECENT_MAX spelled out and `onDay1..14` was DAYS; buildRecent and buildChart capture their own loop index instead, the way buildList already did, so neither constant bounds anything but what is drawn and what a fetch costs. The single-purpose handlers become a `local function` where two sites share one and an inline closure where a builder uses one once. `onStartDraft` is deleted rather than converted: its whole body was `onDraftSubmit()`, one behaviour under two names because a prop could not carry a function. Two globals are left, and they are the two the shell calls into this entry by name: `onOpen` and `onIpc`. `update` is the third name in that set and also survives; it does not match the `^function on[A-Z]` gate. The view model carries no handler in any spelling now. A bar keeps the state index it selects -- the chart is reversed, so a bar's position is not its day -- as a plain number; the chip list collapses to the tags themselves, since a chip's position is its index in `state.recent`. `stopTracking` is the one `timew stop` in the file, shared by the action button and `onIpc("stop")`; it was spelled twice. test.luau reaches every one of these through the prop the tree carries rather than by global name, which is strictly stronger: a name selector proved a global existed, a driver proves the node holds it. `fire` refuses a nil handler, so a dropped control fails loudly where the shell drops the click in silence (luau_host.cpp:3082-3083). Added: a per-state sweep asserting every box/button/input on the tree carries a function and that no `on*` prop anywhere is a string, with the control count derived from the model; the full fourteen bars and four chips clicked one index at a time; and the shell's own node paths recomputed for the two nodes a user can feel move -- the search field's caret and the scroll's offset.Verdict: approve — no blocking findings. 37 mutations against the lines this PR wrote, zero survivors; the shell-callable boundary is exactly right in both directions; no caret- or scroll-reset sequence is constructible, and the reconciler makes the input case stronger than the PR argues. Four advisory findings, the first of which retracts a self-criticism in the description.
Blocking
None.
Advisory
1. The description understates the file: there are no leftover non-entry-point globals —
timew/panel.luau:362-365§1 of the PR body says:
and "What to challenge hardest" #3 offers to fix it: "if 'only what the shell calls' should be literal in this PR, it is a three-line
localforward declaration away."That work is already done, and was done before this PR.
timew/panel.luau:362-365reads:function loadDay(index, keepScope)at:868isf = function…under normal lexical scoping, so it assigns the local declared at:364, not a global — likewisefetchAll:892,viewModel:1094,render:1575. Enumerated rather than reasoned:panelEnv = getfenv(panel.Fmt.clock)(test.luau:627) is the module's own globals table, and dumping its keys aftertest.luau:697prints exactlyplus the suite's three injected stubs. The module's global surface is three names, all three shell entry points. The in-file comment at
:986-989— "What stays global is what the shell calls by name, and only that … and nothing else in this file does" — is therefore literally true, and it is the PR body that contradicts it. Fix: drop the "four file-level globals" paragraph and challenge #3.2.
apply's call-site enumeration still names two functions this PR deleted —timew/panel.luau:375-376Both names were deleted in this PR; the two call sites are now the inline
onChangeclosures at:1258-1259(tag field) and:1407-1408(search field). The count is still right —applyhas exactly six call sites outsidecommit(:789,:859,:880,:1259,:1408,:1629) — so only the two names need replacing, e.g. "the two inlineonChangeclosures on the tag and search fields". Nothing in the suite can catch a comment, which is why it is worth naming here.3.
setFilter's doc names a caller that no longer exists —timew/panel.luau:948(and:399)Present tense, and
onSearchSubmitis gone; the caller issubmitSearch(:1038-1041).:399has the same name in a historical clause ("onSearchSubmit did exactly that"), which reads as history but still sends a grep nowhere. This one matters slightly more than #2 because it is the only place in the file that describesextra's single caller, andsubmitSearchis precisely where the scope-riding argument lives.4. A closure-identity failure prints
<closure> vs <closure>—test.luau:1420samecompares raw (a ~= b,:1258-1263) so the assert does bite — confirmed twice below (M04, M38) — but its message runs both sides throughdump, which collapses every function to the same token. The observed failure is:which reads as a spurious failure until you go and look. The sibling assert in
rowPropalready solved this by dropping the values (test.luau:860,"row 1's dot does not share the tag's closure"). Same treatment here:Verified
1. The shell-callable boundary, re-derived from the shell rather than from the table in §1.
Every by-name entry into a plugin routes through
LuauHost::pushCallback(luau_host.cpp:3077-3091); anything without the@ui:prefix goes tolua_getglobalat:3079, and every producer is gated onhasGlobalfirst (script_runtime.cpp:701-713), so an absent global is a silent skip. The complete set reaching a[[panel]]entry:onOpen(plugin_panel.cpp:237, hot-reload:522),onClose(:256),onIpc(:320),update(script_runtime.cpp:818-821),onKey(plugin_panel.cpp:112, needs manifestcapture_keys),onFrameTick(:273-275, needssetNeedsFrameTick),onConfigChanged(script_runtime.cpp:690-694),onExit(:851-868), and a script-chosen context-menu callback name which must be a 1–128-byte string (plugin_bindings.cpp:636-650). Ruled out by reading, not assumption:onEnable/onOutputsChangedare service-only (plugin_service_host.cpp:239,:246);onClick/onRightClick/onMiddleClick/onHover/onScroll/onAudioSpectrumare bar-widget globals (plugin_widget.cpp:160-197,:322) andtimew/bar.luauis a separate host that keeps its own;onQueryand the launcher'sonActivateare launcher-only (plugin_launcher_provider.cpp:116,:184);onDisable/onLoad/onUnload/init/onTickdo not exist anywhere;renderis a library function (plugin_bindings.cpp:773), never a global the shell calls. No manifest key names a Lua function —[widget.actions]values are bar-action grammar (plugin_manifest.cpp:587-611,widget_action.cpp:88-123).Direction A — a name the shell calls that this PR deleted?
git show main:timew/panel.luaudefines exactly 27 handler globals:onTag1..4(4),onDay1..14(14), andonClearFilter/onScopeDay/onScopeAll/onSearchChange/onSearchSubmit/onDraftChange/onDraftSubmit/onStop/onStartDraft(9). None is in the shell's set, and main never definedonClose,onConfigChanged,onExit,onKeyoronFrameTick, so none was deleted. No silent dead feature.Direction B — a name kept that the shell never calls? The empirical dump in advisory #1 gives exactly
onOpen,onIpc,update.timew/plugin.tomldeclares one[[widget]]and one[[panel]], nocapture_keys, no service, no launcher; the panel asks for second ticks (:1632) and not frame ticks. No dead global.2. The path arithmetic is faithful, and the asserts can fail.
Shell, verbatim (
plugin_bindings.cpp:454-459):Children are passed
i - 1(:555), the root enters atreadUiTreeNode(L, 1, tree, 0, read, {}, 0)(:579), a node's own key names its own path and prefixes its children's (:502-511), and the registered name is"@ui:" + path + "#" + prop(registerUiHandler,:463-471).test.luau:2091-2094reproduces exactly that — one segment per node,position - 1, a non-empty string key replacing the index (the shell's check islua_type(…) == LUA_TSTRING, so thetype(key) == "string"guard matches, coercion and all). Children are read offnode.childrenas a 1-based array (:543-551), which is the shape the suite's stub builds.Re-derived independently against the rendered tree: root
/0; left column/0/0; card/0/0/1; info column/0/0/1/0; keyed tag input →/0/0/1/0/tag-N. Right column/0/2; its children are trend, chart, separator, search, scope, scroll, so the search row is/0/2/3, the keyed input/0/2/3/search-N, and the scroll/0/2/5. All three pinned literals are correct.The probe the review brief asked for — is the reimplementation self-confirming? Three test-only mutations,
panel.luauuntouched, each fails:position - 1→positionthe search field's path: /1/3/4/search-76pathOf(rightColumn().children[4], right, 4)→, 5/0/2/4/search-76pathOf/0/2/3/1The absolute literals at
test.luau:2119-2121are what pin the arithmetic; the laterdump(identity()) == dump(id0)comparisons are anchored to them. Challenge #5 is real but bounded: the literals mirroruiNodePathrather than calling it, and today they mirror it correctly.3. Caret and scroll survival — no regression, and the input is safer than argued.
ui.inputis uncontrolled (ui_tree_reconciler.cpp:1558-1571):focuslikewise fires only at creation.onChange/onSubmitre-wire only when the name string differs (:1594-1610), and re-wiring swaps a lambda, not the buffer. So the caret follows node identity — the key — and not prop churn, and the scroll offset follows the retainedScrollViewthe same way. Neither key changed in this PR.Prop-value churn per node, main → HEAD, since the claim is that only a value moved:
"onSearchChange"/"onSearchSubmit", constant@ui:/0/2/3/search-N#…searchKeybumps, which replaces the key in the same render"onDraftChange"/"onDraftSubmit", constant@ui:/0/0/1/0/tag-N#…draftKey"onDay" .. i— moved whenever#state.dayschanged@ui:/0/2/1/<p>/0/<0|1>#onClick, constant"onTag" .. i— moved when the tag's position moved@ui:/0/0/3/<tag>#onClick, follows the key"onStop"/"onStartDraft"— flipped withm.isActive@ui:/0/0/1/1#onClick, constantui.scrollNo node's handler prop became less stable and three became more stable. The scope row's spacer and total shift by one when the filter chip appears, but neither carries a handler and the two scope buttons are always children 0 and 1. I could not construct a sequence where the caret or the offset resets and did not before.
4. The silent-drop enumeration is exhaustive, and the empty states are actually swept.
Every conditional in the seven builders, read from source:
buildCardactive/idle (label vs wired input) and its action button (both arms functions,:1286);buildRecentper-chip +#out == 0;buildTrendfilter/no-filter (a label);buildChartper-day + the#out == 0earlyreturnat:1385-1387;buildSearch(no branch);scopeButton(no branch);buildScopem.filter ~= "";buildListper-row +#out == 0, plus thetooltipternary, which is not a control;render(no branch);display. The only branch touching an interactive prop is the action button's.ui.glyph,ui.separator,ui.spacerandui.labeltake no click at this API.sweepruns in all twelvecheck(…)states, and "idle, nothing fetched" exercises all three empty branches at once — turning any one of the three empty-stateui.labels into aui.buttonfails witha button is on screen carrying no handler at all(M33/M34/M35). The count identity matches the builders term for term, and deletingbuildScope(m)fromrenderfails on it (M32). No branch emits a visible-but-dead control.5. Gates, the handler count, and the leftover-string sweep.
noctalia plugins linttakes a path (Usage: noctalia plugins lint [path ...]; from/tmpit answerserror: path not found: timew), so run from the worktree it lints this branch'stimew/and not an installed copy — the gate is real for this PR.grep -cE "^function on[A-Z]" timew/panel.luau→ 2 (onOpen:1623,onIpc:1661),updatethe third; 27 definitions removed, none added;panel.luauis 1700 lines on both sides. No deleted name survives as a live reference:git grepputs every remainingonTag/onDay/onScope*/onSearch*/onDraft*/onStop/onStartDrafthit inside a comment,onClearFilteris gone from the repo entirely, andgrep -n '"on[A-Z]' timew/panel.luaureturns one hit — the comment at:1080. That is the sweep's rule (b) confirmed from outside the suite, which matters becausepushCallbackwould have resolved a leftover string against a global (luau_host.cpp:3078-3080).The by-reference
m.recentholds:state.recentis only ever replaced (:847,commit({ recent = table.move(…) })) and never mutated in place, so nothing can alias a stale list.Not checked
ui_tree_reconciler.cpp:1558-1610and the uncontrolled-input contract, not observed on screen.bar.luau,plugin.toml, translations,README.md— untouched by this PR; read, not exercised. README still names old handlers in places; that is U8's.kUiTreeMaxChildren = 256(plugin_bindings.cpp:378) againstALL_WALK_MAX = 200: headroom confirmed, but the 200-row case belongs to #12 and was not re-run.kind/props/childrenand not the shell'stypefield.Mutations
37 mutations against lines this PR wrote, each restored byte-identically (
md5sumback tobb108a0…/0424bfc…after every run). Zero survivors. Baseline before and after:viewModel: 12 states, 1739 field comparisons / ok.buildChart:1356loadDay(day.index)→loadDay(1)test.luau:922no queued walk for2026-08-29:1356→loadDay(day.index + 1):1356→loadDay(chart[#chart].index)(all bars → last day)viewModel:1150index = i→index = 1days[1].index is the state index the reversal puts here -- 1 vs 3:1367hit area losesonClickthe hit area shares the bar's closure -- nil vs <closure>:1367hit area gets its own closurethe hit area shares the bar's closure:1374visible bar gets its own closurebuildRecent:1311startRecent(i)→startRecent(1)(two chips, one index)chip 2 starts the tag it is labelled with: argv[3] is "deep work", expected "focus":1311→startRecent(i + 1)chip 1 …: argv[3] is "focus", expected "deep work":1310-1312chip losesonClickchip 1 carries no handler:1305chip loseskeyrecent[1] keys its own chip -- nil vs "deep work"buildList:1508filterFromRow(i)→filterFromRow(1)row 2 closure filters by its own tag, got tag01:1526row dot losesonClickrow 1's dot does not share the tag's closure:1526row dot gets its own closurebuildScope:1448-1449scopeDay/scopeAllswappedscope button 1 is this day:1462setFilter("")→setFilter("x")the chip in position 3 is what clears the tagscopeButton:1424dropsonClick = handlerthe scope switch is position 5, with its buttons wiredbuildCard:1286action-button branches swappedit is the very start the field's Enter sends: 2 elements, expected 3buildCard:1258/1261onChange/onSubmitswappedthe tag field's Enter is one command, not 0:1261tag field losesonSubmitthe tag field: onSubmit is a nil, not a handler the shell can call:1258-1260tag field losesonChangethe tag field: onChange is a nil, …buildSearch:1407/1410onChange/onSubmitswappeda keystroke in the search field painted 1 times, not 0:1407-1409search field losesonChangethe search field: onChange is a nil, …:1398searchkey→ constant"search"searchKey -- "search" vs "search-6":1248tagkey→ constant"tag"draftKey -- "tag" vs "tag-2"render:1618scroll gainskey = "history"the scroll carries no key, so its identity is its positionrender:1613extra child at the head of the right column (moves both the search input's and the scroll's ancestor)the right column is trend, chart, separator, search, scope, scroll: got 7buildCard:1266extra child before the clock (moves the tag field's ancestor slot)clockText -- "zz" vs "--:--"render:1617-1618scroll and scope row swappedthe walk the current tag asked for is the one that listsrender:1617buildScope(m)deletedwith no days fetched the scope button shows the -- placeholderviewModel:1200recent = state.recent→ per-repaint copyrecent is state's own list, not a copy of itbuildRecent:1317empty-state label → buttona button is on screen carrying no handler at allbuildChart:1386empty-state label → buttonbuildList:1559empty-state label → button:2093position - 1→positionthe search field's path: /1/3/4/search-76:2102right-column position4→5/0/2/4/search-76:2093key-replaces-index removed/0/2/3/1Coverage by family: bars M01/M02/M03/M04/M19/M36/M38, chips M05/M06/M07/M08, rows M09/M10/M37, scope M11/M12/M29, card M13/M15/M16/M30, search M14/M31, node identity M17/M18/M28, structure M21/M22/M23/M32, model shape M20, empty states M33/M34/M35, and the suite's own path arithmetic M24/M25/M26.
Rulings on "what to challenge hardest"
ui.scroll. Agreed as written.#rightKids == 6,rightKids[6].kind == "scroll"and the recomputed/0/2/5make a reorder fail loudly — M23 and M21 both land on those asserts. Addingkey = "history"would move the scroll's path and every row path under it (M28 shows the assert that would then need rewriting) for no behavioural gain.m.recentby reference. Safe:state.recentis only ever replaced (:847), never mutated, and M20 shows the identity assert locks the decision in. Same tradematchedRowsalready makes.CLICKABLEis the place a future decorative box has to argue its case.uiNodePathcorrectly (verified verbatim above) and M24/M25/M26 prove they are load-bearing. The residual — a change inuiNodePathleaving the suite green — is real and cheap next to no check at all.