feat(timew): open the panel on left click, drop the row tooltip and today's total #18

Merged
yuris merged 1 commit from feat/click-bindings into main 2026-09-02 04:40:07 +00:00
Owner

Four changes from one report: two behaviour, two UI faults.

Left click opens the panel; right click is unbound

Left click was the start/stop toggle and right click opened the panel. They
swap, and only half of that is a rebinding — onRightClick is deleted, not
pointed at nothing.

That is a product choice, not a workaround: the bar ships no default on right
click, so the button a user is most likely to rebind is theirs to claim, and
claiming it is how you start and stop from the bar:

[widget.timew.actions]
right = "plugin yuris/timew:bar all toggle"

Starts the tag setting, stops what runs. Bindings resolve per gesture key
rather than per table (widget_action.cpp:66-123), so this leaves the
manifest's middle = "settings-open-widget" in place — verified live with both
set at once.

Correction to the original description of this PR. It claimed the reason
was that "which of a gesture and a widget-level global wins when both claim a
button is unestablished". It is established, and the comments in bar.luau,
plugin.toml and the working-tree README that said otherwise have been
rewritten. widget.cpp:265-270 runs
m_innerArea->setAcceptedButtons(m_innerBaseButtons & ~mask) on the plugin's
own input area — the source comment there says this is "what lets a config
binding override a gesture the widget still handles itself" — and
input_dispatcher.cpp:55-61 selects exactly one innermost accepting area. A
configured binding therefore always wins and the Lua global never fires for
that button; the two are mutually exclusive per button, and the rule is
button-agnostic. A plugin can ship an overridable Lua default for a
rebindable button. This one chooses not to, on right click only.

toggle is now the only route to start/stop from the bar, so the suite drives
the start argv, the failure toast and the cross-entry stop comparison through
onIpc instead of onClick. The absence of onRightClick is asserted, not
assumed.

The row tooltip covered its own context menu

Rows carried Filter by <tag>. A hover tooltip outlives the press that opens
the context menu, so right-clicking a row drew the tooltip on top of the menu.

The shell exposes no dismissal callback to Lua (setOnDismissed is internal to
plugin_panel.cpp), so a repaint cannot know when to put the prop back —
sequencing around it is not constructible. Deleted instead. The row's own text
is the tag, so the tooltip restated it.

tooltipTag leaves the model with it. The untagged row's refusal to filter is
still covered, by the click assert that was always the real check.

"Today: …" printed a timestamp, and is deleted

Not a broken date formatter — never a date at all. On a day with nothing
tracked, timew summary :day answers:

No filtered data found in the range 2026-09-02T00:00:00 - 2026-09-03T00:00:00.

Timew.parseTotal took the last whitespace field of that, so the card read
Today: 2026-09-03T00:00:00.. The suite asserted it verbatim as intended
behaviour ("prose comes back verbatim, exactly as the awk left it").

Fixing the parser was one line, but the label is also redundant: the day header
already shows the same number for the selected scope, and today is the default
scope. So the label, parseTotal, Timew.todayTotal, Timew.cmd.todayTotal,
state.total and m.todayTotal all go — one fetch fewer per open and per
mutation.

parseRangeTotal is untouched. It guards with ^%d+:%d%d:%d%d$, which is why
the day header never had this bug, and it keeps lastField.

The start/stop button is unchanged at ACTION_W × ACTION_W and now sets the
card's height rather than the text column doing it.

What the reviews changed

Two reviews landed on this PR: source (bar.luau, panel.luau,
viewmodel.luau, timew.luau, plugin.toml, en.json) and test.luau under
46 mutations. Neither found a defect in the shipped behaviour; both found
claims wrapped around it that did not survive checking. Applied:

  • The precedence rationale was false and is rewritten in bar.luau:4-9 and
    plugin.toml:24-37, per the correction above. Behaviour unchanged.
  • A dead file pointer is gone. The added comment ended (see UPGRADE.md),
    a file this repository does not contain. The rewritten comment cites
    widget.cpp:265-270 instead, which any reader can open.
  • widget_action.cpp:66-85 → 66-123. Lines 66-85 are resolve()'s
    opening and the applyDefaults lambda; the half that actually makes middle
    survive a user right is applyConfig at 88-123.
  • A surviving mutation is killed. Fmt.span and Fmt.clock are
    byte-identical at or above 3600s, and every parseRangeTotal input in the
    suite was at or above an hour — so substituting Fmt.clock for Fmt.span
    inside Timew.parseRangeTotal passed the whole suite, which is exactly the
    regression Fmt.span was split out to prevent. One sub-hour case
    ("0:35:00" → "0:35", where Fmt.clock would print "35:00") now fails
    that mutation at test.luau:372. Pre-existing on main; this PR added the
    line that claimed to check it.
  • A dead conditional is collapsed. compare() built the expected row
    tooltip as if r.tooltipTag == nil then nil else tr("panel.filter_by", …).
    Both the field and the key are deleted by this commit, so the else arm was
    unreachable, and a model-only reinstatement died inside the suite's locale
    guard in tr (test.luau:1694 as the file stood then, :1699 now) with
    "the EN copy above has no key panel.filter_by … the
    copy has drifted from en.json" — pointing the next reader at the locale copy
    rather than at the reinstated field. It is now a flat nil comparison; the
    same mutation fails at test.luau:2196 naming the field.

Documentation is not in this PR, by the repository's own choice

.git/info/exclude holds *.md. README.md, UPGRADE.md and REFACTOR.md
exist in the working tree and are deliberately untracked, so no markdown is
in this diff and none can be
— on the forge this repository has no README.
The original description's "For a reviewer" note claimed "the paint-count claim
in README moved 25 → 22", which described an edit that only ever existed on
disk. That claim is withdrawn. For the record, the working-tree copies were
corrected alongside this commit (right-click rationale in README.md, the
resolved precedence question in UPGRADE.md, and the paint count, which is 21
— grep -c "paintsFor(" test.luau is 22 and one of those is the definition at
test.luau:2426; the single paints == assert lives inside paintsFor, so
it is not a 22nd path), but none of that ships here.

Verified live

Reloaded on the running shell, driven with real pointer events:

  • left click on the bar → panel opens, no timew command runs (idle and tracking)
  • right click on the bar → nothing: no interval, no panel
  • with the config binding above → right click starts focus, right click again
    stops it, and middle click still opens the widget's settings
  • hover a row 3s then right-click → menu renders with no tooltip over it
  • card in both states: --:-- + tag field, and 1:12 + Learn C++, square
    button in each

Gates: viewModel: 12 states, 1739 field comparisons / ok, and lint
0 errors, 0 warnings. No new orphaned translation keys (checked against
main).

