refactor(panel): hand every control its own closure, delete the handler-name scheme #13

Merged
yuris merged 2 commits from upgrade/u3-closures into main 2026-09-01 14:43:22 +00:00
Owner

A ui.* callback prop takes a function value at this plugin API, so no handler needs a
global to name. The twenty-seven remaining function onX() globals go, and the numbered
families that only existed because a name had to be computed go with them.

Gates

$ nix shell nixpkgs#luau -c luau test.luau
viewModel: 12 states, 1739 field comparisons
ok

$ noctalia plugins lint timew
yuris/timew
  ok

0 errors, 0 warnings

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 to lua_getglobal
(luau_host.cpp:3077-3080); the producers are ScriptRuntime::enqueueCall* and the two
sites inside script_runtime.cpp itself. Every one of them is gated on hasGlobal first
(script_runtime.cpp:690-712), so an absent global is a silent skip, never an error.

The complete set for a [[panel]] entry:

Global Citation Trigger Gate
onOpen plugin_panel.cpp:237, :522 panel opens; re-fired after hot reload while open —
onClose plugin_panel.cpp:256 panel closes —
onIpc plugin_panel.cpp:320, probe script_runtime.cpp:825 noctalia msg plugin … probe decides whether IPC dispatches at all
update script_runtime.cpp:818-820; producer plugin_panel.cpp:299-308 once after load, then per second tick while setWantsSecondTicks(true) and open —
onKey plugin_panel.cpp:112, probe :830 manifest capture_keys chord needs a manifest key spec
onFrameTick plugin_panel.cpp:273-275 animation frame while setNeedsFrameTick(true) needs the script to ask
onConfigChanged script_runtime.cpp:690-695 settings changed in place —
onExit script_runtime.cpp:851-869 reload / disable / uninstall / shutdown —
(script-chosen) plugin_bindings.cpp:626-650, plugin_panel.cpp:406-423 panel.openContextMenu{onActivate="fnName"} item activated must be a 1–128 byte string; a closure is rejected at the binding

Not in the set, checked rather than assumed: onEnable and onOutputsChanged are
[[service]]-only (plugin_service_host.cpp:239, :246). onClick, onRightClick,
onMiddleClick, onHover, onScroll, onAudioSpectrum are bar-widget globals
(plugin_widget.cpp:162-197, :322) — note onScroll is a bar global and has nothing
to do with ui.scroll, whose onScroll is an ordinary prop. onQuery and the launcher's
onActivate are launcher-provider only (plugin_launcher_provider.cpp:116, :184).
onLoad, onUnload, onDisable, init, onTick do not exist anywhere in the shell, and
render is 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 grammar
strings parsed in widget_action.cpp:31-60, never globals.

timew/plugin.toml declares one [[widget]] and one [[panel]], no capture_keys, no
service, no launcher. The panel asks for second ticks and not frame ticks, and U5 has not
added the context menu yet. So panel.luau needs exactly three of those names, and
defines exactly three: onOpen, onIpc, update.

Gate arithmetic. grep -cE "^function on[A-Z]" timew/panel.luau → 2, not 3: the
pattern matches on-prefixed names and update is not one. Both survivors it does count
are shell entry points, and the third survivor it cannot see is too.

$ grep -cE "^function on[A-Z]" timew/panel.luau
2
$ grep -nE "^function on[A-Z]" timew/panel.luau
1624:function onOpen(_context)
1662:function onIpc(event, payload)

And nothing else in the file is global. render, viewModel, loadDay and fetchAll
are forward-declared as locals at timew/panel.luau:362-365, so function loadDay(…) at
:869 is f = function… under normal lexical scoping and assigns that local — likewise
fetchAll:893, viewModel:1095, render:1576. Enumerated rather than reasoned: dumping
getfenv(panel.Fmt.clock) after the suite's last state prints exactly onOpen, onIpc and
update plus the three stubs the suite injects. The in-file comment at :987-990 — "what
stays 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..4 existed because RECENT_MAX was 4 and onDay1..14 because DAYS was 14: each
name 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 way buildList
already 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 one
    timew summary per bar. Raise it and the extra bars click.
  • RECENT_MAX = 4 — display only: how many chips the left column holds. fetchTags caps
    there; 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.recent collapses from {{tag, handler}} to the tag list itself, by reference like
matchedRows, since a chip's position is its index in state.recent. That is the one
model-shape change in this PR.

3. The single-purpose handlers

A local function where two sites share one, an inline closure where a builder uses one
once:

was now why
onScopeDay / onScopeAll local scopeDay / scopeAll second caller is onIpc("scope", …)
onSearchSubmit local submitSearch one site, but it carries the scope-riding prose
onDraftSubmit + onStartDraft local submitDraft two names, one behaviour
onStop local stopTracking second caller is onIpc("stop")
onClearFilter inline function() setFilter("") end one site, one statement
onSearchChange / onDraftChange inline function(value) apply{…} end one site, one statement

onStartDraft's entire body was onDraftSubmit() — a second name for one behaviour that
existed 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, and path is the chain of
ancestor 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.

node key before key after shell path before → after
search ui.input "search-" .. searchKey unchanged /0/2/3/search-N → unchanged
tag/draft ui.input "tag-" .. draftKey unchanged /0/0/1/0/tag-N → unchanged
ui.scroll (none — positional) unchanged /0/2/5 → unchanged

Nothing 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/onSubmit went from a constant global name to
a 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.luau reimplements pathOf with the shell's zero-based
arithmetic and pins all three literals, then pins them across a repaint (update while
tracking), 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 bumps searchKey on purpose
because the field's text was replaced, and the tag field's must change on submit, which
empties it. Mutating key = "search-" .. m.searchKey to a constant fails the suite.

5. Silent-drop enumeration

Every conditional in every builder, and what it puts on screen:

