{
  "name": "sheet",
  "type": "registry:ui",
  "title": "Sheet",
  "description": "The one drawer: floats over the region (never reflows the layout), full takeover under 700px, a lens on the armed object with crossfade swap, resizable handle with keyboard path, lens/focus postures remembered per key.",
  "dependencies": [
    "@candlelight-ds/tokens",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/ui/Sheet.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\n/**\n * Sheet — the drawer floats. ONE presentation at every size (spec §7):\n * the sheet overlays the region with a scrim and the stage shadow.\n * Push mode was built, shipped, and retired; the layout behind never\n * reflows — don't rebuild it.\n *\n * - ≥700px container: overlay — floating panel, scrim, resize handle\n * - <700px: full takeover\n * Callers never branch; the region picks the mode.\n *\n * The sheet is a lens on the armed object (G1): swap `sheetKey` while open\n * and the content crossfades in place instead of closing/reopening.\n *\n *   <SheetRegion\n *     open={!!armed}\n *     onClose={() => setArmed(null)}\n *     title={armed?.name ?? ''}\n *     accent={armed?.accentColor}\n *     sheetKey={armed?.id}\n *     sheet={armed ? <ObjectEditor object={armed} /> : null}\n *   >\n *     <ListPane />  // keeps its scroll; the drawer floats above it\n *   </SheetRegion>\n *\n * Use when: editing/inspecting an object selected from an adjacent list.\n * Don't use when: blocking decisions (Overlay) or full workflows (a route).\n */\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { Maximize2, Minimize2, X } from 'lucide-react'\n// The law numbers live in ONE home (spec §7) — import, never re-declare.\nimport {\n  LENS_CROSSFADE_MS,\n  SHEET_FOCUS_WIDTH,\n  SHEET_HEADER_H,\n  SHEET_LENS_WIDTH,\n  SHEET_TAKEOVER_MAX,\n  SHEET_WIDTH_MAX,\n  SHEET_WIDTH_MIN,\n} from '@candlelight-ds/tokens/metrics'\nimport { cn } from '@/lib/utils'\n\ntype SheetMode = 'overlay' | 'takeover'\n\nconst REGION_MARGIN = 24\n\ninterface SheetRegionProps {\n  open: boolean\n  onClose: () => void\n  title: string\n  /** Width preset: 340 default (exactly the adjacent column's width — the\n   *  region never changes size between the list and a sheet) ·\n   *  380/440 for editors that earn the room. */\n  width?: 340 | 380 | 440\n  /** The focus posture: the lens widens for real writing. Setting\n   *  `postureKey` opts the region in — a ⤢ header toggle, the posture\n   *  remembered per key; Esc steps focus → lens → closed. The drag\n   *  handle's width is remembered per key and posture too. */\n  postureKey?: string\n  /** The focus posture's width. */\n  focusWidth?: number\n  /** Where an unremembered region opens. 'focus' makes drill-down a\n   *  working surface by default; the user can shrink to the lens. */\n  defaultPosture?: 'lens' | 'focus'\n  /** Accent hex of the armed object — tints the sheet header at 10% (G7). */\n  accent?: string\n  /** Change to crossfade sheet content in place while open (armed-object swap). */\n  sheetKey?: string | number | null\n  /** Header-band verbs — reachable without scrolling. */\n  headerActions?: React.ReactNode\n  /** Copy (§9) — every word reaches your copy module; English defaults. */\n  closeLabel?: string\n  resizeLabel?: string\n  /** The ⤢ toggle's labels, by direction. */\n  focusLabel?: string\n  lensLabel?: string\n  sheet: React.ReactNode\n  children: React.ReactNode\n  className?: string\n}\n\nconst widthStorageKey = (postureKey: string, focus: boolean) =>\n  `candlelight:sheet-width:${postureKey}:${focus ? 'focus' : 'lens'}`\n\nfunction readStoredWidth(postureKey: string | undefined, focus: boolean): number | null {\n  if (!postureKey) return null\n  const raw = window.localStorage.getItem(widthStorageKey(postureKey, focus))\n  const parsed = raw === null ? NaN : Number(raw)\n  return Number.isFinite(parsed) ? parsed : null\n}\n\nexport function SheetRegion({\n  open,\n  onClose,\n  title,\n  width = SHEET_LENS_WIDTH,\n  postureKey,\n  focusWidth = SHEET_FOCUS_WIDTH,\n  defaultPosture = 'lens',\n  accent,\n  sheetKey,\n  headerActions,\n  closeLabel = 'Close',\n  resizeLabel = 'Resize the drawer',\n  focusLabel = 'Focus — more room to write',\n  lensLabel = 'Back to the lens',\n  sheet,\n  children,\n  className,\n}: SheetRegionProps) {\n  const regionRef = useRef<HTMLDivElement>(null)\n  const titleRef = useRef<HTMLHeadingElement>(null)\n  const [regionWidth, setRegionWidth] = useState<number>(1280)\n  // Keep the panel mounted through the closing animation.\n  const [mounted, setMounted] = useState(open)\n  const [contentKey, setContentKey] = useState(sheetKey)\n  const [focus, setFocus] = useState(false)\n  // The handle's memory — per posture, per key. Null means the preset.\n  const [customWidth, setCustomWidth] = useState<number | null>(null)\n  const [dragging, setDragging] = useState(false)\n  const dragRef = useRef<{ startX: number; startWidth: number; sign: 1 | -1 } | null>(null)\n\n  // The posture persists per key — UI memory, not data. Unremembered\n  // regions open at their declared default.\n  useEffect(() => {\n    if (!postureKey) return\n    const stored = window.localStorage.getItem(`candlelight:sheet-posture:${postureKey}`)\n    const nextFocus = stored === null ? defaultPosture === 'focus' : stored === '1'\n    // eslint-disable-next-line react-hooks/set-state-in-effect -- posture memory lives in localStorage, unreadable during SSR render; one deliberate cascade on mount\n    setFocus(nextFocus)\n    setCustomWidth(readStoredWidth(postureKey, nextFocus))\n  }, [postureKey, defaultPosture])\n\n  const setPosture = (next: boolean) => {\n    setFocus(next)\n    setCustomWidth(readStoredWidth(postureKey, next))\n    if (postureKey) {\n      window.localStorage.setItem(`candlelight:sheet-posture:${postureKey}`, next ? '1' : '0')\n    }\n  }\n\n  const presetWidth = postureKey && focus ? Math.max(focusWidth, width) : width\n  const mode: SheetMode = regionWidth >= SHEET_TAKEOVER_MAX ? 'overlay' : 'takeover'\n  const effectiveWidth = Math.min(\n    customWidth ?? presetWidth,\n    Math.max(SHEET_WIDTH_MIN, regionWidth - REGION_MARGIN)\n  )\n\n  useLayoutEffect(() => {\n    const el = regionRef.current\n    if (!el) return\n    const observer = new ResizeObserver(([entry]) => {\n      setRegionWidth(entry.contentRect.width)\n    })\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [])\n\n  useEffect(() => {\n    // eslint-disable-next-line react-hooks/set-state-in-effect -- mounted deliberately trails open so the panel stays through the closing animation\n    if (open) setMounted(true)\n  }, [open])\n\n  // Unmount fallback: transitionend is the fast path, but a skipped or\n  // frozen close transition (the Chrome mid-transition-mutation wedge)\n  // would leave the closed panel mounted forever. The timer sweeps it.\n  useEffect(() => {\n    if (open || !mounted) return\n    const timer = setTimeout(() => setMounted(false), 250)\n    return () => clearTimeout(timer)\n  }, [open, mounted])\n\n  // Armed-object swap: crossfade content in place (LENS_CROSSFADE_MS), don't re-open.\n  useEffect(() => {\n    if (sheetKey !== contentKey) {\n      const timer = setTimeout(() => setContentKey(sheetKey), LENS_CROSSFADE_MS)\n      return () => clearTimeout(timer)\n    }\n  }, [sheetKey, contentKey])\n\n  // ⚠️ TRAP (shipped once — do not re-learn). Focus the title on OPEN\n  // only. Keyed on `mounted` too: on a cold open the panel doesn't exist\n  // yet when `open` flips (setMounted lands the same commit), so titleRef\n  // is still null and the focus silently never fires. And this effect\n  // must never share deps with callback props: an unstable onClose\n  // re-firing it mid-edit rips focus out of the sheet's text seats on\n  // every continuous save.\n  useEffect(() => {\n    if (open && mounted) titleRef.current?.focus()\n  }, [open, mounted])\n\n  // Esc steps focus → lens → closed (no focus side effects live here).\n  // The one sanctioned bare listener in this file: the sheet is a scope,\n  // not a surface — it swallows Escape before surface bindings see it.\n  useEffect(() => {\n    if (!open) return\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') {\n        event.stopPropagation()\n        if (postureKey && focus) setPosture(false)\n        else onClose()\n      }\n    }\n    // eslint-disable-next-line @candlelight-ds/no-bare-keydown -- the sheet is a scope, not a surface: it swallows Escape ahead of surface bindings (spec §5/§7)\n    window.addEventListener('keydown', onKey)\n    return () => window.removeEventListener('keydown', onKey)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, onClose, focus, postureKey])\n\n  const beginResize = (event: React.PointerEvent<HTMLDivElement>) => {\n    event.preventDefault()\n    try {\n      event.currentTarget.setPointerCapture(event.pointerId)\n    } catch {\n      // Capture is an optimization (drag survives leaving the handle);\n      // the resize itself works without it.\n    }\n    // Direction-aware (§10): the panel anchors at inline-end, so under\n    // RTL it sits left and dragging away from it inverts sign. Pointer\n    // math is physical; the sign carries the writing direction.\n    const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'\n    dragRef.current = { startX: event.clientX, startWidth: effectiveWidth, sign: rtl ? -1 : 1 }\n    setDragging(true)\n  }\n\n  const moveResize = (event: React.PointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current\n    if (!drag) return\n    const raw = drag.startWidth + drag.sign * (drag.startX - event.clientX)\n    const max = Math.min(SHEET_WIDTH_MAX, Math.max(SHEET_WIDTH_MIN, regionWidth - REGION_MARGIN))\n    setCustomWidth(Math.round(Math.min(max, Math.max(SHEET_WIDTH_MIN, raw))))\n  }\n\n  const endResize = () => {\n    if (!dragRef.current) return\n    dragRef.current = null\n    setDragging(false)\n    if (postureKey && customWidth !== null) {\n      window.localStorage.setItem(widthStorageKey(postureKey, focus), String(customWidth))\n    }\n  }\n\n  const nudgeResize = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const delta =\n      event.key === 'ArrowLeft' ? 16 : event.key === 'ArrowRight' ? -16 : null\n    if (delta === null) return\n    event.preventDefault()\n    const max = Math.min(SHEET_WIDTH_MAX, Math.max(SHEET_WIDTH_MIN, regionWidth - REGION_MARGIN))\n    const next = Math.min(max, Math.max(SHEET_WIDTH_MIN, effectiveWidth + delta))\n    setCustomWidth(next)\n    if (postureKey) {\n      window.localStorage.setItem(widthStorageKey(postureKey, focus), String(next))\n    }\n  }\n\n  const swapping = sheetKey !== contentKey\n\n  const panel = mounted ? (\n    <aside\n      data-state={open ? 'open' : 'closed'}\n      data-dragging={dragging || undefined}\n      aria-label={title}\n      className={cn(\n        // One panel grammar everywhere: rounded, hairline, 72px header\n        // band — a floating panel above the room, wearing the stage\n        // shadow at every size.\n        'sheet-panel relative flex h-full min-h-0 flex-col overflow-hidden',\n        mode === 'takeover'\n          ? 'border-0 bg-surface'\n          : 'rounded-md border border-hairline bg-surface shadow-stage'\n      )}\n      style={{ width: mode === 'takeover' ? '100%' : effectiveWidth }}\n    >\n      {mode === 'overlay' ? (\n        // The handle is a citizen: role=\"separator\", focusable, ←/→\n        // nudges 16px, drag pins to the pointer (the width transition\n        // suspends while dragging via data-dragging).\n        <div\n          role=\"separator\"\n          aria-orientation=\"vertical\"\n          aria-label={resizeLabel}\n          tabIndex={0}\n          onPointerDown={beginResize}\n          onPointerMove={moveResize}\n          onPointerUp={endResize}\n          onPointerCancel={endResize}\n          onKeyDown={nudgeResize}\n          className=\"group absolute inset-y-0 start-0 z-rail flex w-3 cursor-col-resize items-center justify-center touch-none\"\n        >\n          <div\n            className={cn(\n              'h-10 w-[3px] rounded-full transition-colors',\n              dragging ? 'bg-ink-mute' : 'bg-transparent group-hover:bg-ink-faint/60 group-focus-visible:bg-ink-faint/60'\n            )}\n          />\n        </div>\n      ) : null}\n      <header\n        data-optical /* a fixed band (§2.6a): its line centers optically */\n        className=\"relative flex shrink-0 items-center justify-between gap-3 overflow-hidden hairline-b px-5\"\n        style={{\n          height: SHEET_HEADER_H,\n          ...(accent ? { backgroundColor: `${accent}1a` } : undefined),\n        }}\n      >\n        <h2\n          ref={titleRef}\n          tabIndex={-1}\n          className=\"truncate text-title font-semibold tracking-title text-ink outline-none\"\n        >\n          {title}\n        </h2>\n        <div className=\"flex shrink-0 items-center gap-1\">\n          {headerActions}\n          {postureKey && mode !== 'takeover' ? (\n            <button\n              type=\"button\"\n              onClick={() => setPosture(!focus)}\n              aria-label={focus ? lensLabel : focusLabel}\n              aria-pressed={focus}\n              className=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-sm text-ink-faint transition-colors hover:bg-fill-hover hover:text-ink\"\n            >\n              {focus ? <Minimize2 className=\"h-4 w-4\" /> : <Maximize2 className=\"h-4 w-4\" />}\n            </button>\n          ) : null}\n          <button\n            type=\"button\"\n            onClick={onClose}\n            aria-label={closeLabel}\n            className=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-sm text-ink-faint transition-colors hover:bg-fill-hover hover:text-ink\"\n          >\n            <X className=\"h-4 w-4\" />\n          </button>\n        </div>\n      </header>\n      <div\n        className={cn(\n          'min-h-0 flex-1 overflow-y-auto scrollbar-custom transition-opacity duration-instant',\n          // The focus posture breathes wider than the lens — a desk,\n          // not a strip.\n          postureKey && focus && mode !== 'takeover' ? 'px-7 py-6' : 'px-5 py-4',\n          swapping ? 'opacity-0' : 'opacity-100'\n        )}\n      >\n        {sheet}\n      </div>\n    </aside>\n  ) : null\n\n  // ONE mode family: content keeps full width; the sheet floats above it\n  // with the scrim. The layout behind never reflows.\n  return (\n    <div ref={regionRef} className={cn('relative h-full min-h-0', className)}>\n      <div className=\"h-full min-h-0\">{children}</div>\n      {mounted ? (\n        <>\n          <button\n            type=\"button\"\n            aria-label={closeLabel}\n            tabIndex={-1}\n            onClick={onClose}\n            className={cn(\n              'absolute inset-0 z-float cursor-default bg-stage/60 backdrop-blur-[1px] transition-opacity duration-ui',\n              open ? 'opacity-100' : 'pointer-events-none opacity-0'\n            )}\n          />\n          <div\n            onTransitionEnd={() => {\n              if (!open) setMounted(false)\n            }}\n            className={cn(\n              'absolute inset-y-0 end-0 z-float transition-transform duration-ui',\n              mode === 'takeover' && 'start-0',\n              open ? 'translate-x-0' : 'translate-x-full'\n            )}\n            style={{ transitionTimingFunction: 'var(--ease-stage)' }}\n          >\n            {panel}\n          </div>\n        </>\n      ) : null}\n    </div>\n  )\n}\n",
      "target": "components/ui/Sheet.tsx"
    }
  ],
  "meta": {
    "candlelight": {
      "category": "primitive",
      "gRules": [
        "G1",
        "G7"
      ],
      "useWhen": "Editing or inspecting the thing selected from an adjacent list.",
      "dontUseWhen": "Blocking decisions (Overlay) or whole workflows (a route).",
      "states": [
        "closed",
        "open lens (340px)",
        "open focus (560px+)",
        "takeover (<700px container)",
        "dragging the handle",
        "content swapping (120ms crossfade)"
      ],
      "keyboard": [
        {
          "keys": "Esc",
          "does": "steps focus → lens → closed"
        },
        {
          "keys": "←/→ on the handle",
          "does": "nudges width 16px"
        }
      ],
      "traps": [
        "Cold-mount focus: focus the title only after the panel exists (key the effect on mounted, not just open).",
        "Focus steal: the title-focus effect must never depend on callback props — an unstable onClose would re-fire it on every continuous save."
      ],
      "acceptanceTests": [
        "The layout behind never reflows (push mode is dead).",
        "Arming a different object swaps content in place — the drawer does not close/reopen.",
        "The handle is a citizen: role=separator, focusable, ←/→ nudges, width remembered per key per posture.",
        "Closing is announced with Undo when local state dies, never blocked by a dialog."
      ]
    }
  },
  "registryDependencies": [
    "utils"
  ]
}