{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"morphogen-wordmark","type":"registry:ui","title":"Morphogen Wordmark","description":"A hero background grown out of the wordmark itself. Two reagents react and diffuse from the glyphs as the seed, and the front feeds outward into stripes — every load braids differently because nothing is keyframed.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/morphogen-wordmark.tsx","target":"components/ui/morphogen-wordmark.tsx","content":"'use client';\n\nimport './morphogen-wordmark.css';\n\nimport { useEffect, useRef } from 'react';\n\nimport { useCanvasScene, useReducedMotion, type SceneDrawContext, type SceneSetupContext } from '@/hooks/use-canvas-scene';\n\n/**\n * A product hero whose ornament is grown, not drawn: the wordmark is nucleated into a\n * Gray-Scott reaction-diffusion field and the pattern spreads out of the letters.\n *\n *   du/dt = Du*lap(u) - u*v^2 + F*(1 - u)\n *   dv/dt = Dv*lap(v) + u*v^2 - (F + k)*v\n *\n * Integrated explicitly on a five-point Laplacian at unit grid spacing, which is stable\n * while TAU*4*DU < 1 - here 0.576, comfortably inside it. The easy version of this\n * picture is a blurred PNG of the letters with an opacity keyframe, and it cannot do the\n * one thing that matters: the front is autocatalytic, so v eats the u it finds outside\n * the glyphs and keeps going, splitting and merging in a way no curve encodes. Delete the\n * solver and the letters stop growing anything.\n *\n * F 0.037 / k 0.060 is the labyrinth window: stripes one wavelength wide that advance\n * into fresh u and braid around each other. Raise k to ~0.065 and the fronts pin into\n * isolated spots that never leave the letters; drop it to ~0.055 and there is no window\n * at all, v floods the whole field and the wordmark dissolves into a flat wash.\n */\n\nconst LABEL = 'ARTBLOOM';\nconst FONT_STACK = 'ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif';\nconst TRACKING = 0.055;\n\nconst STEP = 1 / 60;\nconst SUBSTEPS = 3;\nconst TAU = 0.9;\nconst DU = 0.16;\nconst DV = 0.08;\nconst FEED = 0.037;\nconst KILL = 0.06;\n\nconst CELL_MIN = 3;\nconst GRID_MAX_X = 260;\nconst GRID_MAX_Y = 168;\nconst WARM_BUDGET = 1.8e7;\nconst WARM_MAX = 900;\nconst RESEED_WARM = 210;\n\nconst V_PEAK = 0.32;\nconst SPILL_DIM = 0.7;\nconst MARK_FLOOR = 0.05;\nconst SEED_GAP = 5;\n\ninterface State {\n  clock: number;\n  carry: number;\n  snap: boolean;\n  gw: number;\n  gh: number;\n  cell: number;\n  u: Float64Array;\n  v: Float64Array;\n  un: Float64Array;\n  vn: Float64Array;\n  mask: Uint8Array;\n  field: ImageData;\n  off: HTMLCanvasElement;\n  offContext: CanvasRenderingContext2D;\n  markSize: number;\n  markX: number;\n  markY: number;\n  seedX: number;\n  seedY: number;\n  hasSeed: boolean;\n  warm: number;\n}\n\n/**\n * Zero-flux walls rather than a wrapping grid: with periodic edges the spill off the top\n * of the wordmark reappears under the copy, which reads as a bug rather than as growth.\n */\nconst advance = (s: State) => {\n  const { u, v, un, vn, gw, gh } = s;\n  for (let y = 0; y < gh; y += 1) {\n    const row = y * gw;\n    const up = (y > 0 ? y - 1 : 0) * gw;\n    const down = (y < gh - 1 ? y + 1 : gh - 1) * gw;\n    for (let x = 0; x < gw; x += 1) {\n      const i = row + x;\n      const cu = u[i];\n      const cv = v[i];\n      const lapU = u[row + (x > 0 ? x - 1 : 0)] + u[row + (x < gw - 1 ? x + 1 : gw - 1)] + u[up + x] + u[down + x] - 4 * cu;\n      const lapV = v[row + (x > 0 ? x - 1 : 0)] + v[row + (x < gw - 1 ? x + 1 : gw - 1)] + v[up + x] + v[down + x] - 4 * cv;\n      const react = cu * cv * cv;\n      const nu = cu + TAU * (DU * lapU - react + FEED * (1 - cu));\n      const nv = cv + TAU * (DV * lapV + react - (FEED + KILL) * cv);\n      // A seed stamp can leave a cell momentarily outside [0,1]; unclamped, the cubic\n      // term there runs away in two or three steps and the NaN never washes out.\n      un[i] = nu < 0 ? 0 : nu > 1 ? 1 : nu;\n      vn[i] = nv < 0 ? 0 : nv > 1 ? 1 : nv;\n    }\n  }\n  s.u = un;\n  s.un = u;\n  s.v = vn;\n  s.vn = v;\n};\n\nconst warmUp = (s: State, steps: number) => {\n  for (let n = 0; n < steps; n += 1) {\n    advance(s);\n  }\n};\n\n/** A disc of v with u locally spent, which is what a nucleation event physically is. */\nconst stampSeed = (s: State, gx: number, gy: number, radius: number) => {\n  const { u, v, gw, gh } = s;\n  const r2 = radius * radius;\n  const x0 = Math.max(0, Math.floor(gx - radius));\n  const x1 = Math.min(gw - 1, Math.ceil(gx + radius));\n  const y0 = Math.max(0, Math.floor(gy - radius));\n  const y1 = Math.min(gh - 1, Math.ceil(gy + radius));\n  for (let y = y0; y <= y1; y += 1) {\n    const dy = y - gy;\n    for (let x = x0; x <= x1; x += 1) {\n      const dx = x - gx;\n      const d2 = dx * dx + dy * dy;\n      if (d2 > r2) {\n        continue;\n      }\n      const fall = 1 - d2 / r2;\n      const i = y * gw + x;\n      const load = 0.55 * fall;\n      if (v[i] < load) {\n        v[i] = load;\n      }\n      u[i] -= 0.5 * fall * u[i];\n    }\n  }\n};\n\nconst seedFromMask = (s: State) => {\n  const { u, v, mask } = s;\n  u.fill(1);\n  v.fill(0);\n  let count = 0;\n  for (let i = 0; i < mask.length; i += 1) {\n    if (mask[i] === 0) {\n      continue;\n    }\n    // Deliberately unequal nuclei. A perfectly uniform seed region breaks symmetry only\n    // on floating-point dust, and then every mount braids the same way.\n    v[i] = 0.16 + 0.24 * Math.random();\n    u[i] = 0.42;\n    count += 1;\n  }\n  if (count === 0) {\n    stampSeed(s, s.gw / 2, s.gh / 2, Math.max(4, s.gw * 0.05));\n  }\n  s.hasSeed = false;\n};\n\n/* Tracked by hand, one glyph at a time: ctx.letterSpacing is not in every lib.dom we\n   compile against, and a wordmark set solid looks like body copy. */\nconst markWidth = (ctx: CanvasRenderingContext2D, size: number) => {\n  ctx.font = `800 ${size}px ${FONT_STACK}`;\n  let total = 0;\n  for (let i = 0; i < LABEL.length; i += 1) {\n    total += ctx.measureText(LABEL.charAt(i)).width;\n  }\n  return total + size * TRACKING * (LABEL.length - 1);\n};\n\nconst paintMark = (ctx: CanvasRenderingContext2D, size: number, x: number, y: number, stroke: boolean) => {\n  ctx.font = `800 ${size}px ${FONT_STACK}`;\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n  let cursor = x;\n  for (let i = 0; i < LABEL.length; i += 1) {\n    const glyph = LABEL.charAt(i);\n    if (stroke) {\n      ctx.strokeText(glyph, cursor, y);\n    } else {\n      ctx.fillText(glyph, cursor, y);\n    }\n    cursor += ctx.measureText(glyph).width + size * TRACKING;\n  }\n};\n\n/** The mask is cut at grid resolution, not canvas resolution: one wavelength of the\n    pattern is several cells wide, so a crisper mask would buy nothing and cost a\n    full-size getImageData on every resize. */\nconst buildMask = (s: State) => {\n  const sheet = document.createElement('canvas');\n  sheet.width = s.gw;\n  sheet.height = s.gh;\n  const ctx = sheet.getContext('2d');\n  if (!ctx) {\n    return;\n  }\n  ctx.fillStyle = '#ffffff';\n  paintMark(ctx, s.markSize / s.cell, s.markX / s.cell, s.markY / s.cell, false);\n  const px = ctx.getImageData(0, 0, s.gw, s.gh).data;\n  for (let i = 0; i < s.mask.length; i += 1) {\n    s.mask[i] = px[i * 4 + 3] > 96 ? 1 : 0;\n  }\n};\n\n/* The grid is blitted up with smoothing on, so the coarse field arrives as soft tissue\n   instead of pixels, and the letters get a hairline on top to stay legible after the\n   pattern has spilled over them. */\nconst renderField = (s: State, context: CanvasRenderingContext2D) => {\n  const { v, mask, field } = s;\n  const px = field.data;\n  for (let i = 0; i < mask.length; i += 1) {\n    const t = v[i] > V_PEAK ? 1 : v[i] / V_PEAK;\n    const inside = mask[i] === 1;\n    const lit = t * t * (3 - 2 * t) * (inside ? 1 : SPILL_DIM) + (inside ? MARK_FLOOR : 0);\n    // The mark floor pushes a saturated cell past 1, and the quartic on the red channel\n    // then lands over 255. Clamped here rather than left to Uint8Clamped, which would\n    // flatten the brightest ridges to a single value and lose the crest.\n    const g = lit > 1 ? 1 : lit;\n    const g2 = g * g;\n    const g3 = g2 * g;\n    const o = i * 4;\n    px[o] = 8 + 236 * g3 * g;\n    px[o + 1] = 11 + 218 * (0.42 * g + 0.58 * g2);\n    px[o + 2] = 15 + 176 * (0.55 * g2 + 0.45 * g3);\n    px[o + 3] = 255;\n  }\n  s.offContext.putImageData(field, 0, 0);\n  context.imageSmoothingEnabled = true;\n  context.imageSmoothingQuality = 'high';\n  // Exactly `cell` CSS pixels per cell, so grid space and canvas space are the same map\n  // the mask was cut in. The last row and column hang off the edge and are clipped.\n  context.drawImage(s.off, 0, 0, s.gw * s.cell, s.gh * s.cell);\n  context.lineWidth = Math.max(1, s.markSize * 0.014);\n  context.strokeStyle = 'rgba(158, 246, 218, 0.34)';\n  paintMark(context, s.markSize, s.markX, s.markY, true);\n};\n\n/** `compact` is the 298x240 catalogue-card variant: presentation only, all of it CSS. */\nexport type MorphogenWordmarkProps = { compact?: boolean };\n\nexport function MorphogenWordmark({ compact = false }: MorphogenWordmarkProps) {\n  const reduced = useReducedMotion();\n  const reseed = useRef(false);\n\n  const setup = ({ context, width, height }: SceneSetupContext): State => {\n    const cell = Math.max(CELL_MIN, Math.ceil(Math.max(width / GRID_MAX_X, height / GRID_MAX_Y)));\n    // Ceil, not floor: the grid must cover at least the canvas so the blit can go up by\n    // exactly `cell` and overhang. Flooring left gw*cell short of width, the blit stretched\n    // to close the gap, and the grown letters slid out from under their own hairline by a\n    // few pixels at the right edge - the mask, the pointer and the paint disagreed.\n    const gw = Math.max(8, Math.ceil(width / cell));\n    const gh = Math.max(8, Math.ceil(height / cell));\n    const cells = gw * gh;\n\n    const off = document.createElement('canvas');\n    off.width = gw;\n    off.height = gh;\n    // A detached 2D context only fails when the tab is out of memory. Falling back to the\n    // scene's own context keeps the type honest and degrades to the hairline wordmark.\n    const grid = off.getContext('2d') ?? context;\n\n    const base = markWidth(context, 100);\n    const pad = Math.max(20, Math.min(48, width * 0.05));\n    const room = Math.min(width - pad * 2, 620);\n    const unit = base > 0 ? base / 100 : 6;\n    const markSize = Math.max(26, Math.min(room / unit, height * 0.2, 108));\n\n    const state: State = {\n      clock: 0,\n      carry: 0,\n      snap: reduced,\n      gw,\n      gh,\n      cell,\n      u: new Float64Array(cells).fill(1),\n      v: new Float64Array(cells),\n      un: new Float64Array(cells),\n      vn: new Float64Array(cells),\n      mask: new Uint8Array(cells),\n      field: grid.createImageData(gw, gh),\n      off,\n      offContext: grid,\n      markSize,\n      markX: pad,\n      markY: Math.round(height * 0.36),\n      seedX: 0,\n      seedY: 0,\n      hasSeed: false,\n      warm: 120,\n    };\n\n    // Fixed work rather than a fixed step count: a wide hero has four times the cells of a\n    // narrow one, and mount latency is what a reader actually notices.\n    state.warm = Math.max(120, Math.min(WARM_MAX, Math.round(WARM_BUDGET / cells)));\n    buildMask(state);\n    seedFromMask(state);\n    warmUp(state, reduced ? Math.round(state.warm * 1.4) : state.warm);\n    return state;\n  };\n\n  const draw = ({ context, state, pointer }: SceneDrawContext<State>) => {\n    // Read fresh every frame: a copy taken at setup goes stale the moment the OS setting\n    // flips, because setup only re-runs on resize.\n    state.snap = reduced;\n\n    if (reseed.current) {\n      reseed.current = false;\n      seedFromMask(state);\n      warmUp(state, state.snap ? state.warm : RESEED_WARM);\n    }\n\n    const now = performance.now() / 1000;\n    const dt = state.clock === 0 ? 0 : Math.min(0.05, now - state.clock);\n    state.clock = now;\n\n    if (pointer.inside) {\n      const gx = pointer.x / state.cell;\n      const gy = pointer.y / state.cell;\n      if (!state.hasSeed) {\n        state.seedX = gx;\n        state.seedY = gy;\n        state.hasSeed = true;\n      }\n      const dx = gx - state.seedX;\n      const dy = gy - state.seedY;\n      const dist = Math.sqrt(dx * dx + dy * dy);\n      // Stamped by distance travelled, not per frame, and interpolated along the segment\n      // so a fast sweep leaves a continuous front instead of beads.\n      if (dist >= SEED_GAP) {\n        const stamps = Math.min(6, Math.floor(dist / SEED_GAP));\n        for (let n = 1; n <= stamps; n += 1) {\n          const f = n / stamps;\n          const radius = (pointer.down ? 3.4 : 2.1) + Math.random() * 1.6;\n          stampSeed(state, state.seedX + dx * f, state.seedY + dy * f, radius);\n        }\n        state.seedX = gx;\n        state.seedY = gy;\n      }\n    } else {\n      state.hasSeed = false;\n    }\n\n    if (!state.snap) {\n      state.carry += dt;\n      let n = 0;\n      while (state.carry >= STEP && n < 8) {\n        for (let k = 0; k < SUBSTEPS; k += 1) {\n          advance(state);\n        }\n        state.carry -= STEP;\n        n += 1;\n      }\n      if (n === 8) {\n        state.carry = 0;\n      }\n    }\n\n    renderField(state, context);\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<State>({ setup, draw });\n\n  // Block body on purpose: `() => requestRender()` hands the effect whatever that call\n  // returns, and React reads a returned value as a cleanup function.\n  useEffect(() => {\n    requestRender();\n  }, [reduced, requestRender]);\n\n  const growAgain = () => {\n    reseed.current = true;\n    requestRender();\n  };\n\n  return (\n    <div className=\"morphogen-wordmark-stage\" data-compact={compact ? 'true' : undefined}>\n      <div ref={stageRef} className=\"morphogen-wordmark-surface\">\n        <canvas ref={canvasRef} aria-hidden=\"true\" />\n      </div>\n      <div className=\"morphogen-wordmark-content\">\n        <h1 className=\"morphogen-wordmark-mark\">Artbloom</h1>\n        <p className=\"morphogen-wordmark-eyebrow\">Gray-Scott morphogenesis</p>\n        <h2 className=\"morphogen-wordmark-headline\">The mark grows the rest of the page.</h2>\n        <p className=\"morphogen-wordmark-body\">\n          Two reagents and no keyframes. The wordmark is nucleated into the field, then the\n          front feeds outward until the stripes have taken the space around it. Every load\n          braids differently, and so does every pass of your pointer.\n        </p>\n        <div className=\"morphogen-wordmark-actions\">\n          {/* Still clickable in a card — only the tab order changes, because the card\n              frame is aria-hidden and a focusable node under that is a real bug. */}\n          <button\n            type=\"button\"\n            className=\"morphogen-wordmark-cta\"\n            tabIndex={compact ? -1 : undefined}\n            onClick={growAgain}\n          >\n            Grow it again\n          </button>\n          <span className=\"morphogen-wordmark-meta\">F 0.037 / k 0.060 / Du 0.16</span>\n        </div>\n      </div>\n      <p className=\"morphogen-wordmark-hint\">move to seed growth</p>\n    </div>\n  );\n}\n\nexport default MorphogenWordmark;\n","type":"registry:ui"},{"path":"components/ui/morphogen-wordmark.css","target":"components/ui/morphogen-wordmark.css","content":".morphogen-wordmark-stage {\n  position: relative;\n  display: flex;\n  width: 100%;\n  min-height: 28rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: #06080b;\n  color: #edf6f1;\n}\n\n/* A border here would push `inset: 0` in on the padding box and slide the canvas\n   origin away from the grid the solver is stepping. Hairline as an inset shadow. */\n.morphogen-wordmark-surface {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n  cursor: crosshair;\n  box-shadow: inset 0 0 0 1px rgba(126, 231, 195, 0.14);\n}\n\n.morphogen-wordmark-surface canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* The field is at its densest exactly where the copy sits, so the copy gets its\n   own floor of contrast rather than hoping the pattern stays polite down there. */\n.morphogen-wordmark-surface::after {\n  content: '';\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n  background:\n    radial-gradient(115% 85% at 82% 6%, rgba(6, 8, 11, 0) 34%, rgba(4, 6, 8, 0.6) 100%),\n    linear-gradient(180deg, rgba(4, 6, 8, 0.18) 0%, rgba(4, 6, 8, 0) 32%, rgba(4, 6, 8, 0.88) 88%);\n}\n\n/* Transparent to the pointer: a drag that starts anywhere over the hero still\n   nucleates. The button takes events back, and works because it is a later\n   sibling than the element the hook captures the pointer on. */\n.morphogen-wordmark-content {\n  position: relative;\n  display: flex;\n  width: 100%;\n  flex-direction: column;\n  justify-content: flex-end;\n  gap: 0.875rem;\n  padding: 1.75rem clamp(1.25rem, 5vw, 3rem) 3.5rem;\n  pointer-events: none;\n}\n\n/* The wordmark itself is painted into the field, and the canvas is aria-hidden.\n   This is the same word, kept in the accessibility tree and out of the picture. */\n.morphogen-wordmark-mark {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  padding: 0;\n  overflow: hidden;\n  clip-path: inset(50%);\n  white-space: nowrap;\n}\n\n.morphogen-wordmark-eyebrow {\n  margin: 0;\n  font: 600 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.16em;\n  text-transform: uppercase;\n  color: rgba(126, 231, 195, 0.82);\n}\n\n.morphogen-wordmark-headline {\n  max-width: 20ch;\n  margin: 0;\n  font-size: clamp(1.5rem, 3.6vw, 2.25rem);\n  font-weight: 600;\n  line-height: 1.1;\n  letter-spacing: -0.025em;\n  text-shadow: 0 1px 20px rgba(4, 6, 8, 0.72);\n}\n\n.morphogen-wordmark-body {\n  max-width: 44ch;\n  margin: 0;\n  font-size: 0.875rem;\n  line-height: 1.5;\n  color: rgba(237, 246, 241, 0.68);\n  text-shadow: 0 1px 16px rgba(4, 6, 8, 0.7);\n}\n\n.morphogen-wordmark-actions {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 0.75rem 1.125rem;\n  margin-top: 0.25rem;\n}\n\n.morphogen-wordmark-cta {\n  appearance: none;\n  margin: 0;\n  padding: 0.625rem 1.125rem;\n  border: 0;\n  border-radius: 999px;\n  background: #7ee7c3;\n  font: inherit;\n  font-size: 0.8125rem;\n  font-weight: 600;\n  color: #04231a;\n  cursor: pointer;\n  pointer-events: auto;\n  transition:\n    background-color 160ms ease,\n    box-shadow 160ms ease;\n}\n\n.morphogen-wordmark-cta:hover {\n  background: #9df3d6;\n  box-shadow: 0 0 0 6px rgba(126, 231, 195, 0.12);\n}\n\n.morphogen-wordmark-cta:focus-visible {\n  outline: 2px solid rgba(157, 243, 214, 0.9);\n  outline-offset: 3px;\n}\n\n.morphogen-wordmark-meta {\n  font: 500 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.1em;\n  color: rgba(237, 246, 241, 0.46);\n}\n\n.morphogen-wordmark-hint {\n  position: absolute;\n  right: 0.875rem;\n  bottom: 0.75rem;\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: rgba(237, 246, 241, 0.3);\n  pointer-events: none;\n}\n\n/*\n * What goes away is the ongoing growth: the hook never starts the loop, so the\n * pattern the component paints once is a field that was already stepped a few\n * hundred times inside setup — the letters are dense, the spill is out past them,\n * and it simply stops there. The button still reseeds and repaints on demand.\n */\n@media (prefers-reduced-motion: reduce) {\n  .morphogen-wordmark-cta {\n    transition: none;\n  }\n\n  .morphogen-wordmark-cta:hover {\n    box-shadow: none;\n  }\n\n  .morphogen-wordmark-surface {\n    cursor: default;\n  }\n}\n\n/*\n * Card variant: the 298x240 catalogue tile, at real pixels and with no scaling\n * anywhere. The hero copy is what has to go — a headline sized in `vw` reads the\n * 1340px viewport, not this box, and it landed under the growing pattern. Here the\n * field owns the whole frame and the copy is one line of eyebrow plus the reseed\n * chip, pinned along the bottom edge where the existing wash is already darkest.\n */\n.morphogen-wordmark-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  border-radius: 0;\n}\n\n/* `none` would eat a vertical swipe over a full-bleed card inside a scrolling\n   grid and trap the page on a phone. Seeding wants both axes, but a stuck page is\n   the worse failure: `pan-y` gives the scroll back and a sideways drag still\n   nucleates, as does any pointer that is not a finger. */\n.morphogen-wordmark-stage[data-compact='true'] .morphogen-wordmark-surface {\n  touch-action: pan-y;\n}\n\n/* Off the mechanism entirely rather than stacked in front of it: a bottom strip\n   about 64px tall, while the wordmark is painted around y=86 and the spill has the\n   rest. Still `pointer-events: none`, so a drag through the strip seeds. */\n.morphogen-wordmark-stage[data-compact='true'] .morphogen-wordmark-content {\n  position: absolute;\n  inset: auto 0 0 0;\n  width: auto;\n  align-items: flex-start;\n  gap: 0.5rem;\n  padding: 0.75rem;\n}\n\n.morphogen-wordmark-stage[data-compact='true'] .morphogen-wordmark-headline,\n.morphogen-wordmark-stage[data-compact='true'] .morphogen-wordmark-body,\n.morphogen-wordmark-stage[data-compact='true'] .morphogen-wordmark-meta,\n.morphogen-wordmark-stage[data-compact='true'] .morphogen-wordmark-hint {\n  display: none;\n}\n\n/* The one line that stays, at a fixed 10px and held to a single line. */\n.morphogen-wordmark-stage[data-compact='true'] .morphogen-wordmark-eyebrow {\n  font-size: 0.625rem;\n  letter-spacing: 0.14em;\n  white-space: nowrap;\n  text-shadow: 0 1px 10px rgba(4, 6, 8, 0.85);\n}\n\n.morphogen-wordmark-stage[data-compact='true'] .morphogen-wordmark-actions {\n  margin-top: 0;\n}\n\n/* Kept, and kept clickable: pressing it re-nucleates the field out of the letters,\n   which is the whole trick in two seconds. `tabIndex={-1}` in the component keeps\n   it out of the tab order under the card's `aria-hidden`. */\n.morphogen-wordmark-stage[data-compact='true'] .morphogen-wordmark-cta {\n  padding: 0.3125rem 0.625rem;\n  font-size: 0.625rem;\n  pointer-events: auto;\n}\n","type":"registry:file"},{"path":"hooks/use-canvas-scene.ts","target":"hooks/use-canvas-scene.ts","content":"\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\n/**\n * The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing\n * store, a rebuild on resize, a loop that stops when the stage scrolls out of\n * view, pointer tracking with per-frame deltas, and teardown.\n *\n * A scene supplies two functions. `setup` builds whatever mutable state the\n * animation owns and is re-run whenever the stage changes size, so the state can\n * be sized to the stage without ever being resized in place. `draw` paints one\n * frame from that state — it is called with the transform already scaled to\n * device pixels, so every coordinate in it is a CSS pixel.\n */\n\nexport type ScenePointer = {\n  x: number\n  y: number\n  /** Position at the previous painted frame, so `x - lastX` is a frame delta. */\n  lastX: number\n  lastY: number\n  down: boolean\n  inside: boolean\n}\n\nexport type SceneSetupContext = {\n  context: CanvasRenderingContext2D\n  width: number\n  height: number\n  dpr: number\n}\n\nexport type SceneDrawContext<State> = SceneSetupContext & {\n  state: State\n  pointer: ScenePointer\n  /** Painted frames since the last rebuild. Useful for every-Nth-frame work. */\n  frame: number\n}\n\nexport type CanvasSceneOptions<State> = {\n  setup: (context: SceneSetupContext) => State\n  draw: (context: SceneDrawContext<State>) => void\n}\n\nexport type CanvasScene = {\n  /** The sizing element. Owns the pointer listeners and is what is observed. */\n  stageRef: (node: HTMLDivElement | null) => void\n  canvasRef: (node: HTMLCanvasElement | null) => void\n  /** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */\n  requestRender: () => void\n}\n\n/** Live `prefers-reduced-motion`. False during SSR and the first paint. */\nexport function useReducedMotion() {\n  const [reduced, setReduced] = useState(false)\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    setReduced(query.matches)\n    const onChange = () => setReduced(query.matches)\n    query.addEventListener(\"change\", onChange)\n    return () => query.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return reduced\n}\n\nexport function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {\n  const reduced = useReducedMotion()\n\n  /*\n   * `draw` is usually an inline closure, so it is a new function on every\n   * render. Reading it through a ref keeps the loop from being torn down and\n   * the scene from being rebuilt each time the component re-renders.\n   */\n  const optionsRef = useRef(options)\n  optionsRef.current = options\n\n  const stage = useRef<HTMLDivElement | null>(null)\n  const canvas = useRef<HTMLCanvasElement | null>(null)\n\n  /*\n   * Plain ref assignment, with no state behind it. React attaches refs during\n   * the commit phase, before passive effects run, so the effect below already\n   * sees both nodes on the first mount — which is why these used to bump a\n   * `mounted` counter for nothing: the two `setMounted` calls batched into one\n   * re-render, the counter went 0 → 2, and the effect's dependency on it tore\n   * the live scene down and rebuilt it. Every scene was constructed, measured\n   * and warmed twice on every mount, four times under StrictMode in dev.\n   *\n   * The requirement this trades for that: a consumer must render the stage and\n   * the canvas unconditionally, in the same commit as the component itself. All\n   * thirteen do. Gating the canvas behind a flag would leave the effect bailing\n   * on the null guard with nothing to re-run it.\n   */\n  const stageRef = useCallback((node: HTMLDivElement | null) => {\n    stage.current = node\n  }, [])\n  const canvasRef = useCallback((node: HTMLCanvasElement | null) => {\n    canvas.current = node\n  }, [])\n\n  /** Set once the scene is live, so `requestRender` before that is a no-op. */\n  const render = useRef<(() => void) | null>(null)\n  const requestRender = useCallback(() => render.current?.(), [])\n\n  useEffect(() => {\n    const stageNode = stage.current\n    const canvasNode = canvas.current\n    if (!stageNode || !canvasNode) return\n\n    const context = canvasNode.getContext(\"2d\")\n    if (!context) return\n\n    const pointer: ScenePointer = {\n      x: 0,\n      y: 0,\n      lastX: 0,\n      lastY: 0,\n      down: false,\n      inside: false,\n    }\n\n    let state: State | null = null\n    let width = 0\n    let height = 0\n    let dpr = 1\n    let frame = 0\n    let loop = 0\n    let pending = 0\n    let visible = true\n\n    /** Rebuild the backing store and the scene state for the current size. */\n    const measure = () => {\n      // `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is\n      // post-transform, so a scene sitting inside a scaled ancestor measured its\n      // own frame at the scaled size, sized the backing store to that, and then\n      // had CSS scale the result a second time — the scene ran at a fraction of\n      // the box it was drawn into. The catalogue's scaled-poster branch is the\n      // one place that happens, and it is reachable again the moment an\n      // animation is registered without a card composition. These two properties\n      // are the untransformed layout box; both are integers, which is what the\n      // rounding below already reduced the rect to.\n      const nextWidth = Math.max(1, stageNode.offsetWidth)\n      const nextHeight = Math.max(1, stageNode.offsetHeight)\n      const nextDpr = Math.min(2, window.devicePixelRatio || 1)\n      if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return\n\n      width = nextWidth\n      height = nextHeight\n      dpr = nextDpr\n      canvasNode.width = Math.round(width * dpr)\n      canvasNode.height = Math.round(height * dpr)\n      canvasNode.style.width = `${width}px`\n      canvasNode.style.height = `${height}px`\n      frame = 0\n      state = optionsRef.current.setup({ context, width, height, dpr })\n    }\n\n    const paint = () => {\n      if (!state) return\n      // Re-applied every frame: a scene is free to install its own transform\n      // for a cell or a sprite, and most do.\n      context.setTransform(dpr, 0, 0, dpr, 0, 0)\n      optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n      frame += 1\n    }\n\n    /** One frame on the next tick, coalescing however many were asked for. */\n    const paintOnce = () => {\n      if (pending) return\n      pending = requestAnimationFrame(() => {\n        pending = 0\n        measure()\n        paint()\n      })\n    }\n    render.current = paintOnce\n\n    const tick = () => {\n      loop = requestAnimationFrame(tick)\n      if (visible) paint()\n    }\n\n    const start = () => {\n      if (loop || reduced) return\n      loop = requestAnimationFrame(tick)\n    }\n    const stop = () => {\n      if (!loop) return\n      cancelAnimationFrame(loop)\n      loop = 0\n    }\n\n    const at = (event: PointerEvent) => {\n      const rect = stageNode.getBoundingClientRect()\n      // The rect is the right thing to subtract here — `clientX` is viewport\n      // space and so is the rect — but the difference comes back in *rendered*\n      // pixels, and a scene reads `pointer` in the scene pixels `measure()` set\n      // up from the untransformed box. Under a CSS scale those two disagree, so\n      // divide the transform back out. `rect.width / offsetWidth` is the scale\n      // actually in force, whatever produced it, and it is exactly 1 when there\n      // is none.\n      const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1\n      pointer.x = (event.clientX - rect.left) / (scale || 1)\n      pointer.y = (event.clientY - rect.top) / (scale || 1)\n      // A frozen loop still owes the user feedback for a drag.\n      if (reduced) paintOnce()\n    }\n\n    const onEnter = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n    }\n    const onMove = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n    }\n    const onDown = (event: PointerEvent) => {\n      pointer.down = true\n      at(event)\n      // Capture keeps a drag alive past the edge of the stage, which is where\n      // a hard throw naturally ends up.\n      stageNode.setPointerCapture(event.pointerId)\n    }\n    const onUp = (event: PointerEvent) => {\n      pointer.down = false\n      at(event)\n      if (stageNode.hasPointerCapture(event.pointerId)) {\n        stageNode.releasePointerCapture(event.pointerId)\n      }\n    }\n    const onLeave = () => {\n      pointer.inside = false\n      pointer.down = false\n      if (reduced) paintOnce()\n    }\n\n    stageNode.addEventListener(\"pointerenter\", onEnter)\n    stageNode.addEventListener(\"pointermove\", onMove)\n    stageNode.addEventListener(\"pointerdown\", onDown)\n    stageNode.addEventListener(\"pointerup\", onUp)\n    stageNode.addEventListener(\"pointercancel\", onUp)\n    stageNode.addEventListener(\"pointerleave\", onLeave)\n\n    const resizes = new ResizeObserver(() => paintOnce())\n    resizes.observe(stageNode)\n\n    /*\n     * An animation nobody can see is heat. The observer both pauses the loop\n     * and, on the way back in, repaints immediately rather than waiting a frame.\n     */\n    const views = new IntersectionObserver(\n      (entries) => {\n        visible = entries.some((entry) => entry.isIntersecting)\n        if (visible) {\n          start()\n          paintOnce()\n        } else {\n          stop()\n        }\n      },\n      { rootMargin: \"120px\" },\n    )\n    views.observe(stageNode)\n\n    measure()\n    paint()\n    start()\n\n    return () => {\n      render.current = null\n      stop()\n      if (pending) cancelAnimationFrame(pending)\n      resizes.disconnect()\n      views.disconnect()\n      stageNode.removeEventListener(\"pointerenter\", onEnter)\n      stageNode.removeEventListener(\"pointermove\", onMove)\n      stageNode.removeEventListener(\"pointerdown\", onDown)\n      stageNode.removeEventListener(\"pointerup\", onUp)\n      stageNode.removeEventListener(\"pointercancel\", onUp)\n      stageNode.removeEventListener(\"pointerleave\", onLeave)\n    }\n  }, [reduced])\n\n  return { stageRef, canvasRef, requestRender }\n}\n","type":"registry:hook"}],"meta":{"kind":"animations","categories":["backgrounds","morph","noise"],"docs":"https://ui.artbloom.tech/artbloom/animations/morphogen-wordmark"}}