Context
Neo.list.plugin.Animate (src/list/plugin/Animate.mjs) animates list reflows — translate moves on sort, fade in/out on filter — and explicitly supports component-based lists (onStoreSort branches into sortComponentList for Neo.list.Component owners). The FM cockpit's fleet roster (companion app ticket, linked below; operator brief 2026-08-22) wants exactly this primitive for its agent cards — and the roster lives in resizable dock panes, which surfaces contract gaps the current in-tree consumers (examples/list/animate, examples/list/circle) never hit because they render into a stable viewport.
The Problem
Verified against the source, 2026-08-22:
- Geometry is computed once.
onOwnerMounted reads getDomRect() a single time: columns = floor(rect.width / itemWidth). No resize path exists — after any owner resize (dock split drag, tear-out, vessel, window resize) every later position computation uses the stale column count and rect: items overlap or strand in a phantom column. Dock panes resize constantly; this alone disqualifies cockpit use.
- Fixed
itemWidth cannot fill a fluid container. The plugin requires literal itemWidth/itemHeight px (construct() console-errors otherwise). Measured on the live cockpit dev build (2026-08-22, grid measure 935px): the roster's fluid 2-col renders 447px cards in a 903px card region with a 9px gutter. A fixed 300px itemWidth there strands ~300px of dead gutter; a fixed 447 breaks at every other pane width.
- The owner must be the containing block — and content-sizing is a trap. Every item is
position: absolute (the createItem override), so the owner renders zero FLOW height and the items anchor to the nearest positioned ancestor — the plugin neither sets position: relative on the owner nor documents the requirement. Falsifier receipt (Chrome 148, 2026-08-22): translated absolute children DO create scrollable overflow (probe: 200px relative+overflow host, child translated to y=900 → scrollHeight 1026, scrolls) — a flex-sized scroll owner works once it is positioned, so the earlier "scrolling dies" framing was wrong and is retired here. What remains real: an unpositioned owner mis-anchors every item, and a content-sized owner collapses to 0.
prefers-reduced-motion is not honored. updateTransitionDetails inserts #<ownerId> .neo-list-item { transition: opacity …, transform … } unconditionally via Neo.util.Css. A vestibular-safe consumer has no lever short of transitionDuration: 0 for everyone.
- Component-list filter parity is unproven.
onStoreFilter's added-items path calls the plugin-wrapped createItem for records entering the filtered set; for a Neo.list.Component owner, item content creates/reuses component instances (createItemContent, sibling pattern: src/calendar/view/calendars/List.mjs). Sort parity is explicit; the filter fade path has no witness for component items.
The Architectural Reality
- Positions:
getItemPosition (index → column/row → translate x/y); the transition rides one per-owner CSS rule.
- The resize seam already exists in the engine:
Neo.main.addon.ResizeObserver is a register-based, rAF-dammed singleton with hidden-document guards. The consumer pattern is Helix.addResizeObserver (src/component/Helix.mjs:306): (await getAddon('ResizeObserver', windowId))[mounted ? 'register' : 'unregister']({id, windowId}); deliveries arrive as DOM resize events.
columns/rows are read-only plugin members computed at mount; itemMargin config exists.
- Backwards surface:
examples/list/animate (list.Base owner, fixed 300×200 items) and examples/list/circle must keep rendering identically.
The Fix
All in src/list/plugin/Animate.mjs (owner seams in src/list/Base.mjs only if strictly required; no new files):
- Resize awareness: register the owner with the ResizeObserver addon on mount, unregister on destroy (the Helix pattern). On a delivered owner resize: recompute
ownerRect + columns (+ fluid itemWidth, see 2), then run a position pass over the rendered items — the same translate write sortComponentList uses. An animated reflow, never a rebuild.
- Fluid width mode (opt-in): new reactive config
minItemWidth_ (Number, null). When set and the owner's itemWidth is null: columns = max(1, floor((rect.width − margin) / (minItemWidth + margin))), itemWidth = floor((rect.width − (columns + 1) × margin) / columns) — recomputed on every resize delivery, written where the fixed width goes today. The construct() guard accepts minItemWidth as a valid width source. Explicit itemWidth keeps today's fixed behavior byte-identical.
- Containing-block guarantee: the plugin ensures the owner establishes the containing block (a plugin-added cls on the owner root carrying
position: relative in the list stylesheet) and documents the flow-height-0 caveat for content-sized owners — a flex- or fixed-height scroll owner needs nothing else, since scrollable overflow from translated items is verified browser behavior (Problem 3 receipt).
- Reduced motion: ONE static rule in the list stylesheet (
resources/scss/src/list/Base.scss): @media (prefers-reduced-motion: reduce) { .neo-list.neo-animated-list .neo-list-item { transition: none !important } }. (Amended at implementation: a per-owner dynamic companion cannot share the delete lifecycle — deleteCssRules matches by selectorText, which @media rules don't carry — so the dynamic copy would leak on every destroy; the static class-scoped rule covers every animated list with zero lifecycle.)
- Component-list parity witnesses: unit specs prove sort-move (instances survive, translates update), filter enter/exit fades, and resize reflow against a minimal
Neo.list.Component fixture.
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
itemWidth/itemHeight (owner configs the plugin consumes) |
src/list/Base.mjs config block |
unchanged when set — fixed mode is today's behavior |
n/a |
docblock |
example demos render identically (manual receipt) |
minItemWidth_ (NEW plugin config) |
this ticket |
fluid mode: columns + per-item width derived from the owner rect |
null → fixed mode |
docblock |
unit witness incl. the measured fixture (903px region, margin 9, minItemWidth 420 → 2 × 438px — the plugin's outer-margin geometry: the region's outer gutters move INTO the plugin, replacing CSS container padding; the CSS-grid 447 assumed inner gaps only) |
columns / rows (plugin members) |
Animate.mjs config block |
update on every resize delivery |
mount-time value until first delivery |
docblock |
unit witness (synthetic resize) |
| ResizeObserver registration |
src/main/addon/ResizeObserver.mjs; Helix.mjs:306 pattern |
register on owner mount, unregister on destroy |
addon absent → mount-time geometry (today's behavior) |
inline |
unit witness (register/unregister observed) |
| owner containing block |
this ticket |
plugin guarantees position: relative on the owner root (cls) |
unpositioned owner mis-anchors items (today's hazard) |
inline + docblock caveat |
unit witness (cls present) |
| transition CSS rule |
updateTransitionDetails → pure getOwnerRules() |
two per-owner dynamic rules (containing block + transition); reduced-motion override is static in Base.scss (amended: @media cannot delete by selectorText) |
n/a |
inline |
rule-payload witness + static-rule presence |
Acceptance Criteria
Out of Scope
- The FM cockpit consumption (the companion app ticket, blocked on this one).
grid.Body animation (its own system); list virtualization / buffered windowing (a different primitive).
- Persisting any geometry.
Avoided Traps
- CSS container queries instead of JS geometry: the plugin positions via inline translate — the column count IS JS state; a CSS-only ladder cannot move absolute items. The ladder input stays JS (
minItemWidth), honoring ADR 0029's own-width discipline through the owner's rect, never the viewport.
- Rebuild-on-resize: destroy/recreate on resize discards exactly the continuity this plugin exists for; the reflow is a translate pass.
- A breaking fluid default: fluid activates only via the new opt-in config; every existing consumer keeps byte-identical behavior.
Related
- Companion consumer (blocked on this ticket): the FM roster ticket, linked after creation. Its arc: #14560.
- Reference demo:
examples/list/animate; component-item sibling: src/calendar/view/calendars/List.mjs.
Decision Record impact: aligned-with ADR 0029 (own-width geometry).
Structure-map gate: N/A — no ai/ touch; src/list/plugin/ sibling-lift, no new files.
Live latest-open sweep: checked latest 20 open issues at 2026-08-22T15:51:54Z (gh issue list --state open --limit 20); no equivalent found. A2A claim sweep at 15:52Z (latest 14, recency-bound): no competing claim; the adjacent #17550 lane-pick (Emmy) is a disjoint surface.
Correction 2026-08-22T16:00Z (pre-implementation V-B-A): Problem 3 / Fix 3 / Ledger / AC 3 re-scoped from "derived owner scroll height" to "containing-block guarantee" — the scroll claim was falsified in-browser before a line was written.
Origin Session ID: 14acab5a-4b6c-4987-91c7-f683e39baa55
Retrieval Hint: "list plugin animate resize fluid columns component list parity"
Context
Neo.list.plugin.Animate(src/list/plugin/Animate.mjs) animates list reflows — translate moves on sort, fade in/out on filter — and explicitly supports component-based lists (onStoreSortbranches intosortComponentListforNeo.list.Componentowners). The FM cockpit's fleet roster (companion app ticket, linked below; operator brief 2026-08-22) wants exactly this primitive for its agent cards — and the roster lives in resizable dock panes, which surfaces contract gaps the current in-tree consumers (examples/list/animate,examples/list/circle) never hit because they render into a stable viewport.The Problem
Verified against the source, 2026-08-22:
onOwnerMountedreadsgetDomRect()a single time:columns = floor(rect.width / itemWidth). No resize path exists — after any owner resize (dock split drag, tear-out, vessel, window resize) every later position computation uses the stale column count and rect: items overlap or strand in a phantom column. Dock panes resize constantly; this alone disqualifies cockpit use.itemWidthcannot fill a fluid container. The plugin requires literalitemWidth/itemHeightpx (construct()console-errors otherwise). Measured on the live cockpit dev build (2026-08-22, grid measure 935px): the roster's fluid 2-col renders 447px cards in a 903px card region with a 9px gutter. A fixed 300pxitemWidththere strands ~300px of dead gutter; a fixed 447 breaks at every other pane width.position: absolute(thecreateItemoverride), so the owner renders zero FLOW height and the items anchor to the nearest positioned ancestor — the plugin neither setsposition: relativeon the owner nor documents the requirement. Falsifier receipt (Chrome 148, 2026-08-22): translated absolute children DO create scrollable overflow (probe: 200pxrelative+overflowhost, child translated to y=900 → scrollHeight 1026, scrolls) — a flex-sized scroll owner works once it is positioned, so the earlier "scrolling dies" framing was wrong and is retired here. What remains real: an unpositioned owner mis-anchors every item, and a content-sized owner collapses to 0.prefers-reduced-motionis not honored.updateTransitionDetailsinserts#<ownerId> .neo-list-item { transition: opacity …, transform … }unconditionally viaNeo.util.Css. A vestibular-safe consumer has no lever short oftransitionDuration: 0for everyone.onStoreFilter's added-items path calls the plugin-wrappedcreateItemfor records entering the filtered set; for aNeo.list.Componentowner, item content creates/reuses component instances (createItemContent, sibling pattern:src/calendar/view/calendars/List.mjs). Sort parity is explicit; the filter fade path has no witness for component items.The Architectural Reality
getItemPosition(index → column/row → translate x/y); the transition rides one per-owner CSS rule.Neo.main.addon.ResizeObserveris a register-based, rAF-dammed singleton with hidden-document guards. The consumer pattern isHelix.addResizeObserver(src/component/Helix.mjs:306):(await getAddon('ResizeObserver', windowId))[mounted ? 'register' : 'unregister']({id, windowId}); deliveries arrive as DOMresizeevents.columns/rowsare read-only plugin members computed at mount;itemMarginconfig exists.examples/list/animate(list.Base owner, fixed 300×200 items) andexamples/list/circlemust keep rendering identically.The Fix
All in
src/list/plugin/Animate.mjs(owner seams insrc/list/Base.mjsonly if strictly required; no new files):ownerRect+columns(+ fluiditemWidth, see 2), then run a position pass over the rendered items — the same translate writesortComponentListuses. An animated reflow, never a rebuild.minItemWidth_(Number, null). When set and the owner'sitemWidthis null:columns = max(1, floor((rect.width − margin) / (minItemWidth + margin))),itemWidth = floor((rect.width − (columns + 1) × margin) / columns)— recomputed on every resize delivery, written where the fixed width goes today. Theconstruct()guard acceptsminItemWidthas a valid width source. ExplicititemWidthkeeps today's fixed behavior byte-identical.position: relativein the list stylesheet) and documents the flow-height-0 caveat for content-sized owners — a flex- or fixed-height scroll owner needs nothing else, since scrollable overflow from translated items is verified browser behavior (Problem 3 receipt).resources/scss/src/list/Base.scss):@media (prefers-reduced-motion: reduce) { .neo-list.neo-animated-list .neo-list-item { transition: none !important } }. (Amended at implementation: a per-owner dynamic companion cannot share the delete lifecycle —deleteCssRulesmatches by selectorText, which@mediarules don't carry — so the dynamic copy would leak on every destroy; the static class-scoped rule covers every animated list with zero lifecycle.)Neo.list.Componentfixture.Contract Ledger
itemWidth/itemHeight(owner configs the plugin consumes)src/list/Base.mjsconfig blockminItemWidth_(NEW plugin config)columns/rows(plugin members)Animate.mjsconfig blocksrc/main/addon/ResizeObserver.mjs;Helix.mjs:306patternposition: relativeon the owner root (cls)updateTransitionDetails→ puregetOwnerRules()Base.scss(amended:@mediacannot delete by selectorText)Acceptance Criteria
Base.scss(its!importantbeats the per-owner id rule), and the two dynamic rules are payload-witnessed incl. destroy deletion (amended from the dynamic-companion shape — see Fix 4).examples/list/animate+examples/list/circlebehave identically in fixed mode (manual receipt in the PR).generate-docs-jsonfor touched classes in the same commit.Out of Scope
grid.Bodyanimation (its own system); list virtualization / buffered windowing (a different primitive).Avoided Traps
minItemWidth), honoring ADR 0029's own-width discipline through the owner's rect, never the viewport.Related
examples/list/animate; component-item sibling:src/calendar/view/calendars/List.mjs.Decision Record impact: aligned-with ADR 0029 (own-width geometry).
Structure-map gate: N/A — no
ai/touch;src/list/plugin/sibling-lift, no new files. Live latest-open sweep: checked latest 20 open issues at 2026-08-22T15:51:54Z (gh issue list --state open --limit 20); no equivalent found. A2A claim sweep at 15:52Z (latest 14, recency-bound): no competing claim; the adjacent #17550 lane-pick (Emmy) is a disjoint surface. Correction 2026-08-22T16:00Z (pre-implementation V-B-A): Problem 3 / Fix 3 / Ledger / AC 3 re-scoped from "derived owner scroll height" to "containing-block guarantee" — the scroll claim was falsified in-browser before a line was written.Origin Session ID: 14acab5a-4b6c-4987-91c7-f683e39baa55 Retrieval Hint: "list plugin animate resize fluid columns component list parity"