{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"umbra","type":"registry:page","title":"Umbra","description":"An immersive experience-studio site on one persistent cursor-reactive canvas — flow-field particles that push, swirl and knit into a constellation — opening with a 0→100 preloader and lag-follow cursor, scroll scrubbing the field's density and hue through chapters. No image assets; settles to a static frame under reduced-motion.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"app/umbra/page.tsx","target":"app/umbra/page.tsx","content":"\"use client\"\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\"\n\n/**\n * Umbra — an immersive, GPU-feel experience for a digital experience studio.\n *\n * The whole site sits on ONE persistent full-viewport canvas: a flow-field of\n * glowing particles that drift on their own, get pushed and swirled by cursor\n * velocity, and knit into a constellation wherever the pointer rests. Scroll acts\n * as a timeline scrubber — density, speed and hue morph through the journey — and\n * the field is the studio's proof of craft, so there is no photography anywhere.\n *\n * Deliberately against the tells: base is a pure cool graphite (not brown-tinted\n * near-black), the single accent is the brief's electric indigo (never a second\n * acid colour — hue only drifts inside the indigo family), no glass/backdrop-blur,\n * no arrow glyphs on links, no middle-dot metas, no tracked-caps eyebrows. Numbering\n * appears only on the chapters, which are a genuine three-part process. Every bit of\n * motion — preloader, canvas, custom cursor, reveals, FLIP — is gated on\n * prefers-reduced-motion, which renders a single calm static frame instead.\n */\n\nconst useIso = typeof window !== \"undefined\" ? useLayoutEffect : useEffect\n\ntype Project = {\n  title: string\n  client: string\n  kind: string\n  year: string\n  copy: string\n  results: string[]\n}\n\nconst PROJECTS: Project[] = [\n  {\n    title: \"Aurora\",\n    client: \"Kist Audio\",\n    kind: \"Launch experience\",\n    year: \"2025\",\n    copy: \"Kist built a synthesiser you play by moving your hands through the air. The site had to feel the same way, so we made a browser instrument: a field of tones you sculpt with the cursor, tuned to the hardware's real oscillators.\",\n    results: [\"4:12 average session\", \"Awwwards Site of the Day\", \"First run sold out in nine days\"],\n  },\n  {\n    title: \"Tide\",\n    client: \"Blue Current\",\n    kind: \"Interactive data story\",\n    year: \"2024\",\n    copy: \"Forty years of ocean-temperature readings, told as a single scroll. We rendered each reading as a moving current so the warming reads as motion rather than a chart, and let readers scrub any year to feel the shift.\",\n    results: [\"1.2M reads in the first month\", \"Cited in two UN briefings\", \"Webby nomination\"],\n  },\n  {\n    title: \"Meridian\",\n    client: \"Relay Logistics\",\n    kind: \"Realtime globe\",\n    year: \"2025\",\n    copy: \"A live globe on Relay's homepage drawing every shipment in motion on the GPU, so a hundred thousand arcs hold sixty frames. The sales team now opens every pitch by spinning it.\",\n    results: [\"100k live routes\", \"60fps on a four-year-old laptop\", \"Halved the pitch deck\"],\n  },\n  {\n    title: \"Loom\",\n    client: \"Atelier Vurn\",\n    kind: \"Fashion film site\",\n    year: \"2024\",\n    copy: \"A film-first site for a Paris atelier, where the collection unspools as you scroll and the cloth keeps moving even after you stop. No stock imagery, no grid — only fabric, type and time.\",\n    results: [\"Featured at Paris Fashion Week\", \"Zero stock photos\", \"Bounce rate under nine percent\"],\n  },\n]\n\nconst OBJECTS: { title: string; kind: string }[] = [\n  { title: \"Aurora\", kind: \"Browser instrument\" },\n  { title: \"Tide\", kind: \"Data story\" },\n  { title: \"Meridian\", kind: \"Realtime globe\" },\n  { title: \"Loom\", kind: \"Film site\" },\n  { title: \"Prism\", kind: \"Museum installation\" },\n  { title: \"Kern\", kind: \"Type playground\" },\n]\n\nconst CHAPTERS: { n: string; title: string; copy: string }[] = [\n  {\n    n: \"01\",\n    title: \"Signal\",\n    copy: \"Every project starts as one behaviour — a single hover, load, or transition — that we get obsessively right before anything else is allowed to exist.\",\n  },\n  {\n    n: \"02\",\n    title: \"Field\",\n    copy: \"Then we build the system around it. A particle field, a physics model, a shader — whatever it takes to make the whole thing move as one piece.\",\n  },\n  {\n    n: \"03\",\n    title: \"Light\",\n    copy: \"By launch it should feel inevitable. You stop noticing the engineering. You just don't want to close the tab.\",\n  },\n]\n\nconst CAPABILITIES = [\n  \"Immersive websites\",\n  \"WebGL and canvas\",\n  \"Realtime and shaders\",\n  \"Product launches\",\n  \"Brand systems in motion\",\n  \"Installations and spatial\",\n  \"Rapid prototyping\",\n  \"Art direction\",\n]\n\n/** Reveal children once, when they scroll into view. Reduced-motion shows instantly. */\nfunction useReveal<T extends HTMLElement>(threshold = 0.2) {\n  const ref = useRef<T>(null)\n  const [shown, setShown] = useState(false)\n  useEffect(() => {\n    const node = ref.current\n    if (!node) return\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      setShown(true)\n      return\n    }\n    const io = new IntersectionObserver(\n      ([e]) => {\n        if (e.isIntersecting) {\n          setShown(true)\n          io.disconnect()\n        }\n      },\n      { threshold },\n    )\n    io.observe(node)\n    return () => io.disconnect()\n  }, [threshold])\n  return [ref, shown] as const\n}\n\n/** One scroll-scrubbed narrative chapter. */\nfunction Chapter({ n, title, copy }: { n: string; title: string; copy: string }) {\n  const [ref, shown] = useReveal<HTMLDivElement>(0.45)\n  return (\n    <div className={`um-chapter ${shown ? \"is-shown\" : \"\"}`} ref={ref}>\n      <span className=\"um-ch-index\">{n}</span>\n      <h3 className=\"um-ch-title\">{title}</h3>\n      <p className=\"um-ch-copy\">{copy}</p>\n    </div>\n  )\n}\n\nexport default function Umbra() {\n  const reducedRef = useRef(false)\n  const [reduced, setReduced] = useState(false)\n\n  // preloader\n  const [count, setCount] = useState(0)\n  const [wipe, setWipe] = useState(false)\n  const [gone, setGone] = useState(false)\n\n  // canvas + cursor plumbing (refs, so the loop never re-renders React)\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const cursorRef = useRef<HTMLDivElement>(null)\n  const hudRef = useRef<HTMLSpanElement>(null)\n  const pointer = useRef({ x: -9999, y: -9999, vx: 0, vy: 0, active: false })\n  const ring = useRef({ x: 0, y: 0, scale: 1 })\n  const scroll = useRef({ p: 0 })\n  const [hasCursor, setHasCursor] = useState(false)\n  const [floatCta, setFloatCta] = useState(false)\n\n  // magnetic label over the objects grid\n  const magRef = useRef<HTMLDivElement>(null)\n  const [magText, setMagText] = useState(OBJECTS[0])\n  const [magOn, setMagOn] = useState(false)\n\n  // FLIP project panel\n  const cardRefs = useRef<(HTMLButtonElement | null)[]>([])\n  const panelRef = useRef<HTMLDivElement>(null)\n  const [active, setActive] = useState<number | null>(null)\n  const [panelReady, setPanelReady] = useState(false)\n\n  const [capsRef, capsShown] = useReveal<HTMLDivElement>(0.3)\n  const [objHeadRef, objHeadShown] = useReveal<HTMLDivElement>(0.3)\n  const [workHeadRef, workHeadShown] = useReveal<HTMLDivElement>(0.3)\n\n  /* ---- preloader: 0 -> 100 counter, then a wipe reveal ---- */\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    reducedRef.current = mq.matches\n    setReduced(mq.matches)\n    const onMq = () => {\n      reducedRef.current = mq.matches\n      setReduced(mq.matches)\n    }\n    mq.addEventListener(\"change\", onMq)\n\n    if (mq.matches) {\n      setCount(100)\n      setGone(true)\n      return () => mq.removeEventListener(\"change\", onMq)\n    }\n\n    document.body.style.overflow = \"hidden\"\n    const start = performance.now()\n    const DUR = 1900\n    let raf = 0\n    let done = 0\n    const tick = (now: number) => {\n      const t = Math.min(1, (now - start) / DUR)\n      const eased = 1 - Math.pow(1 - t, 3)\n      setCount(Math.round(eased * 100))\n      if (t < 1) raf = requestAnimationFrame(tick)\n      else {\n        setWipe(true)\n        done = window.setTimeout(() => {\n          setGone(true)\n          document.body.style.overflow = \"\"\n        }, 950)\n      }\n    }\n    raf = requestAnimationFrame(tick)\n    return () => {\n      cancelAnimationFrame(raf)\n      window.clearTimeout(done)\n      mq.removeEventListener(\"change\", onMq)\n      document.body.style.overflow = \"\"\n    }\n  }, [])\n\n  /* ---- scroll telemetry: progress drives the canvas + surfaces the floating CTA ---- */\n  useEffect(() => {\n    let raf = 0\n    const onScroll = () => {\n      if (raf) return\n      raf = requestAnimationFrame(() => {\n        const max = document.documentElement.scrollHeight - window.innerHeight\n        const p = max > 0 ? window.scrollY / max : 0\n        scroll.current.p = p\n        setFloatCta(p > 0.55 && p < 0.985)\n        raf = 0\n      })\n    }\n    window.addEventListener(\"scroll\", onScroll, { passive: true })\n    window.addEventListener(\"resize\", onScroll)\n    onScroll()\n    return () => {\n      window.removeEventListener(\"scroll\", onScroll)\n      window.removeEventListener(\"resize\", onScroll)\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [])\n\n  /* ---- the canvas engine: flow-field particles, cursor forces, custom cursor ---- */\n  useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const ctx = canvas.getContext(\"2d\", { alpha: false })\n    if (!ctx) return\n\n    let w = 0\n    let h = 0\n    let dpr = 1\n    const resize = () => {\n      dpr = Math.min(2, window.devicePixelRatio || 1)\n      w = window.innerWidth\n      h = window.innerHeight\n      canvas.width = Math.floor(w * dpr)\n      canvas.height = Math.floor(h * dpr)\n      canvas.style.width = w + \"px\"\n      canvas.style.height = h + \"px\"\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n    }\n    resize()\n\n    const N = Math.max(160, Math.min(820, Math.floor((w * h) / 1900)))\n    const P = Array.from({ length: N }, () => ({\n      x: Math.random() * w,\n      y: Math.random() * h,\n      vx: 0,\n      vy: 0,\n      z: 0.4 + Math.random() * 0.9,\n    }))\n\n    const field = (x: number, y: number, t: number) => {\n      const s = 0.0016\n      return (\n        (Math.sin(x * s + t * 0.15) +\n          Math.cos(y * s * 1.3 - t * 0.12) +\n          Math.sin((x + y) * s * 0.6 + t * 0.08)) *\n        1.05\n      )\n    }\n\n    // reduced motion: settle the field once and freeze it, no listeners, no loop\n    if (reducedRef.current) {\n      ctx.fillStyle = \"#070709\"\n      ctx.fillRect(0, 0, w, h)\n      ctx.globalCompositeOperation = \"lighter\"\n      for (let step = 0; step < 70; step++) {\n        for (let i = 0; i < N; i++) {\n          const a = P[i]\n          const ang = field(a.x, a.y, step * 0.05) * Math.PI\n          a.x += Math.cos(ang) * 1.25\n          a.y += Math.sin(ang) * 1.25\n        }\n      }\n      for (let i = 0; i < N; i++) {\n        const a = P[i]\n        ctx.beginPath()\n        ctx.fillStyle = \"hsla(248, 95%, 66%, 0.5)\"\n        ctx.arc(((a.x % w) + w) % w, ((a.y % h) + h) % h, a.z * 1.5, 0, 6.283)\n        ctx.fill()\n      }\n      const onR = () => {\n        resize()\n        ctx.fillStyle = \"#070709\"\n        ctx.fillRect(0, 0, w, h)\n      }\n      window.addEventListener(\"resize\", onR)\n      return () => window.removeEventListener(\"resize\", onR)\n    }\n\n    const coarse = window.matchMedia(\"(pointer: coarse)\").matches\n    if (!coarse) setHasCursor(true)\n\n    const onMove = (e: PointerEvent) => {\n      const pt = pointer.current\n      let dx = e.clientX - pt.x\n      let dy = e.clientY - pt.y\n      if (!pt.active) {\n        dx = 0\n        dy = 0\n      }\n      pt.vx = Math.max(-42, Math.min(42, dx))\n      pt.vy = Math.max(-42, Math.min(42, dy))\n      pt.x = e.clientX\n      pt.y = e.clientY\n      pt.active = true\n    }\n    const onLeave = () => {\n      pointer.current.active = false\n    }\n    const onOver = (e: PointerEvent) => {\n      const el = (e.target as HTMLElement)?.closest(\"a, button, .um-tile, .um-card\")\n      ring.current.scale = el ? 2 : 1\n      if (cursorRef.current) cursorRef.current.classList.toggle(\"is-hover\", !!el)\n    }\n    window.addEventListener(\"pointermove\", onMove, { passive: true })\n    window.addEventListener(\"pointerover\", onOver, { passive: true })\n    window.addEventListener(\"pointerleave\", onLeave)\n    window.addEventListener(\"blur\", onLeave)\n    window.addEventListener(\"resize\", resize)\n\n    ctx.fillStyle = \"#070709\"\n    ctx.fillRect(0, 0, w, h)\n\n    let t = 0\n    let frames = 0\n    let raf = 0\n    const loop = () => {\n      t += 0.016\n      frames++\n      const p = scroll.current.p\n      const speed = 0.6 + p * 1.15\n      const hue = 250 - p * 26 // stays inside the indigo family (250 -> 224)\n      const density = 0.55 + p * 0.45\n      const pt = pointer.current\n\n      // motion trails instead of a hard clear -> the additive-glow bloom look\n      ctx.globalCompositeOperation = \"source-over\"\n      ctx.fillStyle = \"rgba(7, 7, 9, 0.14)\"\n      ctx.fillRect(0, 0, w, h)\n\n      ctx.globalCompositeOperation = \"lighter\"\n      for (let i = 0; i < N; i++) {\n        const a = P[i]\n        const ang = field(a.x, a.y, t) * Math.PI\n        a.vx += Math.cos(ang) * 0.06 * speed * a.z\n        a.vy += Math.sin(ang) * 0.06 * speed * a.z\n        if (pt.active) {\n          const dx = a.x - pt.x\n          const dy = a.y - pt.y\n          const d2 = dx * dx + dy * dy\n          const R = 175\n          if (d2 < R * R) {\n            const d = Math.sqrt(d2) || 1\n            const f = 1 - d / R\n            a.vx += (dx / d) * f * 2.5 + (-dy / d) * f * 1.15 + pt.vx * 0.045 * f\n            a.vy += (dy / d) * f * 2.5 + (dx / d) * f * 1.15 + pt.vy * 0.045 * f\n          }\n        }\n        a.vx *= 0.92\n        a.vy *= 0.92\n        a.x += a.vx\n        a.y += a.vy\n        if (a.x < -12) a.x = w + 12\n        else if (a.x > w + 12) a.x = -12\n        if (a.y < -12) a.y = h + 12\n        else if (a.y > h + 12) a.y = -12\n        const size = a.z * 1.5 * (0.7 + density * 0.6)\n        ctx.beginPath()\n        ctx.fillStyle = `hsla(${hue}, 95%, ${60 + a.z * 10}%, ${0.45 * density + 0.2})`\n        ctx.arc(a.x, a.y, size, 0, 6.283)\n        ctx.fill()\n      }\n\n      // constellation: the field knits into a network only where the cursor rests\n      if (pt.active) {\n        const near: { x: number; y: number }[] = []\n        const R = 150\n        for (let i = 0; i < N; i++) {\n          const a = P[i]\n          const dx = a.x - pt.x\n          const dy = a.y - pt.y\n          if (dx * dx + dy * dy < R * R) near.push(a)\n        }\n        ctx.lineWidth = 1\n        for (let i = 0; i < near.length; i++) {\n          for (let j = i + 1; j < near.length; j++) {\n            const a = near[i]\n            const b = near[j]\n            const dx = a.x - b.x\n            const dy = a.y - b.y\n            const d2 = dx * dx + dy * dy\n            if (d2 < 3600) {\n              const al = (1 - Math.sqrt(d2) / 60) * 0.5\n              ctx.strokeStyle = `hsla(${hue}, 95%, 72%, ${al})`\n              ctx.beginPath()\n              ctx.moveTo(a.x, a.y)\n              ctx.lineTo(b.x, b.y)\n              ctx.stroke()\n            }\n          }\n        }\n      }\n      pt.vx *= 0.86\n      pt.vy *= 0.86\n\n      // custom cursor ring: lags the pointer, grows over interactive targets\n      const r = ring.current\n      r.x += (pt.x - r.x) * 0.18\n      r.y += (pt.y - r.y) * 0.18\n      if (cursorRef.current) {\n        cursorRef.current.style.transform = `translate3d(${r.x}px, ${r.y}px, 0) translate(-50%, -50%) scale(${r.scale})`\n      }\n      // live pointer telemetry in the HUD, a few times a second\n      if (frames % 6 === 0 && hudRef.current && pt.active) {\n        hudRef.current.textContent = `x ${String(Math.round(pt.x)).padStart(4, \"0\")}  y ${String(\n          Math.round(pt.y),\n        ).padStart(4, \"0\")}`\n      }\n\n      raf = requestAnimationFrame(loop)\n    }\n    raf = requestAnimationFrame(loop)\n\n    return () => {\n      cancelAnimationFrame(raf)\n      window.removeEventListener(\"pointermove\", onMove)\n      window.removeEventListener(\"pointerover\", onOver)\n      window.removeEventListener(\"pointerleave\", onLeave)\n      window.removeEventListener(\"blur\", onLeave)\n      window.removeEventListener(\"resize\", resize)\n    }\n  }, [])\n\n  /* ---- FLIP: a card grows into the detail panel from its own rect ---- */\n  useIso(() => {\n    if (active === null) return\n    const panel = panelRef.current\n    const src = cardRefs.current[active]\n    if (!panel) return\n    if (reducedRef.current || !src) {\n      panel.style.transform = \"none\"\n      panel.style.opacity = \"1\"\n      setPanelReady(true)\n      return\n    }\n    const s = src.getBoundingClientRect()\n    const pr = panel.getBoundingClientRect()\n    const dx = s.left - pr.left\n    const dy = s.top - pr.top\n    const sx = s.width / pr.width\n    const sy = s.height / pr.height\n    panel.style.transformOrigin = \"top left\"\n    panel.style.transition = \"none\"\n    panel.style.opacity = \"1\"\n    panel.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`\n    void panel.offsetWidth\n    requestAnimationFrame(() => {\n      panel.style.transition = \"transform 0.62s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease\"\n      panel.style.transform = \"none\"\n      setPanelReady(true)\n    })\n  }, [active])\n\n  const closePanel = () => {\n    const panel = panelRef.current\n    const src = active !== null ? cardRefs.current[active] : null\n    if (!panel || !src || reducedRef.current) {\n      setActive(null)\n      setPanelReady(false)\n      return\n    }\n    const s = src.getBoundingClientRect()\n    const pr = panel.getBoundingClientRect()\n    const dx = s.left - pr.left\n    const dy = s.top - pr.top\n    const sx = s.width / pr.width\n    const sy = s.height / pr.height\n    setPanelReady(false)\n    panel.style.transition = \"transform 0.5s cubic-bezier(0.7, 0, 0.84, 0), opacity 0.4s ease\"\n    panel.style.transformOrigin = \"top left\"\n    panel.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`\n    panel.style.opacity = \"0\"\n    const finish = () => {\n      panel.removeEventListener(\"transitionend\", finish)\n      setActive(null)\n    }\n    panel.addEventListener(\"transitionend\", finish)\n  }\n\n  /* ---- lock scroll + wire Escape while the panel is open ---- */\n  useEffect(() => {\n    if (active === null) return\n    const prev = document.body.style.overflow\n    document.body.style.overflow = \"hidden\"\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") closePanel()\n    }\n    window.addEventListener(\"keydown\", onKey)\n    return () => {\n      document.body.style.overflow = prev\n      window.removeEventListener(\"keydown\", onKey)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [active])\n\n  const onGridMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (reducedRef.current || !magRef.current) return\n    const box = e.currentTarget.getBoundingClientRect()\n    magRef.current.style.transform = `translate(${e.clientX - box.left}px, ${e.clientY - box.top}px) translate(-50%, -140%)`\n  }\n\n  return (\n    <div className={`umbra-root ${hasCursor ? \"has-cursor\" : \"\"} ${gone ? \"is-ready\" : \"is-loading\"}`}>\n      <style>{css}</style>\n\n      <canvas ref={canvasRef} className=\"um-canvas\" aria-hidden=\"true\" />\n\n      {/* CUSTOM CURSOR — a ring that lags the pointer (hidden on touch / reduced motion) */}\n      <div ref={cursorRef} className=\"um-cursor\" aria-hidden=\"true\" />\n\n      {/* PRELOADER */}\n      {!gone && (\n        <div className={`um-pre ${wipe ? \"is-wipe\" : \"\"}`} aria-hidden=\"true\">\n          <div className=\"um-pre-num\">\n            <span className=\"um-pre-count\">{count}</span>\n            <span className=\"um-pre-pct\">%</span>\n          </div>\n          <div className=\"um-pre-bar\">\n            <span className=\"um-pre-bar-fill\" style={{ width: `${count}%` }} />\n          </div>\n          <span className=\"um-pre-label\">Umbra — compiling the field</span>\n        </div>\n      )}\n\n      {/* HUD NAV */}\n      <header className={`um-nav ${gone ? \"um-in\" : \"\"}`}>\n        <a className=\"um-mark\" href=\"#top\">\n          Umbra\n        </a>\n        <nav className=\"um-nav-links\">\n          <a className=\"um-nav-a\" href=\"#work\">\n            Work\n          </a>\n          <a className=\"um-nav-a\" href=\"#studio\">\n            Studio\n          </a>\n          <a className=\"um-nav-a\" href=\"#studio\">\n            Capabilities\n          </a>\n          <a className=\"um-nav-cta\" href=\"#start\">\n            Start a project\n          </a>\n        </nav>\n        <div className=\"um-hud\" aria-hidden=\"true\">\n          <span className=\"um-hud-dot\" />\n          <span className=\"um-hud-read\" ref={hudRef}>\n            live field\n          </span>\n        </div>\n      </header>\n\n      {/* HERO */}\n      <section className=\"um-hero\" id=\"top\">\n        <div className={`um-hero-inner ${gone ? \"um-in\" : \"\"}`}>\n          <p className=\"um-kicker\">A digital experience studio in Lisbon</p>\n          <h1 className=\"um-title\">\n            <span className=\"um-title-line\">Interfaces that</span>\n            <span className=\"um-title-line\">behave like</span>\n            <span className=\"um-title-line um-title-accent\">light.</span>\n          </h1>\n          <p className=\"um-lede\">\n            Umbra builds immersive websites, product launches, and installations for people who want the\n            work itself to be the proof. This page is running the same engine we ship.\n          </p>\n          <div className=\"um-hero-meta\">\n            <a className=\"um-hero-cta\" href=\"#work\">\n              See selected work\n            </a>\n            <span className=\"um-hero-note\">Move your cursor. The field is listening.</span>\n          </div>\n        </div>\n        <div className=\"um-scrollcue\" aria-hidden=\"true\">\n          <span />\n        </div>\n      </section>\n\n      {/* CHAPTERS — scroll is the scrubber; the field morphs as these reveal */}\n      <section className=\"um-chapters\" id=\"studio\">\n        <div className=\"um-scrub\" aria-hidden=\"true\">\n          <span className=\"um-scrub-line\" />\n        </div>\n        <div className=\"um-chapters-head\">\n          <p className=\"um-eyebrow\">How the work gets made</p>\n          <p className=\"um-chapters-sub\">\n            Three moves, in order. The field behind you shifts a little with each one.\n          </p>\n        </div>\n        {CHAPTERS.map((c) => (\n          <Chapter key={c.n} n={c.n} title={c.title} copy={c.copy} />\n        ))}\n      </section>\n\n      {/* OBJECTS — a hover-reactive grid with a magnetic label */}\n      <section className=\"um-objects\">\n        <div className={`um-obj-head ${objHeadShown ? \"is-shown\" : \"\"}`} ref={objHeadRef}>\n          <h2 className=\"um-h2\">Six things we made move</h2>\n          <p className=\"um-obj-sub\">Hover to bring one forward. The label follows your cursor.</p>\n        </div>\n        <div className=\"um-grid\" onPointerMove={onGridMove} onPointerLeave={() => setMagOn(false)}>\n          {OBJECTS.map((o, i) => (\n            <div\n              className=\"um-tile\"\n              key={o.title}\n              style={{ \"--i\": i } as React.CSSProperties}\n              onPointerEnter={() => {\n                setMagText(o)\n                setMagOn(true)\n              }}\n            >\n              <div className=\"um-tile-art\" data-v={i % 4} />\n              <span className=\"um-tile-name\">{o.title}</span>\n            </div>\n          ))}\n          <div ref={magRef} className={`um-mag ${magOn ? \"is-on\" : \"\"}`} aria-hidden=\"true\">\n            <span className=\"um-mag-title\">{magText.title}</span>\n            <span className=\"um-mag-kind\">{magText.kind}</span>\n          </div>\n        </div>\n      </section>\n\n      {/* WORK — cards that FLIP-grow into a detail panel */}\n      <section className=\"um-work\" id=\"work\">\n        <div className={`um-work-head ${workHeadShown ? \"is-shown\" : \"\"}`} ref={workHeadRef}>\n          <h2 className=\"um-h2\">Selected work</h2>\n          <p className=\"um-work-sub\">Four we can talk about. Open one.</p>\n        </div>\n        <div className=\"um-cards\">\n          {PROJECTS.map((p, i) => (\n            <button\n              className=\"um-card\"\n              key={p.title}\n              ref={(el) => {\n                cardRefs.current[i] = el\n              }}\n              onClick={() => {\n                setActive(i)\n                setPanelReady(false)\n              }}\n              aria-label={`Open ${p.title} for ${p.client}`}\n            >\n              <div className=\"um-card-art\" data-v={i % 4} />\n              <div className=\"um-card-body\">\n                <div className=\"um-card-top\">\n                  <h3 className=\"um-card-title\">{p.title}</h3>\n                  <span className=\"um-card-year\">{p.year}</span>\n                </div>\n                <p className=\"um-card-client\">{p.client}</p>\n                <p className=\"um-card-kind\">{p.kind}</p>\n              </div>\n              <span className=\"um-card-open\">Open</span>\n            </button>\n          ))}\n        </div>\n      </section>\n\n      {/* CAPABILITIES — kinetic list, no numbering (not a sequence) */}\n      <section className=\"um-caps\">\n        <div className={`um-caps-inner ${capsShown ? \"is-shown\" : \"\"}`} ref={capsRef}>\n          <p className=\"um-eyebrow\">What we do</p>\n          <ul className=\"um-caps-list\">\n            {CAPABILITIES.map((c, i) => (\n              <li className=\"um-cap\" key={c} style={{ \"--d\": `${i * 60}ms` } as React.CSSProperties}>\n                <span className=\"um-cap-text\">{c}</span>\n              </li>\n            ))}\n          </ul>\n        </div>\n      </section>\n\n      {/* CTA — surfaces at the end of the journey */}\n      <section className=\"um-cta\" id=\"start\">\n        <div className=\"um-cta-inner\">\n          <h2 className=\"um-cta-title\">Have something that should feel alive?</h2>\n          <p className=\"um-cta-lede\">\n            Tell us what you're launching and what you want people to feel. We reply the same day, with an\n            honest read on whether we're the right studio for it.\n          </p>\n          <a className=\"um-cta-btn\" href=\"mailto:studio@umbra.work\">\n            Start a project\n          </a>\n          <div className=\"um-cta-contact\">\n            <a href=\"mailto:studio@umbra.work\">studio@umbra.work</a>\n            <span>Rua do Século, Lisbon</span>\n          </div>\n        </div>\n      </section>\n\n      <footer className=\"um-footer\">\n        <span className=\"um-mark\">Umbra</span>\n        <p className=\"um-foot-copy\">A digital experience studio. We make the screen worth looking at.</p>\n        <div className=\"um-foot-links\">\n          <a href=\"#work\">Work</a>\n          <a href=\"#studio\">Studio</a>\n          <a href=\"mailto:studio@umbra.work\">Contact</a>\n        </div>\n        <p className=\"um-foot-fine\">Booking select projects for 2026.</p>\n      </footer>\n\n      {/* FLOATING CTA — anchored, appears deep in the journey */}\n      <a className={`um-float ${floatCta ? \"is-on\" : \"\"}`} href=\"#start\">\n        Start a project\n      </a>\n\n      {/* FLIP DETAIL PANEL */}\n      {active !== null && (\n        <div className={`um-overlay ${panelReady ? \"is-open\" : \"\"}`}>\n          <button className=\"um-scrim\" aria-label=\"Close\" onClick={closePanel} />\n          <div className=\"um-panel\" ref={panelRef}>\n            <div className=\"um-panel-art\" data-v={active % 4} />\n            <div className={`um-panel-body ${panelReady ? \"is-in\" : \"\"}`}>\n              <button className=\"um-panel-close\" onClick={closePanel} aria-label=\"Close panel\">\n                Close\n              </button>\n              <span className=\"um-panel-kind\">{PROJECTS[active].kind}</span>\n              <h3 className=\"um-panel-title\">{PROJECTS[active].title}</h3>\n              <p className=\"um-panel-client\">\n                {PROJECTS[active].client}\n                <span className=\"um-panel-year\">{PROJECTS[active].year}</span>\n              </p>\n              <p className=\"um-panel-copy\">{PROJECTS[active].copy}</p>\n              <ul className=\"um-panel-results\">\n                {PROJECTS[active].results.map((r) => (\n                  <li key={r}>{r}</li>\n                ))}\n              </ul>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n\nconst cssBase = `\n.umbra-root {\n  --void: #070709;\n  --void-2: #0a0a0e;\n  --ink: #111119;\n  --pale: #f3f3fa;\n  --mute: #9a9ab6;\n  --faint: rgba(154, 154, 182, 0.14);\n  --line: rgba(154, 154, 182, 0.2);\n  --indigo: #6c5cff;\n  --indigo-lo: rgba(108, 92, 255, 0.16);\n  position: relative;\n  background: var(--void);\n  color: var(--pale);\n  font-family: var(--font-geist), system-ui, sans-serif;\n  -webkit-font-smoothing: antialiased;\n  line-height: 1.6;\n  overflow-x: hidden;\n}\n.umbra-root *,\n.umbra-root *::before,\n.umbra-root *::after { box-sizing: border-box; }\n.umbra-root.has-cursor,\n.umbra-root.has-cursor a,\n.umbra-root.has-cursor button { cursor: none; }\n.umbra-root ::selection { background: var(--indigo); color: #fff; }\n.umbra-root a { color: inherit; text-decoration: none; }\n.umbra-root :focus-visible {\n  outline: 2px solid var(--indigo);\n  outline-offset: 3px;\n  border-radius: 2px;\n}\n\n/* the persistent field */\n.um-canvas {\n  position: fixed; inset: 0; z-index: 0;\n  width: 100%; height: 100%; display: block;\n}\n\n/* custom cursor */\n.um-cursor {\n  position: fixed; top: 0; left: 0; z-index: 9999;\n  width: 34px; height: 34px; margin: 0; border-radius: 50%;\n  border: 1.5px solid rgba(108, 92, 255, 0.85);\n  pointer-events: none; display: none;\n  mix-blend-mode: screen;\n  transition: width 0.3s, height 0.3s, background 0.3s;\n  will-change: transform;\n}\n.um-cursor::after {\n  content: \"\"; position: absolute; top: 50%; left: 50%;\n  width: 4px; height: 4px; border-radius: 50%; background: var(--indigo);\n  transform: translate(-50%, -50%);\n}\n.um-cursor.is-hover { background: rgba(108, 92, 255, 0.12); }\n.has-cursor .um-cursor { display: block; }\n\n/* PRELOADER */\n.um-pre {\n  position: fixed; inset: 0; z-index: 9000;\n  background: var(--void);\n  display: flex; flex-direction: column; align-items: center; justify-content: center;\n  gap: 1.4rem;\n  transition: transform 0.9s cubic-bezier(0.76, 0, 0.24, 1);\n}\n.um-pre.is-wipe { transform: translateY(-100%); }\n.um-pre-num { display: flex; align-items: baseline; gap: 0.15rem; color: var(--pale); }\n.um-pre-count {\n  font-family: var(--font-geist-mono), monospace;\n  font-size: clamp(3.5rem, 12vw, 7rem); font-weight: 500;\n  letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1;\n}\n.um-pre-pct {\n  font-family: var(--font-geist-mono), monospace;\n  font-size: 1.3rem; color: var(--indigo);\n}\n.um-pre-bar {\n  width: min(340px, 62vw); height: 2px; background: var(--faint); overflow: hidden; border-radius: 2px;\n}\n.um-pre-bar-fill {\n  display: block; height: 100%;\n  background: linear-gradient(90deg, var(--indigo), #a99cff);\n  box-shadow: 0 0 18px rgba(108, 92, 255, 0.7);\n}\n.um-pre-label {\n  font-family: var(--font-geist-mono), monospace;\n  font-size: 0.8rem; color: var(--mute); letter-spacing: 0.01em;\n}\n\n/* NAV */\n.um-nav {\n  position: fixed; top: 0; left: 0; right: 0; z-index: 60;\n  display: flex; align-items: center; justify-content: space-between;\n  gap: 1rem;\n  padding: 1.35rem clamp(1.1rem, 4vw, 3rem);\n  background: linear-gradient(to bottom, rgba(7, 7, 9, 0.7), transparent);\n  opacity: 0; transform: translateY(-10px);\n}\n.um-nav.um-in { opacity: 1; transform: none; transition: opacity 0.8s ease 0.3s, transform 0.8s ease 0.3s; }\n.um-mark {\n  font-family: var(--font-geist), system-ui, sans-serif;\n  font-weight: 600; font-size: 1.3rem; letter-spacing: -0.02em; color: var(--pale);\n}\n.um-nav-links { display: flex; align-items: center; gap: clamp(1rem, 2.4vw, 2.1rem); }\n.um-nav-a { font-size: 0.95rem; color: var(--mute); transition: color 0.3s; }\n.um-nav-a:hover { color: var(--pale); }\n.um-nav-cta {\n  font-size: 0.9rem; color: var(--pale);\n  border: 1px solid var(--line); border-radius: 999px; padding: 0.5rem 1.05rem;\n  transition: border-color 0.3s, background 0.3s, color 0.3s;\n}\n.um-nav-cta:hover { background: var(--indigo); border-color: var(--indigo); color: #fff; }\n.um-hud { display: flex; align-items: center; gap: 0.5rem; }\n.um-hud-dot {\n  width: 7px; height: 7px; border-radius: 50%; background: var(--indigo);\n  box-shadow: 0 0 10px var(--indigo); animation: umpulse 2.6s ease-in-out infinite;\n}\n@keyframes umpulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }\n.um-hud-read {\n  font-family: var(--font-geist-mono), monospace;\n  font-size: 0.78rem; color: var(--mute); font-variant-numeric: tabular-nums;\n  letter-spacing: 0.02em; min-width: 8.5ch;\n}\n\n/* HERO */\n.um-hero {\n  position: relative; z-index: 1;\n  min-height: 100svh; display: flex; align-items: center;\n  padding: 7rem clamp(1.25rem, 6vw, 6rem) 4rem;\n}\n.um-hero-inner {\n  max-width: 62rem;\n  opacity: 0; transform: translateY(24px);\n}\n.um-hero-inner.um-in { opacity: 1; transform: none; transition: opacity 1s ease 0.55s, transform 1s cubic-bezier(0.16, 1, 0.3, 1) 0.55s; }\n.um-kicker {\n  color: var(--mute); font-size: clamp(0.95rem, 1.4vw, 1.1rem); margin: 0 0 1.6rem;\n}\n.um-title {\n  margin: 0; font-weight: 620;\n  font-size: clamp(3rem, 11.5vw, 9.5rem); line-height: 0.9; letter-spacing: -0.035em;\n}\n.um-title-line { display: block; }\n.um-title-accent {\n  color: var(--indigo);\n  text-shadow: 0 0 48px rgba(108, 92, 255, 0.55);\n}\n.um-lede {\n  max-width: 40rem; margin: 2rem 0 0; font-family: var(--font-inter), system-ui, sans-serif;\n  font-size: clamp(1.05rem, 1.6vw, 1.35rem); color: var(--pale); opacity: 0.9;\n  text-shadow: 0 2px 30px rgba(7, 7, 9, 0.8);\n}\n.um-hero-meta { display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap; margin-top: 2.4rem; }\n.um-hero-cta {\n  display: inline-block; background: var(--indigo); color: #fff; font-weight: 550;\n  padding: 0.9rem 1.7rem; border-radius: 999px;\n  transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s;\n}\n.um-hero-cta:hover { transform: translateY(-3px); box-shadow: 0 20px 44px -14px rgba(108, 92, 255, 0.75); }\n.um-hero-note {\n  font-family: var(--font-geist-mono), monospace; font-size: 0.82rem; color: var(--mute);\n}\n.um-scrollcue { position: absolute; left: 50%; bottom: 1.8rem; transform: translateX(-50%); }\n.um-scrollcue span {\n  display: block; width: 1px; height: 44px;\n  background: linear-gradient(to bottom, transparent, var(--indigo));\n  animation: umcue 2.4s ease-in-out infinite;\n}\n@keyframes umcue { 0%, 100% { opacity: 0.2; transform: scaleY(0.55); transform-origin: top; } 50% { opacity: 1; transform: scaleY(1); } }\n`\n\nconst cssMore = `\n.um-eyebrow {\n  font-family: var(--font-geist-mono), monospace;\n  font-size: 0.82rem; color: var(--indigo); margin: 0 0 0.9rem; letter-spacing: 0.02em;\n}\n.um-h2 {\n  margin: 0; font-weight: 600; font-size: clamp(2.1rem, 5.5vw, 4rem);\n  line-height: 1.0; letter-spacing: -0.03em;\n}\n\n/* CHAPTERS */\n.um-chapters {\n  position: relative; z-index: 1;\n  padding: clamp(6rem, 16vh, 12rem) clamp(1.25rem, 6vw, 6rem);\n  display: grid; gap: clamp(3rem, 9vh, 7rem);\n  max-width: 60rem; margin: 0 auto;\n}\n.um-scrub {\n  position: absolute; left: clamp(0.6rem, 3vw, 3rem); top: 12%; bottom: 12%; width: 2px;\n  background: var(--faint); border-radius: 2px; overflow: hidden;\n}\n.um-scrub-line {\n  position: absolute; inset: 0; transform-origin: top;\n  background: linear-gradient(to bottom, var(--indigo), transparent);\n  animation: umscrub linear both; animation-timeline: view();\n}\n@keyframes umscrub { from { transform: scaleY(0); } to { transform: scaleY(1); } }\n.um-chapters-head { max-width: 34rem; }\n.um-chapters-sub {\n  margin: 0; font-family: var(--font-inter), system-ui, sans-serif;\n  color: var(--mute); font-size: 1.05rem;\n}\n.um-chapter {\n  position: relative; padding-left: clamp(2.6rem, 6vw, 4.5rem);\n  opacity: 0; transform: translateY(30px);\n  transition: opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1), transform 0.9s cubic-bezier(0.16, 1, 0.3, 1);\n}\n.um-chapter.is-shown { opacity: 1; transform: none; }\n.um-ch-index {\n  font-family: var(--font-geist-mono), monospace;\n  font-size: 0.85rem; color: var(--indigo); font-variant-numeric: tabular-nums;\n}\n.um-ch-title {\n  margin: 0.5rem 0 1rem; font-weight: 600;\n  font-size: clamp(2rem, 6vw, 4.2rem); letter-spacing: -0.03em; line-height: 0.98;\n}\n.um-ch-copy {\n  margin: 0; max-width: 40rem; font-family: var(--font-inter), system-ui, sans-serif;\n  font-size: clamp(1.05rem, 1.7vw, 1.4rem); color: var(--pale); opacity: 0.86;\n  text-shadow: 0 2px 26px rgba(7, 7, 9, 0.75);\n}\n\n/* OBJECTS GRID */\n.um-objects {\n  position: relative; z-index: 1;\n  padding: clamp(5rem, 12vh, 9rem) clamp(1.25rem, 6vw, 6rem);\n  background: linear-gradient(to bottom, transparent, rgba(7, 7, 9, 0.72) 22%, rgba(7, 7, 9, 0.72) 78%, transparent);\n}\n.um-obj-head {\n  max-width: 40rem; margin: 0 auto clamp(2.5rem, 5vw, 4rem); text-align: center;\n  opacity: 0; transform: translateY(22px); transition: opacity 0.8s ease, transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);\n}\n.um-obj-head.is-shown { opacity: 1; transform: none; }\n.um-obj-sub { margin: 0.8rem 0 0; color: var(--mute); font-family: var(--font-inter), system-ui, sans-serif; }\n.um-grid {\n  position: relative; max-width: 68rem; margin: 0 auto;\n  display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;\n}\n.um-tile {\n  position: relative; aspect-ratio: 4 / 3; border-radius: 14px; overflow: hidden;\n  border: 1px solid var(--line);\n  transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1), filter 0.5s, opacity 0.5s, border-color 0.5s;\n}\n.um-tile-art { position: absolute; inset: 0; }\n.um-tile-name {\n  position: absolute; left: 1rem; bottom: 0.85rem; z-index: 2;\n  font-weight: 550; font-size: 1.15rem; letter-spacing: -0.01em;\n  opacity: 0; transform: translateY(6px); transition: opacity 0.4s, transform 0.4s;\n}\n.um-grid:hover .um-tile { opacity: 0.4; filter: saturate(0.6); }\n.um-tile:hover {\n  opacity: 1 !important; filter: none !important;\n  transform: scale(1.06) rotate(-0.6deg); border-color: rgba(108, 92, 255, 0.6); z-index: 3;\n}\n.um-tile:hover .um-tile-name { opacity: 1; transform: none; }\n.um-mag {\n  position: absolute; top: 0; left: 0; z-index: 5; pointer-events: none;\n  display: flex; flex-direction: column; align-items: center; gap: 0.15rem;\n  padding: 0.55rem 1rem; border-radius: 999px;\n  background: var(--indigo); color: #fff; white-space: nowrap;\n  opacity: 0; transition: opacity 0.3s ease;\n  will-change: transform;\n}\n.um-mag.is-on { opacity: 1; }\n.um-mag-title { font-weight: 600; font-size: 0.95rem; }\n.um-mag-kind { font-family: var(--font-geist-mono), monospace; font-size: 0.7rem; opacity: 0.85; }\n\n/* WORK CARDS */\n.um-work {\n  position: relative; z-index: 1;\n  padding: clamp(5rem, 12vh, 9rem) clamp(1.25rem, 6vw, 6rem);\n}\n.um-work-head {\n  max-width: 40rem; margin-bottom: clamp(2.5rem, 5vw, 4rem);\n  opacity: 0; transform: translateY(22px); transition: opacity 0.8s ease, transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);\n}\n.um-work-head.is-shown { opacity: 1; transform: none; }\n.um-work-sub { margin: 0.8rem 0 0; color: var(--mute); font-family: var(--font-inter), system-ui, sans-serif; }\n.um-cards {\n  max-width: 72rem; margin: 0 auto;\n  display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.2rem;\n}\n.um-card {\n  position: relative; text-align: left; overflow: hidden;\n  background: var(--void-2); border: 1px solid var(--line); border-radius: 16px;\n  padding: 0; color: inherit; font: inherit;\n  transition: border-color 0.4s, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);\n}\n.um-card:hover { border-color: rgba(108, 92, 255, 0.5); transform: translateY(-5px); }\n.um-card-art { height: 190px; }\n.um-card-body { padding: 1.4rem 1.5rem 1.6rem; }\n.um-card-top { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }\n.um-card-title { margin: 0; font-weight: 600; font-size: 1.7rem; letter-spacing: -0.02em; }\n.um-card-year { font-family: var(--font-geist-mono), monospace; color: var(--mute); font-size: 0.85rem; }\n.um-card-client { margin: 0.4rem 0 0.15rem; color: var(--pale); font-size: 1rem; }\n.um-card-kind {\n  margin: 0; color: var(--mute); font-family: var(--font-inter), system-ui, sans-serif; font-size: 0.9rem;\n}\n.um-card-open {\n  position: absolute; top: 1.2rem; right: 1.3rem;\n  font-family: var(--font-geist-mono), monospace; font-size: 0.78rem; color: var(--indigo);\n  opacity: 0; transform: translateY(-4px); transition: opacity 0.35s, transform 0.35s;\n}\n.um-card:hover .um-card-open { opacity: 1; transform: none; }\n\n/* generative art blocks (no imagery) */\n.um-tile-art, .um-card-art, .um-panel-art { position: relative; overflow: hidden; }\n.umbra-root [data-v=\"0\"] { background:\n  radial-gradient(120% 120% at 15% 10%, rgba(108, 92, 255, 0.55), transparent 55%),\n  radial-gradient(90% 90% at 85% 90%, rgba(70, 60, 180, 0.5), transparent 60%),\n  #0b0b12; }\n.umbra-root [data-v=\"1\"] { background:\n  conic-gradient(from 210deg at 70% 30%, rgba(108, 92, 255, 0.5), rgba(11, 11, 18, 0) 45%, rgba(140, 120, 255, 0.35) 80%),\n  #0a0a10; }\n.umbra-root [data-v=\"2\"] { background:\n  radial-gradient(70% 140% at 50% 120%, rgba(108, 92, 255, 0.6), transparent 60%),\n  linear-gradient(120deg, #0c0c14, #090910),\n  #090910; }\n.umbra-root [data-v=\"3\"] { background:\n  repeating-linear-gradient(115deg, rgba(108, 92, 255, 0.16) 0 2px, transparent 2px 16px),\n  radial-gradient(100% 100% at 20% 100%, rgba(108, 92, 255, 0.5), transparent 55%),\n  #0a0a11; }\n.um-tile-art::after, .um-card-art::after {\n  content: \"\"; position: absolute; inset: 0;\n  background: radial-gradient(60% 60% at 50% 40%, transparent, rgba(7, 7, 9, 0.5));\n}\n\n/* CAPABILITIES */\n.um-caps {\n  position: relative; z-index: 1;\n  padding: clamp(5rem, 12vh, 9rem) clamp(1.25rem, 6vw, 6rem);\n  background: linear-gradient(to bottom, transparent, rgba(7, 7, 9, 0.78) 30%);\n}\n.um-caps-inner { max-width: 64rem; margin: 0 auto; }\n.um-caps-list { list-style: none; margin: 1.4rem 0 0; padding: 0; }\n.um-cap {\n  position: relative; border-top: 1px solid var(--line); overflow: hidden;\n  opacity: 0; transform: translateY(18px);\n}\n.um-cap:last-child { border-bottom: 1px solid var(--line); }\n.um-caps-inner.is-shown .um-cap {\n  opacity: 1; transform: none;\n  transition: opacity 0.7s ease var(--d), transform 0.7s cubic-bezier(0.16, 1, 0.3, 1) var(--d);\n}\n.um-cap-text {\n  display: block; padding: clamp(0.9rem, 2vw, 1.4rem) 0;\n  font-weight: 550; font-size: clamp(1.6rem, 4.5vw, 3rem); letter-spacing: -0.025em;\n  color: var(--pale); transition: color 0.4s, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1), padding-left 0.5s cubic-bezier(0.16, 1, 0.3, 1);\n}\n.um-cap:hover .um-cap-text { color: var(--indigo); padding-left: 1.4rem; }\n.um-cap::before {\n  content: \"\"; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;\n  background: var(--indigo); transform: scaleY(0); transform-origin: bottom; transition: transform 0.45s cubic-bezier(0.16, 1, 0.3, 1);\n}\n.um-cap:hover::before { transform: scaleY(1); transform-origin: top; }\n\n/* CTA */\n.um-cta {\n  position: relative; z-index: 1;\n  min-height: 88vh; display: grid; place-items: center; text-align: center;\n  padding: clamp(5rem, 12vh, 9rem) clamp(1.25rem, 6vw, 4rem);\n}\n.um-cta-inner { max-width: 44rem; }\n.um-cta-title {\n  margin: 0; font-weight: 620; font-size: clamp(2.3rem, 7vw, 5rem); line-height: 0.98; letter-spacing: -0.035em;\n  text-shadow: 0 2px 40px rgba(7, 7, 9, 0.8);\n}\n.um-cta-lede {\n  margin: 1.6rem auto 2.4rem; max-width: 34rem; color: var(--pale); opacity: 0.86;\n  font-family: var(--font-inter), system-ui, sans-serif; font-size: 1.1rem;\n  text-shadow: 0 2px 30px rgba(7, 7, 9, 0.8);\n}\n.um-cta-btn {\n  display: inline-block; background: var(--indigo); color: #fff; font-weight: 600;\n  padding: 1.05rem 2.3rem; border-radius: 999px; font-size: 1.05rem;\n  transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s;\n}\n.um-cta-btn:hover { transform: translateY(-3px); box-shadow: 0 22px 50px -16px rgba(108, 92, 255, 0.8); }\n.um-cta-contact {\n  display: flex; gap: 1.6rem; justify-content: center; flex-wrap: wrap; margin-top: 1.8rem;\n  font-family: var(--font-geist-mono), monospace; font-size: 0.85rem; color: var(--mute);\n}\n.um-cta-contact a { color: var(--mute); transition: color 0.3s; }\n.um-cta-contact a:hover { color: var(--indigo); }\n\n/* FOOTER */\n.um-footer {\n  position: relative; z-index: 1; background: var(--void);\n  border-top: 1px solid var(--line);\n  padding: clamp(3rem, 6vw, 4.5rem) clamp(1.25rem, 6vw, 6rem);\n}\n.um-footer .um-mark { display: inline-block; margin-bottom: 0.8rem; }\n.um-foot-copy { margin: 0 0 1.4rem; color: var(--mute); max-width: 32rem; font-family: var(--font-inter), system-ui, sans-serif; }\n.um-foot-links { display: flex; gap: 1.6rem; flex-wrap: wrap; }\n.um-foot-links a { color: var(--pale); font-size: 0.95rem; transition: color 0.3s; }\n.um-foot-links a:hover { color: var(--indigo); }\n.um-foot-fine { margin: 1.4rem 0 0; color: var(--mute); opacity: 0.6; font-size: 0.85rem; }\n\n/* FLOATING CTA */\n.um-float {\n  position: fixed; z-index: 70; right: clamp(1rem, 4vw, 2.4rem); bottom: clamp(1rem, 4vw, 2.4rem);\n  background: var(--indigo); color: #fff; font-weight: 550; font-size: 0.95rem;\n  padding: 0.85rem 1.5rem; border-radius: 999px;\n  box-shadow: 0 16px 40px -12px rgba(108, 92, 255, 0.75);\n  opacity: 0; transform: translateY(20px) scale(0.95); pointer-events: none;\n  transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);\n}\n.um-float.is-on { opacity: 1; transform: none; pointer-events: auto; }\n\n/* FLIP PANEL */\n.um-overlay { position: fixed; inset: 0; z-index: 8000; }\n.um-scrim {\n  position: absolute; inset: 0; border: 0; padding: 0;\n  background: rgba(5, 5, 8, 0); transition: background 0.5s ease;\n}\n.um-overlay.is-open .um-scrim { background: rgba(5, 5, 8, 0.82); }\n.um-panel {\n  position: absolute; top: 50%; left: 50%;\n  width: min(760px, 92vw); max-height: 88vh; overflow: auto;\n  transform: translate(-50%, -50%); margin: 0;\n  background: var(--void-2); border: 1px solid var(--line); border-radius: 18px;\n  will-change: transform;\n}\n.um-panel-art { height: clamp(160px, 30vh, 260px); }\n.um-panel-body {\n  padding: clamp(1.6rem, 4vw, 2.6rem);\n  opacity: 0; transform: translateY(14px); transition: opacity 0.4s ease 0.15s, transform 0.5s ease 0.15s;\n}\n.um-panel-body.is-in { opacity: 1; transform: none; }\n.um-panel-close {\n  float: right; background: transparent; border: 1px solid var(--line); color: var(--pale);\n  border-radius: 999px; padding: 0.4rem 1rem; font: inherit; font-size: 0.85rem;\n  transition: border-color 0.3s, color 0.3s;\n}\n.um-panel-close:hover { border-color: var(--indigo); color: var(--indigo); }\n.um-panel-kind { font-family: var(--font-geist-mono), monospace; font-size: 0.82rem; color: var(--indigo); }\n.um-panel-title { margin: 0.5rem 0 0.3rem; font-weight: 620; font-size: clamp(2rem, 5vw, 3.2rem); letter-spacing: -0.03em; }\n.um-panel-client { margin: 0 0 1.4rem; color: var(--pale); display: flex; align-items: baseline; gap: 0.8rem; }\n.um-panel-year { font-family: var(--font-geist-mono), monospace; color: var(--mute); font-size: 0.85rem; }\n.um-panel-copy {\n  margin: 0 0 1.6rem; color: var(--pale); opacity: 0.86; font-size: 1.08rem;\n  font-family: var(--font-inter), system-ui, sans-serif;\n}\n.um-panel-results { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; }\n.um-panel-results li {\n  padding-left: 1.3rem; position: relative; color: var(--pale); font-size: 0.98rem;\n  font-family: var(--font-inter), system-ui, sans-serif;\n}\n.um-panel-results li::before {\n  content: \"\"; position: absolute; left: 0; top: 0.6em; width: 7px; height: 7px; border-radius: 50%;\n  background: var(--indigo); box-shadow: 0 0 8px var(--indigo);\n}\n\n/* RESPONSIVE */\n@media (max-width: 860px) {\n  .um-cards { grid-template-columns: 1fr; }\n  .um-grid { grid-template-columns: repeat(2, 1fr); }\n}\n@media (max-width: 640px) {\n  .um-nav-links { gap: 0.9rem; }\n  .um-nav-a { display: none; }\n  .um-hud { display: none; }\n  .um-grid { grid-template-columns: 1fr; }\n}\n\n/* REDUCED MOTION — one calm static field, no preloader, no cursor, no reveal delays */\n@media (prefers-reduced-motion: reduce) {\n  .umbra-root.has-cursor,\n  .umbra-root.has-cursor a,\n  .umbra-root.has-cursor button { cursor: auto; }\n  .um-cursor { display: none !important; }\n  .um-hud-dot { animation: none; }\n  .um-scrollcue span { animation: none; opacity: 0.4; }\n  .um-scrub-line { animation: none; transform: scaleY(1); }\n  .um-hero-inner, .um-nav, .um-chapter, .um-obj-head, .um-work-head, .um-cap { opacity: 1 !important; transform: none !important; transition: none !important; }\n  .um-caps-inner.is-shown .um-cap { transition: none; }\n}\n`\n\nconst css = cssBase + cssMore\n","type":"registry:page"}],"meta":{"kind":"templates","categories":["agency"],"docs":"https://ui.artbloom.tech/artbloom/templates/umbra"}}