builder branch interactive node handler
buildCard m.isActive column + label none needed — not a control
buildCard idle ui.input onChange + onSubmit ✓
buildCard m.isActive action ui.button stopTracking ✓
buildCard idle action ui.button submitDraft ✓
buildRecent per chip ui.button closure over i ✓
buildRecent #out == 0 ui.label none needed
buildChart per day 2 × ui.box one shared closure over day.index ✓
buildChart #out == 0 ui.label none needed
buildSearch (none) ui.input onChange + onSubmit ✓
buildScope always 2 × ui.button scopeDay / scopeAll ✓
buildScope m.filter ~= "" chip ui.button setFilter("") ✓
buildList per row ui.box + ui.button one shared closure over i ✓
buildList #out == 0 ui.label none needed
render (none) — —

No 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.spacer are not controls.

Mechanised rather than left as prose: compare() now sweeps every state's tree and asserts
(a) every box/button/input carries a function-valued onClick/onChange/onSubmit,
(b) no on* prop anywhere is a string — the handler-name scheme asserted gone, not
merely unused, since pushCallback would happily resolve a leftover string against a
global — 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 when
everything it left behind is wired. It runs in all twelve model states.

6. Suite

panel.onDay3() proved a global named onDay3 existed and said nothing about whether the
third 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 the
shell would drop the click in silence. The ROW = "^function" selector is gone (with every
clickable 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
(takeJob raises otherwise) plus the invisible hit area; all four chips against the tag
each 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:

# mutation caught by
M1 loadDay(day.index) → loadDay(1) no queued walk for 2026-08-29
M2 bar hit area loses onClick days[1]: the hit area shares the bar's closure, but its onClick is a nil -- false vs true
M14 model index = i → index = 1 days[1].index is the state index the reversal puts here -- 1 vs 3
M16 hit area gets its own closure days[1]: the hit area shares the bar's closure, but its onClick is a function -- false vs true
M3 startRecent(i) → startRecent(1) chip 2 starts the tag it is labelled with: argv[3] is "deep work", expected "focus"
M15 startRecent(i) → startRecent(i + 1) chip 1 … argv[3] is "focus", expected "deep work"
M4 chip loses onClick chip 1 carries no handler
M12 filterFromRow(i) → filterFromRow(1) row 2 closure filters by its own tag, got tag01
M5 chip setFilter("") → setFilter("x") the chip in position 3 is what clears the tag
M6 scopeDay / scopeAll swapped scope button 1 is this day
M7 search field loses onSubmit the search field: onSubmit is a nil, not a handler the shell can call
M8 search apply → commit a keystroke in the search field painted 1 times, not 0
M9 action button never stops the tracking action button is one command, not 0
M10 tag field loses onSubmit the tag field: onSubmit is a nil, not a handler the shell can call
M11 search key → constant searchKey -- "search" vs "search-6"
M13 scroll moved above the scope row with no days fetched the scope button shows the -- placeholder

At 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

file total code comment
timew/panel.luau 1700 → 1701 (+1) 882 → 859 (−23) 718 → 749 (+31)
test.luau 2010 → 2444 (+434) 1218 → 1479 (+261) 650 → 787 (+137)

27 handler definitions out of panel.luau, the prose that explains the API contract in;
the code shrinks and the file length barely moves. test.luau grows because it now drives
eighteen 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

  1. The ui.scroll still has no key. Its identity is its position — sixth of six in the
    right 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 fails
    loudly rather than silently resetting the offset, and adding key = "history" would
    change 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.
  2. m.recent is now state.recent by reference. The model hands out a live table. It
    is the same trade matchedRows already makes and nothing downstream writes it, but it
    is a purity surface, and compare asserts the identity (m.recent == panel.state.recent)
    which locks the decision in. A per-repaint copy is one table.move if you disagree.
  3. The sweep's totality claim. It asserts every box/button/input on the tree is
    wired, 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.
  4. The recomputed node paths are literals (/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 in
    uiNodePath would leave the suite green and the claim stale. The citations are in the
    comment; the alternative is no check at all.
A `ui.*` callback prop takes a function value at this plugin API, so no handler needs a global to name. The twenty-seven remaining `function onX()` globals go, and the numbered families that only existed because a name had to be computed go with them. ## Gates ``` $ nix shell nixpkgs#luau -c luau test.luau viewModel: 12 states, 1739 field comparisons ok $ noctalia plugins lint timew yuris/timew ok 0 errors, 0 warnings ``` ## 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 to `lua_getglobal` (`luau_host.cpp:3077-3080`); the producers are `ScriptRuntime::enqueueCall*` and the two sites inside `script_runtime.cpp` itself. Every one of them is gated on `hasGlobal` first (`script_runtime.cpp:690-712`), so an absent global is a silent skip, never an error. **The complete set for a `[[panel]]` entry:** | Global | Citation | Trigger | Gate | |---|---|---|---| | `onOpen` | `plugin_panel.cpp:237`, `:522` | panel opens; re-fired after hot reload while open | — | | `onClose` | `plugin_panel.cpp:256` | panel closes | — | | `onIpc` | `plugin_panel.cpp:320`, probe `script_runtime.cpp:825` | `noctalia msg plugin …` | probe decides whether IPC dispatches at all | | `update` | `script_runtime.cpp:818-820`; producer `plugin_panel.cpp:299-308` | once after load, then per second tick while `setWantsSecondTicks(true)` and open | — | | `onKey` | `plugin_panel.cpp:112`, probe `:830` | manifest `capture_keys` chord | needs a manifest key spec | | `onFrameTick` | `plugin_panel.cpp:273-275` | animation frame while `setNeedsFrameTick(true)` | needs the script to ask | | `onConfigChanged` | `script_runtime.cpp:690-695` | settings changed in place | — | | `onExit` | `script_runtime.cpp:851-869` | reload / disable / uninstall / shutdown | — | | *(script-chosen)* | `plugin_bindings.cpp:626-650`, `plugin_panel.cpp:406-423` | `panel.openContextMenu{onActivate="fnName"}` item activated | **must** be a 1–128 byte string; a closure is rejected at the binding | **Not in the set, checked rather than assumed:** `onEnable` and `onOutputsChanged` are `[[service]]`-only (`plugin_service_host.cpp:239`, `:246`). `onClick`, `onRightClick`, `onMiddleClick`, `onHover`, `onScroll`, `onAudioSpectrum` are bar-widget globals (`plugin_widget.cpp:162-197`, `:322`) — note `onScroll` is a *bar* global and has nothing to do with `ui.scroll`, whose `onScroll` is an ordinary prop. `onQuery` and the launcher's `onActivate` are launcher-provider only (`plugin_launcher_provider.cpp:116`, `:184`). `onLoad`, `onUnload`, `onDisable`, `init`, `onTick` do not exist anywhere in the shell, and `render` is 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 grammar strings parsed in `widget_action.cpp:31-60`, never globals. `timew/plugin.toml` declares one `[[widget]]` and one `[[panel]]`, no `capture_keys`, no service, no launcher. The panel asks for second ticks and not frame ticks, and U5 has not added the context menu yet. **So `panel.luau` needs exactly three of those names, and defines exactly three: `onOpen`, `onIpc`, `update`.** **Gate arithmetic.** `grep -cE "^function on[A-Z]" timew/panel.luau` → **2**, not 3: the pattern matches `on`-prefixed names and `update` is not one. Both survivors it does count are shell entry points, and the third survivor it cannot see is too. ``` $ grep -cE "^function on[A-Z]" timew/panel.luau 2 $ grep -nE "^function on[A-Z]" timew/panel.luau 1624:function onOpen(_context) 1662:function onIpc(event, payload) ``` **And nothing else in the file is global.** `render`, `viewModel`, `loadDay` and `fetchAll` are forward-declared as locals at `timew/panel.luau:362-365`, so `function loadDay(…)` at `:869` is `f = function…` under normal lexical scoping and assigns that local — likewise `fetchAll:893`, `viewModel:1095`, `render:1576`. Enumerated rather than reasoned: dumping `getfenv(panel.Fmt.clock)` after the suite's last state prints exactly `onOpen`, `onIpc` and `update` plus the three stubs the suite injects. The in-file comment at `:987-990` — "what stays 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..4` existed because `RECENT_MAX` was 4 and `onDay1..14` because `DAYS` was 14: each name 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 way `buildList` already 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 one `timew summary` per bar. Raise it and the extra bars click. - `RECENT_MAX = 4` — display only: how many chips the left column holds. `fetchTags` caps there; 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.recent` collapses from `{{tag, handler}}` to the tag list itself, by reference like `matchedRows`, since a chip's position *is* its index in `state.recent`. That is the one model-shape change in this PR. ## 3. The single-purpose handlers A `local function` where two sites share one, an inline closure where a builder uses one once: | was | now | why | |---|---|---| | `onScopeDay` / `onScopeAll` | `local scopeDay` / `scopeAll` | second caller is `onIpc("scope", …)` | | `onSearchSubmit` | `local submitSearch` | one site, but it carries the scope-riding prose | | `onDraftSubmit` + `onStartDraft` | `local submitDraft` | **two names, one behaviour** | | `onStop` | `local stopTracking` | second caller is `onIpc("stop")` | | `onClearFilter` | inline `function() setFilter("") end` | one site, one statement | | `onSearchChange` / `onDraftChange` | inline `function(value) apply{…} end` | one site, one statement | `onStartDraft`'s entire body was `onDraftSubmit()` — a second name for one behaviour that existed 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`, and `path` is the chain of ancestor 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. | node | key before | key after | shell path before → after | |---|---|---|---| | search `ui.input` | `"search-" .. searchKey` | **unchanged** | `/0/2/3/search-N` → **unchanged** | | tag/draft `ui.input` | `"tag-" .. draftKey` | **unchanged** | `/0/0/1/0/tag-N` → **unchanged** | | `ui.scroll` | *(none — positional)* | **unchanged** | `/0/2/5` → **unchanged** | Nothing 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`/`onSubmit` went from a constant global name to a 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.luau` reimplements `pathOf` with the shell's zero-based arithmetic and pins all three literals, then pins them across a repaint (`update` while tracking), 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 bumps `searchKey` on purpose because the field's text was replaced, and the tag field's *must* change on submit, which empties it. Mutating `key = "search-" .. m.searchKey` to a constant fails the suite. ## 5. Silent-drop enumeration Every conditional in every builder, and what it puts on screen: | builder | branch | interactive node | handler | |---|---|---|---| | `buildCard` | `m.isActive` | column + label | none needed — not a control | | `buildCard` | idle | `ui.input` | `onChange` + `onSubmit` ✓ | | `buildCard` | `m.isActive` | action `ui.button` | `stopTracking` ✓ | | `buildCard` | idle | action `ui.button` | `submitDraft` ✓ | | `buildRecent` | per chip | `ui.button` | closure over `i` ✓ | | `buildRecent` | `#out == 0` | `ui.label` | none needed | | `buildChart` | per day | 2 × `ui.box` | one shared closure over `day.index` ✓ | | `buildChart` | `#out == 0` | `ui.label` | none needed | | `buildSearch` | *(none)* | `ui.input` | `onChange` + `onSubmit` ✓ | | `buildScope` | always | 2 × `ui.button` | `scopeDay` / `scopeAll` ✓ | | `buildScope` | `m.filter ~= ""` | chip `ui.button` | `setFilter("")` ✓ | | `buildList` | per row | `ui.box` + `ui.button` | one shared closure over `i` ✓ | | `buildList` | `#out == 0` | `ui.label` | none needed | | `render` | *(none)* | — | — | **No 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.spacer` are not controls. Mechanised rather than left as prose: `compare()` now sweeps every state's tree and asserts (a) every `box`/`button`/`input` carries a function-valued `onClick`/`onChange`/`onSubmit`, (b) **no `on*` prop anywhere is a string** — the handler-name scheme asserted *gone*, not merely unused, since `pushCallback` would happily resolve a leftover string against a global — 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 when everything it left behind is wired. It runs in all twelve model states. ## 6. Suite `panel.onDay3()` proved a global named `onDay3` existed and said nothing about whether the third 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 the shell would drop the click in silence. The `ROW = "^function"` selector is gone (with every clickable 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 (`takeJob` raises otherwise) plus the invisible hit area; all four chips against the tag each 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:** | # | mutation | caught by | |---|---|---| | M1 | `loadDay(day.index)` → `loadDay(1)` | no queued walk for `2026-08-29` | | M2 | bar hit area loses `onClick` | `days[1]: the hit area shares the bar's closure, but its onClick is a nil -- false vs true` | | M14 | model `index = i` → `index = 1` | `days[1].index is the state index the reversal puts here -- 1 vs 3` | | M16 | hit area gets its *own* closure | `days[1]: the hit area shares the bar's closure, but its onClick is a function -- false vs true` | | M3 | `startRecent(i)` → `startRecent(1)` | `chip 2 starts the tag it is labelled with: argv[3] is "deep work", expected "focus"` | | M15 | `startRecent(i)` → `startRecent(i + 1)` | `chip 1 … argv[3] is "focus", expected "deep work"` | | M4 | chip loses `onClick` | `chip 1 carries no handler` | | M12 | `filterFromRow(i)` → `filterFromRow(1)` | `row 2 closure filters by its own tag, got tag01` | | M5 | chip `setFilter("")` → `setFilter("x")` | `the chip in position 3 is what clears the tag` | | M6 | `scopeDay` / `scopeAll` swapped | `scope button 1 is this day` | | M7 | search field loses `onSubmit` | `the search field: onSubmit is a nil, not a handler the shell can call` | | M8 | search `apply` → `commit` | `a keystroke in the search field painted 1 times, not 0` | | M9 | action button never stops | `the tracking action button is one command, not 0` | | M10 | tag field loses `onSubmit` | `the tag field: onSubmit is a nil, not a handler the shell can call` | | M11 | search `key` → constant | `searchKey -- "search" vs "search-6"` | | M13 | scroll moved above the scope row | `with no days fetched the scope button shows the -- placeholder` | At 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 | file | total | code | comment | |---|---|---|---| | `timew/panel.luau` | 1700 → 1701 (**+1**) | 882 → 859 (**−23**) | 718 → 749 (**+31**) | | `test.luau` | 2010 → 2444 (**+434**) | 1218 → 1479 (**+261**) | 650 → 787 (**+137**) | 27 handler definitions out of `panel.luau`, the prose that explains the API contract in; the code shrinks and the file length barely moves. `test.luau` grows because it now drives eighteen 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 1. **The `ui.scroll` still has no key.** Its identity is its position — sixth of six in the right 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 fails loudly rather than silently resetting the offset, and adding `key = "history"` would change 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. 2. **`m.recent` is now `state.recent` by reference.** The model hands out a live table. It is the same trade `matchedRows` already makes and nothing downstream writes it, but it is a purity surface, and `compare` asserts the identity (`m.recent == panel.state.recent`) which locks the decision in. A per-repaint copy is one `table.move` if you disagree. 3. **The sweep's totality claim.** It asserts every `box`/`button`/`input` on the tree is wired, 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. 4. **The recomputed node paths are literals** (`/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 in `uiNodePath` would leave the suite green and the claim stale. The citations are in the comment; 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.
yuris left a comment

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:

Four file-level globals remain that are not shell entry points, and none is a handler: loadDay and fetchAll are globals to dodge a forward declaration […] and viewModel/render are the pair commit calls from far above them

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 local forward declaration away."

That work is already done, and was done before this PR. timew/panel.luau:362-365 reads:

local render
local viewModel
local loadDay
local fetchAll

function loadDay(index, keepScope) at :868 is f = function… under normal lexical scoping, so it assigns the local declared at :364, not a global — likewise fetchAll: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 after test.luau:697 prints exactly

ENV|onIpc|function
ENV|onOpen|function
ENV|update|function

plus 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-376

-- keystroke the input widget is already displaying (`onSearchChange`,
-- `onDraftChange`, neither of which painted before this change either). Every

Both names were deleted in this PR; the two call sites are now the inline onChange closures at :1258-1259 (tag field) and :1407-1408 (search field). The count is still right — apply has exactly six call sites outside commit (:789, :859, :880, :1259, :1408, :1629) — so only the two names need replacing, e.g. "the two inline onChange closures 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)

-- `extra` merges into the same patch, so a caller that also moves the scope with
-- the tag (onSearchSubmit) gets one repaint rather than a tree showing the new

Present tense, and onSearchSubmit is gone; the caller is submitSearch (:1038-1041). :399 has 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 describes extra's single caller, and submitSearch is precisely where the scope-riding argument lives.

4. A closure-identity failure prints <closure> vs <closure> — test.luau:1420

same(pad.props.onClick, bar.props.onClick, "days[" .. i .. "]: the hit area shares the bar's closure")

same compares raw (a ~= b, :1258-1263) so the assert does bite — confirmed twice below (M04, M38) — but its message runs both sides through dump, which collapses every function to the same token. The observed failure is:

days[1]: the hit area shares the bar's closure -- <closure> vs <closure>

which reads as a spurious failure until you go and look. The sibling assert in rowProp already solved this by dropping the values (test.luau:860, "row 1's dot does not share the tag's closure"). Same treatment here:

			same(
				pad.props.onClick == bar.props.onClick,
				true,
				"days[" .. i .. "]: the hit area shares the bar's closure"
			)

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 to lua_getglobal at :3079, and every producer is gated on hasGlobal first (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 manifest capture_keys), onFrameTick (:273-275, needs setNeedsFrameTick), 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/onOutputsChanged are service-only (plugin_service_host.cpp:239, :246); onClick/onRightClick/onMiddleClick/onHover/onScroll/onAudioSpectrum are bar-widget globals (plugin_widget.cpp:160-197, :322) and timew/bar.luau is a separate host that keeps its own; onQuery and the launcher's onActivate are launcher-only (plugin_launcher_provider.cpp:116, :184); onDisable/onLoad/onUnload/init/onTick do not exist anywhere; render is 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.luau defines exactly 27 handler globals: onTag1..4 (4), onDay1..14 (14), and onClearFilter/onScopeDay/onScopeAll/onSearchChange/onSearchSubmit/onDraftChange/onDraftSubmit/onStop/onStartDraft (9). None is in the shell's set, and main never defined onClose, onConfigChanged, onExit, onKey or onFrameTick, 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.toml declares one [[widget]] and one [[panel]], no capture_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):

std::string uiNodePath(std::string_view parentPath, std::string_view key, int position) {
  std::string path(parentPath);
  path += '/';
  path += key.empty() ? std::to_string(position) : std::string(key);
  return path;
}

Children are passed i - 1 (:555), the root enters at readUiTreeNode(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-2094 reproduces exactly that — one segment per node, position - 1, a non-empty string key replacing the index (the shell's check is lua_type(…) == LUA_TSTRING, so the type(key) == "string" guard matches, coercion and all). Children are read off node.children as 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.luau untouched, each fails:

probe result
position - 1 → position the search field's path: /1/3/4/search-76
pathOf(rightColumn().children[4], right, 4) → , 5 /0/2/4/search-76
key-replaces-index removed from pathOf /0/2/3/1

The absolute literals at test.luau:2119-2121 are what pin the arithmetic; the later dump(identity()) == dump(id0) comparisons are anchored to them. Challenge #5 is real but bounded: the literals mirror uiNodePath rather than calling it, and today they mirror it correctly.

3. Caret and scroll survival — no regression, and the input is safer than argued.

ui.input is uncontrolled (ui_tree_reconciler.cpp:1558-1571):

// Uncontrolled: the host owns the text buffer/cursor. `value` seeds the
// field once at creation; later reconciles never overwrite what the user
// typed (the node key keeps the same Input instance alive across renders).
if (!slot.seeded) { … }

focus likewise fires only at creation. onChange/onSubmit re-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 retained ScrollView the same way. Neither key changed in this PR.

Prop-value churn per node, main → HEAD, since the claim is that only a value moved:

node main HEAD churn
search input "onSearchChange" / "onSearchSubmit", constant @ui:/0/2/3/search-N#… moves only when searchKey bumps, which replaces the key in the same render
tag input "onDraftChange" / "onDraftSubmit", constant @ui:/0/0/1/0/tag-N#… same, on draftKey
chart bar + hit area "onDay" .. i — moved whenever #state.days changed @ui:/0/2/1/<p>/0/<0|1>#onClick, constant strictly less
chip "onTag" .. i — moved when the tag's position moved @ui:/0/0/3/<tag>#onClick, follows the key strictly less
action button "onStop" / "onStartDraft" — flipped with m.isActive @ui:/0/0/1/1#onClick, constant strictly less
ui.scroll no handler no handler none

No 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: buildCard active/idle (label vs wired input) and its action button (both arms functions, :1286); buildRecent per-chip + #out == 0; buildTrend filter/no-filter (a label); buildChart per-day + the #out == 0 early return at :1385-1387; buildSearch (no branch); scopeButton (no branch); buildScope m.filter ~= ""; buildList per-row + #out == 0, plus the tooltip ternary, 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.spacer and ui.label take no click at this API.

sweep runs in all twelve check(…) states, and "idle, nothing fetched" exercises all three empty branches at once — turning any one of the three empty-state ui.labels into a ui.button fails with a button is on screen carrying no handler at all (M33/M34/M35). The count identity matches the builders term for term, and deleting buildScope(m) from render fails on it (M32). No branch emits a visible-but-dead control.

5. Gates, the handler count, and the leftover-string sweep.

$ nix shell nixpkgs#luau -c luau test.luau
viewModel: 12 states, 1739 field comparisons
ok

$ noctalia plugins lint timew
yuris/timew
  ok

0 errors, 0 warnings

noctalia plugins lint takes a path (Usage: noctalia plugins lint [path ...]; from /tmp it answers error: path not found: timew), so run from the worktree it lints this branch's timew/ 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), update the third; 27 definitions removed, none added; panel.luau is 1700 lines on both sides. No deleted name survives as a live reference: git grep puts every remaining onTag/onDay/onScope*/onSearch*/onDraft*/onStop/onStartDraft hit inside a comment, onClearFilter is gone from the repo entirely, and grep -n '"on[A-Z]' timew/panel.luau returns one hit — the comment at :1080. That is the sweep's rule (b) confirmed from outside the suite, which matters because pushCallback would have resolved a leftover string against a global (luau_host.cpp:3078-3080).

The by-reference m.recent holds: state.recent is only ever replaced (:847, commit({ recent = table.move(…) })) and never mutated in place, so nothing can alias a stale list.

Not checked

  • No live shell run. The panel was not opened in noctalia. Caret and scroll survival are argued from ui_tree_reconciler.cpp:1558-1610 and 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) against ALL_WALK_MAX = 200: headroom confirmed, but the 200-row case belongs to #12 and was not re-run.
  • Luau's 200-locals ceiling for the main chunk, which this PR adds six locals to: it compiles and lints, so it is under; I did not count the margin.
  • Layout/paint. No screenshot; the tree is asserted through the suite's stub constructor, which models kind/props/children and not the shell's type field.

Mutations

37 mutations against lines this PR wrote, each restored byte-identically (md5sum back to bb108a0… / 0424bfc… after every run). Zero survivors. Baseline before and after: viewModel: 12 states, 1739 field comparisons / ok.

# mutation caught by
M01 buildChart:1356 loadDay(day.index) → loadDay(1) test.luau:922 no queued walk for 2026-08-29
M02 :1356 → loadDay(day.index + 1) same, one bar off
M36 :1356 → loadDay(chart[#chart].index) (all bars → last day) same, on bar 1
M19 viewModel:1150 index = i → index = 1 days[1].index is the state index the reversal puts here -- 1 vs 3
M03 :1367 hit area loses onClick the hit area shares the bar's closure -- nil vs <closure>
M04 :1367 hit area gets its own closure the hit area shares the bar's closure
M38 :1374 visible bar gets its own closure same
M05 buildRecent:1311 startRecent(i) → startRecent(1) (two chips, one index) chip 2 starts the tag it is labelled with: argv[3] is "deep work", expected "focus"
M06 :1311 → startRecent(i + 1) chip 1 …: argv[3] is "focus", expected "deep work"
M07 :1310-1312 chip loses onClick chip 1 carries no handler
M08 :1305 chip loses key recent[1] keys its own chip -- nil vs "deep work"
M09 buildList:1508 filterFromRow(i) → filterFromRow(1) row 2 closure filters by its own tag, got tag01
M10 :1526 row dot loses onClick row 1's dot does not share the tag's closure
M37 :1526 row dot gets its own closure same
M11 buildScope:1448-1449 scopeDay/scopeAll swapped scope button 1 is this day
M12 :1462 setFilter("") → setFilter("x") the chip in position 3 is what clears the tag
M29 scopeButton:1424 drops onClick = handler the scope switch is position 5, with its buttons wired
M13 buildCard:1286 action-button branches swapped it is the very start the field's Enter sends: 2 elements, expected 3
M15 buildCard:1258/1261 onChange/onSubmit swapped the tag field's Enter is one command, not 0
M16 :1261 tag field loses onSubmit the tag field: onSubmit is a nil, not a handler the shell can call
M30 :1258-1260 tag field loses onChange the tag field: onChange is a nil, …
M14 buildSearch:1407/1410 onChange/onSubmit swapped a keystroke in the search field painted 1 times, not 0
M31 :1407-1409 search field loses onChange the search field: onChange is a nil, …
M17 :1398 search key → constant "search" searchKey -- "search" vs "search-6"
M18 :1248 tag key → constant "tag" draftKey -- "tag" vs "tag-2"
M28 render:1618 scroll gains key = "history" the scroll carries no key, so its identity is its position
M21 render:1613 extra 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 7
M22 buildCard:1266 extra child before the clock (moves the tag field's ancestor slot) clockText -- "zz" vs "--:--"
M23 render:1617-1618 scroll and scope row swapped the walk the current tag asked for is the one that lists
M32 render:1617 buildScope(m) deleted with no days fetched the scope button shows the -- placeholder
M20 viewModel:1200 recent = state.recent → per-repaint copy recent is state's own list, not a copy of it
M33 buildRecent:1317 empty-state label → button a button is on screen carrying no handler at all
M34 buildChart:1386 empty-state label → button same
M35 buildList:1559 empty-state label → button same
M24 test :2093 position - 1 → position the search field's path: /1/3/4/search-76
M25 test :2102 right-column position 4 → 5 /0/2/4/search-76
M26 test :2093 key-replaces-index removed /0/2/3/1

Coverage 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"

  1. Unkeyed ui.scroll. Agreed as written. #rightKids == 6, rightKids[6].kind == "scroll" and the recomputed /0/2/5 make a reorder fail loudly — M23 and M21 both land on those asserts. Adding key = "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.
  2. m.recent by reference. Safe: state.recent is only ever replaced (:847), never mutated, and M20 shows the identity assert locks the decision in. Same trade matchedRows already makes.
  3. The four non-shell globals. They are not globals — see advisory #1. Nothing to do.
  4. The sweep's totality claim. Correct today and the right default; CLICKABLE is the place a future decorative box has to argue its case.
  5. Recomputed paths as literals. The literals mirror uiNodePath correctly (verified verbatim above) and M24/M25/M26 prove they are load-bearing. The residual — a change in uiNodePath leaving the suite green — is real and cheap next to no check at all.
**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: > Four file-level globals remain that are *not* shell entry points, and none is a handler: `loadDay` and `fetchAll` are globals to dodge a forward declaration […] and `viewModel`/`render` are the pair `commit` calls from far above them 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 `local` forward declaration away."* That work is already done, and was done before this PR. `timew/panel.luau:362-365` reads: ```lua local render local viewModel local loadDay local fetchAll ``` `function loadDay(index, keepScope)` at `:868` is `f = function…` under normal lexical scoping, so it assigns the **local** declared at `:364`, not a global — likewise `fetchAll: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 after `test.luau:697` prints exactly ``` ENV|onIpc|function ENV|onOpen|function ENV|update|function ``` plus 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-376` ```lua -- keystroke the input widget is already displaying (`onSearchChange`, -- `onDraftChange`, neither of which painted before this change either). Every ``` Both names were deleted in this PR; the two call sites are now the inline `onChange` closures at `:1258-1259` (tag field) and `:1407-1408` (search field). The *count* is still right — `apply` has exactly six call sites outside `commit` (`:789`, `:859`, `:880`, `:1259`, `:1408`, `:1629`) — so only the two names need replacing, e.g. "the two inline `onChange` closures 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`) ```lua -- `extra` merges into the same patch, so a caller that also moves the scope with -- the tag (onSearchSubmit) gets one repaint rather than a tree showing the new ``` Present tense, and `onSearchSubmit` is gone; the caller is `submitSearch` (`:1038-1041`). `:399` has 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 describes `extra`'s single caller, and `submitSearch` is precisely where the scope-riding argument lives. ### 4. A closure-identity failure prints `<closure> vs <closure>` — `test.luau:1420` ```lua same(pad.props.onClick, bar.props.onClick, "days[" .. i .. "]: the hit area shares the bar's closure") ``` `same` compares raw (`a ~= b`, `:1258-1263`) so the assert does bite — confirmed twice below (M04, M38) — but its message runs both sides through `dump`, which collapses every function to the same token. The observed failure is: ``` days[1]: the hit area shares the bar's closure -- <closure> vs <closure> ``` which reads as a spurious failure until you go and look. The sibling assert in `rowProp` already solved this by dropping the values (`test.luau:860`, `"row 1's dot does not share the tag's closure"`). Same treatment here: ```suggestion same( pad.props.onClick == bar.props.onClick, true, "days[" .. i .. "]: the hit area shares the bar's closure" ) ``` ## 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 to `lua_getglobal` at `:3079`, and every producer is gated on `hasGlobal` first (`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 manifest `capture_keys`), `onFrameTick` (`:273-275`, needs `setNeedsFrameTick`), `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`/`onOutputsChanged` are service-only (`plugin_service_host.cpp:239`, `:246`); `onClick`/`onRightClick`/`onMiddleClick`/`onHover`/`onScroll`/`onAudioSpectrum` are bar-widget globals (`plugin_widget.cpp:160-197`, `:322`) and `timew/bar.luau` is a separate host that keeps its own; `onQuery` and the launcher's `onActivate` are launcher-only (`plugin_launcher_provider.cpp:116`, `:184`); `onDisable`/`onLoad`/`onUnload`/`init`/`onTick` do not exist anywhere; `render` is 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.luau` defines exactly 27 handler globals: `onTag1..4` (4), `onDay1..14` (14), and `onClearFilter`/`onScopeDay`/`onScopeAll`/`onSearchChange`/`onSearchSubmit`/`onDraftChange`/`onDraftSubmit`/`onStop`/`onStartDraft` (9). None is in the shell's set, and main never defined `onClose`, `onConfigChanged`, `onExit`, `onKey` or `onFrameTick`, 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.toml` declares one `[[widget]]` and one `[[panel]]`, no `capture_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`): ```cpp std::string uiNodePath(std::string_view parentPath, std::string_view key, int position) { std::string path(parentPath); path += '/'; path += key.empty() ? std::to_string(position) : std::string(key); return path; } ``` Children are passed `i - 1` (`:555`), the root enters at `readUiTreeNode(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-2094` reproduces exactly that — one segment per node, `position - 1`, a non-empty **string** key replacing the index (the shell's check is `lua_type(…) == LUA_TSTRING`, so the `type(key) == "string"` guard matches, coercion and all). Children are read off `node.children` as 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.luau` untouched, each **fails**: | probe | result | |---|---| | `position - 1` → `position` | `the search field's path: /1/3/4/search-76` | | `pathOf(rightColumn().children[4], right, 4)` → `, 5` | `/0/2/4/search-76` | | key-replaces-index removed from `pathOf` | `/0/2/3/1` | The absolute literals at `test.luau:2119-2121` are what pin the arithmetic; the later `dump(identity()) == dump(id0)` comparisons are anchored to them. Challenge #5 is real but bounded: the literals mirror `uiNodePath` rather than calling it, and today they mirror it correctly. **3. Caret and scroll survival — no regression, and the input is safer than argued.** `ui.input` is **uncontrolled** (`ui_tree_reconciler.cpp:1558-1571`): ```cpp // Uncontrolled: the host owns the text buffer/cursor. `value` seeds the // field once at creation; later reconciles never overwrite what the user // typed (the node key keeps the same Input instance alive across renders). if (!slot.seeded) { … } ``` `focus` likewise fires only at creation. `onChange`/`onSubmit` re-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 retained `ScrollView` the same way. Neither key changed in this PR. Prop-value churn per node, main → HEAD, since the claim is that only a value moved: | node | main | HEAD | churn | |---|---|---|---| | search input | `"onSearchChange"` / `"onSearchSubmit"`, constant | `@ui:/0/2/3/search-N#…` | moves only when `searchKey` bumps, which replaces the key in the same render | | tag input | `"onDraftChange"` / `"onDraftSubmit"`, constant | `@ui:/0/0/1/0/tag-N#…` | same, on `draftKey` | | chart bar + hit area | `"onDay" .. i` — **moved whenever `#state.days` changed** | `@ui:/0/2/1/<p>/0/<0\|1>#onClick`, constant | strictly less | | chip | `"onTag" .. i` — moved when the tag's position moved | `@ui:/0/0/3/<tag>#onClick`, follows the key | strictly less | | action button | `"onStop"` / `"onStartDraft"` — flipped with `m.isActive` | `@ui:/0/0/1/1#onClick`, constant | strictly less | | `ui.scroll` | no handler | no handler | none | No 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: `buildCard` active/idle (label vs wired input) and its action button (both arms functions, `:1286`); `buildRecent` per-chip + `#out == 0`; `buildTrend` filter/no-filter (a label); `buildChart` per-day + the `#out == 0` early `return` at `:1385-1387`; `buildSearch` (no branch); `scopeButton` (no branch); `buildScope` `m.filter ~= ""`; `buildList` per-row + `#out == 0`, plus the `tooltip` ternary, 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.spacer` and `ui.label` take no click at this API. `sweep` runs in all twelve `check(…)` states, and "idle, nothing fetched" exercises all three empty branches at once — turning any one of the three empty-state `ui.label`s into a `ui.button` fails with `a button is on screen carrying no handler at all` (M33/M34/M35). The count identity matches the builders term for term, and deleting `buildScope(m)` from `render` fails on it (M32). **No branch emits a visible-but-dead control.** **5. Gates, the handler count, and the leftover-string sweep.** ``` $ nix shell nixpkgs#luau -c luau test.luau viewModel: 12 states, 1739 field comparisons ok $ noctalia plugins lint timew yuris/timew ok 0 errors, 0 warnings ``` `noctalia plugins lint` takes a **path** (`Usage: noctalia plugins lint [path ...]`; from `/tmp` it answers `error: path not found: timew`), so run from the worktree it lints this branch's `timew/` 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`), `update` the third; 27 definitions removed, none added; `panel.luau` is 1700 lines on both sides. No deleted name survives as a live reference: `git grep` puts every remaining `onTag`/`onDay`/`onScope*`/`onSearch*`/`onDraft*`/`onStop`/`onStartDraft` hit **inside a comment**, `onClearFilter` is gone from the repo entirely, and `grep -n '"on[A-Z]' timew/panel.luau` returns one hit — the comment at `:1080`. That is the sweep's rule (b) confirmed from outside the suite, which matters because `pushCallback` would have resolved a leftover string against a global (`luau_host.cpp:3078-3080`). The by-reference `m.recent` holds: `state.recent` is only ever *replaced* (`:847`, `commit({ recent = table.move(…) })`) and never mutated in place, so nothing can alias a stale list. ## Not checked - **No live shell run.** The panel was not opened in noctalia. Caret and scroll survival are argued from `ui_tree_reconciler.cpp:1558-1610` and 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`) against `ALL_WALK_MAX = 200`: headroom confirmed, but the 200-row case belongs to #12 and was not re-run. - **Luau's 200-locals ceiling** for the main chunk, which this PR adds six locals to: it compiles and lints, so it is under; I did not count the margin. - **Layout/paint.** No screenshot; the tree is asserted through the suite's stub constructor, which models `kind`/`props`/`children` and not the shell's `type` field. ## Mutations 37 mutations against lines this PR wrote, each restored byte-identically (`md5sum` back to `bb108a0…` / `0424bfc…` after every run). **Zero survivors.** Baseline before and after: `viewModel: 12 states, 1739 field comparisons / ok`. | # | mutation | caught by | |---|---|---| | M01 | `buildChart:1356` `loadDay(day.index)` → `loadDay(1)` | `test.luau:922` no queued walk for `2026-08-29` | | M02 | `:1356` → `loadDay(day.index + 1)` | same, one bar off | | M36 | `:1356` → `loadDay(chart[#chart].index)` (all bars → last day) | same, on bar 1 | | M19 | `viewModel:1150` `index = i` → `index = 1` | `days[1].index is the state index the reversal puts here -- 1 vs 3` | | M03 | `:1367` hit area loses `onClick` | `the hit area shares the bar's closure -- nil vs <closure>` | | M04 | `:1367` hit area gets its own closure | `the hit area shares the bar's closure` | | M38 | `:1374` visible bar gets its own closure | same | | M05 | `buildRecent:1311` `startRecent(i)` → `startRecent(1)` (two chips, one index) | `chip 2 starts the tag it is labelled with: argv[3] is "deep work", expected "focus"` | | M06 | `:1311` → `startRecent(i + 1)` | `chip 1 …: argv[3] is "focus", expected "deep work"` | | M07 | `:1310-1312` chip loses `onClick` | `chip 1 carries no handler` | | M08 | `:1305` chip loses `key` | `recent[1] keys its own chip -- nil vs "deep work"` | | M09 | `buildList:1508` `filterFromRow(i)` → `filterFromRow(1)` | `row 2 closure filters by its own tag, got tag01` | | M10 | `:1526` row dot loses `onClick` | `row 1's dot does not share the tag's closure` | | M37 | `:1526` row dot gets its own closure | same | | M11 | `buildScope:1448-1449` `scopeDay`/`scopeAll` swapped | `scope button 1 is this day` | | M12 | `:1462` `setFilter("")` → `setFilter("x")` | `the chip in position 3 is what clears the tag` | | M29 | `scopeButton:1424` drops `onClick = handler` | `the scope switch is position 5, with its buttons wired` | | M13 | `buildCard:1286` action-button branches swapped | `it is the very start the field's Enter sends: 2 elements, expected 3` | | M15 | `buildCard:1258/1261` `onChange`/`onSubmit` swapped | `the tag field's Enter is one command, not 0` | | M16 | `:1261` tag field loses `onSubmit` | `the tag field: onSubmit is a nil, not a handler the shell can call` | | M30 | `:1258-1260` tag field loses `onChange` | `the tag field: onChange is a nil, …` | | M14 | `buildSearch:1407/1410` `onChange`/`onSubmit` swapped | `a keystroke in the search field painted 1 times, not 0` | | M31 | `:1407-1409` search field loses `onChange` | `the search field: onChange is a nil, …` | | M17 | `:1398` search `key` → constant `"search"` | `searchKey -- "search" vs "search-6"` | | M18 | `:1248` tag `key` → constant `"tag"` | `draftKey -- "tag" vs "tag-2"` | | M28 | `render:1618` scroll gains `key = "history"` | `the scroll carries no key, so its identity is its position` | | M21 | `render:1613` extra 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 7` | | M22 | `buildCard:1266` extra child before the clock (moves the tag field's ancestor slot) | `clockText -- "zz" vs "--:--"` | | M23 | `render:1617-1618` scroll and scope row swapped | `the walk the current tag asked for is the one that lists` | | M32 | `render:1617` `buildScope(m)` deleted | `with no days fetched the scope button shows the -- placeholder` | | M20 | `viewModel:1200` `recent = state.recent` → per-repaint copy | `recent is state's own list, not a copy of it` | | M33 | `buildRecent:1317` empty-state label → button | `a button is on screen carrying no handler at all` | | M34 | `buildChart:1386` empty-state label → button | same | | M35 | `buildList:1559` empty-state label → button | same | | M24 | *test* `:2093` `position - 1` → `position` | `the search field's path: /1/3/4/search-76` | | M25 | *test* `:2102` right-column position `4` → `5` | `/0/2/4/search-76` | | M26 | *test* `:2093` key-replaces-index removed | `/0/2/3/1` | Coverage 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" 1. **Unkeyed `ui.scroll`.** Agreed as written. `#rightKids == 6`, `rightKids[6].kind == "scroll"` and the recomputed `/0/2/5` make a reorder fail loudly — M23 and M21 both land on those asserts. Adding `key = "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. 2. **`m.recent` by reference.** Safe: `state.recent` is only ever replaced (`:847`), never mutated, and M20 shows the identity assert locks the decision in. Same trade `matchedRows` already makes. 3. **The four non-shell globals.** They are not globals — see advisory #1. Nothing to do. 4. **The sweep's totality claim.** Correct today and the right default; `CLICKABLE` is the place a future decorative box has to argue its case. 5. **Recomputed paths as literals.** The literals mirror `uiNodePath` correctly (verified verbatim above) and M24/M25/M26 prove they are load-bearing. The residual — a change in `uiNodePath` leaving the suite green — is real and cheap next to no check at all.
Review findings 2-4 on #13, all prose but one:

- `apply`'s call-site enumeration named `onSearchChange`/`onDraftChange`,
  both deleted in this wave. The two sites are the inline `onChange`
  closures on the tag and search fields; the count of six is unchanged.
- `setFilter`'s note on `extra` named `onSearchSubmit` in the present
  tense. The caller is `submitSearch`. The historical clause above
  `commit` keeps the old name, which is what it is describing.
- The bar/hit-area shared-closure assert compared the two functions
  through `dump`, which collapses every closure to `<closure>`, so a real
  failure printed `<closure> vs <closure>` and read as spurious. It
  compares the equality as a boolean now and names the type of the hit
  area's own prop, which is what separates the two failure modes: `nil`
  is a dropped handler, `function` is a second closure.

Swept the rest rather than reading for them: the thirteen remaining hits
for a deleted handler name are past-tense history or claims that the name
does not exist (`there is no fifteenth bar to click, and no onDay15 to
name either`), and both are true as written.
yuris merged commit 11d8841382 into main 2026-09-01 14:43:22 +00:00
yuris deleted branch upgrade/u3-closures 2026-09-01 14:43:23 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/noctalia-timew!13
No description provided.