{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"flock-search","type":"registry:ui","title":"Flock Search","description":"An empty state that is not empty. Reynolds boids fill the space behind the message, split around the pointer as it passes and close back up behind it — separation, alignment and cohesion, no path to follow.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/flock-search.tsx","target":"components/ui/flock-search.tsx","content":"'use client';\n\nimport './flock-search.css';\n\nimport { useEffect, useRef } from 'react';\n\nimport {\n  useCanvasScene,\n  useReducedMotion,\n  type SceneDrawContext,\n  type SceneSetupContext,\n} from '@/hooks/use-canvas-scene';\n\n/**\n * A search empty state whose backdrop is a real flock: four hundred Reynolds boids.\n *\n * Every bird integrates a_i = Ws·Σ r̂/|r| + Wa·(v̄ − v_i) + Wc·(x̄ − x_i), each term first turned\n * into a steering force — desired velocity at MAX_SPEED along the rule's direction, minus the\n * current velocity, clipped to MAX_FORCE — then semi-implicit Euler at a fixed 1/90 s with |v|\n * held inside a band. The three radii differ, which is the whole behaviour: separation is short\n * and stiff, alignment medium, cohesion long, so birds crowd, agree, and pull back in at\n * different distances. Collapse them to one radius and you get a blob.\n *\n * Neighbours come from a uniform spatial hash rebuilt every step, cell size equal to the largest\n * radius so a 3x3 block covers it. The obvious version — every bird against every other — is\n * 160 000 distance tests per step, roughly two million per second, and it drops frames on a\n * gallery page with other canvases running. The hash makes it about twenty candidates per bird.\n *\n * Fear is a fourth accumulator, weighted far above cohesion, so inside the pointer radius flight\n * beats company: the flock tears open instead of orbiting. Cohesion is what closes it again once\n * the cursor has passed, and that split-and-rejoin is the only proof the three rules are real.\n */\n\nconst STEP = 1 / 90;\nconst COUNT = 400;\n\nconst SEP_R = 13;\nconst ALI_R = 34;\nconst COH_R = 54;\nconst SEP_W = 2.05;\nconst ALI_W = 1.05;\nconst COH_W = 0.9;\n\nconst FEAR_R = 104;\nconst FEAR_R_HELD = 152;\nconst FEAR_W = 5.4;\n\nconst MAX_SPEED = 196;\nconst MIN_SPEED = 96;\nconst MAX_FORCE = 460;\n\nconst EDGE_MARGIN = 64;\nconst EDGE_ACCEL = 1150;\n\nconst KICK_ACCEL = 640;\nconst KICK_DECAY = 3.4;\n\nconst WARM_STEPS = 176;\nconst BIRD_LEN = 7.4;\nconst BIRD_HALF = 2.9;\n\ninterface State {\n  clock: number;\n  carry: number;\n  snap: boolean;\n  kick: number;\n  width: number;\n  height: number;\n  cols: number;\n  rows: number;\n  px: Float64Array;\n  py: Float64Array;\n  vx: Float64Array;\n  vy: Float64Array;\n  cellOf: Int32Array;\n  cellStart: Int32Array;\n  cursor: Int32Array;\n  order: Int32Array;\n}\n\nconst STEER = new Float64Array(2);\n\n/**\n * The clip is load-bearing, not tidiness. Separation divides by |r|, so two birds that land on\n * top of each other ask for an unbounded acceleration and one of them leaves the frame forever.\n */\nfunction steer(dx: number, dy: number, vx: number, vy: number): void {\n  const len = Math.hypot(dx, dy);\n  if (len < 1e-6) {\n    STEER[0] = 0;\n    STEER[1] = 0;\n    return;\n  }\n  let sx = (dx / len) * MAX_SPEED - vx;\n  let sy = (dy / len) * MAX_SPEED - vy;\n  const mag = Math.hypot(sx, sy);\n  if (mag > MAX_FORCE) {\n    const scale = MAX_FORCE / mag;\n    sx *= scale;\n    sy *= scale;\n  }\n  STEER[0] = sx;\n  STEER[1] = sy;\n}\n\n// Seeded so the warmed first frame is the same formation on every mount and every resize.\nfunction noise(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/** Counting sort of the birds into grid cells: one O(n) pass, no per-cell arrays to allocate. */\nfunction rehash(s: State): void {\n  const cells = s.cols * s.rows;\n  const start = s.cellStart;\n  start.fill(0);\n  for (let i = 0; i < COUNT; i += 1) {\n    const cx = Math.min(s.cols - 1, Math.max(0, Math.floor(s.px[i] / COH_R)));\n    const cy = Math.min(s.rows - 1, Math.max(0, Math.floor(s.py[i] / COH_R)));\n    const c = cy * s.cols + cx;\n    s.cellOf[i] = c;\n    start[c + 1] += 1;\n  }\n  for (let c = 0; c < cells; c += 1) {\n    start[c + 1] += start[c];\n  }\n  s.cursor.set(start);\n  for (let i = 0; i < COUNT; i += 1) {\n    const c = s.cellOf[i];\n    s.order[s.cursor[c]] = i;\n    s.cursor[c] += 1;\n  }\n}\n\n/** One fixed step. `fr` is the fear radius in pixels; zero means the pointer is off the stage. */\nfunction advance(s: State, fx: number, fy: number, fr: number): void {\n  rehash(s);\n  const { px, py, vx, vy, cols, rows, cellStart, order } = s;\n  let cenX = 0;\n  let cenY = 0;\n  if (s.kick > 0.002) {\n    for (let i = 0; i < COUNT; i += 1) {\n      cenX += px[i];\n      cenY += py[i];\n    }\n    cenX /= COUNT;\n    cenY /= COUNT;\n  }\n  for (let i = 0; i < COUNT; i += 1) {\n    const x = px[i];\n    const y = py[i];\n    const ivx = vx[i];\n    const ivy = vy[i];\n    let sepX = 0;\n    let sepY = 0;\n    let aliX = 0;\n    let aliY = 0;\n    let cohX = 0;\n    let cohY = 0;\n    let aliN = 0;\n    let cohN = 0;\n    const gx = Math.min(cols - 1, Math.max(0, Math.floor(x / COH_R)));\n    const gy = Math.min(rows - 1, Math.max(0, Math.floor(y / COH_R)));\n    const x1 = Math.min(cols - 1, gx + 1);\n    const y1 = Math.min(rows - 1, gy + 1);\n    for (let cy = Math.max(0, gy - 1); cy <= y1; cy += 1) {\n      for (let cx = Math.max(0, gx - 1); cx <= x1; cx += 1) {\n        const c = cy * cols + cx;\n        const end = cellStart[c + 1];\n        for (let k = cellStart[c]; k < end; k += 1) {\n          const j = order[k];\n          if (j === i) {\n            continue;\n          }\n          const dx = px[j] - x;\n          const dy = py[j] - y;\n          const d2 = dx * dx + dy * dy;\n          if (d2 > COH_R * COH_R) {\n            continue;\n          }\n          const d = Math.sqrt(d2);\n          if (d < SEP_R) {\n            // r̂/|r|, as the header says, not r̂. The accumulated direction has to be dominated by\n            // the bird about to be hit; plain unit vectors let a neighbour at SEP_R outvote one at\n            // two pixels and the pair never resolves. d² is floored so the divide cannot blow up.\n            const crowd = 1 / Math.max(d2, 0.25);\n            sepX -= dx * crowd;\n            sepY -= dy * crowd;\n          }\n          if (d < ALI_R) {\n            aliX += vx[j];\n            aliY += vy[j];\n            aliN += 1;\n          }\n          cohX += px[j];\n          cohY += py[j];\n          cohN += 1;\n        }\n      }\n    }\n    let ax = 0;\n    let ay = 0;\n    if (sepX !== 0 || sepY !== 0) {\n      steer(sepX, sepY, ivx, ivy);\n      ax += STEER[0] * SEP_W;\n      ay += STEER[1] * SEP_W;\n    }\n    if (aliN > 0) {\n      steer(aliX / aliN, aliY / aliN, ivx, ivy);\n      ax += STEER[0] * ALI_W;\n      ay += STEER[1] * ALI_W;\n    }\n    if (cohN > 0) {\n      steer(cohX / cohN - x, cohY / cohN - y, ivx, ivy);\n      ax += STEER[0] * COH_W;\n      ay += STEER[1] * COH_W;\n    }\n    if (fr > 0) {\n      const dx = x - fx;\n      const dy = y - fy;\n      const d = Math.hypot(dx, dy);\n      // Linear falloff: a hard cutoff at the radius makes a visible circular wall of birds.\n      if (d < fr) {\n        steer(dx, dy, ivx, ivy);\n        const w = FEAR_W * (1 - d / fr);\n        ax += STEER[0] * w;\n        ay += STEER[1] * w;\n      }\n    }\n    if (s.kick > 0.002) {\n      const dx = x - cenX;\n      const dy = y - cenY;\n      const d = Math.hypot(dx, dy);\n      if (d > 1e-6) {\n        const w = (KICK_ACCEL * s.kick) / d;\n        ax += dx * w;\n        ay += dy * w;\n      }\n    }\n    // A linear spring in the last EDGE_MARGIN pixels. Wrapping would be cheaper but the hash has\n    // no seam, so cohesion would tear the flock in half every time it crossed one.\n    if (x < EDGE_MARGIN) {\n      ax += EDGE_ACCEL * (1 - x / EDGE_MARGIN);\n    } else if (x > s.width - EDGE_MARGIN) {\n      ax -= EDGE_ACCEL * (1 - (s.width - x) / EDGE_MARGIN);\n    }\n    if (y < EDGE_MARGIN) {\n      ay += EDGE_ACCEL * (1 - y / EDGE_MARGIN);\n    } else if (y > s.height - EDGE_MARGIN) {\n      ay -= EDGE_ACCEL * (1 - (s.height - y) / EDGE_MARGIN);\n    }\n    let nvx = ivx + ax * STEP;\n    let nvy = ivy + ay * STEP;\n    const sp = Math.hypot(nvx, nvy);\n    if (sp > MAX_SPEED) {\n      nvx *= MAX_SPEED / sp;\n      nvy *= MAX_SPEED / sp;\n    } else if (sp < 1e-6) {\n      nvx = MIN_SPEED;\n      nvy = 0;\n    } else if (sp < MIN_SPEED) {\n      // A stalled boid stops being one: its neighbours read a dead heading as a vote to stop too.\n      nvx *= MIN_SPEED / sp;\n      nvy *= MIN_SPEED / sp;\n    }\n    vx[i] = nvx;\n    vy[i] = nvy;\n    px[i] = x + nvx * STEP;\n    py[i] = y + nvy * STEP;\n  }\n  s.kick *= Math.exp(-STEP * KICK_DECAY);\n}\n\n/** `compact` is the 298x240 catalogue-card variant: presentation only, no physics change. */\nexport type FlockSearchProps = { compact?: boolean };\n\nexport function FlockSearch({ compact = false }: FlockSearchProps) {\n  const reduced = useReducedMotion();\n  const kickRef = useRef(0);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const setup = ({ width, height }: SceneSetupContext): State => {\n    const cols = Math.max(1, Math.ceil(width / COH_R));\n    const rows = Math.max(1, Math.ceil(height / COH_R));\n    const cells = cols * rows;\n    const state: State = {\n      clock: 0,\n      carry: 0,\n      snap: false,\n      kick: 0,\n      width,\n      height,\n      cols,\n      rows,\n      px: new Float64Array(COUNT),\n      py: new Float64Array(COUNT),\n      vx: new Float64Array(COUNT),\n      vy: new Float64Array(COUNT),\n      cellOf: new Int32Array(COUNT),\n      cellStart: new Int32Array(cells + 1),\n      cursor: new Int32Array(cells + 1),\n      order: new Int32Array(COUNT),\n    };\n    // Three loose squadrons, not a uniform sprinkle: cohesion has something to find in the first\n    // few steps, so the warm-up ends in lanes rather than in a cloud still deciding what it is.\n    const rand = noise(0x5eed17);\n    for (let i = 0; i < COUNT; i += 1) {\n      const g = i % 3;\n      const heading = g * 2.1 + (rand() - 0.5) * 0.7;\n      const sx = width * (0.26 + 0.24 * g) + (rand() - 0.5) * width * 0.26;\n      const sy = height * (0.3 + 0.2 * (g % 2)) + (rand() - 0.5) * height * 0.36;\n      state.px[i] = Math.min(width - 4, Math.max(4, sx));\n      state.py[i] = Math.min(height - 4, Math.max(4, sy));\n      const speed = MIN_SPEED + rand() * (MAX_SPEED - MIN_SPEED);\n      state.vx[i] = Math.cos(heading) * speed;\n      state.vy[i] = Math.sin(heading) * speed;\n    }\n    // A gallery gives a scroller about a second, and boids need longer than that to organise, so\n    // WARM_STEPS of the real solver runs here. Frame one is already a flock. This is also the\n    // reduced-motion frame: there is no closed-form rest state to draw instead.\n    for (let n = 0; n < WARM_STEPS; n += 1) {\n      advance(state, 0, 0, 0);\n    }\n    return state;\n  };\n\n  const draw = ({ context, width, height, state, pointer }: SceneDrawContext<State>) => {\n    state.snap = reduced;\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    if (kickRef.current > 0) {\n      kickRef.current = 0;\n      // Latched only if something will integrate it away. Under reduced motion `advance` never\n      // runs, so a stored 1 would sit on state and fire as a stale burst if the loop ever resumed.\n      if (!state.snap) {\n        state.kick = 1;\n      }\n    }\n    const fear = pointer.inside ? (pointer.down ? FEAR_R_HELD : FEAR_R) : 0;\n    if (!state.snap) {\n      state.carry += dt;\n      let n = 0;\n      while (state.carry >= STEP && n < 8) {\n        advance(state, pointer.x, pointer.y, fear);\n        state.carry -= STEP;\n        n += 1;\n      }\n      if (n === 8) {\n        state.carry = 0;\n      }\n    }\n\n    context.clearRect(0, 0, width, height);\n    if (fear > 0) {\n      context.strokeStyle = 'rgba(255, 201, 120, 0.14)';\n      context.lineWidth = 1;\n      context.beginPath();\n      context.arc(pointer.x, pointer.y, fear, 0, Math.PI * 2);\n      context.stroke();\n    }\n\n    // Two paths, two fills: the birds inside the fear radius are the ones worth colouring, and\n    // batching them beats 400 separate fill calls by more than the extra pass costs.\n    const calm = new Path2D();\n    const spooked = new Path2D();\n    for (let i = 0; i < COUNT; i += 1) {\n      const x = state.px[i];\n      const y = state.py[i];\n      const speed = Math.hypot(state.vx[i], state.vy[i]);\n      const hx = speed > 1e-6 ? state.vx[i] / speed : 1;\n      const hy = speed > 1e-6 ? state.vy[i] / speed : 0;\n      const hot = fear > 0 && Math.hypot(x - pointer.x, y - pointer.y) < fear;\n      const path = hot ? spooked : calm;\n      const bx = x - hx * BIRD_LEN * 0.38;\n      const by = y - hy * BIRD_LEN * 0.38;\n      path.moveTo(x + hx * BIRD_LEN * 0.62, y + hy * BIRD_LEN * 0.62);\n      path.lineTo(bx - hy * BIRD_HALF, by + hx * BIRD_HALF);\n      path.lineTo(bx + hy * BIRD_HALF, by - hx * BIRD_HALF);\n      path.closePath();\n    }\n    context.fillStyle = 'rgba(196, 214, 240, 0.82)';\n    context.fill(calm);\n    context.fillStyle = 'rgba(255, 201, 120, 0.96)';\n    context.fill(spooked);\n\n    // Over the birds, not under them: 400 moving triangles behind a 1.5rem heading is the busiest\n    // thing on the card, and a text-shadow alone loses at the centre.\n    const veil = context.createRadialGradient(\n      width / 2,\n      height / 2,\n      0,\n      width / 2,\n      height / 2,\n      Math.max(120, Math.min(width, height * 1.6) * 0.56),\n    );\n    veil.addColorStop(0, 'rgba(6, 9, 16, 0.82)');\n    veil.addColorStop(0.6, 'rgba(6, 9, 16, 0.36)');\n    veil.addColorStop(1, 'rgba(6, 9, 16, 0)');\n    context.fillStyle = veil;\n    context.fillRect(0, 0, width, height);\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<State>({ setup, draw });\n  useEffect(() => requestRender(), [reduced, requestRender]);\n\n  // The keyboard path into the physics: no pointer coordinates to borrow, so the impulse is radial\n  // from the flock's own centroid and decays as exp(-t/KICK_DECAY). The flock scatters and re-forms.\n  const scatter = () => {\n    kickRef.current = 1;\n    requestRender();\n  };\n\n  const applyTerm = (term: string) => {\n    const input = inputRef.current;\n    if (input) {\n      input.value = term;\n      input.focus();\n    }\n    scatter();\n  };\n\n  return (\n    <div className=\"flock-search-stage\" data-compact={compact ? 'true' : undefined}>\n      <div ref={stageRef} className=\"flock-search-surface\">\n        <canvas ref={canvasRef} aria-hidden=\"true\" />\n      </div>\n      <div className=\"flock-search-content\">\n        <p className=\"flock-search-eyebrow\">No results</p>\n        <h2 className=\"flock-search-title\">Nothing matched that search</h2>\n        <p className=\"flock-search-help\">\n          Try a shorter term, or search by tag. Every component is indexed by name, category and the\n          equation it integrates.\n        </p>\n        <form\n          className=\"flock-search-field\"\n          role=\"search\"\n          onSubmit={(event) => {\n            event.preventDefault();\n            scatter();\n          }}\n        >\n          <input\n            ref={inputRef}\n            className=\"flock-search-input\"\n            type=\"search\"\n            name=\"q\"\n            aria-label=\"Search the catalogue\"\n            // Was \"Search 214 components\". This library has never had 214 of\n            // anything, and a placeholder is not the place to invent a catalogue\n            // size — the consumer who installs this gets whatever it says.\n            placeholder=\"Search the catalogue\"\n            defaultValue=\"verlet nav\"\n            tabIndex={compact ? -1 : undefined}\n          />\n          <button className=\"flock-search-go\" type=\"submit\" tabIndex={compact ? -1 : undefined}>\n            Search\n          </button>\n        </form>\n        <ul className=\"flock-search-tries\" aria-label=\"Suggested searches\">\n          {['spatial hash', 'stick-slip', 'verlet cloth'].map((term) => (\n            <li key={term}>\n              <button\n                className=\"flock-search-try\"\n                type=\"button\"\n                tabIndex={compact ? -1 : undefined}\n                onClick={() => applyTerm(term)}\n              >\n                {term}\n              </button>\n            </li>\n          ))}\n        </ul>\n      </div>\n      <p className=\"flock-search-hint\">move through the flock</p>\n    </div>\n  );\n}\n\nexport default FlockSearch;\n","type":"registry:ui"},{"path":"components/ui/flock-search.css","target":"components/ui/flock-search.css","content":".flock-search-stage {\n  position: relative;\n  display: grid;\n  place-content: center;\n  width: 100%;\n  min-height: 24rem;\n  padding: 3rem 1.5rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(130% 120% at 50% 0%, #101827 0%, #0a0e18 55%, #06080e 100%);\n  color: #eef3fb;\n}\n\n/* No border on the measured element: `inset: 0` is against the padding box, so one\n   pixel of border would slide the canvas origin off the pointer the flock reads. */\n.flock-search-surface {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);\n  border-radius: inherit;\n}\n\n.flock-search-surface canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* A later sibling of the canvas host, not a child: the hook captures the pointer on\n   pointerdown, which would eat the field's focus click if this sat inside the stage. */\n.flock-search-content {\n  position: relative;\n  width: min(27rem, 100%);\n  margin: 0 auto;\n  text-align: center;\n  pointer-events: none;\n}\n\n.flock-search-eyebrow {\n  margin: 0 0 0.625rem;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.14em;\n  text-transform: uppercase;\n  color: rgba(255, 201, 120, 0.78);\n}\n\n.flock-search-title {\n  margin: 0 0 0.5rem;\n  font-size: 1.5rem;\n  font-weight: 500;\n  line-height: 1.15;\n  letter-spacing: -0.025em;\n  text-shadow: 0 1px 20px rgba(6, 9, 16, 0.9);\n}\n\n.flock-search-help {\n  margin: 0 auto 1.125rem;\n  max-width: 24rem;\n  font-size: 0.875rem;\n  line-height: 1.5;\n  color: rgba(238, 243, 251, 0.6);\n  text-shadow: 0 1px 16px rgba(6, 9, 16, 0.85);\n}\n\n.flock-search-field {\n  display: flex;\n  gap: 0.375rem;\n  align-items: stretch;\n}\n\n/* Opaque enough to hold 4.5:1 text over the busiest part of the flock. */\n.flock-search-input {\n  flex: 1 1 auto;\n  min-width: 0;\n  padding: 0.625rem 0.875rem;\n  border: 1px solid rgba(255, 255, 255, 0.16);\n  border-radius: 999px;\n  background: rgba(8, 12, 20, 0.86);\n  font: inherit;\n  font-size: 0.875rem;\n  color: #f4f8ff;\n  pointer-events: auto;\n  backdrop-filter: blur(8px);\n  transition:\n    border-color 160ms ease,\n    box-shadow 160ms ease;\n}\n\n.flock-search-input::placeholder {\n  color: rgba(238, 243, 251, 0.42);\n}\n\n.flock-search-input:focus-visible {\n  outline: none;\n  border-color: rgba(255, 201, 120, 0.72);\n  box-shadow: 0 0 0 3px rgba(255, 201, 120, 0.18);\n}\n\n.flock-search-go {\n  appearance: none;\n  flex: 0 0 auto;\n  padding: 0.625rem 1.0625rem;\n  border: 1px solid rgba(255, 201, 120, 0.55);\n  border-radius: 999px;\n  background: rgba(255, 201, 120, 0.16);\n  font: inherit;\n  font-size: 0.8125rem;\n  font-weight: 500;\n  color: #ffdca6;\n  cursor: pointer;\n  pointer-events: auto;\n  transition:\n    background-color 160ms ease,\n    color 160ms ease;\n}\n\n.flock-search-go:hover {\n  background: rgba(255, 201, 120, 0.26);\n  color: #fff1d8;\n}\n\n.flock-search-tries {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.375rem;\n  justify-content: center;\n  margin: 0.875rem 0 0;\n  padding: 0;\n  list-style: none;\n}\n\n.flock-search-try {\n  appearance: none;\n  margin: 0;\n  padding: 0.3125rem 0.6875rem;\n  border: 1px solid rgba(255, 255, 255, 0.14);\n  border-radius: 999px;\n  background: rgba(8, 12, 20, 0.7);\n  font: inherit;\n  font-size: 0.75rem;\n  color: rgba(238, 243, 251, 0.74);\n  cursor: pointer;\n  pointer-events: auto;\n  backdrop-filter: blur(6px);\n  transition:\n    border-color 160ms ease,\n    color 160ms ease;\n}\n\n.flock-search-try:hover {\n  border-color: rgba(255, 201, 120, 0.45);\n  color: #fff3de;\n}\n\n.flock-search-go:focus-visible,\n.flock-search-try:focus-visible {\n  outline: 2px solid rgba(255, 201, 120, 0.8);\n  outline-offset: 2px;\n}\n\n.flock-search-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  pointer-events: none;\n  color: rgba(238, 243, 251, 0.26);\n}\n\n@media (max-width: 26rem) {\n  .flock-search-stage {\n    padding: 2.25rem 1rem;\n  }\n\n  .flock-search-field {\n    flex-wrap: wrap;\n  }\n\n  .flock-search-go {\n    flex: 1 1 100%;\n  }\n}\n\n/*\n * Switched off: the integration loop itself. The flock is still solved — setup runs\n * about two seconds of it — but only the one warmed frame is painted, so the birds\n * hold their formation and the cursor no longer splits them. Hover and focus keep\n * their colour change; only their transitions go.\n */\n@media (prefers-reduced-motion: reduce) {\n  .flock-search-input,\n  .flock-search-go,\n  .flock-search-try {\n    transition: none;\n  }\n}\n\n/*\n * Card variant: the same flock re-authored for the catalogue's 298x240 frame. That is the real,\n * final pixel size — nothing here is scaled. The section copy collapses to the eyebrow and the\n * search row along the bottom edge, so the four hundred boids get the whole box instead of\n * sharing it with a 1.5rem heading. The card frame rounds and clips, so this stage does neither.\n */\n.flock-search-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  padding: 0;\n  border-radius: 0;\n}\n\n/* pan-y, not none: the card sits in a scrolling grid, and a full-bleed drag surface that swallows\n   vertical touch traps the page on a phone. Horizontal drags still reach the flock, and the scene\n   hook never calls preventDefault, so touch-action alone decides this. */\n.flock-search-stage[data-compact='true'] .flock-search-surface {\n  touch-action: pan-y;\n}\n\n/* The veil `draw` lays over the birds is there to keep the 1.5rem heading legible, and its radius\n   works out at ~167px inside a 298x240 box: with the heading hidden it is a black hole sitting on\n   the mechanism. `screen` makes a black fill a no-op, so the middle of the flock comes back\n   without the draw call learning anything about the card. */\n.flock-search-stage[data-compact='true'] .flock-search-surface canvas {\n  mix-blend-mode: screen;\n}\n\n/* Off the mechanism entirely: one strip on the bottom edge. `pointer-events: none` is inherited\n   from the base rule, so drags across it still land on the canvas. */\n/* A scrim, because the strip is over the field rather than beside it. At full size the copy\n   sits in a margin the flock never enters; pinned to the bottom edge of a 298px card the birds\n   fly straight through both the label and the input, and neither read. The gradient fades to\n   nothing at its own top edge, so it darkens the bottom ~45px of the field and stops — no band,\n   no visible seam. `pointer-events` is untouched: the base rule leaves this layer deaf and the\n   two controls `auto`, so a drag through the scrim still steers the flock. */\n.flock-search-stage[data-compact='true'] .flock-search-content {\n  position: absolute;\n  inset: auto 0 0 0;\n  width: auto;\n  margin: 0;\n  padding: 0.75rem;\n  background: linear-gradient(\n    to top,\n    rgba(6, 10, 18, 0.94) 0%,\n    rgba(6, 10, 18, 0.78) 55%,\n    rgba(6, 10, 18, 0) 100%\n  );\n}\n\n/* The one line of copy that survives, at a fixed size: `vw` or a `clamp()` with a `vw` term would\n   measure the 1340px viewport rather than this 298px card, which is the bug being fixed.\n\n   The shadow is the whole reason this rule is not just a font size. At full size the eyebrow sits\n   in a wide margin the flock never reaches; in a 298px box the birds fly straight through it, and\n   10px amber caps over a field of pale triangles at the same y was the least readable thing on the\n   card. A tight dark halo pulls it off the field without a scrim rectangle behind it. */\n.flock-search-stage[data-compact='true'] .flock-search-eyebrow {\n  margin: 0 0 0.375rem;\n  font-size: 0.625rem;\n  text-shadow:\n    0 0 3px rgba(6, 10, 18, 0.95),\n    0 1px 8px rgba(6, 10, 18, 0.8);\n}\n\n.flock-search-stage[data-compact='true'] .flock-search-title,\n.flock-search-stage[data-compact='true'] .flock-search-help,\n.flock-search-stage[data-compact='true'] .flock-search-tries,\n.flock-search-stage[data-compact='true'] .flock-search-hint {\n  display: none;\n}\n\n/* The field stays, shrunk: submitting is the second half of the behaviour — a radial kick from the\n   flock's own centroid — so a visitor who clicks Search inside the card sees the flock burst and\n   re-form. Both controls keep the `pointer-events: auto` from their base rules. Held to one row\n   whatever the viewport is, since the 26rem media query would otherwise wrap the button under the\n   field and double the height of this strip. */\n.flock-search-stage[data-compact='true'] .flock-search-field {\n  flex-wrap: nowrap;\n  gap: 0.3125rem;\n}\n\n.flock-search-stage[data-compact='true'] .flock-search-input {\n  padding: 0.375rem 0.625rem;\n  font-size: 0.6875rem;\n}\n\n.flock-search-stage[data-compact='true'] .flock-search-go {\n  flex: 0 0 auto;\n  padding: 0.375rem 0.6875rem;\n  font-size: 0.6875rem;\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":["particles","backgrounds"],"docs":"https://ui.artbloom.tech/artbloom/animations/flock-search"}}