# Stacker v2 — Responsive modal/drawer overlay manager

## Context

Rewrite `src/lib/stacker` **from scratch** (replacing the previous attempt entirely). Stacker becomes a single imperative API for spawning overlays that render as **modals on desktop and drawers on mobile**, chosen automatically per stack, exposed as `<StackerProvider>` + `useStacker()` (Mantine modals-manager style). It must be built on Mantine's lower-level compound primitives (`Modal.Root`/`Drawer.Root`/`ModalBase*`) rather than the opinionated `<Modal>`/`<Drawer>`.

Key behaviors (authoritative requirements):

- **Single `open()`** receives one content + chrome props; kind chosen live from the viewport (mobile → drawer), forceable via optional `variant` on the **first** stack item. Later items' `variant` hints are ignored for rendering. Resizing remounts the matching host immediately.
- **Nested stack** like Mantine Modal Manager: closing the top reveals the previous.
- **Modals**: only ever ONE modal rendered; pushes/pops animate a content swap (scale/fade). ONE scrim (`<Overlay>`) that darkens/fades only — never scales the page.
- **Drawers**: lower drawers stay mounted & peek out behind the top one (shadcn nested look), their **content faded to opacity 0** (sheet surface stays visible). ONE scrim + the page content scales down slightly (fixed amount, no per-depth compounding).
- `title` + `subtitle` (under title); close button pinned top-right even when title/subtitle wrap.
- `side` hint (drawer-only, **default "right"** per user), `draggable` hint (drawer-only, vaul-style velocity drag-to-dismiss).
- Mobile breakpoint: **`sm` = 48em/768px** (per user), overridable via provider `mobileQuery` prop.
- Storybook stories + unit + e2e tests.

## Verified Mantine 9.2.2 facts that drive the design

1. `Transition` never animates on initial mount (`useDidUpdate`) → every Root mounts `opened=false` and flips true in a mount effect (two-phase mount).
2. In the `entered` state, Transition keeps its `in` styles **inline on `Modal.Content`/`Drawer.Content` permanently** → our depth/drag/swap transforms must live on inner elements we own, never on Mantine's Content.
3. Every mounted `ModalBase` registers its own window-capture Escape listener gated only by its own `closeOnEscape` → with N drawer roots mounted, set `closeOnEscape` **only on the top** root.
4. Theme `defaultProps` do NOT reach compound roots (keys are `ModalRoot`/`DrawerRoot`) → replicate house defaults explicitly (modal: `size "lg"`, `radius "sm"`, `centered`, `padding "xl"`). Theme `Overlay` (opacity .25, blur 4) and `CloseButton` (surreal, custom icon) defaults DO apply — free consistency.
5. `ModalBase*` parts (`ModalBaseTitle/Body/...`) are exported from `@mantine/core` root and share one context with both Modal and Drawer → one shared header/body chrome component works in both paths with correct `aria-labelledby`/`aria-describedby`.
6. `onExitTransitionEnd`/`onEnterTransitionEnd` are first-class Root props → use for REMOVE dispatch.
7. `Drawer.Content` has `overflow-y: auto` and `data-hidden` means `opacity: 0 !important` on the whole content → neutralize Content (transparent, `overflow: visible`), never use `__hidden`; scrolling moves into our `.body`.
8. `RemoveScroll` is refcounted → per-root `lockScroll` default is safe with N mounted roots.
9. Drawer `position` auto-picks slide transition (top→slide-down, bottom→slide-up, left→slide-right, right→slide-left); positioning is CSS-vars, not data attributes.
10. `useDrag` (@mantine/hooks) provides movement/velocity/direction with document-level listeners → synthetic PointerEvents work for e2e drag tests.
11. `useMediaQuery(query, initial, { getInitialValueInEffect: false })` reads matchMedia synchronously.
12. Repo: `isolatedDeclarations` (annotate all exports), SCSS modules `dashesOnly`, `clsx` from `@src/helpers/clsx`, `generateRandomId` from `@src/helpers/id`, `useStable` from `src/hooks/use-stable.tsx`, `src/index.ts` already re-exports `./lib/stacker`.

## File layout — `src/lib/stacker/` (delete all 11 existing files)

