{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["pages","skeuomorphism"],"dependencies":["gsap","lucide-react"],"description":"A desktop boots itself — one card riffles through every thumbnail, icons burst out from centre, then the dock magnifies under the cursor and windows drag, stack and pop.","devDependencies":[],"files":[{"content":"import type { ReactNode } from \"react\";\n\n/**\n * Hand-authored app / file artwork.\n *\n * Every icon is a CSS-gradient \"squircle\" (macOS uses ~22.4% corner radius,\n * expressed as a percentage so it survives the dock's width/height lerp) with a\n * flat inline-SVG glyph on top. Nothing here is a third-party mark and nothing\n * loads over the network.\n */\n\nexport type GlyphId = \"files\" | \"preview\" | \"photos\" | \"notes\" | \"terminal\" | \"trash\" | \"folder\";\n\nconst SQUIRCLE_RADIUS = \"22.4%\";\n\nconst SQUIRCLE_SHADOW =\n  \"inset 0 1.5% 0 rgba(255,255,255,0.45), inset 0 -2% 0 rgba(0,0,0,0.18), 0 2% 6% rgba(0,0,0,0.28)\";\n\nconst Squircle = ({\n  background,\n  children,\n}: {\n  background: string;\n  children: ReactNode;\n}): ReactNode => (\n  <div\n    className=\"relative size-full\"\n    style={{\n      background,\n      borderRadius: SQUIRCLE_RADIUS,\n      boxShadow: SQUIRCLE_SHADOW,\n    }}\n  >\n    <svg viewBox=\"0 0 64 64\" className=\"absolute inset-0 size-full\" aria-hidden=\"true\">\n      {children}\n    </svg>\n  </div>\n);\n\nconst PINWHEEL = [\n  { angle: 0, fill: \"#f2b33d\" },\n  { angle: 45, fill: \"#ef6b4b\" },\n  { angle: 90, fill: \"#e34b8a\" },\n  { angle: 135, fill: \"#a05ad6\" },\n  { angle: 180, fill: \"#4f7fe0\" },\n  { angle: 225, fill: \"#35b6d8\" },\n  { angle: 270, fill: \"#45c37a\" },\n  { angle: 315, fill: \"#b9d34a\" },\n] as const;\n\nconst GLYPHS = {\n  files: (\n    <Squircle background=\"linear-gradient(165deg, #6fc0ff 0%, #2f7fe6 52%, #1f57b8 100%)\">\n      <path\n        d=\"M14 22h13l4 5h19a3 3 0 0 1 3 3v14a3 3 0 0 1-3 3H14a3 3 0 0 1-3-3V25a3 3 0 0 1 3-3Z\"\n        fill=\"rgba(255,255,255,0.94)\"\n      />\n      <path d=\"M11 32h42v12a3 3 0 0 1-3 3H14a3 3 0 0 1-3-3V32Z\" fill=\"rgba(255,255,255,0.72)\" />\n    </Squircle>\n  ),\n  folder: (\n    <svg viewBox=\"0 0 64 64\" className=\"size-full\" aria-hidden=\"true\">\n      <path\n        d=\"M6 16h18l5 6h29a3 3 0 0 1 3 3v24a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V19a3 3 0 0 1 3-3Z\"\n        fill=\"#5aa7ef\"\n      />\n      <path d=\"M3 27h58v22a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V27Z\" fill=\"#82c2f8\" />\n      <path d=\"M3 27h58v3H3z\" fill=\"rgba(255,255,255,0.45)\" />\n    </svg>\n  ),\n  notes: (\n    <Squircle background=\"linear-gradient(165deg, #fff0b0 0%, #f7d24a 46%, #eab61f 100%)\">\n      <rect x=\"12\" y=\"12\" width=\"40\" height=\"10\" rx=\"2\" fill=\"rgba(255,255,255,0.85)\" />\n      {[29, 36, 43, 50].map((y) => (\n        <rect\n          key={y}\n          x=\"17\"\n          y={y}\n          width={y === 50 ? 20 : 30}\n          height=\"2.6\"\n          rx=\"1.3\"\n          fill=\"rgba(0,0,0,0.24)\"\n        />\n      ))}\n    </Squircle>\n  ),\n  photos: (\n    <Squircle background=\"linear-gradient(165deg, #ffffff 0%, #e9e6df 100%)\">\n      {PINWHEEL.map((petal) => (\n        <ellipse\n          key={petal.angle}\n          cx=\"32\"\n          cy=\"21\"\n          rx=\"6.5\"\n          ry=\"12\"\n          fill={petal.fill}\n          opacity=\"0.88\"\n          transform={`rotate(${petal.angle} 32 32)`}\n        />\n      ))}\n      <circle cx=\"32\" cy=\"32\" r=\"4.2\" fill=\"rgba(255,255,255,0.85)\" />\n    </Squircle>\n  ),\n  preview: (\n    <Squircle background=\"linear-gradient(165deg, #f6f4ee 0%, #d9d5cb 100%)\">\n      <rect\n        x=\"13\"\n        y=\"14\"\n        width=\"30\"\n        height=\"34\"\n        rx=\"3\"\n        fill=\"#ffffff\"\n        stroke=\"rgba(0,0,0,0.16)\"\n        strokeWidth=\"1.4\"\n      />\n      <path d=\"M17 41l7-9 5 6 4-5 6 8H17Z\" fill=\"#7fb2e8\" />\n      <circle cx=\"35\" cy=\"22\" r=\"3.2\" fill=\"#f2b33d\" />\n      <circle\n        cx=\"41\"\n        cy=\"40\"\n        r=\"9.5\"\n        fill=\"rgba(255,255,255,0.35)\"\n        stroke=\"#2f6fd0\"\n        strokeWidth=\"3\"\n      />\n      <path d=\"M48 47l6.5 6.5\" stroke=\"#2f6fd0\" strokeWidth=\"4\" strokeLinecap=\"round\" />\n    </Squircle>\n  ),\n  terminal: (\n    <Squircle background=\"linear-gradient(165deg, #4a4a52 0%, #1d1d22 40%, #0b0b0e 100%)\">\n      <rect x=\"9\" y=\"9\" width=\"46\" height=\"46\" rx=\"8\" fill=\"rgba(255,255,255,0.04)\" />\n      <path\n        d=\"M18 24l8 8-8 8\"\n        stroke=\"#ffffff\"\n        strokeWidth=\"3.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        fill=\"none\"\n      />\n      <path d=\"M31 42h15\" stroke=\"#ffffff\" strokeWidth=\"3.4\" strokeLinecap=\"round\" />\n    </Squircle>\n  ),\n  trash: (\n    <Squircle background=\"linear-gradient(165deg, rgba(255,255,255,0.55) 0%, rgba(220,222,228,0.42) 100%)\">\n      <path\n        d=\"M20 22h24l-2.4 28a4 4 0 0 1-4 3.6H26.4a4 4 0 0 1-4-3.6L20 22Z\"\n        fill=\"rgba(255,255,255,0.5)\"\n        stroke=\"rgba(40,44,54,0.65)\"\n        strokeWidth=\"2.2\"\n      />\n      <path d=\"M15 20h34\" stroke=\"rgba(40,44,54,0.75)\" strokeWidth=\"3\" strokeLinecap=\"round\" />\n      <path d=\"M26 15h12\" stroke=\"rgba(40,44,54,0.75)\" strokeWidth=\"3\" strokeLinecap=\"round\" />\n      {[27, 32, 37].map((x) => (\n        <path\n          key={x}\n          d={`M${x} 29v17`}\n          stroke=\"rgba(40,44,54,0.45)\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n        />\n      ))}\n    </Squircle>\n  ),\n} satisfies Record<GlyphId, ReactNode>;\n\n/**\n * Fills its square container edge to edge — the dock magnification lerps the\n * wrapper's width/height, so the artwork must honour the `size-full` contract.\n */\nexport const AppGlyph = ({ id }: { id: GlyphId }): ReactNode => GLYPHS[id];\n\n/* --------------------------------------------------------- status glyphs -- */\n\nexport const MacBatteryGlyph = (): ReactNode => (\n  <svg width=\"24\" height=\"12\" viewBox=\"0 0 24 12\" fill=\"none\" aria-hidden=\"true\">\n    <rect\n      x=\"0.5\"\n      y=\"1.5\"\n      width=\"19\"\n      height=\"9\"\n      rx=\"2.4\"\n      stroke=\"currentColor\"\n      strokeOpacity=\"0.55\"\n    />\n    <rect x=\"2\" y=\"3\" width=\"13\" height=\"6\" rx=\"1.2\" fill=\"currentColor\" />\n    <path\n      d=\"M21.5 4.2 v3.6 a1.6 1.6 0 0 0 1.2 -1.8 a1.6 1.6 0 0 0 -1.2 -1.8 Z\"\n      fill=\"currentColor\"\n      fillOpacity=\"0.55\"\n    />\n  </svg>\n);\n\nexport const SignalBarsGlyph = (): ReactNode => (\n  <svg width=\"17\" height=\"11\" viewBox=\"0 0 17 11\" fill=\"currentColor\" aria-hidden=\"true\">\n    <rect x=\"0\" y=\"8\" width=\"2.6\" height=\"3\" rx=\"0.6\" />\n    <rect x=\"4.6\" y=\"6\" width=\"2.6\" height=\"5\" rx=\"0.6\" />\n    <rect x=\"9.2\" y=\"3.5\" width=\"2.6\" height=\"7.5\" rx=\"0.6\" />\n    <rect x=\"13.8\" y=\"0.5\" width=\"2.6\" height=\"10.5\" rx=\"0.6\" />\n  </svg>\n);\n\nexport const WifiGlyph = (): ReactNode => (\n  <svg\n    width=\"15\"\n    height=\"11\"\n    viewBox=\"0 0 15 11\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.4\"\n    strokeLinecap=\"round\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M1.4 3.6 A9 9 0 0 1 13.6 3.6\" />\n    <path d=\"M3.6 6 A5.6 5.6 0 0 1 11.4 6\" />\n    <path d=\"M5.9 8.4 A2.3 2.3 0 0 1 9.1 8.4\" />\n    <circle cx=\"7.5\" cy=\"9.8\" r=\"0.5\" fill=\"currentColor\" stroke=\"none\" />\n  </svg>\n);\n\nexport const IOSBatteryGlyph = (): ReactNode => (\n  <svg width=\"28\" height=\"13\" viewBox=\"0 0 28 13\" fill=\"none\" aria-hidden=\"true\">\n    <rect\n      x=\"0.6\"\n      y=\"0.6\"\n      width=\"24\"\n      height=\"11.8\"\n      rx=\"3.4\"\n      stroke=\"currentColor\"\n      strokeOpacity=\"0.55\"\n    />\n    <rect x=\"2.1\" y=\"2.1\" width=\"21\" height=\"8.8\" rx=\"2\" fill=\"currentColor\" />\n    <path d=\"M26.4 4 v5 a2.1 2.1 0 0 0 0 -5 Z\" fill=\"currentColor\" fillOpacity=\"0.55\" />\n  </svg>\n);\n\nexport const BellSlashGlyph = (): ReactNode => (\n  <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" aria-hidden=\"true\">\n    <path d=\"M3 10 h8 l-1 -1.5 V6 a3 3 0 0 0 -6 0 v2.5 Z\" fill=\"currentColor\" />\n    <path\n      d=\"M5.6 11 a1.4 1.4 0 0 0 2.8 0\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.1\"\n      fill=\"none\"\n      strokeLinecap=\"round\"\n    />\n    <line\n      x1=\"1.6\"\n      y1=\"12.4\"\n      x2=\"12.4\"\n      y2=\"1.6\"\n      stroke=\"#000000\"\n      strokeWidth=\"2.4\"\n      strokeLinecap=\"round\"\n    />\n    <line\n      x1=\"1.6\"\n      y1=\"12.4\"\n      x2=\"12.4\"\n      y2=\"1.6\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.1\"\n      strokeLinecap=\"round\"\n    />\n  </svg>\n);\n","path":"/app-icons.tsx","target":"uicapsule/desktop-os/app-icons.tsx","type":"registry:file"},{"content":"/**\n * All static content for the desktop simulation.\n *\n * Every image is a remote Unsplash CDN derivative — the package ships no binary\n * assets and the wallpaper is pure CSS, so there is nothing to host.\n */\n\nconst unsplash = (id: string, width: number): string =>\n  `https://images.unsplash.com/${id}?w=${width}&q=75&auto=format&fit=crop`;\n\n/* ------------------------------------------------------------------ files -- */\n\nexport interface SeedPos {\n  readonly xPct: number;\n  readonly yPct: number;\n}\n\nexport interface DesktopFile {\n  readonly id: string;\n  readonly name: string;\n  /** Small derivative — desktop tile + boot reel. */\n  readonly thumb: string;\n  /** Large derivative — Quick Look + lightbox. */\n  readonly full: string;\n  readonly dimensions: string;\n  /** Hand-tuned percentage seed so tiles never collide at any frame size. */\n  readonly pos: SeedPos;\n}\n\n/**\n * A tuple (not a plain array) so `FILES[0]` is statically known to exist under\n * `noUncheckedIndexedAccess`.\n */\nexport const FILES = [\n  {\n    dimensions: \"4000 × 2667\",\n    full: unsplash(\"photo-1506744038136-46273834b3fb\", 1600),\n    id: \"wetland-dusk\",\n    name: \"Wetland Dusk.jpg\",\n    pos: { xPct: 8, yPct: 14 },\n    thumb: unsplash(\"photo-1506744038136-46273834b3fb\", 400),\n  },\n  {\n    dimensions: \"3840 × 2560\",\n    full: unsplash(\"photo-1470071459604-3b5ec3a7fe05\", 1600),\n    id: \"fog-pines\",\n    name: \"Fog Pines.jpg\",\n    pos: { xPct: 22, yPct: 22 },\n    thumb: unsplash(\"photo-1470071459604-3b5ec3a7fe05\", 400),\n  },\n  {\n    dimensions: \"3600 × 2400\",\n    full: unsplash(\"photo-1500534314209-a25ddb2bd429\", 1600),\n    id: \"coast-road\",\n    name: \"Coast Road.jpg\",\n    pos: { xPct: 38, yPct: 12 },\n    thumb: unsplash(\"photo-1500534314209-a25ddb2bd429\", 400),\n  },\n  {\n    dimensions: \"5184 × 3456\",\n    full: unsplash(\"photo-1441974231531-c6227db76b6e\", 1600),\n    id: \"sunlit-grove\",\n    name: \"Sunlit Grove.jpg\",\n    pos: { xPct: 56, yPct: 18 },\n    thumb: unsplash(\"photo-1441974231531-c6227db76b6e\", 400),\n  },\n  {\n    dimensions: \"4288 × 2848\",\n    full: unsplash(\"photo-1518837695005-2083093ee35b\", 1600),\n    id: \"open-water\",\n    name: \"Open Water.jpg\",\n    pos: { xPct: 72, yPct: 26 },\n    thumb: unsplash(\"photo-1518837695005-2083093ee35b\", 400),\n  },\n  {\n    dimensions: \"3888 × 2592\",\n    full: unsplash(\"photo-1439066615861-d1af74d74000\", 1600),\n    id: \"salt-flats\",\n    name: \"Salt Flats.jpg\",\n    pos: { xPct: 14, yPct: 38 },\n    thumb: unsplash(\"photo-1439066615861-d1af74d74000\", 400),\n  },\n  {\n    dimensions: \"4608 × 3072\",\n    full: unsplash(\"photo-1493246507139-91e8fad9978e\", 1600),\n    id: \"aurora-fjord\",\n    name: \"Aurora Fjord.jpg\",\n    pos: { xPct: 30, yPct: 46 },\n    thumb: unsplash(\"photo-1493246507139-91e8fad9978e\", 400),\n  },\n  {\n    dimensions: \"5472 × 3648\",\n    full: unsplash(\"photo-1470252649378-9c29740c9fa8\", 1600),\n    id: \"alpine-lake\",\n    name: \"Alpine Lake.jpg\",\n    pos: { xPct: 46, yPct: 42 },\n    thumb: unsplash(\"photo-1470252649378-9c29740c9fa8\", 400),\n  },\n  {\n    dimensions: \"4272 × 2848\",\n    full: unsplash(\"photo-1447752875215-b2761acb3c5d\", 1600),\n    id: \"forest-path\",\n    name: \"Forest Path.jpg\",\n    pos: { xPct: 62, yPct: 50 },\n    thumb: unsplash(\"photo-1447752875215-b2761acb3c5d\", 400),\n  },\n  {\n    dimensions: \"5760 × 3840\",\n    full: unsplash(\"photo-1426604966848-d7adac402bff\", 1600),\n    id: \"green-valley\",\n    name: \"Green Valley.jpg\",\n    pos: { xPct: 78, yPct: 44 },\n    thumb: unsplash(\"photo-1426604966848-d7adac402bff\", 400),\n  },\n] as const satisfies readonly DesktopFile[];\n\nexport const [HERO_FILE] = FILES;\n\nexport const findFile = (id: string | undefined): DesktopFile | undefined =>\n  FILES.find((file) => file.id === id);\n\n/* ------------------------------------------------------------------ tiles -- */\n\nexport type DesktopTile =\n  | { readonly kind: \"file\"; readonly file: DesktopFile }\n  | {\n      readonly kind: \"folder\";\n      readonly id: string;\n      readonly name: string;\n      readonly pos: SeedPos;\n    };\n\nexport const tileId = (tile: DesktopTile): string =>\n  tile.kind === \"file\" ? tile.file.id : tile.id;\n\nexport const tileName = (tile: DesktopTile): string =>\n  tile.kind === \"file\" ? tile.file.name : tile.name;\n\nexport const tilePos = (tile: DesktopTile): SeedPos =>\n  tile.kind === \"file\" ? tile.file.pos : tile.pos;\n\nexport const TILES: readonly DesktopTile[] = [\n  ...FILES.map((file): DesktopTile => ({ file, kind: \"file\" })),\n  { id: \"archive\", kind: \"folder\", name: \"Archive\", pos: { xPct: 6, yPct: 60 } },\n];\n\n/* ----------------------------------------------------------------- photos -- */\n\nexport interface DesktopPhoto {\n  readonly src: string;\n  readonly full: string;\n  readonly ratio: \"tall\" | \"wide\" | \"square\";\n  readonly caption: string;\n}\n\nconst photo = (id: string, ratio: DesktopPhoto[\"ratio\"], caption: string): DesktopPhoto => ({\n  caption,\n  full: unsplash(id, 1600),\n  ratio,\n  src: unsplash(id, 600),\n});\n\nexport const PHOTOS: readonly DesktopPhoto[] = [\n  photo(\"photo-1501785888041-af3ef285b470\", \"wide\", \"Shoreline, low tide\"),\n  photo(\"photo-1472214103451-9374bd1c798e\", \"tall\", \"Ridge line\"),\n  photo(\"photo-1444927714506-8492d94b4e3d\", \"tall\", \"Meadow, mid-morning\"),\n  photo(\"photo-1475924156734-496f6cac6ec1\", \"tall\", \"Wildflowers\"),\n  photo(\"photo-1490750967868-88aa4486c946\", \"tall\", \"Field lines\"),\n  photo(\"photo-1519681393784-d120267933ba\", \"wide\", \"Night sky, north face\"),\n  photo(\"photo-1507525428034-b723cf961d3e\", \"tall\", \"Warm sand\"),\n  photo(\"photo-1454391304352-2bf4678b1a7a\", \"wide\", \"Workbench\"),\n  photo(\"photo-1465101162946-4377e57745c3\", \"tall\", \"First light\"),\n];\n\n/* ------------------------------------------------------------------ notes -- */\n\nexport interface DesktopNote {\n  readonly id: string;\n  readonly title: string;\n  readonly date: string;\n  readonly preview: string;\n  readonly body: string;\n}\n\nexport const NOTES = [\n  {\n    body: `Enter 320ms, exit 180ms. Exits are always faster — the user has already decided.\n\nAnything under 90ms reads as an instant state change, not a transition. Anything over 500ms reads as a wait.\n\nBlur is expensive. Scale and opacity are free. Spend the blur where it is the whole point.`,\n    date: \"Today\",\n    id: \"timings\",\n    preview: \"Enter 320ms, exit 180ms. Exits are always faster.\",\n    title: \"Motion timings\",\n  },\n  {\n    body: `Pick the hue first, then the chroma, then the lightness. In that order, every time.\n\nWarm grey and cold grey are not interchangeable. One of them is wrong for the surface you are on.\n\nTwo accents maximum. A third accent is a decision you have not made yet.`,\n    date: \"Today\",\n    id: \"palette\",\n    preview: \"Warm grey vs cold grey is a whole mood.\",\n    title: \"Palette notes\",\n  },\n  {\n    body: `Keyboard path end to end.\nReduced motion honoured.\nEmpty state drawn, not forgotten.\n320px wide without a horizontal scrollbar.\nFocus ring visible on every interactive thing.\nNothing depends on hover alone.`,\n    date: \"Yesterday\",\n    id: \"checklist\",\n    preview: \"Keyboard, reduced motion, empty state, 320px.\",\n    title: \"Shipping checklist\",\n  },\n  {\n    body: `Spring constants — the gap between motion that feels cheap and motion that feels expensive is about three numbers.\n\nOptical alignment beats geometric alignment whenever a shape has a point on it.\n\nSubpixel text rendering changes when you promote a layer. Know which of your layers are promoted.`,\n    date: \"Mar 02\",\n    id: \"read-later\",\n    preview: \"Spring constants, optical alignment, subpixel text.\",\n    title: \"Read later\",\n  },\n] as const satisfies readonly DesktopNote[];\n\nexport const [FIRST_NOTE] = NOTES;\n\n/* ------------------------------------------------------------------- dock -- */\n\nexport type DockAppId = \"files\" | \"preview\" | \"photos\" | \"notes\" | \"terminal\" | \"trash\";\n\nexport interface DockApp {\n  readonly id: DockAppId;\n  readonly name: string;\n}\n\nexport const DOCK_APPS: readonly DockApp[] = [\n  { id: \"files\", name: \"Files\" },\n  { id: \"preview\", name: \"Preview\" },\n  { id: \"photos\", name: \"Photos\" },\n  { id: \"notes\", name: \"Notes\" },\n  { id: \"terminal\", name: \"Terminal\" },\n  { id: \"trash\", name: \"Trash\" },\n];\n\nexport const MOBILE_PRIMARY: readonly DockAppId[] = [\"files\", \"photos\", \"notes\"];\nexport const GROUP_APPS: readonly DockAppId[] = [\"preview\", \"terminal\", \"trash\"];\n\n/* -------------------------------------------------------------- wallpaper -- */\n\n/**\n * Pure-CSS wallpaper. The thin conic rays are what makes the boot's\n * `blur(24px) → blur(0)` legible on a gradient.\n *\n * Deliberately fixed rather than theme-reactive: the menu bar, dock tooltips\n * and icon labels are all white (as on macOS, where the wallpaper is chosen by\n * the user and does not invert with the system theme). A pale wallpaper drops\n * that chrome to ~1.8:1 contrast, and darkening one far enough to carry white\n * text just reproduces this gradient.\n */\nexport const WALLPAPER = [\n  \"repeating-conic-gradient(from 196deg at 50% 118%, rgba(255,255,255,0.045) 0deg 1.6deg, rgba(255,255,255,0) 1.6deg 7deg)\",\n  \"radial-gradient(120% 82% at 20% 8%, #2b3d74 0%, rgba(43,61,116,0) 58%)\",\n  \"radial-gradient(110% 88% at 82% 4%, #4a2a63 0%, rgba(74,42,99,0) 54%)\",\n  \"radial-gradient(140% 110% at 50% 116%, #05070f 0%, rgba(5,7,15,0) 64%)\",\n  \"linear-gradient(158deg, #1d2647 0%, #2a2350 34%, #171634 66%, #08090f 100%)\",\n].join(\", \");\n\nexport const VEIL_GRADIENT =\n  \"radial-gradient(120% 90% at 50% 30%, rgba(0,0,0,0) 0%, rgba(0,0,0,0.18) 70%, rgba(0,0,0,0.35) 100%)\";\n","path":"/desktop-data.ts","target":"uicapsule/desktop-os/desktop-data.ts","type":"registry:file"},{"content":"\"use client\";\n\nimport type {\n  KeyboardEvent as ReactKeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n  ReactNode,\n} from \"react\";\n\nimport { Fragment, useCallback, useEffect, useRef, useState } from \"react\";\nimport { gsap } from \"gsap\";\n\nimport { useReducedMotion } from \"./use-reduced-motion\";\n\nimport type { DesktopTile, DockAppId } from \"./desktop-data\";\nimport type { OpenWindow, WindowCtx, WindowKind } from \"./window-types\";\n\nimport {\n  AppGlyph,\n  BellSlashGlyph,\n  IOSBatteryGlyph,\n  MacBatteryGlyph,\n  SignalBarsGlyph,\n  WifiGlyph,\n} from \"./app-icons\";\nimport {\n  DOCK_APPS,\n  FILES,\n  GROUP_APPS,\n  HERO_FILE,\n  MOBILE_PRIMARY,\n  TILES,\n  VEIL_GRADIENT,\n  WALLPAPER,\n  tileId,\n  tileName,\n  tilePos,\n} from \"./desktop-data\";\nimport { WindowView } from \"./window-bodies\";\nimport { PhotoLightbox } from \"./window-frame\";\nimport { DEFAULT_WINDOW_SIZES } from \"./window-types\";\n\n/* ---------------------------------------------------------------- tuning -- */\n\nconst DOCK_ICON_BASE = 56;\nconst DOCK_ICON_MAX = 96;\nconst DOCK_HOVER_RANGE = 140;\nconst DOCK_SIZE_LERP = 0.28;\n/**\n * Second-order smoothing on the cursor itself. Together with the per-icon size\n * lerp this is what separates the dock from a CSS `:hover { scale }`.\n */\nconst DOCK_CURSOR_LERP = 0.22;\nconst MOBILE_ICON_BASE = 72;\n\nconst TILE_SIZE = 78;\n/** The springboard grid draws the same tile artwork smaller. */\nconst MOBILE_TILE_SIZE = 64;\nconst FEATURED_SIZE = 180;\n/** Breathing room kept between a tile's box and the frame edge or the dock. */\nconst TILE_EDGE_MARGIN = 4;\n/**\n * The desktop tile's box when no element is available to measure (the seed pass\n * runs for both layouts, and the mobile tree renders a different-sized tile):\n * the wrapper's rendered width, and `TILE_SIZE` plus the 8px gap and the single\n * 11px/1.25 name label beneath it.\n */\nconst TILE_BOX_W = 96;\nconst TILE_BOX_H = TILE_SIZE + 24;\n/** Boot \"slot machine\" reel. */\nconst REEL_FRAME_MS = 100;\nconst REEL_DELAY_MS = 250;\n/** Hard cap on waiting for the thumbnail preload before booting anyway. */\nconst PRELOAD_CAP_MS = 1400;\n\nconst HERO_ID = HERO_FILE.id;\n\nconst clamp = (value: number, min: number, max: number): number =>\n  Math.min(Math.max(value, min), max);\n\nconst topZ = (wins: readonly OpenWindow[]): number => Math.max(100, ...wins.map((win) => win.z));\n\nconst topWindow = (wins: readonly OpenWindow[]): OpenWindow | undefined => {\n  let top: OpenWindow | undefined;\n  for (const win of wins) {\n    if (top === undefined || win.z > top.z) {\n      top = win;\n    }\n  }\n  return top;\n};\n\nconst DOCK_SHADOW =\n  \"0 30px 80px -20px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.32), inset 0 -1px 0 rgba(0,0,0,0.18)\";\n\n/**\n * The one place this component needs real CSS: scrollbar colouring inside the\n * scrollable surfaces (light and dark variants), and the terminal caret blink.\n */\nconst SCOPED_CSS = `\n.dos-scroll { scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.22) transparent; }\n.dos-scroll::-webkit-scrollbar { width: 11px; height: 11px; }\n.dos-scroll::-webkit-scrollbar-track { background: transparent; }\n.dos-scroll::-webkit-scrollbar-thumb {\n  background: rgba(0,0,0,0.22);\n  border-radius: 999px;\n  border: 3px solid transparent;\n  background-clip: content-box;\n}\n/* Same metrics, inverted thumb — for the terminal and the mobile springboard,\n   which scroll over near-black surfaces. */\n.dos-scroll-dark { scrollbar-color: rgba(255,255,255,0.22) transparent; }\n.dos-scroll-dark::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.22); background-clip: content-box; }\n@keyframes dos-blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }\n.dos-caret { animation: dos-blink 1.06s steps(1, end) infinite; }\n@media (prefers-reduced-motion: reduce) { .dos-caret { animation: none } }\n`;\n\n/* ----------------------------------------------------------------- state -- */\n\ninterface TilePosition {\n  x: number;\n  y: number;\n  z: number;\n}\n\ninterface DragState {\n  id: string | null;\n  startX: number;\n  startY: number;\n  originX: number;\n  originY: number;\n  moved: boolean;\n  pointerId: number;\n  el: HTMLDivElement | null;\n  /** Resolved once per gesture — the frame and dock cannot resize mid-drag. */\n  maxX: number;\n  maxY: number;\n  lastX: number;\n  lastY: number;\n}\n\nconst IDLE_DRAG: DragState = {\n  el: null,\n  id: null,\n  lastX: 0,\n  lastY: 0,\n  maxX: 0,\n  maxY: 0,\n  moved: false,\n  originX: 0,\n  originY: 0,\n  pointerId: -1,\n  startX: 0,\n  startY: 0,\n};\n\n/**\n * Wipe the gesture back to idle and hand back the element it owned so the caller\n * can drop its one imperative style. Every exit path routes through here, and it\n * resets *every* field: a gesture that leaves `lastX`/`moved` behind is what let\n * a later, unrelated release commit a stale position.\n */\nconst endTileGesture = (drag: DragState, pointerId: number): HTMLDivElement | null => {\n  const { el } = drag;\n  // Reset before releasing: the release schedules a `lostpointercapture` that\n  // routes into the cancel handler, which must find an already-idle gesture.\n  Object.assign(drag, IDLE_DRAG);\n  if (el && el.hasPointerCapture(pointerId)) {\n    el.releasePointerCapture(pointerId);\n  }\n\n  return el;\n};\n\ninterface OpenSpec {\n  kind: WindowKind;\n  fileId?: string;\n  folderName?: string;\n}\n\nexport const DesktopOS = (): ReactNode => {\n  const [mounted, setMounted] = useState(false);\n  const [isMobile, setIsMobile] = useState(false);\n  const [seeded, setSeeded] = useState(false);\n  const [tilePositions, setTilePositions] = useState<Record<string, TilePosition>>({});\n  const [selectedId, setSelectedId] = useState<string | null>(null);\n  const reducedMotion = useReducedMotion();\n  const [animatedEntranceDone, setAnimatedEntranceDone] = useState(false);\n  const entranceDone = reducedMotion || animatedEntranceDone;\n  const [windows, setWindows] = useState<readonly OpenWindow[]>([]);\n  const [lightbox, setLightbox] = useState<string | null>(null);\n  const [groupPanelOpen, setGroupPanelOpen] = useState(false);\n\n  const sectionRef = useRef<HTMLElement>(null);\n  const wallpaperWrapRef = useRef<HTMLDivElement>(null);\n  const menuBarRef = useRef<HTMLDivElement>(null);\n  const dockRef = useRef<HTMLDivElement>(null);\n  const featuredThumbRef = useRef<HTMLDivElement>(null);\n  const featuredImgRef = useRef<HTMLImageElement>(null);\n  const groupBackdropRef = useRef<HTMLDivElement>(null);\n  const groupPanelRef = useRef<HTMLDivElement>(null);\n\n  const tileRefs = useRef<Map<string, HTMLDivElement>>(new Map());\n  const tilePositionsRef = useRef<Record<string, TilePosition>>({});\n  const isMobileRef = useRef(false);\n  const winCounter = useRef(0);\n  const entranceStartedRef = useRef(false);\n\n  const dockIconEls = useRef<Map<DockAppId, HTMLDivElement>>(new Map());\n  const dockIconSizes = useRef<Map<DockAppId, number>>(new Map());\n  const dockCursor = useRef({ active: false, smooth: -9999, x: -9999 });\n  const dockSettling = useRef(false);\n  const rafId = useRef<number | null>(null);\n\n  const dragRef = useRef<DragState>({ ...IDLE_DRAG });\n\n  /* ------------------------------------------------------ window manager -- */\n\n  const openWindow = (spec: OpenSpec): void => {\n    const sec = sectionRef.current;\n    const sw = sec?.clientWidth ?? 1280;\n    const sh = sec?.clientHeight ?? 800;\n    const base = DEFAULT_WINDOW_SIZES[spec.kind];\n\n    let w: number;\n    let h: number;\n    let x: number;\n    let y: number;\n\n    if (isMobileRef.current) {\n      w = Math.max(200, sw - 32);\n      h = Math.max(220, sh - 60 - 128);\n      x = 16;\n      y = 60;\n    } else {\n      w = Math.min(base.w, Math.max(280, sw - 32));\n      // Reserve 140px of vertical slack so the 28px cascade below always has\n      // somewhere to go — otherwise every window opens at exactly y=28 in a\n      // short frame and two open windows stack pixel-perfect.\n      h = Math.min(base.h, Math.max(200, sh - 140));\n      const offset = Math.min(windows.length, 5) * 28;\n      x = clamp((sw - w) / 2 - 40 + offset, 8, Math.max(8, sw - w - 8));\n      y = clamp(Math.max(28, (sh - h) / 2 - 40) + offset, 28, Math.max(28, sh - h - 8));\n    }\n\n    const uid = winCounter.current + 1;\n    winCounter.current = uid;\n\n    setWindows((prev) => {\n      const z = topZ(prev) + 1;\n\n      return [\n        ...prev,\n        {\n          fileId: spec.fileId,\n          folderName: spec.folderName,\n          h,\n          kind: spec.kind,\n          uid,\n          w,\n          x,\n          y,\n          z,\n        },\n      ];\n    });\n  };\n\n  const closeWindow = useCallback((uid: number): void => {\n    setWindows((prev) => prev.filter((win) => win.uid !== uid));\n  }, []);\n\n  const focusWindow = (uid: number): void => {\n    setWindows((prev) => {\n      const max = topZ(prev);\n      // Every click inside a window asks for focus. Bailing when it is already\n      // frontmost keeps `z` bounded and lets React skip the re-render entirely.\n      if (prev.some((win) => win.uid === uid && win.z === max)) {\n        return prev;\n      }\n\n      return prev.map((win) => (win.uid === uid ? { ...win, z: max + 1 } : win));\n    });\n  };\n\n  const moveWindow = (uid: number, x: number, y: number): void => {\n    setWindows((prev) => prev.map((win) => (win.uid === uid ? { ...win, x, y } : win)));\n  };\n\n  const ctx: WindowCtx = {\n    isMobile,\n    openLightbox: (src) => setLightbox(src),\n    openQuickLook: (fileId) => openWindow({ fileId, kind: \"quicklook\" }),\n  };\n\n  const openTile = (tile: DesktopTile): void => {\n    if (tile.kind === \"folder\") {\n      openWindow({ folderName: tile.name, kind: \"finder\" });\n    } else {\n      openWindow({ fileId: tile.file.id, kind: \"quicklook\" });\n    }\n  };\n\n  const handleDockApp = (id: DockAppId): void => {\n    switch (id) {\n      case \"files\": {\n        openWindow({ folderName: \"Archive\", kind: \"finder\" });\n        break;\n      }\n      case \"preview\": {\n        openWindow({ fileId: HERO_ID, kind: \"quicklook\" });\n        break;\n      }\n      case \"photos\": {\n        openWindow({ kind: \"photos\" });\n        break;\n      }\n      case \"notes\": {\n        openWindow({ kind: \"notes\" });\n        break;\n      }\n      case \"terminal\": {\n        openWindow({ kind: \"terminal\" });\n        break;\n      }\n      case \"trash\": {\n        openWindow({ folderName: \"Trash\", kind: \"finder\" });\n        break;\n      }\n      default: {\n        break;\n      }\n    }\n  };\n\n  /* ------------------------------------------------------ tile positions -- */\n\n  /**\n   * How far a tile of the given box size may travel before it leaves the frame\n   * or slides under the dock. Derived from live geometry rather than baked\n   * constants so it holds at any frame size and for any dock height.\n   */\n  const tileBounds = useCallback((boxW: number, boxH: number) => {\n    const sec = sectionRef.current;\n    const vw = sec?.clientWidth ?? 1280;\n    const vh = sec?.clientHeight ?? 800;\n    const dock = dockRef.current;\n    // The dock is the only chrome that overlaps the tile field from below.\n    const floor =\n      sec && dock ? dock.getBoundingClientRect().top - sec.getBoundingClientRect().top : vh;\n\n    return {\n      maxX: Math.max(TILE_EDGE_MARGIN, vw - boxW - TILE_EDGE_MARGIN),\n      maxY: Math.max(TILE_EDGE_MARGIN, floor - boxH - TILE_EDGE_MARGIN),\n    };\n  }, []);\n\n  /**\n   * Percentage-seeded layout, re-clamped on every resize so icons can never\n   * leave an arbitrarily-sized frame. Shares `tileBounds` with the drag so a\n   * resize cannot nudge a tile the user just placed at the edge.\n   */\n  const seedPositions = useCallback((): void => {\n    const sec = sectionRef.current;\n    const vw = sec?.clientWidth ?? 1280;\n    const vh = sec?.clientHeight ?? 800;\n    const bounds = tileBounds(TILE_BOX_W, TILE_BOX_H);\n\n    setTilePositions((prev) => {\n      const next: Record<string, TilePosition> = {};\n\n      for (const [i, tile] of TILES.entries()) {\n        const id = tileId(tile);\n        const seed = tilePos(tile);\n        const existing = prev[id];\n        const baseX = existing ? existing.x : (seed.xPct / 100) * vw;\n        const baseY = existing ? existing.y : (seed.yPct / 100) * vh;\n        next[id] = {\n          // Same floor and ceiling the drag enforces, or a resize would nudge a\n          // tile the user had just parked against an edge.\n          x: clamp(baseX, TILE_EDGE_MARGIN, bounds.maxX),\n          y: clamp(baseY, TILE_EDGE_MARGIN, bounds.maxY),\n          z: existing ? existing.z : 10 + i,\n        };\n      }\n\n      return next;\n    });\n    setSeeded(true);\n  }, [tileBounds]);\n\n  /* ------------------------------------------------------ mount + resize -- */\n\n  useEffect(() => {\n    isMobileRef.current = window.matchMedia(\"(max-width: 767px)\").matches;\n    setIsMobile(isMobileRef.current);\n\n    // The boot timeline does not start until the thumbnail preload settles (up\n    // to PRELOAD_CAP_MS). Seed the wallpaper's pre-boot state here instead, or\n    // the first thing on screen is a crisp wallpaper that snaps to blur(24px).\n    const wrap = wallpaperWrapRef.current;\n    if (wrap && !window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      if (isMobileRef.current) {\n        gsap.set(wrap, { opacity: 0 });\n      } else {\n        gsap.set(wrap, { filter: \"blur(24px)\", scale: 1.04 });\n      }\n    }\n\n    setMounted(true);\n  }, []);\n\n  useEffect(() => {\n    if (!mounted) {\n      return;\n    }\n    seedPositions();\n\n    const mq = window.matchMedia(\"(max-width: 767px)\");\n    const onChange = (): void => {\n      isMobileRef.current = mq.matches;\n      setIsMobile(mq.matches);\n      seedPositions();\n    };\n    const onResize = (): void => seedPositions();\n\n    mq.addEventListener(\"change\", onChange);\n    window.addEventListener(\"resize\", onResize);\n\n    return () => {\n      mq.removeEventListener(\"change\", onChange);\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [mounted, seedPositions]);\n\n  // Mirror positions into a ref so the boot timeline can read them without\n  // taking `tilePositions` as a dependency — a tile drag must not replay boot.\n  useEffect(() => {\n    tilePositionsRef.current = tilePositions;\n  }, [tilePositions]);\n\n  /* ------------------------------------------------------- boot sequence -- */\n\n  useEffect(() => {\n    if (!mounted || !seeded) {\n      return;\n    }\n    if (entranceStartedRef.current) {\n      return;\n    }\n\n    const sec = sectionRef.current;\n    if (!sec) {\n      return;\n    }\n\n    entranceStartedRef.current = true;\n\n    const wrap = wallpaperWrapRef.current;\n    const menu = menuBarRef.current;\n    const dock = dockRef.current;\n    const thumb = featuredThumbRef.current;\n\n    let cancelled = false;\n    let timeline: ReturnType<typeof gsap.timeline> | null = null;\n    let reelTimeout: number | null = null;\n    let reelInterval: number | null = null;\n    let capTimeout: number | null = null;\n\n    if (reducedMotion) {\n      if (wrap) {\n        gsap.set(wrap, { filter: \"none\", opacity: 1, scale: 1 });\n      }\n      if (menu) {\n        gsap.set(menu, { opacity: 1, y: 0 });\n      }\n      if (dock) {\n        gsap.set(dock, { opacity: 1, y: 0 });\n      }\n      for (const el of tileRefs.current.values()) {\n        gsap.set(el, { opacity: 1 });\n      }\n      return () => {\n        entranceStartedRef.current = false;\n      };\n    }\n\n    const runMobile = (): void => {\n      const tl = gsap.timeline({ defaults: { ease: \"power3.out\" } });\n      timeline = tl;\n\n      if (wrap) {\n        tl.fromTo(wrap, { opacity: 0, scale: 1.04 }, { duration: 0.45, opacity: 1, scale: 1 }, 0);\n      }\n      if (menu) {\n        tl.fromTo(menu, { opacity: 0, y: -28 }, { duration: 0.45, opacity: 1, y: 0 }, 0.05);\n      }\n\n      for (const [i, tile] of TILES.entries()) {\n        const el = tileRefs.current.get(tileId(tile));\n        if (!el) {\n          continue;\n        }\n        tl.fromTo(\n          el,\n          { opacity: 0, scale: 0.94, y: 10 },\n          { duration: 0.5, opacity: 1, scale: 1, y: 0 },\n          0.2 + 0.04 * (i % 10),\n        );\n      }\n\n      if (dock) {\n        tl.fromTo(dock, { opacity: 0, y: 60 }, { duration: 0.5, opacity: 1, y: 0 }, 0.35);\n      }\n      tl.call(() => setAnimatedEntranceDone(true), undefined, 1);\n    };\n\n    const runDesktop = (): void => {\n      const positions = tilePositionsRef.current;\n      const sw = sec.clientWidth;\n      const sh = sec.clientHeight;\n      const centreX = sw / 2 - 48;\n      const centreY = sh / 2 - 58;\n      const heroPos = positions[HERO_ID];\n      const heroEl = tileRefs.current.get(HERO_ID);\n\n      const tl = gsap.timeline({ defaults: { ease: \"power3.out\" } });\n      timeline = tl;\n\n      if (wrap) {\n        tl.set(wrap, { filter: \"blur(24px)\", scale: 1.04 }, 0);\n        tl.to(wrap, { duration: 0.4, filter: \"blur(0px)\", scale: 1 }, 0.95);\n      }\n\n      if (thumb) {\n        gsap.set(thumb, { xPercent: -50, yPercent: -50 });\n        tl.fromTo(\n          thumb,\n          { filter: \"blur(8px)\", opacity: 0, scale: 0.85, y: 320 },\n          { duration: 0.5, filter: \"blur(0px)\", opacity: 1, scale: 1, y: 0 },\n          0,\n        );\n      }\n\n      for (const [i, tile] of TILES.entries()) {\n        const id = tileId(tile);\n        if (id === HERO_ID) {\n          continue;\n        }\n        const el = tileRefs.current.get(id);\n        const pos = positions[id];\n        if (!el || !pos) {\n          continue;\n        }\n        tl.fromTo(\n          el,\n          {\n            filter: \"blur(8px)\",\n            opacity: 0,\n            scale: 0.35,\n            x: centreX,\n            y: centreY,\n          },\n          {\n            duration: 0.55,\n            filter: \"blur(0px)\",\n            opacity: 1,\n            scale: 1,\n            x: pos.x,\n            y: pos.y,\n          },\n          1.15 + 0.02 * (i % 10),\n        );\n      }\n\n      if (thumb && heroPos) {\n        // Hand-off: the 180px featured card shrinks into the 78px hero tile.\n        tl.to(\n          thumb,\n          {\n            duration: 0.36,\n            ease: \"power3.inOut\",\n            scale: TILE_SIZE / FEATURED_SIZE,\n            x: heroPos.x + 48 - sw / 2,\n            y: heroPos.y + 39 - sh / 2,\n          },\n          1.3,\n        );\n      }\n\n      if (thumb) {\n        tl.to(thumb, { duration: 0.15, opacity: 0 }, 1.66);\n      }\n      if (heroEl) {\n        tl.to(heroEl, { duration: 0.15, opacity: 1 }, 1.66);\n      }\n      if (menu) {\n        tl.fromTo(menu, { opacity: 0, y: -32 }, { duration: 0.5, opacity: 1, y: 0 }, 1.75);\n      }\n      if (dock) {\n        tl.fromTo(dock, { opacity: 0, y: 80 }, { duration: 0.55, opacity: 1, y: 0 }, 1.9);\n      }\n      tl.call(() => setAnimatedEntranceDone(true), undefined, 2.15);\n\n      // Riffle through every thumbnail like a slot-machine reel, landing back\n      // on the hero frame just before the card flies to its tile.\n      const reel = FILES.map((file) => file.thumb);\n      let frame = 0;\n      reelTimeout = window.setTimeout(() => {\n        reelInterval = window.setInterval(() => {\n          frame += 1;\n          const img = featuredImgRef.current;\n          const next = reel[frame];\n          if (next === undefined) {\n            if (reelInterval !== null) {\n              window.clearInterval(reelInterval);\n            }\n            if (img) {\n              img.src = HERO_FILE.thumb;\n            }\n            return;\n          }\n          if (img) {\n            img.src = next;\n          }\n        }, REEL_FRAME_MS);\n      }, REEL_DELAY_MS);\n    };\n\n    // Layout mode and tile positions are read here, not at effect setup: the\n    // preload gate below can delay `start` by up to PRELOAD_CAP_MS, and a\n    // breakpoint cross in that window must build the timeline for the tree\n    // that is actually on screen.\n    const start = (): void => {\n      if (cancelled) {\n        return;\n      }\n      if (isMobileRef.current) {\n        runMobile();\n      } else {\n        runDesktop();\n      }\n    };\n\n    // The reel mutates <img src> every 100ms. Without a preload the whole\n    // sequence flashes empty frames against a cold CDN — so wait for the\n    // thumbnails, but never longer than PRELOAD_CAP_MS.\n    let pending = FILES.length;\n    let launched = false;\n    const launch = (): void => {\n      if (launched || cancelled) {\n        return;\n      }\n      launched = true;\n      start();\n    };\n\n    const settle = (): void => {\n      pending -= 1;\n      if (pending <= 0) {\n        launch();\n      }\n    };\n    for (const file of FILES) {\n      const img = new Image();\n      img.addEventListener(\"load\", settle, { once: true });\n      img.addEventListener(\"error\", settle, { once: true });\n      img.src = file.thumb;\n    }\n    capTimeout = window.setTimeout(launch, PRELOAD_CAP_MS);\n\n    return () => {\n      cancelled = true;\n      // Reset so React 19 StrictMode's second mount replays the intro.\n      entranceStartedRef.current = false;\n      timeline?.kill();\n      if (reelTimeout !== null) {\n        window.clearTimeout(reelTimeout);\n      }\n      if (reelInterval !== null) {\n        window.clearInterval(reelInterval);\n      }\n      if (capTimeout !== null) {\n        window.clearTimeout(capTimeout);\n      }\n    };\n  }, [mounted, seeded, reducedMotion]);\n\n  /* -------------------------------------------------- dock magnification -- */\n\n  useEffect(() => {\n    const tick = (): void => {\n      const cursor = dockCursor.current;\n\n      // Idle dock costs nothing: no rect reads, no style writes.\n      if (!isMobileRef.current && (cursor.active || dockSettling.current)) {\n        cursor.smooth += (cursor.x - cursor.smooth) * DOCK_CURSOR_LERP;\n\n        let settled = true;\n        for (const [id, el] of dockIconEls.current) {\n          // Live rect each frame: magnified neighbours push each other apart,\n          // exactly as they do in the real dock.\n          const rect = el.getBoundingClientRect();\n          const centre = rect.left + rect.width / 2;\n          const dist = Math.abs(cursor.smooth - centre);\n          const t = Math.max(0, 1 - dist / DOCK_HOVER_RANGE);\n          // smoothstep\n          const falloff = t * t * (3 - 2 * t);\n          const target = cursor.active\n            ? DOCK_ICON_BASE + (DOCK_ICON_MAX - DOCK_ICON_BASE) * falloff\n            : DOCK_ICON_BASE;\n\n          const current = dockIconSizes.current.get(id) ?? DOCK_ICON_BASE;\n          const next = current + (target - current) * DOCK_SIZE_LERP;\n          dockIconSizes.current.set(id, next);\n          if (Math.abs(target - next) > 0.1) {\n            settled = false;\n          }\n          el.style.width = `${next.toFixed(2)}px`;\n          el.style.height = `${next.toFixed(2)}px`;\n        }\n\n        dockSettling.current = cursor.active || !settled;\n      }\n\n      rafId.current = requestAnimationFrame(tick);\n    };\n\n    rafId.current = requestAnimationFrame(tick);\n\n    return () => {\n      if (rafId.current !== null) {\n        cancelAnimationFrame(rafId.current);\n      }\n      rafId.current = null;\n    };\n  }, []);\n\n  // Sizes live in their own map so a re-render (which detaches and reattaches\n  // every ref callback) cannot snap a magnified icon back to its base size.\n  const setDockIconRef =\n    (id: DockAppId) =>\n    (el: HTMLDivElement | null): void => {\n      if (el) {\n        dockIconEls.current.set(id, el);\n      } else {\n        dockIconEls.current.delete(id);\n      }\n    };\n\n  const dockPointerMove = (e: ReactPointerEvent<HTMLDivElement>): void => {\n    dockCursor.current.x = e.clientX;\n    if (!dockCursor.current.active) {\n      dockCursor.current.smooth = e.clientX;\n      dockCursor.current.active = true;\n    }\n    dockSettling.current = true;\n  };\n\n  const dockPointerLeave = (): void => {\n    dockCursor.current.active = false;\n    dockCursor.current.x = -9999;\n    dockSettling.current = true;\n  };\n\n  /* ------------------------------------------------ group panel entrance -- */\n\n  useEffect(() => {\n    if (!groupPanelOpen) {\n      return;\n    }\n\n    const tweens: gsap.core.Tween[] = [];\n    if (groupBackdropRef.current) {\n      tweens.push(\n        gsap.fromTo(groupBackdropRef.current, { opacity: 0 }, { duration: 0.22, opacity: 1 }),\n      );\n    }\n    if (groupPanelRef.current) {\n      tweens.push(\n        gsap.fromTo(\n          groupPanelRef.current,\n          { filter: \"blur(8px)\", opacity: 0, scale: 0.86 },\n          {\n            duration: 0.34,\n            ease: \"power3.out\",\n            filter: \"blur(0px)\",\n            opacity: 1,\n            scale: 1,\n          },\n        ),\n      );\n    }\n\n    return () => {\n      for (const tween of tweens) {\n        tween.kill();\n      }\n    };\n  }, [groupPanelOpen]);\n\n  /* ----------------------------------------------------------- ESC stack -- */\n\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent): void => {\n      if (e.key !== \"Escape\") {\n        return;\n      }\n\n      if (lightbox) {\n        setLightbox(null);\n        return;\n      }\n      if (groupPanelOpen) {\n        setGroupPanelOpen(false);\n        return;\n      }\n      const top = topWindow(windows);\n      if (top) {\n        closeWindow(top.uid);\n        return;\n      }\n      if (selectedId) {\n        setSelectedId(null);\n      }\n    };\n\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [lightbox, groupPanelOpen, windows, selectedId, closeWindow]);\n\n  /* ----------------------------------------------------------- tile drag -- */\n\n  const onTilePointerDown = (e: ReactPointerEvent<HTMLDivElement>, tile: DesktopTile): void => {\n    const id = tileId(tile);\n\n    if (isMobileRef.current) {\n      // No capture on mobile, so a finger lifted off the tile never reports\n      // back. Each press simply replaces the last rather than being gated on it.\n      dragRef.current = {\n        ...IDLE_DRAG,\n        id,\n        pointerId: e.pointerId,\n        startX: e.clientX,\n        startY: e.clientY,\n      };\n      return;\n    }\n\n    if (e.button !== 0) {\n      return;\n    }\n    // Capture guarantees the active gesture reports its own end, so a second\n    // pointer arriving mid-drag is a hijack, not a recovery.\n    if (dragRef.current.id !== null) {\n      return;\n    }\n    e.preventDefault();\n    const el = e.currentTarget;\n    el.setPointerCapture(e.pointerId);\n\n    const pos = tilePositions[id];\n    // The z bump goes through state, so React stays the only writer of every\n    // style the render owns and cannot be raced by the gesture. The new z is\n    // derived inside the updater so two presses in one batch cannot tie.\n    setTilePositions((prev) => {\n      const current = prev[id];\n      if (!current) {\n        return prev;\n      }\n      let top = 10;\n      for (const entry of Object.values(prev)) {\n        top = Math.max(top, entry.z);\n      }\n      if (current.z === top) {\n        return prev;\n      }\n\n      return { ...prev, [id]: { ...current, z: top + 1 } };\n    });\n\n    const bounds = tileBounds(el.offsetWidth, el.offsetHeight);\n    dragRef.current = {\n      el,\n      id,\n      lastX: pos?.x ?? 0,\n      lastY: pos?.y ?? 0,\n      maxX: bounds.maxX,\n      maxY: bounds.maxY,\n      moved: false,\n      originX: pos?.x ?? 0,\n      originY: pos?.y ?? 0,\n      pointerId: e.pointerId,\n      startX: e.clientX,\n      startY: e.clientY,\n    };\n    setSelectedId(id);\n  };\n\n  const onTilePointerMove = (e: ReactPointerEvent<HTMLDivElement>): void => {\n    const drag = dragRef.current;\n    if (!drag.id || drag.pointerId !== e.pointerId) {\n      return;\n    }\n\n    const dx = e.clientX - drag.startX;\n    const dy = e.clientY - drag.startY;\n    // 4px of travel is the line between \"clicked it\" and \"dragged it\".\n    if (!drag.moved && Math.hypot(dx, dy) > 4) {\n      drag.moved = true;\n    }\n    if (!drag.moved) {\n      return;\n    }\n    if (isMobileRef.current || !drag.el) {\n      return;\n    }\n\n    const nx = clamp(drag.originX + dx, TILE_EDGE_MARGIN, drag.maxX);\n    const ny = clamp(drag.originY + dy, TILE_EDGE_MARGIN, drag.maxY);\n    drag.lastX = nx;\n    drag.lastY = ny;\n    // `translate` composes on top of the `transform` React renders from state.\n    // Keeping the two properties separate means an unrelated re-render mid-drag\n    // cannot clobber the gesture, and abandoning the gesture is one reset away.\n    drag.el.style.translate = `${nx - drag.originX}px ${ny - drag.originY}px`;\n  };\n\n  const onTilePointerUp = (e: ReactPointerEvent<HTMLDivElement>, tile: DesktopTile): void => {\n    const drag = dragRef.current;\n    const id = tileId(tile);\n    // Only the pointer that began this tile's gesture may end it: a secondary\n    // button, or a release that started on the desktop, must not open or move it.\n    if (drag.id !== id || drag.pointerId !== e.pointerId) {\n      return;\n    }\n\n    // Read the gesture out before ending it — `endTileGesture` wipes it clean.\n    const { moved, lastX, lastY } = drag;\n    const el = endTileGesture(drag, e.pointerId);\n\n    if (moved && !isMobileRef.current) {\n      setTilePositions((prev) => {\n        const current = prev[id];\n        if (!current) {\n          return prev;\n        }\n\n        return { ...prev, [id]: { ...current, x: lastX, y: lastY } };\n      });\n    }\n    // Cleared after the commit: React flushes this handler's updates before the\n    // next paint, so the tile never renders at its pre-drag position.\n    if (el) {\n      el.style.translate = \"\";\n    }\n    if (!moved) {\n      openTile(tile);\n    }\n  };\n\n  /**\n   * Alt-tab, a context menu or a cancelled touch ends the gesture without a\n   * `pointerup`. Drop it and let the last committed position redraw the tile.\n   */\n  const onTilePointerCancel = (e: ReactPointerEvent<HTMLDivElement>): void => {\n    const drag = dragRef.current;\n    if (!drag.id || drag.pointerId !== e.pointerId) {\n      return;\n    }\n\n    const el = endTileGesture(drag, e.pointerId);\n    if (el) {\n      el.style.translate = \"\";\n    }\n  };\n\n  const onTileKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>, tile: DesktopTile): void => {\n    if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      openTile(tile);\n    }\n  };\n\n  /* ------------------------------------------------------------- renders -- */\n\n  const renderTileInner = (tile: DesktopTile, size: number): ReactNode => {\n    const selected = selectedId === tileId(tile);\n\n    return (\n      <>\n        {tile.kind === \"folder\" ? (\n          <div\n            className={`relative ${selected ? \"drop-shadow-[0_0_8px_rgba(255,255,255,0.55)]\" : \"\"}`}\n            style={{ height: size, width: size }}\n          >\n            <AppGlyph id=\"folder\" />\n          </div>\n        ) : (\n          <div\n            className={`relative overflow-hidden rounded-[14px] border bg-[rgba(255,255,255,0.04)] transition-[box-shadow,transform] duration-150 ease-out ${\n              selected\n                ? \"border-white/70 shadow-[0_20px_40px_-12px_rgba(0,0,0,0.6),0_0_0_2px_rgba(255,255,255,0.15)]\"\n                : \"border-white/15 shadow-[0_12px_24px_-10px_rgba(0,0,0,0.6)]\"\n            }`}\n            style={{ height: size, width: size }}\n          >\n            <img\n              src={tile.file.thumb}\n              alt={tile.file.name}\n              draggable={false}\n              className=\"size-full object-cover\"\n            />\n          </div>\n        )}\n\n        <div\n          className={`mt-2 max-w-[110px] truncate rounded-[5px] px-1.5 py-[1px] text-center text-[11px] leading-tight font-medium drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)] ${\n            selected ? \"bg-[#3b82f6] text-white\" : \"text-white\"\n          }`}\n        >\n          {tileName(tile)}\n        </div>\n      </>\n    );\n  };\n\n  const registerTileRef =\n    (id: string) =>\n    (el: HTMLDivElement | null): void => {\n      if (el) {\n        tileRefs.current.set(id, el);\n      } else {\n        tileRefs.current.delete(id);\n      }\n    };\n\n  /**\n   * Tiles are hidden by CSS only while the boot timeline still owns their\n   * opacity. Crossing the 767px breakpoint swaps the desktop tile list for the\n   * springboard grid (and back), which mounts brand-new elements that GSAP has\n   * never touched — a permanent `opacity-0` class would leave those orphans\n   * invisible forever, since the one-shot boot never replays.\n   */\n  const renderDesktopTile = (tile: DesktopTile, index: number): ReactNode => {\n    const id = tileId(tile);\n    const pos = tilePositions[id];\n\n    return (\n      <div\n        key={id}\n        // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Safari does not focus a <button> on click, which would break click-then-Enter; the tile also hosts block content a button cannot.\n        role=\"button\"\n        tabIndex={0}\n        aria-label={tileName(tile)}\n        ref={registerTileRef(id)}\n        onPointerDown={(e) => onTilePointerDown(e, tile)}\n        onPointerMove={onTilePointerMove}\n        onPointerUp={(e) => onTilePointerUp(e, tile)}\n        onPointerCancel={onTilePointerCancel}\n        onLostPointerCapture={onTilePointerCancel}\n        onKeyDown={(e) => onTileKeyDown(e, tile)}\n        className={`absolute z-[4] flex cursor-grab flex-col items-center outline-none select-none active:cursor-grabbing ${entranceDone ? \"\" : \"opacity-0\"}`}\n        style={{\n          touchAction: \"none\",\n          transform: pos ? `translate3d(${pos.x}px, ${pos.y}px, 0)` : \"translate3d(0px, 0px, 0)\",\n          width: TILE_BOX_W,\n          zIndex: pos?.z ?? 10 + index,\n        }}\n      >\n        {renderTileInner(tile, TILE_SIZE)}\n      </div>\n    );\n  };\n\n  const renderMobileTile = (tile: DesktopTile): ReactNode => {\n    const id = tileId(tile);\n\n    return (\n      <div\n        key={id}\n        // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Safari does not focus a <button> on click, which would break click-then-Enter; the tile also hosts block content a button cannot.\n        role=\"button\"\n        tabIndex={0}\n        aria-label={tileName(tile)}\n        ref={registerTileRef(id)}\n        onPointerDown={(e) => onTilePointerDown(e, tile)}\n        onPointerMove={onTilePointerMove}\n        onPointerUp={(e) => onTilePointerUp(e, tile)}\n        onPointerCancel={onTilePointerCancel}\n        onKeyDown={(e) => onTileKeyDown(e, tile)}\n        className={`flex flex-col items-center outline-none select-none ${entranceDone ? \"\" : \"opacity-0\"}`}\n        style={{ touchAction: \"manipulation\" }}\n      >\n        {renderTileInner(tile, MOBILE_TILE_SIZE)}\n      </div>\n    );\n  };\n\n  const desktopDock = (\n    <div ref={dockRef} className=\"absolute inset-x-0 bottom-5 z-30 flex justify-center opacity-0\">\n      <div\n        onPointerMove={dockPointerMove}\n        onPointerLeave={dockPointerLeave}\n        className=\"flex items-end gap-1.5 rounded-[26px] border border-white/25 bg-white/[0.14] px-3 pt-3 pb-3 backdrop-blur-2xl backdrop-saturate-150\"\n        style={{ boxShadow: DOCK_SHADOW, height: DOCK_ICON_BASE + 24 }}\n      >\n        {DOCK_APPS.map((app) => (\n          <Fragment key={app.id}>\n            {app.id === \"trash\" && (\n              <span className=\"mx-0.5 my-2 w-px self-stretch bg-white/[0.22]\" aria-hidden=\"true\" />\n            )}\n            <div className=\"group relative flex items-end\">\n              <span\n                className=\"pointer-events-none absolute -top-[44px] left-1/2 -translate-x-1/2 translate-y-1 rounded-[6px] border border-white/20 bg-black/75 px-2.5 py-[5px] text-[12px] font-medium whitespace-nowrap text-white opacity-0 backdrop-blur-md transition-all duration-150 group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:translate-y-0 group-hover:opacity-100\"\n                style={{ boxShadow: \"0 8px 18px -6px rgba(0,0,0,0.6)\" }}\n              >\n                {app.name}\n                <span\n                  className=\"absolute top-full left-1/2 size-0 -translate-x-1/2\"\n                  style={{\n                    borderLeft: \"5px solid transparent\",\n                    borderRight: \"5px solid transparent\",\n                    borderTop: \"5px solid rgba(0,0,0,0.75)\",\n                  }}\n                />\n              </span>\n              <button\n                type=\"button\"\n                aria-label={app.name}\n                onClick={() => handleDockApp(app.id)}\n                className=\"block\"\n              >\n                <div\n                  ref={setDockIconRef(app.id)}\n                  className=\"drop-shadow-[0_4px_10px_rgba(0,0,0,0.35)]\"\n                  style={{ height: DOCK_ICON_BASE, width: DOCK_ICON_BASE }}\n                >\n                  <AppGlyph id={app.id} />\n                </div>\n              </button>\n            </div>\n          </Fragment>\n        ))}\n      </div>\n    </div>\n  );\n\n  const mobileDock = (\n    <div ref={dockRef} className=\"absolute inset-x-0 bottom-4 z-30 px-4 opacity-0\">\n      <div\n        className=\"flex w-full items-end justify-evenly rounded-[26px] border border-white/25 bg-white/[0.14] px-3 pt-3 pb-3 backdrop-blur-2xl backdrop-saturate-150\"\n        style={{ boxShadow: DOCK_SHADOW }}\n      >\n        {MOBILE_PRIMARY.map((id) => {\n          const app = DOCK_APPS.find((entry) => entry.id === id);\n\n          return (\n            <button\n              key={id}\n              type=\"button\"\n              aria-label={app?.name ?? id}\n              onClick={() => handleDockApp(id)}\n              className=\"drop-shadow-[0_4px_10px_rgba(0,0,0,0.35)]\"\n              style={{ height: MOBILE_ICON_BASE, width: MOBILE_ICON_BASE }}\n            >\n              <AppGlyph id={id} />\n            </button>\n          );\n        })}\n        <button\n          type=\"button\"\n          aria-label=\"More apps\"\n          onClick={() => setGroupPanelOpen(true)}\n          className={`rounded-[14px] border border-white/30 bg-white/15 p-[6px] shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_4px_10px_rgba(0,0,0,0.35)] backdrop-blur-md ${\n            groupPanelOpen ? \"ring-2 ring-white/40\" : \"\"\n          }`}\n          style={{ height: MOBILE_ICON_BASE, width: MOBILE_ICON_BASE }}\n        >\n          {/*\n            `grid-rows-3` is load-bearing: with only an implicit row the single\n            row of glyphs is stretched to the definite `h-full` height and each\n            mini-icon renders as a tall bar.\n          */}\n          <div className=\"grid h-full grid-cols-3 grid-rows-3 gap-[3px]\">\n            {GROUP_APPS.map((id) => (\n              <span key={id} className=\"size-full overflow-hidden rounded-[3px]\">\n                <AppGlyph id={id} />\n              </span>\n            ))}\n          </div>\n        </button>\n      </div>\n    </div>\n  );\n\n  return (\n    <main\n      ref={sectionRef}\n      className=\"relative isolate h-full w-full overflow-hidden bg-[#1a1612]\"\n      style={{ fontFamily: \"ui-sans-serif, system-ui, sans-serif\" }}\n    >\n      <style>{SCOPED_CSS}</style>\n\n      <div ref={wallpaperWrapRef} className=\"absolute inset-0 z-0\">\n        <div className=\"absolute inset-0\" style={{ background: WALLPAPER }} />\n      </div>\n\n      <div\n        className=\"pointer-events-none absolute inset-0 z-[1]\"\n        style={{ background: VEIL_GRADIENT }}\n      />\n\n      <div\n        role=\"presentation\"\n        className=\"absolute inset-0 z-[3]\"\n        onMouseDown={(e) => {\n          if (e.target === e.currentTarget) {\n            setSelectedId(null);\n          }\n        }}\n      />\n\n      {mounted && (\n        <>\n          {isMobile ? (\n            <div className=\"dos-scroll dos-scroll-dark absolute inset-x-0 top-[64px] bottom-[120px] z-[4] grid grid-cols-3 content-start gap-x-2 gap-y-4 overflow-y-auto px-4 pt-2 pb-4\">\n              {TILES.map((tile) => renderMobileTile(tile))}\n            </div>\n          ) : (\n            TILES.map((tile, i) => renderDesktopTile(tile, i))\n          )}\n\n          {/*\n            Both bars switch on `isMobile` rather than a `sm:` utility: the\n            springboard/desktop split is decided at 767px, and a 640px Tailwind\n            breakpoint would put the macOS bar above the iOS grid at 720px.\n          */}\n          <div ref={menuBarRef} className=\"absolute inset-x-0 top-0 z-[5] opacity-0\">\n            {isMobile ? (\n              <div className=\"flex h-12 items-center justify-between px-4 text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]\">\n                <div className=\"flex items-center gap-1.5\">\n                  <span className=\"text-[14px] font-semibold tracking-tight\">14:08</span>\n                  <BellSlashGlyph />\n                </div>\n                <div className=\"flex items-center gap-1\">\n                  <SignalBarsGlyph />\n                  <WifiGlyph />\n                  <IOSBatteryGlyph />\n                </div>\n              </div>\n            ) : (\n              <div className=\"flex h-7 items-center justify-between bg-black/15 px-4 backdrop-blur-md\">\n                <div className=\"flex items-center gap-4 text-[11px] text-white/80\">\n                  <span className=\"size-3 rounded-[2px] bg-white\" aria-hidden=\"true\" />\n                  <span className=\"font-bold\">Finder</span>\n                  <span className=\"text-white/70\">File</span>\n                  <span className=\"text-white/70\">Edit</span>\n                  <span className=\"text-white/70\">View</span>\n                  <span className=\"text-white/70\">Window</span>\n                  <span className=\"text-white/70\">Help</span>\n                </div>\n                <div className=\"flex items-center gap-3 text-[11px] text-white/80\">\n                  <span className=\"text-white\">\n                    <MacBatteryGlyph />\n                  </span>\n                  <span>100%</span>\n                  <span>Wed 14:08</span>\n                </div>\n              </div>\n            )}\n          </div>\n\n          {!isMobile && !entranceDone && (\n            <div\n              ref={featuredThumbRef}\n              className=\"absolute top-1/2 left-1/2 z-40 size-[180px] overflow-hidden rounded-[28px] border border-white/15 opacity-0\"\n              style={{\n                boxShadow: \"0 40px 90px -12px rgba(0,0,0,0.7), 0 0 0 4px rgba(255,255,255,0.06)\",\n              }}\n            >\n              <img\n                ref={featuredImgRef}\n                src={HERO_FILE.thumb}\n                alt=\"\"\n                draggable={false}\n                className=\"size-full object-cover\"\n              />\n            </div>\n          )}\n\n          {isMobile ? mobileDock : desktopDock}\n\n          {groupPanelOpen && (\n            <div\n              ref={groupBackdropRef}\n              role=\"presentation\"\n              onMouseDown={(e) => {\n                if (e.target === e.currentTarget) {\n                  setGroupPanelOpen(false);\n                }\n              }}\n              className=\"absolute inset-0 z-[45] flex items-center justify-center bg-black/35 p-6 backdrop-blur-md\"\n            >\n              <div\n                ref={groupPanelRef}\n                className=\"w-full max-w-[360px] rounded-[28px] border border-white/[0.22] bg-white/[0.13] p-6 backdrop-blur-2xl backdrop-saturate-150\"\n                style={{ boxShadow: DOCK_SHADOW }}\n              >\n                <p className=\"text-[12px] font-semibold tracking-[0.18em] text-white/75 uppercase\">\n                  More\n                </p>\n                <div className=\"mt-4 grid grid-cols-3 gap-x-2 gap-y-5\">\n                  {GROUP_APPS.map((id) => {\n                    const app = DOCK_APPS.find((entry) => entry.id === id);\n\n                    return (\n                      <button\n                        key={id}\n                        type=\"button\"\n                        onClick={() => {\n                          handleDockApp(id);\n                          setGroupPanelOpen(false);\n                        }}\n                        className=\"flex flex-col items-center gap-2\"\n                      >\n                        <span\n                          className=\"drop-shadow-[0_4px_10px_rgba(0,0,0,0.35)]\"\n                          style={{ height: 58, width: 58 }}\n                        >\n                          <AppGlyph id={id} />\n                        </span>\n                        <span className=\"text-[11.5px] font-medium text-white/85\">\n                          {app?.name ?? id}\n                        </span>\n                      </button>\n                    );\n                  })}\n                </div>\n              </div>\n            </div>\n          )}\n\n          <div className=\"pointer-events-none absolute inset-0 z-[50]\">\n            {windows.map((win) => (\n              <WindowView\n                key={win.uid}\n                win={win}\n                ctx={ctx}\n                sectionRef={sectionRef}\n                onClose={closeWindow}\n                onFocus={focusWindow}\n                onMove={moveWindow}\n              />\n            ))}\n          </div>\n\n          {lightbox && <PhotoLightbox src={lightbox} onClose={() => setLightbox(null)} />}\n        </>\n      )}\n    </main>\n  );\n};\n","path":"/desktop-os.tsx","target":"uicapsule/desktop-os/desktop-os.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { RotateCcwIcon } from \"lucide-react\";\n\nimport { DesktopOS } from \"./desktop-os\";\n\nconst Preview = () => {\n  // The boot sequence writes inline styles that React never clears, so replay\n  // has to be a full remount rather than a timeline restart.\n  const [bootKey, setBootKey] = useState(0);\n\n  return (\n    <div className=\"relative h-screen w-full overflow-hidden bg-[#1a1612]\">\n      <DesktopOS key={bootKey} />\n\n      <button\n        type=\"button\"\n        onClick={() => setBootKey((n) => n + 1)}\n        className=\"absolute bottom-4 left-4 z-[70] flex items-center gap-1.5 rounded-full border border-white/15 bg-black/45 px-3 py-1.5 text-xs font-medium text-white/80 backdrop-blur-md transition-colors hover:bg-black/65 hover:text-white\"\n      >\n        <RotateCcwIcon className=\"size-3.5\" />\n        Replay boot\n      </button>\n    </div>\n  );\n};\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/desktop-os/preview.tsx","type":"registry:file"},{"content":"import { useSyncExternalStore } from \"react\";\n\nconst QUERY = \"(prefers-reduced-motion: reduce)\";\n\nconst subscribe = (onChange: () => void) => {\n  const query = window.matchMedia(QUERY);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\n\nconst getSnapshot = () => window.matchMedia(QUERY).matches;\nconst getServerSnapshot = () => false;\n\nexport const useReducedMotion = () =>\n  useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n","path":"/use-reduced-motion.ts","target":"uicapsule/desktop-os/use-reduced-motion.ts","type":"registry:file"},{"content":"\"use client\";\n\nimport type { ReactNode } from \"react\";\n\nimport { useEffect, useState } from \"react\";\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  Columns3Icon,\n  FolderIcon,\n  ImageIcon,\n  LayoutGridIcon,\n  ListIcon,\n  SearchIcon,\n} from \"lucide-react\";\n\nimport type { DesktopFile } from \"./desktop-data\";\nimport type { FrameProps } from \"./window-frame\";\nimport type { OpenWindow, WindowCtx } from \"./window-types\";\n\nimport { AppGlyph } from \"./app-icons\";\nimport { FILES, FIRST_NOTE, NOTES, PHOTOS, findFile } from \"./desktop-data\";\nimport { WindowFrame } from \"./window-frame\";\nimport { useReducedMotion } from \"./use-reduced-motion\";\n\n/* -------------------------------------------------------------- Quick Look -- */\n\nconst QuickLookBody = ({\n  fileId,\n  ctx,\n}: {\n  fileId: string | undefined;\n  ctx: WindowCtx;\n}): ReactNode => {\n  const startIndex = Math.max(\n    0,\n    FILES.findIndex((file) => file.id === fileId),\n  );\n  const [index, setIndex] = useState(startIndex);\n\n  const file: DesktopFile = FILES[index] ?? FILES[0];\n\n  const step = (delta: number): void => {\n    setIndex((prev) => (prev + delta + FILES.length) % FILES.length);\n  };\n\n  // `file.full` is a cold 1600px derivative. Warm both neighbours so an arrow\n  // click swaps to a decoded frame instead of blanking for the fetch.\n  useEffect(() => {\n    const neighbours = [\n      FILES[(index + 1) % FILES.length],\n      FILES[(index - 1 + FILES.length) % FILES.length],\n    ];\n    for (const neighbour of neighbours) {\n      if (neighbour) {\n        const img = new Image();\n        img.src = neighbour.full;\n      }\n    }\n  }, [index]);\n\n  return (\n    <div className=\"flex h-full flex-col bg-[#1c1a18]\">\n      <div className=\"relative min-h-0 flex-1\">\n        <button\n          type=\"button\"\n          onClick={() => ctx.openLightbox(file.full)}\n          aria-label={`Open ${file.name} full screen`}\n          className=\"absolute inset-0 flex items-center justify-center p-6\"\n        >\n          {/* No `key`: reusing the element lets the browser hold the previous\n              frame until the next one decodes, instead of flashing empty. */}\n          <img\n            src={file.full}\n            alt={file.name}\n            draggable={false}\n            className=\"max-h-full max-w-full rounded-[6px] object-contain shadow-[0_18px_50px_-20px_rgba(0,0,0,0.9)]\"\n          />\n        </button>\n\n        <button\n          type=\"button\"\n          onClick={() => step(-1)}\n          aria-label=\"Previous file\"\n          className=\"absolute top-1/2 left-3 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-white/12 text-white backdrop-blur-md transition-colors hover:bg-white/25\"\n        >\n          <ChevronLeftIcon className=\"size-4\" />\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => step(1)}\n          aria-label=\"Next file\"\n          className=\"absolute top-1/2 right-3 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-white/12 text-white backdrop-blur-md transition-colors hover:bg-white/25\"\n        >\n          <ChevronRightIcon className=\"size-4\" />\n        </button>\n      </div>\n\n      <div className=\"flex shrink-0 items-baseline gap-3 border-t border-white/10 bg-black/40 px-4 py-2.5 text-white\">\n        <span className=\"truncate text-[12.5px] font-medium\">{file.name}</span>\n        <span className=\"text-[11px] text-white/50\">{file.dimensions}</span>\n        <span className=\"ml-auto text-[11px] text-white/40\">\n          {index + 1} of {FILES.length}\n        </span>\n      </div>\n    </div>\n  );\n};\n\n/* ------------------------------------------------------------------ Photos -- */\n\nconst PhotosBody = ({ ctx }: { ctx: WindowCtx }): ReactNode => (\n  <div className=\"dos-scroll h-full overflow-y-auto px-6 py-7 sm:px-8 sm:py-8\">\n    <p className=\"text-[10px] font-semibold tracking-[0.18em] text-black/45 uppercase\">\n      Recents · {PHOTOS.length}\n    </p>\n    <div\n      className=\"mt-4 grid auto-rows-[120px] grid-cols-4 gap-3\"\n      style={{ gridAutoFlow: \"row dense\" }}\n    >\n      {PHOTOS.map((photo) => (\n        <button\n          key={photo.src}\n          type=\"button\"\n          onClick={() => ctx.openLightbox(photo.full)}\n          className={`group relative overflow-hidden rounded-[8px] bg-black/5 ${\n            photo.ratio === \"tall\" ? \"row-span-2\" : \"\"\n          } ${photo.ratio === \"wide\" ? \"col-span-2\" : \"\"}`}\n        >\n          <img\n            src={photo.src}\n            alt={photo.caption}\n            loading=\"lazy\"\n            draggable={false}\n            className=\"size-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.05]\"\n          />\n          <span className=\"pointer-events-none absolute inset-x-0 bottom-0 translate-y-2 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-[11px] font-medium text-white opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100\">\n            {photo.caption}\n          </span>\n        </button>\n      ))}\n    </div>\n  </div>\n);\n\n/* ------------------------------------------------------------------- Notes -- */\n\nconst NotesBody = ({ ctx }: { ctx: WindowCtx }): ReactNode => {\n  const [activeId, setActiveId] = useState<string>(FIRST_NOTE.id);\n  const [mobileView, setMobileView] = useState<\"list\" | \"detail\">(\"list\");\n\n  const active = NOTES.find((note) => note.id === activeId) ?? FIRST_NOTE;\n  const showList = !ctx.isMobile || mobileView === \"list\";\n  const showDetail = !ctx.isMobile || mobileView === \"detail\";\n\n  return (\n    <div className=\"flex h-full\">\n      {showList && (\n        <div\n          className={`flex flex-col bg-[#f7f5ee] md:border-r md:border-black/[0.08] ${\n            ctx.isMobile ? \"w-full\" : \"w-60\"\n          }`}\n        >\n          <p className=\"border-b border-black/[0.06] px-4 py-2.5 text-[10px] font-semibold tracking-[0.16em] text-black/45 uppercase\">\n            Notes — {NOTES.length}\n          </p>\n          <div className=\"dos-scroll flex-1 overflow-y-auto\">\n            {NOTES.map((note) => (\n              <button\n                key={note.id}\n                type=\"button\"\n                onClick={() => {\n                  setActiveId(note.id);\n                  if (ctx.isMobile) {\n                    setMobileView(\"detail\");\n                  }\n                }}\n                className={`block w-full border-b border-black/[0.05] px-4 py-3 text-left transition-colors ${\n                  note.id === activeId && !ctx.isMobile\n                    ? \"bg-[#f4d34c]/60\"\n                    : \"hover:bg-black/[0.03]\"\n                }`}\n              >\n                <p className=\"truncate text-[13px] font-semibold text-[#1a1612]\">{note.title}</p>\n                <div className=\"mt-0.5 flex items-baseline gap-2\">\n                  <span className=\"text-[11px] font-medium text-black/70\">{note.date}</span>\n                  <span className=\"truncate text-[11px] text-black/55\">{note.preview}</span>\n                </div>\n              </button>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {showDetail && (\n        <div className=\"flex min-w-0 flex-1 flex-col overflow-hidden\">\n          {ctx.isMobile && (\n            <button\n              type=\"button\"\n              onClick={() => setMobileView(\"list\")}\n              className=\"flex items-center gap-1 border-b border-black/[0.06] px-3 py-2.5 text-[13px] font-medium text-[#1a1612]\"\n            >\n              <ChevronLeftIcon className=\"size-4\" />\n              Notes\n            </button>\n          )}\n          <div className=\"dos-scroll flex-1 overflow-y-auto px-5 py-6 sm:px-8 sm:py-7\">\n            <p className=\"text-[11px] text-black/45\">{active.date}</p>\n            <h3 className=\"mt-1 text-[22px] leading-tight font-semibold tracking-tight text-[#1a1612]\">\n              {active.title}\n            </h3>\n            <p className=\"mt-5 text-[14px] leading-relaxed whitespace-pre-line text-black/80\">\n              {active.body}\n            </p>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n\n/* ------------------------------------------------------------------ Finder -- */\n\nconst FINDER_SIDEBAR = [\n  { color: \"#62a8e8\", label: \"Desktop\" },\n  { color: \"#7c8595\", label: \"Applications\" },\n  { color: \"#7c8595\", label: \"Documents\" },\n  { color: \"#5fd0c9\", label: \"Captures\" },\n  { color: \"#a8a08b\", label: \"Exports\" },\n  { color: \"#7c8595\", label: \"Downloads\" },\n] as const;\n\nconst FinderFolderDot = ({ size = 14 }: { size?: number }): ReactNode => (\n  <FolderIcon\n    className=\"shrink-0 text-[#4aa3f0]\"\n    style={{ height: size, width: size }}\n    fill=\"#4aa3f0\"\n  />\n);\n\nconst ViewModeButton = ({\n  children,\n  active,\n  label,\n}: {\n  children: ReactNode;\n  active?: boolean;\n  label: string;\n}): ReactNode => (\n  <button\n    type=\"button\"\n    aria-label={label}\n    className={`rounded p-1 text-black/45 ${\n      active ? \"bg-white text-black/70 shadow-[0_1px_0_rgba(0,0,0,0.08)]\" : \"\"\n    }`}\n  >\n    {children}\n  </button>\n);\n\nconst FinderBody = ({ win, ctx }: { win: OpenWindow; ctx: WindowCtx }): ReactNode => {\n  const folderName = win.folderName ?? \"Archive\";\n  // Trash and Archive hold different slices of the same library.\n  const contents = folderName === \"Trash\" ? FILES.slice(6, 10) : FILES.slice(1, 6);\n\n  return (\n    <div className=\"flex h-full flex-col bg-[#f7f5ef]\">\n      <div className=\"flex items-center gap-3 border-b border-black/[0.06] bg-[#ecebe6] px-3 py-1.5\">\n        <div className=\"flex items-center text-black/35\">\n          <button\n            type=\"button\"\n            aria-label=\"Back\"\n            className=\"rounded-md p-1 transition-colors hover:bg-black/5\"\n          >\n            <ChevronLeftIcon className=\"size-3.5\" />\n          </button>\n          <button\n            type=\"button\"\n            aria-label=\"Forward\"\n            className=\"rounded-md p-1 transition-colors hover:bg-black/5\"\n          >\n            <ChevronRightIcon className=\"size-3.5\" />\n          </button>\n        </div>\n        <div className=\"flex items-center gap-1.5 text-[12px] font-semibold text-[#1a1612]\">\n          <FinderFolderDot />\n          {folderName}\n        </div>\n        <div className=\"flex-1\" />\n        <div className=\"flex items-center gap-0.5 rounded-md border border-black/10 bg-white/70 p-0.5\">\n          <ViewModeButton label=\"Icon view\" active>\n            <LayoutGridIcon className=\"size-3.5\" />\n          </ViewModeButton>\n          <ViewModeButton label=\"List view\">\n            <ListIcon className=\"size-3.5\" />\n          </ViewModeButton>\n          <ViewModeButton label=\"Column view\">\n            <Columns3Icon className=\"size-3.5\" />\n          </ViewModeButton>\n          <ViewModeButton label=\"Gallery view\">\n            <ImageIcon className=\"size-3.5\" />\n          </ViewModeButton>\n        </div>\n        <button\n          type=\"button\"\n          aria-label=\"Search\"\n          className=\"rounded-md p-1 text-black/40 transition-colors hover:bg-black/5\"\n        >\n          <SearchIcon className=\"size-3.5\" />\n        </button>\n      </div>\n\n      <div className=\"flex min-h-0 flex-1\">\n        <div className=\"dos-scroll hidden w-40 shrink-0 overflow-y-auto bg-[#ecebe6] px-2 py-2 sm:block\">\n          <p className=\"px-1.5 py-1 text-[10px] font-semibold tracking-[0.06em] text-black/45 uppercase\">\n            Locations\n          </p>\n          {[\"Recents\", \"Shared\"].map((item) => (\n            <div\n              key={item}\n              className=\"flex items-center gap-2 rounded-[6px] px-1.5 py-1 transition-colors hover:bg-black/[0.06]\"\n            >\n              <span className=\"size-3 rounded-[3px]\" style={{ background: \"#7c8595\" }} />\n              <span className=\"truncate text-[12px] text-[#1a1612]\">{item}</span>\n            </div>\n          ))}\n          <p className=\"mt-2 px-1.5 py-1 text-[10px] font-semibold tracking-[0.06em] text-black/45 uppercase\">\n            Favourites\n          </p>\n          {FINDER_SIDEBAR.map((item) => (\n            <div\n              key={item.label}\n              className=\"flex items-center gap-2 rounded-[6px] px-1.5 py-1 transition-colors hover:bg-black/[0.06]\"\n            >\n              <span className=\"size-3 rounded-[3px]\" style={{ background: item.color }} />\n              <span className=\"truncate text-[12px] text-[#1a1612]\">{item.label}</span>\n            </div>\n          ))}\n        </div>\n\n        <div className=\"dos-scroll min-w-0 flex-1 overflow-y-auto bg-white px-5 py-4\">\n          <div\n            className=\"grid gap-x-2 gap-y-4\"\n            style={{\n              gridTemplateColumns: \"repeat(auto-fill, minmax(96px, 1fr))\",\n            }}\n          >\n            {contents.map((file) => (\n              <button\n                key={file.id}\n                type=\"button\"\n                onClick={() => ctx.openQuickLook(file.id)}\n                className=\"group flex flex-col items-center gap-1.5 rounded-md p-2 focus:outline-none focus-visible:bg-[#cce3ff]\"\n              >\n                <span className=\"size-[68px] overflow-hidden rounded-[6px] border border-black/10 bg-black/5\">\n                  <img\n                    src={file.thumb}\n                    alt=\"\"\n                    loading=\"lazy\"\n                    draggable={false}\n                    className=\"size-full object-cover\"\n                  />\n                </span>\n                <span className=\"line-clamp-2 max-w-[96px] rounded-[4px] px-1 text-center text-[11px] leading-[1.2] font-medium text-[#1a1612] group-focus-visible:bg-[#3b82f6] group-focus-visible:text-white\">\n                  {file.name}\n                </span>\n              </button>\n            ))}\n            <div className=\"flex flex-col items-center gap-1.5 rounded-md p-2\">\n              <span className=\"size-[68px]\">\n                <AppGlyph id=\"folder\" />\n              </span>\n              <span className=\"max-w-[96px] truncate px-1 text-center text-[11px] leading-[1.2] font-medium text-[#1a1612]\">\n                Older\n              </span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center gap-1.5 border-t border-black/[0.06] bg-[#f4f2eb] px-3 py-1.5 text-[10.5px] text-black/55\">\n        <FinderFolderDot size={12} />\n        <span className=\"flex items-center gap-1\">\n          Macintosh HD\n          <ChevronRightIcon className=\"size-2.5 text-black/35\" />\n          Users\n          <ChevronRightIcon className=\"size-2.5 text-black/35\" />\n          Desktop\n          <ChevronRightIcon className=\"size-2.5 text-black/35\" />\n          <span className=\"font-semibold text-black/75\">{folderName}</span>\n        </span>\n        <span className=\"ml-auto\">{contents.length + 1} items</span>\n      </div>\n    </div>\n  );\n};\n\n/* ---------------------------------------------------------------- Terminal -- */\n\ninterface TerminalLine {\n  readonly prompt: boolean;\n  readonly text: string;\n  readonly tone?: \"ok\" | \"dim\";\n}\n\nconst TONE_CLASS: Record<NonNullable<TerminalLine[\"tone\"]>, string> = {\n  dim: \"text-white/45\",\n  ok: \"text-[#9fe6c0]\",\n};\n\nconst TERMINAL_LINES: readonly TerminalLine[] = [\n  { prompt: true, text: \"system --status\" },\n  { prompt: false, text: \"compositor      up     16.6ms/frame\", tone: \"ok\" },\n  { prompt: false, text: \"window server   up     6 surfaces\", tone: \"ok\" },\n  { prompt: false, text: \"dock            up     magnification on\", tone: \"ok\" },\n  { prompt: false, text: \"wallpaper       up     gradient, no bitmap\", tone: \"dim\" },\n  { prompt: true, text: \"uptime\" },\n  { prompt: false, text: \"2 seconds since boot\", tone: \"dim\" },\n];\n\nconst TerminalBody = (): ReactNode => {\n  const [visible, setVisible] = useState(0);\n  const reducedMotion = useReducedMotion();\n\n  useEffect(() => {\n    if (reducedMotion) {\n      return;\n    }\n\n    // The count lives in the closure, not in the updater: React may invoke an\n    // updater twice, and stopping the interval from inside one is a side effect\n    // that has no business running during a re-render.\n    let shown = 0;\n    const id = window.setInterval(() => {\n      shown += 1;\n      setVisible(shown);\n      if (shown >= TERMINAL_LINES.length) {\n        window.clearInterval(id);\n      }\n    }, 190);\n\n    return () => window.clearInterval(id);\n  }, [reducedMotion]);\n\n  return (\n    <div className=\"dos-scroll dos-scroll-dark h-full overflow-y-auto bg-[#101014] px-4 py-3 font-mono text-[12px] leading-[1.7] text-[#d6d6da]\">\n      {TERMINAL_LINES.slice(0, reducedMotion ? TERMINAL_LINES.length : visible).map((line) => (\n        <div key={line.text} className=\"flex gap-2\">\n          {line.prompt && <span className=\"text-[#5fd0a0]\">~ %</span>}\n          <span className={line.tone === undefined ? \"\" : TONE_CLASS[line.tone]}>{line.text}</span>\n        </div>\n      ))}\n      <div className=\"flex gap-2\">\n        <span className=\"text-[#5fd0a0]\">~ %</span>\n        <span className=\"dos-caret inline-block h-[14px] w-[7px] bg-[#d6d6da] align-middle\" />\n      </div>\n    </div>\n  );\n};\n\n/* -------------------------------------------------------------- dispatcher -- */\n\n/**\n * Sibling of `titleForWindow`: the two functions are the whole mapping from a\n * window's kind to what the frame shows, and they stay next to each other so a\n * new kind cannot satisfy one exhaustive switch and miss the other.\n */\nconst bodyForWindow = (win: OpenWindow, ctx: WindowCtx): ReactNode => {\n  switch (win.kind) {\n    case \"quicklook\": {\n      return <QuickLookBody fileId={win.fileId} ctx={ctx} />;\n    }\n    case \"photos\": {\n      return <PhotosBody ctx={ctx} />;\n    }\n    case \"notes\": {\n      return <NotesBody ctx={ctx} />;\n    }\n    case \"finder\": {\n      return <FinderBody win={win} ctx={ctx} />;\n    }\n    case \"terminal\": {\n      return <TerminalBody />;\n    }\n    default: {\n      const exhaustive: never = win.kind;\n      return exhaustive;\n    }\n  }\n};\n\nconst titleForWindow = (win: OpenWindow): string => {\n  switch (win.kind) {\n    case \"quicklook\": {\n      return findFile(win.fileId)?.name ?? \"Quick Look\";\n    }\n    case \"photos\": {\n      return \"Photos\";\n    }\n    case \"notes\": {\n      return \"Notes\";\n    }\n    case \"finder\": {\n      return win.folderName ?? \"Files\";\n    }\n    case \"terminal\": {\n      return \"Terminal — bash\";\n    }\n    default: {\n      const exhaustive: never = win.kind;\n      return exhaustive;\n    }\n  }\n};\n\nexport const WindowView = ({\n  win,\n  ctx,\n  sectionRef,\n  onClose,\n  onFocus,\n  onMove,\n}: Pick<FrameProps, \"sectionRef\" | \"onClose\" | \"onFocus\" | \"onMove\"> & {\n  win: OpenWindow;\n  ctx: WindowCtx;\n}): ReactNode => (\n  <WindowFrame\n    win={win}\n    isMobile={ctx.isMobile}\n    sectionRef={sectionRef}\n    onClose={onClose}\n    onFocus={onFocus}\n    onMove={onMove}\n    title={titleForWindow(win)}\n  >\n    {bodyForWindow(win, ctx)}\n  </WindowFrame>\n);\n","path":"/window-bodies.tsx","target":"uicapsule/desktop-os/window-bodies.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport type { PointerEvent as ReactPointerEvent, ReactNode, RefObject } from \"react\";\n\nimport { useEffect, useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { XIcon } from \"lucide-react\";\n\nimport type { OpenWindow } from \"./window-types\";\n\nexport interface FrameProps {\n  win: OpenWindow;\n  isMobile: boolean;\n  sectionRef: RefObject<HTMLElement | null>;\n  onClose: (uid: number) => void;\n  onFocus: (uid: number) => void;\n  onMove: (uid: number, x: number, y: number) => void;\n  /** Required: it is both the visible header label and the dialog's accessible name. */\n  title: string;\n  children: ReactNode;\n}\n\n/**\n * Modelled as a union so \"not dragging\" cannot carry drag coordinates: the\n * previous shape kept a `dragging` boolean beside stale start/origin values,\n * and any path that failed to reset it left the window glued to the cursor.\n */\ntype HeaderDrag =\n  | { readonly phase: \"idle\" }\n  | {\n      readonly phase: \"active\";\n      readonly pointerId: number;\n      readonly startX: number;\n      readonly startY: number;\n      readonly originX: number;\n      readonly originY: number;\n      moved: boolean;\n      last: { readonly x: number; readonly y: number } | null;\n    };\n\nconst clamp = (value: number, min: number, max: number): number =>\n  Math.min(Math.max(value, min), max);\n\n/**\n * Releasing capture schedules a `lostpointercapture`, which routes back into the\n * cancel handler — so every caller drops its own state *before* calling this,\n * leaving that handler nothing to undo.\n */\nconst releaseCapture = (el: HTMLElement, pointerId: number): void => {\n  if (el.hasPointerCapture(pointerId)) {\n    el.releasePointerCapture(pointerId);\n  }\n};\n\nexport const WindowFrame = ({\n  win,\n  isMobile,\n  sectionRef,\n  onClose,\n  onFocus,\n  onMove,\n  title,\n  children,\n}: FrameProps): ReactNode => {\n  const frameRef = useRef<HTMLDivElement>(null);\n  const drag = useRef<HeaderDrag>({ phase: \"idle\" });\n\n  // Mount pop. gsap writes inline styles, so the tween owns the rest state too.\n  useEffect(() => {\n    const el = frameRef.current;\n    if (!el) {\n      return;\n    }\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    if (reduced) {\n      gsap.set(el, { filter: \"none\", opacity: 1, scale: 1, y: 0 });\n      return;\n    }\n\n    const tween = gsap.fromTo(\n      el,\n      { filter: \"blur(6px)\", opacity: 0, scale: 0.94, y: 14 },\n      {\n        duration: 0.36,\n        ease: \"power3.out\",\n        filter: \"blur(0px)\",\n        opacity: 1,\n        scale: 1,\n        y: 0,\n      },\n    );\n\n    return () => {\n      tween.kill();\n    };\n  }, []);\n\n  /** Undo the gesture's imperative writes and hand `left`/`top` back to React. */\n  const resetFramePosition = (): void => {\n    const el = frameRef.current;\n    if (!el) {\n      return;\n    }\n    el.style.left = `${win.x}px`;\n    el.style.top = `${win.y}px`;\n  };\n\n  const onHeaderPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {\n    if (isMobile) {\n      return;\n    }\n    if (e.button !== 0) {\n      return;\n    }\n    if (drag.current.phase === \"active\") {\n      return;\n    }\n    if (!(e.target instanceof Element)) {\n      return;\n    }\n    // Traffic lights are inside the drag handle but must stay clickable.\n    if (e.target.closest(\"[data-traffic-light]\")) {\n      return;\n    }\n    if (!frameRef.current) {\n      return;\n    }\n\n    onFocus(win.uid);\n    // Capture on the element that owns these handlers. Capturing on an ancestor\n    // retargets every later event to that ancestor, where nothing is listening —\n    // which is to say the drag would never move and never end.\n    e.currentTarget.setPointerCapture(e.pointerId);\n    drag.current = {\n      last: null,\n      moved: false,\n      originX: win.x,\n      originY: win.y,\n      phase: \"active\",\n      pointerId: e.pointerId,\n      startX: e.clientX,\n      startY: e.clientY,\n    };\n  };\n\n  const onHeaderPointerMove = (e: ReactPointerEvent<HTMLDivElement>): void => {\n    const state = drag.current;\n    if (state.phase !== \"active\" || state.pointerId !== e.pointerId) {\n      return;\n    }\n    const el = frameRef.current;\n    const section = sectionRef.current;\n    if (!el || !section) {\n      return;\n    }\n\n    const dx = e.clientX - state.startX;\n    const dy = e.clientY - state.startY;\n    if (!state.moved && Math.hypot(dx, dy) < 3) {\n      return;\n    }\n    state.moved = true;\n\n    const nx = clamp(state.originX + dx, -win.w + 80, section.clientWidth - 80);\n    const ny = clamp(state.originY + dy, 28, Math.max(28, section.clientHeight - 60));\n    el.style.left = `${nx}px`;\n    el.style.top = `${ny}px`;\n    state.last = { x: nx, y: ny };\n  };\n\n  const onHeaderPointerUp = (e: ReactPointerEvent<HTMLDivElement>): void => {\n    const state = drag.current;\n    if (state.phase !== \"active\" || state.pointerId !== e.pointerId) {\n      return;\n    }\n\n    drag.current = { phase: \"idle\" };\n    releaseCapture(e.currentTarget, e.pointerId);\n\n    const committed = state.last;\n    if (state.moved && committed) {\n      onMove(win.uid, committed.x, committed.y);\n    }\n  };\n\n  /**\n   * Alt-tab, a context menu or a cancelled touch ends the gesture without a\n   * `pointerup`. Drop the drag and redraw from the last committed position\n   * rather than stranding half a move in the inline style.\n   */\n  const onHeaderPointerCancel = (e: ReactPointerEvent<HTMLDivElement>): void => {\n    const state = drag.current;\n    if (state.phase !== \"active\" || state.pointerId !== e.pointerId) {\n      return;\n    }\n\n    drag.current = { phase: \"idle\" };\n    releaseCapture(e.currentTarget, e.pointerId);\n    resetFramePosition();\n  };\n\n  return (\n    // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- mousedown only raises the window's z-order; focusing anything inside the window reaches the same state from the keyboard\n    <div\n      ref={frameRef}\n      // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- a <dialog> element's UA inset/fit-content sizing and open/close model fight this absolutely positioned, pointer-dragged frame\n      role=\"dialog\"\n      aria-label={title}\n      onMouseDown={() => onFocus(win.uid)}\n      className=\"pointer-events-auto absolute flex flex-col overflow-hidden rounded-[12px] border border-black/15 bg-[#fbfaf7] text-[#1a1612] shadow-[0_30px_80px_-20px_rgba(0,0,0,0.45),0_8px_24px_-8px_rgba(0,0,0,0.35)]\"\n      style={{\n        height: win.h,\n        left: win.x,\n        top: win.y,\n        width: win.w,\n        zIndex: win.z,\n      }}\n    >\n      <div\n        onPointerDown={onHeaderPointerDown}\n        onPointerMove={onHeaderPointerMove}\n        onPointerUp={onHeaderPointerUp}\n        onPointerCancel={onHeaderPointerCancel}\n        onLostPointerCapture={onHeaderPointerCancel}\n        className={`flex shrink-0 select-none items-center gap-3 border-b border-black/[0.07] bg-[#ecebe6] px-3 py-2 ${\n          isMobile ? \"cursor-default\" : \"cursor-grab active:cursor-grabbing\"\n        }`}\n        style={{ touchAction: \"none\" }}\n      >\n        <div data-traffic-light className=\"flex items-center gap-1.5\">\n          <button\n            type=\"button\"\n            aria-label=\"Close window\"\n            onClick={() => onClose(win.uid)}\n            className=\"group/tl flex size-3 items-center justify-center rounded-full bg-[#ff5f57] ring-1 ring-black/10\"\n          >\n            <svg\n              width=\"6\"\n              height=\"6\"\n              viewBox=\"0 0 6 6\"\n              className=\"opacity-0 group-hover/tl:opacity-100\"\n            >\n              <path\n                d=\"M1 1 L5 5 M5 1 L1 5\"\n                stroke=\"#5a0900\"\n                strokeWidth=\"1.1\"\n                strokeLinecap=\"round\"\n              />\n            </svg>\n          </button>\n          <span\n            className=\"size-3 rounded-full bg-[#febc2e] ring-1 ring-black/10\"\n            aria-hidden=\"true\"\n          />\n          <span\n            className=\"size-3 rounded-full bg-[#28c840] ring-1 ring-black/10\"\n            aria-hidden=\"true\"\n          />\n        </div>\n\n        <div className=\"flex min-w-0 flex-1 items-center justify-center\">\n          <span className=\"truncate text-[12px] font-medium tracking-tight text-black/70\">\n            {title}\n          </span>\n        </div>\n\n        {/*\n          Empty on purpose. The traffic lights sit in the row's left slot, so the\n          title only lands optically centred while a same-order counterweight\n          holds the right slot open.\n        */}\n        <div className=\"shrink-0\" style={{ minWidth: 60 }} aria-hidden=\"true\" />\n      </div>\n\n      <div className=\"relative min-h-0 flex-1 overflow-hidden\">{children}</div>\n    </div>\n  );\n};\n\nexport const PhotoLightbox = ({\n  src,\n  onClose,\n}: {\n  src: string;\n  onClose: () => void;\n}): ReactNode => {\n  const overlayRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const el = overlayRef.current;\n    if (!el) {\n      return;\n    }\n\n    const tween = gsap.fromTo(\n      el,\n      { opacity: 0 },\n      { duration: 0.25, ease: \"power2.out\", opacity: 1 },\n    );\n\n    return () => {\n      tween.kill();\n    };\n  }, []);\n\n  return (\n    <div\n      ref={overlayRef}\n      role=\"presentation\"\n      onMouseDown={(e) => {\n        if (e.target === e.currentTarget) {\n          onClose();\n        }\n      }}\n      className=\"absolute inset-0 z-[60] flex items-center justify-center bg-black/85 p-8 backdrop-blur-sm\"\n    >\n      <img\n        src={src}\n        alt=\"Enlarged view\"\n        draggable={false}\n        className=\"max-h-full max-w-full rounded-[8px] shadow-2xl\"\n      />\n      <button\n        type=\"button\"\n        onClick={onClose}\n        aria-label=\"Close\"\n        className=\"absolute top-5 right-5 flex size-9 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur-md transition-colors hover:bg-white/25\"\n      >\n        <XIcon className=\"size-5\" />\n      </button>\n    </div>\n  );\n};\n","path":"/window-frame.tsx","target":"uicapsule/desktop-os/window-frame.tsx","type":"registry:file"},{"content":"/** Shared window-manager vocabulary. Kept dependency-free to avoid import cycles. */\n\nexport type WindowKind = \"quicklook\" | \"photos\" | \"notes\" | \"finder\" | \"terminal\";\n\nexport interface OpenWindow {\n  readonly uid: number;\n  readonly kind: WindowKind;\n  readonly z: number;\n  readonly x: number;\n  readonly y: number;\n  readonly w: number;\n  readonly h: number;\n  /** Quick Look only — which desktop file is being previewed. */\n  readonly fileId?: string;\n  /** Finder only — which folder the window is showing. */\n  readonly folderName?: string;\n}\n\nexport const DEFAULT_WINDOW_SIZES = {\n  finder: { h: 460, w: 760 },\n  notes: { h: 520, w: 720 },\n  photos: { h: 600, w: 880 },\n  quicklook: { h: 560, w: 760 },\n  terminal: { h: 400, w: 640 },\n} satisfies Record<WindowKind, { readonly w: number; readonly h: number }>;\n\nexport interface WindowCtx {\n  readonly isMobile: boolean;\n  readonly openQuickLook: (fileId: string) => void;\n  readonly openLightbox: (src: string) => void;\n}\n","path":"/window-types.ts","target":"uicapsule/desktop-os/window-types.ts","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/desktop-os","name":"desktop-os","registryDependencies":[],"title":"Desktop OS","type":"registry:block"}