{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "solar-wind",
  "title": "Solar wind",
  "description": "The animated aurora: flux lines behind the page that light the edges of hovered cards and section titles, and feed the aurora-canvas curtain. Mount SolarWind once in the root layout. For marketing pages, not dense app screens.",
  "registryDependencies": [
    "@dwellir/dwellir-theme",
    "@dwellir/utils",
    "@dwellir/field-style",
    "@dwellir/aurora-canvas"
  ],
  "files": [
    {
      "path": "components/aurora/wind/SolarWind.tsx",
      "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { AURORA_START_TIMEOUT_MS, whenIdle } from '@/lib/aurora/idle';\nimport { startQualityGovernor } from '../qualityGovernor';\nimport { startWind } from './windSim';\n\n/**\n * The solar wind: field lines whose bright swells light an aurora on the page\n * elements they reach. One layer behind the whole page; every tagged element\n * on the page is a collider. Mount it once, in the app's root layout: it also\n * starts the quality governor, which steps the motion down on slow machines.\n */\nexport default function SolarWind() {\n  // The layer appears after the portal mounts.\n  const [layer, setLayer] = useState<HTMLDivElement | null>(null);\n  const [host, setHost] = useState<HTMLElement | null>(null);\n\n  useEffect(() => {\n    setHost(document.body);\n    return startQualityGovernor();\n  }, []);\n\n  // Hydration is one long task: the wind measures the page after it, at the first idle moment.\n  useEffect(() => {\n    if (!layer) return;\n    let stop = () => {};\n    const cancel = whenIdle(() => {\n      stop = startWind(layer);\n    }, AURORA_START_TIMEOUT_MS);\n    return () => {\n      cancel();\n      stop();\n    };\n  }, [layer]);\n\n  if (!host) return null;\n  // .au-wind-layer: styles/solar-wind.css.\n  return createPortal(<div ref={setLayer} className=\"au-wind-layer\" aria-hidden=\"true\" />, host);\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/SolarWind.tsx"
    },
    {
      "path": "components/aurora/qualityGovernor.ts",
      "content": "/**\n * Steps the motion quality (lib/aurora/quality.ts) down while frames stay\n * slow. In each one-second window it times a short burst of back-to-back\n * animation frames, and asks for no frames between bursts: every frame runs\n * the whole rendering pipeline. Two slow windows in a row step full to lite,\n * or lite to still. It starts at the first idle moment after `load`, so a\n * page that still streams in does not count against the machine.\n * It steps up once per tab session, from lite to full, after GOOD_WINDOWS\n * windows in a row at half the slow frame time. The gap between the two\n * thresholds, and the single step, keep the level from swinging. The tab\n * keeps its level for the session. After 3 fast windows in a row the probe\n * rests until the next scroll or return to the tab, so a settled page\n * requests no frames. A pinned level (?quality=) stops it.\n */\nimport { onAuroraFrame } from '@/lib/aurora/frameLoop';\nimport { whenIdle } from '@/lib/aurora/idle';\nimport {\n  isQualityPinned,\n  QUALITY_ATTR,\n  QUALITY_SESSION_KEY,\n  type Quality,\n  readQuality,\n} from '@/lib/aurora/quality';\n\nconst WINDOW_MS = 1000;\n/** The first idle moment after `load` starts the probe, or this long after `load` at most. */\nconst FIRST_IDLE_TIMEOUT_MS = 2000;\n/** No measuring for this long after a level change, a scroll or a return to the tab. */\nconst SETTLE_MS = 600;\n/** A window is slow when its median frame takes longer than this (under 25 fps).\n * A 30 fps power-saving cap (33 ms) stays at full. */\nconst SLOW_FRAME_MS = 40;\nconst SLOW_WINDOWS = 2;\n/** A window is good when its median frame takes less than this: room to spare at full. */\nconst GOOD_FRAME_MS = SLOW_FRAME_MS / 2;\n/** Good windows in a row at lite before the one step up. */\nconst GOOD_WINDOWS = 5;\n/** sessionStorage: the tab has used its step up. */\nconst STEPPED_UP_KEY = 'dwellir-au-quality-up';\n/** Gaps timed per window, from consecutive frames. Their median is the window's frame time. */\nconst BURST_GAPS = 4;\n/** Fast windows in a row before the probe rests. */\nconst REST_AFTER = 3;\n\nconst NEXT: Record<Quality, Quality | null> = { full: 'lite', lite: 'still', still: null };\n\nfunction median(values: number[]) {\n  const sorted = [...values].sort((a, b) => a - b);\n  return sorted[Math.floor(sorted.length / 2)] ?? 0;\n}\n\n/** Without storage, the step up is spent for this page load only. */\nlet steppedUp = false;\n\nfunction remember(key: string, value: string) {\n  try {\n    window.sessionStorage.setItem(key, value);\n  } catch {\n    // Storage can be off; the value then lasts until the next page load.\n  }\n}\n\nfunction setLevel(level: Quality) {\n  document.documentElement.setAttribute(QUALITY_ATTR, level);\n  remember(QUALITY_SESSION_KEY, level);\n}\n\nfunction stepDown(): Quality | null {\n  const next = NEXT[readQuality()];\n  if (next) setLevel(next);\n  return next;\n}\n\nfunction canStepUp() {\n  if (steppedUp || readQuality() !== 'lite') return false;\n  try {\n    return window.sessionStorage.getItem(STEPPED_UP_KEY) === null;\n  } catch {\n    return true;\n  }\n}\n\nfunction stepUp() {\n  if (!canStepUp()) return false;\n  steppedUp = true;\n  remember(STEPPED_UP_KEY, '1');\n  setLevel('full');\n  return true;\n}\n\n/** Starts the governor. Returns the cleanup. */\nexport function startQualityGovernor() {\n  let stopFrame = () => {};\n  let running = false;\n  let last = 0;\n  let windowStart = 0;\n  let gaps: number[] = [];\n  /** The burst's first frame has come; its gap to an earlier frame does not count. */\n  let primed = false;\n  let slow = 0;\n  let fast = 0;\n  let good = 0;\n  /** The page has loaded and gone idle once; before that, nothing starts the probe. */\n  let loaded = false;\n  let cancelIdle = () => {};\n\n  const stop = () => {\n    running = false;\n    stopFrame();\n  };\n\n  const settle = (now: number, ms: number) => {\n    gaps = [];\n    primed = false;\n    windowStart = now + ms;\n  };\n\n  /** A new level: count its windows from zero. */\n  const changed = (now: number) => {\n    slow = 0;\n    fast = 0;\n    good = 0;\n    settle(now, SETTLE_MS);\n  };\n\n  /** Counts the window's median frame into the runs of slow, fast and good windows. */\n  const count = (frame: number) => {\n    const isSlow = frame > SLOW_FRAME_MS;\n    slow = isSlow ? slow + 1 : 0;\n    fast = isSlow ? 0 : fast + 1;\n    good = frame < GOOD_FRAME_MS ? good + 1 : 0;\n  };\n\n  const judge = (now: number) => {\n    count(median(gaps));\n    gaps = [];\n    primed = false;\n    windowStart = now;\n    if (isQualityPinned()) return stop();\n    if (good >= GOOD_WINDOWS && stepUp()) return changed(now);\n    // A run of good windows at lite keeps the probe awake until it can step up.\n    if (fast >= REST_AFTER && !(good > 0 && canStepUp())) return stop();\n    if (slow < SLOW_WINDOWS) return;\n    const next = stepDown();\n    if (next === null || next === 'still') stop();\n    else changed(now);\n  };\n\n  const tick = (now: number) => {\n    const gap = now - last;\n    last = now;\n    if (now < windowStart) return;\n    if (gaps.length < BURST_GAPS) {\n      if (primed) gaps.push(gap);\n      primed = true;\n    }\n    if (now - windowStart >= WINDOW_MS) judge(now);\n  };\n\n  /** Frames back to back during a burst, then one at the end of the window. */\n  const wait = (now: number) => {\n    if (now < windowStart) return windowStart - now;\n    if (gaps.length < BURST_GAPS) return 0;\n    return windowStart + WINDOW_MS - now;\n  };\n\n  const start = (settleMs: number) => {\n    if (!loaded || running || document.hidden || isQualityPinned() || readQuality() === 'still')\n      return;\n    running = true;\n    slow = 0;\n    fast = 0;\n    good = 0;\n    last = performance.now();\n    settle(last, settleMs);\n    stopFrame = onAuroraFrame(tick, wait);\n  };\n\n  const wake = () => start(SETTLE_MS);\n  const onVisibility = () => {\n    stop();\n    wake();\n  };\n  window.addEventListener('scroll', wake, { passive: true });\n  document.addEventListener('visibilitychange', onVisibility);\n  const levels = new MutationObserver(wake);\n  levels.observe(document.documentElement, { attributes: true, attributeFilter: [QUALITY_ATTR] });\n  const begin = () => {\n    cancelIdle = whenIdle(() => {\n      loaded = true;\n      start(SETTLE_MS);\n    }, FIRST_IDLE_TIMEOUT_MS);\n  };\n  if (document.readyState === 'complete') begin();\n  else window.addEventListener('load', begin, { once: true });\n\n  return () => {\n    stop();\n    cancelIdle();\n    window.removeEventListener('load', begin);\n    window.removeEventListener('scroll', wake);\n    document.removeEventListener('visibilitychange', onVisibility);\n    levels.disconnect();\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/qualityGovernor.ts"
    },
    {
      "path": "components/aurora/wind/canvasSurface.ts",
      "content": "import type { FieldContext } from '../fieldTypes';\nimport { createImpactLayer } from './impactLayer';\nimport { createFluxInk } from './styles/fluxInk';\nimport { clipToWindows, type WindWindow } from './windows';\nimport type { WindConfig } from './windProtocol';\nimport type { WindCanvas, WindSurface } from './windSurface';\n\n/** The worker and main-thread fallback share the same solid-stroke painter. */\nexport function createCanvasWindSurface(canvas: WindCanvas, ctx: FieldContext): WindSurface {\n  const paintLines = createFluxInk();\n  const impacts = createImpactLayer();\n  let config: WindConfig;\n  let clipped = false;\n  let dark = false;\n  /** Set by clipLines: where the lines show in full, and where only their edge glow shows. */\n  let full: WindWindow[] | null = null;\n  let glow: WindWindow[] = [];\n  const clipTo = (windows: WindWindow[]) => {\n    ctx.restore();\n    clipToWindows(ctx, windows, config.dpr);\n    ctx.globalCompositeOperation = dark ? 'lighter' : 'source-over';\n    ctx.setTransform(config.dpr, 0, 0, config.dpr, 0, 0);\n  };\n  const clear = () => {\n    ctx.setTransform(1, 0, 0, 1, 0, 0);\n    ctx.clearRect(0, 0, canvas.width, canvas.height);\n  };\n  return {\n    kind: 'canvas2d',\n    configure(next) {\n      config = { ...next, dpr: Math.min(next.dpr, 1) };\n      const width = Math.round(config.width * config.dpr);\n      const height = Math.round(config.height * config.dpr);\n      if (canvas.width !== width) canvas.width = width;\n      if (canvas.height !== height) canvas.height = height;\n      impacts.configure(config);\n    },\n    begin(windows, windowed, look) {\n      clear();\n      clipped = windowed;\n      dark = look.dark;\n      full = null;\n      if (clipped) clipToWindows(ctx, windows, config.dpr);\n      ctx.globalCompositeOperation = dark ? 'lighter' : 'source-over';\n    },\n    impacts(colliders, look, t) {\n      impacts.paint(ctx, config, look, colliders, t);\n    },\n    clipLines(sheets, glows) {\n      if (!clipped) return;\n      full = sheets;\n      glow = glows;\n    },\n    lines(lines, frame, page) {\n      if (!full) {\n        ctx.setTransform(config.dpr, 0, 0, config.dpr, 0, 0);\n        paintLines(ctx, frame, lines, page);\n        return;\n      }\n      if (full.length > 0) {\n        clipTo(full);\n        paintLines(ctx, frame, lines, page);\n      }\n      clipTo(glow);\n      paintLines(ctx, frame, lines, page, true);\n    },\n    end() {\n      if (clipped) ctx.restore();\n    },\n    clear,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/canvasSurface.ts"
    },
    {
      "path": "components/aurora/wind/colliders.ts",
      "content": "/**\n * Colliders: page elements that the solar wind strikes. Tag any element with\n * data-au-collider:\n * - \"always\" (or empty): it always collides; while hovered, strikes on it\n *   burn brighter;\n * - \"hover\": it is no collider at rest (the wind passes behind it), and a\n *   full one while hovered or focused;\n * - \"off\": never a collider.\n * Every .au-card is a hover collider unless it carries its own tag.\n * Band h1/h2/h3 titles outside cards are quiet text colliders without extra HTML.\n * data-au-collider-shape=\"text\" traces the letters instead of the box.\n * data-au-collider-shape=\"disc\" makes a round collider (data-au-disc: radius as\n * a share of the element's width). It draws no light of its own: each strike\n * is sent to the element as a WIND_STRIKE_EVENT, and the element shows it (the\n * globe lights its polar aurora).\n * data-au-collider-light=\"off\" keeps the collider, so the wind still parts\n * round it and feeds the hero curtain, but strikes on it make no light: no\n * edge light or rays, no strike event and no flux glow.\n * Use it where light on the element would hurt reading (a page title).\n */\nimport { packGlyphMask } from './glyphField';\nimport { drawGlyphMask, type GlyphMask, glyphOutline } from './glyphs';\nimport { allocateOutline, type Box, buildOutline, type Outline } from './outline';\nimport type { ReadWindRect } from './windGeometry';\n\nexport type ColliderMode = 'always' | 'hover';\n\nconst HEADING_SELECTOR = ':is([data-au-band], [data-slot=\"section-heading\"]) :is(h1, h2, h3)';\nconst CANDIDATE_SELECTOR = '[data-au-collider], .au-card, h1, h2, h3';\nconst CONTAINER_SELECTOR = '[data-au-collider], .au-card, [data-slot=\"card\"]';\nexport const COLLIDER_SELECTOR = `[data-au-collider], .au-card, ${HEADING_SELECTOR}`;\n\n/** How much brighter strikes on an always collider burn while the cursor is on it. */\nconst HOVER_BOOST = 1.2;\n/** A hover collider below this activity lets the wind pass. */\nexport const ACTIVE_MIN = 0.05;\n\n/** One strike on the outline. It spreads along the edge and fades. */\ninterface Strike {\n  index: number;\n  age: number;\n  amp: number;\n  /** Where on the spectrum this strike starts: each one is a slightly different green. */\n  hue: number;\n}\n\nexport interface Collider extends ColliderState {\n  element: HTMLElement;\n  revision: number;\n}\n\nexport interface ColliderState {\n  mode: ColliderMode;\n  text: boolean;\n  disc: boolean;\n  /** data-au-collider-light=\"off\": strikes on it make no light. */\n  unlit: boolean;\n  /** Canvas px per CSS px of the element (a scaled lab tile has scale ≠ 1). */\n  scale: number;\n  /** Text colliders: the rendered letters, for hit tests. */\n  mask: GlyphMask | null;\n  outline: Outline | null;\n  /** Energy per outline sample this frame, before tone mapping. */\n  energy: Float32Array;\n  /** Spectrum position per outline sample this frame (see rays.ts). */\n  tint: Float32Array;\n  strikes: Strike[];\n  /** Light that touching flux lines hold on a hovered edge, per outline sample. It fades by HOLD_LIFE. */\n  held: Float32Array;\n  shapeKey: string;\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n  /** The longest ray, in px. */\n  reach: number;\n  visible: boolean;\n  /** 0 at rest, 1 while hovered or focused; it eases between the two. */\n  active: number;\n}\n\n/** Canvas space: client px minus (x, y), times k. A scaled lab tile has k ≠ 1. */\nexport interface Frame {\n  x: number;\n  y: number;\n  k: number;\n  width: number;\n  height: number;\n}\n\nconst SPREAD_SIGMA = 4;\n/** Outline samples sit this many px apart; it converts a spread in samples to px. */\nconst SAMPLE_PX = 3;\n/** Samples per second that a strike widens by. */\nconst SPREAD_RATE = 12;\n/** Seconds for a strike to fade to 1/e. */\nconst STRIKE_LIFE = 1.1;\nconst MAX_STRIKES = 40;\n\nfunction readMode(element: Element): ColliderMode | null {\n  const tag = element.getAttribute('data-au-collider');\n  if (tag === 'off') return null;\n  if (tag === null) return element.matches('h1, h2, h3') ? 'always' : 'hover';\n  return tag === 'hover' ? 'hover' : 'always';\n}\n\nfunction createCollider(element: HTMLElement, mode: ColliderMode): Collider {\n  const sectionHeading = element.matches(HEADING_SELECTOR);\n  return {\n    element,\n    revision: 0,\n    mode,\n    text: sectionHeading || element.getAttribute('data-au-collider-shape') === 'text',\n    disc: element.getAttribute('data-au-collider-shape') === 'disc',\n    unlit: sectionHeading || element.getAttribute('data-au-collider-light') === 'off',\n    scale: 1,\n    mask: null,\n    outline: null,\n    energy: new Float32Array(0),\n    tint: new Float32Array(0),\n    strikes: [],\n    held: new Float32Array(0),\n    shapeKey: '',\n    left: 0,\n    top: 0,\n    width: 0,\n    height: 0,\n    reach: 40,\n    visible: false,\n    active: 0,\n  };\n}\n\n/** Finds the colliders under root; known colliders keep their state. */\nexport function scanColliders(root: ParentNode, known: Map<Element, Collider>) {\n  const found = new Map<Element, Collider>();\n  for (const element of root.querySelectorAll<HTMLElement>(CANDIDATE_SELECTOR)) {\n    const heading = element.matches('h1, h2, h3');\n    if (\n      heading &&\n      !element.hasAttribute('data-au-collider') &&\n      !element.closest('[data-au-band], [data-slot=\"section-heading\"]')\n    )\n      continue;\n    const mode = readMode(element);\n    if (!mode) continue;\n    if (heading && element.parentElement?.closest(CONTAINER_SELECTOR)) continue;\n    const collider = known.get(element) ?? createCollider(element, mode);\n    collider.mode = mode;\n    found.set(element, collider);\n  }\n  return found;\n}\n\n/**\n * Whether a change at `target` can reshape the collider. Text traces its letters, so\n * any change inside it counts. A box or disc keeps its outline while its content\n * changes (a live counter ticks every second): only its own attributes or a change\n * round it can restyle it, and measureCollider catches a change of size.\n */\nfunction reshapes(collider: Collider, target: Node) {\n  if (target.contains(collider.element)) return true;\n  return collider.element.contains(target) && collider.text;\n}\n\n/** Invalidates changed content and styles without retracing unrelated colliders. */\nexport function invalidateColliderShapes(known: Map<Element, Collider>, target?: Node) {\n  for (const collider of known.values()) {\n    if (!target || reshapes(collider, target)) collider.revision += 1;\n  }\n}\n\nfunction toCanvas(rect: DOMRect, frame: Frame): Box {\n  return {\n    x: (rect.left - frame.x) * frame.k,\n    y: (rect.top - frame.y) * frame.k,\n    w: rect.width * frame.k,\n    h: rect.height * frame.k,\n  };\n}\n\n/** Line boxes of a text element, relative to the element: one box per line, gaps closed. */\nfunction lineBoxes(collider: Collider, frame: Frame): Box[] {\n  const range = document.createRange();\n  range.selectNodeContents(collider.element);\n  const lines: Box[] = [];\n  for (const rect of range.getClientRects()) {\n    if (rect.width < 1 || rect.height < 1) continue;\n    const box = toCanvas(rect, frame);\n    box.x -= collider.left;\n    box.y -= collider.top;\n    const line = lines.find(\n      (item) => Math.abs(item.y + item.h / 2 - (box.y + box.h / 2)) < box.h / 2,\n    );\n    if (!line) {\n      lines.push(box);\n      continue;\n    }\n    const right = Math.max(line.x + line.w, box.x + box.w);\n    line.x = Math.min(line.x, box.x);\n    line.w = right - line.x;\n  }\n  lines.sort((a, b) => a.y - b.y);\n  for (let i = 1; i < lines.length; i += 1) {\n    const above = lines[i - 1];\n    const below = lines[i];\n    const middle = (above.y + above.h + below.y) / 2;\n    if (below.y - (above.y + above.h) > above.h) continue;\n    below.h += below.y - middle;\n    below.y = middle;\n    above.h = middle - above.y;\n  }\n  return lines;\n}\n\nfunction textShape(collider: Collider, frame: Frame, boxes: Box[]) {\n  const lineHeight = Math.min(...boxes.map((box) => box.h));\n  collider.reach = Math.max(18, Math.min(0.42 * lineHeight, 40));\n  const toLocal = (rect: DOMRect) => {\n    const box = toCanvas(rect, frame);\n    return { x: box.x - collider.left, y: box.y - collider.top, h: box.h };\n  };\n  const resolution = collider.unlit ? 0.5 : 1;\n  const mask = drawGlyphMask(\n    collider.element,\n    collider.width,\n    collider.height,\n    frame.k,\n    toLocal,\n    resolution,\n  );\n  collider.mask = collider.unlit ? null : mask;\n  collider.outline = collider.unlit\n    ? allocateOutline(0, boxes, false)\n    : mask\n      ? glyphOutline(mask, boxes)\n      : buildOutline(boxes, 10);\n  if (mask) {\n    // The lines keep about half a cap height of air round the letters.\n    const pad = Number.parseFloat(getComputedStyle(collider.element).fontSize) * frame.k * 0.4;\n    collider.outline.glyph = packGlyphMask(mask, 1 / resolution, pad);\n  }\n}\n\nfunction boxShape(collider: Collider, frame: Frame, boxes: Box[]) {\n  const radius = Number.parseFloat(getComputedStyle(collider.element).borderTopLeftRadius);\n  collider.mask = null;\n  collider.outline = buildOutline(boxes, (radius || 0) * frame.k);\n  // Rays scale with the element: short on a button, long on the dashboard window.\n  collider.reach = Math.max(26, Math.min(0.4 * Math.min(collider.width, collider.height), 88));\n}\n\n/** The disc's radius in canvas px: data-au-disc is a share of the element's width. */\nconst discRadius = (collider: Collider) =>\n  (Number.parseFloat(collider.element.dataset.auDisc ?? '') || 0.5) * collider.width;\n\nfunction discShape(collider: Collider) {\n  const r = discRadius(collider);\n  const box = { x: collider.width / 2 - r, y: collider.height / 2 - r, w: 2 * r, h: 2 * r };\n  collider.mask = null;\n  collider.outline = buildOutline([box], r);\n  collider.reach = 40;\n}\n\nfunction rebuild(collider: Collider, frame: Frame, sizeKey: string) {\n  const { width, height } = collider;\n  const boxes = collider.text ? lineBoxes(collider, frame) : [{ x: 0, y: 0, w: width, h: height }];\n  const boxKey = boxes.map((b) => `${b.x | 0},${b.y | 0},${b.w | 0},${b.h | 0}`).join(';');\n  const shapeKey = `${sizeKey}|${boxKey}`;\n  if (shapeKey === collider.shapeKey || boxes.length === 0) return;\n  collider.shapeKey = shapeKey;\n  if (collider.text) textShape(collider, frame, boxes);\n  else if (collider.disc) discShape(collider);\n  else boxShape(collider, frame, boxes);\n  collider.energy = new Float32Array(collider.outline?.count ?? 0);\n  collider.tint = new Float32Array(collider.energy.length);\n  collider.strikes = [];\n  collider.held = new Float32Array(collider.energy.length);\n}\n\n/** Reads where the element is this frame, and rebuilds its outline when its shape changes. */\nexport function measureCollider(collider: Collider, frame: Frame, readRect: ReadWindRect) {\n  const box = toCanvas(readRect(collider.element), frame);\n  collider.scale = frame.k;\n  collider.left = box.x;\n  collider.top = box.y;\n  collider.width = box.w;\n  collider.height = box.h;\n  const margin = collider.reach * 1.5;\n  collider.visible =\n    box.w > 1 &&\n    box.h > 1 &&\n    box.x < frame.width + margin &&\n    box.x + box.w > -margin &&\n    box.y < frame.height + margin &&\n    box.y + box.h > -margin;\n  if (!collider.visible) return;\n  const disc = collider.disc ? `:${collider.element.dataset.auDisc}` : '';\n  const sizeKey = `${Math.round(box.w)}x${Math.round(box.h)}:${frame.k}:${collider.revision}${disc}`;\n  if (collider.shapeKey.startsWith(`${sizeKey}|`)) return;\n  rebuild(collider, frame, sizeKey);\n}\n\n/** Eases a collider towards active while hovered or focused. */\nexport function updateHover(collider: ColliderState, over: boolean, dt: number) {\n  const target = over ? 1 : 0;\n  collider.active += (target - collider.active) * (1 - Math.exp(-dt / 0.3));\n}\n\n/** Letters are small targets with short edges, so a strike on text burns a little brighter. */\nconst TEXT_GAIN = 1.35;\n\n/** The strength of a strike on this collider, by mode and hover state. */\nexport function colliderGain(collider: ColliderState) {\n  const base = collider.mode === 'always' ? 1 + HOVER_BOOST * collider.active : collider.active;\n  return collider.text ? base * TEXT_GAIN : base;\n}\n\nexport function addStrike(collider: ColliderState, index: number, amp: number) {\n  collider.strikes.push({ index, age: 0, amp, hue: Math.random() * 0.12 });\n  if (collider.strikes.length > MAX_STRIKES) collider.strikes.shift();\n}\n\n/** Seconds over which a strike's light reaches the gold end of the spectrum. */\nconst TINT_AGE = 2.2;\n\n/**\n * Adds a strike's light to one sample. `spread` is 0 where it struck and 1 at\n * the edge of its light: the tint runs through green, violet, rose and gold\n * further out, and it drifts on as the strike ages.\n */\nfunction deposit(\n  collider: ColliderState,\n  j: number,\n  weight: number,\n  strike: Strike,\n  spread: number,\n) {\n  collider.energy[j] += weight;\n  collider.tint[j] +=\n    weight *\n    (strike.hue + 0.45 * Math.min(1, spread) ** 1.6 + 0.88 * Math.min(1, strike.age / TINT_AGE));\n}\n\n/** Glyph edges: a strike lights every edge sample near it, so it spreads across letters. */\nfunction spreadNear(\n  collider: ColliderState,\n  outline: Outline,\n  strike: Strike,\n  sigma: number,\n  amp: number,\n) {\n  const radius = sigma * SAMPLE_PX;\n  const cx = outline.x[strike.index];\n  const cy = outline.y[strike.index];\n  const limit = (2.5 * radius) ** 2;\n  for (let i = 0; i < outline.count; i += 1) {\n    const distance = (outline.x[i] - cx) ** 2 + (outline.y[i] - cy) ** 2;\n    if (distance >= limit) continue;\n    const weight = amp * Math.exp((-0.5 * distance) / radius ** 2);\n    deposit(collider, i, weight, strike, Math.sqrt(distance) / (2.5 * radius));\n  }\n}\n\n/** d^1.6 for the steps of a walk, so a sample's tint needs no Math.pow. */\nconst POW_16 = Float64Array.from({ length: 256 }, (_, d) => d ** 1.6);\n\n/**\n * Box edges: the light runs along the outline in one direction from the\n * strike. It deposits as deposit() does; the Gaussian comes by recurrence, as\n * each step's ratio exp(-(2d + 1) / 2σ²) falls by exp(-1 / σ²).\n */\nfunction walk(\n  collider: ColliderState,\n  outline: Outline,\n  links: Int32Array,\n  strike: Strike,\n  sigma: number,\n  amp: number,\n) {\n  const steps = Math.min(Math.ceil(sigma * 2.5), outline.count >> 1);\n  const fall = Math.exp(-1 / (sigma * sigma));\n  let ratio = Math.exp(-0.5 / (sigma * sigma));\n  let gauss = 1;\n  const tint = strike.hue + 0.88 * Math.min(1, strike.age / TINT_AGE);\n  const spread = (2.5 * sigma) ** -1.6;\n  let j = links[strike.index];\n  for (let d = 1; d <= steps && j >= 0 && j !== strike.index; d += 1) {\n    gauss *= ratio;\n    ratio *= fall;\n    const weight = amp * gauss;\n    collider.energy[j] += weight;\n    collider.tint[j] += weight * (tint + 0.45 * Math.min(1, (POW_16[d] ?? d ** 1.6) * spread));\n    j = links[j];\n  }\n}\n\n/** Seconds for held light to fade to 1/e, once the line leaves the edge or the hover ends. */\nconst HOLD_LIFE = 1.4;\n/** Held light is green at its foot, as the curtain's lower edge; its rays turn violet towards the tip. */\nconst HOLD_TINT = 0.06;\n/** Weights of the samples beside the one a line holds, so a hugging line lights a continuous band. */\nconst HOLD_SPREAD = [0.6, 0.3];\n\n/** A line that touches the edge at sample `index` holds `amount` more light there. */\nexport function holdLight(collider: ColliderState, index: number, amount: number) {\n  const { outline, held } = collider;\n  if (!outline || index < 0) return;\n  held[index] += amount;\n  if (!outline.linked) return;\n  let next = index;\n  let prev = index;\n  for (const weight of HOLD_SPREAD) {\n    next = next < 0 ? next : outline.next[next];\n    prev = prev < 0 ? prev : outline.prev[prev];\n    if (next >= 0) held[next] += amount * weight;\n    if (prev >= 0) held[prev] += amount * weight;\n  }\n}\n\n/** Fades the held light and adds it to this frame's energy. */\nfunction addHeld(collider: ColliderState, dt: number) {\n  const { held, energy, tint } = collider;\n  const keep = Math.exp(-dt / HOLD_LIFE);\n  for (let i = 0; i < held.length; i += 1) {\n    if (held[i] === 0) continue;\n    held[i] = held[i] * keep < 1e-3 ? 0 : held[i] * keep;\n    energy[i] += held[i];\n    tint[i] += held[i] * HOLD_TINT;\n  }\n}\n\n/** Ages the strikes and sums them into energy and tint: each one widens and shifts colour as it fades. */\nexport function updateEnergy(collider: ColliderState, dt: number) {\n  const { outline, energy, tint } = collider;\n  energy.fill(0);\n  tint.fill(0);\n  if (!outline) return;\n  collider.strikes = collider.strikes.filter((strike) => {\n    strike.age += dt;\n    return strike.age < STRIKE_LIFE * 4;\n  });\n  for (const strike of collider.strikes) {\n    const sigma = SPREAD_SIGMA + SPREAD_RATE * strike.age;\n    const amp = strike.amp * Math.exp(-strike.age / STRIKE_LIFE) * Math.sqrt(SPREAD_SIGMA / sigma);\n    if (!outline.linked) {\n      spreadNear(collider, outline, strike, sigma, amp);\n      continue;\n    }\n    deposit(collider, strike.index, amp, strike, 0);\n    walk(collider, outline, outline.next, strike, sigma, amp);\n    walk(collider, outline, outline.prev, strike, sigma, amp);\n  }\n  addHeld(collider, dt);\n  for (let i = 0; i < energy.length; i += 1) {\n    if (energy[i] > 0) tint[i] /= energy[i];\n  }\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/colliders.ts"
    },
    {
      "path": "components/aurora/wind/events.ts",
      "content": "/** Sent to a disc collider (data-au-collider-shape=\"disc\") each time the wind strikes it. */\nexport const WIND_STRIKE_EVENT = 'au-wind-strike';\n\nexport interface WindStrikeDetail {\n  /** Where the swell struck, in CSS px from the element's top-left corner. */\n  x: number;\n  y: number;\n  /** Strike strength, about 0–2: the swell's strength times the collider's gain. */\n  amp: number;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/events.ts"
    },
    {
      "path": "components/aurora/wind/framePresenters.ts",
      "content": "import { WIND_IDLE_STEPS } from './presentation';\nimport type { WindConfig, WindEffect, WindMessage, WindReply } from './windProtocol';\n\ntype Post = (reply: WindReply, transfer?: Transferable[]) => void;\n\n/** How the worker gets a painted frame on screen (presentation.ts). */\nexport interface FramePresenter {\n  /** The engine canvas holds a new frame, painted at `anchor`. */\n  frame(anchor: number, effects: WindEffect[]): void;\n  receive(message: WindMessage): void;\n  configure(config: WindConfig): void;\n  clear(): void;\n}\n\n/**\n * Presentation 'main': a bitmap per shown frame. At most one is in flight\n * until the main thread acknowledges it; the newest paint waits in the\n * canvas meanwhile. While the page is still, it shows one clock step in\n * WIND_IDLE_STEPS, and a held step hands its effects on to the next.\n */\nexport function createBitmapPresenter(canvas: OffscreenCanvas, post: Post): FramePresenter {\n  let pending: { anchor: number; effects: WindEffect[] } | null = null;\n  let inFlight = false;\n  let active = false;\n  let steps = 0;\n\n  const present = () => {\n    if (inFlight || !pending || (!active && steps < WIND_IDLE_STEPS)) return;\n    const frame = pending;\n    pending = null;\n    steps = 0;\n    const bitmap = canvas.transferToImageBitmap();\n    try {\n      post({ type: 'frame', bitmap, ...frame }, [bitmap]);\n      inFlight = true;\n    } catch (error) {\n      bitmap.close();\n      throw error;\n    }\n  };\n\n  return {\n    frame(anchor, effects) {\n      steps += 1;\n      // A paint replaced while a bitmap is in flight drops its effects, as before pacing.\n      const held = pending && !inFlight ? pending.effects : [];\n      pending = { anchor, effects: held.length > 0 ? [...held, ...effects] : effects };\n      present();\n    },\n    receive(message) {\n      if (message.type === 'presented') inFlight = false;\n      else if (message.type === 'pace') active = message.active;\n      else return;\n      present();\n    },\n    configure() {\n      pending = null;\n    },\n    clear() {\n      pending = null;\n    },\n  };\n}\n\n/** The hidden target keeps its frame this long after a swap, until the swap is surely on screen. */\nconst RELEASE_MS = 250;\n\ninterface Target {\n  canvas: OffscreenCanvas;\n  ctx: ImageBitmapRenderingContext;\n  /** The anchor of the frame it holds; NaN when empty. */\n  anchor: number;\n}\n\n/** The longest wait for the worker's next frame: a hidden or throttled canvas may get none. */\nconst NEXT_FRAME_MAX_MS = 34;\n\n/**\n * After the worker's next frame, or NEXT_FRAME_MAX_MS, whichever comes\n * first: the swap must not reach the page before its pixels, nor wait for a\n * frame that does not come.\n */\nfunction afterNextFrame(callback: () => void) {\n  let done = false;\n  const once = () => {\n    if (done) return;\n    done = true;\n    callback();\n  };\n  const scope = globalThis as { requestAnimationFrame?: (callback: () => void) => number };\n  if (typeof scope.requestAnimationFrame === 'function') scope.requestAnimationFrame(once);\n  setTimeout(once, NEXT_FRAME_MAX_MS);\n}\n\n/**\n * Presentation 'worker': the worker shows each frame on one of two page\n * canvases. Frames at the shown canvas's anchor go straight to it. A frame\n * at a new anchor goes to the hidden canvas, and the main thread reveals\n * that canvas and moves the layer together, so pixels and position change\n * in one commit. Until it confirms, only frames at the new anchor are shown.\n * Each clear starts a new epoch: a swap or a confirmation from an earlier\n * epoch is dropped, so a restart never reveals a canvas at an old anchor.\n */\nexport function createTargetPresenter(\n  canvas: OffscreenCanvas,\n  targets: OffscreenCanvas[],\n  post: Post,\n): FramePresenter {\n  const screens: Target[] = targets.map((target) => {\n    const ctx = target.getContext('bitmaprenderer');\n    if (!ctx) throw new Error('Bitmap presentation unavailable');\n    return { canvas: target, ctx, anchor: Number.NaN };\n  });\n  let shown = 0;\n  let swapping = false;\n  /** Clears so far; the page counts its own the same way. */\n  let epoch = 0;\n  let release: ReturnType<typeof setTimeout> | undefined;\n\n  const draw = (screen: Target, anchor: number) => {\n    screen.anchor = anchor;\n    screen.ctx.transferFromImageBitmap(canvas.transferToImageBitmap());\n  };\n  const empty = (screen: Target) => {\n    screen.anchor = Number.NaN;\n    screen.ctx.transferFromImageBitmap(null);\n  };\n\n  return {\n    frame(anchor, effects) {\n      if (effects.length > 0) post({ type: 'effects', effects });\n      const index = 1 - shown;\n      const spare = screens[index];\n      if (swapping) {\n        if (spare.anchor === anchor) draw(spare, anchor);\n        return;\n      }\n      if (screens[shown].anchor === anchor) return draw(screens[shown], anchor);\n      clearTimeout(release);\n      draw(spare, anchor);\n      swapping = true;\n      const swap = epoch;\n      afterNextFrame(() => {\n        if (swap === epoch) post({ type: 'show', index, anchor, epoch: swap });\n      });\n    },\n    receive(message) {\n      if (message.type !== 'shown' || message.epoch !== epoch) return;\n      shown = message.index;\n      swapping = false;\n      const hidden = screens[1 - shown];\n      clearTimeout(release);\n      release = setTimeout(() => empty(hidden), RELEASE_MS);\n    },\n    configure(config) {\n      const width = Math.round(config.width * config.dpr);\n      const height = Math.round(config.height * config.dpr);\n      for (const screen of screens) {\n        if (screen.canvas.width !== width) screen.canvas.width = width;\n        if (screen.canvas.height !== height) screen.canvas.height = height;\n      }\n    },\n    clear() {\n      clearTimeout(release);\n      epoch += 1;\n      swapping = false;\n      for (const screen of screens) empty(screen);\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/framePresenters.ts"
    },
    {
      "path": "components/aurora/wind/glyphField.ts",
      "content": "import type { GlyphMask } from './glyphs';\nimport type { Box } from './outline';\n\n/** The main thread posts a cached, coarse alpha mask only when the text changes. */\nexport interface GlyphShape {\n  bits: Uint8Array;\n  w: number;\n  h: number;\n  step: number;\n  pad: number;\n}\n\n/** Cached signed distances in grid cells, plus the padding. Quiet text uses half resolution. */\nexport interface GlyphField {\n  data: Float32Array;\n  w: number;\n  h: number;\n  x: number;\n  y: number;\n  step: number;\n  pad: number;\n  bounds: Box;\n}\n\nconst SOLID = 128;\n/** Covers the flux sheath as well as the padding. */\nconst BORDER = 80;\n/** How far the hull rounds into the gaps between letters and words, as a multiple of the padding. */\nconst ROUNDING = 2;\n/** Cells of box blur, per pass, that smooth the steps of the coarse mask out of the hull's edge. */\nconst SMOOTH = 2;\n/** The field's cells merge mask cells while the padding still spans this many of them. */\nconst CELLS_PER_PAD = 6;\nconst fields = new WeakMap<GlyphShape, GlyphField | null>();\n\n/** Distance construction stays on the worker, once per received shape. */\nexport function fieldFor(shape: GlyphShape) {\n  if (!fields.has(shape)) fields.set(shape, createGlyphField(shape));\n  return fields.get(shape) ?? undefined;\n}\n\n/** One bit per coarse pixel; no alpha or edge-light texture crosses the worker boundary. */\nexport function packGlyphMask(mask: GlyphMask, step: number, pad: number): GlyphShape {\n  const bits = new Uint8Array(Math.ceil(mask.alpha.length / 8));\n  for (let i = 0; i < mask.alpha.length; i += 1) {\n    if (mask.alpha[i] >= SOLID) bits[i >> 3] |= 1 << (i & 7);\n  }\n  return { bits, w: mask.w, h: mask.h, step, pad };\n}\n\nconst inkAt = (shape: GlyphShape, i: number) => (shape.bits[i >> 3] >> (i & 7)) & 1;\n\nfunction inkBounds(mask: GlyphShape) {\n  let left = mask.w;\n  let right = 0;\n  let top = mask.h;\n  let bottom = 0;\n  for (let y = 0; y < mask.h; y += 1) {\n    for (let x = 0; x < mask.w; x += 1) {\n      if (!inkAt(mask, y * mask.w + x)) continue;\n      left = Math.min(left, x);\n      right = Math.max(right, x + 1);\n      top = Math.min(top, y);\n      bottom = Math.max(bottom, y + 1);\n    }\n  }\n  return right > left ? { left, right, top, bottom } : null;\n}\n\nconst FAR = 1e10;\n\n/** One row or column of Felzenszwalb's exact squared distance transform, in place. */\nfunction transformLine(f: Float64Array, n: number, scratch: Scratch) {\n  const { v, z, d } = scratch;\n  let k = 0;\n  v[0] = 0;\n  z[0] = -FAR;\n  z[1] = FAR;\n  const meet = (q: number, p: number) => (f[q] + q * q - f[p] - p * p) / (2 * q - 2 * p);\n  for (let q = 1; q < n; q += 1) {\n    let s = meet(q, v[k]);\n    while (s <= z[k]) {\n      k -= 1;\n      s = meet(q, v[k]);\n    }\n    k += 1;\n    v[k] = q;\n    z[k] = s;\n    z[k + 1] = FAR;\n  }\n  k = 0;\n  for (let q = 0; q < n; q += 1) {\n    while (z[k + 1] < q) k += 1;\n    d[q] = (q - v[k]) ** 2 + f[v[k]];\n  }\n  f.set(d.subarray(0, n));\n}\n\ninterface Scratch {\n  v: Int32Array;\n  z: Float64Array;\n  d: Float64Array;\n}\n\n/** Exact Euclidean distance in cells from each cell to the nearest cell set in `inside`. */\nfunction distanceTo(inside: Uint8Array, w: number, h: number) {\n  const n = Math.max(w, h);\n  const scratch = { v: new Int32Array(n), z: new Float64Array(n + 1), d: new Float64Array(n) };\n  const line = new Float64Array(n);\n  const out = new Float32Array(w * h);\n  for (let y = 0; y < h; y += 1) {\n    for (let x = 0; x < w; x += 1) line[x] = inside[y * w + x] ? 0 : FAR;\n    transformLine(line, w, scratch);\n    for (let x = 0; x < w; x += 1) out[y * w + x] = line[x];\n  }\n  for (let x = 0; x < w; x += 1) {\n    for (let y = 0; y < h; y += 1) line[y] = out[y * w + x];\n    transformLine(line, h, scratch);\n    for (let y = 0; y < h; y += 1) out[y * w + x] = Math.sqrt(line[y]);\n  }\n  return out;\n}\n\n/** A running box blur of `length` values, `stride` apart from `start`, past clamped ends. */\nfunction blurLine(\n  data: Float32Array,\n  start: number,\n  stride: number,\n  length: number,\n  line: Float32Array,\n) {\n  for (let b = 0; b < length; b += 1) line[b] = data[start + b * stride];\n  const last = length - 1;\n  let sum = line[0] * (SMOOTH + 1);\n  for (let o = 1; o <= SMOOTH; o += 1) sum += line[Math.min(o, last)];\n  for (let b = 0; b < length; b += 1) {\n    data[start + b * stride] = sum / (2 * SMOOTH + 1);\n    sum += line[Math.min(b + SMOOTH + 1, last)] - line[Math.max(b - SMOOTH, 0)];\n  }\n}\n\n/** Two passes of a box blur along rows, then columns. A distance field stays linear where it is flat. */\nfunction blur(data: Float32Array, w: number, h: number) {\n  const line = new Float32Array(Math.max(w, h));\n  for (let pass = 0; pass < 2; pass += 1) {\n    for (let y = 0; y < h; y += 1) blurLine(data, y * w, 1, w, line);\n    for (let x = 0; x < w; x += 1) blurLine(data, x, w, h, line);\n  }\n}\n\n/**\n * The field's grid: cells of `merge` mask cells each, as coarse as the padding\n * allows, with a border that holds the grown ink, the sheath and the blur.\n */\nfunction fieldGrid(mask: GlyphShape, ink: NonNullable<ReturnType<typeof inkBounds>>) {\n  const merge = Math.max(1, Math.floor(mask.pad / (CELLS_PER_PAD * mask.step)));\n  const step = mask.step * merge;\n  const grow = (mask.pad * (1 + ROUNDING)) / step;\n  const border = Math.ceil(Math.max(grow + 1, (mask.pad + BORDER) / step)) + 2 * SMOOTH;\n  const left = Math.floor(ink.left / merge) - border;\n  const top = Math.floor(ink.top / merge) - border;\n  const w = Math.ceil(ink.right / merge) + border - left;\n  const h = Math.ceil(ink.bottom / merge) + border - top;\n  const solid = new Uint8Array(w * h);\n  for (let y = ink.top; y < ink.bottom; y += 1) {\n    for (let x = ink.left; x < ink.right; x += 1) {\n      if (inkAt(mask, y * mask.w + x))\n        solid[(Math.floor(y / merge) - top) * w + Math.floor(x / merge) - left] = 1;\n    }\n  }\n  return { step, grow, left, top, w, h, solid };\n}\n\n/**\n * Builds once per text revision; quiet text keeps no glyph-edge lighting samples.\n * The ink is grown by the padding and closed by `pad * ROUNDING` (grown, then\n * shrunk back), so the flux bends round the heading as one rounded block\n * instead of every word and letter, where it kinked and jittered. Exact distances, kept as\n * floats and blurred over the mask's steps, give an edge without kinks that\n * shift as the lines slide past.\n */\nfunction createGlyphField(mask: GlyphShape): GlyphField | null {\n  const ink = inkBounds(mask);\n  if (!ink) return null;\n  const { step, grow, left, top, w, h, solid } = fieldGrid(mask, ink);\n  const fromInk = distanceTo(solid, w, h);\n  const outside = fromInk.map((d) => (d > grow ? 1 : 0));\n  const fromOutside = distanceTo(new Uint8Array(outside), w, h);\n  // Outside the grown ink the distance to the hull is the distance to the ink less the padding.\n  const data = fromInk.map((d, i) => (d > grow ? d : grow - fromOutside[i] + 0.5));\n  blur(data, w, h);\n  return {\n    data,\n    w,\n    h,\n    x: left * step,\n    y: top * step,\n    step,\n    pad: mask.pad,\n    bounds: {\n      x: ink.left * mask.step,\n      y: ink.top * mask.step,\n      w: (ink.right - ink.left) * mask.step,\n      h: (ink.bottom - ink.top) * mask.step,\n    },\n  };\n}\n\n/** Shared result: the tracer consumes it before the next lookup. */\nexport const glyphSample = { nx: 0, ny: 0 };\n\n/** Bilinear distance and its gradient, in collider-local canvas coordinates. Allocates nothing. */\nexport function sampleGlyphField(field: GlyphField, x: number, y: number) {\n  const u = Math.max(0, Math.min(field.w - 1.001, (x - field.x) / field.step - 0.5));\n  const v = Math.max(0, Math.min(field.h - 1.001, (y - field.y) / field.step - 0.5));\n  const ix = Math.floor(u);\n  const iy = Math.floor(v);\n  const fx = u - ix;\n  const fy = v - iy;\n  const i = iy * field.w + ix;\n  const a = field.data[i];\n  const b = field.data[i + 1];\n  const c = field.data[i + field.w];\n  const d = field.data[i + field.w + 1];\n  const gx = (b - a) * (1 - fy) + (d - c) * fy;\n  const gy = (c - a) * (1 - fx) + (d - b) * fx;\n  const length = Math.sqrt(gx * gx + gy * gy) || 1;\n  glyphSample.nx = gx / length;\n  glyphSample.ny = gy / length;\n  return ((a + (b - a) * fx) * (1 - fy) + (c + (d - c) * fx) * fy) * field.step - field.pad;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/glyphField.ts"
    },
    {
      "path": "components/aurora/wind/glyphs.ts",
      "content": "/**\n * Text colliders trace the letters, not their box. The text is drawn into an\n * alpha mask, each character at the position the browser gave it, so the mask\n * matches the rendered glyphs to about a pixel. The outline is every glyph\n * edge pixel in the mask, thinned to one per SPACING px, with an outward\n * normal from the mask gradient.\n */\nimport type { Box, Outline } from './outline';\nimport { allocateOutline } from './outline';\n\nexport interface GlyphMask {\n  /** Alpha, 0–255, one byte per canvas px of the collider's box. */\n  alpha: Uint8Array;\n  w: number;\n  h: number;\n}\n\n/** Where a client rect sits in the collider's own canvas px. */\ntype ToLocal = (rect: DOMRect) => { x: number; y: number; h: number };\n\nconst SPACING = 3;\nconst SOLID = 128;\nlet raster: HTMLCanvasElement | null = null;\n\nfunction caseOf(style: CSSStyleDeclaration, char: string) {\n  if (style.textTransform === 'uppercase') return char.toUpperCase();\n  if (style.textTransform === 'lowercase') return char.toLowerCase();\n  return char;\n}\n\n/** Sets the canvas font from a text node's parent; returns the share of the line box above the baseline. */\nfunction applyFont(ctx: CanvasRenderingContext2D, style: CSSStyleDeclaration, scale: number) {\n  const size = Number.parseFloat(style.fontSize) * scale;\n  ctx.font = `${style.fontStyle} ${style.fontWeight} ${size}px ${style.fontFamily}`;\n  const metrics = ctx.measureText('Hg');\n  const ascent = metrics.fontBoundingBoxAscent;\n  return ascent / (ascent + metrics.fontBoundingBoxDescent || 1);\n}\n\n/** Finds a wrapped line with logarithmic range reads instead of measuring every character. */\nfunction lineEnd(range: Range, node: Text, start: number, end: number) {\n  let low = start + 1;\n  let high = end;\n  range.setStart(node, start);\n  while (low < high) {\n    const middle = Math.ceil((low + high) / 2);\n    range.setEnd(node, middle);\n    if (range.getClientRects().length > 1) high = middle - 1;\n    else low = middle;\n  }\n  return low;\n}\n\n/** Canvas shapes whole lines, preserving kerning and ligatures as well as CSS spacing. */\nfunction drawLines(ctx: CanvasRenderingContext2D, node: Text, toLocal: ToLocal, above: number) {\n  const text = node.textContent ?? '';\n  const range = document.createRange();\n  range.selectNodeContents(node);\n  const rects = [...range.getClientRects()];\n  let start = 0;\n  for (let i = 0; i < rects.length; i += 1) {\n    const end = i === rects.length - 1 ? text.length : lineEnd(range, node, start, text.length);\n    const { x, y, h } = toLocal(rects[i]);\n    ctx.fillText(text.slice(start, end).replace(/\\s+/g, ' ').trim(), x, y + h * above);\n    start = end;\n  }\n}\n\nfunction drawNode(\n  ctx: CanvasRenderingContext2D,\n  node: Text,\n  toLocal: ToLocal,\n  scale: number,\n  coarse: boolean,\n) {\n  const parent = node.parentElement;\n  const text = node.textContent ?? '';\n  if (!parent || text.trim() === '') return;\n  const style = getComputedStyle(parent);\n  const above = applyFont(ctx, style, scale);\n  if (coarse && style.textTransform === 'none') {\n    ctx.letterSpacing = `${(Number.parseFloat(style.letterSpacing) || 0) * scale}px`;\n    ctx.wordSpacing = `${(Number.parseFloat(style.wordSpacing) || 0) * scale}px`;\n    drawLines(ctx, node, toLocal, above);\n    return;\n  }\n  const range = document.createRange();\n  for (let i = 0; i < text.length; i += 1) {\n    if (text[i].trim() === '') continue;\n    range.setStart(node, i);\n    range.setEnd(node, i + 1);\n    const rect = range.getBoundingClientRect();\n    if (rect.width === 0) continue;\n    const { x, y, h } = toLocal(rect);\n    ctx.fillText(caseOf(style, text[i]), x, y + h * above);\n  }\n}\n\n/** Draws the element's text into a mask the size of its box. Null when the canvas is unavailable. */\nexport function drawGlyphMask(\n  element: HTMLElement,\n  w: number,\n  h: number,\n  scale: number,\n  toLocal: ToLocal,\n  resolution = 1,\n) {\n  const canvas = (raster ??= document.createElement('canvas'));\n  canvas.width = Math.max(1, Math.ceil(w * resolution));\n  canvas.height = Math.max(1, Math.ceil(h * resolution));\n  const ctx = canvas.getContext('2d', { willReadFrequently: true });\n  if (!ctx) return null;\n  ctx.scale(resolution, resolution);\n  ctx.textBaseline = 'alphabetic';\n  ctx.fillStyle = '#000';\n  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);\n  for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n    drawNode(ctx, node as Text, toLocal, scale, resolution < 1);\n  }\n  const rgba = ctx.getImageData(0, 0, canvas.width, canvas.height).data;\n  const alpha = new Uint8Array(canvas.width * canvas.height);\n  for (let i = 0; i < alpha.length; i += 1) alpha[i] = rgba[i * 4 + 3];\n  return { alpha, w: canvas.width, h: canvas.height };\n}\n\n/** Alpha at a pixel; 0 outside the mask. */\nfunction alphaAt(mask: GlyphMask, x: number, y: number) {\n  const ix = Math.floor(x);\n  const iy = Math.floor(y);\n  if (ix < 0 || iy < 0 || ix >= mask.w || iy >= mask.h) return 0;\n  return mask.alpha[iy * mask.w + ix];\n}\n\nconst insideGlyph = (mask: GlyphMask, x: number, y: number) => alphaAt(mask, x, y) >= SOLID;\n\nfunction isEdge(mask: GlyphMask, x: number, y: number) {\n  return (\n    insideGlyph(mask, x, y) &&\n    !(\n      insideGlyph(mask, x - 1, y) &&\n      insideGlyph(mask, x + 1, y) &&\n      insideGlyph(mask, x, y - 1) &&\n      insideGlyph(mask, x, y + 1)\n    )\n  );\n}\n\n/** Outward normal: against the alpha gradient, read 2 px apart so antialiasing does not jitter it. */\nfunction normalAt(mask: GlyphMask, x: number, y: number): [number, number] | null {\n  let gx = 0;\n  let gy = 0;\n  for (let d = -1; d <= 1; d += 1) {\n    gx += alphaAt(mask, x + 2, y + d) - alphaAt(mask, x - 2, y + d);\n    gy += alphaAt(mask, x + d, y + 2) - alphaAt(mask, x + d, y - 2);\n  }\n  const length = Math.hypot(gx, gy);\n  return length < 1 ? null : [-gx / length, -gy / length];\n}\n\n/** The glyph edges as an outline. Samples are not linked: light spreads to nearby edges by distance. */\nexport function glyphOutline(mask: GlyphMask, boxes: Box[]): Outline {\n  const taken = new Set<number>();\n  const points: number[] = [];\n  for (let y = 0; y < mask.h; y += 1) {\n    for (let x = 0; x < mask.w; x += 1) {\n      const cell = Math.floor(x / SPACING) + Math.floor(y / SPACING) * 100003;\n      if (taken.has(cell) || !isEdge(mask, x, y)) continue;\n      const normal = normalAt(mask, x, y);\n      if (!normal) continue;\n      taken.add(cell);\n      points.push(x + 0.5, y + 0.5, normal[0], normal[1]);\n    }\n  }\n  const outline = allocateOutline(points.length / 4, boxes, false);\n  for (let i = 0; i < outline.count; i += 1) {\n    [outline.x[i], outline.y[i], outline.nx[i], outline.ny[i]] = points.slice(i * 4, i * 4 + 4);\n    outline.s[i] = outline.x[i];\n  }\n  return outline;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/glyphs.ts"
    },
    {
      "path": "components/aurora/wind/gpu/fluxMesh.ts",
      "content": "import { inkAt } from '../styles/fluxInk';\nimport { COS, GLOW, LINE, type Lines, MAX_POINTS, SIN, SWELL } from '../styles/fluxLines';\nimport type { StyleFrame } from '../styles/types';\n\nexport const VERTEX_FLOATS = 5;\nconst PAD = 6;\n\nconst glows = (lines: Lines, from: number, to: number) => {\n  for (let i = from; i < to; i += 1) if (lines.extra[i] > 0) return true;\n  return false;\n};\n\n/** Adjacent segments share vertices, so their joins do not add a second cap under additive blending. */\nexport function createFluxMesh() {\n  const vertices = new Float32Array(MAX_POINTS * 2 * VERTEX_FLOATS);\n  const indices = new Uint16Array(MAX_POINTS * 6);\n  let usedVertices = 0;\n  let usedIndices = 0;\n\n  function point(\n    lines: Lines,\n    i: number,\n    from: number,\n    to: number,\n    line: number,\n    frame: StyleFrame,\n    page: number,\n    glowOnly: boolean,\n  ) {\n    const prev = Math.max(from, i - 1);\n    const next = Math.min(to - 1, i + 1);\n    const x = lines.x[i];\n    const y = lines.y[i];\n    const ax = x - lines.x[prev];\n    const ay = y - lines.y[prev];\n    const bx = lines.x[next] - x;\n    const by = lines.y[next] - y;\n    const before = Math.sqrt(ax * ax + ay * ay) || 1;\n    const after = Math.sqrt(bx * bx + by * by) || 1;\n    const outX = next === i ? -ay / before : -by / after;\n    const outY = next === i ? ax / before : bx / after;\n    let nx = -ay / before + outX;\n    let ny = ax / before + outY;\n    const size = Math.sqrt(nx * nx + ny * ny) || 1;\n    nx /= size;\n    ny /= size;\n    const miter = PAD / Math.max(0.5, nx * outX + ny * outY);\n    const swell = glowOnly ? 0 : inkAt(line, COS * x + SIN * (y + page), frame.t);\n    const fade = glowOnly ? 0 : Math.min(lines.fade[i], lines.fade[next]);\n    const spread = frame.quality === 'full' ? 0.015 * swell : 0;\n    const core =\n      ((LINE + SWELL * swell - spread) * fade + Math.min(GLOW * 1.25, lines.extra[i])) *\n      frame.look.gain;\n    const halo = spread * fade * frame.look.gain;\n    for (let side = -1; side <= 1; side += 2) {\n      const at = usedVertices * VERTEX_FLOATS;\n      vertices[at] = x + side * nx * miter;\n      vertices[at + 1] = y + side * ny * miter;\n      vertices[at + 2] = side * PAD;\n      vertices[at + 3] = core;\n      vertices[at + 4] = halo;\n      usedVertices += 1;\n    }\n  }\n\n  function run(\n    lines: Lines,\n    from: number,\n    to: number,\n    line: number,\n    frame: StyleFrame,\n    page: number,\n    glowOnly: boolean,\n  ) {\n    if (to - from < 2 || (glowOnly && !glows(lines, from, to))) return;\n    const base = usedVertices;\n    for (let i = from; i < to; i += 1) point(lines, i, from, to, line, frame, page, glowOnly);\n    for (let i = 0; i < to - from - 1; i += 1) {\n      const a = base + i * 2;\n      indices[usedIndices++] = a;\n      indices[usedIndices++] = a + 1;\n      indices[usedIndices++] = a + 2;\n      indices[usedIndices++] = a + 2;\n      indices[usedIndices++] = a + 1;\n      indices[usedIndices++] = a + 3;\n    }\n  }\n\n  return {\n    vertices,\n    indices,\n    get usedVertices() {\n      return usedVertices;\n    },\n    /** `glowOnly` meshes just the glow along the edges, without the lines it sits on. */\n    update(lines: Lines, frame: StyleFrame, page: number, glowOnly = false) {\n      usedVertices = 0;\n      usedIndices = 0;\n      for (let l = 0; l < lines.lines; l += 1) {\n        const end = l + 1 < lines.lines ? lines.start[l + 1] : lines.count;\n        let from = lines.start[l];\n        for (let i = from + 1; i < end; i += 1) {\n          if (!lines.move[i]) continue;\n          run(lines, from, i, lines.k[l], frame, page, glowOnly);\n          from = i;\n        }\n        run(lines, from, end, lines.k[l], frame, page, glowOnly);\n      }\n      return usedIndices;\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/gpu/fluxMesh.ts"
    },
    {
      "path": "components/aurora/wind/gpu/fluxPainter.ts",
      "content": "import { mix } from '../../fieldTypes';\nimport type { Look } from '../render';\nimport type { Lines } from '../styles/fluxLines';\nimport type { StyleFrame } from '../styles/types';\nimport { createFluxMesh, VERTEX_FLOATS } from './fluxMesh';\nimport { CLIP_SHADER, createProgram, type WindowClip } from './program';\n\nconst VERTEX = `#version 300 es\nlayout(location=0) in vec2 a_pos;\nlayout(location=1) in float a_across;\nlayout(location=2) in float a_core;\nlayout(location=3) in float a_halo;\nuniform vec2 u_view;\nout vec2 v_pos;\nout float v_across;\nout float v_core;\nout float v_halo;\nvoid main() {\n  gl_Position = vec4(a_pos.x/u_view.x*2.0-1.0,1.0-a_pos.y/u_view.y*2.0,0.0,1.0);\n  v_pos=a_pos; v_across=a_across; v_core=a_core; v_halo=a_halo;\n}`;\nconst FRAGMENT = `#version 300 es\nprecision highp float;\n${CLIP_SHADER}\nin vec2 v_pos;\nin float v_across;\nin float v_core;\nin float v_halo;\nuniform vec3 u_colour;\nout vec4 colour;\nvoid main() {\n  float aa=max(1.0/u_dpr,fwidth(v_across));\n  float core=1.0-smoothstep(0.6-aa*0.5,0.6+aa*0.5,abs(v_across));\n  float halo=v_halo>0.0?exp(-v_across*v_across/4.5):0.0;\n  float alpha=(core*v_core+halo*v_halo)*windowCoverage(v_pos);\n  if(alpha<0.001)discard;\n  colour=vec4(u_colour*alpha,alpha);\n}`;\n\nexport function createGpuFluxPainter(gl: WebGL2RenderingContext, clip: WindowClip) {\n  const program = createProgram(gl, VERTEX, FRAGMENT);\n  const vao = gl.createVertexArray();\n  const vertex = gl.createBuffer();\n  const index = gl.createBuffer();\n  if (!vao || !vertex || !index) throw new Error('Wind mesh allocation failed');\n  const mesh = createFluxMesh();\n  let look: Look | null = null;\n  gl.bindVertexArray(vao);\n  gl.bindBuffer(gl.ARRAY_BUFFER, vertex);\n  gl.bufferData(gl.ARRAY_BUFFER, mesh.vertices.byteLength, gl.DYNAMIC_DRAW);\n  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, index);\n  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices.byteLength, gl.DYNAMIC_DRAW);\n  let offset = 0;\n  for (const [location, size] of [\n    [0, 2],\n    [1, 1],\n    [2, 1],\n    [3, 1],\n  ]) {\n    gl.enableVertexAttribArray(location);\n    gl.vertexAttribPointer(location, size, gl.FLOAT, false, VERTEX_FLOATS * 4, offset * 4);\n    offset += size;\n  }\n  const colour = gl.getUniformLocation(program, 'u_colour');\n  return (lines: Lines, frame: StyleFrame, page: number, glowOnly = false) => {\n    const count = mesh.update(lines, frame, page, glowOnly);\n    if (count === 0) return;\n    // biome-ignore lint/correctness/useHookAtTopLevel: WebGL useProgram is not a React hook.\n    gl.useProgram(program);\n    gl.bindVertexArray(vao);\n    clip.apply(program);\n    if (look !== frame.look) {\n      look = frame.look;\n      const rgb = mix(look.speck, look.halo, look.dark ? 0.3 : 0.65);\n      gl.uniform3f(colour, rgb[0] / 255, rgb[1] / 255, rgb[2] / 255);\n    }\n    gl.bindBuffer(gl.ARRAY_BUFFER, vertex);\n    gl.bufferSubData(gl.ARRAY_BUFFER, 0, mesh.vertices, 0, mesh.usedVertices * VERTEX_FLOATS);\n    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, index);\n    gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, 0, mesh.indices, 0, count);\n    gl.drawElements(gl.TRIANGLES, count, gl.UNSIGNED_SHORT, 0);\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/gpu/fluxPainter.ts"
    },
    {
      "path": "components/aurora/wind/gpu/gpuSurface.ts",
      "content": "import { forEachRaySprite, RAY_WIDTH } from '../impactSprites';\nimport type { Look } from '../render';\nimport type { WindWindow } from '../windows';\nimport type { WindCanvas, WindSurface } from '../windSurface';\nimport { createGpuFluxPainter } from './fluxPainter';\nimport { createWindowClip } from './program';\nimport { createGpuSpritePainter } from './spritePainter';\n\n/** Lines and sprites share the display framebuffer; no rendered layer is copied through Canvas2D. */\nexport function createGpuWindSurface(canvas: WindCanvas, gl: WebGL2RenderingContext): WindSurface {\n  const clip = createWindowClip(gl);\n  const lines = createGpuFluxPainter(gl, clip);\n  const sprites = createGpuSpritePainter(gl, clip);\n  let palette: Look | null = null;\n  let clipped = false;\n  /** Set by clipLines: the band round hovered cards on the plain ground that shows only edge glow. */\n  let glow: WindWindow[] | null = null;\n  let full = true;\n  const clear = () => gl.clear(gl.COLOR_BUFFER_BIT);\n  gl.disable(gl.DEPTH_TEST);\n  gl.disable(gl.STENCIL_TEST);\n  gl.enable(gl.BLEND);\n  gl.clearColor(0, 0, 0, 0);\n  return {\n    kind: 'webgl2',\n    configure(config, look) {\n      const width = Math.round(config.width * config.dpr);\n      const height = Math.round(config.height * config.dpr);\n      if (canvas.width !== width) canvas.width = width;\n      if (canvas.height !== height) canvas.height = height;\n      gl.viewport(0, 0, width, height);\n      clip.resize(config.width, config.height, config.dpr);\n      if (palette !== look) {\n        palette = look;\n        sprites.atlas(look.sprites);\n      }\n    },\n    begin(windows, windowed, look) {\n      clear();\n      clipped = windowed;\n      glow = null;\n      full = true;\n      clip.update(windows, windowed);\n      gl.blendFunc(gl.ONE, look.dark ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA);\n    },\n    impacts(colliders, look, t) {\n      for (const collider of colliders) {\n        if (!collider.visible || collider.disc) continue;\n        forEachRaySprite(collider, look.sprites, look.gain, t, (image, ray, alpha) => {\n          const lx = ray.x + ray.ny * (RAY_WIDTH / 2);\n          const ly = ray.y - ray.nx * (RAY_WIDTH / 2);\n          const rx = ray.x - ray.ny * (RAY_WIDTH / 2);\n          const ry = ray.y + ray.nx * (RAY_WIDTH / 2);\n          const ox = ray.nx * ray.length;\n          const oy = ray.ny * ray.length;\n          sprites.quad(image, lx + ox, ly + oy, rx + ox, ry + oy, lx, ly, rx, ry, alpha);\n        });\n      }\n      sprites.flush();\n    },\n    clipLines(sheets, glows) {\n      if (!clipped) return;\n      glow = glows;\n      full = sheets.length > 0;\n      clip.update(sheets, true);\n    },\n    lines(traced, frame, page) {\n      if (full) lines(traced, frame, page);\n      if (!glow) return;\n      clip.update(glow, true);\n      lines(traced, frame, page, true);\n    },\n    end() {\n      sprites.flush();\n      gl.flush();\n    },\n    clear,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/gpu/gpuSurface.ts"
    },
    {
      "path": "components/aurora/wind/gpu/program.ts",
      "content": "import type { WindWindow } from '../windows';\n\nexport const MAX_WINDOWS = 64;\nexport const CLIP_SHADER = `\nuniform vec2 u_view;\nuniform float u_dpr;\nuniform bool u_windowed;\nuniform int u_count;\nuniform vec4 u_windows[64];\nuniform float u_radii[64];\nfloat windowCoverage(vec2 point) {\n  if (!u_windowed) return 1.0;\n  float distance = 1e9;\n  for (int i = 0; i < 64; i++) {\n    if (i >= u_count) break;\n    vec4 w = u_windows[i];\n    if(w.z<=0.0 || w.w<=0.0)continue;\n    float r = clamp(u_radii[i], 0.0, min(w.z, w.w) * 0.5);\n    vec2 q = abs(point - w.xy - w.zw * 0.5) - w.zw * 0.5 + r;\n    float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;\n    distance = min(distance, d);\n  }\n  return 1.0 - smoothstep(-0.5 / u_dpr, 0.5 / u_dpr, distance);\n}`;\n\nfunction compile(gl: WebGL2RenderingContext, kind: number, source: string) {\n  const shader = gl.createShader(kind);\n  if (!shader) throw new Error('Wind shader allocation failed');\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    const reason = gl.getShaderInfoLog(shader);\n    gl.deleteShader(shader);\n    throw new Error(reason ?? 'Wind shader compilation failed');\n  }\n  return shader;\n}\n\nexport function createProgram(gl: WebGL2RenderingContext, vertex: string, fragment: string) {\n  const program = gl.createProgram();\n  if (!program) throw new Error('Wind program allocation failed');\n  const shaders = [\n    compile(gl, gl.VERTEX_SHADER, vertex),\n    compile(gl, gl.FRAGMENT_SHADER, fragment),\n  ];\n  for (const shader of shaders) gl.attachShader(program, shader);\n  gl.linkProgram(program);\n  for (const shader of shaders) gl.deleteShader(shader);\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    const reason = gl.getProgramInfoLog(program);\n    gl.deleteProgram(program);\n    throw new Error(reason ?? 'Wind program link failed');\n  }\n  return program;\n}\n\nexport function createWindowClip(gl: WebGL2RenderingContext) {\n  const bounds = new Float32Array(MAX_WINDOWS * 4);\n  const radii = new Float32Array(MAX_WINDOWS);\n  const locations = new Map<WebGLProgram, Record<string, WebGLUniformLocation | null>>();\n  let count = 0;\n  let windowed = false;\n  let width = 1;\n  let height = 1;\n  let dpr = 1;\n  return {\n    resize(w: number, h: number, scale: number) {\n      width = w;\n      height = h;\n      dpr = scale;\n    },\n    update(windows: WindWindow[], clipped: boolean) {\n      if (windows.length > MAX_WINDOWS)\n        throw new Error('Too many wind windows for the GPU surface');\n      count = windows.length;\n      windowed = clipped;\n      for (let i = 0; i < count; i += 1) {\n        const w = windows[i];\n        bounds[i * 4] = w.x;\n        bounds[i * 4 + 1] = w.y;\n        bounds[i * 4 + 2] = w.w;\n        bounds[i * 4 + 3] = w.h;\n        radii[i] = w.r;\n      }\n    },\n    apply(program: WebGLProgram) {\n      let uniforms = locations.get(program);\n      if (!uniforms) {\n        uniforms = Object.fromEntries(\n          ['u_view', 'u_dpr', 'u_windowed', 'u_count', 'u_windows', 'u_radii'].map((name) => [\n            name,\n            gl.getUniformLocation(program, name),\n          ]),\n        );\n        locations.set(program, uniforms);\n      }\n      gl.uniform2f(uniforms.u_view, width, height);\n      gl.uniform1f(uniforms.u_dpr, dpr);\n      gl.uniform1i(uniforms.u_windowed, Number(windowed));\n      gl.uniform1i(uniforms.u_count, count);\n      gl.uniform4fv(uniforms.u_windows, bounds);\n      gl.uniform1fv(uniforms.u_radii, radii);\n    },\n  };\n}\nexport type WindowClip = ReturnType<typeof createWindowClip>;\n",
      "type": "registry:component",
      "target": "components/aurora/wind/gpu/program.ts"
    },
    {
      "path": "components/aurora/wind/gpu/spritePainter.ts",
      "content": "import { type FieldContext, fieldCanvas } from '../../fieldTypes';\nimport type { SpriteSets } from '../impactSprites';\nimport type { WindSprite } from '../windSurface';\nimport { CLIP_SHADER, createProgram, type WindowClip } from './program';\n\ninterface TextureBox {\n  texture: WebGLTexture;\n  u0: number;\n  v0: number;\n  u1: number;\n  v1: number;\n}\nconst VERTEX = `#version 300 es\nlayout(location=0) in vec2 a_pos;\nlayout(location=1) in vec2 a_uv;\nlayout(location=2) in float a_alpha;\nuniform vec2 u_view;\nout vec2 v_pos;\nout vec2 v_uv;\nout float v_alpha;\nvoid main(){\n  gl_Position=vec4(a_pos.x/u_view.x*2.0-1.0,1.0-a_pos.y/u_view.y*2.0,0.0,1.0);\n  v_pos=a_pos; v_uv=a_uv; v_alpha=a_alpha;\n}`;\nconst FRAGMENT = `#version 300 es\nprecision highp float;\n${CLIP_SHADER}\nin vec2 v_pos;\nin vec2 v_uv;\nin float v_alpha;\nuniform sampler2D u_image;\nout vec4 colour;\nvoid main(){colour=texture(u_image,v_uv)*v_alpha*windowCoverage(v_pos);}`;\n\nfunction upload(gl: WebGL2RenderingContext, image: WindSprite) {\n  const texture = gl.createTexture();\n  if (!texture) throw new Error('Wind texture allocation failed');\n  gl.bindTexture(gl.TEXTURE_2D, texture);\n  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);\n  return texture;\n}\n\nexport function createGpuSpritePainter(gl: WebGL2RenderingContext, clip: WindowClip) {\n  const program = createProgram(gl, VERTEX, FRAGMENT);\n  const vao = gl.createVertexArray();\n  const buffer = gl.createBuffer();\n  if (!vao || !buffer) throw new Error('Wind sprite allocation failed');\n  const data = new Float32Array(4096 * 30);\n  let used = 0;\n  let current: WebGLTexture | null = null;\n  let images = new WeakMap<WindSprite, TextureBox>();\n  const textures = new Set<WebGLTexture>();\n  gl.bindVertexArray(vao);\n  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n  gl.bufferData(gl.ARRAY_BUFFER, data.byteLength, gl.DYNAMIC_DRAW);\n  let offset = 0;\n  for (const [location, size] of [\n    [0, 2],\n    [1, 2],\n    [2, 1],\n  ]) {\n    gl.enableVertexAttribArray(location);\n    gl.vertexAttribPointer(location, size, gl.FLOAT, false, 20, offset * 4);\n    offset += size;\n  }\n  const sampler = gl.getUniformLocation(program, 'u_image');\n\n  function flush() {\n    if (used === 0) return;\n    // biome-ignore lint/correctness/useHookAtTopLevel: WebGL useProgram is not a React hook.\n    gl.useProgram(program);\n    gl.bindVertexArray(vao);\n    clip.apply(program);\n    gl.activeTexture(gl.TEXTURE0);\n    gl.bindTexture(gl.TEXTURE_2D, current);\n    gl.uniform1i(sampler, 0);\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    gl.bufferSubData(gl.ARRAY_BUFFER, 0, data, 0, used);\n    gl.drawArrays(gl.TRIANGLES, 0, used / 5);\n    used = 0;\n  }\n\n  function vertex(x: number, y: number, u: number, v: number, alpha: number) {\n    data[used++] = x;\n    data[used++] = y;\n    data[used++] = u;\n    data[used++] = v;\n    data[used++] = alpha;\n  }\n\n  function imageBox(image: WindSprite) {\n    let box = images.get(image);\n    if (!box) {\n      const texture = upload(gl, image);\n      textures.add(texture);\n      box = { texture, u0: 0, v0: 0, u1: 1, v1: 1 };\n      images.set(image, box);\n    }\n    return box;\n  }\n\n  function atlas(sprites: SpriteSets) {\n    for (const texture of textures) gl.deleteTexture(texture);\n    textures.clear();\n    images = new WeakMap();\n    current = null;\n    used = 0;\n    const tileW = sprites.rays[0][0].width + 2;\n    const tileH = sprites.rays[0][0].height + 2;\n    const canvas = fieldCanvas(tileW * sprites.rays.length, tileH * sprites.rays[0].length);\n    const ctx = canvas.getContext('2d') as FieldContext | null;\n    if (!ctx) throw new Error('Wind atlas context unavailable');\n    for (let col = 0; col < sprites.rays.length; col += 1)\n      for (let row = 0; row < sprites.rays[col].length; row += 1)\n        ctx.drawImage(sprites.rays[col][row], col * tileW + 1, row * tileH + 1);\n    const texture = upload(gl, canvas);\n    textures.add(texture);\n    for (let col = 0; col < sprites.rays.length; col += 1) {\n      for (let row = 0; row < sprites.rays[col].length; row += 1) {\n        const image = sprites.rays[col][row];\n        const x = col * tileW + 1;\n        const y = row * tileH + 1;\n        images.set(image, {\n          texture,\n          u0: x / canvas.width,\n          v0: y / canvas.height,\n          u1: (x + image.width) / canvas.width,\n          v1: (y + image.height) / canvas.height,\n        });\n      }\n    }\n  }\n\n  return {\n    atlas,\n    flush,\n    quad(\n      image: WindSprite,\n      ax: number,\n      ay: number,\n      bx: number,\n      by: number,\n      cx: number,\n      cy: number,\n      dx: number,\n      dy: number,\n      alpha: number,\n    ) {\n      const box = imageBox(image);\n      if (box.texture !== current || used + 30 > data.length) flush();\n      current = box.texture;\n      vertex(ax, ay, box.u0, box.v0, alpha);\n      vertex(cx, cy, box.u0, box.v1, alpha);\n      vertex(bx, by, box.u1, box.v0, alpha);\n      vertex(bx, by, box.u1, box.v0, alpha);\n      vertex(cx, cy, box.u0, box.v1, alpha);\n      vertex(dx, dy, box.u1, box.v1, alpha);\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/gpu/spritePainter.ts"
    },
    {
      "path": "components/aurora/wind/gpu/workerSurface.ts",
      "content": "import { createCanvasWindSurface } from '../canvasSurface';\nimport type { Look } from '../render';\nimport type { WindConfig } from '../windProtocol';\nimport type { WindSurface } from '../windSurface';\nimport { createGpuWindSurface } from './gpuSurface';\n\nconst ATTRIBUTES: WebGLContextAttributes = {\n  alpha: true,\n  premultipliedAlpha: true,\n  antialias: false,\n  depth: false,\n  stencil: false,\n  powerPreference: 'low-power',\n};\n\n/** Detect software GL on a probe, so rejecting it does not lock the display canvas to that context. */\nfunction hardwareGlAvailable() {\n  const probe = new OffscreenCanvas(1, 1);\n  const gl = probe.getContext('webgl2', ATTRIBUTES);\n  if (!gl) return false;\n  const debug = gl.getExtension('WEBGL_debug_renderer_info');\n  const renderer = String(gl.getParameter(debug?.UNMASKED_RENDERER_WEBGL ?? gl.RENDERER));\n  const hardware = !/swiftshader|llvmpipe|software/i.test(renderer);\n  gl.getExtension('WEBGL_lose_context')?.loseContext();\n  return hardware;\n}\n\nfunction selectSurface(canvas: OffscreenCanvas, failed: () => void): WindSurface {\n  if (hardwareGlAvailable()) {\n    const gl = canvas.getContext('webgl2', ATTRIBUTES);\n    if (gl) {\n      canvas.addEventListener('webglcontextlost', failed, { once: true });\n      return createGpuWindSurface(canvas, gl);\n    }\n  }\n  const ctx = canvas.getContext('2d');\n  if (!ctx) throw new Error('Offscreen wind context unavailable');\n  return createCanvasWindSurface(canvas, ctx);\n}\n\n/** Keep a transferable 1×1 surface until the first visible window needs its full framebuffer. */\nexport function createWorkerWindSurface(canvas: OffscreenCanvas, failed: () => void): WindSurface {\n  const renderer = selectSurface(canvas, failed);\n  let configured = false;\n  let config: WindConfig;\n  let look: Look;\n  let visible = false;\n  return {\n    kind: 'worker',\n    configure(next, palette) {\n      config = next;\n      look = palette;\n      if (configured) renderer.configure(next, palette);\n    },\n    begin(windows, windowed, palette) {\n      visible = !windowed || windows.length > 0;\n      if (!visible) {\n        renderer.clear();\n        return;\n      }\n      if (!configured) {\n        renderer.configure(config, look);\n        configured = true;\n      }\n      renderer.begin(windows, windowed, palette);\n    },\n    impacts(colliders, palette, t) {\n      if (visible) renderer.impacts(colliders, palette, t);\n    },\n    clipLines(sheets, glows) {\n      if (visible) renderer.clipLines(sheets, glows);\n    },\n    lines(lines, frame, page) {\n      if (visible) renderer.lines(lines, frame, page);\n    },\n    end() {\n      if (visible) renderer.end();\n    },\n    clear() {\n      renderer.clear();\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/gpu/workerSurface.ts"
    },
    {
      "path": "components/aurora/wind/impactLayer.ts",
      "content": "import { type FieldContext, fieldCanvas } from '../fieldTypes';\nimport type { ColliderState } from './colliders';\nimport { paintRays } from './impactSprites';\nimport type { Look } from './render';\nimport type { WindConfig } from './windProtocol';\n\ninterface Layer {\n  canvas: ReturnType<typeof fieldCanvas>;\n  ctx: FieldContext;\n  dpr: number;\n}\n\nfunction paintColliders(\n  ctx: FieldContext,\n  look: Look,\n  colliders: ColliderState[],\n  t: number,\n  dpr: number,\n) {\n  let drawn = false;\n  for (const collider of colliders) {\n    if (collider.visible && !collider.disc)\n      drawn = paintRays(ctx, collider, look.sprites, look.gain, t, dpr) || drawn;\n  }\n  return drawn;\n}\n\n/** Keeps soft card light at its original resolution while lines use a sharper canvas. */\nexport function createImpactLayer() {\n  let layer: Layer | null = null;\n  return {\n    configure(config: WindConfig) {\n      const dpr = config.width >= 1200 ? 0.5 : 1;\n      if (config.dpr <= dpr) {\n        layer = null;\n        return;\n      }\n      if (!layer) {\n        const canvas = fieldCanvas(1, 1);\n        const ctx = canvas.getContext('2d') as FieldContext | null;\n        if (!ctx) return;\n        layer = { canvas, ctx, dpr };\n      }\n      layer.dpr = dpr;\n      const width = Math.round(config.width * dpr);\n      const height = Math.round(config.height * dpr);\n      if (layer.canvas.width !== width) layer.canvas.width = width;\n      if (layer.canvas.height !== height) layer.canvas.height = height;\n    },\n    paint(\n      ctx: FieldContext,\n      config: WindConfig,\n      look: Look,\n      colliders: ColliderState[],\n      t: number,\n    ) {\n      if (!layer) {\n        paintColliders(ctx, look, colliders, t, config.dpr);\n        return;\n      }\n      const target = layer.ctx;\n      target.setTransform(1, 0, 0, 1, 0, 0);\n      target.clearRect(0, 0, layer.canvas.width, layer.canvas.height);\n      target.globalCompositeOperation = look.dark ? 'lighter' : 'source-over';\n      if (!paintColliders(target, look, colliders, t, layer.dpr)) return;\n      ctx.setTransform(config.dpr, 0, 0, config.dpr, 0, 0);\n      ctx.globalAlpha = 1;\n      ctx.drawImage(layer.canvas, 0, 0, config.width, config.height);\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/impactLayer.ts"
    },
    {
      "path": "components/aurora/wind/impactSprites.ts",
      "content": "/**\n * The impact light, drawn with pre-rendered sprites, one per tint: the\n * curtain's fine rays, standing on the edge. Each ray starts at its tint and\n * shifts further along the spectrum towards its tip.\n */\nimport { type FieldContext, fieldCanvas, mix, type Rgb } from '../fieldTypes';\nimport { ACTIVE_MIN, type ColliderState as Collider } from './colliders';\nimport { forEachRay, type Ray, spectrumAt, tintStep } from './rays';\n\ninterface SpriteShape {\n  w: number;\n  h: number;\n  /** How far up the spectrum the tip is from the foot. */\n  shift: number;\n  /** Vertical fade: higher fades faster. */\n  decay: number;\n  /** Horizontal falloff: higher is narrower. */\n  narrow: number;\n  /** Night only: the foot burns whiter, as the curtain's lower edge does. */\n  lip: boolean;\n}\n\nconst RAY_SHAPE: SpriteShape = { w: 12, h: 72, shift: 0.38, decay: 2.2, narrow: 1.7, lip: true };\n\n/** The share of the spectrum each ray sprite covers: a short ray stays near its own tint. */\nconst RAY_REACH = [0.3, 0.5, 0.75, 1];\nconst RAY_TINTS = 12;\n/** Drawn rays stay wider than their six-pixel spacing, so the edge stays continuous. */\nexport const RAY_WIDTH = 12;\n/** Overlapping sprites add up; this keeps the sum calm. */\nconst RAY_ALPHA = 1;\nconst WHITE: Rgb = [255, 255, 255];\n\nexport interface SpriteSets {\n  /** [tint][reach] */\n  rays: (HTMLCanvasElement | OffscreenCanvas)[][];\n}\n\nfunction paintSprite(\n  lut: Uint8ClampedArray,\n  tint: number,\n  reach: number,\n  shape: SpriteShape,\n  dark: boolean,\n) {\n  const canvas = fieldCanvas(shape.w, shape.h);\n  const ctx = canvas.getContext('2d') as FieldContext | null;\n  if (!ctx) return canvas;\n  const image = ctx.createImageData(shape.w, shape.h);\n  for (let row = 0; row < shape.h; row += 1) {\n    const lift = 1 - (row + 0.5) / shape.h;\n    const hue = spectrumAt(lut, tint + lift * reach * shape.shift);\n    const color = shape.lip && dark && lift < 0.06 ? mix(hue, WHITE, 0.3) : hue;\n    const fade = Math.exp(-lift * shape.decay) * (1 - lift ** 3);\n    for (let col = 0; col < shape.w; col += 1) {\n      const u = ((col + 0.5) / shape.w) * 2 - 1;\n      const i = (row * shape.w + col) * 4;\n      image.data.set(color, i);\n      image.data[i + 3] = 255 * fade * Math.exp(-((u * shape.narrow) ** 2));\n    }\n  }\n  ctx.putImageData(image, 0, 0);\n  return canvas;\n}\n\nexport function buildSprites(lut: Uint8ClampedArray, dark: boolean): SpriteSets {\n  const tints = (steps: number) => Array.from({ length: steps }, (_, k) => k / (steps - 1));\n  return {\n    rays: tints(RAY_TINTS).map((tint) =>\n      RAY_REACH.map((reach) => paintSprite(lut, tint, reach, RAY_SHAPE, dark)),\n    ),\n  };\n}\n\n/** Points sprite x along the edge and sprite -y along the outward normal, foot at (x, y). */\nfunction standOn(ctx: FieldContext, x: number, y: number, nx: number, ny: number, dpr: number) {\n  ctx.setTransform(-ny * dpr, nx * dpr, -nx * dpr, -ny * dpr, x * dpr, y * dpr);\n}\n\nexport function forEachRaySprite(\n  collider: Collider,\n  sprites: SpriteSets,\n  gain: number,\n  t: number,\n  visit: (sprite: HTMLCanvasElement | OffscreenCanvas, ray: Ray, alpha: number) => void,\n) {\n  const minAlpha = collider.active >= ACTIVE_MIN ? 0.05 : 0.15;\n  forEachRay(collider, t, (ray) => {\n    const alpha = Math.min(1, ray.alpha * gain * RAY_ALPHA);\n    if (alpha < minAlpha) return;\n    const set = sprites.rays[tintStep(ray.tint, RAY_TINTS)];\n    const sprite =\n      set[Math.min(set.length - 1, Math.floor((ray.length / collider.reach) * set.length))];\n    visit(sprite, ray, alpha);\n  });\n}\n\nexport function paintRays(\n  ctx: FieldContext,\n  collider: Collider,\n  sprites: SpriteSets,\n  gain: number,\n  t: number,\n  dpr: number,\n) {\n  let drawn = false;\n  forEachRaySprite(collider, sprites, gain, t, (sprite, ray, alpha) => {\n    ctx.globalAlpha = alpha;\n    standOn(ctx, ray.x, ray.y, ray.nx, ray.ny, dpr);\n    ctx.drawImage(sprite, -RAY_WIDTH / 2, -ray.length, RAY_WIDTH, ray.length);\n    drawn = true;\n  });\n  return drawn;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/impactSprites.ts"
    },
    {
      "path": "components/aurora/wind/outline.ts",
      "content": "import type { GlyphShape } from './glyphField';\n\n/**\n * A collider's outline: one point every SPACING px along its edge, each with an\n * outward normal. Coordinates are canvas px from the collider's top-left corner.\n * A text collider joins the boxes of its lines; an edge that one line shares\n * with the next is dropped, so light rises only from the outer edge of the text.\n */\n\nexport interface Box {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\nexport interface Outline {\n  count: number;\n  x: Float32Array;\n  y: Float32Array;\n  nx: Float32Array;\n  ny: Float32Array;\n  /** Distance along the edge in px. It pins the ray texture to the edge. */\n  s: Float32Array;\n  /** The neighbours along the edge; -1 where the edge stops. */\n  prev: Int32Array;\n  next: Int32Array;\n  /** False for glyph edges: their samples have no order, so light spreads by distance. */\n  linked: boolean;\n  boxes: Box[];\n  /** Flux geometry, independent of the outline's lighting samples. */\n  glyph?: GlyphShape;\n}\n\nconst SPACING = 3;\n\ntype Point = [x: number, y: number, nx: number, ny: number];\n\ninterface Piece {\n  len: number;\n  at: (d: number) => Point;\n}\n\n/** A straight edge of a clockwise loop. With y down, its outward normal is (dy, -dx). */\nfunction edge(x: number, y: number, dx: number, dy: number, len: number): Piece {\n  return { len: Math.max(0, len), at: (d) => [x + dx * d, y + dy * d, dy, -dx] };\n}\n\nfunction corner(cx: number, cy: number, r: number, from: number): Piece {\n  return {\n    len: (Math.PI / 2) * r,\n    at: (d) => {\n      const angle = from + d / r;\n      const cos = Math.cos(angle);\n      const sin = Math.sin(angle);\n      return [cx + r * cos, cy + r * sin, cos, sin];\n    },\n  };\n}\n\n/** A rounded box, clockwise from the top-left end of its top edge. */\nfunction boxPieces({ x, y, w, h }: Box, radius: number): Piece[] {\n  const r = Math.max(0, Math.min(radius, w / 2, h / 2));\n  const half = Math.PI / 2;\n  return [\n    edge(x + r, y, 1, 0, w - 2 * r),\n    corner(x + w - r, y + r, r, -half),\n    edge(x + w, y + r, 0, 1, h - 2 * r),\n    corner(x + w - r, y + h - r, r, 0),\n    edge(x + w - r, y + h, -1, 0, w - 2 * r),\n    corner(x + r, y + h - r, r, half),\n    edge(x, y + h - r, 0, -1, h - 2 * r),\n    corner(x + r, y + r, r, Math.PI),\n  ].filter((piece) => piece.len > 0.01);\n}\n\ninterface Sample {\n  point: Point;\n  s: number;\n}\n\nfunction sampleLoop(pieces: Piece[], spacing: number): Sample[] {\n  const total = pieces.reduce((sum, piece) => sum + piece.len, 0);\n  const count = Math.max(4, Math.round(total / spacing));\n  const step = total / count;\n  const samples: Sample[] = [];\n  let piece = 0;\n  let start = 0;\n  for (let i = 0; i < count; i += 1) {\n    const d = (i + 0.5) * step;\n    while (piece < pieces.length - 1 && d > start + pieces[piece].len) {\n      start += pieces[piece].len;\n      piece += 1;\n    }\n    samples.push({ point: pieces[piece].at(Math.min(d - start, pieces[piece].len)), s: d });\n  }\n  return samples;\n}\n\nconst covers = (box: Box, x: number, y: number) =>\n  x > box.x - 0.5 && x < box.x + box.w + 0.5 && y > box.y - 0.5 && y < box.y + box.h + 0.5;\n\nexport function allocateOutline(count: number, boxes: Box[], linked: boolean): Outline {\n  return {\n    count,\n    x: new Float32Array(count),\n    y: new Float32Array(count),\n    nx: new Float32Array(count),\n    ny: new Float32Array(count),\n    s: new Float32Array(count),\n    prev: new Int32Array(count).fill(-1),\n    next: new Int32Array(count).fill(-1),\n    linked,\n    boxes,\n  };\n}\n\n/** Links each kept sample to the kept samples beside it in its own loop. */\nfunction link(outline: Outline, slots: Int32Array[]) {\n  for (const slot of slots) {\n    for (let i = 0; i < slot.length; i += 1) {\n      const here = slot[i];\n      const after = slot[(i + 1) % slot.length];\n      if (here < 0 || after < 0 || here === after) continue;\n      outline.next[here] = after;\n      outline.prev[after] = here;\n    }\n  }\n}\n\nexport function buildOutline(boxes: Box[], radius: number, spacing = SPACING): Outline {\n  const loops = boxes.map((box) => sampleLoop(boxPieces(box, radius), spacing));\n  const kept = loops.map((loop, own) =>\n    loop.map(({ point }) => !boxes.some((box, b) => b !== own && covers(box, point[0], point[1]))),\n  );\n  const count = kept.flat().filter(Boolean).length;\n  const outline = allocateOutline(count, boxes, true);\n  const slots = loops.map((loop) => new Int32Array(loop.length).fill(-1));\n  let index = 0;\n  loops.forEach((loop, l) => {\n    loop.forEach(({ point, s }, i) => {\n      if (!kept[l][i]) return;\n      [outline.x[index], outline.y[index], outline.nx[index], outline.ny[index]] = point;\n      outline.s[index] = s + l * 977;\n      slots[l][i] = index;\n      index += 1;\n    });\n  });\n  link(outline, slots);\n  return outline;\n}\n\n/** The sample closest to a point, in the outline's coordinates; -1 for an empty outline. */\nexport function nearestSample(outline: Outline, x: number, y: number) {\n  let best = -1;\n  let bestDistance = Number.POSITIVE_INFINITY;\n  for (let i = 0; i < outline.count; i += 1) {\n    const distance = (outline.x[i] - x) ** 2 + (outline.y[i] - y) ** 2;\n    if (distance < bestDistance) {\n      bestDistance = distance;\n      best = i;\n    }\n  }\n  return best;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/outline.ts"
    },
    {
      "path": "components/aurora/wind/presentation.ts",
      "content": "import { AURORA_FPS } from '@/lib/aurora/frameGate';\n\n/**\n * Where the wind's frames reach the screen. The owner chose 'worker' (1 Nov).\n * - 'main' (B): the worker posts each frame as a bitmap. The main thread\n *   shows it and moves the layer to the frame's anchor in the same task, so\n *   the lines stay on the page while it scrolls (#1919). Every shown frame\n *   costs the main thread a rendering update, so while nothing moves the\n *   worker shows WIND_IDLE_FPS frames a second. A scroll, a pointer move or\n *   a touch brings back AURORA_FPS for WIND_ACTIVE_MS.\n * - 'worker' (A): the worker shows its frames on two canvases it owns, at\n *   AURORA_FPS, with no main-thread work per frame. The main thread acts\n *   only when the anchor moves: it reveals the canvas that already holds the\n *   frame at the new anchor, and moves the layer, in one commit.\n * `?windPresent=main|worker` picks one for a page load, for measuring.\n */\nexport type WindPresentation = 'main' | 'worker';\nexport const WIND_PRESENTATION: WindPresentation = 'worker';\n\n/** Frames a second the main-thread presentation shows while the page is still. */\nexport const WIND_IDLE_FPS = 12;\n/** How long after the last scroll, pointer move or touch the wind shows every frame. */\nexport const WIND_ACTIVE_MS = 1000;\n/** Clock steps between shown frames while the page is still. */\nexport const WIND_IDLE_STEPS = Math.max(1, Math.round(AURORA_FPS / WIND_IDLE_FPS));\n\nconst QUERY_PARAM = 'windPresent';\n\nexport function windPresentation(search: string): WindPresentation {\n  const value = new URLSearchParams(search).get(QUERY_PARAM);\n  return value === 'main' || value === 'worker' ? value : WIND_PRESENTATION;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/presentation.ts"
    },
    {
      "path": "components/aurora/wind/rays.ts",
      "content": "/**\n * The impact light's colour spectrum, and rays from glowing outline samples\n * (where it stands, which way it points, how long and bright it is, and its\n * tint). impactSprites.ts draws the rays.\n *\n * Tint is a position on the palette's spectrum, 0 to 1: green, violet, rose,\n * gold. A strike starts green and turns violet, rose and gold as its\n * light spreads along the edge and ages (colliders.ts), so one impact shows\n * several colours.\n */\nimport { rayTexture } from '../curtainEngine';\nimport type { FieldPalette, Rgb } from '../fieldTypes';\nimport type { ColliderState as Collider } from './colliders';\n\nexport const SPECTRUM_SIZE = 256;\n\n/**\n * Where each palette hue sits on the spectrum: [position, hue index]. Green\n * holds for the first stretch, as in a real aurora, so the core of a strike\n * stays green and only its wings and tips turn violet, rose and gold.\n */\nconst STOPS: [number, number][] = [\n  [0, 0],\n  [0.16, 0],\n  [0.46, 1],\n  [0.74, 2],\n  [1, 3],\n];\n\ntype Hsl = [h: number, s: number, l: number];\n\nconst clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value));\n\n/** Hue as a turn, 0–1, from the largest channel. */\nfunction hueOf(r: number, g: number, b: number, high: number, d: number) {\n  if (high === r) return ((g - b) / d + (g < b ? 6 : 0)) / 6;\n  if (high === g) return ((b - r) / d + 2) / 6;\n  return ((r - g) / d + 4) / 6;\n}\n\nfunction toHsl(rgb: Rgb): Hsl {\n  const [r, g, b] = rgb.map((channel) => channel / 255);\n  const high = Math.max(r, g, b);\n  const low = Math.min(r, g, b);\n  const l = (high + low) / 2;\n  const d = high - low;\n  if (d === 0) return [0, 0, l];\n  return [hueOf(r, g, b, high, d), d / (1 - Math.abs(2 * l - 1)), l];\n}\n\nfunction toRgb([h, s, l]: Hsl): Rgb {\n  const c = (1 - Math.abs(2 * l - 1)) * s;\n  const x = c * (1 - Math.abs(((h * 6) % 2) - 1));\n  const m = l - c / 2;\n  const sector = Math.floor(h * 6) % 6;\n  const [r, g, b] = [\n    [c, x, 0],\n    [x, c, 0],\n    [0, c, x],\n    [0, x, c],\n    [x, 0, c],\n    [c, 0, x],\n  ][sector];\n  return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];\n}\n\n/**\n * Impact light is light, not paint: each hue gets more saturation and an even,\n * bright lightness (deeper on a day ground). A grey stays grey, so a neutral\n * palette keeps neutral light.\n */\nfunction vivid(rgb: Rgb, dark: boolean): Hsl {\n  const [h, s, l] = toHsl(rgb);\n  const lightness = dark ? clamp(l, 0.58, 0.72) : clamp(l, 0.4, 0.52);\n  return [h, s < 0.08 ? s : Math.min(1, s * 1.3 + 0.06), lightness];\n}\n\n/** Hue takes the short way round the wheel, so green to violet runs through cyan and blue, not grey. */\nfunction blend(a: Hsl, b: Hsl, k: number): Hsl {\n  let dh = b[0] - a[0];\n  if (dh > 0.5) dh -= 1;\n  if (dh < -0.5) dh += 1;\n  return [(a[0] + dh * k + 1) % 1, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];\n}\n\n/** The palette's hues as one smooth, vivid ramp: SPECTRUM_SIZE RGB triplets. */\nexport function buildSpectrum(palette: FieldPalette) {\n  const hues = palette.hues.map((hue) => vivid(hue, palette.dark));\n  const lut = new Uint8ClampedArray(SPECTRUM_SIZE * 3);\n  for (let i = 0; i < SPECTRUM_SIZE; i += 1) {\n    const at = i / (SPECTRUM_SIZE - 1);\n    let next = 1;\n    while (next < STOPS.length - 1 && STOPS[next][0] < at) next += 1;\n    const [from, fromHue] = STOPS[next - 1];\n    const [to, toHue] = STOPS[next];\n    const k = clamp((at - from) / (to - from), 0, 1);\n    lut.set(toRgb(blend(hues[fromHue], hues[toHue], k)), i * 3);\n  }\n  return lut;\n}\n\nexport function spectrumAt(lut: Uint8ClampedArray, tint: number): Rgb {\n  const k = Math.round(Math.max(0, Math.min(1, tint)) * (SPECTRUM_SIZE - 1)) * 3;\n  return [lut[k], lut[k + 1], lut[k + 2]];\n}\n\n/** The nearest of `steps` evenly spaced tints, as an index. */\nexport const tintStep = (tint: number, steps: number) =>\n  Math.round(Math.max(0, Math.min(1, tint)) * (steps - 1));\n\nexport interface Ray {\n  index: number;\n  /** Foot of the ray on the edge, in canvas px. */\n  x: number;\n  y: number;\n  /** Outward normal. */\n  nx: number;\n  ny: number;\n  length: number;\n  /** Brightness 0–1, before the style's own strength. */\n  alpha: number;\n  tint: number;\n}\n\n/**\n * How far light rises from an edge, by the edge's outward normal. The field\n * lines run up, as above a horizon: full on top edges, shorter on the sides,\n * shortest underneath.\n */\nconst rise = (ny: number) => (ny < 0 ? 0.55 - 0.45 * ny : 0.55 - 0.15 * ny);\n\n/**\n * Calls `visit` for each outline sample that glows this frame: only where the\n * wind struck, so a hovered card lights where strikes land and nowhere else.\n * The same Ray object is reused for every call.\n */\nexport function forEachRay(collider: Collider, t: number, visit: (ray: Ray) => void) {\n  const outline = collider.outline;\n  if (!outline) return;\n  const ray: Ray = { index: 0, x: 0, y: 0, nx: 0, ny: 0, length: 0, alpha: 0, tint: 0 };\n  for (let i = 0; i < outline.count; i += 2) {\n    const energy = collider.energy[i];\n    if (energy < 0.015) continue;\n    const level = 1 - Math.exp(-1.7 * energy);\n    const texture = Math.min(1, rayTexture(outline.s[i], t) * 2);\n    ray.index = i;\n    ray.nx = outline.nx[i];\n    ray.ny = outline.ny[i];\n    ray.x = collider.left + outline.x[i];\n    ray.y = collider.top + outline.y[i];\n    ray.length = collider.reach * rise(ray.ny) * (0.3 + 0.7 * level) * (0.5 + 0.5 * texture);\n    ray.alpha = level * (0.55 + 0.45 * texture);\n    ray.tint = collider.tint[i];\n    visit(ray);\n  }\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/rays.ts"
    },
    {
      "path": "components/aurora/wind/render.ts",
      "content": "/**\n * The shared wind colours. Flux paints the wind (styles/flux.ts); the impact\n * light on struck edges is in impactSprites.ts.\n */\nimport { type FieldPalette, mix, type Rgb } from '../fieldTypes';\nimport { buildSprites, type SpriteSets } from './impactSprites';\nimport { buildSpectrum } from './rays';\n\nexport interface Look {\n  dark: boolean;\n  /** The wind's ink. */\n  speck: Rgb;\n  /** The palette's green. */\n  halo: Rgb;\n  /** Glow strength on this ground: day light needs more alpha to read. */\n  gain: number;\n  sprites: SpriteSets;\n}\n\nexport function buildLook(palette: FieldPalette, ink: Rgb): Look {\n  const green = palette.hues[0];\n  return {\n    dark: palette.dark,\n    speck: palette.dark ? mix(ink, green, 0.25) : ink,\n    halo: green,\n    gain: palette.dark ? 1 : 1.2,\n    sprites: buildSprites(buildSpectrum(palette), palette.dark),\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/render.ts"
    },
    {
      "path": "components/aurora/wind/storm.ts",
      "content": "/**\n * Substorm: the solar wind feeds the hero's aurora curtain. A swell of the\n * flux lines that crosses the curtain's lower edge (styles/fluxContacts.ts), or\n * strikes inside a hero, adds energy across that hero's width\n * (curtainStorm, feedStorm). The close uses the hero mode, so its curtain is\n * fed the same way.\n */\n\nimport { STORM_EDGE } from '../curtainStorm';\nimport type { Frame } from './colliders';\nimport type { ReadWindRect } from './windGeometry';\n\n/** The share of a strike's amplitude that feeds the curtain above it. */\nconst STRIKE_FEED = 0.55;\n\n/** A hero curtain canvas in the wind canvas's px, with the y of its lower edge. */\nexport interface StormZone<T = Element> {\n  canvas: T;\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n  edge: number;\n}\n\n/** The hero curtain canvases under `root`: the homepage hero and the close. */\nexport function findStormCanvases(root: ParentNode): Element[] {\n  return [...root.querySelectorAll(\"[data-au-field='hero'] canvas\")];\n}\n\n/** The zones on screen now, in the wind canvas's px (client px less the frame's origin). */\nexport function measureStormZones(\n  canvases: Element[],\n  frame: Frame,\n  readRect: ReadWindRect,\n): StormZone[] {\n  const zones: StormZone[] = [];\n  for (const canvas of canvases) {\n    const r = readRect(canvas);\n    if (r.width === 0 || r.bottom < 0 || r.top > window.innerHeight) continue;\n    const top = r.top - frame.y;\n    const edge = top + STORM_EDGE * r.height;\n    zones.push({ canvas, left: r.left - frame.x, top, width: r.width, height: r.height, edge });\n  }\n  return zones;\n}\n\n/** A strike inside a hero feeds its curtain above the struck point. */\nexport function feedStrike<T>(\n  zones: StormZone<T>[],\n  x: number,\n  y: number,\n  amp: number,\n  feed: (canvas: T, fx: number, amount: number) => void,\n) {\n  for (const zone of zones) {\n    if (y >= zone.top && y <= zone.top + zone.height) {\n      feed(zone.canvas, (x - zone.left) / zone.width, STRIKE_FEED * amp);\n    }\n  }\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/storm.ts"
    },
    {
      "path": "components/aurora/wind/styles/flux.ts",
      "content": "import type { WindSurface } from '../windSurface';\nimport { createContacts, findContacts } from './fluxContacts';\nimport { createCursor } from './fluxCursor';\nimport { traceFlux } from './fluxField';\nimport { createLines } from './fluxLines';\nimport type { StyleFrame, WindStyle } from './types';\n\nfunction createFluxPainter(surface: WindSurface) {\n  const lines = createLines();\n  const contacts = createContacts();\n  const cursor = createCursor();\n  let page = 0;\n  return {\n    contacts,\n    glow(frame: StyleFrame) {\n      page += frame.shift;\n      const stir = cursor.update(frame);\n      traceFlux(lines, frame, page, stir);\n      findContacts(lines, frame, page, contacts);\n      surface.lines(lines, frame, page);\n    },\n  };\n}\n\nexport const flux: WindStyle = { createPainter: createFluxPainter };\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/flux.ts"
    },
    {
      "path": "components/aurora/wind/styles/fluxContacts.ts",
      "content": "/**\n * Where the swells of the flux lines meet the page. A swell is a bright run\n * travelling along a line. While its crest is on a stretch of the line that\n * touches a collider's edge, the swell strikes the collider at the crest.\n * A swell feeds a hero curtain while it covers the curtain's lower edge.\n * flux.ts draws the same swells, from the same crest distance (fluxLines.ts),\n * so a reader can follow a bright pulse along a line into the edge it lights.\n */\n\nimport { ACTIVE_MIN } from '../colliders';\nimport {\n  COS,\n  crestDistance,\n  FADED,\n  groupOf,\n  type Lines,\n  SIN,\n  SWELL_HALF,\n  swellAt,\n  swellSpeed,\n  swellStrength,\n} from './fluxLines';\nimport type { Contacts, StyleFrame } from './types';\n\nconst MAX_CONTACTS = 64;\nconst MAX_TOUCHES = 256;\n/** The light a touching line holds without a swell on it, against 1 under a full crest. */\nconst HOLD_FLOOR = 0.35;\n/** The contact (fluxLines.ts) from which a line counts as touching an edge: about 9 px from it. */\nconst ENTRY = 0.5;\n/** Px along the wind between two strikes of one crest. */\nconst STRIKE_PITCH = 56;\n\nexport const createContacts = (): Contacts => ({\n  hits: 0,\n  hitCollider: new Int16Array(MAX_CONTACTS),\n  hitX: new Float32Array(MAX_CONTACTS),\n  hitY: new Float32Array(MAX_CONTACTS),\n  hitAmp: new Float32Array(MAX_CONTACTS),\n  touches: 0,\n  touchCollider: new Int16Array(MAX_TOUCHES),\n  touchX: new Float32Array(MAX_TOUCHES),\n  touchY: new Float32Array(MAX_TOUCHES),\n  touchAmp: new Float32Array(MAX_TOUCHES),\n  feeds: 0,\n  feedZone: new Int16Array(MAX_CONTACTS),\n  feedAt: new Float32Array(MAX_CONTACTS),\n  feedAmp: new Float32Array(MAX_CONTACTS),\n});\n\n/** The line that point n of `lines` is on, at or after line l. */\nfunction lineOf(lines: Lines, n: number, l: number) {\n  let line = l;\n  while (line + 1 < lines.lines && lines.start[line + 1] <= n) line += 1;\n  return line;\n}\n\n/**\n * Where a crest of the group's swells crosses a strike anchor this frame, as a\n * page along-wind coordinate, or NaN. Anchors lie every STRIKE_PITCH px along\n * the wind, and `along` picks the crest nearest to it.\n */\nfunction anchorCrossed(group: number, along: number, frame: StyleFrame) {\n  const crest = along - crestDistance(group, along, frame.t);\n  const anchor = Math.floor(crest / STRIKE_PITCH);\n  const before = Math.floor((crest - swellSpeed(group) * frame.dt) / STRIKE_PITCH);\n  return anchor === before ? Number.NaN : anchor * STRIKE_PITCH;\n}\n\n/**\n * Collects the strikes. A crest that crosses an anchor while it is on a step\n * of a line that touches a collider's edge (within about 9 px) strikes the\n * collider where the crest is. A crest running along a touching line so\n * leaves a trail of strikes at the pitch of the anchors. `page` is how far the\n * canvas top is down the page.\n */\nfunction collectHits(lines: Lines, frame: StyleFrame, page: number, out: Contacts) {\n  let l = 0;\n  for (let b = 0; b < lines.bandCount && out.hits < MAX_CONTACTS; b += 1) {\n    const n = lines.banded[b];\n    if (lines.contact[n] < ENTRY) continue;\n    const a0 = COS * lines.x[n - 1] + SIN * (lines.y[n - 1] + page);\n    const a1 = COS * lines.x[n] + SIN * (lines.y[n] + page);\n    l = lineOf(lines, n, l);\n    const group = groupOf(lines.k[l]);\n    const at = anchorCrossed(group, Math.min(a0, a1), frame);\n    // A step takes [low, high): a crest at the joint of two steps strikes once.\n    if (!(at >= Math.min(a0, a1) && at < Math.max(a0, a1))) continue;\n    const u = (at - a0) / (a1 - a0);\n    const i = out.hits;\n    out.hitCollider[i] = lines.owner[n];\n    out.hitX[i] = lines.x[n - 1] + u * (lines.x[n] - lines.x[n - 1]);\n    out.hitY[i] = lines.y[n - 1] + u * (lines.y[n] - lines.y[n - 1]);\n    out.hitAmp[i] = swellStrength(group) * lines.contact[n];\n    out.hits += 1;\n  }\n}\n\n/** True for a collider whose edge holds the light of the lines that touch it: a hovered, lit card. */\nfunction holds(frame: StyleFrame, index: number) {\n  const collider = frame.colliders[index];\n  return (\n    collider !== undefined &&\n    collider.mode === 'hover' &&\n    !collider.unlit &&\n    !collider.disc &&\n    collider.active >= ACTIVE_MIN\n  );\n}\n\n/** Collects the steps that touch a hovered edge, with how much light each holds there. */\nfunction collectTouches(lines: Lines, frame: StyleFrame, page: number, out: Contacts) {\n  let l = 0;\n  for (let b = 0; b < lines.bandCount && out.touches < MAX_TOUCHES; b += 1) {\n    const n = lines.banded[b];\n    if (lines.contact[n] < ENTRY || !holds(frame, lines.owner[n])) continue;\n    l = lineOf(lines, n, l);\n    const x = (lines.x[n - 1] + lines.x[n]) / 2;\n    const y = (lines.y[n - 1] + lines.y[n]) / 2;\n    const swell = swellAt(groupOf(lines.k[l]), COS * x + SIN * (y + page), frame.t);\n    const i = out.touches;\n    out.touchCollider[i] = lines.owner[n];\n    out.touchX[i] = x;\n    out.touchY[i] = y;\n    out.touchAmp[i] = lines.contact[n] * (HOLD_FLOOR + swell);\n    out.touches += 1;\n  }\n}\n\n/** Adds a feed to zone z of the frame, `at` the share of its width, if there is room. */\nexport function addFeed(out: Contacts, zone: number, at: number, amp: number) {\n  if (out.feeds >= MAX_CONTACTS) return;\n  out.feedZone[out.feeds] = zone;\n  out.feedAt[out.feeds] = at;\n  out.feedAmp[out.feeds] = amp;\n  out.feeds += 1;\n}\n\n/** Feeds throughout a swell's overlap with the zone's lower edge. */\nfunction feedLine(\n  lines: Lines,\n  l: number,\n  zone: number,\n  frame: StyleFrame,\n  page: number,\n  out: Contacts,\n) {\n  const { edge, left, width } = frame.zones[zone];\n  const end = l + 1 < lines.lines ? lines.start[l + 1] : lines.count;\n  const group = groupOf(lines.k[l]);\n  for (let i = lines.start[l] + 1; i < end && out.feeds < MAX_CONTACTS; i += 1) {\n    const y0 = lines.y[i - 1];\n    const y1 = lines.y[i];\n    if (lines.move[i] || lines.fade[i] < FADED || y0 >= edge || y1 < edge) continue;\n    const x = lines.x[i - 1] + ((lines.x[i] - lines.x[i - 1]) * (edge - y0)) / (y1 - y0);\n    const at = (x - left) / width;\n    if (at < 0 || at > 1) continue;\n    // The quartic swell integrates to 16/15 half-widths: one impulse per overlap.\n    const rate = (15 * swellSpeed(group)) / (16 * SWELL_HALF);\n    const amp = swellAt(group, COS * x + SIN * (edge + page), frame.t) * rate * frame.dt;\n    if (amp > 0) addFeed(out, zone, at, amp);\n  }\n}\n\n/** Collects feeds from swells covering each curtain's lower edge. */\nfunction collectFeeds(lines: Lines, frame: StyleFrame, page: number, out: Contacts) {\n  for (let z = 0; z < frame.zones.length; z += 1) {\n    for (let l = 0; l < lines.lines; l += 1) feedLine(lines, l, z, frame, page, out);\n  }\n}\n\n/** Finds this frame's contacts on the lines just traced into `lines`. */\nexport function findContacts(lines: Lines, frame: StyleFrame, page: number, out: Contacts) {\n  out.hits = 0;\n  out.touches = 0;\n  out.feeds = 0;\n  if (frame.dt <= 0) return;\n  collectHits(lines, frame, page, out);\n  collectTouches(lines, frame, page, out);\n  collectFeeds(lines, frame, page, out);\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/fluxContacts.ts"
    },
    {
      "path": "components/aurora/wind/styles/fluxCursor.ts",
      "content": "/**\n * The pointer in the flux. A pointer that moves through the wind is a small\n * shield (fluxShields.ts): the lines near it bend aside, and a swell on a line\n * that it bends glows a little. The shield follows the pointer's motion, not\n * its presence. It eases in as the pointer speeds up and out as it slows, and a\n * still pointer, or none, stirs nothing and costs nothing.\n */\nimport type { Stir } from './fluxShields';\nimport type { StyleFrame } from './types';\n\n/** Seconds that the shield takes to follow the pointer, to grow and to fade. */\nconst FOLLOW_S = 0.05;\nconst RISE_S = 0.12;\nconst FALL_S = 0.3;\n/** Seconds over which the measured speed follows the pointer's. */\nconst SPEED_S = 0.08;\n/** Px/s below which the pointer counts as still, and at which it stirs fully. */\nconst STILL = 30;\nconst FULL = 450;\n/** The strongest weight of the shield: the lines never part as for a card. */\nconst MAX_WEIGHT = 0.85;\nexport function createCursor() {\n  const stir: Stir = { x: 0, y: 0, weight: 0 };\n  /** The last pointer in the viewport: its speed is the pointer's own, not the page scrolling under it. */\n  let lastX = 0;\n  let lastY = 0;\n  let known = false;\n  let speed = 0;\n  const ease = (dt: number, seconds: number) => 1 - Math.exp(-dt / seconds);\n  const rest = (frame: StyleFrame, clientY: number) => {\n    const { pointer, dt } = frame;\n    if (dt <= 0 || frame.quality === 'still') {\n      stir.weight = 0;\n      known = false;\n      return true;\n    }\n    if (stir.weight !== 0) return false;\n    if (pointer && (pointer.x !== lastX || clientY !== lastY || speed > STILL)) return false;\n    stir.x = pointer?.x ?? stir.x;\n    stir.y = pointer?.y ?? stir.y;\n    speed = 0;\n    return true;\n  };\n\n  return {\n    /** Moves the shield to this frame's pointer and returns it: weight 0 where there is none. */\n    update(frame: StyleFrame): Stir {\n      const { pointer, dt } = frame;\n      const clientY = pointer?.clientY ?? pointer?.y ?? 0;\n      if (pointer && !known) {\n        [stir.x, stir.y, lastX, lastY, speed] = [pointer.x, pointer.y, pointer.x, clientY, 0];\n      }\n      known = pointer !== null;\n      if (rest(frame, clientY)) return stir;\n      let target = 0;\n      if (pointer) {\n        const moved = Math.hypot(pointer.x - lastX, clientY - lastY) / dt;\n        speed += (moved - speed) * ease(dt, SPEED_S);\n        stir.x += (pointer.x - stir.x) * ease(dt, FOLLOW_S);\n        stir.y += (pointer.y - stir.y) * ease(dt, FOLLOW_S);\n        [lastX, lastY] = [pointer.x, clientY];\n        target = MAX_WEIGHT * Math.min(1, Math.max(0, (speed - STILL) / (FULL - STILL)));\n      }\n      stir.weight += (target - stir.weight) * ease(dt, target > stir.weight ? RISE_S : FALL_S);\n      if (target === 0 && stir.weight < 0.01) stir.weight = 0;\n      return stir;\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/fluxCursor.ts"
    },
    {
      "path": "components/aurora/wind/styles/fluxField.ts",
      "content": "/**\n * The flux field and its tracer; flux.ts draws the lines. Each line is a level\n * set of a stream function: the distance across the wind plus two slow waves.\n * A solid collider makes its own edge a level set (Bridson's curl-noise\n * boundary), so lines bend round it and crowd along it. Lines are fixed to\n * the page. Every frame traces every line again. In open sky a line's points\n * sit at fixed steps along the wind on the page, so they move only across it\n * and never crawl along it. Near a shield the tracer follows the field in\n * steps whose chords stay within SAG px of the curve.\n */\n\nimport { glyphSample, sampleGlyphField } from '../glyphField';\nimport { COS, FADED, GLOW, groupOf, type Lines, MAX_POINTS, SIN, swellAt } from './fluxLines';\nimport { fitShields, type Shield, type Stir, shields } from './fluxShields';\nimport {\n  BEND_MAX,\n  GAP,\n  LONG,\n  NEAR_STEP,\n  OFF_LEVEL,\n  SAMPLE,\n  SLIDE,\n  SPACE,\n  SPLIT,\n  STEP,\n  STIR_BASE,\n  STIR_GAIN,\n  STIR_LIGHT,\n  STIR_TOUCH,\n  TOUCH,\n  ZONE_STEPS,\n} from './fluxTuning';\nimport {\n  A,\n  A_ACROSS,\n  A_ALONG,\n  acrossAt,\n  B,\n  B_ACROSS,\n  B_ALONG,\n  lineRange,\n  phases,\n  range,\n  resetWalk,\n  type Sky,\n  turnWaves,\n  WAVE,\n  wave,\n} from './fluxWaves';\nimport type { StyleFrame } from './types';\n\n/** A_X and A_Y turn a wave's slope (fluxWaves.ts) into the gradient. */\nconst A_X = A * (COS / A_ALONG - SIN / A_ACROSS);\nconst A_Y = A * (SIN / A_ALONG + COS / A_ACROSS);\nconst B_X = B * (COS / B_ALONG - SIN / B_ACROSS);\nconst B_Y = B * (SIN / B_ALONG + COS / B_ACROSS);\n\n/** Px past the canvas edge that lines run to, so no line starts or ends in view. */\nexport const marginFor = (lite: number) => SAMPLE[lite] + 8;\n\nlet shieldCount = 0;\n/** This frame's time, page offset, quality (0 full, 1 lite), canvas size and margin, and wave phases. */\nlet now = 0;\nlet offset = 0;\nlet lite = 0;\nlet width = 0;\nlet height = 0;\nlet margin = 0;\nlet aPhase = 0;\nlet bPhase = 0;\nconst sky: Sky = { offset: 0, margin: 0, width: 0, height: 0 };\n/** The level of the line being traced. */\nlet lineLevel = 0;\n/** Results of base(), sdf() and field(), kept here so nothing is allocated per step. */\nlet psi = 0;\nlet gx = 0;\nlet gy = 0;\nlet nx = 0;\nlet ny = 0;\n/** The gradient of the full stream function; the flow runs along (fy, -fx). */\nlet fx = 0;\nlet fy = 0;\nlet touch = 0;\nlet fade = 1;\n/** Results of contactAt(): the strongest lit contact, and which collider has the strongest contact. */\nlet litGlow = 0;\nlet contactOwner = 0;\n/** Px to the nearest shield edge in reach; infinite where none is near. */\nlet depth = 0;\n/** The flat side of a shield nearest the last field() point (shield · 4 + side), or -1. */\nlet side = -1;\n/** The last field() point is inside a sheath. */\nlet sheathed = false;\nlet nextStep = 0;\n/** The last point added continues the line; a zone trace ended it, so skip samples until clear. */\nlet open = false;\nlet blocked = false;\n\n/** The group of swells that the line being traced is in. */\nlet lineGroup = 0;\n\n/** The undisturbed stream function and its gradient at canvas (x, y). */\nfunction base(x: number, y: number) {\n  const page = y + offset;\n  const across = COS * page - SIN * x;\n  const along = COS * x + SIN * page;\n  turnWaves(\n    along / A_ALONG + across / A_ACROSS + aPhase,\n    along / B_ALONG + across / B_ACROSS + bPhase,\n  );\n  psi = across + A * wave.sinA + B * wave.sinB;\n  gx = A_X * wave.cosA + B_X * wave.cosB - SIN;\n  gy = A_Y * wave.cosA + B_Y * wave.cosB + COS;\n}\n\n/** Signed px from the shield's edge; sets the outward normal. */\nfunction sdf(shield: Shield, x: number, y: number) {\n  if (shield.glyph) {\n    const distance = sampleGlyphField(shield.glyph, x - shield.left, y - shield.top);\n    nx = glyphSample.nx;\n    ny = glyphSample.ny;\n    return distance;\n  }\n  const dx = x - shield.cx;\n  const dy = y - shield.cy;\n  const r = shield.round;\n  const qx = Math.abs(dx) - shield.hx + r;\n  const qy = Math.abs(dy) - shield.hy + r;\n  if (qx > 0 && qy > 0) {\n    const length = Math.sqrt(qx * qx + qy * qy);\n    nx = (Math.sign(dx) * qx) / length;\n    ny = (Math.sign(dy) * qy) / length;\n    return length - r;\n  }\n  nx = qx > qy ? Math.sign(dx) : 0;\n  ny = qx > qy ? 0 : Math.sign(dy);\n  return Math.max(qx, qy) - r;\n}\n\n/** The flat side of shield i that sdf() just measured from, or -1 at a corner. */\nfunction sideOf(i: number) {\n  if (shields[i].glyph) return -1;\n  if (nx !== 0 && ny !== 0) return -1;\n  return i * 4 + (nx > 0 ? 0 : nx < 0 ? 1 : ny > 0 ? 2 : 3);\n}\n\nconst smooth = (u: number) => (u >= 1 ? 1 : u * u * (3 - 2 * u));\n\n/**\n * The field at canvas (x, y): the full stream function in psi and its\n * gradient in (fx, fy), the glow near an edge in touch, and the fade of a line\n * near the level that splits at a shield (SPLIT).\n */\nfunction field(x: number, y: number) {\n  base(x, y);\n  let keep = 1;\n  let ax = 0;\n  let ay = 0;\n  let full = psi;\n  touch = 0;\n  fade = 1;\n  depth = Number.POSITIVE_INFINITY;\n  side = -1;\n  sheathed = false;\n  for (let i = 0; i < shieldCount; i += 1) {\n    const s = shields[i];\n    if (Math.abs(x - s.cx) > s.zx || Math.abs(y - s.cy) > s.zy) continue;\n    const d = sdf(s, x, y);\n    if (d >= s.reach) continue;\n    sheathed = true;\n    // The pointer's shield bends the lines but is no solid: it has no edge to keep out of.\n    if (d < depth && s.index >= 0) {\n      depth = d;\n      side = sideOf(i);\n    }\n    const r = Math.max(0, d) / s.reach;\n    const hold = s.weight * (1 - (r * (15 - r * r * (10 - 3 * r * r))) / 8);\n    const u = 1 - r * r;\n    const lift = ((s.level - psi) * s.weight * 15 * u * u) / (8 * s.reach);\n    keep -= hold;\n    full += hold * (s.level - psi);\n    ax -= lift * nx;\n    ay -= lift * ny;\n    touch = Math.max(touch, s.weight * (1 - Math.max(0, d) / (s.index < 0 ? STIR_TOUCH : TOUCH)));\n    fade = Math.min(fade, 1 - s.weight * (1 - smooth(Math.abs(lineLevel - s.level) / SPLIT)));\n  }\n  keep = Math.max(0, keep);\n  fx = gx * keep + ax;\n  fy = gy * keep + ay;\n  psi = full;\n}\n\n/** The undisturbed stream function at canvas (x, y): the level of an edge there. */\nfunction levelAt(x: number, y: number) {\n  base(x, y);\n  return psi;\n}\n\n/** Where keepOut() put the point. */\nlet outX = 0;\nlet outY = 0;\n\n/** Moves (x, y) out to GAP px from any shield it is inside or too near. */\nfunction keepOut(x: number, y: number) {\n  outX = x;\n  outY = y;\n  for (let i = 0; i < shieldCount; i += 1) {\n    const s = shields[i];\n    if (Math.abs(outX - s.cx) > s.zx || Math.abs(outY - s.cy) > s.zy) continue;\n    const d = sdf(s, outX, outY);\n    if (d >= GAP || s.index < 0) continue;\n    outX += (GAP - d) * nx;\n    outY += (GAP - d) * ny;\n  }\n}\n\n/** True inside a sheath, where a shield bends the lines. */\nfunction inZone(x: number, y: number) {\n  for (let i = 0; i < shieldCount; i += 1) {\n    const s = shields[i];\n    if (Math.abs(x - s.cx) < s.zx && Math.abs(y - s.cy) < s.zy && sdf(s, x, y) < s.reach)\n      return true;\n  }\n  return false;\n}\n\n/** True where the chord from (x0, y0) to (x1, y1) may cut a sheath: its box meets a sheath's box. */\nfunction chordCuts(x0: number, y0: number, x1: number, y1: number) {\n  const left = Math.min(x0, x1);\n  const right = Math.max(x0, x1);\n  const top = Math.min(y0, y1);\n  const bottom = Math.max(y0, y1);\n  for (let i = 0; i < shieldCount; i += 1) {\n    const s = shields[i];\n    if (right > s.cx - s.zx && left < s.cx + s.zx && bottom > s.cy - s.zy && top < s.cy + s.zy)\n      return true;\n  }\n  return false;\n}\n\nconst near = (x: number, y: number) =>\n  x > -margin && y > -margin && x < width + margin && y < height + margin;\n\nfunction push(lines: Lines, x: number, y: number, move: boolean) {\n  const n = lines.count;\n  lines.x[n] = x;\n  lines.y[n] = y;\n  lines.move[n] = move ? 1 : 0;\n  lines.fade[n] = 1;\n  lines.extra[n] = 0;\n  lines.contact[n] = 0;\n  lines.count = n + 1;\n}\n\n/**\n * The contact at canvas (x, y), 0–1: the strongest shield within TOUCH px, lit\n * or not. It sets litGlow to the strongest lit one, and contactOwner to the\n * collider of the strongest. The pointer's shield is no contact: it lights the\n * line near it a little, and fully where a swell is on it.\n */\nfunction contactAt(x: number, y: number) {\n  let contact = 0;\n  litGlow = 0;\n  for (let i = 0; i < shieldCount; i += 1) {\n    const s = shields[i];\n    if (Math.abs(x - s.cx) > s.zx || Math.abs(y - s.cy) > s.zy) continue;\n    if (s.index < 0) {\n      // A moving pointer lights the line fully from half its top weight.\n      const near = Math.min(1, 2 * s.weight) * (1 - Math.max(0, sdf(s, x, y)) / STIR_TOUCH);\n      const crest = Math.min(1, STIR_LIGHT * swellAt(lineGroup, COS * x + SIN * (y + offset), now));\n      litGlow = Math.max(litGlow, STIR_GAIN * near * (STIR_BASE + (1 - STIR_BASE) * crest));\n      continue;\n    }\n    const near = s.weight * (1 - Math.max(0, sdf(s, x, y)) / TOUCH);\n    litGlow = Math.max(litGlow, s.glow * near);\n    if (near <= contact) continue;\n    contact = near;\n    contactOwner = s.index;\n  }\n  return contact;\n}\n\n/**\n * Adds a traced point, with the fade from the last field() call. A step near\n * a lit edge gets the glow at its middle, which the bands draw over the line.\n */\nfunction pushTraced(lines: Lines, x: number, y: number) {\n  const n = lines.count;\n  const near = touch > 0 || lines.contact[n - 1] > 0;\n  push(lines, x, y, false);\n  lines.fade[n] = fade > FADED ? 1 : fade;\n  if (!near) return;\n  const contact = contactAt((x + lines.x[n - 1]) / 2, (y + lines.y[n - 1]) / 2);\n  lines.extra[n] = GLOW * fade * litGlow;\n  lines.contact[n] = fade * contact;\n  lines.owner[n] = contactOwner;\n  lines.banded[lines.bandCount] = n;\n  lines.bandCount += 1;\n}\n\n/**\n * Takes one step from (x, y) along the unit tangent (tx, ty): the longest one\n * whose chord stays within SAG px of the curve. Leaves the field at the new\n * point evaluated and returns the step used; `step` is the length to try first.\n */\nfunction stepAlong(x: number, y: number, tx: number, ty: number, step: number, least: number) {\n  const from = side;\n  for (;;) {\n    field(x + tx * step, y + ty * step);\n    if (step > STEP[lite] && (from < 0 || side !== from)) {\n      step = STEP[lite];\n      continue;\n    }\n    const grad = Math.sqrt(fx * fx + fy * fy) || 1;\n    const bend = (1 - (fy * tx - fx * ty) / grad) * step * step;\n    // The bend grows with the step's fourth power: aim the retry or the next step at BEND_MAX.\n    const fit = 0.9 * Math.sqrt(Math.sqrt(BEND_MAX / Math.max(bend, 1e-12)));\n    // A step that ends where the curve turns back can cut the corner it skipped: it lands off the level.\n    const off = Math.abs(lineLevel - psi) / grad > OFF_LEVEL;\n    if ((bend <= BEND_MAX && !off) || step <= least) {\n      nextStep = Math.max(least, step * Math.min(1.5, fit));\n      return step;\n    }\n    step = Math.max(least, step * Math.min(0.5, fit));\n  }\n}\n\n/**\n * Traces the line downwind from its last point, through the zones, and stops\n * at the first point outside them that is at least `until` along the wind.\n * Returns that point's along-wind page coordinate, or NaN where the line ends\n * (it leaves the canvas, or stalls where it splits at a shield).\n */\nfunction traceZone(lines: Lines, until: number) {\n  let x = lines.x[lines.count - 1];\n  let y = lines.y[lines.count - 1];\n  let step = STEP[lite];\n  field(x, y);\n  for (let n = 0; n < ZONE_STEPS && lines.count < MAX_POINTS; n += 1) {\n    let grad = Math.sqrt(fx * fx + fy * fy);\n    if (grad < 1e-4) break;\n    const tx = fy / grad;\n    const ty = -fx / grad;\n    const used = stepAlong(x, y, tx, ty, step, NEAR_STEP[lite]);\n    x += tx * used;\n    y += ty * used;\n    // Pull the point onto the line's level, so lines never drift into one another.\n    grad = Math.sqrt(fx * fx + fy * fy);\n    if (grad < 1e-4) break;\n    const pull = Math.max(-used, Math.min(used, (lineLevel - psi) / grad)) / grad;\n    x += fx * pull;\n    y += fy * pull;\n    // The pull moves the point less than a step, so only a point near an edge can reach a shield.\n    if (depth < GAP + used) {\n      keepOut(x, y);\n      x = outX;\n      y = outY;\n    }\n    pushTraced(lines, x, y);\n    if (!near(x, y)) break;\n    const along = COS * x + SIN * (y + offset);\n    // The pull moved the point less than a step from where field() last looked.\n    if (along >= until && !sheathed) return along;\n    step = Math.min(side >= 0 ? LONG[lite] : STEP[lite], nextStep);\n  }\n  return Number.NaN;\n}\n\n/** Puts a point inside a sheath onto the line's level, where a line starts in a zone. */\nfunction settle(x: number, y: number) {\n  for (let i = 0; i < 3; i += 1) {\n    field(x, y);\n    const grad2 = fx * fx + fy * fy;\n    if (grad2 < 1e-8) break;\n    const pull = (lineLevel - psi) / grad2;\n    x += fx * pull;\n    y += fy * pull;\n  }\n  return [x, y] as const;\n}\n\n/** Traces through the zone at sample j (at s, near (x, y)); returns the last sample it covered. */\nfunction crossZone(lines: Lines, j: number, s: number, x: number, y: number) {\n  if (!open) {\n    const [sx, sy] = settle(x, y);\n    field(sx, sy);\n    // A line that would start inside a shield runs round it off the canvas: pick it up past the zone.\n    if (depth >= 0) push(lines, sx, sy, true);\n  }\n  const exit = open || depth >= 0 ? traceZone(lines, s) : Number.NaN;\n  if (Number.isNaN(exit)) {\n    blocked = true;\n    open = false;\n    return j;\n  }\n  open = true;\n  const n = lines.count - 1;\n  resetWalk(COS * (lines.y[n] + offset) - SIN * lines.x[n], 3);\n  return Math.max(j, Math.ceil(exit / SAMPLE[lite]) - 1);\n}\n\n/** Adds sample j of the line; returns the last sample used, as a zone trace covers several. */\nfunction sampleAt(lines: Lines, j: number) {\n  const s = j * SAMPLE[lite];\n  const c = acrossAt(s, lineLevel);\n  const x = COS * s - SIN * c;\n  const y = SIN * s + COS * c - offset;\n  if (!near(x, y)) {\n    open = false;\n    return j;\n  }\n  const n = lines.count - 1;\n  if (open ? !chordCuts(lines.x[n], lines.y[n], x, y) : !inZone(x, y)) {\n    blocked = false;\n    push(lines, x, y, !open);\n    open = true;\n    return j;\n  }\n  if (blocked) {\n    open = false;\n    return j;\n  }\n  return crossZone(lines, j, s, x, y);\n}\n\n/** Traces the line of index k, if it crosses the canvas. */\nfunction traceLine(lines: Lines, k: number) {\n  if (!lineRange(lineLevel, SAMPLE[lite], sky) || lines.lines >= lines.k.length) return;\n  lineGroup = groupOf(k);\n  lines.k[lines.lines] = k;\n  lines.start[lines.lines] = lines.count;\n  lines.lines += 1;\n  resetWalk(lineLevel, 5);\n  open = false;\n  blocked = false;\n  for (let j = range.first; j <= range.last && lines.count < MAX_POINTS; j += 1) {\n    j = sampleAt(lines, j);\n  }\n}\n\n/**\n * Traces every line that crosses the canvas this frame into `lines`.\n * `page` is how far the canvas top is down the page, in px.\n */\nexport function traceFlux(lines: Lines, frame: StyleFrame, page: number, stir: Stir) {\n  lite = frame.quality === 'lite' ? 1 : 0;\n  width = frame.width;\n  height = frame.height;\n  now = frame.t;\n  offset = page;\n  margin = marginFor(lite);\n  const spacing = SPACE[lite];\n  ({ a: aPhase, b: bPhase } = phases(now));\n  Object.assign(sky, { offset, margin, width, height });\n  shieldCount = fitShields(frame.colliders, stir, levelAt);\n  lines.count = 0;\n  lines.lines = 0;\n  lines.bandCount = 0;\n  // The across-wind span of the grown canvas, widened by the waves.\n  const low = COS * (offset - margin) - SIN * (width + margin) - WAVE;\n  const high = COS * (offset + height + margin) + SIN * margin + WAVE;\n  const first = Math.ceil((low - SLIDE * now) / spacing);\n  const last = Math.floor((high - SLIDE * now) / spacing);\n  for (let k = first; k <= last && lines.count < MAX_POINTS; k += 1) {\n    lineLevel = k * spacing + SLIDE * now;\n    traceLine(lines, k);\n  }\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/fluxField.ts"
    },
    {
      "path": "components/aurora/wind/styles/fluxInk.ts",
      "content": "import { type FieldContext, mix, rgba } from '../../fieldTypes';\nimport type { Look } from '../render';\nimport {\n  COS,\n  crestDistance,\n  GLOW,\n  groupOf,\n  LINE,\n  type Lines,\n  MAX_POINTS,\n  SIN,\n  SWELL,\n  SWELL_HALF,\n  swellStrength,\n} from './fluxLines';\nimport type { StyleFrame } from './types';\n\nconst BODY_BANDS = 16;\nconst CREST_BANDS = 16;\nconst EDGE_BANDS = 24;\nconst EDGE_TOP = 30;\n\n/** Clips a segment to one alpha band. Endpoints slide continuously along its traced chord. */\nfunction bandSegment(\n  ctx: FieldContext,\n  lines: Lines,\n  i: number,\n  a: number,\n  b: number,\n  low: number,\n  high: number,\n) {\n  if (Math.max(a, b) < low || Math.min(a, b) > high) return false;\n  const delta = b - a;\n  const from = delta === 0 ? 0 : Math.max(0, Math.min((low - a) / delta, (high - a) / delta));\n  const to = delta === 0 ? 1 : Math.min(1, Math.max((low - a) / delta, (high - a) / delta));\n  if (to <= from) return false;\n  const x = lines.x[i - 1];\n  const y = lines.y[i - 1];\n  const dx = lines.x[i] - x;\n  const dy = lines.y[i] - y;\n  ctx.moveTo(x + from * dx, y + from * dy);\n  ctx.lineTo(x + to * dx, y + to * dy);\n  return true;\n}\n\nfunction appendBody(ctx: FieldContext, lines: Lines, bands: Uint8Array, band: number) {\n  let open = -2;\n  let drawn = false;\n  for (let i = 1; i < lines.count; i += 1) {\n    if (lines.move[i] || bands[i] !== band) continue;\n    if (open !== i - 1) ctx.moveTo(lines.x[i - 1], lines.y[i - 1]);\n    ctx.lineTo(lines.x[i], lines.y[i]);\n    open = i;\n    drawn = true;\n  }\n  return drawn;\n}\n\nfunction appendCrest(ctx: FieldContext, lines: Lines, values: Float32Array, band: number) {\n  const low = (band - 0.5) / CREST_BANDS;\n  const high = (band + 0.5) / CREST_BANDS;\n  let drawn = false;\n  for (let i = 1; i < lines.count; i += 1) {\n    if (lines.move[i]) continue;\n    const a = values[i - 1] * lines.fade[i];\n    const b = values[i] * lines.fade[i];\n    drawn = bandSegment(ctx, lines, i, a, b, low, high) || drawn;\n  }\n  return drawn;\n}\n\nfunction makeInk(look: Look) {\n  const colour = mix(look.speck, look.halo, look.dark ? 0.3 : 0.65);\n  const colours = (count: number, alpha: number) =>\n    Array.from({ length: count + 1 }, (_, n) => rgba(colour, (n / count) * alpha * look.gain));\n  return {\n    body: colours(BODY_BANDS, LINE),\n    crest: colours(CREST_BANDS, SWELL),\n    edge: Array.from({ length: EDGE_TOP + 1 }, (_, n) =>\n      rgba(colour, (n / EDGE_BANDS) * GLOW * look.gain),\n    ),\n  };\n}\n\n/** Flatten the visible crest without changing the shared energy envelope. */\nexport function inkAt(line: number, along: number, t: number) {\n  const distance = Math.abs(crestDistance(line, along, t));\n  const shoulder = Math.max(0, Math.min(1, (distance - 70) / (SWELL_HALF - 70)));\n  return swellStrength(line) * (1 - shoulder * shoulder * (3 - 2 * shoulder));\n}\n\nfunction swellValues(\n  values: Float32Array,\n  bodyBands: Uint8Array,\n  lines: Lines,\n  frame: StyleFrame,\n  page: number,\n) {\n  let used = 0;\n  for (let l = 0; l < lines.lines; l += 1) {\n    const end = l + 1 < lines.lines ? lines.start[l + 1] : lines.count;\n    const group = groupOf(lines.k[l]);\n    for (let i = lines.start[l]; i < end; i += 1) {\n      values[i] = inkAt(group, COS * lines.x[i] + SIN * (lines.y[i] + page), frame.t);\n      bodyBands[i] = Math.round(lines.fade[i] * BODY_BANDS);\n      used |= 1 << bodyBands[i];\n    }\n  }\n  return used;\n}\n\nfunction paintEdges(ctx: FieldContext, lines: Lines, bands: Uint8Array, colours: string[]) {\n  let used = 0;\n  for (let b = 0; b < lines.bandCount; b += 1) {\n    bands[b] = Math.min(EDGE_TOP, Math.round((lines.extra[lines.banded[b]] / GLOW) * EDGE_BANDS));\n    used |= 1 << bands[b];\n  }\n  for (let band = 1; band <= EDGE_TOP; band += 1) {\n    if (!(used & (1 << band))) continue;\n    ctx.strokeStyle = colours[band];\n    ctx.beginPath();\n    for (let b = 0; b < lines.bandCount; b += 1) {\n      if (bands[b] !== band) continue;\n      const i = lines.banded[b];\n      ctx.moveTo(lines.x[i - 1], lines.y[i - 1]);\n      ctx.lineTo(lines.x[i], lines.y[i]);\n    }\n    ctx.stroke();\n  }\n}\n\nfunction paintLines(\n  ctx: FieldContext,\n  lines: Lines,\n  bodyBands: Uint8Array,\n  values: Float32Array,\n  used: number,\n  body: string[],\n  crest: string[],\n) {\n  for (let band = 1; band <= BODY_BANDS; band += 1) {\n    if (!(used & (1 << band))) continue;\n    ctx.strokeStyle = body[band];\n    ctx.beginPath();\n    if (appendBody(ctx, lines, bodyBands, band)) ctx.stroke();\n  }\n  for (let band = 1; band <= CREST_BANDS; band += 1) {\n    ctx.strokeStyle = crest[band];\n    ctx.beginPath();\n    if (appendCrest(ctx, lines, values, band)) ctx.stroke();\n  }\n}\n\n/** Solid body and crest passes keep each line's swell independent, without per-line gradients. */\nexport function createFluxInk() {\n  const values = new Float32Array(MAX_POINTS);\n  const bands = new Uint8Array(MAX_POINTS);\n  const bodyBands = new Uint8Array(MAX_POINTS);\n  let look: Look | null = null;\n  let body: string[] = [];\n  let crest: string[] = [];\n  let edge: string[] = [];\n\n  /** `glowOnly` draws just the glow along the edges, without the lines it sits on. */\n  return (ctx: FieldContext, frame: StyleFrame, lines: Lines, page: number, glowOnly = false) => {\n    if (frame.look !== look) {\n      look = frame.look;\n      ({ body, crest, edge } = makeInk(look));\n    }\n    ctx.globalAlpha = 1;\n    ctx.lineWidth = 1.2;\n    ctx.lineJoin = 'round';\n    if (!glowOnly) {\n      const used = swellValues(values, bodyBands, lines, frame, page);\n      paintLines(ctx, lines, bodyBands, values, used, body, crest);\n    }\n    paintEdges(ctx, lines, bands, edge);\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/fluxInk.ts"
    },
    {
      "path": "components/aurora/wind/styles/fluxLines.ts",
      "content": "/**\n * What the flux tracer (fluxField.ts) hands to its painter (flux.ts): the\n * traced points of every line, and the numbers both of them use.\n */\n\nimport type { Box } from '../outline';\n\n/** The wind's mean angle below the horizon, as in particles.ts. */\nconst DRIFT = 0.42;\nexport const SIN = Math.sin(DRIFT);\nexport const COS = Math.cos(DRIFT);\n/** Swells of light along each line: speed in px/s (each line ±20%), gap and half width in px. */\nconst SWELL_SPEED = 64;\n/** A step that fades less than this is drawn at full alpha. */\nexport const FADED = 0.98;\nexport const SWELL_GAP = 1000;\nexport const SWELL_HALF = 220;\n/**\n * Alpha of a plain line, a swell's crest (times its strength), and an edge glow.\n * A quiet body leaves contrast for each flat crest. Device-aware rasterization keeps its core sharp.\n */\nexport const LINE = 0.07;\nexport const SWELL = 0.12;\nexport const GLOW = 0.17;\nexport const MAX_POINTS = 12288;\nconst MAX_LINES = 512;\n\nexport const frac = (u: number) => u - Math.floor(u);\n/** Each line has its own swell identifier. Contacts use the same helpers as the painter. */\nexport const groupOf = (k: number) => k;\nexport const swellPhase = (line: number) => frac(line * 0.618);\nexport const swellSpeed = (line: number) => SWELL_SPEED * (0.8 + 0.4 * frac(line * 0.57));\nexport const swellStrength = (line: number) => 0.45 + 0.55 * frac(line * 0.755);\n/**\n * Signed px along the wind from the nearest crest of a group's swells to the\n * page point `along`, at wind time t. It is positive while the crest is\n * upwind of the point, and falls to zero as the crest arrives. The crests are\n * at (n + 0.5 - phase) gaps, carried at the line's speed.\n */\nexport function crestDistance(group: number, along: number, t: number) {\n  const d = along - swellSpeed(group) * t - (0.5 - swellPhase(group)) * SWELL_GAP;\n  return d - SWELL_GAP * Math.round(d / SWELL_GAP);\n}\n\n/** The strength 0–1 of a group's swells at page along-wind coordinate `along`, at wind time t. */\nexport function swellAt(group: number, along: number, t: number) {\n  const f = crestDistance(group, along, t) / SWELL_HALF;\n  return f * f >= 1 ? 0 : swellStrength(group) * (1 - f * f) ** 2;\n}\n\nexport interface Lines {\n  x: Float32Array;\n  y: Float32Array;\n  /** 1 where a point starts a subpath. */\n  move: Uint8Array;\n  /** 0–1: how far the step that ends at the point fades, near a split (1 in open sky). */\n  fade: Float32Array;\n  /** Alpha of the glow on the step that ends at the point, which the bands add over the line. */\n  extra: Float32Array;\n  count: number;\n  /** Each line's index k and its first point; the next line's first point ends it. */\n  k: Int32Array;\n  start: Int32Array;\n  lines: number;\n  /** Points whose step the bands draw. */\n  banded: Int32Array;\n  bandCount: number;\n  /** 0–1: how near the step that ends at the point runs to a collider's edge, lit or not, times its fade. */\n  contact: Float32Array;\n  /** The index in frame.colliders of the collider that `contact` measures. */\n  owner: Int16Array;\n}\n\nexport const createLines = (): Lines => ({\n  x: new Float32Array(MAX_POINTS),\n  y: new Float32Array(MAX_POINTS),\n  move: new Uint8Array(MAX_POINTS),\n  fade: new Float32Array(MAX_POINTS),\n  extra: new Float32Array(MAX_POINTS),\n  count: 0,\n  k: new Int32Array(MAX_LINES),\n  start: new Int32Array(MAX_LINES),\n  lines: 0,\n  banded: new Int32Array(MAX_POINTS),\n  bandCount: 0,\n  contact: new Float32Array(MAX_POINTS),\n  owner: new Int16Array(MAX_POINTS),\n});\n\n/** The box round all of an outline's boxes. */\nexport function bounds(boxes: readonly Box[]) {\n  let left = Number.POSITIVE_INFINITY;\n  let top = Number.POSITIVE_INFINITY;\n  let right = Number.NEGATIVE_INFINITY;\n  let bottom = Number.NEGATIVE_INFINITY;\n  for (const box of boxes) {\n    left = Math.min(left, box.x);\n    top = Math.min(top, box.y);\n    right = Math.max(right, box.x + box.w);\n    bottom = Math.max(bottom, box.y + box.h);\n  }\n  return { left, top, right, bottom };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/fluxLines.ts"
    },
    {
      "path": "components/aurora/wind/styles/fluxShields.ts",
      "content": "/**\n * The shields of the flux field (fluxField.ts): the colliders that stop the\n * wind, and the small one that a moving pointer makes. A shield is a rounded\n * box with a sheath round it, and the lines bend round it.\n */\nimport type { ColliderState } from '../colliders';\nimport { fieldFor, type GlyphField } from '../glyphField';\nimport type { Box } from '../outline';\nimport { bounds } from './fluxLines';\nimport { CORNER, STIR_RADIUS, STIR_REACH, TEXT_PAD } from './fluxTuning';\n\n/**\n * A collider as the field sees it: a rounded box with a sheath round it. A\n * disc is a box with equal sides and full rounding. Text samples cached\n * glyph distances, including padding, independently of its lighting.\n */\nexport interface Shield {\n  glyph: GlyphField | undefined;\n  left: number;\n  top: number;\n  cx: number;\n  cy: number;\n  hx: number;\n  hy: number;\n  /** Corner radius in px. */\n  round: number;\n  /** Px of the sheath where lines bend round the edge. */\n  reach: number;\n  /** 0–1: how far the collider stops the wind (a hover collider eases in). */\n  weight: number;\n  /** 1 where lines glow at the edge; 0 on an unlit collider. */\n  glow: number;\n  /** The collider's index in this frame's list, or -1 for the pointer's shield. */\n  index: number;\n  /** The stream function at the centre: the level of the edge. */\n  level: number;\n  /** Half size of the box round the sheath, where lines are traced. */\n  zx: number;\n  zy: number;\n}\n\n/** The pointer's shield: where it is, and how hard it stirs (0 where it does not). */\nexport interface Stir {\n  x: number;\n  y: number;\n  weight: number;\n}\n\nexport const shields: Shield[] = [];\n\nfunction fitCollider(s: Shield, collider: ColliderState, boxes: Box[]) {\n  const shape = collider.outline?.glyph;\n  const glyph = shape ? fieldFor(shape) : undefined;\n  const { left, top, right, bottom } = bounds(glyph ? [glyph.bounds] : boxes);\n  s.glyph = glyph;\n  s.left = collider.left;\n  s.top = collider.top;\n  const pad = glyph ? glyph.pad : collider.text ? TEXT_PAD : 0;\n  s.hx = (right - left) / 2 + pad;\n  s.hy = (bottom - top) / 2 + pad;\n  s.cx = collider.left + (left + right) / 2;\n  s.cy = collider.top + (top + bottom) / 2;\n  s.round = Math.min(s.hx, s.hy, collider.disc || collider.text ? s.hx : CORNER);\n  s.reach = glyph\n    ? Math.max(12, Math.min(64, glyph.pad * 2))\n    : Math.min(64, 16 + 0.15 * (s.hx + s.hy));\n  s.glow = collider.unlit ? 0 : 1;\n  s.zx = s.hx + s.reach;\n  s.zy = s.hy + s.reach;\n}\n\n/**\n * Fits the shields to this frame's colliders that stop the wind, and the\n * pointer's, last. `levelAt` gives the undisturbed stream function. Returns\n * the number of shields.\n */\nexport function fitShields(\n  colliders: readonly ColliderState[],\n  stir: Stir,\n  levelAt: (x: number, y: number) => number,\n) {\n  let count = 0;\n  const next = () => {\n    shields[count] ??= {} as Shield;\n    return shields[count];\n  };\n  for (let index = 0; index < colliders.length; index += 1) {\n    const collider = colliders[index];\n    const weight = collider.mode === 'always' ? 1 : collider.active;\n    const boxes = collider.outline?.boxes ?? [];\n    if (!collider.visible || weight < 0.01 || boxes.length === 0) continue;\n    const s = next();\n    fitCollider(s, collider, boxes);\n    s.weight = weight;\n    s.index = index;\n    s.level = levelAt(s.cx, s.cy);\n    count += 1;\n  }\n  if (stir.weight < 0.01) return count;\n  const s = next();\n  Object.assign(s, {\n    glyph: undefined,\n    cx: stir.x,\n    cy: stir.y,\n    hx: STIR_RADIUS,\n    hy: STIR_RADIUS,\n    round: STIR_RADIUS,\n    reach: STIR_REACH,\n    weight: stir.weight,\n    glow: 1,\n    index: -1,\n    zx: STIR_RADIUS + STIR_REACH,\n    zy: STIR_RADIUS + STIR_REACH,\n    level: levelAt(stir.x, stir.y),\n  });\n  return count + 1;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/fluxShields.ts"
    },
    {
      "path": "components/aurora/wind/styles/fluxTuning.ts",
      "content": "/** The numbers of the flux tracer (fluxField.ts), in px unless it says otherwise. */\n\n/** Px across the wind between two lines: full, then lite quality. */\nexport const SPACE = [64, 92];\n/** Px along the wind between the points of a line in open sky. */\nexport const SAMPLE = [64, 80];\n/** Px per traced step near a shield, longest and shortest. */\nexport const STEP = [48, 64];\n/** Px of the longest step along one flat side of a shield, where no corner can hide inside it. */\nexport const LONG = [96, 128];\nexport const NEAR_STEP = [5, 6];\n/** Px that a traced point keeps outside a shield: a line that hugs it slides along its edge. */\nexport const GAP = 0.25;\n/** Steps one zone trace may take before it gives up. */\nexport const ZONE_STEPS = 600;\n/**\n * Px that a traced chord may stray from the curve. A step that turns the line\n * by θ strays about step·θ/8, so (1 − cos θ)·step² must stay under 32·SAG².\n */\nexport const SAG = 0.5;\nexport const BEND_MAX = 32 * SAG * SAG;\n/** Px from its line's level where a step may end. A step strays about step²·κ/2 there, 4 SAG at the bend limit. */\nexport const OFF_LEVEL = 4 * SAG;\n/** Px per second that the lines slide across the wind. */\nexport const SLIDE = 4;\n/** Px from an edge where a passing line starts to touch it: it glows there, and a swell can strike. */\nexport const TOUCH = 18;\n/**\n * The line that meets a shield head-on splits there, and it flips sides as the\n * field drifts. Lines this close to its level hug the edge within about 2 px,\n * where the collider hides them, so they fade out and the flip goes unseen.\n */\nexport const SPLIT = 12;\n/** Px of air kept between the lines and a text collider's letters. */\nexport const TEXT_PAD = 8;\n/** Px of rounding on a box's corners, so lines that hug it turn on a curve a step can follow. */\nexport const CORNER = 12;\n/** The pointer's shield: its radius, and the px round it where lines bend. */\nexport const STIR_RADIUS = 28;\nexport const STIR_REACH = 76;\n/** The light that the pointer draws on any line it bends (0–1), and how strongly a swell lights it on top (a swell strength of 1 / STIR_LIGHT lights it fully). */\nexport const STIR_BASE = 0.75;\nexport const STIR_LIGHT = 2.4;\n/** Px from the pointer's shield where a line it bends still lights. */\nexport const STIR_TOUCH = 44;\n/** The pointer's light, over a card edge's: the bands cap it at 1.25 times a card edge's brightest. */\nexport const STIR_GAIN = 2.2;\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/fluxTuning.ts"
    },
    {
      "path": "components/aurora/wind/styles/fluxWaves.ts",
      "content": "/**\n * The two slow waves that bend the flux lines, and the open-sky walk. In open\n * sky a line's points sit at fixed steps along the wind on the page, so they\n * move only across the wind and never crawl along the line. fluxField.ts\n * traces the lines near the shields.\n */\nimport { COS, SIN } from './fluxLines';\n\n/** Two slow waves: px of amplitude, px per radian along and across the wind, and radians per second. */\nexport const A = 70;\nexport const A_ALONG = 380;\nexport const A_ACROSS = 1100;\nconst A_RATE = 0.045;\nexport const B = 24;\nexport const B_ALONG = 260;\nexport const B_ACROSS = -700;\nconst B_RATE = -0.03;\n/** The most the waves move a line across the wind, in px. */\nexport const WAVE = A + B;\n\n/** The waves' phases at this frame's time. */\nconst phase = { a: 0, b: 0 };\n\n/** Sets the waves' phases for time t in s; returns them. */\nexport function phases(t: number) {\n  phase.a = A_RATE * t;\n  phase.b = B_RATE * t + 2;\n  return phase;\n}\n\n/**\n * The sines and cosines of the waves at the angles last given to turnWaves().\n * Successive calls lie close together, so turning the last values by the\n * small change in angle is cheaper than Math.sin and Math.cos, and as exact to\n * 1e-9 px. Every 64 turns, or after a jump, they are computed in full.\n */\nexport const wave = { sinA: 0, cosA: 1, sinB: 0, cosB: 1 };\nlet lastA = Number.POSITIVE_INFINITY;\nlet lastB = 0;\nlet turns = 0;\n/** sin and cos of the small angle given to small(), by Taylor series. */\nlet sinD = 0;\nlet cosD = 1;\n\n/** Sets sinD and cosD for |d| < 0.35 rad: the next terms are under 3e-10. */\nfunction small(d: number) {\n  const d2 = d * d;\n  // The factors are 1/6, 1/20, 1/42 and 1/2, 1/12, 1/30, 1/56.\n  sinD = d * (1 - d2 * 0.16666666666666666 * (1 - d2 * 0.05 * (1 - d2 * 0.023809523809523808)));\n  cosD =\n    1 -\n    d2 *\n      0.5 *\n      (1 -\n        d2 *\n          0.08333333333333333 *\n          (1 - d2 * 0.03333333333333333 * (1 - d2 * 0.017857142857142856)));\n}\n\n/** Sets `wave` for the angles a and b of the two waves. */\nexport function turnWaves(a: number, b: number) {\n  if (turns < 64 && Math.abs(a - lastA) < 0.35 && Math.abs(b - lastB) < 0.35) {\n    small(a - lastA);\n    const s = wave.sinA * cosD + wave.cosA * sinD;\n    wave.cosA = wave.cosA * cosD - wave.sinA * sinD;\n    wave.sinA = s;\n    small(b - lastB);\n    const t = wave.sinB * cosD + wave.cosB * sinD;\n    wave.cosB = wave.cosB * cosD - wave.sinB * sinD;\n    wave.sinB = t;\n    turns += 1;\n  } else {\n    wave.sinA = Math.sin(a);\n    wave.cosA = Math.cos(a);\n    wave.sinB = Math.sin(b);\n    wave.cosB = Math.cos(b);\n    turns = 0;\n  }\n  lastA = a;\n  lastB = b;\n}\n\n/** The canvas grown by a margin, in its own px, and how far down the page its top is. */\nexport interface Sky {\n  offset: number;\n  margin: number;\n  width: number;\n  height: number;\n}\n\n/** The samples where the line being walked can be on the sky; lineRange() sets them. */\nexport const range = { first: 0, last: 0 };\n\n/**\n * The walk's guess for the next sample: the across-wind coordinate of the\n * last sample with its first and second differences, the samples known since\n * the walk last started, and the fixed-point steps for a fresh start.\n */\nlet lastC = 0;\nlet lastDc = 0;\nlet lastDdc = 0;\nlet known = 0;\nlet freshSteps = 0;\n\n/**\n * The across-wind coordinate where the line of this level crosses the\n * along-wind coordinate s in open sky: c + waves(s, c) = level, by fixed-point\n * steps. The waves change by at most a tenth of a change in c, so each step\n * gains a digit.\n */\nexport function acrossAt(s: number, level: number) {\n  // Three known samples give a quadratic guess within a px, so one step is enough.\n  let c = lastC + (known > 1 ? lastDc : 0) + (known > 2 ? lastDdc : 0);\n  const steps = known > 2 ? 1 : known > 1 ? 2 : freshSteps;\n  for (let i = 0; i < steps; i += 1) {\n    c =\n      level -\n      A * Math.sin(s / A_ALONG + c / A_ACROSS + phase.a) -\n      B * Math.sin(s / B_ALONG + c / B_ACROSS + phase.b);\n  }\n  const dc = c - lastC;\n  lastDdc = dc - lastDc;\n  lastDc = dc;\n  lastC = c;\n  known += 1;\n  return c;\n}\n\n/** Starts the walk again from a known across-wind coordinate, with `steps` fixed-point steps for the first guess. */\nexport function resetWalk(c: number, steps: number) {\n  lastC = c;\n  lastDc = 0;\n  lastDdc = 0;\n  known = 0;\n  freshSteps = steps;\n}\n\n/** Sets `range`: the samples, `gap` px apart, where the line can be on the sky. */\nexport function lineRange(level: number, gap: number, sky: Sky) {\n  const { offset, margin, width, height } = sky;\n  const low = level - WAVE;\n  const high = level + WAVE;\n  const from = Math.max((SIN * low - margin) / COS, (offset - margin - COS * high) / SIN);\n  const to = Math.min(\n    (width + margin + SIN * high) / COS,\n    (offset + height + margin - COS * low) / SIN,\n  );\n  range.first = Math.floor(from / gap);\n  range.last = Math.ceil(to / gap);\n  return from <= to;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/fluxWaves.ts"
    },
    {
      "path": "components/aurora/wind/styles/types.ts",
      "content": "/**\n * How the engine (windEngine.ts) and flux (flux.ts), the look of the solar\n * wind, meet.\n *\n * Shared, in the engine: the edge light and the hero feeds an impact gives,\n * the quality governor, the frame gate, the pauses (hidden tab, offscreen,\n * reduced motion) and where the wind shows (the sheet and reveal windows,\n * windows.ts).\n *\n * Flux owns:\n * - createPainter(): its draw pass. glow() paints in the glow blend after the\n *   edge light. The painter may keep state between frames: frame.colliders\n *   are the shapes its lines drape round, and frame.shift is the scroll.\n * - the contacts: where a bright swell of a line reaches a collider's edge or\n *   a hero curtain's lower edge. They are the only cause of an impact or a feed.\n *\n * The painter must respect:\n * - quality: 'lite' draws fewer lines; drop costly extras too;\n * - reduced motion: the wind does not run, and t stops; never read a clock,\n *   and report no contact while dt is 0;\n * - both palettes: colours come from frame.look; look.dark picks the ground,\n *   and the glow blend adds light at night only;\n * - the budget: batch strokes by colour into a few paths, and allocate\n *   nothing per frame.\n */\nimport type { Quality } from '@/lib/aurora/quality';\nimport type { ColliderState } from '../colliders';\nimport type { Look } from '../render';\nimport type { StormZone } from '../storm';\nimport type { WindSurface } from '../windSurface';\n\n/** What the painter gets each frame. The engine reuses this object: read it, do not keep it. */\nexport interface StyleFrame {\n  /** The colliders in canvas px, so the style can shape its own model round them. Do not change them. */\n  colliders: readonly ColliderState[];\n  /** The hero curtains on the canvas, in canvas px. */\n  zones: readonly StormZone<number>[];\n  look: Look;\n  /**\n   * The pointer in canvas px while it can stir the lines: on a fine pointer inside a\n   * wind window, and off a quiet collider. Otherwise null.\n   */\n  pointer: { x: number; y: number; clientY?: number } | null;\n  /** Wind time in s. It stops while motion is reduced: animate with t only. */\n  t: number;\n  /** Wind time since the last frame, in s: 0 while motion is reduced. */\n  dt: number;\n  quality: Quality;\n  /** The canvas in CSS px. */\n  width: number;\n  height: number;\n  /** Px the layer moved down the page this frame: subtract it from any y you keep. */\n  shift: number;\n}\n\n/**\n * What the swells did in the last glow(). The painter reuses this object: read\n * it, do not keep it. A swell is bright enough to count while its crest\n * arrives at a place where a line meets a collider or crosses a curtain's edge.\n */\nexport interface Contacts {\n  /** Swell crests that reached a collider's edge. */\n  hits: number;\n  /** The collider's index in frame.colliders, the place on the canvas, and the strength 0–1. */\n  hitCollider: Int16Array;\n  hitX: Float32Array;\n  hitY: Float32Array;\n  hitAmp: Float32Array;\n  /**\n   * Line steps that touch a hovered collider's edge: they hold light there\n   * while they touch, more where a swell runs (colliders.ts holdLight).\n   */\n  touches: number;\n  touchCollider: Int16Array;\n  touchX: Float32Array;\n  touchY: Float32Array;\n  touchAmp: Float32Array;\n  /** Swell crests that crossed a curtain's lower edge. */\n  feeds: number;\n  /** The zone's index in frame.zones, the share 0–1 of its width, and the strength 0–1. */\n  feedZone: Int16Array;\n  feedAt: Float32Array;\n  feedAmp: Float32Array;\n}\n\nexport interface WindPainter {\n  /** Paints in the glow blend ('lighter' at night, 'source-over' by day), after the edge light. */\n  glow(frame: StyleFrame): void;\n  readonly contacts: Contacts;\n}\n\nexport interface WindStyle {\n  /** A painter for one canvas. It may keep state between frames. */\n  createPainter(surface: WindSurface): WindPainter;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/styles/types.ts"
    },
    {
      "path": "components/aurora/wind/wind.worker.ts",
      "content": "import { createFixedClock } from '@/lib/aurora/fixedClock';\nimport { AURORA_FPS } from '@/lib/aurora/frameGate';\nimport {\n  createBitmapPresenter,\n  createTargetPresenter,\n  type FramePresenter,\n} from './framePresenters';\nimport { createWorkerWindSurface } from './gpu/workerSurface';\nimport { createWindEngine } from './windEngine';\nimport type { WindMessage, WindReply, WindView } from './windProtocol';\n\nlet engine: ReturnType<typeof createWindEngine> | null = null;\nlet presenter: FramePresenter | null = null;\nlet view: WindView = { anchor: 0, pointer: null };\n\nconst post = (reply: WindReply, transfer: Transferable[] = []) =>\n  self.postMessage(reply, { transfer });\n\n// A frame carries its document anchor, so its pixels and position arrive together.\nconst clock = createFixedClock(AURORA_FPS, (dt) => {\n  if (!engine || !presenter) return;\n  const effects = engine.paint({ now: performance.now(), dt, ...view });\n  presenter.frame(view.anchor, effects);\n});\n\nconst fail = () => {\n  clock.stop();\n  presenter?.clear();\n  post({ type: 'error' });\n};\n\nself.onmessage = ({ data }: MessageEvent<WindMessage>) => {\n  try {\n    if (data.type === 'init') {\n      const surface = createWorkerWindSurface(data.canvas, fail);\n      engine = createWindEngine(data.canvas, surface);\n      presenter = data.targets\n        ? createTargetPresenter(data.canvas, data.targets, post)\n        : createBitmapPresenter(data.canvas, post);\n    } else if (data.type === 'configure') {\n      presenter?.configure(data.config);\n      engine?.configure(data.config);\n    } else if (data.type === 'scene') engine?.scene(data.scene);\n    else if (data.type === 'view') {\n      if (data.scene) engine?.scene(data.scene);\n      view = data.view;\n    } else if (data.type === 'run') clock.start();\n    else if (data.type === 'clear') {\n      clock.stop();\n      presenter?.clear();\n      engine?.clear();\n    } else presenter?.receive(data);\n  } catch {\n    fail();\n  }\n};\n\npost({ type: 'ready' });\n",
      "type": "registry:component",
      "target": "components/aurora/wind/wind.worker.ts"
    },
    {
      "path": "components/aurora/wind/windEngine.ts",
      "content": "import type { FieldContext } from '../fieldTypes';\nimport { createCanvasWindSurface } from './canvasSurface';\nimport {\n  addStrike,\n  type ColliderState,\n  colliderGain,\n  holdLight,\n  updateEnergy,\n  updateHover,\n} from './colliders';\nimport { nearestSample } from './outline';\nimport { buildLook, type Look } from './render';\nimport { feedStrike, type StormZone } from './storm';\nimport { flux } from './styles/flux';\nimport type { Contacts, StyleFrame, WindPainter } from './styles/types';\nimport { glowWindows, inWindow, revealWindows, type WindWindow } from './windows';\nimport type { WindConfig, WindEffect, WindFrame, WindScene } from './windProtocol';\nimport type { WindSurface } from './windSurface';\n\nconst STRIKE_AMP = 1.15;\n/** Strength of the edge light that a swell of full strength gives. */\nconst HIT_AMP = 1.4;\n/** Charge that a swell of full strength gives a hero curtain as its crest crosses the lower edge. */\nconst FEED_AMP = 2.4;\n/** Light per second that a touching line holds on a hovered edge, at full contact. */\nconst HOLD_RATE = 0.5;\n/** Px round a quiet collider where the pointer stirs nothing: its lines stay as they are. */\nconst QUIET_PAD = 60;\n\ninterface WindState extends WindConfig {\n  surface: WindSurface;\n  colliders: ColliderState[];\n  ids: Map<ColliderState, number>;\n  look: Look;\n  painter: WindPainter;\n  /** Reused each frame for the painter. */\n  frame: StyleFrame;\n  t: number;\n  /** Wind time since the last frame; 0 while motion is reduced. */\n  dt: number;\n  /** The pointer where it can stir the lines (see StyleFrame.pointer). */\n  pointer: { x: number; y: number; clientY?: number } | null;\n  anchor: number;\n  windowed: boolean;\n  /** Where strikes land and light shows: the sheets plus each hovered card's reveal window. */\n  windows: WindWindow[];\n  /** Where lines show in full: the sheets only. */\n  lineWindows: WindWindow[];\n  /** Where only the lines' edge glow shows: a band round each hovered card on the plain ground. */\n  glowWindows: WindWindow[];\n  zones: StormZone<number>[];\n  effects: WindEffect[];\n  feed: (id: number, fx: number, amount: number) => void;\n}\n\nconst isOpen = (state: WindState, x: number, y: number) =>\n  !state.windowed || inWindow(state.windows, x, y);\n\n/**\n * The pointer, if it may stir the lines: it is a mouse or pen, motion runs, it\n * is inside a wind window, and it is not near a quiet collider's letters.\n */\nfunction stirring(state: WindState, pointer: WindFrame['pointer']) {\n  if (!pointer || pointer.touch || state.reduced || !isOpen(state, pointer.x, pointer.y))\n    return null;\n  const quiet = state.colliders.some(\n    (c) =>\n      c.unlit &&\n      c.visible &&\n      pointer.x > c.left - QUIET_PAD &&\n      pointer.x < c.left + c.width + QUIET_PAD &&\n      pointer.y > c.top - QUIET_PAD &&\n      pointer.y < c.top + c.height + QUIET_PAD,\n  );\n  return quiet ? null : pointer;\n}\n\n/** A swell strikes a collider at canvas (x, y), with strength `amp` (0–1 times the contact). */\nfunction strike(state: WindState, collider: ColliderState, x: number, y: number, amp: number) {\n  const outline = collider.outline;\n  if (!outline || (!collider.unlit && outline.count === 0)) return;\n  const gain = colliderGain(collider);\n  if (state.zones.length > 0) feedStrike(state.zones, x, y, amp * gain, state.feed);\n  // An unlit collider still parts the wind and feeds the curtain, but shows no light itself.\n  if (collider.unlit) return;\n  const struck = nearestSample(outline, x - collider.left, y - collider.top);\n  const strength = amp * HIT_AMP * gain;\n  if (collider.disc)\n    state.effects.push({\n      type: 'strike',\n      id: state.ids.get(collider) ?? -1,\n      x: outline.x[struck] / collider.scale,\n      y: outline.y[struck] / collider.scale,\n      amp: strength,\n    });\n  else addStrike(collider, struck, strength * STRIKE_AMP);\n}\n\n/** A line touching a hovered collider at canvas (x, y) holds light on its edge there. */\nfunction hold(state: WindState, collider: ColliderState, x: number, y: number, amp: number) {\n  const outline = collider.outline;\n  if (!outline || outline.count === 0) return;\n  const at = nearestSample(outline, x - collider.left, y - collider.top);\n  holdLight(collider, at, amp * HOLD_RATE * colliderGain(collider) * state.dt);\n}\n\n/** Applies what the swells did in the frame just painted. */\nfunction applyContacts(state: WindState, contacts: Contacts) {\n  for (let i = 0; i < contacts.hits; i += 1) {\n    const collider = state.colliders[contacts.hitCollider[i]];\n    const x = contacts.hitX[i];\n    const y = contacts.hitY[i];\n    if (isOpen(state, x, y)) strike(state, collider, x, y, contacts.hitAmp[i]);\n  }\n  for (let i = 0; i < contacts.touches; i += 1) {\n    const x = contacts.touchX[i];\n    const y = contacts.touchY[i];\n    if (isOpen(state, x, y))\n      hold(state, state.colliders[contacts.touchCollider[i]], x, y, contacts.touchAmp[i]);\n  }\n  for (let i = 0; i < contacts.feeds; i += 1) {\n    const zone = state.zones[contacts.feedZone[i]];\n    state.feed(zone.canvas, contacts.feedAt[i], FEED_AMP * contacts.feedAmp[i]);\n  }\n}\n\nfunction fillFrame(state: WindState) {\n  const { frame } = state;\n  frame.colliders = state.colliders;\n  frame.zones = state.zones;\n  frame.pointer = state.pointer;\n  frame.look = state.look;\n  frame.t = state.t;\n  frame.dt = state.dt;\n  frame.quality = state.quality;\n  frame.width = state.width;\n  frame.height = state.height;\n  return frame;\n}\n\nfunction draw(state: WindState) {\n  const { surface, look } = state;\n  surface.begin(state.windows, state.windowed, look);\n  surface.impacts(state.colliders, look, state.t);\n  if (state.glowWindows.length > 0) surface.clipLines(state.lineWindows, state.glowWindows);\n  state.painter.glow(fillFrame(state));\n  applyContacts(state, state.painter.contacts);\n  surface.end();\n}\n\nfunction moveAnchor(state: WindState, anchor: number) {\n  state.frame.shift = anchor - state.anchor;\n  state.anchor = anchor;\n}\n\nfunction stepHover(scene: WindScene, byId: Map<number, ColliderState>, { pointer, dt }: WindFrame) {\n  for (const item of scene.colliders) {\n    const collider = byId.get(item.id);\n    if (!collider) continue;\n    const over =\n      pointer !== null &&\n      pointer.x >= collider.left &&\n      pointer.x <= collider.left + collider.width &&\n      pointer.y >= collider.top &&\n      pointer.y <= collider.top + collider.height;\n    updateHover(collider, over || item.focused, dt);\n  }\n}\n\nfunction advance(state: WindState, dt: number) {\n  const sim = state.reduced ? 0 : dt;\n  state.t += sim;\n  state.dt = sim;\n  for (const collider of state.colliders) {\n    if (collider.visible) updateEnergy(collider, sim);\n  }\n}\n\nfunction createState(config: WindConfig, surface: WindSurface, look: Look): WindState {\n  const state: WindState = {\n    ...config,\n    surface,\n    colliders: [],\n    ids: new Map(),\n    look,\n    painter: flux.createPainter(surface),\n    frame: {\n      colliders: [],\n      look,\n      zones: [],\n      pointer: null,\n      t: 0,\n      dt: 0,\n      quality: config.quality,\n      width: config.width,\n      height: config.height,\n      shift: 0,\n    },\n    t: 0,\n    dt: 0,\n    pointer: null,\n    anchor: 0,\n    windowed: false,\n    windows: [],\n    lineWindows: [],\n    glowWindows: [],\n    zones: [],\n    effects: [],\n    feed: (id, fx, amount) => state.effects.push({ type: 'feed', id, fx, amount }),\n  };\n  return state;\n}\n\n/**\n * A reshaped collider's light: an outline with the same samples keeps the light\n * it holds, so a re-sent shape does not cut it off. Another outline starts dark.\n */\nfunction lightFor(collider: ColliderState | undefined, samples: number) {\n  if (collider && collider.held.length === samples) {\n    const { energy, tint, strikes, held } = collider;\n    return { energy, tint, strikes, held };\n  }\n  return {\n    energy: new Float32Array(samples),\n    tint: new Float32Array(samples),\n    strikes: [],\n    held: new Float32Array(samples),\n  };\n}\n\n/** The same simulation and drawing code runs in the worker and the fallback. */\nexport function createWindEngine(\n  canvas: HTMLCanvasElement | OffscreenCanvas,\n  ctx: FieldContext | WindSurface,\n) {\n  const surface = 'kind' in ctx ? ctx : createCanvasWindSurface(canvas, ctx);\n  let state: WindState | null = null;\n  let scene: WindScene = { colliders: [], windows: [], zones: [], windowed: false };\n  let byId = new Map<number, ColliderState>();\n\n  return {\n    configure(config: WindConfig) {\n      const look = buildLook(config.palette, config.ink);\n      state ??= createState(config, surface, look);\n      Object.assign(state, config);\n      state.look = look;\n      surface.configure(config, look);\n    },\n    scene(next: WindScene) {\n      scene = next;\n      const updated = new Map<number, ColliderState>();\n      for (const item of next.colliders) {\n        let collider = byId.get(item.id);\n        if (item.shape) {\n          const shape = {\n            ...item,\n            ...item.shape,\n            mask: null,\n            active: collider?.active ?? 0,\n            ...lightFor(collider, item.shape.outline?.count ?? 0),\n          };\n          collider = collider ? Object.assign(collider, shape) : shape;\n        }\n        if (!collider) continue;\n        Object.assign(collider, item);\n        updated.set(item.id, collider);\n      }\n      byId = updated;\n    },\n    paint(frame: WindFrame) {\n      if (!state || state.width <= 0 || state.height <= 0) return [];\n      state.effects = [];\n      state.colliders = [...byId.values()];\n      state.ids = new Map([...byId].map(([id, collider]) => [collider, id]));\n      state.zones = scene.zones;\n      state.windowed = scene.windowed;\n      moveAnchor(state, frame.anchor);\n      stepHover(scene, byId, frame);\n      const reveals = revealWindows(state.colliders, scene.windows);\n      state.lineWindows = scene.windows;\n      state.glowWindows = reveals.length > 0 ? glowWindows(state.colliders, scene.windows) : [];\n      state.windows = reveals.length > 0 ? [...scene.windows, ...reveals] : scene.windows;\n      state.pointer = stirring(state, frame.pointer);\n      advance(state, frame.dt);\n      draw(state);\n      return state.effects;\n    },\n    clear() {\n      surface.clear();\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/windEngine.ts"
    },
    {
      "path": "components/aurora/wind/windGeometry.ts",
      "content": "import { usesScrollTimeline } from './windMotion';\n\n/** Reuses document positions; viewport and scroll animations keep live bounds. */\nexport function createWindGeometry() {\n  let bounds = new WeakMap<Element, { rect: DOMRect; x: number; y: number }>();\n  let anchored = new WeakMap<Element, boolean>();\n  let movesWithScroll = false;\n  const followsViewport = (element: Element): boolean => {\n    const known = anchored.get(element);\n    if (known !== undefined) return known;\n    const position = getComputedStyle(element).position;\n    const scrolls =\n      typeof element.getAnimations === 'function' &&\n      element.getAnimations().some(usesScrollTimeline);\n    const follows =\n      position === 'fixed' ||\n      position === 'sticky' ||\n      scrolls ||\n      (element.parentElement !== null && followsViewport(element.parentElement));\n    anchored.set(element, follows);\n    return follows;\n  };\n  return {\n    clear() {\n      bounds = new WeakMap();\n      anchored = new WeakMap();\n      movesWithScroll = false;\n    },\n    get movesWithScroll() {\n      return movesWithScroll;\n    },\n    read(element: Element) {\n      if (followsViewport(element)) {\n        movesWithScroll = true;\n        return element.getBoundingClientRect();\n      }\n      let cached = bounds.get(element);\n      if (!cached) {\n        const rect = element.getBoundingClientRect();\n        cached = { rect, x: rect.x + window.scrollX, y: rect.y + window.scrollY };\n        bounds.set(element, cached);\n      }\n      cached.rect.x = cached.x - window.scrollX;\n      cached.rect.y = cached.y - window.scrollY;\n      return cached.rect;\n    },\n  };\n}\n\nexport type ReadWindRect = (element: Element) => DOMRect;\n",
      "type": "registry:component",
      "target": "components/aurora/wind/windGeometry.ts"
    },
    {
      "path": "components/aurora/wind/windMotion.ts",
      "content": "const MOTION_EVENTS = [\n  'transitionrun',\n  'transitionend',\n  'transitioncancel',\n  'animationstart',\n  'animationend',\n  'animationcancel',\n] as const;\n\nexport function usesScrollTimeline(animation: Animation) {\n  const timeline = animation.timeline;\n  return Boolean(timeline && timeline !== document.timeline);\n}\n\n/** Scroll timelines advance with input; endless UI animations never finish. */\nfunction isEventDriven(event: Event, target: Element) {\n  if (event.type !== 'animationstart' || typeof target.getAnimations !== 'function') return false;\n  const { animationName } = event as AnimationEvent;\n  return target\n    .getAnimations({ subtree: true })\n    .some(\n      (animation) =>\n        (animation as CSSAnimation).animationName === animationName &&\n        (animation.effect?.getTiming().iterations === Number.POSITIVE_INFINITY ||\n          usesScrollTimeline(animation)),\n    );\n}\n\n/** One transition or animation on a target: its property or name, pseudo-element and kind. */\nfunction motionKey(event: Event) {\n  const motion = event as TransitionEvent & AnimationEvent;\n  const transition = event.type.startsWith('transition');\n  return JSON.stringify([\n    transition ? motion.propertyName : motion.animationName,\n    motion.pseudoElement,\n    transition,\n  ]);\n}\n\n/**\n * Keeps cached bounds live until every relevant transition or animation\n * finishes. An endless animation only marks the bounds for one new measure.\n */\nexport function observeWindMotion(elements: () => Iterable<Element>, invalidate: () => void) {\n  const moving = new Map<Element, Set<string>>();\n  const onMotion = (event: Event) => {\n    const target = event.target;\n    if (!(target instanceof Element)) return;\n    const relevant = [...elements()].some(\n      (element) => target.contains(element) || element.contains(target),\n    );\n    if (!relevant && !moving.has(target)) return;\n    if (isEventDriven(event, target)) {\n      invalidate();\n      return;\n    }\n    const key = motionKey(event);\n    const active = moving.get(target) ?? new Set<string>();\n    if (event.type === 'transitionrun' || event.type === 'animationstart') active.add(key);\n    else active.delete(key);\n    if (active.size > 0) moving.set(target, active);\n    else moving.delete(target);\n    invalidate();\n  };\n  for (const type of MOTION_EVENTS) document.addEventListener(type, onMotion);\n  return {\n    active() {\n      for (const element of moving.keys()) if (!element.isConnected) moving.delete(element);\n      return moving.size > 0;\n    },\n    dispose() {\n      for (const type of MOTION_EVENTS) document.removeEventListener(type, onMotion);\n      moving.clear();\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/windMotion.ts"
    },
    {
      "path": "components/aurora/wind/windProtocol.ts",
      "content": "import type { Quality } from '@/lib/aurora/quality';\nimport type { FieldPalette, Rgb } from '../fieldTypes';\nimport type { ColliderState } from './colliders';\nimport type { StormZone } from './storm';\nimport type { WindWindow } from './windows';\n\nexport interface WindConfig {\n  width: number;\n  height: number;\n  dpr: number;\n  quality: Quality;\n  reduced: boolean;\n  palette: FieldPalette;\n  ink: Rgb;\n}\n\nexport interface ColliderSnapshot {\n  id: number;\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n  scale: number;\n  visible: boolean;\n  focused: boolean;\n  shape?: Pick<\n    ColliderState,\n    'outline' | 'shapeKey' | 'mode' | 'text' | 'disc' | 'unlit' | 'reach'\n  >;\n}\n\nexport interface WindScene {\n  colliders: ColliderSnapshot[];\n  windows: WindWindow[];\n  zones: StormZone<number>[];\n  windowed: boolean;\n}\n\n/** Where the layer sits and where the pointer is, in the wind canvas's px. Sent when they change. */\nexport interface WindView {\n  anchor: number;\n  /**\n   * In the wind canvas's px. `clientY` is the pointer's y in the viewport: it moves\n   * only with the pointer, not when the page scrolls. `touch`: a finger, which has\n   * no hover and stirs nothing.\n   */\n  pointer: { x: number; y: number; clientY?: number; touch?: boolean } | null;\n}\n\nexport interface WindFrame extends WindView {\n  now: number;\n  dt: number;\n}\n\nexport type WindEffect =\n  | { type: 'feed'; id: number; fx: number; amount: number }\n  | { type: 'strike'; id: number; x: number; y: number; amp: number };\n\n/**\n * `canvas` is the engine's drawing canvas. `targets`: the two page canvases\n * the worker shows its frames on (presentation 'worker'); without them it\n * posts each frame as a bitmap (presentation 'main').\n */\nexport type WindMessage =\n  | { type: 'init'; canvas: OffscreenCanvas; targets?: OffscreenCanvas[] }\n  | { type: 'configure'; config: WindConfig }\n  | { type: 'scene'; scene: WindScene }\n  | { type: 'view'; view: WindView; scene?: WindScene }\n  | { type: 'presented' }\n  | { type: 'pace'; active: boolean }\n  | { type: 'shown'; index: number; epoch: number }\n  | { type: 'run' }\n  | { type: 'clear' };\n\n/**\n * `frame`: a bitmap for the main thread to show (presentation 'main').\n * `show`: the target at `index` holds a frame at `anchor`; reveal it there.\n *   `epoch` counts the clears before it; the page drops a show from an earlier one.\n * `effects`: the effects of a frame the worker showed itself.\n */\nexport type WindReply =\n  | { type: 'ready' }\n  | { type: 'error' }\n  | { type: 'frame'; bitmap: ImageBitmap; anchor: number; effects: WindEffect[] }\n  | { type: 'show'; index: number; anchor: number; epoch: number }\n  | { type: 'effects'; effects: WindEffect[] };\n",
      "type": "registry:component",
      "target": "components/aurora/wind/windProtocol.ts"
    },
    {
      "path": "components/aurora/wind/windRenderer.ts",
      "content": "import { onAuroraPaint } from '@/lib/aurora/frameLoop';\nimport { WIND_ACTIVE_MS, windPresentation } from './presentation';\nimport type { createWindEngine } from './windEngine';\nimport type {\n  ColliderSnapshot,\n  WindConfig,\n  WindEffect,\n  WindMessage,\n  WindReply,\n  WindScene,\n  WindView,\n} from './windProtocol';\n\n/** Hides a canvas the worker shows frames on, keeping its layer, so its next frame is ready when it shows. */\nconst hide = (canvas: HTMLCanvasElement) => {\n  canvas.style.opacity = '0';\n};\n\n/**\n * Owns the wind canvas. With a worker, the worker steps the wind on its own\n * clock and the main thread only sends changes: size, scene and view. The\n * frames reach the screen as presentation.ts sets. A main-thread canvas\n * takes over, with the scene replayed, when workers or transfer are\n * unavailable or the worker fails. Only then does the page load the engine\n * and flux, so they stay out of the startup JS.\n */\nexport function createWindRenderer(host: HTMLElement, effects: (effects: WindEffect[]) => void) {\n  const mode = windPresentation(window.location.search);\n  let canvas = document.createElement('canvas');\n  host.append(canvas);\n  /** Presentation 'worker': the second canvas, stacked on the first. */\n  let spare: HTMLCanvasElement | null = null;\n  let worker: Worker | null = null;\n  let presentation: ImageBitmapRenderingContext | null = null;\n  let engine: ReturnType<typeof createWindEngine> | null = null;\n  let config: WindConfig | null = null;\n  let scene: WindScene | null = null;\n  let pendingScene: WindScene | null = null;\n  let shapes = new Map<number, ColliderSnapshot['shape']>();\n  let view: WindView = { anchor: 0, pointer: null };\n  let running = false;\n  let ready = false;\n  let contextLocked = false;\n  let disposed = false;\n  let timeout = 0;\n  let last = 0;\n  /** Presentation 'main': input came in the last WIND_ACTIVE_MS, so every frame shows. */\n  let active = false;\n  let stirredAt = 0;\n  /** Clears the worker has been sent; its shows carry the count they were made in. */\n  let epoch = 0;\n  let paceTimer = 0;\n  let stopFrame = () => {};\n  const post = (message: WindMessage, transfer: Transferable[] = []) =>\n    worker?.postMessage(message, transfer);\n  const fullScene = (next: WindScene) => ({\n    ...next,\n    colliders: next.colliders.map((item) => ({ ...item, shape: shapes.get(item.id) })),\n  });\n  const place = (anchor: number) => {\n    const transform = `translateY(${anchor}px)`;\n    if (host.style.transform !== transform) host.style.transform = transform;\n  };\n  const sizeCanvas = () => {\n    if (!config || !presentation) return;\n    const width = Math.round(config.width * config.dpr);\n    const height = Math.round(config.height * config.dpr);\n    if (canvas.width !== width) canvas.width = width;\n    if (canvas.height !== height) canvas.height = height;\n  };\n\n  const runFallback = () => {\n    stopFrame();\n    stopFrame = () => {};\n    if (!running || !engine) return;\n    last = performance.now();\n    stopFrame = onAuroraPaint((now) => {\n      const dt = Math.max(0, now - last) / 1000;\n      last = now;\n      if (engine) {\n        effects(engine.paint({ now, dt, ...view }));\n        place(view.anchor);\n      }\n    });\n  };\n\n  const fallback = () => {\n    window.clearTimeout(timeout);\n    worker?.terminate();\n    worker = null;\n    ready = false;\n    pendingScene = null;\n    if (disposed) return;\n    spare?.remove();\n    spare = null;\n    if (contextLocked) {\n      const replacement = document.createElement('canvas');\n      canvas.replaceWith(replacement);\n      canvas = replacement;\n      presentation = null;\n      contextLocked = false;\n    }\n    const target = canvas;\n    const ctx = target.getContext('2d');\n    if (!ctx) return;\n    import('./windEngine')\n      .then(({ createWindEngine }) => {\n        if (disposed) return;\n        engine = createWindEngine(target, ctx);\n        if (config) engine.configure(config);\n        if (scene) engine.scene(fullScene(scene));\n        runFallback();\n      })\n      .catch(() => {\n        // The engine chunk did not load: the page goes on without the wind.\n      });\n  };\n\n  const init = () => {\n    const offscreen = new OffscreenCanvas(1, 1);\n    if (mode === 'main') {\n      presentation = canvas.getContext('bitmaprenderer');\n      if (!presentation) throw new Error('Bitmap presentation unavailable');\n      contextLocked = true;\n      sizeCanvas();\n      post({ type: 'init', canvas: offscreen }, [offscreen]);\n      return;\n    }\n    const second = document.createElement('canvas');\n    second.style.position = 'absolute';\n    second.style.inset = '0';\n    host.append(second);\n    spare = second;\n    hide(canvas);\n    hide(second);\n    contextLocked = true;\n    const targets = [canvas.transferControlToOffscreen(), second.transferControlToOffscreen()];\n    post({ type: 'init', canvas: offscreen, targets }, [offscreen, ...targets]);\n  };\n\n  const connect = () => {\n    init();\n    if (active) post({ type: 'pace', active });\n    if (config) post({ type: 'configure', config });\n    if (scene) post({ type: 'scene', scene: fullScene(scene) });\n    pendingScene = null;\n    post({ type: 'view', view });\n    if (running) post({ type: 'run' });\n    ready = true;\n    window.clearTimeout(timeout);\n  };\n\n  const presentFrame = (data: Extract<WindReply, { type: 'frame' }>) => {\n    try {\n      if (disposed || !worker || !running || !presentation) return data.bitmap.close();\n      presentation.transferFromImageBitmap(data.bitmap);\n      place(data.anchor);\n      effects(data.effects);\n    } catch {\n      data.bitmap.close();\n      fallback();\n    } finally {\n      if (!disposed) post({ type: 'presented' });\n    }\n  };\n\n  /**\n   * Presentation 'worker': the target at `index` holds a frame at `anchor`.\n   * A show from before the last clear may name a canvas the worker has\n   * since painted at another anchor, so it is dropped.\n   */\n  const show = (index: number, anchor: number, from: number) => {\n    if (from !== epoch) return;\n    const screens = spare ? [canvas, spare] : [];\n    if (running && screens[index]) {\n      place(anchor);\n      screens[index].style.opacity = '';\n      hide(screens[1 - index]);\n    }\n    post({ type: 'shown', index, epoch });\n  };\n\n  const receive = ({ data }: MessageEvent<WindReply>) => {\n    if (data.type === 'frame') return presentFrame(data);\n    if (disposed || !worker) return;\n    if (data.type === 'effects') return effects(data.effects);\n    if (data.type === 'show') return show(data.index, data.anchor, data.epoch);\n    if (data.type === 'error') return fallback();\n    try {\n      connect();\n    } catch {\n      fallback();\n    }\n  };\n\n  try {\n    const disabled = new URLSearchParams(window.location.search).get('auroraWorker') === 'off';\n    if (disabled || typeof OffscreenCanvas === 'undefined' || typeof Worker === 'undefined')\n      fallback();\n    else {\n      worker = new Worker(new URL('./wind.worker.ts', import.meta.url), { type: 'module' });\n      worker.onmessage = receive;\n      worker.onerror = (event) => {\n        event.preventDefault();\n        fallback();\n      };\n      worker.onmessageerror = fallback;\n      timeout = window.setTimeout(fallback, 5000);\n    }\n  } catch {\n    fallback();\n  }\n\n  /** Back to the idle rate once WIND_ACTIVE_MS pass without input. */\n  const settlePace = () => {\n    const left = stirredAt + WIND_ACTIVE_MS - performance.now();\n    if (left > 0) {\n      paceTimer = window.setTimeout(settlePace, left);\n      return;\n    }\n    active = false;\n    if (ready) post({ type: 'pace', active });\n  };\n\n  return {\n    configure(next: WindConfig) {\n      config = next;\n      sizeCanvas();\n      if (ready) post({ type: 'configure', config });\n      else engine?.configure(config);\n    },\n    scene(next: WindScene) {\n      scene = next;\n      shapes = new Map(next.colliders.map((item) => [item.id, item.shape ?? shapes.get(item.id)]));\n      if (ready) pendingScene = pendingScene ? fullScene(next) : next;\n      else engine?.scene(scene);\n    },\n    view(next: WindView) {\n      if (\n        !pendingScene &&\n        view.anchor === next.anchor &&\n        view.pointer?.x === next.pointer?.x &&\n        view.pointer?.y === next.pointer?.y &&\n        view.pointer?.clientY === next.pointer?.clientY &&\n        view.pointer?.touch === next.pointer?.touch\n      )\n        return;\n      view = next;\n      if (ready) post({ type: 'view', view, scene: pendingScene ?? undefined });\n      pendingScene = null;\n    },\n    /** A scroll, pointer move or touch: presentation 'main' shows every frame for a while. */\n    stir() {\n      if (mode !== 'main') return;\n      stirredAt = performance.now();\n      if (active) return;\n      active = true;\n      if (ready) post({ type: 'pace', active });\n      paceTimer = window.setTimeout(settlePace, WIND_ACTIVE_MS);\n    },\n    /** Steps and paints the wind on the renderer's clock until cleared. */\n    run() {\n      if (running) return;\n      running = true;\n      if (ready) post({ type: 'run' });\n      else if (!worker) runFallback();\n    },\n    clear() {\n      running = false;\n      presentation?.transferFromImageBitmap(null);\n      if (spare) for (const screen of [canvas, spare]) hide(screen);\n      if (ready) {\n        epoch += 1;\n        post({ type: 'clear' });\n      } else if (!worker) {\n        stopFrame();\n        engine?.clear();\n      }\n    },\n    dispose() {\n      disposed = true;\n      stopFrame();\n      window.clearTimeout(timeout);\n      window.clearTimeout(paceTimer);\n      worker?.terminate();\n      canvas.remove();\n      spare?.remove();\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/windRenderer.ts"
    },
    {
      "path": "components/aurora/wind/windSim.ts",
      "content": "import { onAuroraPaint } from '@/lib/aurora/frameLoop';\nimport { createFrameTask } from '@/lib/aurora/frameTask';\nimport { canvasDpr, QUALITY_ATTR, readQuality } from '@/lib/aurora/quality';\nimport { feedStorm } from '../curtainStorm';\nimport { readPalette, readTriplet } from '../fieldStyle';\nimport type { FieldPalette, Rgb } from '../fieldTypes';\nimport {\n  COLLIDER_SELECTOR,\n  type Collider,\n  type Frame,\n  invalidateColliderShapes,\n  measureCollider,\n  scanColliders,\n} from './colliders';\nimport { WIND_STRIKE_EVENT } from './events';\nimport { findStormCanvases, measureStormZones } from './storm';\nimport { createWindGeometry } from './windGeometry';\nimport { observeWindMotion } from './windMotion';\nimport { findSurfaces, measureWindows } from './windows';\nimport type { ColliderSnapshot, WindEffect } from './windProtocol';\nimport { createWindRenderer } from './windRenderer';\n\nconst OVERSCAN = 200;\nconst IDLE_CHECK_MS = 1000;\n/** Colliders can appear or go without a geometry event, so the page is scanned this often. */\nconst RESCAN_MS = 1000;\n\n/**\n * Measures changed DOM geometry; the renderer owns wind physics, pixels and\n * time. The main thread works only on events: a scroll, a pointer move, a\n * geometry change, colliders in motion and the periodic rescan. Between them\n * the worker steps the wind alone.\n */\nexport function startWind(host: HTMLElement) {\n  let known = new Map<Element, Collider>();\n  let surfaces = findSurfaces(document);\n  let canvases = findStormCanvases(document);\n  const ids = new WeakMap<Element, number>();\n  let elements = new Map<number, Element>();\n  let nextId = 0;\n  let shapeKeys = new Map<number, string>();\n  let observed = new Set<Element>();\n  let dirty = true;\n  /** A scroll event came since the last update. */\n  let scrolled = true;\n  let lastFrame: Frame | null = null;\n  const bounds = createWindGeometry();\n  let width = 0;\n  let height = 0;\n  let documentHeight = 0;\n  let anchor = 0;\n  let configKey = '';\n  let palette: FieldPalette | undefined;\n  let ink: Rgb = [226, 234, 230];\n  let idle = 0;\n  let rescan = 0;\n  let live = false;\n  let updating = false;\n  let pointer: { x: number; y: number; touch: boolean } | null = null;\n  let hoverActive = false;\n  let focusActive = false;\n  let visibilityReady = false;\n  const visibleSurfaces = new Set<Element>();\n  const observedSurfaces = new Set<Element>();\n  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');\n  let stopFrame = () => {};\n  /** Geometry updates run on the frame loop only while something changes. */\n  const stopUpdates = () => {\n    stopFrame();\n    stopFrame = () => {};\n    updating = false;\n  };\n  // Declared below; events call it to measure and send the view again.\n  let wakeUpdates = () => {};\n  const invalidate = () => {\n    dirty = true;\n    bounds.clear();\n    wakeUpdates();\n  };\n  const invalidateShapes = () => {\n    invalidateColliderShapes(known);\n    invalidate();\n  };\n  const motion = observeWindMotion(() => observed, invalidate);\n  const idFor = (element: Element) => {\n    let id = ids.get(element);\n    if (id === undefined) {\n      id = ++nextId;\n      ids.set(element, id);\n    }\n    elements.set(id, element);\n    return id;\n  };\n  const applyEffects = (effects: WindEffect[]) => {\n    for (const effect of effects) {\n      const element = elements.get(effect.id);\n      if (!element) continue;\n      if (effect.type === 'feed') feedStorm(element, effect.fx, effect.amount);\n      else\n        element.dispatchEvent(\n          new CustomEvent(WIND_STRIKE_EVENT, {\n            detail: { x: effect.x, y: effect.y, amp: effect.amp },\n          }),\n        );\n    }\n  };\n  const renderer = createWindRenderer(host, applyEffects);\n  // Wake geometry before it reaches the canvas overscan and light rays.\n  const nearby = new Set<Element>();\n  const geometryVisibility = new IntersectionObserver(\n    (entries) => {\n      let changed = false;\n      for (const entry of entries) {\n        if (nearby.has(entry.target) === entry.isIntersecting) continue;\n        if (entry.isIntersecting) nearby.add(entry.target);\n        else nearby.delete(entry.target);\n        changed = true;\n      }\n      if (changed) {\n        dirty = true;\n        wakeUpdates();\n      }\n    },\n    { rootMargin: `${OVERSCAN * 2}px` },\n  );\n  const sizes = new ResizeObserver((entries) => {\n    for (const entry of entries) {\n      const collider = known.get(entry.target);\n      if (collider) collider.revision += 1;\n    }\n    if (entries.some((entry) => entry.target === host)) resize();\n    invalidate();\n  });\n  sizes.observe(host);\n  sizes.observe(document.body);\n  const visibility = new IntersectionObserver((entries) => {\n    visibilityReady = true;\n    for (const entry of entries) {\n      if (entry.isIntersecting) visibleSurfaces.add(entry.target);\n      else visibleSurfaces.delete(entry.target);\n    }\n    start();\n  });\n  const observeSurfaces = () => {\n    const next = new Set<Element>(surfaces.map((surface) => surface.element));\n    for (const element of observedSurfaces) {\n      if (next.has(element)) continue;\n      visibility.unobserve(element);\n      visibleSurfaces.delete(element);\n      observedSurfaces.delete(element);\n    }\n    for (const element of next) {\n      if (observedSurfaces.has(element)) continue;\n      visibility.observe(element);\n      observedSurfaces.add(element);\n    }\n  };\n\n  const scan = () => {\n    const previous = known;\n    known = scanColliders(document, known);\n    const nextSurfaces = findSurfaces(document);\n    const nextCanvases = findStormCanvases(document);\n    if (\n      known.size !== previous.size ||\n      [...known.keys()].some((element) => !previous.has(element)) ||\n      nextSurfaces.some((surface, index) => surface.element !== surfaces[index]?.element) ||\n      nextSurfaces.length !== surfaces.length ||\n      nextCanvases.some((canvas, index) => canvas !== canvases[index]) ||\n      nextCanvases.length !== canvases.length\n    )\n      dirty = true;\n    surfaces = nextSurfaces;\n    canvases = nextCanvases;\n    observeSurfaces();\n    const nextObserved = new Set([\n      ...known.keys(),\n      ...canvases,\n      ...surfaces.map((surface) => surface.element),\n    ]);\n    for (const element of observed) {\n      if (nextObserved.has(element)) continue;\n      sizes.unobserve(element);\n      geometryVisibility.unobserve(element);\n      nearby.delete(element);\n    }\n    for (const element of nextObserved) {\n      if (observed.has(element)) continue;\n      sizes.observe(element);\n      geometryVisibility.observe(element);\n    }\n    observed = nextObserved;\n    if (dirty) wakeUpdates();\n  };\n\n  const configure = () => {\n    const style = getComputedStyle(host);\n    palette = readPalette(host, palette);\n    ink = readTriplet(style, '--au-ink', ink);\n    const next = {\n      width,\n      height,\n      dpr: Math.min(\n        canvasDpr(readQuality(), Math.min(width, window.innerHeight) < 768 ? 1.5 : 2),\n        Math.sqrt(8_000_000 / (width * height)),\n      ),\n      quality: readQuality(),\n      reduced: reducedMotion.matches,\n      palette,\n      ink,\n    };\n    const key = JSON.stringify(next);\n    if (key === configKey) return;\n    configKey = key;\n    renderer.configure(next);\n  };\n  function resize() {\n    const nextHeight = `${window.innerHeight + OVERSCAN * 2}px`;\n    if (host.style.height !== nextHeight) host.style.height = nextHeight;\n    const rect = host.getBoundingClientRect();\n    const previousWidth = width;\n    const previousHeight = height;\n    width = host.clientWidth || rect.width;\n    height = host.clientHeight || rect.height;\n    configure();\n    if (width !== previousWidth || height !== previousHeight) invalidateShapes();\n    else invalidate();\n  }\n\n  /** The wind shows only through the sheets. */\n  const showsWind = () => surfaces.length > 0;\n\n  const measure = (frame: Frame) => {\n    if (motion.active()) bounds.clear();\n    const colliders: ColliderSnapshot[] = [];\n    const nextKeys = new Map<number, string>();\n    elements = new Map();\n    for (const element of nearby) {\n      const collider = known.get(element);\n      if (!collider) continue;\n      measureCollider(collider, frame, bounds.read);\n      if (!collider.visible) continue;\n      const id = idFor(collider.element);\n      const changed = shapeKeys.get(id) !== collider.shapeKey;\n      nextKeys.set(id, collider.shapeKey);\n      const {\n        left,\n        top,\n        width,\n        height,\n        scale,\n        visible,\n        outline,\n        shapeKey,\n        mode,\n        text,\n        disc,\n        unlit,\n        reach,\n      } = collider;\n      colliders.push({\n        id,\n        left,\n        top,\n        width,\n        height,\n        scale,\n        visible,\n        focused: collider.element.matches(':focus-within'),\n        shape: changed ? { outline, shapeKey, mode, text, disc, unlit, reach } : undefined,\n      });\n    }\n    shapeKeys = nextKeys;\n    const zones = measureStormZones(\n      canvases.filter((canvas) => nearby.has(canvas)),\n      frame,\n      bounds.read,\n    ).map((zone) => ({\n      ...zone,\n      canvas: idFor(zone.canvas),\n    }));\n    renderer.scene({\n      colliders,\n      zones,\n      windows: measureWindows(\n        surfaces.filter((surface) => nearby.has(surface.element)),\n        frame,\n        bounds.read,\n      ),\n      windowed: showsWind(),\n    });\n    documentHeight = document.documentElement.scrollHeight;\n    dirty = false;\n  };\n\n  const isLive = () =>\n    !document.hidden &&\n    !reducedMotion.matches &&\n    readQuality() !== 'still' &&\n    (!visibilityReady || visibleSurfaces.size > 0 || hoverActive || focusActive);\n  const wake = () => {\n    if (!isLive()) return;\n    scan();\n    if (showsWind()) start();\n    else idle = window.setTimeout(wake, IDLE_CHECK_MS);\n  };\n  const sleep = () => {\n    live = false;\n    stopUpdates();\n    window.clearInterval(rescan);\n    renderer.clear();\n    idle = window.setTimeout(wake, IDLE_CHECK_MS);\n  };\n  /*\n   * Reading the scroll position updates style. With a CSS animation running on\n   * the page (the logo marquee), that costs a rendered frame. So an update\n   * reuses the last frame, and the position is read only after a scroll\n   * event, a geometry change or while colliders move.\n   */\n  const followScroll = () => {\n    if (lastFrame && !dirty && !scrolled && !motion.active()) return lastFrame;\n    scrolled = false;\n    if (dirty) documentHeight = document.documentElement.scrollHeight;\n    const previousAnchor = anchor;\n    const nextAnchor = Math.min(\n      Math.max(0, documentHeight - height),\n      Math.max(0, window.scrollY - OVERSCAN),\n    );\n    const offset = window.scrollY - anchor;\n    if (dirty || !lastFrame || offset < OVERSCAN * 0.75 || offset > OVERSCAN * 1.25)\n      anchor = nextAnchor;\n    const frame = { x: 0, y: anchor - window.scrollY, k: 1, width, height };\n    if (dirty || motion.active() || anchor !== previousAnchor || bounds.movesWithScroll)\n      measure(frame);\n    lastFrame = frame;\n    return frame;\n  };\n  const update = () => {\n    if (width === 0 || height === 0) return stopUpdates();\n    if (!showsWind()) return sleep();\n    const frame = followScroll();\n    renderer.view({\n      anchor,\n      pointer: pointer && {\n        x: pointer.x,\n        y: pointer.y - frame.y,\n        clientY: pointer.y,\n        touch: pointer.touch,\n      },\n    });\n    if (!dirty && !scrolled && !motion.active()) stopUpdates();\n  };\n  wakeUpdates = () => {\n    if (!live || updating) return;\n    updating = true;\n    stopFrame = onAuroraPaint(update);\n  };\n  const start = () => {\n    stopUpdates();\n    window.clearTimeout(idle);\n    window.clearInterval(rescan);\n    live = false;\n    if (!isLive()) return renderer.clear();\n    resize();\n    scan();\n    live = true;\n    invalidate();\n    renderer.run();\n    rescan = window.setInterval(scan, RESCAN_MS);\n  };\n\n  const onPointer = (event: PointerEvent) => {\n    pointer = { x: event.clientX, y: event.clientY, touch: event.pointerType === 'touch' };\n    renderer.stir();\n    wakeUpdates();\n    const active =\n      event.target instanceof Element && Boolean(event.target.closest(COLLIDER_SELECTOR));\n    if (active !== hoverActive) {\n      hoverActive = active;\n      if (visibilityReady && visibleSurfaces.size === 0) start();\n    }\n  };\n  const onLeave = () => {\n    pointer = null;\n    wakeUpdates();\n    hoverActive = false;\n    if (visibilityReady && visibleSurfaces.size === 0) start();\n  };\n  const onFocus = () => {\n    invalidate();\n    const active = Boolean(document.activeElement?.closest(COLLIDER_SELECTOR));\n    if (active !== focusActive) {\n      focusActive = active;\n      if (visibilityReady && visibleSurfaces.size === 0) start();\n    }\n  };\n  const restyle = createFrameTask(() => {\n    invalidateShapes();\n    start();\n  });\n  const styles = new MutationObserver(restyle.schedule);\n  // The theme class sits on <body> on this site and on <html> (.dark) in a shadcn app.\n  styles.observe(document.documentElement, {\n    attributes: true,\n    attributeFilter: [QUALITY_ATTR, 'class'],\n  });\n  styles.observe(document.body, { attributes: true, attributeFilter: ['class'] });\n  const onScroll = (event: Event) => {\n    if (event.target !== document) invalidate();\n    else {\n      scrolled = true;\n      renderer.stir();\n      wakeUpdates();\n    }\n  };\n  const geometry = new MutationObserver((records) => {\n    const changes = records.filter((record) => !host.contains(record.target));\n    if (changes.length === 0) return;\n    for (const record of changes) invalidateColliderShapes(known, record.target);\n    invalidate();\n  });\n  geometry.observe(document.body, {\n    childList: true,\n    characterData: true,\n    subtree: true,\n    attributes: true,\n    attributeFilter: ['class', 'style', 'data-au-disc'],\n  });\n  window.addEventListener('pointermove', onPointer, { passive: true });\n  window.addEventListener('resize', resize);\n  document.addEventListener('scroll', onScroll, { passive: true, capture: true });\n  document.documentElement.addEventListener('pointerleave', onLeave);\n  document.addEventListener('focusin', onFocus);\n  document.addEventListener('focusout', onFocus);\n  document.addEventListener('visibilitychange', start);\n  reducedMotion.addEventListener('change', start);\n  document.fonts.addEventListener('loadingdone', invalidateShapes);\n  start();\n  return () => {\n    live = false;\n    stopUpdates();\n    window.clearTimeout(idle);\n    window.clearInterval(rescan);\n    sizes.disconnect();\n    geometryVisibility.disconnect();\n    visibility.disconnect();\n    styles.disconnect();\n    restyle.cancel();\n    geometry.disconnect();\n    renderer.dispose();\n    motion.dispose();\n    window.removeEventListener('pointermove', onPointer);\n    window.removeEventListener('resize', resize);\n    document.removeEventListener('scroll', onScroll, true);\n    document.documentElement.removeEventListener('pointerleave', onLeave);\n    document.removeEventListener('focusin', onFocus);\n    document.removeEventListener('focusout', onFocus);\n    document.removeEventListener('visibilitychange', start);\n    reducedMotion.removeEventListener('change', start);\n    document.fonts.removeEventListener('loadingdone', invalidateShapes);\n  };\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/windSim.ts"
    },
    {
      "path": "components/aurora/wind/windSurface.ts",
      "content": "import type { ColliderState } from './colliders';\nimport type { Look } from './render';\nimport type { Lines } from './styles/fluxLines';\nimport type { StyleFrame } from './styles/types';\nimport type { WindWindow } from './windows';\nimport type { WindConfig } from './windProtocol';\n\nexport type WindCanvas = HTMLCanvasElement | OffscreenCanvas;\nexport type WindSprite = HTMLCanvasElement | OffscreenCanvas;\n\nexport interface WindSurface {\n  kind: 'canvas2d' | 'webgl2' | 'worker';\n  configure(config: WindConfig, look: Look): void;\n  begin(windows: WindWindow[], windowed: boolean, look: Look): void;\n  impacts(colliders: ColliderState[], look: Look, t: number): void;\n  /**\n   * Where the lines that follow show: in full inside `full` (the sheets), and\n   * only as their edge glow inside `glow` (round hovered cards on the plain ground).\n   */\n  clipLines(full: WindWindow[], glow: WindWindow[]): void;\n  lines(lines: Lines, frame: StyleFrame, page: number): void;\n  end(): void;\n  clear(): void;\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/windSurface.ts"
    },
    {
      "path": "components/aurora/wind/windows.ts",
      "content": "import type { FieldContext } from '../fieldTypes';\n/**\n * Where the wind shows. On a page with section sheets (components/SectionBand),\n * each sky and second-ground sheet is a window onto its own storm: the hero,\n * the alt sections and the close. The plain sections between them stay calm.\n * A hovered collider on the plain ground opens a small window of its own, so the\n * wind strikes it and its edge lights. The lines stay clipped to the sheets:\n * round the collider the ground shows only their glow along its edge, as on a\n * card in a sheet, and no lines across the ground (windEngine). One inside a sheet needs\n * no window, and its window would spill past the sheet's edge. Outside every\n * window no swell strikes a collider. On a page without sheets the wind\n * sleeps (windSim).\n */\nimport { ACTIVE_MIN, type ColliderState as Collider, type Frame } from './colliders';\nimport { TOUCH } from './styles/fluxTuning';\nimport type { ReadWindRect } from './windGeometry';\n\nconst SURFACES = \":is([data-au-band='sky'], [data-au-band='alt'])\";\n/** How far a hovered collider's window reaches past its box, at full hover. */\nconst REVEAL_PX = 110;\nconst REVEAL_RADIUS = 48;\n/** How far a hovered collider's edge glow reaches past its box: lines glow within TOUCH px of an edge. */\nconst GLOW_PX = TOUCH + 6;\nconst GLOW_RADIUS = 24;\n\nexport interface WindWindow {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n  r: number;\n}\n\ninterface Surface {\n  element: HTMLElement;\n  radius: number;\n}\n\n/** The sheets under root. */\nexport function findSurfaces(root: ParentNode): Surface[] {\n  return [...root.querySelectorAll<HTMLElement>(SURFACES)].map((element) => ({\n    element,\n    radius: Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0,\n  }));\n}\n\n/** The surfaces on the canvas this frame. */\nexport function measureWindows(\n  surfaces: Surface[],\n  frame: Frame,\n  readRect: ReadWindRect = (element) => element.getBoundingClientRect(),\n) {\n  const windows: WindWindow[] = [];\n  for (const { element, radius } of surfaces) {\n    const rect = readRect(element);\n    const y = (rect.top - frame.y) * frame.k;\n    const bottom = (rect.bottom - frame.y) * frame.k;\n    if (bottom < 0 || y > frame.height) continue;\n    const x = (rect.left - frame.x) * frame.k;\n    windows.push({ x, y, w: rect.width * frame.k, h: bottom - y, r: radius * frame.k });\n  }\n  return windows;\n}\n\nfunction aroundHovered(\n  colliders: Collider[],\n  sheets: WindWindow[],\n  pad: (c: Collider) => number,\n  r: number,\n) {\n  const windows: WindWindow[] = [];\n  for (const collider of colliders) {\n    if (!collider.visible || collider.active < ACTIVE_MIN) continue;\n    const centreX = collider.left + collider.width / 2;\n    const centreY = collider.top + collider.height / 2;\n    if (inWindow(sheets, centreX, centreY)) continue;\n    const reach = pad(collider);\n    windows.push({\n      x: collider.left - reach,\n      y: collider.top - reach,\n      w: collider.width + reach * 2,\n      h: collider.height + reach * 2,\n      r,\n    });\n  }\n  return windows;\n}\n\n/** A window round each hovered collider on the plain ground: one in a sheet already shows its wind. */\nexport const revealWindows = (colliders: Collider[], sheets: WindWindow[]) =>\n  aroundHovered(colliders, sheets, (collider) => REVEAL_PX * collider.active, REVEAL_RADIUS);\n\n/** The band round each of those colliders where the lines' edge glow shows, and nothing else of them. */\nexport const glowWindows = (colliders: Collider[], sheets: WindWindow[]) =>\n  aroundHovered(colliders, sheets, () => GLOW_PX, GLOW_RADIUS);\n\nexport function inWindow(windows: WindWindow[], x: number, y: number) {\n  for (const w of windows) {\n    if (x >= w.x && x <= w.x + w.w && y >= w.y && y <= w.y + w.h) return true;\n  }\n  return false;\n}\n\n/** Clips everything drawn after it to the windows, in device px. Pair it with ctx.restore(). */\nexport function clipToWindows(ctx: FieldContext, windows: WindWindow[], dpr: number) {\n  ctx.save();\n  ctx.setTransform(1, 0, 0, 1, 0, 0);\n  ctx.beginPath();\n  for (const w of windows) ctx.roundRect(w.x * dpr, w.y * dpr, w.w * dpr, w.h * dpr, w.r * dpr);\n  ctx.clip();\n}\n",
      "type": "registry:component",
      "target": "components/aurora/wind/windows.ts"
    }
  ],
  "css": {
    ".au-wind-layer": {
      "position": "absolute",
      "top": "0",
      "left": "0",
      "width": "100%",
      "will-change": "transform",
      "z-index": "-1",
      "pointer-events": "none"
    },
    ".au-wind-layer canvas": {
      "display": "block",
      "width": "100%",
      "height": "100%"
    },
    ":root, .dark": {
      "--au-ink": "var(--foreground)"
    },
    "@layer components": {
      "body:has(> .au-wind-layer) [data-slot='section']:is([data-au-band='alt'], [data-au-band='sky'])": {
        "isolation": "auto",
        "background": "transparent"
      },
      "body:has(> .au-wind-layer) [data-slot='section']:is([data-au-band='alt'], [data-au-band='sky'])::before": {
        "content": "''",
        "position": "absolute",
        "inset": "0",
        "z-index": "-2",
        "border-radius": "inherit",
        "background": "var(--background)",
        "pointer-events": "none"
      },
      "body:has(> .au-wind-layer) [data-slot='section'][data-au-band='sky']::before": {
        "background": "var(--sky-grain) 0 0 / 180px 180px, var(--sky)"
      }
    },
    "html:is([data-au-quality='lite'], [data-au-quality='still']) :is(.glass, [data-surface='glass'])": {
      "backdrop-filter": "none"
    }
  },
  "type": "registry:component"
}