For a reviewer

  • The diff is seven files: test.luau, timew/bar.luau,
    timew/panel.luau, timew/plugin.toml, timew/timew.luau,
    timew/translations/en.json, timew/viewmodel.luau. (The source review said
    six; it is seven.) No .md, per the section above.
  • Four translation keys were orphaned before this PR — panel.all_time,
    panel.scope_day, panel.scope_day_short, panel.scope_all_short. Left
    alone deliberately, out of scope. panel.idle is documented-unused.
  • The card assert is a child count (#info.children == 2), not an absence
    check. A reinstated third line in the card column has to come back through
    that comparison; a label appended as a sibling of the info column is not
    covered, which is a pre-existing property of the positional tree comparison.
  • plugin.toml:39-43 still calls its gesture-key list exhaustive (seven keys)
    when the shell accepts nine, and still says an unknown key "fails only at
    dispatch" when only the verb is deferred. Both are pre-existing wording, both
    are real, and both are left for a follow-up rather than widened into this
    commit.
Four changes from one report: two behaviour, two UI faults. ## Left click opens the panel; right click is unbound Left click was the start/stop toggle and right click opened the panel. They swap, and only half of that is a rebinding — `onRightClick` is **deleted**, not pointed at nothing. That is a product choice, not a workaround: the bar ships no default on right click, so the button a user is most likely to rebind is theirs to claim, and claiming it is how you start and stop from the bar: ```toml [widget.timew.actions] right = "plugin yuris/timew:bar all toggle" ``` Starts the `tag` setting, stops what runs. Bindings resolve per gesture key rather than per table (`widget_action.cpp:66-123`), so this leaves the manifest's `middle = "settings-open-widget"` in place — verified live with both set at once. **Correction to the original description of this PR.** It claimed the reason was that "which of a gesture and a widget-level global wins when both claim a button is unestablished". It is established, and the comments in `bar.luau`, `plugin.toml` and the working-tree README that said otherwise have been rewritten. `widget.cpp:265-270` runs `m_innerArea->setAcceptedButtons(m_innerBaseButtons & ~mask)` on the plugin's own input area — the source comment there says this is "what lets a config binding override a gesture the widget still handles itself" — and `input_dispatcher.cpp:55-61` selects exactly one innermost accepting area. A configured binding therefore always wins and the Lua global never fires for that button; the two are mutually exclusive per button, and the rule is button-agnostic. A plugin *can* ship an overridable Lua default for a rebindable button. This one chooses not to, on right click only. `toggle` is now the only route to start/stop from the bar, so the suite drives the start argv, the failure toast and the cross-entry stop comparison through `onIpc` instead of `onClick`. The absence of `onRightClick` is asserted, not assumed. ## The row tooltip covered its own context menu Rows carried `Filter by <tag>`. A hover tooltip outlives the press that opens the context menu, so right-clicking a row drew the tooltip on top of the menu. The shell exposes no dismissal callback to Lua (`setOnDismissed` is internal to `plugin_panel.cpp`), so a repaint cannot know when to put the prop back — sequencing around it is not constructible. Deleted instead. The row's own text is the tag, so the tooltip restated it. `tooltipTag` leaves the model with it. The untagged row's refusal to filter is still covered, by the click assert that was always the real check. ## "Today: …" printed a timestamp, and is deleted Not a broken date formatter — never a date at all. On a day with nothing tracked, `timew summary :day` answers: ``` No filtered data found in the range 2026-09-02T00:00:00 - 2026-09-03T00:00:00. ``` `Timew.parseTotal` took the last whitespace field of that, so the card read `Today: 2026-09-03T00:00:00.`. **The suite asserted it verbatim** as intended behaviour ("prose comes back verbatim, exactly as the awk left it"). Fixing the parser was one line, but the label is also redundant: the day header already shows the same number for the selected scope, and today is the default scope. So the label, `parseTotal`, `Timew.todayTotal`, `Timew.cmd.todayTotal`, `state.total` and `m.todayTotal` all go — one fetch fewer per open and per mutation. `parseRangeTotal` is untouched. It guards with `^%d+:%d%d:%d%d$`, which is why the day header never had this bug, and it keeps `lastField`. The start/stop button is unchanged at `ACTION_W` × `ACTION_W` and now sets the card's height rather than the text column doing it. ## What the reviews changed Two reviews landed on this PR: source (`bar.luau`, `panel.luau`, `viewmodel.luau`, `timew.luau`, `plugin.toml`, `en.json`) and `test.luau` under 46 mutations. Neither found a defect in the shipped behaviour; both found claims wrapped around it that did not survive checking. Applied: - **The precedence rationale was false** and is rewritten in `bar.luau:4-9` and `plugin.toml:24-37`, per the correction above. Behaviour unchanged. - **A dead file pointer is gone.** The added comment ended `(see UPGRADE.md)`, a file this repository does not contain. The rewritten comment cites `widget.cpp:265-270` instead, which any reader can open. - **`widget_action.cpp:66-85` → `66-123`.** Lines 66-85 are `resolve()`'s opening and the `applyDefaults` lambda; the half that actually makes `middle` survive a user `right` is `applyConfig` at 88-123. - **A surviving mutation is killed.** `Fmt.span` and `Fmt.clock` are byte-identical at or above 3600s, and every `parseRangeTotal` input in the suite was at or above an hour — so substituting `Fmt.clock` for `Fmt.span` inside `Timew.parseRangeTotal` passed the whole suite, which is exactly the regression `Fmt.span` was split out to prevent. One sub-hour case (`"0:35:00"` → `"0:35"`, where `Fmt.clock` would print `"35:00"`) now fails that mutation at `test.luau:372`. Pre-existing on `main`; this PR added the line that claimed to check it. - **A dead conditional is collapsed.** `compare()` built the expected row tooltip as `if r.tooltipTag == nil then nil else tr("panel.filter_by", …)`. Both the field and the key are deleted by this commit, so the `else` arm was unreachable, and a model-only reinstatement died inside the suite's locale guard in `tr` (`test.luau:1694` as the file stood then, `:1699` now) with "the EN copy above has no key panel.filter_by … the copy has drifted from en.json" — pointing the next reader at the locale copy rather than at the reinstated field. It is now a flat nil comparison; the same mutation fails at `test.luau:2196` naming the field. ## Documentation is not in this PR, by the repository's own choice `.git/info/exclude` holds `*.md`. `README.md`, `UPGRADE.md` and `REFACTOR.md` exist in the working tree and are deliberately untracked, so **no markdown is in this diff and none can be** — on the forge this repository has no README. The original description's "For a reviewer" note claimed "the paint-count claim in README moved 25 → 22", which described an edit that only ever existed on disk. That claim is withdrawn. For the record, the working-tree copies were corrected alongside this commit (right-click rationale in `README.md`, the resolved precedence question in `UPGRADE.md`, and the paint count, which is 21 — `grep -c "paintsFor(" test.luau` is 22 and one of those is the definition at `test.luau:2426`; the single `paints ==` assert lives *inside* `paintsFor`, so it is not a 22nd path), but none of that ships here. ## Verified live Reloaded on the running shell, driven with real pointer events: - left click on the bar → panel opens, no timew command runs (idle and tracking) - right click on the bar → nothing: no interval, no panel - with the config binding above → right click starts `focus`, right click again stops it, and middle click still opens the widget's settings - hover a row 3s then right-click → menu renders with no tooltip over it - card in both states: `--:--` + tag field, and `1:12` + `Learn C++`, square button in each Gates: `viewModel: 12 states, 1739 field comparisons` / `ok`, and lint `0 errors, 0 warnings`. No new orphaned translation keys (checked against `main`). ## For a reviewer - The diff is **seven** files: `test.luau`, `timew/bar.luau`, `timew/panel.luau`, `timew/plugin.toml`, `timew/timew.luau`, `timew/translations/en.json`, `timew/viewmodel.luau`. (The source review said six; it is seven.) No `.md`, per the section above. - Four translation keys were orphaned *before* this PR — `panel.all_time`, `panel.scope_day`, `panel.scope_day_short`, `panel.scope_all_short`. Left alone deliberately, out of scope. `panel.idle` is documented-unused. - The card assert is a child *count* (`#info.children == 2`), not an absence check. A reinstated third line in the card column has to come back through that comparison; a label appended as a *sibling* of the info column is not covered, which is a pre-existing property of the positional tree comparison. - `plugin.toml:39-43` still calls its gesture-key list exhaustive (seven keys) when the shell accepts nine, and still says an unknown key "fails only at dispatch" when only the verb is deferred. Both are pre-existing wording, both are real, and both are left for a follow-up rather than widened into this commit.
Four changes, one pass over the click bindings and the two UI faults they
surfaced.

Left click opened nothing and toggled tracking; right click opened the panel.
Now left click opens the panel, and `onRightClick` is deleted rather than
rebound. Which of a gesture and a widget-level global wins when both claim a
button is still unestablished, so the button a user is most likely to rebind is
left with exactly one claimant:

  [widget.timew.actions]
  right = "plugin yuris/timew:bar all toggle"

That starts the `tag` setting and stops what runs. Bindings resolve per gesture
key rather than per table (widget_action.cpp:66-85), so it leaves the
manifest's middle-click alone -- verified live, both together.

The activity rows lose their "Filter by <tag>" tooltip. A hover tooltip
outlives the press that opens the row's context menu, so right-clicking drew
the tooltip over the menu it had just opened. The shell reports no dismissal to
Lua, so no repaint can know when to restore the prop; the row's own text is the
tag either way.

The card loses "Today: <total>", and Timew.parseTotal with it. It was never a
date: on a day with nothing tracked `timew summary :day` answers with prose,
and parseTotal took the last whitespace field of it, so the panel printed
"Today: 2026-09-02T00:00:00.". test.luau asserted that verbatim as intended
behaviour. parseRangeTotal guards with ^%d+:%d%d:%d%d$ and is untouched -- it
feeds the day header, which already shows the same number the label duplicated.
The start/stop button is unchanged at ACTION_W square and now sets the card's
height.
yuris left a comment

Review — source half (bar / panel / viewmodel / timew / plugin.toml / en.json / README)

Scope: everything except test.luau. Shell claims checked against
/nix/store/7pgw3p2dhkn8sfpnli7qiyzs9zyhr01d-source. The behaviour in this PR is
correct and I could not break it. Three of the four claims wrapped around it do not
survive checking, and one of them is the justification for the whole change.

Verified

Gates, run here, exact output

  • nix shell nixpkgs#luau -c luau test.luau → viewModel: 12 states, 1739 field comparisons / ok, exit 0.
  • noctalia plugins lint timew → yuris/timew / ok / 0 errors, 0 warnings, exit 0.

Both run in a throwaway clone at 904c065, not in the live checkout.

1. The click rebinding is complete

  • onClick opens the panel — bar.luau:188-190 → openPanel() → noctalia.togglePanel("yuris/timew:panel") at bar.luau:184-186.
  • No onRightClick global survives. grep -rn onRightClick timew/ returns four
    comment lines and one hit at panel.luau:1046, which is the row's node prop for
    the context menu — a different mechanism, correctly kept. grep -n '^function ' timew/bar.luau
    gives exactly onConfigChanged, update, onClick, onIpc.
  • Right click really is a no-op, not a fallback. plugin_widget.cpp:160-173 maps
    BTN_RIGHT → the string "onRightClick" and enqueues it; script_runtime.cpp:702-705
    is if (!host->hasGlobal(...)) return std::nullopt; — no else, silent drop. No default
    claims it either: builtin defaults bind Middle only (widget_gesture_defaults.cpp:15-18),
    the right → control-center default is dead-zone-only (:20-23), and plugin is not in
    kTypeReserved (:130-139, four built-in types). The plugin's InputArea still accepts
    BTN_RIGHT, so the click is consumed and cannot leak to the dead zone.
  • toggleTracking is still reachable and still sends the tag as one argv element —
    bar.luau:174-182, { "timew", "start", Config.get("tag") }, reached from
    onIpc:197-203. IPC shape confirmed: plugin_ipc.cpp:14 grammar
    plugin <author/plugin:entry> <target> <event> [payload], event passed verbatim
    (:136-137) to plugin_widget.cpp:531 enqueueCallStrings("onIpc", event, payload, …)
    — two strings, matching onIpc(event, _payload).
  • Nothing else assumes left-click toggles. grep -rn -i 'left click|click toggle|toggles tracking|starts/stops tracking' timew/
    returns only the three updated prose lines.

2. Per-gesture-key resolution, and the middle survival claim

True. widget_action.h:48-52 documents four last-wins layers; m_actions is
std::array<std::optional<WidgetAction>, kGestureCount> (widget_action.h:88) and every
layer writes individual slots — applyDefaults at widget_action.cpp:71-85, applyConfig
at :88-123. A user right overwrites only right; the manifest's middle survives (and
would anyway, via the layer-1 builtin at widget_gesture_defaults.cpp:15-18). No
whole-table override path exists.

The [widget.timew.actions] example form is right for a plugin widget: widget.cpp:222
builds the context as widget.{m_configName}, and m_configName is the user's bar-config
key (bar.cpp:2477), declared alongside type — same shape as this machine's own
[widget.cat] / type = "noctalia/bongocat:cat".

3. The deletions left nothing dangling

git grep on feat/click-bindings for parseTotal, todayTotal, state.total,
m.todayTotal, tooltipTag, panel.today, panel.filter_by finds no reader in
timew/. (panel.today_short is a different, live key.) lastField keeps a live caller
at timew.luau:173; parseRangeTotal is byte-identical to main and still guards with
^%d+:%d%d:%d%d$ (timew.luau:172-175).

Translation keys: en.json is valid JSON with no duplicate keys on either side (41 → 39
raw = 41 → 39 unique). The key-set diff is exactly panel.today and panel.filter_by
removed, nothing added — the rest of that hunk is reordering noise. Every panel.* /
settings.* literal in timew/ is declared; every settings.*_key in plugin.toml
resolves.

Orphan claim confirmed, and confirmed as not a regression. Declared-but-unreferenced,
computed identically on both refs: panel.all_time, panel.scope_day,
panel.scope_day_short, panel.scope_all_short, plus panel.idle — five on main, the
same five on the PR. The four named in the description were already dead, and panel.idle
is documented-unused at panel.luau:735-736. This PR adds no orphan.

4. The root-cause story — reproduced, not taken on faith

Real timew 1.9.1 against an empty TIMEWARRIORDB:

$ TIMEWARRIORDB=/tmp/twdb timew summary :day
No filtered data found in the range 2026-09-02T00:00:00 - 2026-09-03T00:00:00.

main's parser run on exactly those bytes:

lastField       = "2026-09-03T00:00:00."
parseTotal      = "2026-09-03T00:00:00."
parseRangeTotal = "0:00"
rendered label  = Today: 2026-09-03T00:00:00.

Exactly as described, trailing period included, and parseRangeTotal's guard is why it
was immune. The characterisation is accurate and not overstated.

5. The layout claim

Holds, and the width arithmetic is exact. ACTION_W = 76, LEFT_W = 220,
SUBJECT_H = 24 (panel.luau:98-100); subjectW = LEFT_W - ACTION_W - 24 = 120, and the
- 24 is precisely the row's padding = 8 on both sides plus gap = 8, so
8 + 120 + 8 + 76 + 8 = 220 = LEFT_W. The button is untouched at 76 x 76
(panel.luau:774-779).

