Type: maintenance · Scope: morph_kit · Last verified: 2026-07-19 Companion docs:
ARCHITECTURE.md·CAPABILITY_ROADMAP.md
Maintenance recipes and the invariants that must not break. When code and docs disagree, code wins — then fix the doc in the same commit.
make check # analyze + package tests + example journeys
# or piecewise:
fvm flutter analyze
fvm flutter test # package suite (unit + widget)
cd example && fvm flutter test # full-app journeysGreen on all three before claiming any change done. The example app is the integration harness — it reproduces the hardest consumer topology (nested kept-alive tab routers + overlay mode + URL sync) so package changes are validated against real conditions without touching a real app.
The overlay compact mode rests on three invariants. Every one was earned by
a real failure; tests pin them (test/core/list_detail/ list_detail_overlay_test.dart, paint_visibility_detector_test.dart, and
the example's overlay journeys).
- The
OverlayPortalis shown once and never hidden. The overlay child collapses toSizedBox.shrink()instead. Toggling show/hide on layout transitions tripsOverlayPortalController's assertion during the layout phase and reintroduces a one-frame gap. PaintVisibilityDetector.evaluate()runs during layout;PaintVisibilityObserverreports during paint; the re-show notifier update is deferred to a post-frame callback. Layout-after-paint ordering is what makes the was-painted flag trustworthy; mutating a notifier during paint is illegal. Hide is zero-frame, re-show has a one-frame lag — that asymmetry is by design.- Suppression needs a subsequent build pass of the inactive child. Real tab switches produce one. Don't "fix" this by polling or timers; if a consumer hits a case with no rebuild, the answer is an explicit rebuild at the shell, not package-side scheduling.
Related: on expanded → compact with an open selection, the slide controller JUMPS to 1.0 (no re-animation) — the detail was already visible.
- The list, the detail, and both split panes mount under stable
GlobalKeys. Anything that changes a pane's position in the tree between compact and expanded must keep the same key wrapping the same builder output, or state preservation (the package's core guarantee) silently dies. The resize tests fail loudly if it does. - Dismiss keeps the outgoing detail in the tree until the exit animation
reaches
dismissed(_outgoingDetailId, plus_lastSeenSelectedIdfor externalcontroller.dismiss()calls). Removing either breaks one of the two dismiss paths.
showAdaptiveModal presents real Material routes (DialogRoute,
ModalBottomSheetRoute) and replaces one with the other when the window
crosses the breakpoint. The swap machinery in
src/core/modal/adaptive_modal.dart holds three invariants:
- The swap is one synchronous block: point
_activeat the new route →removeRoute(old)→push(new). All three land in one frame — that is what lets theGlobalKey'd content reparent instead of unmount, and what keeps old and new content from coexisting (duplicate-key crash). - The identity guard decides what a route completion means. A removed
route also completes its
poppedfuture (with null); only the completion of the route that is still_activeis the user dismissing the modal. Reorder the swap so_activestill points at the old route duringremoveRouteand the caller's future completes with null mid-swap. - Swaps navigate post-frame, from live width. The crossing is detected during build (navigation is illegal there); by the time the callback runs the width may have crossed back, so the target mode is re-derived — never captured at schedule time.
With the container transform (ModalConfig.morph, default on), three more:
- Exactly one holder of the content key per frame. While a flight is
in the air the routes build a placeholder instead of the keyed content
(
_morphingdrives the branch). Flight start and flight end each move the key in one synchronous block. Two holders in one frame is a duplicate-key crash; zero is a silent state reset. - The subtree from the keyed node down is identical in every host.
The flight's
Builder(for the captured-themes context) sits ABOVE theKeyedSubtree— insert anything between the key and the user's content in one host but not the others and the reparent degrades into a rebuild. - A dismissal mid-flight lands the content before the result completes.
The
poppedhandler calls_endFlight()first, so the exiting route shows the content during its exit animation and the flight's ticker is disposed. Completing the result while a flight is airborne leaks the ticker and pops an empty route. - Content lays TIGHT at the container's lerped width; the placeholder's
width comes from the one-time natural-width sample. Tight layout is
what makes the content shrink and grow WITH the morph — loose layout
lets self-sized content jump to its destination width at takeoff, and
destination-width layout makes the narrower container visibly crop it.
The width circularity that tight-following would otherwise cause
(placeholder width from content width from container width) is broken
by sampling: the measurer lays the child LOOSE as a pre-pass inside
the same layout (never painted — a painted loose frame is a visible
narrow-flash at takeoff), records the natural width and whether the
content is full-bleed, then lays tight for real. The placeholder's
width is that frozen sample (full-bleed → the slot decides via
double.infinity; self-sized → the sampled width), delivered throughsampleRevision— the size channel alone can't carry it, since the tight-laid size often doesn't change when the sample does. A retarget switches the target form, so it marks the sample stale and the next layout resamples — a stale sample is the sheet landing at the dialog's width and snapping wide. Only the HEIGHT flows from the live measurement. Related: both resting forms passClip.antiAliasso corner rendering matches the flight's clipped surface; reverting to Material's defaultClip.nonemakes content near the corners pop square at handoff. - The destination stays a ghost until the flight becomes it. During a morph the sheet is pushed chrome-less (transparent surface, no handle, no drag — but the REAL barrier, for scrim continuity) and the dialog form zeroes its chrome reactively. Landing on a ghost sheet does a same-frame swap to the normally-chromed route; the reveal is invisible because the flight's final frame is pixel-identical to the real chrome. Pushing a visible destination brings back the "fully-formed empty sheet waiting for its content" artifact.
- The handle band is
kMinInteractiveDimension, imported — plus one structural replica. The ghost's placeholder inset and the flight's surface inset both use the SAME constantBottomSheetuses for its content padding, so the metric cannot drift — and both apply it asPaddingINSIDE the bounded slot, never a spacer stacked above: constraint-filling content must get height − band, or the slot overflows by exactly the band (found live with a scrollable panel; the flight mirrors the math viamaxHeight − inset). The handle's look (dragHandleSize ?? theme ?? Size(32, 4), radius h/2, colordragHandleColor ?? theme ?? onSurfaceVariant) is replicated from the SDK source and pinned by the landing-parity test — if a Flutter upgrade moves it, that test fails loudly. - The flight's Material must not out-paint the flight. Three paint
parities, each found as a real landing pop: the flight's
MaterialsetsanimationDuration: Duration.zero(Material implicitly tweens shape/elevation over ~200ms, lagging the lerp — the widget tree claims the lerped shape while the pixels stay behind); itsshadowColorresolves from the theme with the M3 default of transparent (both real forms cast no shadow — a default black shadow on the flight vanishes at handoff); and the ghost → realDialogflips aValueKeyso its Material mounts fresh instead of implicitly tweening elevation after landing.
CompactDetailMode.route hosts the compact detail in a real page route.
The machinery in list_detail_layout.dart holds:
- All navigation flows through
_syncDetailRoute, post-frame. Build detects, the reconciler acts. Navigating during build asserts. - Exactly one holder of the detail key per frame — the route (while
_detailRouted), the expanded pane, or the one-frame bridge. The pane leaves its slot empty while a route holds the key; the route still covers the screen that frame, so the gap is invisible. - Dismissal is deselection — there is NO pop handoff. The content rides the popped route's real exit animation and dies with it, exactly like the inline outgoing pattern. This is why predictive back (and its cancel) needs no special handling. An exiting route keeps the key until its animation is dismissed; new pushes wait for it.
- The identity guard on
popped. A removed route also completes; only the still-active route's completion is a user dismissal. - The route stays non-opaque. The paint probe below detects "tab hidden under the route"; an opaque route blinds the probe AND un-paints the very layout that owns the route — a suppression loop.
- Suppression is a build-armed one-shot, and it corrects the stale
probe.
evaluate()(layout) resets the paint flag; the post-frame check PEEKSpaintedThisFrameonly after a frame whose build armed it — clean idle frames (where paint legitimately skips undirtied layers) can never false-trigger. The check never resets the flag;evaluateowns the reset. It runs on EVERY route-mode build (not only routed ones): paint can stop without any build runningevaluate()(keep-alive tab buckets — TabBarView), leaving the notifier stale-TRUE. A built-but-unpainted frame flips it false — which is also what re-arms_onPaint's deferred "paint resumed" signal; a stuck-true notifier never fires it and the re-show would never wake. - A push needs same-frame paint evidence; a suppressed detail is
re-homed in the bridge. The sync reads
paintedThisFramewhen the frame's build ranevaluate()(fresh both ways: pushes in the very flush where paint resumed, AND vetoes the stale-true notifier that once pushed a hidden tab's route over the visible screen), falling back to the notifier only for listener-woken syncs in buildless frames._scheduleRouteSynccallsensureVisualUpdate()— the listener fires inside another frame's post-frame flush, where a queued callback otherwise waits for a frame an idle device never schedules. And whenever the route is removed while the selection is kept,_bridgeDetailis set in the same block: the bridge claims the detail key the frame the route dies — a keyless frame unmounts the element and destroys its state. (Repro for all three: open the detail expanded, hide the tab, resize to compact, return.) - Route pushes run Hero scans over the whole shell. Flutter scans
the from-route subtree — including kept-alive tab children — for
Herotags on every push. TwoFloatingActionButtons with default hero tags anywhere under the shell assert and wreck the transition. App-side rule (documented in the README): give FABs explicitheroTags (ornull) when several coexist under one page. - Crossings animate the ARRANGEMENT, never the tracking — and the
offstage bridge is the route's handoff. Pane geometry follows a
window drag with zero motion (a lagging pane fights the hand); the
arrangement flip at the breakpoint animates on EVERY crossing, drag
or jump alike — the Compose-canonical pane motion. Into compact:
inline/overlay reuse the slide controller (start at the old divider
fraction, settle to full); route mode drops
instantEntranceand lets the REAL entrance play over the LIST — the bridge goesOffstagefor the handoff frame: still the detail key's holder (a keyless frame unmounts the element) but invisible, so the entrance never slides over a copy of itself. Into expanded (every mode): the detail starts full width — for route mode this makes the instant route removal seamless, the pane's first frame matches the route's last — and_expandEntryControllerslides the list in, scaling only the RENDERED list width (the width model keeps real geometry; the rebuild rides an AnimatedBuilder, never a setState listener, which would fire during build when a crossing frame seeds the value). Hidden layouts (paint-probe false) always take the instant path — an entrance nobody watches is wasted, and the re-show contract expects instant. A first build is never a crossing (_hasBuiltOnce) — deep links render settled. The EMPTY placeholder pane crosses too: it reveals via_detailPaneControlleron expand, and on shrink build() keeps the expanded geometry alive at the compact width (_emptyPaneRetreating) until the retreat lands — the steady visuals on both ends are a full-width list, so the tree swap is invisible. During those builds_lastExpandedWidthmust NOT update (it would record the compact width and corrupt the crossing math). didUpdateWidgetmirrors initState's per-mode wiring. Mode flips happen on LIVE layouts (settings screens exist). Entering route mode must attach the paint-probe listener — it IS the re-show chain; a layout flipped into route mode without it looks fine until a tab hide + return leaves the detail resting inline forever. Anything a mode's initState wires, the flip must wire; anything it wires, the flip away must unwind. The regression test pins this with a paint-only re-show (children reused across the tab switch), because a harness that rebuilds on tab switch masks a dead listener.
- All divider interactivity lives in
PaneDividerRegion— drag, double-click reset, keyboard shortcuts, semantics. The layouts supply callbacks; neither layout builds its ownGestureDetectorfor the divider. A new interaction goes into the region once and both widgets get it. - Collapse mechanics are model state (
PaneWidthModel.collapsed, VS Code spec: half-minimum threshold on the raw drag position,_cachedWidthrestore, spring-back short of the threshold). Widgets never track their own collapsed flag. PaneSide/PaneCollapsibleare DIRECTIONAL in every public surface (config,DividerState.collapsed,PaneScope). The width model works in model space (start = the measured pane).SplitLayoutwith an end-positioned primary translates at its boundary: flippedcollapsibleinto the model, flippedcollapsed/ at-limit flags / Home-End targets out of it.ListDetailLayout's model space IS directional, no translation. Breaking this makes the pull tab point the wrong way andPaneScopelie.- Collapsed pane content reflows down to its floor width, never
below it. The clip slot (
heldAtFloor) lays content atmax(floor, slot width)— live reflow while the slot is above the floor, rigid-and-clipped below it (start pane's floor isminListWidth, end pane's isavailable * (1 - maxListRatio)). No jump at an animation's first frame, no squish below the floor the app designed for. - Programmatic collapse/restore snap instantly; only the drag path
animates.
_settleToWidthis the single settle primitive — anchors, Home/End, and double-click reset all route through it. - Keyboard steps are micro-drags through
_handleDividerDragUpdate, so RTL (and split's primary-position inversion) apply identically to pointer and keyboard. Never add a parallel resize path. - Semantics carry
value+increasedValue+decreasedValuetogether — Flutter asserts if increase/decrease actions exist with avaluebut no stepped values. The share strings come from the layout's_paneSharePercent. - Parked panes don't dance. A collapsed pane's crossing arrives at
the final arrangement directly: the rail docks at its parked width
from frame one (fixed end slot + background spacer while the list's
slide-in plays; divider anchored to the DOCKED boundary), and both
the parked-list entry slide and the empty-pane reveal/retreat are
skipped when parked. The collapse animation belongs to the moment
the user collapsed — a window resize isn't that moment. A rail
entrance flourish is the app's own business inside its rail builder;
never stretch a fixed-width rail across an animating slot.
8b. Rail slots replace the clip only when the app opts in. A
collapsed*Builderlays its content at the REAL slot width (StackFit.expand— the offstage child is zero-size and must not set the stack's size) while the pane parks inOffstage+TickerMode(false), state alive. Both panes carry reparenting GlobalKeys, so the wrap/unwrap reparents instead of remounting. - Never
clamppane bounds without the min-wins guard. Expanded geometry renders at COMPACT widths during the empty-pane retreat, soavailableWidth * maxListRatiocan drop belowminListWidth—double.clampTHROWS on inverted bounds.PaneWidthModel._clampand every_paneSharePercentguard with "ceiling <= floor → floor"; any new bound computation must too. - The divider stays grabbable when parked. The region's position is
clamped fully on-screen at
collapsedSize; a collapsed pane must always be recoverable by drag alone.
- Create the file under
src/components/{dividers|empty_states}/. - Match the shared contract: dividers implement the
DividerBuildertypedef via a staticbuilder; empty states expose aWidgetBuilder-returning static (IconMessageEmpty.ofshape). - Components may import Material and core typedefs — never core widgets' internals. Core must not gain an import of the new component.
- Export from the barrel's components section.
- Mirror a test under
test/components/...asserting the visual states (idle / dragging / settling / focused / collapsed pull-tab for dividers). - Row in
CAPABILITY_ROADMAP.md.
- Add the field to the right pure-data class (
PaneConfig,CompactConfig) with a default that preserves current behavior. - Add the field to
==andhashCodewhen the class has them (PaneConfigdoes). A field missing from equality makes two different configs compare equal — the layout then ignores the change at runtime. Never compare configs byidentical: apps construct them inline inbuild, and identity comparison resets user state (the dragged divider) on every rebuild. Watchdouble.nansentinel fields — NaN == NaN is false (PaneAnchorcarries the fix). - Wire it where it acts. If it affects pane width, it goes through
PaneWidthModel— never inline width math in a widget (the two widgets must stay identical in resize behavior). - If a widget must react to the field changing at runtime, handle it in
didUpdateWidget(see thepaneConfigreset there). - Unit-test the model change; widget-test the visible effect.
- No decorative fields. A config field with no behavior behind it is a
lie — this package once shipped
anchors/resizeMode/isSettlingunimplemented; they're real now. Don't regress the standard.
- New folder under
src/core/<name>/(peer oflist_detail/,split/). - Reuse the shared vocabulary:
AdaptiveLayoutConfig.resolveBreakpoint,PaneConfig+PaneWidthModelfor any draggable pane, theDividerBuildertypedef, GlobalKey reparenting for anything that morphs. - Nullable builder params for anything a component could fill.
- Barrel export, mirrored test folder, roadmap rows, architecture tree update.
Pre-1.0, the minor version is the breaking axis — bump it for any breaking
change and say so in the changelog. Workspace consumers use path:
dependencies, so breaks surface at their next analyze. After any signature
change: run the check sequence here, then
fvm flutter analyze in each consuming app (the workspace grep for
morph_kit finds them). Update the example's usage in the same
session — it is the reference consumers copy from.
| Failure | First check |
|---|---|
| Overlay detail lingers over another tab | Did a rebuild pass reach the inactive child? (§1.3) Then: evaluate() still called from the layout builder? |
| Assertion: OverlayPortalController show/hide during layout | Someone reintroduced portal toggling — restore the always-showing shape (§1.1) |
| Duplicate GlobalKey crash on resize | A pane is built in both layouts within one frame — check the mode branches only ever mount one copy |
| Detail state resets on window resize | GlobalKey chain broken (§2) |
| Divider ignores drags | The hit zone (dividerHitWidth, default 24) is centered on the pane border; check the width actually read from PaneWidthModel, and whether a collapsed pane parked it at the window edge |
| Snap lands at the wrong place | Anchor positions clamp by min/max — verify against pane_width_model_test.dart expectations |