| File                  | Responsibility                                                                                                                                                                                                                                                  |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `index.ts`            | Public re-exports (components, hook, types, `stackerReducer`, pure helpers for tests)                                                                                                                                                                           |
| `types.ts`            | `StackerKind`, `StackerSide`, `StackerSettings`, `StackerItem`, `StackerContextValue`                                                                                                                                                                           |
| `config.ts`           | Constants: `STACKER_MOBILE_QUERY = "(max-width: 48em)"`, z base 200, durations (root 200ms, swap 220ms, drawer 300ms, page 400ms), easing `cubic-bezier(0.32, 0.72, 0, 1)`, `DRAWER_PEEK_PX 12`, `DRAWER_DEPTH_SCALE 0.04`, `PAGE_SCALE 0.98`, `PAGE_RADIUS 12` |
| `helpers.ts`          | Pure: `resolveKind(variant, isMobile)`, `resolveLiveKind(items, isMobile)`, `drawerDepthStyle(side, depth)`, `topOpened(items)`                                                                                                                                 |
| `reducer.ts`          | `stackerReducer` — OPEN/CLOSE/CLOSE_ALL/REMOVE/UPDATE (items only; no stored kind)                                                                                                                                                                              |
| `context.ts`          | `StackerContext`, `useStacker()` (throws outside provider)                                                                                                                                                                                                      |
| `provider.tsx`        | `StackerProvider`: reducer, `useMediaQuery`, stable callbacks, page-scale shell, mounts one host by kind                                                                                                                                                        |
| `modal-host.tsx`      | The single `Modal.Root` tree; REMOVE on root exit                                                                                                                                                                                                               |
| `modal-swap.tsx`      | Content cross-swap state machine (current/leaving panes, push/pop direction, cleanup, reduced motion)                                                                                                                                                           |
| `drawer-host.tsx`     | Shared scrim (Portal + Transition + `<Overlay fixed>`) + one `Drawer.Root` per item                                                                                                                                                                             |
| `drawer-sheet.tsx`    | Per-item inner sheet: depth transform, content fade, drag wiring, handle                                                                                                                                                                                        |
| `chrome.tsx`          | `StackerHeader` (title/subtitle/close, `live` vs inert mode) + `StackerBody`                                                                                                                                                                                    |
| `use-drag-dismiss.ts` | Port of existing hook nearly as-is (`position`→`side`); pure `shouldDismiss` kept                                                                                                                                                                               |
| `style.module.scss`   | Page shell, sheet, content fade, handle, header, swap panes/keyframes, reduced-motion                                                                                                                                                                           |
| `AGENTS.md`           | Rewritten module doc                                                                                                                                                                                                                                            |

### Public API

```ts
type StackerKind = "modal" | "drawer";
type StackerSide = "left" | "right" | "top" | "bottom";

interface StackerSettings {
  id?: string;                       // default generateRandomId()
  title?: ReactNode;
  subtitle?: ReactNode;              // under title
  children?: ReactNode;              // THE one content
  variant?: StackerKind;             // first-overlay hint only
  side?: StackerSide;                // drawer-only, DEFAULT "right"
  draggable?: boolean;               // drawer-only, default false
  size?: MantineSize | (string & {}) | number;  // modal default "lg", drawer "md"
  padding?: MantineSpacing;          // default "xl"
  withCloseButton?: boolean;         // default true
  closeOnEscape?: boolean;           // default true
  closeOnClickOutside?: boolean;     // default true
  onClose?: () => void;
}

useStacker(): { open(settings?): string; close(id?): void; closeAll(): void;
                update(id, patch): void; stack: StackerItem[]; kind: StackerKind | null }

<StackerProvider mobileQuery?: string /* default "(max-width: 48em)" */ zIndex?: number>
```

## State & reducer

`{ items: StackerItem[] /* bottom→top; opened=false = playing exit */ }`

- **OPEN** `{item}`: existing id → no-op if open, **revive** (flip opened, merge settings) if closing; else append.
- **CLOSE**: flip `opened=false` (idempotent, same-ref no-op).
- **CLOSE_ALL**: flip all.
- **REMOVE**: filter id.
- **UPDATE**: shallow-merge patch.

