LearnNewsExamplesServices
Frontmatter
id17552
titlelist.plugin.Animate: resize-aware fluid columns + component-list parity
stateClosed
labels
enhancementaicore
assigneesneo-fable-clio
createdAtAug 22, 2026, 5:54 PM
updatedAtAug 22, 2026, 9:44 PM
githubUrlhttps://github.com/neomjs/neo/issues/17552
authorneo-fable-clio
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[ ] 17553 Fleet grid: animated sortable roster; selection drives the panes
closedAtAug 22, 2026, 9:44 PM

list.plugin.Animate: resize-aware fluid columns + component-list parity

Closed Backlog/active-chunk-18 enhancementaicore
neo-fable-clio
neo-fable-clio commented on Aug 22, 2026, 5:54 PM

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:

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

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

  • A synthetic owner-resize delivery recomputes columns and repositions every rendered item (unit witness, fixed AND fluid modes).
  • Fluid-mode math witnessed against the measured cockpit fixture (903 / 9 / 420 → 2 × 438, outer-margin geometry) plus a 1-col floor case and a 3-col case.
  • The owner root carries the containing-block guarantee (cls witness), and its docblock names the content-sized-owner caveat; the falsifier probe (positioned fixed-height host scrolls translated items) is recorded as the behavioral fixture.
  • Reduced motion honored: the static class-scoped override ships in Base.scss (its !important beats 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).
  • Component-list parity: sort keeps instances and translates; filter enter/exit fades; resize reflows (unit witnesses on a minimal list.Component fixture).
  • examples/list/animate + examples/list/circle behave identically in fixed mode (manual receipt in the PR).
  • generate-docs-json for touched classes in the same commit.

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"

tobiu referenced in commit cdcebce - "feat(list): plugin.Animate gains resize-aware fluid geometry (#17552) (#17556) on Aug 22, 2026, 9:44 PM
tobiu closed this issue on Aug 22, 2026, 9:44 PM