{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"folded-map","type":"registry:component","title":"Folded map","description":"An itinerary that unfolds panel by panel like a paper road map, recentring as it opens.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/folded-map.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  motionValue,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControlsWithThen,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Panel size when the map unfolds sideways: four panels make a 576px sheet.\nconst PANEL_W = 144;\nconst PANEL_H = 340;\n// Panel height when it unfolds downward on narrow screens.\nconst ROW_H = 168;\n// Not quite flat: folded panels keep a hair of air between them, like real\n// paper, which also stops coplanar faces from z-fighting.\nconst FOLDED = 178;\nconst EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\n// Unfolding is a staged reveal of large paper panels, so it runs longer than\n// UI motion: each panel takes 480ms and starts when the one before it is\n// just past halfway, reading as one continuous pull. Folding is the system\n// tidying up, so it runs faster and overlaps more.\nconst UNFOLD = { duration: 0.48, stagger: 0.28 };\nconst FOLD = { duration: 0.3, stagger: 0.14 };\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\n// A printed road map is a physical object, so it keeps its own inks in both\n// themes. In dark mode the paper is dimmed, like paper under a lamp, rather\n// than inverted.\nconst PAPER = \"light-dark(oklch(0.975 0.012 85), oklch(0.84 0.02 80))\";\nconst PAPER_BACK = \"light-dark(oklch(0.95 0.014 85), oklch(0.78 0.02 80))\";\nconst INK = \"oklch(0.25 0.012 60)\";\nconst INK_SOFT = \"oklch(0.47 0.014 60)\";\n// The publisher's cover colour and the two map inks: sea and highway red.\nconst COVER = \"light-dark(oklch(0.45 0.12 255), oklch(0.4 0.1 255))\";\nconst COVER_INK = \"oklch(0.97 0.01 85)\";\nconst SEA = \"light-dark(oklch(0.91 0.035 230), oklch(0.77 0.04 230))\";\nconst ROAD = \"oklch(0.57 0.19 30)\";\n// Fine paper grain: static noise, rendered once by the browser.\nconst GRAIN = `url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.3 0 0 0 0 0.25 0 0 0 0 0.2 0 0 0 .09 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")`;\n\ntype Stop = { time: string; place: string; x: number; y: number };\ntype Day = { title: string; stops: Stop[] };\n\n// Catmull-Rom through every stop, converted to cubic Beziers, so the road\n// bends smoothly through each one.\nfunction routePath(points: { x: number; y: number }[]) {\n  let d = `M${points[0].x} ${points[0].y}`;\n  for (let i = 0; i < points.length - 1; i++) {\n    const p0 = points[Math.max(i - 1, 0)];\n    const p1 = points[i];\n    const p2 = points[i + 1];\n    const p3 = points[Math.min(i + 2, points.length - 1)];\n    d += `C${p1.x + (p2.x - p0.x) / 6} ${p1.y + (p2.y - p0.y) / 6} ${p2.x - (p3.x - p1.x) / 6} ${p2.y - (p3.y - p1.y) / 6} ${p2.x} ${p2.y}`;\n  }\n  return d;\n}\n\n// One continuous sheet of map art, 576 wide. Each panel shows its own\n// slice, so contours, coast and road line up across the creases.\nconst CONTOURS = [\n  \"M0 96C70 84 120 108 190 98S320 76 390 92 500 112 576 100\",\n  \"M0 206C80 194 130 218 210 206S330 184 410 198 520 220 576 208\",\n  \"M150 60C200 52 240 70 290 62S380 44 430 58\",\n  \"M300 236C340 228 380 244 430 236S520 222 576 232\",\n];\nconst COAST =\n  \"M0 300C40 292 70 308 112 302S176 286 214 296 282 314 330 304 404 288 446 298 520 314 576 306V340H0Z\";\n// Side roads branch off the highway and stay in the map band, clear of\n// the headings above and the lists below.\nconst MINOR_ROADS = [\n  \"M220 170L236 200\",\n  \"M196 96C206 108 214 116 226 122\",\n  \"M360 118L378 94\",\n  \"M406 160C420 176 424 188 440 198\",\n  \"M504 172L494 200\",\n];\n\nexport function FoldedMap({\n  title,\n  summary,\n  days,\n  className,\n}: {\n  title: string;\n  summary: string;\n  /** Three days fill the sheet: one panel each beside the cover. */\n  days: Day[];\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [open, setOpen] = useState(false);\n  // Stays true until the last fold lands, so a narrow layout only gives\n  // back its height once the paper is closed.\n  const [expanded, setExpanded] = useState(false);\n  const [vertical, setVertical] = useState(false);\n  const root = useRef<HTMLDivElement>(null);\n  // 0 is flat, 1 is folded, one per hinge.\n  const [hinges] = useState(() => days.map(() => motionValue(1)));\n  const running = useRef<AnimationPlaybackControlsWithThen[]>([]);\n  const listId = useId();\n  const openRef = useRef(false);\n  const sheetW = PANEL_W * (days.length + 1);\n\n  // Keeps the visible paper centred while it unfolds: walk the chain of\n  // panels, find the right edge of the paper as seen from the front, and\n  // shift the sheet by half the unused width. Folded, the cover sits in the\n  // middle; flat, the sheet fills the frame.\n  const shift = useTransform(hinges, (ps: number[]) => {\n    let angle = 0;\n    let x = PANEL_W;\n    let right = PANEL_W;\n    ps.forEach((p, i) => {\n      angle += (i % 2 === 0 ? 1 : -1) * p * FOLDED;\n      x += PANEL_W * Math.cos((angle * Math.PI) / 180);\n      right = Math.max(right, x);\n    });\n    return (sheetW - right) / 2;\n  });\n  const zero = useMotionValue(0);\n\n  useEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => {\n      // Squeezed below the full sheet width by max-w-full: unfold downward.\n      setVertical(entry.contentRect.width < sheetW - 1);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [sheetW]);\n\n  useEffect(() => () => running.current.forEach((c) => c.stop()), []);\n\n  const toggle = () => {\n    const next = !open;\n    openRef.current = next;\n    setOpen(next);\n    running.current.forEach((c) => c.stop());\n    if (next) setExpanded(true);\n    if (reduceMotion) {\n      hinges.forEach((h) => h.jump(next ? 0 : 1));\n      if (!next) setExpanded(false);\n      return;\n    }\n    const n = hinges.length;\n    running.current = hinges.map((h, i) =>\n      animate(h, next ? 0 : 1, {\n        duration: next ? UNFOLD.duration : FOLD.duration,\n        ease: EASE_IN_OUT,\n        // Out from the cover in order; back in from the far end.\n        delay: next ? i * UNFOLD.stagger : (n - 1 - i) * FOLD.stagger,\n      }),\n    );\n    if (!next) {\n      running.current[0]?.then(() => {\n        if (!openRef.current) setExpanded(false);\n      });\n    }\n  };\n\n  const panelW = vertical ? undefined : PANEL_W;\n  const panelH = vertical ? ROW_H : PANEL_H;\n\n  return (\n    <div\n      ref={root}\n      className={cn(\n        \"relative max-w-full select-none\",\n        // Panels tipping toward the viewer grow a little in perspective;\n        // on a phone-width column that must not widen the page. The margin\n        // keeps the paper's edge shadow.\n        vertical && \"overflow-x-clip [overflow-clip-margin:8px]\",\n        className,\n      )}\n      style={{\n        width: sheetW,\n        perspective: 1400,\n        height: vertical\n          ? expanded\n            ? ROW_H * (days.length + 1)\n            : ROW_H\n          : PANEL_H,\n      }}\n    >\n      <motion.div\n        className=\"absolute inset-y-0 left-0\"\n        style={{\n          x: vertical ? zero : shift,\n          width: vertical ? \"100%\" : PANEL_W,\n          transformStyle: \"preserve-3d\",\n        }}\n      >\n        {/* The folded panels' edges, stacked behind the cover: the map's\n            thickness, and a hint that there is more paper in there. */}\n        <FoldedEdges\n          count={days.length}\n          progress={hinges[0]}\n          width={panelW}\n          height={panelH}\n          vertical={vertical}\n        />\n        <div\n          className=\"relative\"\n          style={{\n            width: panelW ?? \"100%\",\n            height: panelH,\n            transformStyle: \"preserve-3d\",\n          }}\n        >\n          <Cover\n            title={title}\n            summary={summary}\n            vertical={vertical}\n            open={open}\n            listId={listId}\n            onToggle={toggle}\n          />\n          {/* Folded panels stay out of the tab order and reading order. */}\n          <Hinge\n            id={listId}\n            inert={!open}\n            depth={0}\n            days={days}\n            hinges={hinges}\n            parentWorld={null}\n            vertical={vertical}\n            height={panelH}\n          />\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n\nfunction Paper({\n  className,\n  back,\n  style,\n  children,\n}: {\n  className?: string;\n  back?: boolean;\n  style?: React.CSSProperties;\n  children?: React.ReactNode;\n}) {\n  return (\n    <div\n      className={cn(\"absolute inset-0 overflow-hidden rounded-[2px]\", className)}\n      style={{\n        backgroundColor: back ? PAPER_BACK : PAPER,\n        backgroundImage: GRAIN,\n        color: INK,\n        ...style,\n      }}\n    >\n      {children}\n    </div>\n  );\n}\n\nfunction Cover({\n  title,\n  summary,\n  vertical,\n  open,\n  listId,\n  onToggle,\n}: {\n  title: string;\n  summary: string;\n  vertical: boolean;\n  open: boolean;\n  listId: string;\n  onToggle: () => void;\n}) {\n  return (\n    <Paper className=\"flex flex-col shadow-raised\">\n      <div\n        className={cn(\n          \"relative flex px-3.5\",\n          vertical ? \"flex-row items-start gap-3 pt-3 pb-3.5\" : \"flex-col pt-3.5 pb-4\",\n        )}\n        style={{ backgroundColor: COVER, color: COVER_INK }}\n      >\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"flex items-center justify-between text-xs font-medium tracking-[0.08em] uppercase opacity-75\">\n            <span>Road map</span>\n            {!vertical && <span className=\"tabular-nums\">No. 03</span>}\n          </p>\n          <h3 className=\"mt-2 text-xl leading-[1.1] font-semibold tracking-[-0.01em] text-balance\">\n            {title}\n          </h3>\n          <p className=\"mt-1.5 text-[13px] opacity-80\">{summary}</p>\n        </div>\n        {vertical && <MiniMap className=\"h-[72px] w-[96px] shrink-0\" />}\n        {/* The thin printed rule under the band that map covers carry. */}\n        <span\n          aria-hidden\n          className=\"absolute inset-x-0 bottom-0 h-[3px]\"\n          style={{ backgroundColor: ROAD }}\n        />\n      </div>\n      {!vertical && (\n        <div className=\"flex flex-1 flex-col px-3.5 pt-3.5\">\n          <MiniMap className=\"h-[104px] w-full\" />\n          <ScaleBar />\n        </div>\n      )}\n      <div className={cn(\"px-3.5 pb-3.5\", vertical && \"mt-auto\")}>\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={listId}\n          onClick={onToggle}\n          className=\"relative flex h-10 w-full items-center justify-center gap-1.5 rounded-full text-sm font-medium outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 active:scale-[0.96] motion-reduce:transition-none\"\n          style={{\n            backgroundColor: INK,\n            color: COVER_INK,\n            outlineColor: INK,\n          }}\n        >\n          <span className=\"grid\">\n            <Label visible={!open}>Unfold</Label>\n            <Label visible={open}>Fold</Label>\n          </span>\n          <span className=\"relative size-4\">\n            <SwapIcon visible={!open}>\n              <path d={vertical ? \"M4 6.5l4 4 4-4\" : \"M6.5 4l4 4-4 4\"} />\n            </SwapIcon>\n            <SwapIcon visible={open}>\n              <path d={vertical ? \"M4 9.5l4-4 4 4\" : \"M9.5 4l-4 4 4 4\"} />\n            </SwapIcon>\n          </span>\n        </button>\n      </div>\n    </Paper>\n  );\n}\n\n// The whole trip in miniature on the cover, with a north arrow.\nfunction MiniMap({ className }: { className?: string }) {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"150 40 420 300\"\n      preserveAspectRatio=\"xMidYMid slice\"\n      className={cn(\"rounded-[2px]\", className)}\n      fill=\"none\"\n      style={{\n        backgroundColor: PAPER,\n        boxShadow: \"0 0 0 1px oklch(0 0 0 / 0.12)\",\n      }}\n    >\n      <path d={COAST} style={{ fill: SEA }} />\n      {CONTOURS.map((d) => (\n        <path key={d} d={d} stroke={INK} strokeOpacity={0.14} strokeWidth={3} />\n      ))}\n      <path d={ROUTE} stroke={ROAD} strokeWidth={9} strokeLinecap=\"round\" />\n      {[DAYS[0].stops[0], DAYS[2].stops[2]].map((s) => (\n        <circle\n          key={s.place}\n          cx={s.x}\n          cy={s.y}\n          r={12}\n          style={{ fill: PAPER }}\n          stroke={INK}\n          strokeWidth={6}\n        />\n      ))}\n      <path\n        d=\"M540 216L550 250L540 243L530 250Z\"\n        fill={INK}\n        stroke={INK}\n        strokeWidth={4}\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction ScaleBar() {\n  return (\n    <div\n      aria-hidden\n      className=\"mt-3 flex items-center gap-2 text-xs\"\n      style={{ color: INK_SOFT }}\n    >\n      <span\n        className=\"flex h-1.5 w-12 overflow-hidden rounded-[1px]\"\n        style={{ boxShadow: `0 0 0 1px ${INK}` }}\n      >\n        <span className=\"flex-1\" style={{ backgroundColor: INK }} />\n        <span className=\"flex-1\" />\n        <span className=\"flex-1\" style={{ backgroundColor: INK }} />\n      </span>\n      <span className=\"tabular-nums\">50 km</span>\n    </div>\n  );\n}\n\nfunction FoldedEdges({\n  count,\n  progress,\n  width,\n  height,\n  vertical,\n}: {\n  count: number;\n  progress: MotionValue<number>;\n  width?: number;\n  height: number;\n  vertical: boolean;\n}) {\n  // Gone by the time the first panel has swung a sixth of the way.\n  const opacity = useTransform(progress, [0.82, 0.97], [0, 1]);\n  return (\n    <>\n      {Array.from({ length: count }, (_, i) => {\n        // Each folded panel sits a hair further back and out.\n        const offset = (count - i) * 2;\n        return (\n          <motion.div\n            key={i}\n            aria-hidden\n            className=\"absolute top-0 left-0 rounded-[2px]\"\n            style={{\n              width: width ?? \"100%\",\n              height,\n              opacity,\n              x: vertical ? 0 : offset,\n              y: vertical ? offset : offset / 2,\n              backgroundColor: PAPER_BACK,\n              boxShadow:\n                \"0 0 0 1px oklch(0 0 0 / 0.1), 0 2px 6px -2px oklch(0 0 0 / 0.2)\",\n            }}\n          />\n        );\n      })}\n    </>\n  );\n}\n\nfunction Hinge({\n  id,\n  inert,\n  depth,\n  days,\n  hinges,\n  parentWorld,\n  vertical,\n  height,\n}: {\n  id?: string;\n  inert?: boolean;\n  depth: number;\n  days: Day[];\n  hinges: MotionValue<number>[];\n  parentWorld: MotionValue<number> | null;\n  vertical: boolean;\n  height: number;\n}) {\n  const progress = hinges[depth];\n  const zero = useMotionValue(0);\n  // Accordion folds alternate direction. The first panel tucks behind the\n  // cover, so the cover and its button stay on top when folded.\n  const sign = depth % 2 === 0 ? 1 : -1;\n  const aroundY = useTransform(progress, (p) => p * FOLDED * sign);\n  const aroundX = useTransform(progress, (p) => -p * FOLDED * sign);\n  // Only |cos| of this is used, so the horizontal angles serve both layouts.\n  const world = useTransform(\n    [parentWorld ?? zero, aroundY],\n    ([a, b]: number[]) => a + b,\n  );\n  // Lit from the front: a panel turned edge-on catches the least light.\n  const shade = useTransform(\n    world,\n    (a) => (1 - Math.abs(Math.cos((a * Math.PI) / 180))) * 0.4,\n  );\n  const day = days[depth];\n  const last = depth === days.length - 1;\n  // Accordion creases alternate valley and mountain, so the light falls on\n  // opposite sides of neighbouring creases even once the map lies flat.\n  const angle = vertical ? \"180deg\" : \"90deg\";\n  const crease =\n    depth % 2 === 0\n      ? `linear-gradient(${angle}, oklch(0 0 0 / 0.09), transparent 14px)`\n      : `linear-gradient(${angle}, oklch(1 0 0 / 0.45), transparent 10px)`;\n\n  return (\n    <motion.div\n      id={id}\n      inert={inert}\n      role=\"group\"\n      aria-label={`Day ${depth + 1}, ${day.title}`}\n      className={cn(\n        \"absolute\",\n        vertical ? \"top-full left-0 w-full\" : \"top-0 left-full\",\n      )}\n      style={{\n        width: vertical ? undefined : PANEL_W,\n        height,\n        transformStyle: \"preserve-3d\",\n        transformOrigin: vertical ? \"50% 0\" : \"0 50%\",\n        rotateY: vertical ? zero : aroundY,\n        rotateX: vertical ? aroundX : zero,\n      }}\n    >\n      <Paper className=\"shadow-raised\" style={{ backfaceVisibility: \"hidden\" }}>\n        <MapSlice index={depth + 1} vertical={vertical} />\n        <DayContent index={depth} day={day} vertical={vertical} />\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0\"\n          style={{ backgroundImage: crease }}\n        />\n        <motion.div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0\"\n          style={{ opacity: shade, backgroundColor: INK }}\n        />\n      </Paper>\n      {/* The blank back of the paper, seen while a panel is past edge-on. */}\n      <Paper\n        back\n        style={{\n          backfaceVisibility: \"hidden\",\n          transform: vertical ? \"rotateX(180deg)\" : \"rotateY(180deg)\",\n        }}\n      >\n        <motion.div\n          className=\"absolute inset-0\"\n          style={{ opacity: shade, backgroundColor: INK }}\n        />\n      </Paper>\n      {!last && (\n        <Hinge\n          depth={depth + 1}\n          days={days}\n          hinges={hinges}\n          parentWorld={world}\n          vertical={vertical}\n          height={height}\n        />\n      )}\n    </motion.div>\n  );\n}\n\nfunction DayContent({\n  index,\n  day,\n  vertical,\n}: {\n  index: number;\n  day: Day;\n  vertical: boolean;\n}) {\n  return (\n    <div\n      className={cn(\n        \"relative flex h-full p-3.5\",\n        vertical ? \"flex-row gap-3 pr-[92px]\" : \"flex-col\",\n      )}\n    >\n      <div className={cn(vertical && \"w-[42%] shrink-0\")}>\n        <p\n          className=\"text-xs font-semibold tracking-[0.08em] uppercase\"\n          style={{ color: ROAD }}\n        >\n          Day {index + 1}\n        </p>\n        <h4 className=\"mt-1 text-[15px] leading-snug font-semibold text-balance\">\n          {day.title}\n        </h4>\n      </div>\n      <ul\n        className={cn(\n          \"flex flex-col gap-2\",\n          vertical ? \"justify-center\" : \"mt-auto\",\n        )}\n      >\n        {day.stops.map((s) => (\n          <li key={s.place} className=\"flex h-9 flex-col justify-center\">\n            <span\n              className=\"font-mono text-xs tabular-nums\"\n              style={{ color: INK_SOFT }}\n            >\n              {s.time}\n            </span>\n            <span className=\"text-sm leading-tight font-medium\">\n              {s.place}\n            </span>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\n// The shared sheet art, cropped to one panel. Sideways the sheet is 576\n// wide; downward it becomes a strip down the right edge of every row, with\n// each stop level with its line in the list.\nfunction MapSlice({ index, vertical }: { index: number; vertical: boolean }) {\n  if (vertical) {\n    return (\n      <svg\n        aria-hidden\n        viewBox={`0 ${index * ROW_H} 80 ${ROW_H}`}\n        className=\"pointer-events-none absolute top-0 right-3 h-full w-20\"\n        fill=\"none\"\n      >\n        <path d={ROUTE_V} style={{ stroke: PAPER }} strokeWidth={6} strokeLinecap=\"round\" />\n        <path d={ROUTE_V} stroke={ROAD} strokeWidth={2.5} strokeLinecap=\"round\" />\n        {STOPS_V.map((s) => (\n          <circle\n            key={s.place}\n            cx={s.x}\n            cy={s.y}\n            r={4}\n            strokeWidth={2}\n            style={{ fill: PAPER }}\n            stroke={INK}\n          />\n        ))}\n      </svg>\n    );\n  }\n  return (\n    <svg\n      aria-hidden\n      viewBox={`${index * PANEL_W} 0 ${PANEL_W} ${PANEL_H}`}\n      className=\"pointer-events-none absolute inset-0 size-full\"\n      fill=\"none\"\n    >\n      <path d={COAST} style={{ fill: SEA }} />\n      {/* The printed grid every map sheet carries. */}\n      {[48, 96, 144, 192, 240, 288].map((y) => (\n        <line key={y} x1={0} x2={576} y1={y} y2={y} stroke={INK} strokeOpacity={0.05} />\n      ))}\n      {CONTOURS.map((d) => (\n        <path key={d} d={d} stroke={INK} strokeOpacity={0.1} />\n      ))}\n      {MINOR_ROADS.map((d) => (\n        <path key={d} d={d} stroke={INK} strokeOpacity={0.22} />\n      ))}\n      {/* A cased highway: a paper-coloured casing under the red road. */}\n      <path d={ROUTE} style={{ stroke: PAPER }} strokeWidth={6} strokeLinecap=\"round\" />\n      <path d={ROUTE} stroke={ROAD} strokeWidth={2.5} strokeLinecap=\"round\" />\n      {DAYS.flatMap((d) => d.stops).map((s) => (\n        <circle\n          key={s.place}\n          cx={s.x}\n          cy={s.y}\n          r={4}\n          strokeWidth={2}\n          style={{ fill: PAPER }}\n          stroke={INK}\n        />\n      ))}\n    </svg>\n  );\n}\n\nfunction Label({\n  visible,\n  children,\n}: {\n  visible: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <span\n      aria-hidden={!visible}\n      className={cn(\n        \"col-start-1 row-start-1 text-center transition-[opacity,filter] duration-200 ease-out\",\n        !visible && \"opacity-0 blur-[4px]\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\nfunction SwapIcon({\n  visible,\n  children,\n}: {\n  visible: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <motion.svg\n      viewBox=\"0 0 16 16\"\n      className=\"absolute inset-0 size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n      initial={false}\n      animate={\n        visible\n          ? { scale: 1, opacity: 1, filter: \"blur(0px)\" }\n          : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" }\n      }\n      transition={ICON_SWAP}\n    >\n      {children}\n    </motion.svg>\n  );\n}\n\n// Stops sit in the band between each panel's heading and its list.\nconst DAYS: Day[] = [\n  {\n    title: \"Porto to Costa Nova\",\n    stops: [\n      { time: \"09:00\", place: \"Ribeira\", x: 172, y: 122 },\n      { time: \"13:30\", place: \"Aveiro\", x: 220, y: 170 },\n      { time: \"18:00\", place: \"Costa Nova\", x: 264, y: 132 },\n    ],\n  },\n  {\n    title: \"Coimbra and the coast\",\n    stops: [\n      { time: \"10:00\", place: \"Coimbra\", x: 312, y: 174 },\n      { time: \"15:00\", place: \"Nazaré\", x: 360, y: 118 },\n      { time: \"19:30\", place: \"Óbidos\", x: 406, y: 160 },\n    ],\n  },\n  {\n    title: \"Sintra to Lisbon\",\n    stops: [\n      { time: \"09:30\", place: \"Sintra\", x: 458, y: 124 },\n      { time: \"14:00\", place: \"Cascais\", x: 504, y: 172 },\n      { time: \"18:30\", place: \"Lisbon\", x: 548, y: 140 },\n    ],\n  },\n];\n\nconst ROUTE = routePath(DAYS.flatMap((d) => d.stops));\n// The downward strip: each stop level with its line in the row's list\n// (three 36px lines, 8px apart, centred in a 168px row: 40, 84, 128), and\n// wandering sideways as the sideways route wanders up and down.\nconst STOPS_V = DAYS.flatMap((d, di) =>\n  d.stops.map((s, k) => ({\n    place: s.place,\n    x: 18 + ((s.y - 118) / 56) * 44,\n    y: ROW_H * (di + 1) + 40 + k * 44,\n  })),\n);\nconst ROUTE_V = routePath(STOPS_V);\n\nexport default function FoldedMapDemo() {\n  return (\n    <FoldedMap title=\"Porto to Lisbon\" summary=\"3 days, 412 km\" days={DAYS} />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/folded-map"}