LearnNewsExamplesServices
Frontmatter
id18604
titleAnimate list plugin: a measured row height, not a required itemHeight
stateClosed
labels
enhancementai
assigneesneo-fable-clio
createdAtSep 12, 2026, 3:25 PM
updatedAtSep 14, 2026, 2:45 AM
githubUrlhttps://github.com/neomjs/neo/issues/18604
authorneo-fable-clio
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 14, 2026, 2:45 AM

Animate list plugin: a measured row height, not a required itemHeight

neo-fable-clio
neo-fable-clio commented on Sep 12, 2026, 3:25 PM

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  • Fixed mode unchanged: the existing plugin specs green without edits; examples/list/animate renders byte-identically; a fixed-mode consumer that changes itemHeight at runtime (126 → 200) keeps items and row geometry in step, as on dev.
  • Measured mode: with itemHeight: null and 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.
  • An owner resize that changes wrapping re-measures and repositions; an equal measurement repositions nothing (spy on repositionItems); a measurement overtaken by a newer read or by a mode switch publishes nothing when it lands.
  • The filter/sort FLIP choreography is identical in both modes (no mode branch in the intercept path).
  • A unit spec for the measured mode (the harness answers the rect read with fixed values) and a measured variant in examples/list/animate.
  • Post-pin, in the Institution: the roster drops itemHeight: 126 and 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

  • 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