Height: the left column was clock(28pt) + gap4 + subject(24) + gap4 + today(12pt), now
clock(28pt) + gap4 + subject(24) ≈ 65px, which is under ACTION_W 76 — so the button
does now set the card height, at 8 + 76 + 8 = 92. The card gets ~9px shorter. It does
not look wrong: the row is align = "center", so the shorter text column centres against
the square button. Nothing else in the left column depended on the third line — subjectW
never did, and the two survivors keep their own props verbatim.

Not checked

  • Live pointer behaviour on the running shell. I read the dispatch source instead; the
    description's live-verification bullets are consistent with it, but I did not drive the
    compositor.
  • Rendered pixel height of a 28pt label. The height conclusion is arithmetic on the
    constants plus the shell's box model, not a screenshot.
  • Locales other than en.
  • test.luau — ReviewSuite owns it.

Findings

Blocking

1. The stated reason for deleting onRightClick is false — the shell does establish precedence.
timew/bar.luau:8-9 and timew/plugin.toml:24-26

Both added comments say which of a gesture and a widget-level global wins "is not
established". It is established, deterministically, with a source comment saying so.
widget.cpp:265-270:

if (m_innerArea != nullptr) {
  // Both dispatcher walks start at the innermost area, so the widget's own root has to give up
  // whatever the wrapper is bound to. This is what lets a config binding override a gesture the
  // widget still handles itself, for scroll as much as for clicks.
  m_innerArea->setAcceptedButtons(m_innerBaseButtons & ~mask);

Any gesture-bound button is stripped from the plugin's own InputArea, and
input_dispatcher.cpp:55-61 selects exactly one innermost accepting area — so the two are
mutually exclusive by construction. A config binding wins and the Lua global never
fires; they never both run.
Verified for left as well as right (widget.cpp:269 is
button-agnostic).

Why it matters: this is the load-bearing justification for the change, repeated in three
places, and it is backwards. Keeping onRightClick would not have created a contest —
a user's right = … would have overridden it cleanly, which is exactly the outcome the
comment says cannot be relied on. The behaviour shipped here is a legitimate product
choice; the reason given for it is not, and the next person to touch this will believe a
plugin cannot ship an overridable Lua default for a rebindable button. It can.

Fix: keep the behaviour if that is the intent, and restate the rationale — right-click is
left unbound because the plugin does not want a default there, not because precedence is
unknown. Same edit in README.md:9-11.

2. The README half of this PR is not in this PR.
README.md (whole file)

.git/info/exclude:7 is *.md, so README.md, UPGRADE.md and REFACTOR.md are all
untracked. git ls-tree -r --name-only feat/click-bindings lists no .md file at all, and
git diff main...feat/click-bindings --name-only is six files, none of them the README.
The description's "For a reviewer" note — "The paint-count claim in README moved 25 → 22" —
describes an edit that exists only on your disk.

Why it matters: the entire user-facing instruction for getting start/stop back (the
[widget.timew.actions] right = … block at README.md:14-17) is the mitigation for a
behaviour this PR removes, and it ships nowhere. On the forge this repo has no README.
Either track the .md files or drop the claim from the description.

3. Added comment points at a file that is not in the repository.
timew/bar.luau:9

The line added by this commit ends (see UPGRADE.md). Per finding 2, UPGRADE.md is
excluded and absent from the tree — git grep -n "UPGRADE.md" feat/click-bindings -- timew/
is this one hit, and no .md file exists on either ref. A reader who clones this repo has
no way to follow it. (If finding 1 is taken, this sentence probably goes away with it.)

Advisory

4. README's paint count is off by one — 21, not 22.
README.md:194

grep -c "paintsFor(" test.luau = 22, but test.luau:2421 is the definition
local function paintsFor(what, expected, fn); the other 21 are call sites. The single
paints == assert at test.luau:2424 sits inside paintsFor, so it runs once per call
site rather than being a 22nd path — the recipe in the description counts it twice. The
old 25 was stale; 22 is stale by one in the other direction. Not landed anyway, per
finding 2.

5. "both fail only at dispatch" is right for the verb, wrong for the key.
timew/plugin.toml:41-42 — pre-existing wording, but this commit re-flowed the line

The lint half is correct: plugin_lint.h:11-16 has only four settings-only finding kinds
and plugin_lint.cpp:344-492 never touches gestures. But only the verb is deferred to
dispatch (widget_action.h:36-40, deliberately). An unknown key is rejected at
bar-build time — widget_action.cpp:93-96 for user config,
widget_gesture_defaults.cpp:156-160 for the manifest — and noctalia config validate
rejects it too (config_validate.cpp:325-341). Cheap to correct while the line is already
being touched.

6. The accepted-gesture list is missing two keys.
timew/plugin.toml:38-39 — pre-existing, unchanged context lines

"Gesture keys the shell accepts: left, right, middle, forward, back, scroll_up,
scroll_down" reads as exhaustive. The shell accepts nine: widget_gesture.cpp:26-36
kGestures also has scroll_left and scroll_right, and docs/user/bar/actions.mdx:33-40
lists all nine. No listed key is invalid. Flagged only because the sentence claims
completeness.

7. Citation points at the mechanism, not at the consequence.
timew/plugin.toml:35-36

"Bindings resolve per gesture key rather than per table (widget_action.cpp:66-85), so
setting right there leaves this middle in place." The behaviour is true (verified
above). Lines 66-85 are resolve()'s opening and the applyDefaults lambda — layers 1-2
only — which do show per-key slot writes but not the user-vs-manifest merge. The half that
actually makes middle survive a user right is applyConfig at
widget_action.cpp:88-123. Suggest citing 66-123.

Not mine

test.luau still carries the deleted field. On feat/click-bindings, lines 1961-1962
build tr("panel.filter_by", { tag = r.tooltipTag }) and lines 2190/2192 assert
rows[N].tooltipTag == nil — but Viewmodel.build no longer assigns tooltipTag, so
those asserts compare nil to nil and cannot fail whatever the viewmodel does, and
panel.filter_by is no longer a key this plugin ships. Raised with ReviewSuite, who owns
that file; flagging here only so it is not lost between the two halves.

