{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agency-reel",
  "title": "Agency: Reel",
  "description": "An ink-blue panel with a browser and a rail of projects: the address bar types each client's domain and the site loads line by line.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/agency-core.json",
    "https://eased.dev/r/button.json",
    "https://eased.dev/r/text-swap.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/templates/agency/reel.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useInView, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore, type KeyboardEvent } from \"react\";\nimport { Button } from \"../../button/button\";\nimport { enter, exit, reducedFade, stagger, useSafeReducedMotion } from \"../../lib/motion\";\nimport { TextSwap } from \"../../text-swap/text-swap\";\nimport { ClientArt } from \"./client-art\";\nimport { brand, projects, reel, type Project } from \"./content\";\nimport { COLUMN, Tiles } from \"./kit\";\n\n/**\n * The signature: a case-study reel. A panel in the studio's ink blue, inset to the page column\n * under the wordmark, holds a browser window and, beside it, a rail of the four projects; the\n * window plays the studio's client sites in turn. For each one the address bar types the\n * client's domain, a load line runs under the toolbar, and the drawn site comes in, its lines\n * rising 80ms apart; it holds, and the next one types. It starts on its own a beat after it is\n * in view, plays the four once and rests on the last, with Replay.\n *\n * Everything is one clock (`clock`, ms) that runs only while the panel is on screen, the tab is\n * visible and the reel is playing, so it pauses itself offscreen and the pause button (rule 85)\n * just stops the clock. The address bar, the page and the tabs' progress lines are all read\n * from the clock. Reduced motion shows one still: the first site, loaded, with the tabs to\n * switch sites and no clock.\n */\n\nconst SITES = reel.projects.map((id) => projects.find((p) => p.id === id)!);\n\n/** The blank tab before the first site types: a beat to notice the window. */\nconst BEAT = 900;\n/** One typed character, about a quick typist's pace. */\nconst CHAR = 55;\n/** After the last character, before Return. */\nconst ENTER = 220;\n/** The load line under the toolbar. */\nconst LOAD = 480;\n/** How long each site holds once it has loaded. */\nconst HOLD = 3400;\n\n/** Each site's span on the clock: typing from `start`, loaded at `loaded`, next site at `end`. */\nconst SPANS = SITES.reduce<{ start: number; loaded: number; end: number }[]>((spans, site) => {\n  const start = spans.at(-1)?.end ?? BEAT;\n  const loaded = start + site.domain.length * CHAR + ENTER + LOAD;\n  return [...spans, { start, loaded, end: loaded + HOLD }];\n}, []);\nconst TOTAL = SPANS.at(-1)!.end;\n\n/** What the window shows at a time on the clock: the page that is up, and the address bar. */\ntype Frame = { page: number; typing: number; typed: number; ended: boolean };\n\nfunction frameAt(t: number): Frame {\n  if (t >= TOTAL) return { page: SITES.length - 1, typing: -1, typed: 0, ended: true };\n  const i = SPANS.findLastIndex((s) => t >= s.start);\n  if (i === -1) return { page: -1, typing: -1, typed: 0, ended: false };\n  const span = SPANS[i];\n  if (t >= span.loaded) return { page: i, typing: -1, typed: 0, ended: false };\n  // Typing (then loading): the previous page stays up until the new one has loaded.\n  return { page: i - 1, typing: i, typed: Math.min(SITES[i].domain.length, Math.floor((t - span.start) / CHAR)), ended: false };\n}\n\nconst sameFrame = (a: Frame, b: Frame) => a.page === b.page && a.typing === b.typing && a.typed === b.typed && a.ended === b.ended;\n\nconst subscribeVisibility = (onChange: () => void) => {\n  document.addEventListener(\"visibilitychange\", onChange);\n  return () => document.removeEventListener(\"visibilitychange\", onChange);\n};\nconst useTabVisible = () => useSyncExternalStore(subscribeVisibility, () => document.visibilityState === \"visible\", () => true);\n\n/** The reel's clock and controls: it advances in animation frames while `active`. */\nfunction useReel(active: boolean, reduced: boolean) {\n  const clock = useMotionValue(0);\n  const [frame, setFrame] = useState<Frame>(() => frameAt(0));\n  const [playing, setPlaying] = useState(true);\n\n  useMotionValueEvent(clock, \"change\", (t) => {\n    const next = frameAt(t);\n    setFrame((prev) => (sameFrame(prev, next) ? prev : next));\n  });\n\n  // Reduced motion: one still, the first site loaded, and no clock.\n  useEffect(() => {\n    if (reduced) clock.set(SPANS[0].loaded);\n  }, [reduced, clock]);\n\n  const running = active && playing && !reduced && !frame.ended;\n  useEffect(() => {\n    if (!running) return;\n    let raf = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      clock.set(Math.min(TOTAL, clock.get() + (now - last)));\n      last = now;\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [running, clock]);\n\n  return {\n    clock,\n    frame,\n    playing,\n    togglePlaying: () => setPlaying((p) => !p),\n    replay: () => {\n      clock.set(0);\n      setPlaying(true);\n    },\n    /** A tab: the site at once, loaded; the reel carries on from there if it is playing. */\n    show: (i: number) => clock.set(SPANS[i].loaded),\n  };\n}\n\nexport function Reel() {\n  const reduced = useSafeReducedMotion();\n  const panel = useRef<HTMLDivElement>(null);\n  const inView = useInView(panel, { amount: 0.35 });\n  const visible = useTabVisible();\n  const { clock, frame, playing, togglePlaying, replay, show } = useReel(inView && visible, reduced);\n  const id = useId();\n  const current = frame.page >= 0 ? SITES[frame.page] : undefined;\n  // The tab marked as selected: the site being typed, else the one that is up.\n  const selected = frame.typing >= 0 ? frame.typing : Math.max(frame.page, 0);\n  // Arrow keys move between the tabs, Home and End jump to the ends, and each site shows as its tab takes focus (one tab stop).\n  const onTabKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    const last = SITES.length - 1;\n    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];\n    const jump = { Home: 0, End: last }[e.key];\n    if (step === undefined && jump === undefined) return;\n    e.preventDefault();\n    const next = jump ?? (selected + step! + SITES.length) % SITES.length;\n    show(next);\n    document.getElementById(`${id}-tab-${next}`)?.focus();\n  };\n\n  return (\n    <section id=\"reel\" aria-label={reel.label} className={COLUMN}>\n      {/* The studio's ink in both themes; its tokens are the dark theme's, so text and lines read on it. */}\n      <div\n        ref={panel}\n        data-theme=\"dark\"\n        className=\"relative isolate grid gap-pad-md overflow-hidden rounded-surface bg-(--tessera-ink) p-2.5 text-text sm:p-pad-lg lg:grid-cols-[minmax(0,1fr)_15rem] lg:gap-pad-lg lg:p-pad-xl\"\n      >\n        <BrowserWindow frame={frame} clock={clock} panelId={`${id}-page`} tabId={`${id}-tab-${selected}`} reduced={reduced} />\n        <div className=\"flex min-w-0 flex-col gap-pad-md px-1.5 pb-1.5 sm:p-0\">\n          <div className=\"flex h-control-sm items-center justify-between gap-in\">\n            <p className=\"flex items-center gap-2 text-ui-sm text-text-secondary\">\n              <Tiles className=\"size-3.5 text-text\" />\n              {reel.label}\n            </p>\n            {!reduced && <PlayControl ended={frame.ended} playing={playing} onToggle={togglePlaying} onReplay={replay} />}\n          </div>\n          <div role=\"tablist\" onKeyDown={onTabKey} className=\"grid grid-cols-4 gap-1.5 lg:grid-cols-1 lg:gap-0\">\n            {SITES.map((site, i) => (\n              <ReelTab\n                key={site.id}\n                id={`${id}-tab-${i}`}\n                panelId={`${id}-page`}\n                index={i}\n                site={site}\n                selected={i === selected}\n                clock={clock}\n                reduced={reduced}\n                onSelect={() => show(i)}\n              />\n            ))}\n          </div>\n          <Mosaic />\n          <div className=\"flex min-w-0 flex-col gap-1\">\n            <p className=\"flex min-w-0 text-ui-md font-medium text-text\">\n              <TextSwap swapKey={current?.id ?? \"start\"}>{current?.client ?? reel.start.title}</TextSwap>\n            </p>\n            <p className=\"line-clamp-3 min-h-15 text-ui text-pretty text-text-secondary\">{current?.line ?? reel.start.line}</p>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n\n/**\n * The rail's framing: a mosaic of small tiles filling the space under the tabs, fading out\n * upward, one of them in vermilion. Drawn once and still; wide screens only.\n */\nfunction Mosaic() {\n  const tiles = Array.from({ length: 8 * 6 }, (_, i) => ({ x: (i % 8) * 14, y: Math.floor(i / 8) * 14, lit: i === 29 }));\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 110 82\"\n      className=\"pointer-events-none mt-auto w-full text-text [mask-image:linear-gradient(to_top,#000_10%,transparent_95%)] max-lg:hidden\"\n    >\n      {tiles.map((t) => (\n        <rect key={`${t.x}-${t.y}`} x={t.x} y={t.y} width=\"11\" height=\"11\" rx=\"1.5\" fill={t.lit ? \"var(--tessera-tile)\" : \"currentColor\"} fillOpacity={t.lit ? 1 : 0.07} />\n      ))}\n    </svg>\n  );\n}\n\n/**\n * Pause and play while the reel runs; Replay once it has rested on the last site. One button\n * whose label swaps, anchored to the end of the rail's first line, so nothing beside it moves\n * when its width does (rule 108).\n */\nfunction PlayControl({ ended, playing, onToggle, onReplay }: { ended: boolean; playing: boolean; onToggle: () => void; onReplay: () => void }) {\n  const state = ended ? \"replay\" : playing ? \"pause\" : \"play\";\n  const label = { replay: \"Replay\", pause: \"Pause\", play: \"Play\" }[state];\n  return (\n    <Button variant=\"ghost\" size=\"sm\" edge=\"end\" swapKey={state} onClick={ended ? onReplay : onToggle}>\n      {{ replay: <ReplayIcon />, pause: <PauseIcon />, play: <PlayIcon /> }[state]}\n      {label}\n    </Button>\n  );\n}\n\n/**\n * One site's tab. In the rail (lg) it is a row: number, client and disciplines, with a 2px\n * vermilion line down its leading edge that fills while its site is on the clock. On narrower\n * screens the tabs sit four across under the window and the line runs along the bottom. Both\n * lines are transforms read from the clock; under reduced motion they mark the selected tab.\n */\nfunction ReelTab({\n  id,\n  panelId,\n  index,\n  site,\n  selected,\n  clock,\n  reduced,\n  onSelect,\n}: {\n  id: string;\n  panelId: string;\n  index: number;\n  site: Project;\n  selected: boolean;\n  clock: MotionValue<number>;\n  reduced: boolean;\n  onSelect: () => void;\n}) {\n  const span = SPANS[index];\n  const progress = useTransform(clock, [span.start, span.end], [0, 1], { clamp: true });\n  const fill = reduced ? (selected ? 1 : 0) : progress;\n  return (\n    <button\n      type=\"button\"\n      role=\"tab\"\n      id={id}\n      aria-selected={selected}\n      tabIndex={selected ? 0 : -1}\n      aria-controls={panelId}\n      onClick={onSelect}\n      className={`group focus-ring relative flex min-w-0 cursor-pointer flex-col gap-2 rounded-control-xs pt-1 text-left text-ui-sm transition-colors duration-(--eased-duration-fast) ease-standard lg:gap-0.5 lg:py-2.5 lg:pr-0 lg:pl-pad-base ${\n        selected ? \"cursor-default text-text\" : \"text-text-secondary hover-fine:text-text\"\n      }`}\n    >\n      <span className=\"flex min-w-0 gap-1.5\">\n        <span className=\"tabular-nums\">{String(index + 1).padStart(2, \"0\")}</span>\n        <span className=\"truncate max-sm:sr-only\">{site.client}</span>\n      </span>\n      <span className=\"truncate text-ui-xs text-text-muted max-lg:hidden\">{site.disciplines.join(\", \")}</span>\n      <span aria-hidden className=\"relative h-0.5 overflow-hidden rounded-pill bg-line lg:absolute lg:inset-y-0 lg:left-0 lg:h-auto lg:w-0.5\">\n        <motion.span className=\"absolute inset-0 origin-left bg-(--tessera-tile) lg:hidden\" style={{ scaleX: fill }} />\n        <motion.span className=\"absolute inset-0 origin-top bg-(--tessera-tile) max-lg:hidden\" style={{ scaleY: fill }} />\n      </span>\n    </button>\n  );\n}\n\n/** The page's lines rise 8px, 80ms apart, as it loads; the outgoing page fades under it. */\nconst PAGE = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1, transition: { ...enter, staggerChildren: stagger.line, delayChildren: 0.05 } },\n  gone: { opacity: 0, transition: exit },\n};\n\n/**\n * A plain browser window, drawn: traffic lights, back and forward, the address bar and the page.\n * The page area is 16:10, 4:5 on a phone, and the client sites lay themselves out to fit it.\n */\nfunction BrowserWindow({ frame, clock, panelId, tabId, reduced }: { frame: Frame; clock: MotionValue<number>; panelId: string; tabId: string; reduced: boolean }) {\n  const site = frame.page >= 0 ? SITES[frame.page] : undefined;\n  return (\n    <div className=\"overflow-hidden rounded-panel border border-line bg-surface shadow-floating [--pad:0px]\">\n      <div className=\"relative grid h-11 grid-cols-[1fr_minmax(0,28rem)_1fr] items-center gap-3 border-b border-line-subtle px-3.5\">\n        <div className=\"flex items-center gap-4\">\n          <span aria-hidden className=\"flex gap-1.5\">\n            <span className=\"size-3 rounded-full bg-danger\" />\n            <span className=\"size-3 rounded-full bg-warning\" />\n            <span className=\"size-3 rounded-full bg-success\" />\n          </span>\n          <span aria-hidden className=\"flex gap-2 text-text-disabled max-sm:hidden\">\n            <Chevron className=\"rotate-180\" />\n            <Chevron />\n          </span>\n        </div>\n        <AddressBar frame={frame} />\n        <span aria-hidden className=\"flex justify-end text-text-muted max-sm:hidden\">\n          <PlusIcon />\n        </span>\n        <LoadLine key={frame.typing} clock={clock} typing={frame.typing} reduced={reduced} />\n      </div>\n      <div\n        id={panelId}\n        role=\"tabpanel\"\n        aria-labelledby={tabId}\n        className=\"relative aspect-[4/5] overflow-hidden bg-bg sm:aspect-[16/10]\"\n      >\n        <AnimatePresence initial={false}>\n          <motion.div\n            key={site?.id ?? \"start\"}\n            aria-hidden\n            className=\"absolute inset-0\"\n            initial=\"hidden\"\n            animate=\"shown\"\n            exit=\"gone\"\n            variants={reduced ? REDUCED_PAGE : PAGE}\n          >\n            {site ? <ClientArt id={site.id} /> : <StartPage />}\n          </motion.div>\n        </AnimatePresence>\n        {/* What the picture shows, for assistive tech: the drawn site itself is decoration. */}\n        <p className=\"sr-only\">{site ? `${site.client}: ${site.title}.` : reel.start.line}</p>\n      </div>\n    </div>\n  );\n}\n\nconst REDUCED_PAGE = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1, transition: reducedFade },\n  gone: { opacity: 0, transition: reducedFade },\n};\n\n/** The address bar: the domain of the site that is up, or the one being typed with a caret. */\nfunction AddressBar({ frame }: { frame: Frame }) {\n  const typing = frame.typing >= 0;\n  const domain = typing ? SITES[frame.typing].domain.slice(0, frame.typed) : frame.page >= 0 ? SITES[frame.page].domain : \"\";\n  return (\n    <div\n      aria-hidden\n      className={`flex h-7 min-w-0 items-center justify-center gap-1.5 rounded-control-sm px-3 text-ui-sm transition-colors duration-(--eased-duration-fast) ease-standard ${\n        typing ? \"bg-surface-active text-text\" : \"bg-surface-hover text-text-secondary\"\n      }`}\n    >\n      {!typing && domain && <LockIcon />}\n      <span className=\"truncate\">\n        {domain || (!typing && <span className=\"text-text-muted\">{brand.name.toLowerCase()} / work</span>)}\n      </span>\n      {typing && <span className=\"-ml-1 h-3.5 w-px shrink-0 bg-text\" />}\n    </div>\n  );\n}\n\n/** The 2px load line under the toolbar: it runs while a site loads, then fades. */\nfunction LoadLine({ clock, typing, reduced }: { clock: MotionValue<number>; typing: number; reduced: boolean }) {\n  const span = SPANS[Math.max(typing, 0)];\n  const scaleX = useTransform(clock, [span.loaded - LOAD, span.loaded], [0, 1], { clamp: true });\n  if (reduced || typing < 0) return null;\n  return <motion.span aria-hidden className=\"absolute inset-x-0 -bottom-px h-0.5 origin-left bg-(--tessera-tile)\" style={{ scaleX }} />;\n}\n\n/** The new tab before the first site: the studio's own start page, quiet. */\nfunction StartPage() {\n  return (\n    <div className=\"absolute inset-0 flex flex-col items-center justify-center gap-in bg-surface-sunken text-center\">\n      <p className=\"text-ui-md font-medium text-text\">{reel.start.title}</p>\n      <ol className=\"flex flex-col gap-1 text-ui text-text-muted\">\n        {SITES.map((s, i) => (\n          <li key={s.id} className=\"tabular-nums\">\n            {String(i + 1).padStart(2, \"0\")} {s.client}\n          </li>\n        ))}\n      </ol>\n    </div>\n  );\n}\n\nfunction Chevron({ className = \"\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" className={`size-4 shrink-0 ${className}`} fill=\"none\" aria-hidden>\n      <path d=\"m6.5 3.75 4.25 4.25-4.25 4.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction PlusIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0\" fill=\"none\" aria-hidden>\n      <path d=\"M8 3.25v9.5M3.25 8h9.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction LockIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3.5 shrink-0 text-text-muted\" fill=\"none\" aria-hidden>\n      <rect x=\"3.75\" y=\"7\" width=\"8.5\" height=\"6\" rx=\"1.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"M5.75 7V5.5a2.25 2.25 0 0 1 4.5 0V7\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n    </svg>\n  );\n}\n\nfunction PauseIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0\" fill=\"currentColor\" aria-hidden>\n      <rect x=\"4\" y=\"3.5\" width=\"2.75\" height=\"9\" rx=\"0.75\" />\n      <rect x=\"9.25\" y=\"3.5\" width=\"2.75\" height=\"9\" rx=\"0.75\" />\n    </svg>\n  );\n}\n\n/** Shifted right about 10% of its width, so it sits optically centred (rule 57). */\nfunction PlayIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0\" fill=\"currentColor\" aria-hidden>\n      <path d=\"M5.5 3.6v8.8a.75.75 0 0 0 1.14.64l7-4.4a.75.75 0 0 0 0-1.28l-7-4.4a.75.75 0 0 0-1.14.64Z\" />\n    </svg>\n  );\n}\n\nfunction ReplayIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0\" fill=\"none\" aria-hidden>\n      <path d=\"M3.25 8a4.75 4.75 0 1 0 1.4-3.37M3.25 3v2.5h2.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/eased/templates/agency/reel.tsx"
    }
  ],
  "meta": {
    "template": "agency",
    "kind": "template-section",
    "industries": [
      "media"
    ]
  },
  "type": "registry:block"
}