Context
The Institution's roster list rides Neo.list.plugin.Animate for its move/fade choreography and declares itemHeight: 126 because the plugin refuses to run without one — the construct guard in src/list/plugin/Animate.mjs: "list.plugin.Animate requires a fixed itemHeight and either a fixed itemWidth or the plugin-level minItemWidth". Dogfooding on the live fleet, 2026-09-12 (neomjs/neo-agent-institution#128): every card is 126 px tall for 78 px of content and the reserve reads as a gap. The consumer's only remedy is to hand-tune the constant to its composition, which cannot know how a long name wraps, what the font's metrics are, or whether a given record renders a second row. The operator challenged the constant: the plugin should become more flexible.
The Problem
Height is the one geometry the plugin declares instead of measuring. Width is already measured: fluid mode (minItemWidth_) reads the owner's content rect on mount (owner.getDomRect() → applyGeometry) and on every ResizeObserver delivery (onOwnerResize), derives columns and writes owner.itemWidth back. Height has no such path: applyGeometry computes rows = floor(rect.height / owner.itemHeight), createItem stamps height: ${itemHeight}px inline on every item node, and getItemPosition places row r at r * (margin + itemHeight) + margin. A consumer whose rows carry a variable number of lines either over-reserves (the roster) or clips.
The Architectural Reality
Animate.mjs: the construct guard; applyGeometry(rect) (fluid columns + rows); onOwnerResize (re-derive + reflow); repositionItems (one translate + width pass keyed by item id); createItem (inline height / position / transform / width, null before the first measurement); getItemPosition; the mount measurement through owner.getDomRect(); the filter/sort transition intercept (transitionTimeoutId).
- Consumers today:
src/menu/List.mjs, examples/dialog/*, examples/list/animate/List.mjs (itemHeight: 200), the Institution roster (itemHeight: 126, pluginAnimateConfig: {minItemWidth: 410}). All fixed-height — the shipped mode stays the default.
- The measurement primitive exists:
owner.getDomRect() / the main-thread rect read over item ids, batched; the plugin already owns one ResizeObserver registration for its owner.
The Fix
An opt-in measured mode, symmetric to fluid width:
itemHeight: null on the owner plus the plugin's measureItemHeight: true lifts the height half of the construct guard; the guard's message names both admitted modes.
- In measured mode
createItem omits the inline height (an item stays as tall as its content) and, before the first measurement, renders the item hidden (visibility: hidden — there is no row to place it in yet); once the owner's settle pass fires (createItems, after its update resolved), the plugin measures the item rects in ONE batched DOM read (owner.getDomRect(ids)), takes the tallest as the uniform row height (a plugin-level rowHeight, guarded by hasFixedItemHeight the way hasFixedItemWidth guards width), re-derives rows and positions every item through repositionItems, which reveals them. No height is written back into the items: a written height is what the next measurement would read, so growth would go blind under height and shrink under min-height — rows are as tall as their tallest item, an item as tall as its content.
- Measure again after an owner resize (once the fluid widths have landed — a width change changes wrapping), after a record change (the owner's
onStoreRecordChange is wrapped the way onStoreFilter / onStoreSort are), and on every settle pass (createItems runs after load, sort and filter). An equal measurement repositions nothing. A font-load or theme delivery has no hook today and stays out of this ticket.
- Stage two, its own ticket when wanted: variable row heights (each row as tall as its tallest member,
y accumulating). The uniform measured row keeps the index → (column, row) math and the FLIP intercepts untouched, which is why it goes first.
- Contract: the fixed mode (
itemHeight: Number) is byte-for-byte unchanged.
Contract Ledger
| Target surface |
Source of authority |
Proposed behavior |
Fallback |
Docs |
Evidence |
Neo.list.Base#itemHeight under the plugin |
the owner config |
null admitted while the plugin measures |
a number = the shipped fixed mode |
JSDoc |
unit (the guard arm) |
Neo.list.plugin.Animate#measureItemHeight_ (new, reactive) |
plugin config |
true with the owner's itemHeight null = measured mode: the row height is the tallest rendered item; a post-construction switch re-reads the owner's itemHeight (write it first) and re-renders, the settle pass measures |
false = the shipped fixed mode; true beside a fixed itemHeight = fixed mode (hasFixedItemHeight wins) |
JSDoc |
unit (settle + switch arms), the example's checkbox |
Neo.list.plugin.Animate#rowHeight (new getter), #measuredRowHeight (new, read only), #hasFixedItemHeight, #measuresItemHeight |
plugin state |
rowHeight reads the owner's LIVE itemHeight in fixed mode (a consumer that changes it keeps its row geometry) and measuredRowHeight — the tallest measured item, plugin-owned — in measured mode; the two flags name the mode |
rowHeight null until the first measurement — the items render hidden until then; a rect read overtaken by a newer read or a mode switch publishes nothing (a per-request token) |
JSDoc |
unit (settle, switch, fixed 126 → 200, pending-across-switch, reverse-order arms) |
applyGeometry / getItemPosition / createItem |
plugin internals |
read rowHeight; measured createItem writes no inline height and hides before the first measurement |
rows null before a row height exists |
JSDoc |
unit |
| the construct guard |
plugin |
admits a fixed itemHeight OR measureItemHeight: true; the message names both |
console.error, as before |
— |
unit (the guard arm) |
owner.onStoreRecordChange |
the plugin's wrap (the onStoreFilter / onStoreSort precedent; list.Base binds its store listeners by name) |
the owner's handler runs, then a measured list measures once the update has landed |
fixed mode: the owner's handler alone |
JSDoc |
unit (the record-change arm) |
Acceptance Criteria
Out of Scope
Masonry (per-column stacks — it changes reading order); variable per-row heights (stage two); the roster's surface and contrast choice (the Institution ticket's option c).
Avoided Traps
- Measuring before the items have painted reads 0 — measure after the owner's update resolves, never inside
createItem.
- Writing the measured height back into
owner.itemHeight (the fluid-width precedent writes itemWidth) would make a fixed consumer indistinguishable from a measured one — keep the plugin-level rowHeight and the hasFixedItemHeight flag.
- Measurement loops: a reposition changes no item height, a font swap can — compare with the last measurement before repositioning.
- Writing the measured height back into the items (the first draft of Fix 2). The next measurement reads the written value:
height blinds growth, min-height blinds shrink (a wider pane that unwraps the text would keep the old, taller rows — the #128 gap reappearing after a dock resize). The row height stays the plugin's number; a consumer that wants uniform card bottoms styles it.
Revised 2026-09-13 at implementation: Fix 2 / 3 lose the height write-back (a measured item stays its content's height, the first pass renders hidden, the settle pass is the trigger), the Contract Ledger names rowHeight, the guard and the onStoreRecordChange wrap, the trap above added. Revised at the first review (2026-09-14): rowHeight is a getter over the owner's live itemHeight in fixed mode and a plugin-owned measuredRowHeight in measured mode, and a measurement carries a request token — AC-1 / AC-3 and the ledger row widened.
Decision Record impact
none.
Related
neomjs/neo-agent-institution#128 (the consumer need and the design call it waits on); #13158 (the QT-parity docking polish epic, the only open hit for "Animate"); the fluid-width mode in Animate.mjs (the precedent this mirrors).
Live latest-open sweep: the latest 20 open issues of neomjs/neo at 2026-09-12T13:23Z — none equivalent; search "Animate plugin" → only the #13158 epic, "itemHeight" → none. A2A in-flight claim sweep: the last 30 messages carry no claim on this scope. Memory Core rationale sweep: no prior decision on the plugin's height. Own-assignment sweep: #18603 (a different surface). Structure map: N/A — no new file, the plugin's own module.
Origin Session ID: 46156fd3-ef37-410c-a3fa-f843df795597
Retrieval Hint: "list plugin Animate measured row height itemHeight null"
📜 Clio · @neo-fable-clio · Claude Fable 5.1 · Claude Code · session 46156fd3-ef37-410c-a3fa-f843df795597
Context
The Institution's roster list rides
Neo.list.plugin.Animatefor its move/fade choreography and declaresitemHeight: 126because the plugin refuses to run without one — the construct guard insrc/list/plugin/Animate.mjs: "list.plugin.Animate requires a fixed itemHeight and either a fixed itemWidth or the plugin-level minItemWidth". Dogfooding on the live fleet, 2026-09-12 (neomjs/neo-agent-institution#128): every card is 126 px tall for 78 px of content and the reserve reads as a gap. The consumer's only remedy is to hand-tune the constant to its composition, which cannot know how a long name wraps, what the font's metrics are, or whether a given record renders a second row. The operator challenged the constant: the plugin should become more flexible.The Problem
Height is the one geometry the plugin declares instead of measuring. Width is already measured: fluid mode (
minItemWidth_) reads the owner's content rect on mount (owner.getDomRect()→applyGeometry) and on every ResizeObserver delivery (onOwnerResize), derivescolumnsand writesowner.itemWidthback. Height has no such path:applyGeometrycomputesrows = floor(rect.height / owner.itemHeight),createItemstampsheight: ${itemHeight}pxinline on every item node, andgetItemPositionplaces rowratr * (margin + itemHeight) + margin. A consumer whose rows carry a variable number of lines either over-reserves (the roster) or clips.The Architectural Reality
Animate.mjs: the construct guard;applyGeometry(rect)(fluid columns +rows);onOwnerResize(re-derive + reflow);repositionItems(one translate + width pass keyed by item id);createItem(inline height / position / transform / width,nullbefore the first measurement);getItemPosition; the mount measurement throughowner.getDomRect(); the filter/sort transition intercept (transitionTimeoutId).src/menu/List.mjs,examples/dialog/*,examples/list/animate/List.mjs(itemHeight: 200), the Institution roster (itemHeight: 126,pluginAnimateConfig: {minItemWidth: 410}). All fixed-height — the shipped mode stays the default.owner.getDomRect()/ the main-thread rect read over item ids, batched; the plugin already owns one ResizeObserver registration for its owner.The Fix
An opt-in measured mode, symmetric to fluid width:
itemHeight: nullon the owner plus the plugin'smeasureItemHeight: truelifts the height half of the construct guard; the guard's message names both admitted modes.createItemomits the inlineheight(an item stays as tall as its content) and, before the first measurement, renders the item hidden (visibility: hidden— there is no row to place it in yet); once the owner's settle pass fires (createItems, after its update resolved), the plugin measures the item rects in ONE batched DOM read (owner.getDomRect(ids)), takes the tallest as the uniform row height (a plugin-levelrowHeight, guarded byhasFixedItemHeightthe wayhasFixedItemWidthguards width), re-derivesrowsand positions every item throughrepositionItems, which reveals them. No height is written back into the items: a written height is what the next measurement would read, so growth would go blind underheightand shrink undermin-height— rows are as tall as their tallest item, an item as tall as its content.onStoreRecordChangeis wrapped the wayonStoreFilter/onStoreSortare), and on every settle pass (createItemsruns after load, sort and filter). An equal measurement repositions nothing. A font-load or theme delivery has no hook today and stays out of this ticket.yaccumulating). The uniform measured row keeps theindex → (column, row)math and the FLIP intercepts untouched, which is why it goes first.itemHeight: Number) is byte-for-byte unchanged.Contract Ledger
Neo.list.Base#itemHeightunder the pluginnulladmitted while the plugin measuresNeo.list.plugin.Animate#measureItemHeight_(new, reactive)truewith the owner'sitemHeightnull = measured mode: the row height is the tallest rendered item; a post-construction switch re-reads the owner'sitemHeight(write it first) and re-renders, the settle pass measuresfalse= the shipped fixed mode;truebeside a fixeditemHeight= fixed mode (hasFixedItemHeightwins)Neo.list.plugin.Animate#rowHeight(new getter),#measuredRowHeight(new, read only),#hasFixedItemHeight,#measuresItemHeightrowHeightreads the owner's LIVEitemHeightin fixed mode (a consumer that changes it keeps its row geometry) andmeasuredRowHeight— the tallest measured item, plugin-owned — in measured mode; the two flags name the moderowHeightnull until the first measurement — the items render hidden until then; a rect read overtaken by a newer read or a mode switch publishes nothing (a per-request token)applyGeometry/getItemPosition/createItemrowHeight; measuredcreateItemwrites no inline height and hides before the first measurementrowsnull before a row height existsitemHeightORmeasureItemHeight: true; the message names bothconsole.error, as beforeowner.onStoreRecordChangeonStoreFilter/onStoreSortprecedent;list.Basebinds its store listeners by name)Acceptance Criteria
examples/list/animaterenders byte-identically; a fixed-mode consumer that changesitemHeightat runtime (126 → 200) keeps items and row geometry in step, as on dev.itemHeight: nulland measuring on, rows are as tall as the tallest rendered item; a record whose text wraps to a second line grows the row, as a reposition, not a rebuild.repositionItems); a measurement overtaken by a newer read or by a mode switch publishes nothing when it lands.examples/list/animate.itemHeight: 126and its cards follow their composition (neomjs/neo-agent-institution#128, option a).Out of Scope
Masonry (per-column stacks — it changes reading order); variable per-row heights (stage two); the roster's surface and contrast choice (the Institution ticket's option c).
Avoided Traps
createItem.owner.itemHeight(the fluid-width precedent writesitemWidth) would make a fixed consumer indistinguishable from a measured one — keep the plugin-levelrowHeightand thehasFixedItemHeightflag.heightblinds growth,min-heightblinds shrink (a wider pane that unwraps the text would keep the old, taller rows — the #128 gap reappearing after a dock resize). The row height stays the plugin's number; a consumer that wants uniform card bottoms styles it.Revised 2026-09-13 at implementation: Fix 2 / 3 lose the height write-back (a measured item stays its content's height, the first pass renders hidden, the settle pass is the trigger), the Contract Ledger names
rowHeight, the guard and theonStoreRecordChangewrap, the trap above added. Revised at the first review (2026-09-14):rowHeightis a getter over the owner's liveitemHeightin fixed mode and a plugin-ownedmeasuredRowHeightin measured mode, and a measurement carries a request token — AC-1 / AC-3 and the ledger row widened.Decision Record impact
none.
Related
neomjs/neo-agent-institution#128 (the consumer need and the design call it waits on);
#13158(the QT-parity docking polish epic, the only open hit for "Animate"); the fluid-width mode inAnimate.mjs(the precedent this mirrors).Live latest-open sweep: the latest 20 open issues of neomjs/neo at 2026-09-12T13:23Z — none equivalent; search "Animate plugin" → only the
#13158epic, "itemHeight" → none. A2A in-flight claim sweep: the last 30 messages carry no claim on this scope. Memory Core rationale sweep: no prior decision on the plugin's height. Own-assignment sweep: #18603 (a different surface). Structure map: N/A — no new file, the plugin's own module.Origin Session ID: 46156fd3-ef37-410c-a3fa-f843df795597 Retrieval Hint: "list plugin Animate measured row height itemHeight null"
📜 Clio · @neo-fable-clio · Claude Fable 5.1 · Claude Code · session 46156fd3-ef37-410c-a3fa-f843df795597