Provider `open()` (stable via `useStable`, state via ref): dispatches `{ item }` only. Live kind is `resolveLiveKind(items, isMobile)` = first item's `variant` ?? viewport.
Provider `close(id?)`: target = id or top opened item; skip if already closing (onClose fires once); fire `onClose`; **non-top modal → immediate REMOVE** (it isn't rendered, no animation); else CLOSE.

Edge cases: double-close = no-op; reopen mid-exit = revive (Transition reverses); viewport change mid-stack remounts the matching host with the same items.

## Modal path — ONE `Modal.Root` for the whole stack

```
<Modal.Root opened={entered && anyOpen} onClose={() => close()}
    closeOnEscape={top.closeOnEscape} closeOnClickOutside={top.closeOnClickOutside}
    trapFocus returnFocus lockScroll centered radius="sm"
    padding={top.padding ?? "xl"} size={top.size ?? "lg"}
    transitionProps={{ transition: "pop", duration: 200, timingFunction: EASING }}
    onExitTransitionEnd={removeAllClosedItems} zIndex={zIndex}>
  <Modal.Overlay data-stacker-scrim />          ← THE single scrim (darken/fade only)
  <Modal.Content data-stacker="modal">
    <ModalSwap top={lastItemRegardlessOfOpened} onLeaveEnd={removeIfStillClosed}>
      pane: <StackerHeader live /> + <StackerBody />
    </ModalSwap>
  </Modal.Content>
</Modal.Root>
```

- Escape + overlay click funnel through `onClose` → pops top. Native Mantine focus trap/scroll lock/portal.
- When the LAST item pops, content persists (swap keyed on last item, not last _open_) while the Root plays exit; `onExitTransitionEnd` REMOVEs → kind reset.

### Swap mechanism (`modal-swap.tsx`)

- Derived-state-in-render: when top id ≠ current id, `setLeaving({item: current, direction})`, `setCurrent(top)` during render (no flash frame). Direction: old current still in items → "push", else "pop".
- Layout: `.swap { display:grid }`, `.pane { grid-area: 1/1; min-width: 0 }` (panes overlap; no height animation in v1).
- CSS keyframes 220ms, fill-mode both, direction-aware deck: push = old scales back to 0.94 + fades, new enters from 1.06; pop = reversed.
- Leaving pane is `inert aria-hidden` with `live={false}` chrome (plain markup — avoids duplicate `ModalBaseTitle` aria ids).
- Cleanup: `onAnimationEnd` → `onLeaveEnd(id)` → provider REMOVEs only if item still present with `opened === false`; `setTimeout(duration+100)` fallback. Reduced motion (`useReducedMotion`): skip leaving pane entirely, instant swap.
- Focus: entering pane `tabIndex={-1}`, focused (preventScroll) when `document.activeElement` collapsed to body.

## Drawer path — one `Drawer.Root` per item + standalone scrim

```
<Portal>
  <Transition mounted={anyOpen} transition="fade" duration={200}>
    {(styles) => <Overlay fixed data-stacker-scrim style={styles} zIndex={zIndex}
                   onClick={() => topAllowsClickOutside && close()} />}
  </Transition>
</Portal>
{items.map((item, index) =>
  <Drawer.Root opened={entered && item.opened} onClose={() => close(item.id)}
      position={side /* default "right" */} size={size ?? "md"} padding={padding ?? "xl"}
      closeOnEscape={isTop && closeOnEscape}     ← CRITICAL (fact 3)
      closeOnClickOutside={false}                 ← scrim owns click-outside
      trapFocus={isTop} returnFocus lockScroll
      transitionProps={{ duration: 300, timingFunction: EASING }}  ← slide-* auto per position
      onExitTransitionEnd={() => remove(item.id)} zIndex={zIndex + 1 + index}>
    <Drawer.Content className={contentReset} data-stacker="drawer" data-covered={depth > 0}>
      <div className={sheet} data-side data-covered style={depthOrDragTransform}>
        {draggable && <div className={grip} ref={isTop ? dragRef : undefined}
                        style={{ touchAction: isTop ? "none" : undefined }}><div className={handle}/></div>}
        <StackerHeader live className={fadeItem} />
        <StackerBody className={fadeItem} />
      </div>
    </Drawer.Content>
  </Drawer.Root>)}
```

- **Standalone scrim** (not `Drawer.Overlay` in root[0]) because a mid-stack/bottom REMOVE must not tear down the scrim. z: scrim at base, roots at base+1+index.
- **Depth visuals** (`drawerDepthStyle(side, depth)`, depth = count of opened items above): transform-origin at the **free edge**, translate away from anchor + scale, net peek = depth × 12px:
    - right: `translateX(-12px·depth) scale(1 − 0.04·depth)`, origin `0% 50%`
    - left: `translateX(+…)`, origin `100% 50%`; bottom: `translateY(-…)`, origin `50% 0%`; top: `translateY(+…)`, origin `50% 100%`
- Mantine's slide transform lives on Content (outer), ours on `.sheet` (inner) — they compose, never conflict (fact 2). `.sheet { transition: transform .3s EASING }`.
- **Content fade**: `.sheet[data-covered] :is(.header, .body) { opacity: 0 }` — surface + handle stay visible. Covered content gets `pointer-events: none` so clicks on peeking sheets fall through to the scrim.
- **Content neutralization**: `.contentReset { background: transparent; box-shadow: none; overflow: visible }`; scrolling in `.body { flex:1; min-height:0; overflow-y:auto }`; per-side radius on `.sheet` (round free corners only).
- **Drag**: reuse ported `use-drag-dismiss` (flick ≥ 0.5 px/ms toward edge OR past 50% of extent → dismiss); enabled only for top sheet with `draggable`; `transition: none` while dragging; spring-back via sheet transition. Drag and depth transforms never coexist (only depth-0 drags).

## Page scale (drawers only)

Provider wraps children: `<Box className={page} data-pushed={kind === "drawer" && anyOpen}>`. `.page[data-pushed] { transform: scale(0.98); border-radius: 12px; overflow: hidden }`, 0.4s EASING, `transform-origin: top center`, no transform when idle (keeps `position: fixed` descendants working), fixed amount regardless of depth, reduced-motion disables it. Never applied for modals.

## Header (`chrome.tsx`)

Flex row `align-items: flex-start`: left column `.titles { flex:1; min-width:0 }` with `ModalBaseTitle` (live) or plain div (inert) + subtitle `<Text c="dimmed" size="sm">`; right `CloseButton` `.close { flex-shrink:0; margin-inline-start:auto }` → pinned top-right regardless of wrapping. Padding via `--mb-padding`. NOT using `ModalBaseHeader` (sticky/min-height/opaque bg don't fit sheets). Body = `ModalBaseBody` (live) for `aria-describedby`.

## Stories (`src/stories/primitives/Stacker.stories.tsx`, CSF3, NO play functions)

1. `Default` — auto kind, nested push buttons, title/subtitle demo
2. `MobileEmulated` — `mobileQuery="(min-width: 0px)"` + explanatory banner
3. `ForcedModal` / `ForcedDrawer`
4. `ModalDeck` — recursive push/pop (+ `update` title button)
5. `StackedDrawers` — nested right drawers, peek + content fade
6. `DrawerSides` — 4 side buttons
7. `DraggableDrawer` — `draggable`, `size "60vh"`
8. `CoercedStack` — drawer first, then `variant:"modal"` push renders as drawer
9. `DismissControl` — closeOnEscape/closeOnClickOutside/withCloseButton false combos
10. `LongHeader` — wrapping title+subtitle fixture
11. `Lifecycle` — visible onClose counters + closeAll

## Tests

**Unit** (`tests/unit/Stacker/`): `reducer.test.ts` (open order/kind lock incl. all-closing stack/revive/idempotent close/remove resets kind/close-all/update), `helpers.test.ts` (`resolveKind` matrix; `drawerDepthStyle` 4 sides × depths), `drag.test.ts` (keep existing 4 `shouldDismiss` cases).

**E2E** (`tests/e2e/Stacker/stacker.test.tsx`, composeStories + mountStory, selectors via `data-stacker*`):

- E1 nothing mounts on render; E2 desktop auto → modal, page scale 1; E3 `MobileEmulated` → drawer + page scale < 0.99; E4 forced drawer on desktop; E5 kind coercion (2 sheets, 0 modals); E6 modal push keeps exactly ONE dialog + ONE scrim, old pane leaves DOM; E7 pop reveals previous, scrim stays mounted; E8 drawer peek geometry (lower sheet offset ≈ 12px) + lower body computed opacity 0; E9 page scale not compounding + returns to 1 after closeAll; E10 escape/scrim-click pop only top + respect opt-outs + focus returns into revealed drawer; E11 close button pinned with wrapping header; E12 drag-dismiss via synthetic PointerEvents (past 50% closes; 30px springs back); E13 onClose fires once, closeAll empties, kind resets after stack empties.
- Delete stale `tests/e2e/Stacker/__screenshots__/`.

## Implementation sequence

1. `types/config/helpers/reducer` → unit tests green
2. `context` + `provider` (callbacks, kind decision, page shell)
3. Drawer path (`drawer-host`, `drawer-sheet`, `chrome`, SCSS, `use-drag-dismiss` port)
4. Modal path (`modal-host`, `modal-swap`, keyframes)
5. Stories rewrite; manual Storybook pass
6. E2E rewrite; delete stale screenshots
7. `AGENTS.md`; verify `src/index.ts` re-export

## Verification

- `npx tsc --noEmit` clean (mind `isolatedDeclarations`)
- `npm run qc` (oxlint + biome exhaustive-deps + oxfmt) clean
- `npx vitest run --project=unit tests/unit/Stacker --project=e2e tests/e2e/Stacker --project=storybook src/stories/primitives/Stacker.stories.tsx` all green
- Live check in Storybook via preview server (spawn on a free port; user's own runs on 6006): no auto-open on load; modal path (single dialog, swap animation, scrim persists, page never scales); drawer path (right default, peek + content fade, page scales once, drag-dismiss); `npm run build` rolls up types

## Key risks (mitigations built into design)

- Transition inline styles on Content → transforms only on inner elements (2)
- No initial-mount animation → two-phase mount everywhere (1)
- Escape multi-close across drawer roots → closeOnEscape top-only (3)
- `data-hidden`/`__hidden` hides sheet surface → never used; fade header/body instead (7)
- Duplicate ARIA ids in swap → inert `live={false}` leaving pane
- Focus tug-of-war on drawer pop → e2e asserts; fallback = delay trapFocus flip one frame or manual focus restore
- Page transform breaks fixed descendants while drawers open → inherent vaul trade-off, no transform when idle, documented