# Review — source half (bar / panel / viewmodel / timew / plugin.toml / en.json / README) Scope: everything except `test.luau`. Shell claims checked against `/nix/store/7pgw3p2dhkn8sfpnli7qiyzs9zyhr01d-source`. The behaviour in this PR is correct and I could not break it. Three of the four *claims* wrapped around it do not survive checking, and one of them is the justification for the whole change. ## Verified **Gates, run here, exact output** - `nix shell nixpkgs#luau -c luau test.luau` → `viewModel: 12 states, 1739 field comparisons` / `ok`, exit 0. - `noctalia plugins lint timew` → `yuris/timew` / ` ok` / `0 errors, 0 warnings`, exit 0. Both run in a throwaway clone at `904c065`, not in the live checkout. **1. The click rebinding is complete** - `onClick` opens the panel — `bar.luau:188-190` → `openPanel()` → `noctalia.togglePanel("yuris/timew:panel")` at `bar.luau:184-186`. - No `onRightClick` **global** survives. `grep -rn onRightClick timew/` returns four comment lines and one hit at `panel.luau:1046`, which is the row's *node prop* for the context menu — a different mechanism, correctly kept. `grep -n '^function ' timew/bar.luau` gives exactly `onConfigChanged`, `update`, `onClick`, `onIpc`. - Right click really is a no-op, not a fallback. `plugin_widget.cpp:160-173` maps BTN_RIGHT → the string `"onRightClick"` and enqueues it; `script_runtime.cpp:702-705` is `if (!host->hasGlobal(...)) return std::nullopt;` — no else, silent drop. No default claims it either: builtin defaults bind Middle only (`widget_gesture_defaults.cpp:15-18`), the `right → control-center` default is dead-zone-only (`:20-23`), and `plugin` is not in `kTypeReserved` (`:130-139`, four built-in types). The plugin's InputArea still *accepts* BTN_RIGHT, so the click is consumed and cannot leak to the dead zone. - `toggleTracking` is still reachable and still sends the tag as one argv element — `bar.luau:174-182`, `{ "timew", "start", Config.get("tag") }`, reached from `onIpc:197-203`. IPC shape confirmed: `plugin_ipc.cpp:14` grammar `plugin <author/plugin:entry> <target> <event> [payload]`, event passed verbatim (`:136-137`) to `plugin_widget.cpp:531` `enqueueCallStrings("onIpc", event, payload, …)` — two strings, matching `onIpc(event, _payload)`. - Nothing else assumes left-click toggles. `grep -rn -i 'left click|click toggle|toggles tracking|starts/stops tracking' timew/` returns only the three updated prose lines. **2. Per-gesture-key resolution, and the `middle` survival claim** True. `widget_action.h:48-52` documents four last-wins layers; `m_actions` is `std::array<std::optional<WidgetAction>, kGestureCount>` (`widget_action.h:88`) and every layer writes individual slots — `applyDefaults` at `widget_action.cpp:71-85`, `applyConfig` at `:88-123`. A user `right` overwrites only `right`; the manifest's `middle` survives (and would anyway, via the layer-1 builtin at `widget_gesture_defaults.cpp:15-18`). No whole-table override path exists. The `[widget.timew.actions]` example form is right for a plugin widget: `widget.cpp:222` builds the context as `widget.{m_configName}`, and `m_configName` is the user's bar-config key (`bar.cpp:2477`), declared alongside `type` — same shape as this machine's own `[widget.cat] / type = "noctalia/bongocat:cat"`. **3. The deletions left nothing dangling** `git grep` on `feat/click-bindings` for `parseTotal`, `todayTotal`, `state.total`, `m.todayTotal`, `tooltipTag`, `panel.today`, `panel.filter_by` finds no reader in `timew/`. (`panel.today_short` is a different, live key.) `lastField` keeps a live caller at `timew.luau:173`; `parseRangeTotal` is byte-identical to `main` and still guards with `^%d+:%d%d:%d%d$` (`timew.luau:172-175`). Translation keys: `en.json` is valid JSON with no duplicate keys on either side (41 → 39 raw = 41 → 39 unique). The key-set diff is exactly `panel.today` and `panel.filter_by` removed, nothing added — the rest of that hunk is reordering noise. Every `panel.*` / `settings.*` literal in `timew/` is declared; every `settings.*_key` in `plugin.toml` resolves. Orphan claim confirmed, and confirmed as *not* a regression. Declared-but-unreferenced, computed identically on both refs: `panel.all_time`, `panel.scope_day`, `panel.scope_day_short`, `panel.scope_all_short`, plus `panel.idle` — five on `main`, the same five on the PR. The four named in the description were already dead, and `panel.idle` is documented-unused at `panel.luau:735-736`. This PR adds no orphan. **4. The root-cause story — reproduced, not taken on faith** Real `timew` 1.9.1 against an empty `TIMEWARRIORDB`: ``` $ TIMEWARRIORDB=/tmp/twdb timew summary :day No filtered data found in the range 2026-09-02T00:00:00 - 2026-09-03T00:00:00. ``` `main`'s parser run on exactly those bytes: ``` lastField = "2026-09-03T00:00:00." parseTotal = "2026-09-03T00:00:00." parseRangeTotal = "0:00" rendered label = Today: 2026-09-03T00:00:00. ``` Exactly as described, trailing period included, and `parseRangeTotal`'s guard is why it was immune. The characterisation is accurate and not overstated. **5. The layout claim** Holds, and the width arithmetic is exact. `ACTION_W = 76`, `LEFT_W = 220`, `SUBJECT_H = 24` (`panel.luau:98-100`); `subjectW = LEFT_W - ACTION_W - 24 = 120`, and the `- 24` is precisely the row's `padding = 8` on both sides plus `gap = 8`, so `8 + 120 + 8 + 76 + 8 = 220 = LEFT_W`. The button is untouched at `76 x 76` (`panel.luau:774-779`). Height: the left column was `clock(28pt) + gap4 + subject(24) + gap4 + today(12pt)`, now `clock(28pt) + gap4 + subject(24)` ≈ 65px, which is under `ACTION_W` 76 — so the button does now set the card height, at `8 + 76 + 8 = 92`. The card gets ~9px shorter. It does not look wrong: the row is `align = "center"`, so the shorter text column centres against the square button. Nothing else in the left column depended on the third line — `subjectW` never did, and the two survivors keep their own props verbatim. ## Not checked - Live pointer behaviour on the running shell. I read the dispatch source instead; the description's live-verification bullets are consistent with it, but I did not drive the compositor. - Rendered pixel height of a 28pt label. The height conclusion is arithmetic on the constants plus the shell's box model, not a screenshot. - Locales other than `en`. - `test.luau` — `ReviewSuite` owns it. ## Findings ### Blocking **1. The stated reason for deleting `onRightClick` is false — the shell does establish precedence.** `timew/bar.luau:8-9` and `timew/plugin.toml:24-26` Both added comments say which of a gesture and a widget-level global wins "is not established". It is established, deterministically, with a source comment saying so. `widget.cpp:265-270`: ```cpp if (m_innerArea != nullptr) { // Both dispatcher walks start at the innermost area, so the widget's own root has to give up // whatever the wrapper is bound to. This is what lets a config binding override a gesture the // widget still handles itself, for scroll as much as for clicks. m_innerArea->setAcceptedButtons(m_innerBaseButtons & ~mask); ``` Any gesture-bound button is stripped from the plugin's own InputArea, and `input_dispatcher.cpp:55-61` selects exactly one innermost accepting area — so the two are mutually exclusive by construction. **A config binding wins and the Lua global never fires; they never both run.** Verified for left as well as right (`widget.cpp:269` is button-agnostic). Why it matters: this is the load-bearing justification for the change, repeated in three places, and it is backwards. Keeping `onRightClick` would *not* have created a contest — a user's `right = …` would have overridden it cleanly, which is exactly the outcome the comment says cannot be relied on. The behaviour shipped here is a legitimate product choice; the reason given for it is not, and the next person to touch this will believe a plugin cannot ship an overridable Lua default for a rebindable button. It can. Fix: keep the behaviour if that is the intent, and restate the rationale — right-click is left unbound because the plugin does not want a default there, not because precedence is unknown. Same edit in `README.md:9-11`. **2. The README half of this PR is not in this PR.** `README.md` (whole file) `.git/info/exclude:7` is `*.md`, so `README.md`, `UPGRADE.md` and `REFACTOR.md` are all untracked. `git ls-tree -r --name-only feat/click-bindings` lists no `.md` file at all, and `git diff main...feat/click-bindings --name-only` is six files, none of them the README. The description's "For a reviewer" note — "The paint-count claim in README moved 25 → 22" — describes an edit that exists only on your disk. Why it matters: the entire user-facing instruction for getting start/stop back (the `[widget.timew.actions] right = …` block at `README.md:14-17`) is the mitigation for a behaviour this PR removes, and it ships nowhere. On the forge this repo has no README. Either track the `.md` files or drop the claim from the description. **3. Added comment points at a file that is not in the repository.** `timew/bar.luau:9` The line added by this commit ends `(see UPGRADE.md)`. Per finding 2, `UPGRADE.md` is excluded and absent from the tree — `git grep -n "UPGRADE.md" feat/click-bindings -- timew/` is this one hit, and no `.md` file exists on either ref. A reader who clones this repo has no way to follow it. (If finding 1 is taken, this sentence probably goes away with it.) ### Advisory **4. README's paint count is off by one — 21, not 22.** `README.md:194` `grep -c "paintsFor(" test.luau` = 22, but `test.luau:2421` is the definition `local function paintsFor(what, expected, fn)`; the other 21 are call sites. The single `paints ==` assert at `test.luau:2424` sits *inside* `paintsFor`, so it runs once per call site rather than being a 22nd path — the recipe in the description counts it twice. The old 25 was stale; 22 is stale by one in the other direction. Not landed anyway, per finding 2. **5. "both fail only at dispatch" is right for the verb, wrong for the key.** `timew/plugin.toml:41-42` — pre-existing wording, but this commit re-flowed the line The lint half is correct: `plugin_lint.h:11-16` has only four settings-only finding kinds and `plugin_lint.cpp:344-492` never touches gestures. But only the **verb** is deferred to dispatch (`widget_action.h:36-40`, deliberately). An unknown **key** is rejected at bar-build time — `widget_action.cpp:93-96` for user config, `widget_gesture_defaults.cpp:156-160` for the manifest — and `noctalia config validate` rejects it too (`config_validate.cpp:325-341`). Cheap to correct while the line is already being touched. **6. The accepted-gesture list is missing two keys.** `timew/plugin.toml:38-39` — pre-existing, unchanged context lines "Gesture keys the shell accepts: left, right, middle, forward, back, scroll_up, scroll_down" reads as exhaustive. The shell accepts nine: `widget_gesture.cpp:26-36` `kGestures` also has `scroll_left` and `scroll_right`, and `docs/user/bar/actions.mdx:33-40` lists all nine. No listed key is invalid. Flagged only because the sentence claims completeness. **7. Citation points at the mechanism, not at the consequence.** `timew/plugin.toml:35-36` "Bindings resolve per gesture key rather than per table (widget_action.cpp:66-85), so setting `right` there leaves this `middle` in place." The behaviour is true (verified above). Lines 66-85 are `resolve()`'s opening and the `applyDefaults` lambda — layers 1-2 only — which do show per-key slot writes but not the user-vs-manifest merge. The half that actually makes `middle` survive a user `right` is `applyConfig` at `widget_action.cpp:88-123`. Suggest citing `66-123`. ## Not mine `test.luau` still carries the deleted field. On `feat/click-bindings`, lines 1961-1962 build `tr("panel.filter_by", { tag = r.tooltipTag })` and lines 2190/2192 assert `rows[N].tooltipTag == nil` — but `Viewmodel.build` no longer assigns `tooltipTag`, so those asserts compare nil to nil and cannot fail whatever the viewmodel does, and `panel.filter_by` is no longer a key this plugin ships. Raised with `ReviewSuite`, who owns that file; flagging here only so it is not lost between the two halves.
yuris left a comment

Review: test.luau (233 changed lines)

Scope: test.luau only. Plugin source is ReviewSource's half.

Method: every claim below was mutation-tested. The main checkout was never touched — all
work ran in cp -r-copy /tmp/suite-copy. 46 mutations, one full gate run each.

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

Verified

Each new or rewired assert, against the smallest source mutation that kills it.

1. onClick -> onIpc("toggle"): nothing lost in the swap

assert mutation that kills it
:864 argv pin, bar idle onIpc toggle branch made a no-op -> 471: not an argv array but a nil
:871 START_TAGS hostile-tag loop start argv hardcodes "focus" -> 871: argv[3] is "focus", expected "deep work"
:871 (same loop) toggleTracking reads Config.get("icon_size") -> 864: argv[3] is "17", expected "focus"
:864/:1013 config read Config.get always returns the default -> 737
:913 toast wiring loop callback drops notifyError -> 913; callback builds its own body from result.stderr -> 913; callback toasts on success too -> bar.luau:178 via 3351
:3352 sync re-entry callback skips sync() -> bar.luau:178 via 3352
:1043 + :3357 cross-entry stop panel stop gains --dry -> 1400: no queued command matching timew stop
:1017 literal backstop both stops drift together (bar and panel -> timew stop --dry) -> 472: 3 elements, expected 2

That last row is the one worth calling out: the block comment claims nothing below compares
against a literal, so a coordinated drift needs a literal somewhere. :1017 is it, and it holds.

2. The click-route block: every assert is load-bearing

assert mutation that kills it
:992 rawget(barEnv, "onRightClick") == nil function onRightClick() openPanel() end put back in bar.luau -> 992
:999 left click opens, idle onClick calls toggleTracking -> 999; onClick emptied -> 999; openPanel opens yuris/timew:other -> 999
:1000 and runs nothing onClick opens and toggles -> 1000
:1004/:1005 same, tracking covered by the above
:1013 toggle starts the configured tag onIpc toggle no-op -> 471
:1017 toggle stops what runs stop argv gains --dry -> 1017; state branch inverted -> 472
:1021 unknown verb runs nothing if event == "toggle" -> if true -> 1021
:1028 onIpc("panel") verb renamed to "open" -> 1028
:1031 unknown verb opens nothing elseif event == "panel" -> else -> 1031

On the vacuity question for :992: it is a real check, not an accident of the wrong table or
the wrong spelling. barEnv is getfenv(bar.elapsed) (:643), which is the bar entry's own
globals table — the exact table a reinstated file-scope function onRightClick() writes to.
Proved by putting one back: the suite goes red at :992, not somewhere downstream. rawget
rather than plain indexing is the right spelling here, since barEnv carries an __index
chain to the CLI globals.

The two lastCmd == nil asserts are bracketed by positive controls — :864 before and
:1013 after both prove lastCmd is still being recorded — so they cannot pass by the
harness having gone deaf.

3. parseTotal block replacement

lastField's two documented cases survived the rewrite verbatim and both are live:

assert mutation that kills it
:362 blank tail ("a b\nc d \n\n" -> "d") lastField takes the first field of the line -> 361; drops the %s*$ trailing-space tolerance -> 362
:363 whitespace-only line -> "" last seeded with "0:00:00" -> 363
:361 summary shape lastField keeps the first field it finds -> 361
:366 prose floors to 0:00 parseRangeTotal guard removed -> 366
:367 a real clock converts parseRangeTotal always returns "0:00" -> 367

Neither new parseRangeTotal assert compares two literals. :366 compares a computed value
to a literal; :367 is discussed under Findings.

4. Row tooltip sweep

assert mutation that kills it
:1522 tips == "nil,nil" tooltip put back on the row tag button -> 1522
:1522 (coordinated) tooltipTag back in viewmodel and the tr("panel.filter_by") prop back in buildList -> 1522
:1525 control action button loses its tooltip prop -> 1525

The control does its job, but it is not the only thing standing between the sweep and a dead
harness — and it is not even the strongest. rowProp("tooltip") reads
row.children[3].props.tooltip (:1332-1343), the same node and the same props table that
rowTexts() reads eight lines earlier at :1513, whose contents are pinned at :1514. So a
harness that stopped reading row props fails at :1514 first, with a better message. The
actionNode() control covers a different node and is worth keeping, but the real defence is
upstream.

