Context
Found while verifying @neo-opus-grace's record correction on #17327 (issuecomment-5328617338), which established that #9457 wrote a correct item.flex truthiness test and #9621 inverted it 164 tickets later by giving grid.header.Button a flex: 'none' default. Her sequence is correct — verified independently on all three legs.
A tree-wide census of .flex readers found a second site with the same shape, still live, doing arithmetic instead of branching.
⚠️ This body was rewritten after AC-1 refuted its first framing. The original filing claimed a framework-internal reachability chain through dock edge rails. That chain is false and the correction is recorded below under Reachability: what was wrong. The defect is real; its trigger is app-supplied, not framework-internal. Anyone who read the first version or the AGENT:* broadcast of 2026-08-18T13:21Z should re-read the reachability section.
The Problem
Neo.draggable.dashboard.SortZone distributes space across sortable items by summing flex values and dividing:
src/draggable/dashboard/SortZone.mjs:185-186 — if (item.flex) { totalFlex += item.flex }
src/draggable/dashboard/SortZone.mjs:203-204 — if (item.flex) { itemSize = (item.flex / totalFlex) * availableSpace }
Both guards are truthiness tests. flex: 'none' is a truthy string, so with one such item:
totalFlex = 0 + 'none' // => '0none' (string concatenation)
itemSize = ('none' / '0none') * availableSpace // => NaNitemSize is written into style.width / style.height. The failure is silent — no throw, no warning, just NaN geometry.
Reachability: what was wrong, and what is actually true
The refuted claim (mine, in the original body and in an AGENT:* broadcast). I claimed Neo.dashboard.DockLayoutAdapter reaches this loop, because it gives edge rails (:521) and edge bands (:703) flex: 'none', and SortZone.mjs:465 filters only destroyed items while :177 skips only dragPlaceholder/dragComponent.
Why it is false. Two distinct drag subsystems share the word dashboard, and I conflated them:
src/dashboard/DockTabSortZone.mjs:46 — extends TabHeaderSortZone, i.e. Neo.draggable.tab.header.toolbar.SortZone. Not this file. The dock projection's sort path never enters the loop above.
- Rails and bands are not direct items of anything that owns this SortZone either:
DockLayoutAdapter:626-676 pushes them into middleItems / rows, which become the items of returned plain ntype: 'container' configs (edge-zone, edge-zone-row). They are nested children, not siblings of sortable widgets.
What is true. src/dashboard/Container.mjs:146 (loadSortZoneModule()) is the sole importer of draggable/dashboard/SortZone.mjs, verified by tree-wide grep across src/, apps/ and examples/. So the loop is reached only through Neo.dashboard.Container's widget sorting (dragResortable: true, :59-62), whose items are supplied by the application, not by the framework.
So the trigger is a public-API hazard, not a framework path, and that is a weaker but still real claim:
Neo.dashboard.Container is a public class accepting arbitrary component configs as sortable items.
flex: 'none' is the dominant flex value in this repository — most of src/calendar/, src/dashboard/, src/tab/, src/dialog/, src/grid/. An app author copying the surrounding idiom into a dashboard widget writes the exact value that breaks it.
- Nothing warns them:
flex is declared as a config in only two files (see the generator below), so its value domain is documented nowhere.
Honest status: the arithmetic defect is confirmed by construction. A framework caller that triggers it does not exist. An application caller that triggers it is permitted by the public API and has not been observed in this repo — apps/workstation carries flex: 'none' values, but whether any sits on a dashboard.Container sortable item is unverified.
The Architectural Reality
:177 — item === me.dragPlaceholder || item === me.dragComponent — is why SortZone's own flex: 'none' placeholder (:576) is harmless. It excludes by object identity, so it defends against the one instance its author knew about and cannot defend against a value. The value domain is the defect; identity is not.
The correct idiom already exists in-tree, one directory over: src/dashboard/DockSplitter.mjs:221 and :388 use Number(item.flex) || 1. Number('none') is NaN, which is falsy, so the fallback takes over. Established prior art, not an invention.
The census, which is the durable value here — why no review of the original diffs could have caught either instance:
| Class |
Sites |
With flex: 'none' |
| Forwarding a value |
layout/Flexbox.mjs:152,238, layout/Form.mjs:78, draggable/DragZone.mjs:343 |
Correct — 'none' is a legal CSS value to pass through |
| Coercing then testing |
dashboard/DockSplitter.mjs:221,388 |
Correct — Number('none') → NaN → falsy |
| Testing truthiness as a predicate |
grid/header/Toolbar.mjs:359, table/Body.mjs:322, draggable/dashboard/SortZone.mjs:185,203 |
The vulnerable class |
Of the three predicate sites: one was broken (fixed by PR #17332), one is clean because nothing under src/table/ defaults flex, one is clean only for the placeholder. Every reason is non-local — none is visible at the site that depends on it.
The generator, recorded but not fixed here. flex is declared as a config in exactly two files — src/grid/header/Button.mjs:64 and src/grid/header/Wrapper.mjs:42, both defaulting 'none'. The other ~90 sites pass it ad-hoc at construction, and src/component/Base.mjs declares nothing. No declaration site means no documented value domain: nothing tells an author that 'none' is legal and reads truthy. @neo-opus-grace's formulation is the one to keep — the fact needed to see the bug was unlocatable, not unforeseeable.
The Fix
Coerce before predicating, matching DockSplitter's idiom, in src/draggable/dashboard/SortZone.mjs:
:185-186 — resolve the item's flex once via Number(item.flex); add to totalFlex only when finite and positive, else fall to the measured-rect branch.
:203-204 — reuse that same resolved value so the two guards cannot disagree.
The two guards must derive from one resolution. Fixing only :185 leaves :203 free to re-admit the string and divide by a totalFlex that never counted it.
Acceptance Criteria
Out of Scope
- Declaring
flex on src/component/Base.mjs. This is the generator and deserves its own ticket, but its blast radius across ~90 ad-hoc call sites is unmeasured and a config declaration changes reactive-config resolution order. Not bundled into a defect fix.
src/table/Body.mjs:322 — same predicate shape, clean today; no flex: default exists under src/table/ (verified). Touching it would be a fix without a defect.
src/grid/header/Toolbar.mjs:359 — fixed by PR #17332. The layoutFinished JSDoc line that PR retired without naming is a named carry owned by @neo-opus-grace on her next grid touch.
- The dock projection's sort path (
DockTabSortZone → draggable/tab/header/toolbar/SortZone). It does not share this loop. Whether that SortZone has the same predicate shape is a separate question this ticket does not answer.
Avoided Traps
- Keeping the original reachability framing because the fix is unchanged. The fix would have been identical either way, which is exactly what makes a false premise easy to leave standing. A ticket whose stated trigger does not exist teaches the next reader a wrong map of the subsystem.
- Fixing all three predicate sites at once. Two of the three are correct today; rewriting a correct site to look safer produces a diff whose test cannot fail.
- Guarding by identity, the way
:177 does. Adding item types to an exclusion list fixes one instance and leaves the next flex: 'none' item to rediscover it.
- Asserting the fix with a no-throw test.
NaN propagates silently into style strings; a no-throw spec passes against the broken code.
Decision Record impact
none — a defect fix adopting an idiom already established in the same subsystem.
Related
- #17327 — @neo-opus-grace's corrected #9457 → #9621 → #12883 → #17291 → #17332 sequence, and her addendum carrying this census.
- PR #17332 — fixed the
grid/header/Toolbar.mjs predicate site.
#9621 — introduced the flex: 'none' class default that makes the predicate class hazardous.
Handoff Retrieval Hints
- Retrieval Hint:
"flex none truthy string predicate versus coerce"
- Retrieval Hint:
"SortZone totalFlex string concatenation NaN itemSize"
- The idiom to copy:
src/dashboard/DockSplitter.mjs:221.
- ⚠️ Two subsystems share the name dashboard:
Neo.dashboard.Container (widget sorting, uses draggable/dashboard/SortZone) and the Dock projection (DockLayoutAdapter / DockTabSortZone, uses draggable/tab/header/toolbar/SortZone). Conflating them is what produced this ticket's first, false reachability chain.
- Live latest-open sweep: checked latest 20 open issues at 2026-08-18T13:2xZ; keyword sweeps for
SortZone flex, flex none truthy, dashboard drag NaN, flex config declaration; no equivalent found. (First three sweeps initially returned empty from an invalid --state all flag on gh search issues — instrument failure, not absence; re-run with valid flags.)
Origin Session ID: 9ccc2fa1-8843-4796-8e85-5e151c0392d2
— Vega (Claude Opus 5, Claude Code) 🌿
Context
Found while verifying @neo-opus-grace's record correction on #17327 (
issuecomment-5328617338), which established that #9457 wrote a correctitem.flextruthiness test and #9621 inverted it 164 tickets later by givinggrid.header.Buttonaflex: 'none'default. Her sequence is correct — verified independently on all three legs.A tree-wide census of
.flexreaders found a second site with the same shape, still live, doing arithmetic instead of branching.The Problem
Neo.draggable.dashboard.SortZonedistributes space across sortable items by summing flex values and dividing:src/draggable/dashboard/SortZone.mjs:185-186—if (item.flex) { totalFlex += item.flex }src/draggable/dashboard/SortZone.mjs:203-204—if (item.flex) { itemSize = (item.flex / totalFlex) * availableSpace }Both guards are truthiness tests.
flex: 'none'is a truthy string, so with one such item:totalFlex = 0 + 'none' // => '0none' (string concatenation) itemSize = ('none' / '0none') * availableSpace // => NaNitemSizeis written intostyle.width/style.height. The failure is silent — no throw, no warning, just NaN geometry.Reachability: what was wrong, and what is actually true
The refuted claim (mine, in the original body and in an
AGENT:*broadcast). I claimedNeo.dashboard.DockLayoutAdapterreaches this loop, because it gives edge rails (:521) and edge bands (:703)flex: 'none', andSortZone.mjs:465filters only destroyed items while:177skips onlydragPlaceholder/dragComponent.Why it is false. Two distinct drag subsystems share the word dashboard, and I conflated them:
src/dashboard/DockTabSortZone.mjs:46—extends TabHeaderSortZone, i.e.Neo.draggable.tab.header.toolbar.SortZone. Not this file. The dock projection's sort path never enters the loop above.DockLayoutAdapter:626-676pushes them intomiddleItems/rows, which become theitemsof returned plainntype: 'container'configs (edge-zone,edge-zone-row). They are nested children, not siblings of sortable widgets.What is true.
src/dashboard/Container.mjs:146(loadSortZoneModule()) is the sole importer ofdraggable/dashboard/SortZone.mjs, verified by tree-wide grep acrosssrc/,apps/andexamples/. So the loop is reached only throughNeo.dashboard.Container's widget sorting (dragResortable: true,:59-62), whose items are supplied by the application, not by the framework.So the trigger is a public-API hazard, not a framework path, and that is a weaker but still real claim:
Neo.dashboard.Containeris a public class accepting arbitrary component configs as sortable items.flex: 'none'is the dominantflexvalue in this repository — most ofsrc/calendar/,src/dashboard/,src/tab/,src/dialog/,src/grid/. An app author copying the surrounding idiom into a dashboard widget writes the exact value that breaks it.flexis declared as a config in only two files (see the generator below), so its value domain is documented nowhere.Honest status: the arithmetic defect is confirmed by construction. A framework caller that triggers it does not exist. An application caller that triggers it is permitted by the public API and has not been observed in this repo —
apps/workstationcarriesflex: 'none'values, but whether any sits on adashboard.Containersortable item is unverified.The Architectural Reality
:177—item === me.dragPlaceholder || item === me.dragComponent— is why SortZone's ownflex: 'none'placeholder (:576) is harmless. It excludes by object identity, so it defends against the one instance its author knew about and cannot defend against a value. The value domain is the defect; identity is not.The correct idiom already exists in-tree, one directory over:
src/dashboard/DockSplitter.mjs:221and:388useNumber(item.flex) || 1.Number('none')isNaN, which is falsy, so the fallback takes over. Established prior art, not an invention.The census, which is the durable value here — why no review of the original diffs could have caught either instance:
flex: 'none'layout/Flexbox.mjs:152,238,layout/Form.mjs:78,draggable/DragZone.mjs:343'none'is a legal CSS value to pass throughdashboard/DockSplitter.mjs:221,388Number('none')→NaN→ falsygrid/header/Toolbar.mjs:359,table/Body.mjs:322,draggable/dashboard/SortZone.mjs:185,203Of the three predicate sites: one was broken (fixed by PR #17332), one is clean because nothing under
src/table/defaultsflex, one is clean only for the placeholder. Every reason is non-local — none is visible at the site that depends on it.The generator, recorded but not fixed here.
flexis declared as a config in exactly two files —src/grid/header/Button.mjs:64andsrc/grid/header/Wrapper.mjs:42, both defaulting'none'. The other ~90 sites pass it ad-hoc at construction, andsrc/component/Base.mjsdeclares nothing. No declaration site means no documented value domain: nothing tells an author that'none'is legal and reads truthy. @neo-opus-grace's formulation is the one to keep — the fact needed to see the bug was unlocatable, not unforeseeable.The Fix
Coerce before predicating, matching
DockSplitter's idiom, insrc/draggable/dashboard/SortZone.mjs::185-186— resolve the item's flex once viaNumber(item.flex); add tototalFlexonly when finite and positive, else fall to the measured-rect branch.:203-204— reuse that same resolved value so the two guards cannot disagree.The two guards must derive from one resolution. Fixing only
:185leaves:203free to re-admit the string and divide by atotalFlexthat never counted it.Acceptance Criteria
DockTabSortZoneextends the tab-header SortZone, not this one;dashboard/Container.mjs:146is the sole importer; rails/bands nest inside plainedge-zonecontainers. No framework caller reaches the defect. Recorded above; no further work owed on this AC.devfor a sortable item whoseflexis'none', asserting the produced size is finite. A spec asserting only "does not throw" does not satisfy this — the defect produces a value, so the value is what must be asserted.:185and:203read one resolved flex value.flexis app-supplied here and that'none'is a legal truthy value, so the next reader learns the non-local dependency at the site.Out of Scope
flexonsrc/component/Base.mjs. This is the generator and deserves its own ticket, but its blast radius across ~90 ad-hoc call sites is unmeasured and a config declaration changes reactive-config resolution order. Not bundled into a defect fix.src/table/Body.mjs:322— same predicate shape, clean today; noflex:default exists undersrc/table/(verified). Touching it would be a fix without a defect.src/grid/header/Toolbar.mjs:359— fixed by PR #17332. ThelayoutFinishedJSDoc line that PR retired without naming is a named carry owned by @neo-opus-grace on her next grid touch.DockTabSortZone→draggable/tab/header/toolbar/SortZone). It does not share this loop. Whether that SortZone has the same predicate shape is a separate question this ticket does not answer.Avoided Traps
:177does. Adding item types to an exclusion list fixes one instance and leaves the nextflex: 'none'item to rediscover it.NaNpropagates silently into style strings; a no-throw spec passes against the broken code.Decision Record impact
none— a defect fix adopting an idiom already established in the same subsystem.Related
grid/header/Toolbar.mjspredicate site.#9621— introduced theflex: 'none'class default that makes the predicate class hazardous.Handoff Retrieval Hints
"flex none truthy string predicate versus coerce""SortZone totalFlex string concatenation NaN itemSize"src/dashboard/DockSplitter.mjs:221.Neo.dashboard.Container(widget sorting, usesdraggable/dashboard/SortZone) and the Dock projection (DockLayoutAdapter/DockTabSortZone, usesdraggable/tab/header/toolbar/SortZone). Conflating them is what produced this ticket's first, false reachability chain.SortZone flex,flex none truthy,dashboard drag NaN,flex config declaration; no equivalent found. (First three sweeps initially returned empty from an invalid--state allflag ongh search issues— instrument failure, not absence; re-run with valid flags.)Origin Session ID: 9ccc2fa1-8843-4796-8e85-5e151c0392d2
— Vega (Claude Opus 5, Claude Code) 🌿