refactor(panel): split render into region builders reading the model #9
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "refactor/t5b-render"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Wave 4, second and last. PR #8 built the model beside an untouched
render();this moves
render()onto it and splits the 256-line function into regionbuilders. After this
render()assembles regions and derives nothing.Region-builder inventory
render(assembly)renderbuildCard— clock, subject, today, actionrender)buildRecent— the chip rowrecentButtonsbuildTrend— the chart captionrender)buildChart— the 14 day barsdayColumnsbuildSearch— the field and its magnifierrender)scopeButtonrender)buildScope— two scope buttons, chip, totalrender)buildList— the windowed rowsactivityRowsbuildPager— number strip and range labelrender)display— the(value, isKey)contracttagLabelviewModelwent 127 → 134: one field and its comment (below).panel.luau1547 → 1607,
test.luau1234 → 1372.Three seams differ from the brief's sketch, because that is what the code is:
buildScope's, notbuildSearch's. It rides in the headerrow so that appearing and disappearing costs the list no rows —
render's owncomment said so on
main. The search box sets it; the scope row shows it.buildTrendexists as its own ten lines. It is a sibling of the chart rowin the right column, not a child of it, and folding it into
buildChartwouldhave changed the tree.
scopeButtonmoved to module level from a closure insiderender. Samethree props, and one fewer closure per repaint — which is not a saving worth
quoting against what the split does cost. Measured, below.
What the split costs, measured
renderonmainderived as it painted;renderhere builds a 35-table modelfirst, every repaint. That is a real cost and the honest number is a loss, not a
saving. Both panels loaded in one
luauprocess, worst state the panel can reach(200 all-time rows, page 4 of 25), min of 9 interleaved rounds x 500, five runs:
mainuinode / prop tables+0.003 ms and +6 KiB, or +4–8% of a repaint that uses 0.19% of the 25 ms
per-handler budget: 0.19% → 0.21%. A page click pays one extra
viewModel()on top, since the handler reads
pager.pageBaseoff the model before rendering:0.049 ms → 0.070 ms of 25 ms. Equal node and prop counts are the tree identity
restated — no builder allocates per row or per day where
maindid not, and thewhole delta is the model itself.
256 lines of derive-as-you-paint down to 46 lines of assembly, for 0.02% of the
budget. Worth it, and worth stating as a price rather than implied to be free.
render()derives nothing — the audit, case by caseEverything a builder now puts in a prop, and which of the three legal sources it
came from. No case needed a new display field; the one field added is not a
displayed value at all.
From the model — every value in PR #8's field inventory, unchanged.
Layout constants —
LEFT_W,RIGHT_W,ACTION_W,SUBJECT_H,BAR_W,BAR_H,BAR_GAP,LEAD_W,DUR_W,ROW_H,CHIP_H, every gap, padding,radius,
fontSize,fontWeight, colour role, alignment, and the row dot's 8x8.Constant translation keys, which vary with nothing:
panel.title,panel.recent,panel.no_tags,panel.no_history,panel.nothing_day,panel.scope_all,panel.clear_filter,panel.search_placeholder,panel.tag_placeholder,panel.start/panel.stop,panel.newer/panel.older.The two in
renderitself —panel.titleandpanel.recent— are the leftcolumn's section labels; they head the two builders below them and are part of
the composition, not of a region.
Substituted keys where the builder keeps the literal and the model carries the
value, exactly as #8 argued:
panel.today{todayTotal},panel.trend/panel.trend_tag{DAYS, filter},panel.filter_chip{filter},panel.filter_by{tooltipTag},panel.start_tag{tag},panel.page_range{firstShown, lastShown, total}.Literals that are a shape and not text —
‹and›.--:--and--wereliterals in
renderonmain; both are the model's now (clockText,Fmt.dayLabel), so the builders hold two fewer thanrenderdid.Six computations survive in builders. Each one is layout, and here is the
argument for each rather than an assurance:
math.max(2, math.floor(BAR_H * day.seconds / peak))—buildChart. A pixelheight.
BAR_His layout,secondsandchartPeakare the model's; #8named this split explicitly and
test.luaureadsBAR_Hback off the treerather than copying it, so the constant is never spelled twice.
p.pages <= 1,p.current > 1,p.current < p.pages—buildPager. Whetherthe row exists and whether each arrow is enabled. Comparisons between two
model numbers; #8 omitted
pager.visiblefor this reason and the omissionstill holds.
m.scope == "day"/m.scope == "all"—buildScope. Which of two buttonsis tinted. A model field against the constant that button names; a
dayIsSelectedfield would be the same comparison with a longer name.m.filter ~= ""—buildScope. Whether the chip node exists. Same class:""is the model's own spelling of "no tag".subjectW = LEFT_W - ACTION_W - 24—buildCard. Arithmetic on three layoutconstants.
mainspelled this twice, once assubjectWand once inline;collapsed to the one local, same number.
"tag-" .. m.draftKey,"search-" .. m.searchKey,tostring(slot.page)—widget identity and a number-to-label. The counter is the model's; the prefix
is the widget's own name and belongs to whoever builds that widget.
The one thing
renderdoes that is not building a node:activityRowsused to writeshownon its way to building the list. A callbackis resolved by global name at this plugin API and cannot be handed an argument,
so
onRowNindexesshownandgotoPageclamps againstmatchingwhen theclick arrives — both have to outlive the repaint. The model computed both; this
publishes them, and it is the only assignment left in
renderbesidesfocusOnce = false, which is wave 5's.That needed one new model field:
The raw rows, because
filterFromRowcomparesUNTAGGEDandrows[i].tagTexthas already turned that into a key. The alternative I rejected: publish
m.rowsand havefilterFromRowreadrows[i].tooltipTag, which is nilexactly when the tag is the sentinel — #8's review argued that field's presence
is the filter-target answer, so this would have cost zero new fields. I did
not take it, for two reasons: it makes a field named for the tooltip the click
target, and it edits
filterFromRow, which #8 hash-pinned as identical tomain's. Challenge it if you disagree; it is a two-line change either way.windowRowshas no prop, so it is pinned incompare()against the rows thatdo — same length, same order, and each row's tag against the
tagTextthebuilder displays, keyed where the sentinel needed it.
The
pageBaseduplication is closedlocal pageBase = 1at:240is gone, along withpageBase = firstShowninrenderandrender's secondQuery.pageStripcall.m.pager.pageBaseis theonly spelling left. The handlers read it off the model:
Why this and not baking the page into five handler names: you cannot. The
five names are fixed public surface and the strip slides; a name can only
carry its slot number, which is what the base exists to offset. There is no
third option that keeps one spelling — a narrow
local function pageBase()overthe same module state would be a second spelling, which is precisely what the
deferred item forbids.
The cost, measured rather than asserted. One
viewModel()on a page click,in the worst state the panel can reach (all-time, 45 matches, page 4 of 6):
The click already pays for a repaint that builds the same model plus those 103
nodes, so the handler's extra model is about an eighth of a click it was already
making, and the window it walks is bounded by
ALL_WALK_MAX= 200. Against the25 ms per-handler budget this is not measurable. The thing that would have
been expensive is a builder calling
viewModel()— one model per region insteadof one per repaint, on a panel that repaints once a second — and
test.luaunowpins that count.
One honest consequence, not hidden.
pageBasewas written at paint time;the model is read at click time. Those differ only along a path that changes
state without repainting, and there is exactly one:
loadDay(index)with anindex
daysdoes not hold returns before itsrender(), reachable vianoctalia msg … day 99. On that path the strip on screen is stale in everyother respect too, and the new reading is the fresher of the two. Noted because
it is a real behaviour delta that the tree harness cannot see.
Also closed, the second
REFACTOR.mditem:buildChartreadsm.chartPeakinstead of calling
barPeak()a second time.barPeaknow has exactly onecaller, the model.
Tree identity
render()'s output must be identical tomain's, not equivalent. A harnessrequires
git show main:timew/panel.luaualongside this branch's copy — eachgets its own global table from the luau CLI, so both are stubbed identically —
drives the two through the same sequence of shell callbacks, and flattens both
trees with props key-sorted, one node per line plus a child count per node. It
lives outside the repo, because
main's copy is not a file in the repo.en.jsonis not hand-copied into it:jqgenerates the table fromtimew/translations/en.json, so the two sides resolve keys through the reallocale and a missing key raises.
27 states. The 14
test.luaudrives, plus a bareupdate()repaint, a dayscope whose filter matches no row, the empty-search state reached by clearing
the chip (
searchempty with its key bumped, no chip, list back toeverything), all six pager positions on the 6-page list rather than three, both
ends reached through
‹/›so arrow enablement is pinned at both boundaries,and the five pager handlers each driven from a painted page 4 where the strip's
base is 2. Every state the brief named is in there: filtered day scope, filtered
all-time scope, an untagged row, a day with no intervals, and the deferred
backwards-range edge (
17-16 / 12), which is reproduced, not fixed —gotoPagestill clamps against a stalematchingand I did not touch it.The harness fails when it should. Three mutants against the branch copy, each
restored
cmp-clean:buildScoperowgap = 4→5row(align="center", gap=5, …)selected = row.isFiltered→trueonPage1()→gotoPage(1)pager handler onPage1 from page 4The second mutant is why byte counts alone are not the check:
gap = 4→5isbyte-identical in length, and only the line diff catches it.
Tests
The existing 1234 lines pass unchanged. Nothing reached into a function I
deleted:
dayColumns,activityRows,recentButtonsandtagLabelwere allmodule locals with no export, and the suite reads the tree through the
panel.renderstub. Nothing needed adapting — so there is no per-testjustification to give, which is the strongest form of the answer.
compare(),which indexes the tree structurally in all 14 states, is the part that would
have broken first, and it did not.
It also earned its keep: my first
buildListdropped the row'sui.spacer({ flexGrow = 1 }), andcompare()caught it atkids[5]before Ihad run anything else.
Three new asserts, plus
windowRowsincompare(). Every one proven live bymutation, all mutation in a
/tmpcopy, every restorecmp-clean against thepre-mutation file.
1. One model per repaint. The trap in this split is a builder calling
viewModel()itself: nothing about the tree would look wrong, so the identityharness cannot see it. Counted as a parity —
Fmt,ParseandQueryareexported by reference, so wrapping their entries is visible inside the module
with no hook the panel would not otherwise have — and a repaint must reach the
pure core exactly as many times as taking the model alone does.
barPeakis aQueryentry for this count's sake. As the module local it wason
mainit reached none of the three watched tables, so a builder re-derivingthe chart peak added no counted call and this parity passed — the one
duplication the count exists to catch that it could not see. Its body is
main's byte for byte; the declaration line moved, and it now takes the daylist as an argument like every other entry in the pure core, where
daysis notyet in scope to be read any other way.
rendercallsviewModel()twicetest.luau:1315— 24 calls against the model's 12buildChartre-derivesQuery.barRolestest.luau:1315— 15 against 12test.luau:1314— "the counter counts something, or the parity below is vacuous"test.luau:1310— the restore canaryThe canary was vacuous on the first try: this file's
Queryalias at:50ispanel.Query, so comparing them after the restore compared a wrapper withitself. Fixed by taking the real function before wrapping.
2.
renderpublishes the slice a click indexes.renderstops publishingshowntest.luau:1340, and:692on the full suiterenderpublishes a staleshowntest.luau:1340filterFromRowreads the wrong field of the slicetest.luau:1340test.luau:974—windowRows is the slice m.rows describes -- 1 vs 3test.luau:974—3 vs 2, in the filtered day stateThe first three fire at
:1340only once the earlieronIpc("row", …)sectionis cut out of a sandbox copy — on the full suite
:692catches them first,which is the older guard on the same contract. Said plainly rather than claimed
as a first failure. The two fixture preconditions above them (
#slice == 2, andthe slice carrying
(untagged)rather than the key) are guarded upstream, at:86for the sentinel and bycompare()for the length; their job is to makethe two clicks below them unambiguous.
3. The pager handlers agree with the model's base. Driven from a painted
page 4 of 6, where the base is 2 rather than 1, and asserting the strongest
form: each handler pages to the number its own button showed.
onPage1()→gotoPage(1)test.luau:1362— "onPage1 pages to the number its button showed, 2"gotoStripPagedrops the basetest.luau:1362test.luau:1358— "the strip's slot 1 is onPage1"Field comparisons 1385 → 1485:
windowRowsadds two per row plus one per state.Gate
luau-analyzereports the same diagnostics asmain— the pre-existing hostglobals and unused handler names — with one fewer
noctaliaread, 39 → 38,because
tagLabel'strcall andrender's two inlineisKeybranchescollapsed into one
display.No plugin source added or removed, no
timewdata touched, the running shelluntouched.
Screenshots to take before merging
Visual verification is yours. The tree is byte-identical for every state below,
so a difference in any of them is a shell-side layout effect the harness cannot
reach:
--:--, the tag field focused,--on the scopebutton, both empty-state placeholders.
four-tag cap, the stop glyph.
against the header total.
buttons, the trend caption's tag form.
carrying the tag beside a dark zero day.
2 3 4 5 6with 4 selected,both arrows enabled, and the range label. Click
2and confirm it landson page 2: that is the
pageBasechange, and it is the one interaction nostill image proves.
(untagged)label with notooltip on it, and a tooltip on the row below.
placeholder, with the chart still drawn above it.
Verdict: APPROVE
Reviewed by rebuilding the acceptance check rather than reading yours: a second
tree-identity harness, my own core-call instrumentation, my own timings, and
mutation against a throwaway copy of the worktree. The tree holds. One test-side
hole to close, two notes.
Findings
1.
barPeakis invisible to the one-model-per-repaint parity — P2timew/panel.luau:929,timew/panel.luau:1008, guarded attest.luau:1303.The parity counts calls into
Fmt/Parse/Query.barPeakis a module localwhose body reaches none of them:
So a builder that re-derives the peak adds zero counted calls. The comment
directly above the parity names this exact duplication as what it is guarding
against —
test.luau:1261-1262"which is what main did twice -- barPeak again indayColumns, Query.window again in activityRows" — and it cannot see it. Both
spellings pass:
The tree-identity harness cannot see it either — it is a cost, not a value — so
this mutant survives both gates:
barPeakis already a pure function ofdays; it only takes its argumentimplicitly. Route it through the table the counter watches and the existing
assert covers it for free:
with its one call site at
:1008:Proven, on top of that change: the suite still passes clean, and the mutant that
passed above now dies where it should.
That moves
barPeak's declaration line, which #8 hash-pinned; the body isuntouched. If you would rather not move it, the honest alternative is to narrow
test.luau:1261-1262so it stops claiming to cover thebarPeakhalf — butthen the duplication your own comment names has no guard at all. I prefer the
move.
2.
test.luau:1323names the published slice and asserts the model's — P3The subject is
viewModel().windowRows, not the module localrenderpublishedinto. The publication is genuinely pinned two lines down, by
onRow1/onRow2landing on the right filter — I killed a dropped
shown, matching = …and itdied at
test.luau:684— so nothing is weakened. Only the message overclaims.3. The repaint is measurably slower and heavier; the only stated perf claim is a saving — P3
timew/panel.luau:1290. The PR body claimsscopeButtonat module level "costsa once-a-second repaint one fewer closure". True, and it is one closure against
the 35-table model that
rendernow builds every repaint andmainnever builtat all. Measured, both sides in one process, worst state the panel can reach
(200 all-time rows, page 4 of 25), min of 9 interleaved rounds x 500:
No builder allocates per row or per day where
maindid not — the node and propcounts are equal, which is the tree identity restated. The whole delta is the
model, +7 KiB and +4.6 us, against a 25 ms budget. Nothing to fix in the code;
the fix is to say so, since "one fewer closure" is the only number in the PR
body and it points the wrong way.
Verified
Tree identity — my own harness, 32 states, zero differences. Both
git show main:timew/panel.luauand this branch loaded in oneluauprocess,each with its own global table, stubbed identically, driven through the same
sequence, flattened one line per node with props key-sorted and a child count.
trresolves through a tablejqgenerates fromtimew/translations/en.json,so a key no locale defines raises.
The harness asserts its own two soundness preconditions: that the two modules do
not share a global table (they would otherwise both write one
panelTreeandevery comparison would be vacuous), and that no function value ever reaches a
prop slot — the no-closures-in-callbacks rule, checked on every one of those
2372 nodes rather than argued.
States: idle; a scope-click repaint;
onOpen's own paint with the focus requeststill pending; tracking a multi-word tag; a second-tick repaint while tracking;
tracking untagged; day scope with rows; day scope filtered; day scope filtered by
a tag no row carries; all-time; all-time filtered by a tag some days carry; the
chip cleared back to an empty search; a day with an untagged row; pages 1-6 of a
6-page list; both ends reached through
</>; the five named page handlers eachdriven from a painted page 4 where the base is 2; a day with no intervals; the
backwards range; the bar's published
elapsedText; an untagged row in theall-time list; the search box mid-typing.
The backwards range is reproduced, not fixed —
17-16 / 12on both sides.Nine mutants against the branch copy, every one killed, every one restored
cmp-clean:buildScoperowgap = 4->5buildListselected = row.isFiltered->trueonPage1()->gotoPage(1)subjectW- 24->- 25buildListdrops the row spacerbuildScopedrops its spacermath.max(2, ...)->math.max(1, ...)buildPagerp.current > 1->truedisplaydrops thetrThe
max(2 -> 1)mutant is byte-count-identical on both sides (244419 each) andshows up only in the line diff, which is the case for not trusting a byte total.
One model per repaint — instrumented, not assumed. Wrapping every
Fmt/Parse/Queryentry and counting a bareupdate()against oneviewModel(), in the worst state:Query.windowis 1 per repaint andviewModelcalls it exactly once, sorenderbuilds exactly one model and no builder re-derives — subject to finding1, which is the one derivation this instrument cannot see.
mainscores 18/18too, which is the like-for-like the timing above compares.
The
pageBasecollapse — the staleness question, ruled. There is exactlyone path in this module that moves pager state and returns without a repaint,
and it is the one you named:
loadDay(index)early-returns attimew/panel.luau:674whendays[index]is nil, after having already writtenlistOffset,selectedandmode. Reachable asnoctalia msg plugin yuris/timew:panel all day 99. Every other mutator renders.I drove it on both sides:
mainreads the base its last paint wrote, 2, and pages an all-time base into athree-row day list: page 2, which is empty. The branch reads a base derived from
the state the click actually finds, 1, and shows the day. The branch is
right, and by a wider margin than the PR body claims — this is not "the fresher
of two equally stale readings", it is the difference between an empty list and
the correct one. The strip on screen is stale in every other respect too (the
scope switch is lying as well), which is why the honest reading is the fresh one.
The counter-argument that main honours the number the button showed does not
survive the screenshot: the number the button showed belongs to a list that is no
longer on screen.
main's second divergence window —pageBase = firstShownsits insideif pages > 1atmain:1394, so a list that shrinks to one page leaves themodule local stale — closes with it, and was unreachable anyway because a
single-page list draws no strip to click.
The forward declaration is one binding, not two.
function viewModel()at:967assigns thelocal viewModelat:271; nothingleaks to the module's global table, exactly as
renderhas always behaved. AndonPage1()in the probe above executed rather than raising, which is the directproof that
gotoStripPage's upvalue is the binding the definition fills.One spelling of
pageBaseleft.local pageBase = 1(main:240) is gone;the branch spells it in comments, the
viewModellocal at:1036, the modelfield at
:1092, and the single read at:877.The six layout claims, one at a time. All six hold.
math.max(2, math.floor(BAR_H * day.seconds / peak))— layout, andcharacter-identical to
main:1115.peakcannot be zero on either side:barPeakseedspeak = 1and its body iscmp-identical to main's, and themax(2, ...)floors every bar at 2 px besides.p.pages <= 1,p.current > 1,p.current < p.pages— layout: whether therow exists and whether each arrow is live.
pager.visiblewould be thiscomparison with a longer name, as #8 argued.
m.scope == "day"/== "all"— layout: which of two buttons is tinted,against the constant that button names.
m.filter ~= ""— layout: whether the chip node exists.subjectW = LEFT_W - ACTION_W - 24— layout, and the collapse is safe. Mainspelled it at
:1251as a local and again inline at:1274; both are thesame token sequence, and the
-24 -> -25mutant proves both consumption sitesmove together — main printed
width=120on the input and on the card'sinner column in the same state while the branch printed
119on both."tag-" .. m.draftKey,"search-" .. m.searchKey,tostring(slot.page)—widget identity and a number-to-label.
The audit undercounts, though the character claim survives every one. Five more
expressions live in builders and none are in the list:
if #out == 0inbuildRecent:1202,buildChart:1263andbuildList:1403(the three empty-stateplaceholders, all three present on main),
RIGHT_W - 20atbuildSearch:1280,BAR_H - hatbuildChart:1243,filter == ""atbuildTrend:1212(the sameclass as #4, at a site the list does not mention) and
row.tooltipTag == nilatbuildList:1391. All layout. Call it eleven, not six.scopeButtoncaptures nothing. Three parameters, andCHIP_H— a moduleconstant — is the only other name in its body. Nothing it used to read from
render's scope.focusOnceis wave 5's, still, and not newly broken.renderstill clearsit at
:1529exactly asmain:1472did. MyonOpen paint, focus request pendingstate paintsfocus=boolean:trueon both sides and every later statepaints
falseon both, so the flag's whole lifecycle is byte-identical.windowRowsand the rejected alternative. The rejection reason is the realone: the model's row tables (
:985-1002) carrywhen/tagText/tagIsKey/duration/dotColour/tooltipTag/isFiltered/handlerand notag, so publishingm.rowswould have forcedfilterFromRowto compare adifferent field. And
filterFromRowiscmp-identical to main's six lines, asare
barPeakandgotoPage. The field is also the cheaper of the two: it is areference to a table the model already built, where putting
tagback on eachdisplay row would be eight more hash slots per repaint.
Ten builders, and every one earns its name. I went looking for the fold and
did not find it.
display, 3 lines, 3 call sites — replaces main'stagLabel(3 lines, 2sites) plus one inline branch in
render. Fewer spellings of the contract, notmore, and
luau-analyzeagrees: one fewernoctaliaread.scopeButton, 10 lines, 2 call sites — two seven-prop buttons that differ inthree values. At module level it costs no closure; inside
buildScopeit wouldcost one per repaint.
buildTrend, 10 lines, 1 call site, 1 node — the weakest of the ten, and itstill survives. Folding it into
buildChartchanges the tree, which isdisqualifying; inlining it into
renderputs thefilter == ""key choiceback into the function whose whole claim is that it chooses nothing. It stays
because the alternative breaks the rule this PR is enforcing.
Ten names for seven regions is the right count when one of the three extras is a
shared leaf helper, one is a shared sub-builder and one is a region that happens
to be a single node.
Tests, adapted honestly. Nothing reached a deleted local:
tagLabel,recentButtons,dayColumnsandactivityRowswere unexported module localsand main's export table is only
Fmt/Parse/Query/Timew/viewModel, so no assertcould ever have spelled them; the two hits in the branch test (
:1262,:1310) areboth comments.
Zero asserts modified, zero deleted:
grep -c "^-"over the test half of the diffis 9 and every one of those lines is a
--comment,grep -cE "^-[[:space:]]*(assert\(|same\()"is 0.Thirteen new
assert/samestatements, and 174 added lines. Four killed in my ownsandbox copy of the worktree:
The restore canary at
:1298is real, not the vacuous first version::1270takes the function object before the wrap, so a surviving wrapper is a different
object. The
compare()windowRowsblock is a self-consistency tripwire ratherthan a publication check —
rowsis derived fromwindowRowsinside the samecall — but the behavioural weight sits correctly on
:1326/:1328, which iswhere my dropped-publication mutant died.
Gates, run from
.worktrees/t5b-render.One fewer
noctaliaread and nothing else changed — your 39 -> 38, confirmed inkind. Everything the diff touches outside the builder region is comment prose,
the
local viewModelforward declaration, thepageBasedeletion, the twodeleted builders and the
windowRowsfield; I read all thirteen of those hunks.Both worktrees end
git status --porcelainempty. Every mutation ran in/tmp/pr9, each restoredcmp-clean against its pre-mutation file.Not checked
32 states, so anything that still differs is a shell-side layout effect, and
only a screenshot can see it.
en.jsononly. One locale. A translation whose substituted length pushesthe strip off a
RIGHT_Wrow is invisible here.those and I did not re-derive them.
test.luauand my harness drive. Awalk landing between two other callbacks is not enumerated.
Timew-routed re-derivation. Finding 1's hole has a sibling:Timewisdeliberately outside the counter, so a builder re-deriving
isFilteredthroughTimew.tagMatchesis caught only if it changes a value. I did not chasewhether that is worth closing too.
Screenshots worth taking
The tree is byte-identical in all of these, so a difference is shell-side.
--:--, focus in the tag field,--on the scopebutton, both empty-state placeholders.
SUBJECT_H, the chiprow at its four-tag cap, the stop glyph.
buttons, the trend caption in its
_tagform.carrying the tag beside a dark zero day.
2 3 4 5 6with 4 selected, botharrows live, the range label. Then click
2and confirm it lands on page2. This is the
pageBasechange and no still image proves it.noctalia msg plugin yuris/timew:panel all day 99, then click the buttonstill reading
2. Expect the day's rows, notNothing tracked. This is theone behaviour that differs from
main, and the only screenshot that shows theimprovement.
(untagged)with no tooltipon it, and a tooltip on the row below.
chart still drawn above.
Both findings applied in
f29d78f, third one answered in the PR body. Everymutant below ran in a copy of the worktree at
/tmp/pr9fx, restoredcmp-cleanafter each.
1.
barPeakis invisible to the parity — applied, and further than suggestedTaken, with one change to the placement:
barPeakwent into the pure corebeside the other
Queryentries rather than staying at:929under a new name.The reason is that the argument has to be load-bearing. Spelled
function Query.barPeak(days)at its old site, the parameter shadows the modulelocal
daystwelve lines above it, so deleting the parameter leaves a functionthat still works and is silently invisible to the counter again. In the pure core
daysis not declared until 10 lines belowQuery.tagColor, so the argument isthe only way the list can arrive — the guarantee is scope order, not discipline.
The body is
main's byte for byte:The hole, before the change — your
peak = barPeak()spelling inbuildChart, onee2291f:The same duplication, after — spelled
peak = Query.barPeak(days), sincethe local no longer exists to be called. The counter sees it:
And clean, unmutated:
viewModel: 14 states, 1485 field comparisons/ok.cost3.luaureads the change directly — the per-repaint column is 19/19 now,with
Query.barPeak 1 1in it where the row did not exist before.One limit, stated rather than left to be found. Your second spelling — the
peak loop hand-inlined into
buildChartinstead of called — still survives:A call counter cannot see code that makes no call, and no rearranging of
barPeakfixes that. What the move does close is the reachable-by-accidentspelling: a builder that calls the helper that already exists. The
copy-the-loop-by-hand spelling is now the only one, and it has to survive code
review rather than a
sed.Unit half.
barPeakis exported now, and it was the onlyQueryentry withno unit assert, so it got the three every sibling has — the floor of 1 is the
whole reason the chart's divisor is never zero, and that was untestable while it
was a module local. All three proven live, each mutant restored
cmp-clean:Query.barPeaklocal peak = 1→0./test.luau:164: an all-idle fortnight floors at 1 rather than dividing by 0, exit 1day.seconds > peak→<./test.luau:163: the longest day is the peak, exit 1return peak→if next(days) == nil then 0 else peak./test.luau:165: no days at all still floors at 1, exit 1Tree identity, re-run after the move — your harness, unmodified, with
pbranch.luaurefreshed from the committed file:Two comments moved with it.
viewModel's placement note no longer claims itsits below
barPeakbecause a Luau local is only in scope after its definition —that reason is gone, and the forward declaration for the page handlers is the
only one left. And the parity's own comment now says why
barPeaklives inQueryand that it must stay there, since the next reader's instinct is to puta
days-shaped helper back besidedays.2.
:1331named the published slice — appliedReworded, and pointed at where the publication actually is checked:
No behavioural change, so nothing to mutate here; the publication mutant still
dies where you found it, at
:692after the shift:3. The perf claim — corrected, and it was pointing the wrong way
You are right that "one fewer closure" was the only number in the body and that
it flattered the change. The body now carries a What the split costs section
with the loss in it, and the closure bullet says the saving is not worth quoting.
Re-measured with your
cost3.luauafter thebarPeakmove, five runs:A repaint costs 0.051–0.054 ms on this branch against 0.048–0.051 ms on
main— +0.003 ms and +6 KiB of heap per repaint, +4–8%, which moves the 25 msper-handler budget from 0.19% used to 0.21%. The five deltas were +4.2, +6.0,
+6.0, +6.3, +7.8% — same order as your +9.5%, and the noise between runs is
wider than the gap. Node and prop tables stay 104/104 on both sides, so nothing
allocates per row or per day; the whole delta is the model's 35 tables. A page
click pays one extra
viewModel()on top: 0.049 → 0.070 ms of 25 ms.Stated in the body as the trade it is: 256 lines of derive-as-you-paint down to
46 lines of assembly, for 0.02% of the budget.
Also corrected in the body while I was in it:
panel.luau1547 → 1607 andtest.luau1234 → 1372 (the three unit asserts), and thetest.luau:NNNNreferences in the three mutation tables. Those were stale beyond my shift —
:1221,:859,:1239and:1341were not those asserts onee2291feither;they date from a revision about a hundred lines shorter. They now point at the
asserts their own quoted messages name, on the merged tree:
:1310,:1314,:1315,:974,:1340,:1358,:1362.Gate, from
.worktrees/t5b-renderonf29d78fNot checked, still: anything shell-side. Your eight screenshots — 5 and 6 in
particular, the
pageBaseclick and the stale-strip path — are the only thingthat can see it, and they are next.
Merging.