On "nil,nil" pinning the row count as a side effect: it does, and it is harmless. Shrinking
the list to one row fails at :1484 — well before :1522 — so the confusing failure the
count-pinning could cause is unreachable. The row set is pinned by contents at :1514
before it is pinned by arity at :1522.

5. Card shape

assert mutation that kills it
:1837 #info.children == 2 a third ui.label back in the card column -> 1837: 3 vs 2
:1829 clock identity clock and subject reordered -> 1829: nil vs "--:--"; clock label stops reading m.clockText -> 1442
:1831 action wiring action button loses onClick -> 1831

A count is the right invariant for the reinstatement it is aimed at: the faithful one — the
deleted ui.label put back where it was — is caught, and a reorder is caught separately by
:1829 reading children[1] as the clock. Boundary noted under Findings.

6. Census edits

The sed that dropped total = "..." from every open() spec did not corrupt anything.
Checked mechanically, not by eye: every changed open({...}) line on the branch is
byte-identical to its main counterpart with total = "<value>", removed —

$ git diff main...HEAD -- test.luau | grep "^-.*open({" | sed "s/^-//; s/total = \"[^\"]*\", //" > a
$ git diff main...HEAD -- test.luau | grep "^+.*open({" | sed "s/^+//" > b
$ diff a b && echo IDENTICAL
IDENTICAL

No spec.total, state.total, todayTotal, parseTotal or panel.today reference is left
anywhere in the file except one comment at :351.

The counts are right, not merely green:

assert mutation that kills it
:3193 closed state field set total = "0:00:00" put back in panel state -> 3193
:2938 #panelJobs == 3 run() refreshes twice -> 2938: 6 commands, not 3
:2941-2944 + :2945 leftover check run() skips the refresh -> 1400
adopting-open paint list commit paints twice -> 2424; refresh drops fetchDays -> 2053

refresh() is fetchActive + fetchTags + fetchDays in day scope, so 3 is arithmetically
right, and the block is closed on both ends — == 3 at the top, == 0 leftover at :2945
— so a fetch added or dropped fails one or the other.

Not checked

  • Plugin source behaviour (bar.luau, panel.luau, viewmodel.luau, timew.luau,
    plugin.toml, en.json, README.md) — ReviewSource's ownership. Source files were
    mutated here only as instruments for judging asserts, never reviewed as code.
  • The live-shell claims in the PR body (real pointer events, gesture binding precedence).
    Not reproducible from the suite; taken on the author's report.
  • EN at :1671 against timew/translations/en.json — the suite says itself that the luau
    CLI has no io, so this is a by-eye copy with no automated drift check. Out of scope for
    a mutation pass.

Mutations

46 total, 43 killed, 3 survived.

# mutation result
1 reinstate function onRightClick() in bar.luau killed :992
2 onClick calls toggleTracking killed :999
3 onClick body deleted killed :999
4 onClick opens and toggles killed :1000
5 onIpc toggle branch no-op killed :471
6 onIpc toggles on any verb killed :1021
7 onIpc opens the panel on any other verb killed :1031
8 start argv hardcodes "focus" killed :871
9 bar stop argv gains --dry killed :1017
10 openPanel opens yuris/timew:other killed :999
11 Config.get always returns the default killed :737
12 toggle reads Config.get("icon_size") killed :864
13 toggle state branch inverted killed :472
14 onIpc panel verb renamed to "open" killed :1028
15 toggle callback never toasts killed :913
16 toggle callback toasts result.stderr directly killed :913
17 toggle callback toasts on success too killed bar.luau:178 @ :3351
18 toggle callback skips sync() killed bar.luau:178 @ :3352
19 bar and panel stop drift together killed :472 (@:1017)
20 lastField keeps the first field killed :361
21 lastField takes the first field of a line killed :361
22 lastField seeds last with "0:00:00" killed :363
23 lastField drops %s*$ tolerance killed :362
24 parseRangeTotal guard removed killed :366
25 parseRangeTotal always "0:00" killed :367
26 parseRangeTotal uses Fmt.clock for Fmt.span SURVIVED
27 parseRangeTotal guard loosened to ^%d+:%d%d SURVIVED
28 tooltip back on the row tag button killed :1522
29 tooltip + tooltipTag back together (coordinated) killed :1522
30 action button loses its tooltip killed :1525
31 tooltipTag back, tagged rows only killed :1694 (see F2)
32 tooltipTag back on every row killed :1694
33 tooltipTag back as "" killed :1694
34 tooltipTag back, with :1522/:1961 neutered killed :2191 (reachability probe)
35 third ui.label back in the card column killed :1837
36 clock and subject reordered killed :1829
37 clock label stops reading m.clockText killed :1442
38 action button loses onClick killed :1831
39 third ui.label as a sibling in the card row SURVIVED
40 state.total reinstated killed :3193
41 refresh drops fetchDays killed :2053
42 commit paints twice killed :2424
43 run() skips the refresh killed :1400
44 run() refreshes twice killed :2938
45 panel stop argv gains --dry killed :1400
46 list drops its last row killed :1484 (ordering probe)

All three survivors are analysed below. None of them is a regression this PR introduced —
26 and 27 were checked against main and survive there too — but 26 is the one the PR
writes a new assert about.

Findings

Blocking

None. No assert this patch adds or rewires is vacuous, and no coverage was lost in the
onClick -> onIpc("toggle") swap.

Advisory

F1 — test.luau:367: the new parseRangeTotal assert cannot see the conversion it names.

assert(T.parseRangeTotal("  1:02:03  \n") == Fmt.span(3723), "and a real clock it converts to a span")

Fmt.span and Fmt.clock are byte-identical for every input at or above 3600s: both return
string.format("%d:%02d", s // 3600, (s % 3600) // 60) on that branch. 3723 is above it, so
Fmt.span(3723) and Fmt.clock(3723) are both "1:02". Every parseRangeTotal input in the
file is at or above an hour — "1:02:03" here, "5:20:00" at :431, and the panel fixtures
"2:00:00", "3:22:00", "6:00:00", "7:00:00", "9:11:00". Result: substituting
Fmt.clock for Fmt.span inside Timew.parseRangeTotal survives the whole suite
(mutation 26). That substitution is exactly the regression Fmt.span was split out to
prevent — fmt.luau:22-24 says so in as many words: "35:00" for a 35-minute span was being
read as 35 hours.

On the specific question of Fmt.span(3723) versus the literal "1:02": the literal alone
would not fix it — Fmt.clock(3723) is "1:02" too. What computing the expected side
through the same function costs is legibility: the line reads as a check on the conversion
while it actually pins only Parse.clock and the guard branch, both of which :366 and
:431 already hold. The input is the fixable half. Verified: adding one sub-hour case kills
the mutation.

assert(T.parseRangeTotal("  1:02:03  \n") == Fmt.span(3723), "and a real clock it converts to a span")
assert(T.parseRangeTotal("0:35:00") == "0:35", "a sub-hour total reads as a span, not as the 35 hours Fmt.clock would print")

Pre-existing on main, so not a regression — but main did not have a line claiming to
check it.

F2 — test.luau:1961: a live assert routes its failure through a key this commit deleted.

compare() still builds the expected row tooltip as
if r.tooltipTag == nil then nil else tr("panel.filter_by", { tag = r.tooltipTag }).
Viewmodel.build no longer sets tooltipTag, and the same commit removed panel.filter_by
from timew/translations/en.json and from the EN mirror at :1671. The else arm is
unreachable while the suite is green, and the moment a regression makes it reachable it dies
inside tr instead of at the comparison. Putting tooltipTag back in viewmodel.luau fails
with:

./test.luau:1694: the EN copy above has no key panel.filter_by -- render asked for one no
locale defines, or the copy has drifted from en.json

The guard still goes red, so this is diagnosis quality and not coverage: it points the next
reader at the locale copy rather than at the reinstated field, and the obvious first move —
add panel.filter_by back to EN — is wrong. Now that the field is gone, the conditional
has nothing left to express.

			same(kids[3].props.tooltip, nil, "rows[" .. i .. "] carries no tooltip")

F3 — test.luau:2190-2194: live, but dominated; no change needed.

Recording this because it reads as vacuous and is not. The pair asserts tooltipTag == nil
on a field nothing assigns, which looks like nil == nil. It is reachable: with :1522 and
the same at :1959-1963 neutered, reinstating tooltipTag does fail at :2191
(mutation 34). In the shipped file it can never be the first failure — a model-only
reinstatement dies at :1694/:1960 during state 1, a model-plus-builder one at :1522 —
so the pair is documentation with a real assert behind it. Keeping it costs nothing.

F4 — test.luau:1837: #info.children == 2 is a container pin, not a card-wide absence check.

The PR body already scopes it this way; recording the measured boundary. The faithful
reinstatement — a third ui.label in the card column — dies at :1837 with 3 vs 2. A
ui.label appended as a sibling of the info column, inside the card row, survives the entire
suite (mutation 39). compare() reads the card positionally
(left.children[2].children[1] and [2]), so any non-clickable node appended past the last
indexed child of any container is unasserted — a pre-existing property of the tree
comparison, not something this patch changed. The count is the right invariant for the shape
the deleted line actually had.

Not mine

timew/viewmodel.luau and timew/translations/en.json are ReviewSource's; F2 is filed
against test.luau because the stale reference lives there, not in the source.

# Review: `test.luau` (233 changed lines) Scope: `test.luau` only. Plugin source is ReviewSource's half. Method: every claim below was mutation-tested. The main checkout was never touched — all work ran in `cp -r`-copy `/tmp/suite-copy`. 46 mutations, one full gate run each. ## 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 ``` ## Verified Each new or rewired assert, against the smallest source mutation that kills it. ### 1. `onClick` -> `onIpc("toggle")`: nothing lost in the swap | assert | mutation that kills it | | --- | --- | | `:864` argv pin, bar idle | `onIpc` toggle branch made a no-op -> `471: not an argv array but a nil` | | `:871` `START_TAGS` hostile-tag loop | start argv hardcodes `"focus"` -> `871: argv[3] is "focus", expected "deep work"` | | `:871` (same loop) | `toggleTracking` reads `Config.get("icon_size")` -> `864: argv[3] is "17", expected "focus"` | | `:864`/`:1013` config read | `Config.get` always returns the default -> `737` | | `:913` toast wiring loop | callback drops `notifyError` -> `913`; callback builds its own body from `result.stderr` -> `913`; callback toasts on success too -> `bar.luau:178` via `3351` | | `:3352` sync re-entry | callback skips `sync()` -> `bar.luau:178` via `3352` | | `:1043` + `:3357` cross-entry stop | panel stop gains `--dry` -> `1400: no queued command matching timew stop` | | `:1017` literal backstop | **both** stops drift together (`bar` and `panel` -> `timew stop --dry`) -> `472: 3 elements, expected 2` | That last row is the one worth calling out: the block comment claims nothing below compares against a literal, so a coordinated drift needs a literal somewhere. `:1017` is it, and it holds. ### 2. The click-route block: every assert is load-bearing | assert | mutation that kills it | | --- | --- | | `:992` `rawget(barEnv, "onRightClick") == nil` | `function onRightClick() openPanel() end` put back in bar.luau -> `992` | | `:999` left click opens, idle | `onClick` calls `toggleTracking` -> `999`; `onClick` emptied -> `999`; `openPanel` opens `yuris/timew:other` -> `999` | | `:1000` and runs nothing | `onClick` opens **and** toggles -> `1000` | | `:1004`/`:1005` same, tracking | covered by the above | | `:1013` toggle starts the configured tag | `onIpc` toggle no-op -> `471` | | `:1017` toggle stops what runs | stop argv gains `--dry` -> `1017`; state branch inverted -> `472` | | `:1021` unknown verb runs nothing | `if event == "toggle"` -> `if true` -> `1021` | | `:1028` `onIpc("panel")` | verb renamed to `"open"` -> `1028` | | `:1031` unknown verb opens nothing | `elseif event == "panel"` -> `else` -> `1031` | On the vacuity question for `:992`: it is a real check, not an accident of the wrong table or the wrong spelling. `barEnv` is `getfenv(bar.elapsed)` (`:643`), which is the bar entry's own globals table — the exact table a reinstated file-scope `function onRightClick()` writes to. Proved by putting one back: the suite goes red at `:992`, not somewhere downstream. `rawget` rather than plain indexing is the right spelling here, since `barEnv` carries an `__index` chain to the CLI globals. The two `lastCmd == nil` asserts are bracketed by positive controls — `:864` before and `:1013` after both prove `lastCmd` is still being recorded — so they cannot pass by the harness having gone deaf. ### 3. `parseTotal` block replacement `lastField`'s two documented cases survived the rewrite verbatim and both are live: | assert | mutation that kills it | | --- | --- | | `:362` blank tail (`"a b\nc d \n\n"` -> `"d"`) | `lastField` takes the first field of the line -> `361`; drops the `%s*$` trailing-space tolerance -> `362` | | `:363` whitespace-only line -> `""` | `last` seeded with `"0:00:00"` -> `363` | | `:361` summary shape | `lastField` keeps the first field it finds -> `361` | | `:366` prose floors to `0:00` | `parseRangeTotal` guard removed -> `366` | | `:367` a real clock converts | `parseRangeTotal` always returns `"0:00"` -> `367` | Neither new `parseRangeTotal` assert compares two literals. `:366` compares a computed value to a literal; `:367` is discussed under Findings. ### 4. Row tooltip sweep | assert | mutation that kills it | | --- | --- | | `:1522` `tips == "nil,nil"` | tooltip put back on the row tag button -> `1522` | | `:1522` (coordinated) | `tooltipTag` back in viewmodel **and** the `tr("panel.filter_by")` prop back in `buildList` -> `1522` | | `:1525` control | action button loses its `tooltip` prop -> `1525` | The control does its job, but it is not the only thing standing between the sweep and a dead harness — and it is not even the strongest. `rowProp("tooltip")` reads `row.children[3].props.tooltip` (`:1332-1343`), the same node and the same props table that `rowTexts()` reads eight lines earlier at `:1513`, whose contents are pinned at `:1514`. So a harness that stopped reading row props fails at `:1514` first, with a better message. The `actionNode()` control covers a different node and is worth keeping, but the real defence is upstream. On `"nil,nil"` pinning the row count as a side effect: it does, and it is harmless. Shrinking the list to one row fails at `:1484` — well before `:1522` — so the confusing failure the count-pinning could cause is unreachable. The row set is pinned by contents at `:1514` before it is pinned by arity at `:1522`. ### 5. Card shape | assert | mutation that kills it | | --- | --- | | `:1837` `#info.children == 2` | a third `ui.label` back in the card column -> `1837: 3 vs 2` | | `:1829` clock identity | clock and subject reordered -> `1829: nil vs "--:--"`; clock label stops reading `m.clockText` -> `1442` | | `:1831` action wiring | action button loses `onClick` -> `1831` | A count is the right invariant for the reinstatement it is aimed at: the faithful one — the deleted `ui.label` put back where it was — is caught, and a reorder is caught separately by `:1829` reading `children[1]` as the clock. Boundary noted under Findings. ### 6. Census edits The `sed` that dropped `total = "..."` from every `open()` spec did not corrupt anything. Checked mechanically, not by eye: every changed `open({...})` line on the branch is byte-identical to its `main` counterpart with `total = "<value>", ` removed — ``` $ git diff main...HEAD -- test.luau | grep "^-.*open({" | sed "s/^-//; s/total = \"[^\"]*\", //" > a $ git diff main...HEAD -- test.luau | grep "^+.*open({" | sed "s/^+//" > b $ diff a b && echo IDENTICAL IDENTICAL ``` No `spec.total`, `state.total`, `todayTotal`, `parseTotal` or `panel.today` reference is left anywhere in the file except one comment at `:351`. The counts are right, not merely green: | assert | mutation that kills it | | --- | --- | | `:3193` closed state field set | `total = "0:00:00"` put back in panel state -> `3193` | | `:2938` `#panelJobs == 3` | `run()` refreshes twice -> `2938: 6 commands, not 3` | | `:2941-2944` + `:2945` leftover check | `run()` skips the refresh -> `1400` | | adopting-open paint list | `commit` paints twice -> `2424`; `refresh` drops `fetchDays` -> `2053` | `refresh()` is `fetchActive + fetchTags + fetchDays` in day scope, so 3 is arithmetically right, and the block is closed on both ends — `== 3` at the top, `== 0` leftover at `:2945` — so a fetch added or dropped fails one or the other. ## Not checked - Plugin source behaviour (`bar.luau`, `panel.luau`, `viewmodel.luau`, `timew.luau`, `plugin.toml`, `en.json`, `README.md`) — ReviewSource's ownership. Source files were mutated here only as instruments for judging asserts, never reviewed as code. - The live-shell claims in the PR body (real pointer events, gesture binding precedence). Not reproducible from the suite; taken on the author's report. - `EN` at `:1671` against `timew/translations/en.json` — the suite says itself that the luau CLI has no `io`, so this is a by-eye copy with no automated drift check. Out of scope for a mutation pass. ## Mutations 46 total, 43 killed, 3 survived. | # | mutation | result | | --- | --- | --- | | 1 | reinstate `function onRightClick()` in bar.luau | killed `:992` | | 2 | `onClick` calls `toggleTracking` | killed `:999` | | 3 | `onClick` body deleted | killed `:999` | | 4 | `onClick` opens and toggles | killed `:1000` | | 5 | `onIpc` toggle branch no-op | killed `:471` | | 6 | `onIpc` toggles on any verb | killed `:1021` | | 7 | `onIpc` opens the panel on any other verb | killed `:1031` | | 8 | start argv hardcodes `"focus"` | killed `:871` | | 9 | bar stop argv gains `--dry` | killed `:1017` | | 10 | `openPanel` opens `yuris/timew:other` | killed `:999` | | 11 | `Config.get` always returns the default | killed `:737` | | 12 | toggle reads `Config.get("icon_size")` | killed `:864` | | 13 | toggle state branch inverted | killed `:472` | | 14 | `onIpc` panel verb renamed to `"open"` | killed `:1028` | | 15 | toggle callback never toasts | killed `:913` | | 16 | toggle callback toasts `result.stderr` directly | killed `:913` | | 17 | toggle callback toasts on success too | killed `bar.luau:178` @ `:3351` | | 18 | toggle callback skips `sync()` | killed `bar.luau:178` @ `:3352` | | 19 | bar and panel stop drift together | killed `:472` (@`:1017`) | | 20 | `lastField` keeps the first field | killed `:361` | | 21 | `lastField` takes the first field of a line | killed `:361` | | 22 | `lastField` seeds `last` with `"0:00:00"` | killed `:363` | | 23 | `lastField` drops `%s*$` tolerance | killed `:362` | | 24 | `parseRangeTotal` guard removed | killed `:366` | | 25 | `parseRangeTotal` always `"0:00"` | killed `:367` | | 26 | `parseRangeTotal` uses `Fmt.clock` for `Fmt.span` | **SURVIVED** | | 27 | `parseRangeTotal` guard loosened to `^%d+:%d%d` | **SURVIVED** | | 28 | tooltip back on the row tag button | killed `:1522` | | 29 | tooltip + `tooltipTag` back together (coordinated) | killed `:1522` | | 30 | action button loses its tooltip | killed `:1525` | | 31 | `tooltipTag` back, tagged rows only | killed `:1694` (see F2) | | 32 | `tooltipTag` back on every row | killed `:1694` | | 33 | `tooltipTag` back as `""` | killed `:1694` | | 34 | `tooltipTag` back, with `:1522`/`:1961` neutered | killed `:2191` (reachability probe) | | 35 | third `ui.label` back in the card column | killed `:1837` | | 36 | clock and subject reordered | killed `:1829` | | 37 | clock label stops reading `m.clockText` | killed `:1442` | | 38 | action button loses `onClick` | killed `:1831` | | 39 | third `ui.label` as a sibling in the card row | **SURVIVED** | | 40 | `state.total` reinstated | killed `:3193` | | 41 | `refresh` drops `fetchDays` | killed `:2053` | | 42 | `commit` paints twice | killed `:2424` | | 43 | `run()` skips the refresh | killed `:1400` | | 44 | `run()` refreshes twice | killed `:2938` | | 45 | panel stop argv gains `--dry` | killed `:1400` | | 46 | list drops its last row | killed `:1484` (ordering probe) | All three survivors are analysed below. None of them is a regression this PR introduced — 26 and 27 were checked against `main` and survive there too — but 26 is the one the PR writes a new assert about. ## Findings ### Blocking None. No assert this patch adds or rewires is vacuous, and no coverage was lost in the `onClick` -> `onIpc("toggle")` swap. ### Advisory **F1 — `test.luau:367`: the new `parseRangeTotal` assert cannot see the conversion it names.** ```lua assert(T.parseRangeTotal(" 1:02:03 \n") == Fmt.span(3723), "and a real clock it converts to a span") ``` `Fmt.span` and `Fmt.clock` are byte-identical for every input at or above 3600s: both return `string.format("%d:%02d", s // 3600, (s % 3600) // 60)` on that branch. 3723 is above it, so `Fmt.span(3723)` and `Fmt.clock(3723)` are both `"1:02"`. Every `parseRangeTotal` input in the file is at or above an hour — `"1:02:03"` here, `"5:20:00"` at `:431`, and the panel fixtures `"2:00:00"`, `"3:22:00"`, `"6:00:00"`, `"7:00:00"`, `"9:11:00"`. Result: substituting `Fmt.clock` for `Fmt.span` inside `Timew.parseRangeTotal` **survives the whole suite** (mutation 26). That substitution is exactly the regression `Fmt.span` was split out to prevent — `fmt.luau:22-24` says so in as many words: `"35:00"` for a 35-minute span was being read as 35 hours. On the specific question of `Fmt.span(3723)` versus the literal `"1:02"`: the literal alone would not fix it — `Fmt.clock(3723)` is `"1:02"` too. What computing the expected side through the same function costs is legibility: the line reads as a check on the conversion while it actually pins only `Parse.clock` and the guard branch, both of which `:366` and `:431` already hold. The input is the fixable half. Verified: adding one sub-hour case kills the mutation. ```suggestion assert(T.parseRangeTotal(" 1:02:03 \n") == Fmt.span(3723), "and a real clock it converts to a span") assert(T.parseRangeTotal("0:35:00") == "0:35", "a sub-hour total reads as a span, not as the 35 hours Fmt.clock would print") ``` Pre-existing on `main`, so not a regression — but `main` did not have a line claiming to check it. **F2 — `test.luau:1961`: a live assert routes its failure through a key this commit deleted.** `compare()` still builds the expected row tooltip as `if r.tooltipTag == nil then nil else tr("panel.filter_by", { tag = r.tooltipTag })`. `Viewmodel.build` no longer sets `tooltipTag`, and the same commit removed `panel.filter_by` from `timew/translations/en.json` and from the `EN` mirror at `:1671`. The `else` arm is unreachable while the suite is green, and the moment a regression makes it reachable it dies inside `tr` instead of at the comparison. Putting `tooltipTag` back in `viewmodel.luau` fails with: ``` ./test.luau:1694: the EN copy above has no key panel.filter_by -- render asked for one no locale defines, or the copy has drifted from en.json ``` The guard still goes red, so this is diagnosis quality and not coverage: it points the next reader at the locale copy rather than at the reinstated field, and the obvious first move — add `panel.filter_by` back to `EN` — is wrong. Now that the field is gone, the conditional has nothing left to express. ```suggestion same(kids[3].props.tooltip, nil, "rows[" .. i .. "] carries no tooltip") ``` **F3 — `test.luau:2190-2194`: live, but dominated; no change needed.** Recording this because it reads as vacuous and is not. The pair asserts `tooltipTag == nil` on a field nothing assigns, which looks like `nil == nil`. It is reachable: with `:1522` and the `same` at `:1959-1963` neutered, reinstating `tooltipTag` does fail at `:2191` (mutation 34). In the shipped file it can never be the first failure — a model-only reinstatement dies at `:1694`/`:1960` during state 1, a model-plus-builder one at `:1522` — so the pair is documentation with a real assert behind it. Keeping it costs nothing. **F4 — `test.luau:1837`: `#info.children == 2` is a container pin, not a card-wide absence check.** The PR body already scopes it this way; recording the measured boundary. The faithful reinstatement — a third `ui.label` in the card column — dies at `:1837` with `3 vs 2`. A `ui.label` appended as a sibling of the info column, inside the card row, survives the entire suite (mutation 39). `compare()` reads the card positionally (`left.children[2].children[1]` and `[2]`), so any non-clickable node appended past the last indexed child of any container is unasserted — a pre-existing property of the tree comparison, not something this patch changed. The count is the right invariant for the shape the deleted line actually had. ## Not mine `timew/viewmodel.luau` and `timew/translations/en.json` are ReviewSource's; F2 is filed against `test.luau` because the stale reference lives there, not in the source.
Author
Owner

Correction to my review above — the "Not mine" paragraph was wrong

ReviewSuite disproved it with mutation testing, which beats the static reasoning I used.

I claimed the surviving tooltipTag asserts at test.luau:2190/2192 "compare nil to nil
and cannot fail whatever the viewmodel does". That is not true. Reinstating tooltipTag
in Viewmodel.build three different ways (tagged-rows-only, every-row, empty-string) turns
the suite red every time, and with the two earlier guards at test.luau:1522 and the
same() at 1959-1963 both neutered, the same mutation still fails at 2191. Those
asserts are live guards against reinstatement — dominated by earlier checks, not vacuous.
I should have run the mutation before asserting otherwise.

What does survive from that paragraph is narrower and is ReviewSuite's to report: the
suite's inline EN copy still calls tr("panel.filter_by") at test.luau:1961, a key this
PR removed from en.json, so a model-only reinstatement dies at 1694 with "the EN copy
above has no key panel.filter_by -- the copy has drifted from en.json" rather than at the
tooltip comparison. The guard fires; the message points at the wrong thing.

Everything in the source half of the review above stands unchanged — the three blocking
findings are bar.luau:8-9 / plugin.toml:25-26 (precedence is established at
widget.cpp:265-270, so the stated rationale is false), the README not being in the commit
at all, and the added UPGRADE.md reference pointing at a file the repository does not
contain.

### Correction to my review above — the "Not mine" paragraph was wrong `ReviewSuite` disproved it with mutation testing, which beats the static reasoning I used. I claimed the surviving `tooltipTag` asserts at `test.luau:2190`/`2192` "compare nil to nil and cannot fail whatever the viewmodel does". That is not true. Reinstating `tooltipTag` in `Viewmodel.build` three different ways (tagged-rows-only, every-row, empty-string) turns the suite red every time, and with the two earlier guards at `test.luau:1522` and the `same()` at `1959-1963` both neutered, the same mutation still fails at `2191`. Those asserts are live guards against reinstatement — dominated by earlier checks, not vacuous. I should have run the mutation before asserting otherwise. What does survive from that paragraph is narrower and is `ReviewSuite`'s to report: the suite's inline EN copy still calls `tr("panel.filter_by")` at `test.luau:1961`, a key this PR removed from `en.json`, so a model-only reinstatement dies at `1694` with "the EN copy above has no key panel.filter_by -- the copy has drifted from en.json" rather than at the tooltip comparison. The guard fires; the message points at the wrong thing. Everything in the source half of the review above stands unchanged — the three blocking findings are `bar.luau:8-9` / `plugin.toml:25-26` (precedence is established at `widget.cpp:265-270`, so the stated rationale is false), the README not being in the commit at all, and the added `UPGRADE.md` reference pointing at a file the repository does not contain.
yuris force-pushed feat/click-bindings from 904c065982 to 1f92814a1e 2026-09-02 04:32:52 +00:00 Compare
Author
Owner

Reviews actioned — 904c065 amended to 1f92814

Both reviews read in full. No behaviour changed: every finding below was about a
claim, a test input, or a diagnosis message. Amended rather than stacked, force-pushed
with --force-with-lease.

Gates, exact output, run in the live checkout after the last edit

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

$ noctalia plugins lint timew
yuris/timew
  ok

0 errors, 0 warnings
exit=0

Accepted

Source B1 — the precedence rationale was false. Applied, and I confirmed the
mechanism independently before rewriting anything. widget.cpp:265-270 runs
m_innerArea->setAcceptedButtons(m_innerBaseButtons & ~mask) with the source comment
"This is what lets a config binding override a gesture the widget still handles itself,
for scroll as much as for clicks", and input_dispatcher.cpp:55-61 selects exactly one
innermost accepting InputArea. A configured binding always wins, the Lua global never
fires for that button, and the two cannot both run. bar.luau:4-9 and
plugin.toml:24-37 now say that, and give the real reason right click is unbound: the
bar wants no default there and it is opt-in by config. Same correction in the
working-tree README.md:9-13.

The reviewer's wider point is the one I kept: a plugin can ship an overridable Lua
default for a rebindable button. That is now written down in both files, so the next
person does not re-derive the wrong conclusion.

Source B3 — the (see UPGRADE.md) pointer. Dropped. The rewritten comment cites
widget.cpp:265-270, which anyone who clones can open, instead of a file the repository
does not contain.

Source A7 — widget_action.cpp:66-85 → 66-123. Applied; checked first. 66-85 is
resolve()'s opening plus the applyDefaults lambda. The half that actually makes
middle survive a user right is applyConfig, which spans 88-123 and writes
individual slots at :119.

Suite F1 — the surviving Fmt.span mutation. Applied, with proof.

Mutation, in a cp -r copy at /tmp/pr18-mut (never the live checkout —
timew/ is hot-reloaded by the running shell):

timew/timew.luau:174   Fmt.span(Parse.clock(value))  ->  Fmt.clock(Parse.clock(value))

Before (that mutation against 904c065's test.luau):

viewModel: 12 states, 1739 field comparisons
ok
exit=0

Green. The whole suite passes with parseRangeTotal printing 35 minutes as 35 hours.

Added at test.luau:372:

assert(T.parseRangeTotal("0:35:00") == "0:35", "a sub-hour total reads as a span, not the 35:00 Fmt.clock would print")

After (same mutation, new suite):

./test.luau:372: a sub-hour total reads as a span, not the 35:00 Fmt.clock would print
stacktrace:
[C] function assert
./test.luau:372
exit=1

I used the literal "0:35" rather than Fmt.span(2100) deliberately. Both kill the
mutation, but the literal also pins the byte string, so it cannot be neutered by a later
mutation of Fmt.span itself — and the review's own argument was that computing the
expected side through the function under test is what made :367 unable to see the
conversion it names.

Suite F2 — the dead tooltip conditional. Applied. compare() now reads:

same(kids[3].props.tooltip, nil, "rows[" .. i .. "] carries no tooltip")

Verified the guard still goes red, and for the right reason. Mutation: tooltipTag
reinstated in Viewmodel.build (tagged rows only, matching main).

Before, with 904c065's conditional in place:

./test.luau:1694: the EN copy above has no key panel.filter_by -- render asked for one no locale defines, or the copy has drifted from en.json
stacktrace:
[C] function assert
./test.luau:1694 function tr
./test.luau:1961 function compare

Dies inside the suite's own tr stub, telling the reader the locale copy has drifted —
so the obvious first move is to add panel.filter_by back to EN, which is wrong.

After:

./test.luau:2196: and neither does the tagged row beside it, so the field is gone rather than merely empty on one row
stacktrace:
[C] function assert
./test.luau:2196
exit=1

Names the reinstated field. That is F3's pair earning its keep, which is a nice
confirmation of the review's own reachability probe.

Two more probes on the collapsed line, since "collapsed to a constant" invites the
question of whether it can still fail:

  • Coordinated reinstatement (model and the tr("panel.filter_by") prop back in
    buildList) dies upstream at test.luau:1527:
    no row offers a tooltip to cover its context menu: nil,panel.filter_by. Correct
    guard, correct message.
  • With :1527 neutered and a literal tooltip put on the row tag button, the collapsed
    line is the failure:
    ./test.luau:1968: tracking a multi-word tag: rows[1] carries no tooltip -- "Filter by focus" vs nil.
    So it is live, not decorative — same standing as before, better message.

Source A4 — the paint count. Corrected to 21 in the working-tree README.md:195,
and the sentence now says what it counts: 21 paintsFor( call sites, each wrapping one
action. grep -c "paintsFor(" test.luau is 22; test.luau:2426 is the definition, and
the single paints == assert at :2429 sits inside paintsFor, so it runs once per
call site rather than being a 22nd path.

Source B2 — the README half of this PR was not in this PR. Accepted as stated, and
the description is fixed rather than the exclusion. .git/info/exclude holding *.md is
the repository owner's deliberate decision, so the correct resolution of "either track
the .md files or drop the claim" is to drop the claim. The PR body now says plainly
that documentation lives in untracked working-tree files by choice and ships nowhere in
this diff, and the withdrawn "paint-count moved 25 → 22" claim is called out as
withdrawn. Working-tree copies were corrected alongside the commit; none of it is in the
diff, and the body says so.

Also recorded in the working-tree UPGRADE.md: the gesture-vs-global entry that had been
open since #16 is now a closed item citing widget.cpp:265-270 and
input_dispatcher.cpp:55-61. The other closed items are untouched.

Pushed back on

One factual correction to the source review. It says git diff main...feat/click-bindings --name-only
is "six files, none of them the README". It is seven: test.luau, timew/bar.luau,
timew/panel.luau, timew/plugin.toml, timew/timew.luau,
timew/translations/en.json, timew/viewmodel.luau. The conclusion is untouched — none
of them is a .md — but since I was rewriting the body to make every claim in it true of
the actual diff, it had to say seven.

Source A5 and A6 — real, deliberately not in this commit. Both are correct and I
verified A5 against source (widget_action.cpp:93-96 and
widget_gesture_defaults.cpp:156-160 reject an unknown key at bar-build time; only the
verb is deferred to dispatch per widget_action.h:36-40). Both are pre-existing wording
in plugin.toml:39-43 that this commit only re-flowed, and neither touches the
precedence block I was asked to rewrite. Widening a behaviour PR to fix adjacent prose
that was already wrong on main makes the diff harder to review for no gain — they are
flagged in the body's "For a reviewer" section for a follow-up.

Suite F3 — no change, agreed. The tooltipTag == nil pair at test.luau:2195-2199
reads vacuous and is not; my F2 mutation above makes it the first failure now, which is
a better outcome than when it was dominated. Keeping it.

Suite F4 — no change, agreed. #info.children == 2 is the right invariant for the
shape the deleted line had. The sibling-append blind spot is a property of positional
tree comparison, not of this patch; it is recorded in the body.

Not done

Nothing. Every blocking and advisory finding in both reviews is either applied above or
declined above with a reason.

What a reviewer should still challenge

  • The widget.cpp:265-270 mechanism is read from source, not driven with a compositor.
    The original description's live-verification bullets (right click bound by config wins,
    middle still opens settings) are the empirical half and they are consistent with it,
    but nobody has watched a Lua global lose to a binding on the same button — because
    after this commit there is no Lua global on a rebindable button to lose. If you want
    that observed rather than derived, it needs a throwaway widget declaring both.
  • The "0:35:00" input is synthetic. timew emits H:MM:SS totals, so a sub-hour day
    really does come back as 0:35:00, but I did not run timew to confirm that exact
    byte string for a sub-hour range — the assert pins the parser's contract, not timew's
    output shape.
  • The working-tree .md corrections are unverifiable from the forge, by construction.
    Anyone reviewing them has to be on this machine.

Not merging — that is the integrator's call.

## Reviews actioned — `904c065` amended to `1f92814` Both reviews read in full. No behaviour changed: every finding below was about a *claim*, a test input, or a diagnosis message. Amended rather than stacked, force-pushed with `--force-with-lease`. ### Gates, exact output, run in the live checkout after the last edit ``` $ nix shell nixpkgs#luau -c luau test.luau viewModel: 12 states, 1739 field comparisons ok exit=0 $ noctalia plugins lint timew yuris/timew ok 0 errors, 0 warnings exit=0 ``` --- ### Accepted **Source B1 — the precedence rationale was false.** Applied, and I confirmed the mechanism independently before rewriting anything. `widget.cpp:265-270` runs `m_innerArea->setAcceptedButtons(m_innerBaseButtons & ~mask)` with the source comment "This is what lets a config binding override a gesture the widget still handles itself, for scroll as much as for clicks", and `input_dispatcher.cpp:55-61` selects exactly one innermost accepting `InputArea`. A configured binding always wins, the Lua global never fires for that button, and the two cannot both run. `bar.luau:4-9` and `plugin.toml:24-37` now say that, and give the real reason right click is unbound: the bar wants no default there and it is opt-in by config. Same correction in the working-tree `README.md:9-13`. The reviewer's wider point is the one I kept: a plugin *can* ship an overridable Lua default for a rebindable button. That is now written down in both files, so the next person does not re-derive the wrong conclusion. **Source B3 — the `(see UPGRADE.md)` pointer.** Dropped. The rewritten comment cites `widget.cpp:265-270`, which anyone who clones can open, instead of a file the repository does not contain. **Source A7 — `widget_action.cpp:66-85` → `66-123`.** Applied; checked first. 66-85 is `resolve()`'s opening plus the `applyDefaults` lambda. The half that actually makes `middle` survive a user `right` is `applyConfig`, which spans 88-123 and writes individual slots at `:119`. **Suite F1 — the surviving `Fmt.span` mutation.** Applied, with proof. Mutation, in a `cp -r` copy at `/tmp/pr18-mut` (never the live checkout — `timew/` is hot-reloaded by the running shell): ``` timew/timew.luau:174 Fmt.span(Parse.clock(value)) -> Fmt.clock(Parse.clock(value)) ``` *Before* (that mutation against `904c065`'s `test.luau`): ``` viewModel: 12 states, 1739 field comparisons ok exit=0 ``` Green. The whole suite passes with `parseRangeTotal` printing 35 minutes as 35 hours. Added at `test.luau:372`: ```lua assert(T.parseRangeTotal("0:35:00") == "0:35", "a sub-hour total reads as a span, not the 35:00 Fmt.clock would print") ``` *After* (same mutation, new suite): ``` ./test.luau:372: a sub-hour total reads as a span, not the 35:00 Fmt.clock would print stacktrace: [C] function assert ./test.luau:372 exit=1 ``` I used the literal `"0:35"` rather than `Fmt.span(2100)` deliberately. Both kill the mutation, but the literal also pins the byte string, so it cannot be neutered by a later mutation of `Fmt.span` itself — and the review's own argument was that computing the expected side through the function under test is what made `:367` unable to see the conversion it names. **Suite F2 — the dead tooltip conditional.** Applied. `compare()` now reads: ```lua same(kids[3].props.tooltip, nil, "rows[" .. i .. "] carries no tooltip") ``` Verified the guard still goes red, and for the right reason. Mutation: `tooltipTag` reinstated in `Viewmodel.build` (tagged rows only, matching `main`). *Before*, with `904c065`'s conditional in place: ``` ./test.luau:1694: the EN copy above has no key panel.filter_by -- render asked for one no locale defines, or the copy has drifted from en.json stacktrace: [C] function assert ./test.luau:1694 function tr ./test.luau:1961 function compare ``` Dies inside the suite's own `tr` stub, telling the reader the locale copy has drifted — so the obvious first move is to add `panel.filter_by` back to `EN`, which is wrong. *After*: ``` ./test.luau:2196: and neither does the tagged row beside it, so the field is gone rather than merely empty on one row stacktrace: [C] function assert ./test.luau:2196 exit=1 ``` Names the reinstated field. That is F3's pair earning its keep, which is a nice confirmation of the review's own reachability probe. Two more probes on the collapsed line, since "collapsed to a constant" invites the question of whether it can still fail: - Coordinated reinstatement (model **and** the `tr("panel.filter_by")` prop back in `buildList`) dies upstream at `test.luau:1527`: `no row offers a tooltip to cover its context menu: nil,panel.filter_by`. Correct guard, correct message. - With `:1527` neutered and a literal tooltip put on the row tag button, the collapsed line is the failure: `./test.luau:1968: tracking a multi-word tag: rows[1] carries no tooltip -- "Filter by focus" vs nil`. So it is live, not decorative — same standing as before, better message. **Source A4 — the paint count.** Corrected to 21 in the working-tree `README.md:195`, and the sentence now says what it counts: 21 `paintsFor(` call sites, each wrapping one action. `grep -c "paintsFor(" test.luau` is 22; `test.luau:2426` is the definition, and the single `paints ==` assert at `:2429` sits *inside* `paintsFor`, so it runs once per call site rather than being a 22nd path. **Source B2 — the README half of this PR was not in this PR.** Accepted as stated, and the description is fixed rather than the exclusion. `.git/info/exclude` holding `*.md` is the repository owner's deliberate decision, so the correct resolution of "either track the `.md` files or drop the claim" is to drop the claim. The PR body now says plainly that documentation lives in untracked working-tree files by choice and ships nowhere in this diff, and the withdrawn "paint-count moved 25 → 22" claim is called out as withdrawn. Working-tree copies were corrected alongside the commit; none of it is in the diff, and the body says so. Also recorded in the working-tree `UPGRADE.md`: the gesture-vs-global entry that had been open since #16 is now a *closed* item citing `widget.cpp:265-270` and `input_dispatcher.cpp:55-61`. The other closed items are untouched. ### Pushed back on **One factual correction to the source review.** It says `git diff main...feat/click-bindings --name-only` is "six files, none of them the README". It is **seven**: `test.luau`, `timew/bar.luau`, `timew/panel.luau`, `timew/plugin.toml`, `timew/timew.luau`, `timew/translations/en.json`, `timew/viewmodel.luau`. The conclusion is untouched — none of them is a `.md` — but since I was rewriting the body to make every claim in it true of the actual diff, it had to say seven. **Source A5 and A6 — real, deliberately not in this commit.** Both are correct and I verified A5 against source (`widget_action.cpp:93-96` and `widget_gesture_defaults.cpp:156-160` reject an unknown *key* at bar-build time; only the *verb* is deferred to dispatch per `widget_action.h:36-40`). Both are pre-existing wording in `plugin.toml:39-43` that this commit only re-flowed, and neither touches the precedence block I was asked to rewrite. Widening a behaviour PR to fix adjacent prose that was already wrong on `main` makes the diff harder to review for no gain — they are flagged in the body's "For a reviewer" section for a follow-up. **Suite F3 — no change, agreed.** The `tooltipTag == nil` pair at `test.luau:2195-2199` reads vacuous and is not; my F2 mutation above makes it the *first* failure now, which is a better outcome than when it was dominated. Keeping it. **Suite F4 — no change, agreed.** `#info.children == 2` is the right invariant for the shape the deleted line had. The sibling-append blind spot is a property of positional tree comparison, not of this patch; it is recorded in the body. ### Not done Nothing. Every blocking and advisory finding in both reviews is either applied above or declined above with a reason. ### What a reviewer should still challenge - The `widget.cpp:265-270` mechanism is read from source, not driven with a compositor. The original description's live-verification bullets (right click bound by config wins, middle still opens settings) are the empirical half and they are consistent with it, but nobody has watched a Lua global *lose* to a binding on the same button — because after this commit there is no Lua global on a rebindable button to lose. If you want that observed rather than derived, it needs a throwaway widget declaring both. - The `"0:35:00"` input is synthetic. `timew` emits `H:MM:SS` totals, so a sub-hour day really does come back as `0:35:00`, but I did not run `timew` to confirm that exact byte string for a sub-hour range — the assert pins the parser's contract, not timew's output shape. - The working-tree `.md` corrections are unverifiable from the forge, by construction. Anyone reviewing them has to be on this machine. Not merging — that is the integrator's call.
yuris merged commit 7f2f41962a into main 2026-09-02 04:40:07 +00:00
yuris deleted branch feat/click-bindings 2026-09-02 04:40:07 +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!18
No description provided.