{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "engine",
  "title": "Engine",
  "description": "The shared WebGL2 render engine every dotgrain component uses.",
  "files": [
    {
      "path": "registry/engine/context.ts",
      "content": "import { glyphGrid, render } from \"./cpu\";\nimport { FIELD_IDS, fieldParams } from \"./fields\";\nimport { clearGlyphAtlases, DEFAULT_FONT, GLYPH_ASPECT, glyphAtlas, rampGlyphs } from \"./glyphs\";\nimport { resolvePalette, type RGB } from \"./palettes\";\nimport { thresholdMap } from \"./patterns\";\nimport { ASCII, clearPrograms, DITHER, getProgram } from \"./programs\";\nimport { getScheduler } from \"./scheduler\";\nimport type { Frame } from \"./types\";\n\nconst SHARED = [\"uSize\", \"uTime\", \"uField\", \"uParams\", \"uValues\", \"uCursor\", \"uContrast\", \"uThreshold\", \"uPalette\", \"uLevels\"];\nconst DITHER_UNIFORMS = [...SHARED, \"uPattern\"];\nconst ASCII_UNIFORMS = [...SHARED, \"uCell\", \"uAspect\", \"uGlyphs\", \"uAtlas\"];\n\nlet gl: WebGL2RenderingContext | null | undefined;\nlet glCanvas: HTMLCanvasElement;\nlet textures = new WeakMap<object, WebGLTexture>();\nconst LIVE = {};\n\n// One WebGL2 context per page, shared by every view (browsers cap live contexts at ~16).\nfunction getGL(): WebGL2RenderingContext | null {\n  if (gl === undefined) {\n    glCanvas = document.createElement(\"canvas\");\n    // Dev-only escape hatch to see the Canvas2D path: `?cpu=1`.\n    const forceCPU = process.env.NODE_ENV !== \"production\" && new URLSearchParams(location.search).has(\"cpu\");\n    gl = forceCPU ? null : glCanvas.getContext(\"webgl2\", { antialias: false, depth: false, stencil: false, premultipliedAlpha: false });\n    // While lost, views draw on the CPU; on restore, rebuild programs and textures lazily and redraw everything.\n    glCanvas.addEventListener(\"webglcontextlost\", (e) => {\n      e.preventDefault();\n      getScheduler().invalidateAll();\n    });\n    glCanvas.addEventListener(\"webglcontextrestored\", () => {\n      if (gl) clearPrograms(gl);\n      textures = new WeakMap();\n      getScheduler().invalidateAll();\n    });\n    // Atlases drawn with a fallback font are stale once a web font arrives.\n    document.fonts?.addEventListener(\"loadingdone\", () => {\n      clearGlyphAtlases();\n      getScheduler().invalidateAll();\n    });\n  }\n  return gl;\n}\n\n/** Upload a texture once per source object (threshold map or glyph atlas). */\nfunction texture(gl: WebGL2RenderingContext, key: object, upload: () => void): WebGLTexture {\n  let tex = textures.get(key);\n  if (!tex) {\n    tex = gl.createTexture()!;\n    gl.bindTexture(gl.TEXTURE_2D, tex);\n    upload();\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);\n    textures.set(key, tex);\n  }\n  return tex;\n}\n\n/**\n * Render a frame into the view's own 2D canvas. Dither renders one pixel per mark (CSS upscales it);\n * ASCII renders each cell at device resolution so glyphs stay sharp.\n */\nexport function draw(canvas: HTMLCanvasElement, frame: Frame): void {\n  const { width: cols, height: rows, source, tokens } = frame;\n  const ascii = tokens.mode === \"ascii\";\n  const cellH = ascii ? Math.max(1, Math.round(tokens.cell * (frame.dpr ?? 1))) : 1;\n  const cellW = ascii ? Math.max(1, Math.round(cellH * GLYPH_ASPECT)) : 1;\n  const w = cols * cellW, h = rows * cellH;\n  if (canvas.width !== w) canvas.width = w;\n  if (canvas.height !== h) canvas.height = h;\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return;\n  const g = getGL();\n  if (source.kind !== \"field\" && !frame.values) return;\n  // Diffused frames are already quantized on the CPU, so they skip the GPU.\n  if (!g || g.isContextLost() || frame.indices) {\n    if (ascii) drawGlyphs(ctx, frame, cellW, cellH);\n    else ctx.putImageData(new ImageData(render(frame), cols, rows), 0, 0);\n    return;\n  }\n  if (glCanvas.width < w) glCanvas.width = w;\n  if (glCanvas.height < h) glCanvas.height = h;\n\n  const { program, uniforms: u } = getProgram(g, ascii ? ASCII : DITHER, ascii ? ASCII_UNIFORMS : DITHER_UNIFORMS);\n  const palette = resolvePalette(tokens.palette);\n  g.useProgram(program);\n  g.viewport(0, 0, w, h);\n  g.uniform2f(u.uSize, cols, rows);\n  g.uniform1f(u.uTime, frame.time);\n  if (source.kind === \"field\") {\n    g.uniform1i(u.uField, FIELD_IDS[source.field]);\n    g.uniform4fv(u.uParams, fieldParams(source.field, source.params));\n  } else {\n    const values = frame.values!;\n    const upload = () => g.texImage2D(g.TEXTURE_2D, 0, g.R32F, cols, rows, 0, g.RED, g.FLOAT, values);\n    g.uniform1i(u.uField, -1);\n    g.activeTexture(g.TEXTURE1);\n    // Image and text values are cached per size; video frames, scrambles and reveals change every draw, so they share one texture re-uploaded each time.\n    const live = source.kind === \"video\" || (source.kind === \"text\" && !!(source.scramble || source.reveal));\n    g.bindTexture(g.TEXTURE_2D, texture(g, live ? LIVE : values, upload));\n    if (live) upload();\n  }\n  g.uniform1i(u.uValues, 1);\n  g.uniform4fv(u.uCursor, frame.cursor ?? [0, 0, 0, 0]);\n  g.uniform1f(u.uContrast, tokens.contrast);\n  g.uniform1f(u.uThreshold, tokens.threshold);\n  g.uniform3fv(u.uPalette, [0, 1, 2, 3].flatMap((i) => palette[Math.min(i, palette.length - 1)]));\n  g.uniform1i(u.uLevels, palette.length);\n  g.activeTexture(g.TEXTURE0);\n  if (ascii) {\n    const glyphs = rampGlyphs(tokens.glyphs);\n    const atlas = glyphAtlas(glyphs, cellW, cellH, frame.font ?? DEFAULT_FONT);\n    g.bindTexture(g.TEXTURE_2D, texture(g, atlas, () => g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, g.RGBA, g.UNSIGNED_BYTE, atlas)));\n    g.uniform2f(u.uCell, cellW, cellH);\n    g.uniform1f(u.uAspect, (cols * GLYPH_ASPECT) / rows);\n    g.uniform1i(u.uGlyphs, glyphs.length);\n    g.uniform1i(u.uAtlas, 0);\n  } else {\n    const map = thresholdMap(tokens.pattern);\n    g.bindTexture(g.TEXTURE_2D, texture(g, map, () => g.texImage2D(g.TEXTURE_2D, 0, g.R32F, map.size, map.size, 0, g.RED, g.FLOAT, map.data)));\n    g.uniform1i(u.uPattern, 0);\n  }\n  g.drawArrays(g.TRIANGLES, 0, 3);\n  // The viewport sits at the bottom-left of the shared canvas.\n  ctx.drawImage(glCanvas, 0, glCanvas.height - h, w, h, 0, 0, w, h);\n}\n\nconst tints = new WeakMap<HTMLCanvasElement, Map<string, HTMLCanvasElement>>();\n\n/** The atlas recoloured to one palette colour (cached), so the CPU path blits glyphs like the shader. */\nfunction tinted(atlas: HTMLCanvasElement, [r, g, b]: RGB): HTMLCanvasElement {\n  let byColor = tints.get(atlas);\n  if (!byColor) tints.set(atlas, (byColor = new Map()));\n  const fill = `rgb(${r},${g},${b})`;\n  let tint = byColor.get(fill);\n  if (!tint) {\n    tint = document.createElement(\"canvas\");\n    tint.width = atlas.width;\n    tint.height = atlas.height;\n    const t = tint.getContext(\"2d\")!;\n    t.drawImage(atlas, 0, 0);\n    t.globalCompositeOperation = \"source-in\";\n    t.fillStyle = fill;\n    t.fillRect(0, 0, tint.width, tint.height);\n    byColor.set(fill, tint);\n  }\n  return tint;\n}\n\n/** Canvas2D twin of the ASCII shader: background fill, then one tinted glyph per cell. */\nexport function drawGlyphs(ctx: CanvasRenderingContext2D, frame: Frame, cellW: number, cellH: number): void {\n  const { width: cols, height: rows, tokens } = frame;\n  const palette = resolvePalette(tokens.palette);\n  const atlas = glyphAtlas(rampGlyphs(tokens.glyphs), cellW, cellH, frame.font ?? DEFAULT_FONT);\n  const tints = palette.map((c) => tinted(atlas, c));\n  const { glyphs, bands } = glyphGrid(frame);\n  const [r, g, b] = palette[0];\n  ctx.fillStyle = `rgb(${r},${g},${b})`;\n  ctx.fillRect(0, 0, cols * cellW, rows * cellH);\n  for (let i = 0; i < glyphs.length; i++) {\n    const x = (i % cols) * cellW, y = Math.floor(i / cols) * cellH;\n    ctx.drawImage(tints[bands[i]], glyphs[i] * cellW, 0, cellW, cellH, x, y, cellW, cellH);\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/context.ts"
    },
    {
      "path": "registry/engine/cpu.ts",
      "content": "import { cursorBump, field, fieldParams } from \"./fields\";\nimport { GLYPH_ASPECT, glyphCell, rampGlyphs } from \"./glyphs\";\nimport { resolvePalette } from \"./palettes\";\nimport { level, thresholdMap } from \"./patterns\";\nimport type { Frame } from \"./types\";\n\n/** Calls back with the source value (0–1 plus cursor) at every mark's centre. */\nfunction eachValue(frame: Frame, aspect: number, callback: (x: number, y: number, v: number) => void): void {\n  const { source, width, height, time, cursor = [0, 0, 0, 0], values } = frame;\n  if (source.kind !== \"field\" && !values) throw new Error(`dotgrain: ${source.kind} frames need \\`values\\``);\n  const params = source.kind === \"field\" ? fieldParams(source.field, source.params) : [];\n  for (let y = 0; y < height; y++) {\n    for (let x = 0; x < width; x++) {\n      const u = (x + 0.5) / width, w = (y + 0.5) / height;\n      const v = source.kind === \"field\" ? field(source.field, u, w, aspect, time, params, [width, height]) : values![y * width + x];\n      callback(x, y, v + cursorBump(u, w, aspect, cursor));\n    }\n  }\n}\n\n/** Deterministic CPU dither render of a frame: RGBA bytes, width × height marks. */\nexport function render(frame: Frame): Uint8ClampedArray<ArrayBuffer> {\n  const { tokens, width, height, indices } = frame;\n  const palette = resolvePalette(tokens.palette);\n  const out = new Uint8ClampedArray(width * height * 4);\n  if (indices) {\n    indices.forEach((i, p) => out.set([...palette[i], 255], p * 4));\n    return out;\n  }\n  const { size, data: thresholds } = thresholdMap(tokens.pattern);\n  eachValue(frame, width / height, (x, y, v) => {\n    const t = thresholds[(y % size) * size + (x % size)];\n    const [r, g, b] = palette[level(v, tokens.contrast, tokens.threshold, t, palette.length)];\n    out.set([r, g, b, 255], (y * width + x) * 4);\n  });\n  return out;\n}\n\n/** Deterministic CPU ASCII render: glyph index per cell and palette band for its colour. */\nexport function glyphGrid(frame: Frame): { glyphs: Uint16Array; bands: Uint8Array } {\n  const { tokens, width, height } = frame;\n  const ramp = rampGlyphs(tokens.glyphs).length;\n  const colors = resolvePalette(tokens.palette).length;\n  const glyphs = new Uint16Array(width * height);\n  const bands = new Uint8Array(width * height);\n  eachValue(frame, (width * GLYPH_ASPECT) / height, (x, y, v) => {\n    const cell = glyphCell(v, tokens.contrast, tokens.threshold, ramp, colors);\n    glyphs[y * width + x] = cell.glyph;\n    bands[y * width + x] = cell.band;\n  });\n  return { glyphs, bands };\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/cpu.ts"
    },
    {
      "path": "registry/engine/diffusion.ts",
      "content": "import type { Pattern } from \"./types\";\n\nexport type DiffusionPattern = \"atkinson\" | \"floyd\";\n\nexport const isDiffusion = (pattern: Pattern): pattern is DiffusionPattern => pattern === \"atkinson\" || pattern === \"floyd\";\n\n/**\n * Error-diffuse luminance into palette indices (n levels), row by row, left to right.\n * Self-contained (no imports or closures): its source is stringified into the Worker.\n */\nexport function diffuse(\n  values: Float32Array,\n  width: number,\n  height: number,\n  pattern: DiffusionPattern,\n  contrast: number,\n  threshold: number,\n  n: number,\n): Uint8Array {\n  // [dx, dy, weight]: Floyd–Steinberg spreads all of the error; Atkinson spreads 6/8 for crisper highlights.\n  const kernel =\n    pattern === \"floyd\"\n      ? [1, 0, 7 / 16, -1, 1, 3 / 16, 0, 1, 5 / 16, 1, 1, 1 / 16]\n      : [1, 0, 1 / 8, 2, 0, 1 / 8, -1, 1, 1 / 8, 0, 1, 1 / 8, 1, 1, 1 / 8, 0, 2, 1 / 8];\n  const s = new Float32Array(values.length);\n  // Same tone mapping as ordered dither, so contrast and threshold behave identically.\n  for (let i = 0; i < s.length; i++) s[i] = Math.min(1, Math.max(0, (values[i] - 0.5) * contrast + 1 - threshold)) * (n - 1);\n  const out = new Uint8Array(values.length);\n  for (let y = 0; y < height; y++) {\n    for (let x = 0; x < width; x++) {\n      const i = y * width + x;\n      const q = Math.min(n - 1, Math.max(0, Math.round(s[i])));\n      out[i] = q;\n      const err = s[i] - q;\n      for (let k = 0; k < kernel.length; k += 3) {\n        const nx = x + kernel[k], ny = y + kernel[k + 1];\n        if (nx >= 0 && nx < width && ny < height) s[ny * width + nx] += err * kernel[k + 2];\n      }\n    }\n  }\n  return out;\n}\n\nconst WORKER_PIXELS = 512 * 512;\nlet worker: Worker | null | undefined;\nlet nextId = 0;\ntype Args = Parameters<typeof diffuse>;\nconst pending = new Map<number, { args: Args; done: (indices: Uint8Array) => void }>();\n\n// Built from diffuse's own source at runtime, so installs need no bundler config for a worker file.\nfunction getWorker(): Worker | null {\n  if (worker === undefined) {\n    try {\n      const source = `const diffuse = (${diffuse.toString()});\nonmessage = (e) => { const out = diffuse(...e.data.args); postMessage({ id: e.data.id, out }, [out.buffer]); };`;\n      worker = new Worker(URL.createObjectURL(new Blob([source], { type: \"text/javascript\" })));\n      worker.onmessage = (e: MessageEvent<{ id: number; out: Uint8Array }>) => {\n        pending.get(e.data.id)?.done(e.data.out);\n        pending.delete(e.data.id);\n      };\n      // e.g. a CSP that blocks blob: workers: finish queued work here and stay on the main thread.\n      worker.onerror = () => {\n        worker = null;\n        pending.forEach(({ args, done }) => done(diffuse(...args)));\n        pending.clear();\n      };\n    } catch {\n      worker = null;\n    }\n  }\n  return worker;\n}\n\nconst results = new WeakMap<Float32Array, Map<string, Uint8Array | \"pending\">>();\n\n/**\n * Diffused indices for an image's values, cached per tone settings. Images over 512×512 diffuse in a\n * Worker when one is available: undefined is returned until then, and `onReady` fires once it lands.\n */\nexport function diffused(\n  values: Float32Array,\n  width: number,\n  height: number,\n  pattern: DiffusionPattern,\n  contrast: number,\n  threshold: number,\n  n: number,\n  onReady: () => void,\n): Uint8Array | undefined {\n  let byKey = results.get(values);\n  if (!byKey) results.set(values, (byKey = new Map()));\n  const key = `${pattern}|${contrast}|${threshold}|${n}`;\n  const hit = byKey.get(key);\n  if (hit) return hit === \"pending\" ? undefined : hit;\n  const args: Args = [values, width, height, pattern, contrast, threshold, n];\n  const w = width * height > WORKER_PIXELS && typeof Worker !== \"undefined\" ? getWorker() : null;\n  if (!w) {\n    const indices = diffuse(...args);\n    byKey.set(key, indices);\n    return indices;\n  }\n  const id = nextId++;\n  byKey.set(key, \"pending\");\n  pending.set(id, {\n    args,\n    done: (indices) => {\n      byKey.set(key, indices);\n      onReady();\n    },\n  });\n  w.postMessage({ id, args });\n  return undefined;\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/diffusion.ts"
    },
    {
      "path": "registry/engine/ease.ts",
      "content": "export interface Easer {\n  /** True while any eased value is still short of its target. */\n  settling(target: Record<string, number>): boolean;\n  /** Advance toward the target; snaps on the first frame and when `snap` (reduced motion). */\n  step(target: Record<string, number>, seconds: number, snap: boolean): Record<string, number>;\n}\n\n/** Frame-rate independent exponential ease for numeric params, e.g. a progress value. */\nexport function createEaser(rate = 10): Easer {\n  let current: Record<string, number> | undefined;\n  let last: number | undefined;\n  const far = (target: Record<string, number>) =>\n    !!current && Object.keys(target).some((k) => Math.abs((current![k] ?? target[k]) - target[k]) > 1e-3);\n  return {\n    settling: far,\n    step(target, seconds, snap) {\n      if (!current || snap || !far(target)) {\n        current = { ...target };\n        last = undefined;\n        return current;\n      }\n      // After idle, the gap since the last eased frame is meaningless: take one nominal 60 Hz step.\n      const dt = last === undefined ? 1 / 60 : Math.min(Math.max(seconds - last, 0), 0.1);\n      const k = 1 - Math.exp(-dt * rate);\n      const from = current;\n      current = Object.fromEntries(Object.entries(target).map(([key, v]) => [key, (from[key] ?? v) + (v - (from[key] ?? v)) * k]));\n      last = seconds;\n      return current;\n    },\n  };\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/ease.ts"
    },
    {
      "path": "registry/engine/fields.ts",
      "content": "import type { FieldName } from \"./types\";\n\n// Each field exists twice: GLSL for the GPU and an identical JS twin for the CPU path and tests.\n// The integer hash keeps both sides bit-compatible (sin-based hashes drift between GPU and CPU).\n\n// Param order is the vec4 slot order the shader reads.\nconst FIELD_PARAMS: Record<FieldName, Record<string, number>> = {\n  noise: { scale: 3 },\n  radial: { x: 0.5, y: 0.5, radius: 0.5 },\n  linear: { angle: 0 },\n  progress: { value: 0.5, softness: 0.05 },\n  // Share of columns raining; tail length as a share of the height; columns across the width, more than any\n  // glyph grid has so each glyph column falls on its own. The speed token scales time, so fall speed needs no param.\n  rain: { density: 0.7, length: 0.4, columns: 1024 },\n  // Ring radius and thickness in heights; turns once per second at speed 1.\n  spin: { radius: 0.32, thickness: 0.16 },\n  // Resting level; band half-width as a share of the width. One sweep every ~1.7 s at speed 1.\n  shimmer: { base: 0.3, width: 0.25 },\n  // Border width in marks (whole cells in ASCII), fading inward; the level inside it.\n  frame: { border: 2, fill: 0 },\n};\n\n/** Index the shader switches on. */\nexport const FIELD_IDS: Record<FieldName, number> = { noise: 0, radial: 1, linear: 2, progress: 3, rain: 4, spin: 5, shimmer: 6, frame: 7 };\n\n/** Field params packed into the vec4 the shader reads, defaults filled in. */\nexport function fieldParams(field: FieldName, params: Record<string, number> = {}): number[] {\n  const out = Object.entries(FIELD_PARAMS[field]).map(([k, d]) => params[k] ?? d);\n  while (out.length < 4) out.push(0);\n  return out;\n}\n\nexport const FIELD_GLSL = `\nfloat hash3(ivec3 c) {\n  uint h = (uint(c.x) * 0x27d4eb2du) ^ (uint(c.y) * 0x165667b1u) ^ (uint(c.z) * 0x9e3779b1u);\n  h = (h ^ (h >> 15u)) * 0x85ebca6bu;\n  h ^= h >> 13u;\n  return float(h) / 4294967295.0;\n}\nfloat vnoise(vec3 p) {\n  vec3 i = floor(p);\n  vec3 f = p - i;\n  vec3 u = f * f * (3.0 - 2.0 * f);\n  ivec3 c = ivec3(i);\n  float a = mix(hash3(c), hash3(c + ivec3(1, 0, 0)), u.x);\n  float b = mix(hash3(c + ivec3(0, 1, 0)), hash3(c + ivec3(1, 1, 0)), u.x);\n  float d = mix(hash3(c + ivec3(0, 0, 1)), hash3(c + ivec3(1, 0, 1)), u.x);\n  float e = mix(hash3(c + ivec3(0, 1, 1)), hash3(c + ivec3(1, 1, 1)), u.x);\n  return mix(mix(a, b, u.y), mix(d, e, u.y), u.z);\n}\nfloat field(int kind, vec2 uv, float aspect, float t, vec4 params) {\n  if (kind == 1) return clamp(1.0 - length((uv - params.xy) * vec2(aspect, 1.0)) / params.z, 0.0, 1.0);\n  if (kind == 2) {\n    float a = radians(params.x);\n    return clamp(dot(uv - 0.5, vec2(cos(a), sin(a))) + 0.5, 0.0, 1.0);\n  }\n  if (kind == 3) return clamp((params.x - uv.x) / max(params.y, 1e-4) * 0.5 + 0.5, 0.0, 1.0);\n  if (kind == 4) {\n    // Each column: hashed on/off, phase and speed; a head falls and its tail fades behind it, wrapping.\n    int c = int(floor(uv.x * params.z));\n    if (hash3(ivec3(c, 2, 0)) > params.x) return 0.0;\n    float head = fract(hash3(ivec3(c, 1, 0)) + t * (0.3 + 0.7 * hash3(ivec3(c, 0, 0))));\n    float d = fract(head - uv.y);\n    return d < params.y ? 1.0 - d / params.y : 0.0;\n  }\n  if (kind == 5) {\n    // A ring whose brightness sweeps round it: brightest at the head, fading over one turn behind it.\n    vec2 p = (uv - 0.5) * vec2(aspect, 1.0);\n    if (abs(length(p) - params.x) > params.y * 0.5) return 0.0;\n    return fract(atan(p.y, p.x) / 6.2831853 + 0.5 - t);\n  }\n  if (kind == 6) {\n    // A slightly slanted bright band sweeping left to right over a flat base, off-screen between sweeps.\n    float d = abs(uv.x + uv.y * 0.15 - (fract(t * 0.6) * 1.8 - 0.4));\n    return params.x + (1.0 - params.x) * clamp(1.0 - d / max(params.y, 1e-4), 0.0, 1.0);\n  }\n  if (kind == 7) {\n    // uSize is the mark grid of whichever program includes this, so the border is counted in marks.\n    // The border fades inward from full so dither shows in it.\n    vec2 m = uv * uSize;\n    float d = min(min(m.x, uSize.x - m.x), min(m.y, uSize.y - m.y));\n    return d < params.x ? 1.0 - 0.6 * d / params.x : params.y;\n  }\n  vec2 p = vec2(uv.x * aspect, uv.y) * params.x;\n  float z = t * 0.25;\n  return 0.65 * vnoise(vec3(p, z)) + 0.35 * vnoise(vec3(p * 2.0 + 17.0, z * 2.0));\n}\n// Soft radial bump at the pointer, added to the field value.\nfloat cursorBump(vec2 uv, float aspect, vec4 cursor) {\n  vec2 d = (uv - cursor.xy) * vec2(aspect, 1.0);\n  return cursor.z * 0.6 * exp(-dot(d, d) / max(cursor.w * cursor.w, 1e-6));\n}\n`;\n\nfunction hash3(x: number, y: number, z: number): number {\n  let h = Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1) ^ Math.imul(z, 0x9e3779b1);\n  h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);\n  h ^= h >>> 13;\n  return (h >>> 0) / 4294967295;\n}\n\nconst mix = (a: number, b: number, t: number) => a + (b - a) * t;\n\nfunction vnoise(x: number, y: number, z: number): number {\n  const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);\n  const fx = x - xi, fy = y - yi, fz = z - zi;\n  const ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy), uz = fz * fz * (3 - 2 * fz);\n  const a = mix(hash3(xi, yi, zi), hash3(xi + 1, yi, zi), ux);\n  const b = mix(hash3(xi, yi + 1, zi), hash3(xi + 1, yi + 1, zi), ux);\n  const d = mix(hash3(xi, yi, zi + 1), hash3(xi + 1, yi, zi + 1), ux);\n  const e = mix(hash3(xi, yi + 1, zi + 1), hash3(xi + 1, yi + 1, zi + 1), ux);\n  return mix(mix(a, b, uy), mix(d, e, uy), uz);\n}\n\nconst clamp01 = (x: number) => Math.min(1, Math.max(0, x));\nconst fract = (x: number) => x - Math.floor(x);\n\n/** JS twin of the GLSL `field`: value 0–1 at uv for the given time. */\nexport function field(name: FieldName, u: number, v: number, aspect: number, t: number, params: number[], size: [number, number] = [1, 1]): number {\n  if (name === \"radial\") return clamp01(1 - Math.hypot((u - params[0]) * aspect, v - params[1]) / params[2]);\n  if (name === \"linear\") {\n    const a = (params[0] * Math.PI) / 180;\n    return clamp01((u - 0.5) * Math.cos(a) + (v - 0.5) * Math.sin(a) + 0.5);\n  }\n  if (name === \"progress\") return clamp01(((params[0] - u) / Math.max(params[1], 1e-4)) * 0.5 + 0.5);\n  if (name === \"rain\") {\n    const c = Math.floor(u * params[2]);\n    if (hash3(c, 2, 0) > params[0]) return 0;\n    const head = fract(hash3(c, 1, 0) + t * (0.3 + 0.7 * hash3(c, 0, 0)));\n    const d = fract(head - v);\n    return d < params[1] ? 1 - d / params[1] : 0;\n  }\n  if (name === \"spin\") {\n    const px = (u - 0.5) * aspect, py = v - 0.5;\n    if (Math.abs(Math.hypot(px, py) - params[0]) > params[1] * 0.5) return 0;\n    return fract(Math.atan2(py, px) / 6.2831853 + 0.5 - t);\n  }\n  if (name === \"shimmer\") {\n    const d = Math.abs(u + v * 0.15 - (fract(t * 0.6) * 1.8 - 0.4));\n    return params[0] + (1 - params[0]) * clamp01(1 - d / Math.max(params[1], 1e-4));\n  }\n  if (name === \"frame\") {\n    const [w, h] = size, x = u * w, y = v * h;\n    const d = Math.min(x, w - x, y, h - y);\n    return d < params[0] ? 1 - (0.6 * d) / params[0] : params[1];\n  }\n  const px = u * aspect * params[0], py = v * params[0], z = t * 0.25;\n  return 0.65 * vnoise(px, py, z) + 0.35 * vnoise(px * 2 + 17, py * 2 + 17, z * 2);\n}\n\n/** JS twin of the GLSL `cursorBump`. */\nexport function cursorBump(u: number, v: number, aspect: number, [x, y, strength, radius]: number[]): number {\n  const dx = (u - x) * aspect, dy = v - y;\n  return strength * 0.6 * Math.exp(-(dx * dx + dy * dy) / Math.max(radius * radius, 1e-6));\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/fields.ts"
    },
    {
      "path": "registry/engine/glyphs.ts",
      "content": "import type { GlyphRampName } from \"./types\";\n\nexport const DEFAULT_FONT = '\"JetBrains Mono\", ui-monospace, monospace';\n\n/** ASCII cell width ÷ height: the advance of a monospace glyph, so `█` tiles solid. `cell` is the height. */\nexport const GLYPH_ASPECT = 0.6;\n\n// Ordered background → foreground (sparse → dense), like palettes.\n// shortcut: katakana is the half-width block U+FF66–U+FF85 in code-point order, not sorted by ink coverage;\n// sort by measured coverage if it reads too noisy.\nexport const RAMPS: Record<GlyphRampName, string> = {\n  standard: \" .:-=+*#%@\",\n  blocks: \" ░▒▓█\",\n  binary: \"01\",\n  katakana: Array.from({ length: 32 }, (_, i) => String.fromCharCode(0xff66 + i)).join(\"\"),\n};\n\n/** Ramp characters, splitting by code point so surrogate pairs stay whole. */\nexport function rampGlyphs(glyphs: GlyphRampName | string): string[] {\n  return [...(glyphs in RAMPS ? RAMPS[glyphs as GlyphRampName] : glyphs)];\n}\n\nconst clamp01 = (x: number) => Math.min(1, Math.max(0, x));\n\n/**\n * One ASCII cell: glyph index into the ramp and palette band (1…n-1) for the foreground.\n * JS twin of the ASCII shader's cell logic.\n */\nexport function glyphCell(v: number, contrast: number, threshold: number, glyphs: number, colors: number) {\n  const s = clamp01((v - 0.5) * contrast + 1 - threshold);\n  return {\n    glyph: Math.min(glyphs - 1, Math.floor(s * glyphs)),\n    band: Math.min(colors - 1, 1 + Math.floor(s * (colors - 1))),\n  };\n}\n\nconst atlases = new Map<string, HTMLCanvasElement>();\n\n/** White-on-transparent strip of glyphs, one cellW × cellH box each; cached by font, cell size and ramp. */\nexport function glyphAtlas(glyphs: string[], cellW: number, cellH: number, font: string): HTMLCanvasElement {\n  const key = `${font}|${cellW}x${cellH}|${glyphs.join(\"\")}`;\n  let atlas = atlases.get(key);\n  if (!atlas) {\n    atlas = document.createElement(\"canvas\");\n    atlas.width = glyphs.length * cellW;\n    atlas.height = cellH;\n    const ctx = atlas.getContext(\"2d\")!;\n    ctx.font = `${cellH}px ${font}`;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    ctx.fillStyle = \"#fff\";\n    glyphs.forEach((g, i) => {\n      ctx.save();\n      ctx.beginPath();\n      ctx.rect(i * cellW, 0, cellW, cellH);\n      ctx.clip();\n      ctx.fillText(g, (i + 0.5) * cellW, cellH / 2);\n      ctx.restore();\n    });\n    atlases.set(key, atlas);\n  }\n  return atlas;\n}\n\n/** Drop cached atlases (e.g. once a web font finishes loading). */\nexport function clearGlyphAtlases(): void {\n  atlases.clear();\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/glyphs.ts"
    },
    {
      "path": "registry/engine/images.ts",
      "content": "export type ImageStatus = \"loading\" | \"ready\" | \"error\";\n\ninterface Entry {\n  image: HTMLImageElement;\n  status: ImageStatus;\n  listeners: Set<() => void>;\n  values: Map<string, Float32Array>;\n}\n\nconst images = new Map<string, Entry>();\n\n/** Rec. 709 luma of RGBA bytes, 0–1 per pixel. */\nexport function luminance(rgba: Uint8ClampedArray): Float32Array {\n  const out = new Float32Array(rgba.length / 4);\n  for (let i = 0; i < out.length; i++) {\n    out[i] = (0.2126 * rgba[i * 4] + 0.7152 * rgba[i * 4 + 1] + 0.0722 * rgba[i * 4 + 2]) / 255;\n  }\n  return out;\n}\n\nfunction entry(src: string, crossOrigin?: \"anonymous\"): Entry {\n  const key = `${crossOrigin ?? \"\"}|${src}`;\n  let e = images.get(key);\n  if (!e) {\n    const image = new Image();\n    const created: Entry = { image, status: \"loading\", listeners: new Set(), values: new Map() };\n    const settle = (status: ImageStatus) => {\n      created.status = status;\n      created.listeners.forEach((cb) => cb());\n    };\n    image.onload = () => settle(\"ready\");\n    image.onerror = () => settle(\"error\");\n    if (crossOrigin) image.crossOrigin = crossOrigin;\n    image.src = src;\n    images.set(key, (e = created));\n  }\n  return e;\n}\n\n/** Load (once per src) and call back when its status changes; returns an unsubscribe. */\nexport function watchImage(src: string, crossOrigin: \"anonymous\" | undefined, onChange: () => void): () => void {\n  const e = entry(src, crossOrigin);\n  e.listeners.add(onChange);\n  return () => e.listeners.delete(onChange);\n}\n\n/**\n * Luminance of the image cover-fitted to cols × rows marks, or the image's status while it can't be read.\n * Cached per size, so token changes never reload or re-sample it.\n */\nexport function imageValues(src: string, crossOrigin: \"anonymous\" | undefined, cols: number, rows: number, aspect: number): Float32Array | ImageStatus {\n  const e = entry(src, crossOrigin);\n  if (e.status !== \"ready\") return e.status;\n  const key = `${cols}x${rows}x${aspect.toFixed(4)}`;\n  let values = e.values.get(key);\n  if (!values) {\n    try {\n      values = sample(e.image, e.image.naturalWidth, e.image.naturalHeight, cols, rows, aspect, false);\n    } catch {\n      // Tainted canvas (cross-origin image without CORS headers) or no 2D context: pixels can't be read.\n      e.status = \"error\";\n      e.listeners.forEach((cb) => cb());\n      return \"error\";\n    }\n    e.values.set(key, values);\n  }\n  return values;\n}\n\n/** Luminance of the video's current frame cover-fitted to cols × rows marks, or its status while it has no frame. */\nexport function videoValues(video: HTMLVideoElement, cols: number, rows: number, aspect: number): Float32Array | ImageStatus {\n  if (video.error) return \"error\";\n  if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return \"loading\";\n  try {\n    return sample(video, video.videoWidth, video.videoHeight, cols, rows, aspect, true);\n  } catch {\n    return \"error\";\n  }\n}\n\n// Video reads back every frame, so it gets a CPU-backed canvas; images keep the default (GPU) resampling.\nconst scratches = new Map<boolean, CanvasRenderingContext2D | null>();\n\n/** Cover-fit a drawable into cols × rows: crop its longer side to the element's aspect. Throws if pixels can't be read. */\nexport function sample(source: CanvasImageSource, iw: number, ih: number, cols: number, rows: number, aspect: number, frequent: boolean): Float32Array {\n  if (!scratches.has(frequent)) scratches.set(frequent, document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: frequent }));\n  const scratch = scratches.get(frequent);\n  if (!scratch) throw new Error(\"no 2D context\");\n  scratch.canvas.width = cols;\n  scratch.canvas.height = rows;\n  const sw = Math.min(iw, ih * aspect), sh = sw / aspect;\n  scratch.imageSmoothingQuality = \"high\";\n  scratch.drawImage(source, (iw - sw) / 2, (ih - sh) / 2, sw, sh, 0, 0, cols, rows);\n  return luminance(scratch.getImageData(0, 0, cols, rows).data);\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/images.ts"
    },
    {
      "path": "registry/engine/index.ts",
      "content": "export { glyphGrid, render } from \"./cpu\";\nexport { RAMPS } from \"./glyphs\";\nexport type { PlotType } from \"./plot\";\nexport { PALETTES, paletteColors } from \"./palettes\";\nexport { DEFAULT_TOKENS, DitherProvider, useColorScheme, useDitherFont, useDitherTokens, useReducedMotion, type ColorScheme, type DitherProviderProps } from \"./provider\";\nexport { Quantize, type QuantizeProps } from \"./quantize\";\nexport { useTrigger, type Trigger, type TriggerState } from \"./trigger\";\nexport type * from \"./types\";\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/index.ts"
    },
    {
      "path": "registry/engine/palettes.ts",
      "content": "import type { PaletteName } from \"./types\";\n\nexport type RGB = [number, number, number];\nexport type Scheme = \"light\" | \"dark\";\n\nconst PAPER = \"#f2f0e9\";\nconst INK = \"#0a0a0a\";\n\n// Ordered background → foreground. `ink` flips with the colour scheme.\nexport const INK_PALETTES: Record<Scheme, string[]> = { light: [PAPER, INK], dark: [INK, PAPER] };\n\nexport const PALETTES: Record<PaletteName, string[]> = {\n  ink: INK_PALETTES.dark,\n  gameboy: [\"#0f380f\", \"#306230\", \"#8bac0f\", \"#9bbc0f\"],\n  amber: [\"#140b00\", \"#ffb000\"],\n  phosphor: [\"#001408\", \"#33ff66\"],\n  cga: [\"#000000\", \"#55ffff\", \"#ff55ff\", \"#ffffff\"],\n  riso: [\"#f2efe6\", \"#ff48b0\", \"#0078bf\", \"#1a1a1a\"],\n};\n\nlet probe: CanvasRenderingContext2D | null | undefined;\n\n// shortcut: hex and rgb() parse anywhere; other CSS colours go through canvas normalization,\n// which only works in a browser and only when it yields hex/rgb (not oklch); extend if users hit it.\nfunction parseColor(color: string): RGB | null {\n  const c = color.trim().toLowerCase();\n  const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(c)?.[1];\n  if (hex) {\n    const full = hex.length <= 4 ? hex.replace(/./g, \"$&$&\") : hex;\n    return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)) as RGB;\n  }\n  const rgb = /^rgba?\\(\\s*([\\d.]+)[\\s,]+([\\d.]+)[\\s,]+([\\d.]+)/.exec(c);\n  if (rgb) return [1, 2, 3].map((i) => Math.round(Math.min(255, Number(rgb[i])))) as RGB;\n  if (probe === undefined) probe = typeof document === \"undefined\" ? null : document.createElement(\"canvas\").getContext(\"2d\");\n  if (!probe) return null;\n  probe.fillStyle = \"#010203\";\n  probe.fillStyle = c;\n  const normalized = probe.fillStyle;\n  return normalized === \"#010203\" && c !== \"#010203\" ? null : /^(#|rgb)/.test(normalized) ? parseColor(normalized) : null;\n}\n\n/** Palette as CSS colours for a scheme; throws a clear error for invalid custom palettes. */\nexport function paletteColors(palette: PaletteName | string[], scheme: Scheme = \"dark\"): string[] {\n  if (palette === \"ink\") return INK_PALETTES[scheme];\n  if (typeof palette === \"string\") {\n    if (!(palette in PALETTES)) throw new Error(`dotgrain: unknown palette \"${palette}\"`);\n    return PALETTES[palette];\n  }\n  if (palette.length < 2 || palette.length > 4) {\n    throw new Error(`dotgrain: palettes need 2–4 colours, got ${palette.length}`);\n  }\n  const bad = palette.find((c) => !parseColor(c));\n  if (bad !== undefined) throw new Error(`dotgrain: can't parse palette colour \"${bad}\"; use hex or rgb()`);\n  return palette;\n}\n\nexport function resolvePalette(palette: PaletteName | string[], scheme: Scheme = \"dark\"): RGB[] {\n  return paletteColors(palette, scheme).map((c) => parseColor(c)!);\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/palettes.ts"
    },
    {
      "path": "registry/engine/patterns.ts",
      "content": "import type { Pattern } from \"./types\";\n\n/** n×n Bayer matrix (n a power of 2), row-major thresholds in (0, 1). */\nexport function bayer(n: number): Float32Array {\n  let m = [0];\n  for (let s = 1; s < n; s *= 2) {\n    const next: number[] = new Array(4 * s * s);\n    for (let y = 0; y < s; y++) {\n      for (let x = 0; x < s; x++) {\n        const v = 4 * m[y * s + x];\n        next[y * 2 * s + x] = v;\n        next[y * 2 * s + x + s] = v + 2;\n        next[(y + s) * 2 * s + x] = v + 3;\n        next[(y + s) * 2 * s + x + s] = v + 1;\n      }\n    }\n    m = next;\n  }\n  return Float32Array.from(m, (v) => (v + 0.5) / (n * n));\n}\n\n/**\n * size×size blue-noise threshold map via void-and-cluster (Ulichney 1993), deterministic.\n * Energy is a toroidal Gaussian; each pixel change updates every energy in O(n), so 64×64 is ~20M ops.\n */\nexport function blueNoise(size = 64, sigma = 1.5): Float32Array {\n  const n = size * size;\n  const kernel = new Float32Array(n);\n  for (let y = 0; y < size; y++) {\n    for (let x = 0; x < size; x++) {\n      const dx = Math.min(x, size - x), dy = Math.min(y, size - y);\n      kernel[y * size + x] = Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));\n    }\n  }\n  const on = new Uint8Array(n);\n  const energy = new Float32Array(n);\n  const toggle = (i: number, sign: number) => {\n    on[i] = sign > 0 ? 1 : 0;\n    const ix = i % size, iy = (i / size) | 0;\n    for (let y = 0; y < size; y++) {\n      const row = ((y - iy + size) % size) * size;\n      for (let x = 0; x < size; x++) energy[y * size + x] += sign * kernel[row + ((x - ix + size) % size)];\n    }\n  };\n  // Tightest cluster = highest-energy \"on\" pixel; largest void = lowest-energy \"off\" pixel.\n  const extreme = (state: number, pickMax: boolean) => {\n    let best = -1;\n    for (let i = 0; i < n; i++) {\n      if (on[i] === state && (best < 0 || (pickMax ? energy[i] > energy[best] : energy[i] < energy[best]))) best = i;\n    }\n    return best;\n  };\n\n  let seed = 0x9e3779b9;\n  const random = () => {\n    seed = (seed + 0x6d2b79f5) | 0;\n    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n    t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n  const initial = Math.round(n * 0.1);\n  for (let placed = 0; placed < initial; ) {\n    const i = Math.floor(random() * n);\n    if (!on[i]) (toggle(i, 1), placed++);\n  }\n  // Phase 0: move cluster pixels into voids until the prototype pattern is stable.\n  for (;;) {\n    const cluster = extreme(1, true);\n    toggle(cluster, -1);\n    const void_ = extreme(0, false);\n    toggle(void_, 1);\n    if (void_ === cluster) break;\n  }\n  const prototype = on.slice(), prototypeEnergy = energy.slice();\n  const rank = new Float32Array(n);\n  // Phase 1: rank prototype pixels by removing tightest clusters.\n  for (let r = initial - 1; r >= 0; r--) {\n    const cluster = extreme(1, true);\n    toggle(cluster, -1);\n    rank[cluster] = r;\n  }\n  on.set(prototype);\n  energy.set(prototypeEnergy);\n  // Phase 2: fill the remaining pixels into the largest voids.\n  for (let r = initial; r < n; r++) {\n    const void_ = extreme(0, false);\n    toggle(void_, 1);\n    rank[void_] = r;\n  }\n  return rank.map((r) => (r + 0.5) / n);\n}\n\nexport interface ThresholdMap {\n  size: number;\n  data: Float32Array;\n}\n\nconst cache = new Map<string, ThresholdMap>();\nlet warnedDiffusion = false;\n\n/** Threshold map for an ordered pattern, built on first use; diffusion patterns fall back to bayer4 here. */\nexport function thresholdMap(pattern: Pattern): ThresholdMap {\n  if ((pattern === \"atkinson\" || pattern === \"floyd\") && !warnedDiffusion && process.env.NODE_ENV !== \"production\") {\n    warnedDiffusion = true;\n    console.warn(`dotgrain: pattern \"${pattern}\" only applies to still images; using \"bayer4\" instead.`);\n  }\n  const key = pattern === \"bayer2\" || pattern === \"bayer8\" || pattern === \"blueNoise\" ? pattern : \"bayer4\";\n  let map = cache.get(key);\n  if (!map) {\n    const size = key === \"blueNoise\" ? 64 : Number(key.slice(5));\n    cache.set(key, (map = { size, data: key === \"blueNoise\" ? blueNoise(size) : bayer(size) }));\n  }\n  return map;\n}\n\n/** Quantize a 0–1 value to a palette index of n levels against threshold t. */\nexport function level(v: number, contrast: number, threshold: number, t: number, n: number): number {\n  const s = Math.min(1, Math.max(0, (v - 0.5) * contrast + 1 - threshold)) * (n - 1);\n  const b = Math.floor(s);\n  return Math.min(n - 1, b + (s - b > t ? 1 : 0));\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/patterns.ts"
    },
    {
      "path": "registry/engine/plot.ts",
      "content": "export type PlotType = \"line\" | \"bar\";\n\nconst cache = new WeakMap<readonly number[], { key: string; values: Float32Array }>();\n\n/**\n * Per-mark levels (row-major, top row first) for a series drawn into cols × rows marks.\n * Bars fill up to each value with a fractional top mark, so a ramp draws partial heights; lines join the points.\n */\nexport function plotValues(series: readonly number[], type: PlotType, cols: number, rows: number): Float32Array {\n  const key = `${type}|${cols}x${rows}|${series.join()}`;\n  const hit = cache.get(series);\n  if (hit?.key === key) return hit.values;\n  const values = new Float32Array(cols * rows);\n  const n = series.length;\n  const lo = Math.min(0, ...series), span = Math.max(...series) - lo || 1;\n  // Height in marks at fractional index i, interpolated between points.\n  const at = (i: number) => {\n    const a = Math.min(n - 1, Math.floor(i)), b = Math.min(n - 1, a + 1);\n    return ((series[a] + (series[b] - series[a]) * (i - a) - lo) / span) * rows;\n  };\n  for (let x = 0; n && x < cols; x++) {\n    const bar = type === \"bar\" ? at(Math.floor(((x + 0.5) / cols) * n)) : 0;\n    // The line's extent across this column: both edges plus any data point (peak or trough) inside it.\n    const i0 = (x / cols) * (n - 1), i1 = ((x + 1) / cols) * (n - 1);\n    const ys = [at(i0), at(i1)];\n    for (let k = Math.ceil(i0); k <= i1; k++) ys.push(at(k));\n    const y0 = Math.min(...ys) - 0.5, y1 = Math.max(...ys) + 0.5;\n    for (let b = 0; b < rows; b++) {\n      const c = b + 0.5;\n      values[(rows - 1 - b) * cols + x] = type === \"bar\" ? Math.min(1, Math.max(0, bar - b)) : c >= y0 && c <= y1 ? 1 : 0;\n    }\n  }\n  cache.set(series, { key, values });\n  return values;\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/plot.ts"
    },
    {
      "path": "registry/engine/pointer.ts",
      "content": "export interface PointerTracker {\n  /** Still easing toward the pointer, so the view needs more frames. */\n  settling(): boolean;\n  /** Advance the easing; returns uv position and strength 0–1. */\n  step(seconds: number): [number, number, number];\n  dispose(): void;\n}\n\n/** Tracks the pointer (mouse, pen or touch) relative to an element and eases a soft spot toward it. */\nexport function trackPointer(element: Element, onChange: () => void): PointerTracker {\n  let x = 0.5, y = 0.5, tx = 0.5, ty = 0.5, strength = 0, target = 0, last = 0;\n  // Listen on window so content layered above the canvas doesn't swallow the events.\n  const move = (e: PointerEvent) => {\n    const r = element.getBoundingClientRect();\n    tx = (e.clientX - r.left) / r.width;\n    ty = (e.clientY - r.top) / r.height;\n    const inside = tx >= 0 && tx <= 1 && ty >= 0 && ty <= 1;\n    if (!target && inside) [x, y] = [tx, ty];\n    target = inside ? 1 : 0;\n    onChange();\n  };\n  const leave = () => {\n    target = 0;\n    onChange();\n  };\n  window.addEventListener(\"pointermove\", move, { passive: true });\n  document.documentElement.addEventListener(\"pointerleave\", leave);\n  return {\n    settling: () => Math.abs(strength - target) > 0.005 || Math.abs(x - tx) + Math.abs(y - ty) > 0.002,\n    step(seconds) {\n      // Frame-rate independent exponential ease; large gaps (tab switch) snap instead of lurching.\n      const k = 1 - Math.exp(-Math.min(Math.max(seconds - last, 0), 0.1) * 10);\n      last = seconds;\n      x += (tx - x) * k;\n      y += (ty - y) * k;\n      strength += (target - strength) * k;\n      return [x, y, strength];\n    },\n    dispose() {\n      window.removeEventListener(\"pointermove\", move);\n      document.documentElement.removeEventListener(\"pointerleave\", leave);\n    },\n  };\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/pointer.ts"
    },
    {
      "path": "registry/engine/programs.ts",
      "content": "import { FIELD_GLSL } from \"./fields\";\n\n// Full-screen triangle from gl_VertexID; no vertex buffers needed.\nexport const VERTEX = `#version 300 es\nvoid main() {\n  vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}`;\n\n// Mirrors cpu.ts: px is the top-left-origin mark coordinate, so the CPU and GPU\n// index the threshold map identically. uField < 0 reads per-mark image luminance from uValues.\nexport const DITHER = `#version 300 es\nprecision highp float;\nprecision highp int;\nuniform vec2 uSize;\nuniform float uTime;\nuniform int uField;\nuniform vec4 uParams;\nuniform highp sampler2D uValues;\nuniform vec4 uCursor;\nuniform float uContrast;\nuniform float uThreshold;\nuniform vec3 uPalette[4];\nuniform int uLevels;\nuniform highp sampler2D uPattern;\nout vec4 outColor;\n${FIELD_GLSL}\nvoid main() {\n  vec2 px = vec2(gl_FragCoord.x, uSize.y - gl_FragCoord.y);\n  vec2 uv = px / uSize;\n  float aspect = uSize.x / uSize.y;\n  float v = (uField < 0 ? texelFetch(uValues, ivec2(px), 0).r : field(uField, uv, aspect, uTime, uParams)) + cursorBump(uv, aspect, uCursor);\n  float t = texelFetch(uPattern, ivec2(px) % textureSize(uPattern, 0), 0).r;\n  float s = clamp((v - 0.5) * uContrast + 1.0 - uThreshold, 0.0, 1.0) * float(uLevels - 1);\n  float b = floor(s);\n  int i = min(uLevels - 1, int(b) + (s - b > t ? 1 : 0));\n  outColor = vec4(uPalette[i] / 255.0, 1.0);\n}`;\n\n// One cell per mark: sample the source at the cell centre, pick a glyph by value and\n// mask the background → band colour with the glyph atlas. Mirrors glyphCell() in glyphs.ts.\nexport const ASCII = `#version 300 es\nprecision highp float;\nprecision highp int;\nuniform vec2 uSize;\nuniform vec2 uCell;\nuniform float uAspect;\nuniform float uTime;\nuniform int uField;\nuniform vec4 uParams;\nuniform highp sampler2D uValues;\nuniform vec4 uCursor;\nuniform float uContrast;\nuniform float uThreshold;\nuniform vec3 uPalette[4];\nuniform int uLevels;\nuniform int uGlyphs;\nuniform sampler2D uAtlas;\nout vec4 outColor;\n${FIELD_GLSL}\nvoid main() {\n  vec2 px = vec2(gl_FragCoord.x, uSize.y * uCell.y - gl_FragCoord.y);\n  vec2 cell = floor(px / uCell);\n  vec2 uv = (cell + 0.5) / uSize;\n  float v = (uField < 0 ? texelFetch(uValues, ivec2(cell), 0).r : field(uField, uv, uAspect, uTime, uParams)) + cursorBump(uv, uAspect, uCursor);\n  float s = clamp((v - 0.5) * uContrast + 1.0 - uThreshold, 0.0, 1.0);\n  int glyph = min(uGlyphs - 1, int(floor(s * float(uGlyphs))));\n  int band = min(uLevels - 1, 1 + int(floor(s * float(uLevels - 1))));\n  vec2 local = (px - cell * uCell) / uCell;\n  float mask = texture(uAtlas, vec2((float(glyph) + local.x) / float(uGlyphs), local.y)).a;\n  outColor = vec4(mix(uPalette[0], uPalette[band], mask) / 255.0, 1.0);\n}`;\n\nexport interface Program {\n  program: WebGLProgram;\n  uniforms: Record<string, WebGLUniformLocation | null>;\n}\n\nconst cache = new WeakMap<WebGL2RenderingContext, Map<string, Program>>();\n\nfunction compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {\n  const shader = gl.createShader(type)!;\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(`dotgrain: ${gl.getShaderInfoLog(shader)}`);\n  return shader;\n}\n\n/** Compile (once per context) and return a program with its uniform locations. */\n/** Forget compiled programs, e.g. after the context was lost and restored. */\nexport function clearPrograms(gl: WebGL2RenderingContext): void {\n  cache.delete(gl);\n}\n\nexport function getProgram(gl: WebGL2RenderingContext, fragment: string, uniforms: string[]): Program {\n  let programs = cache.get(gl);\n  if (!programs) cache.set(gl, (programs = new Map()));\n  let p = programs.get(fragment);\n  if (!p) {\n    const program = gl.createProgram()!;\n    gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX));\n    gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragment));\n    gl.linkProgram(program);\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`dotgrain: ${gl.getProgramInfoLog(program)}`);\n    p = { program, uniforms: Object.fromEntries(uniforms.map((u) => [u, gl.getUniformLocation(program, u)])) };\n    programs.set(fragment, p);\n  }\n  return p;\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/programs.ts"
    },
    {
      "path": "registry/engine/provider.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, useSyncExternalStore, type ReactNode } from \"react\";\nimport type { Scheme } from \"./palettes\";\nimport type { DitherTokens } from \"./types\";\n\nexport type ColorScheme = Scheme | \"system\";\n\nexport const DEFAULT_TOKENS: DitherTokens = {\n  mode: \"dither\",\n  pattern: \"bayer4\",\n  cell: 3,\n  palette: \"ink\",\n  glyphs: \"standard\",\n  speed: 1,\n  contrast: 1,\n  threshold: 0.5,\n};\n\ninterface ProviderValue {\n  tokens: Partial<DitherTokens>;\n  colorScheme: ColorScheme;\n  font?: string;\n}\n\nconst DitherContext = createContext<ProviderValue>({ tokens: {}, colorScheme: \"system\" });\n\nconst defined = <T extends object>(o: T): Partial<T> =>\n  Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>;\n\nexport type DitherProviderProps = Partial<DitherTokens> & {\n  /** Scheme `ink` follows; `system` reads `<html class=\"dark\">` / `data-theme`, then the OS setting. */\n  colorScheme?: ColorScheme;\n  /** Font family for ASCII glyphs. */\n  font?: string;\n  children?: ReactNode;\n};\n\n/** Sets tokens for a subtree; nested providers inherit and override. */\nexport function DitherProvider({ colorScheme, font, children, ...tokens }: DitherProviderProps) {\n  const parent = useContext(DitherContext);\n  const value: ProviderValue = {\n    tokens: { ...parent.tokens, ...defined(tokens) },\n    colorScheme: colorScheme ?? parent.colorScheme,\n    font: font ?? parent.font,\n  };\n  return <DitherContext.Provider value={value}>{children}</DitherContext.Provider>;\n}\n\n/** Merge defaults, then provider tokens, then props; undefined props fall through. */\nexport function useDitherTokens(overrides: Partial<DitherTokens>): DitherTokens {\n  const { tokens: provided } = useContext(DitherContext);\n  const tokens = { ...DEFAULT_TOKENS, ...provided, ...defined(overrides) };\n  if (provided.cell === undefined && overrides.cell === undefined && tokens.mode === \"ascii\") tokens.cell = 10;\n  return tokens;\n}\n\n/** Font family for ASCII glyphs set by the nearest provider, if any. */\nexport function useDitherFont(): string | undefined {\n  return useContext(DitherContext).font;\n}\n\nfunction systemScheme(): Scheme {\n  const html = document.documentElement;\n  const theme = html.dataset.theme ?? (html.classList.contains(\"dark\") ? \"dark\" : html.classList.contains(\"light\") ? \"light\" : \"\");\n  if (theme === \"dark\" || theme === \"light\") return theme;\n  return matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n}\n\nfunction subscribeScheme(onChange: () => void): () => void {\n  const media = matchMedia(\"(prefers-color-scheme: dark)\");\n  const observer = new MutationObserver(onChange);\n  media.addEventListener(\"change\", onChange);\n  observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"data-theme\"] });\n  return () => {\n    media.removeEventListener(\"change\", onChange);\n    observer.disconnect();\n  };\n}\n\n/** The provider's colour-scheme preference and the scheme it resolves to right now. */\nexport function useColorScheme(): { preference: ColorScheme; scheme: Scheme } {\n  const { colorScheme } = useContext(DitherContext);\n  const system = useSyncExternalStore(subscribeScheme, systemScheme, () => \"light\" as Scheme);\n  return { preference: colorScheme, scheme: colorScheme === \"system\" ? system : colorScheme };\n}\n\nconst motion = () => matchMedia(\"(prefers-reduced-motion: reduce)\");\nconst subscribeMotion = (cb: () => void) => {\n  motion().addEventListener(\"change\", cb);\n  return () => motion().removeEventListener(\"change\", cb);\n};\n\n/** Live `prefers-reduced-motion: reduce`; false during SSR. */\nexport function useReducedMotion(): boolean {\n  return useSyncExternalStore(subscribeMotion, () => motion().matches, () => false);\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/provider.tsx"
    },
    {
      "path": "registry/engine/quantize.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useImperativeHandle, useRef, type CSSProperties, type Ref } from \"react\";\nimport { draw } from \"./context\";\nimport { diffused, isDiffusion } from \"./diffusion\";\nimport { createEaser, type Easer } from \"./ease\";\nimport { INK_PALETTES, paletteColors } from \"./palettes\";\nimport { GLYPH_ASPECT } from \"./glyphs\";\nimport { imageValues, videoValues, watchImage } from \"./images\";\nimport { plotValues } from \"./plot\";\nimport { revealAmount, revealed, scrambled, textValues } from \"./text\";\nimport { trackPointer, type PointerTracker } from \"./pointer\";\nimport { useColorScheme, useDitherFont, useDitherTokens } from \"./provider\";\nimport { getScheduler, type ViewHandle } from \"./scheduler\";\nimport type { DitherTokens, Source } from \"./types\";\n\nexport type QuantizeProps = Partial<DitherTokens> & {\n  source: Source;\n  /** Announces the canvas as one image; without it the canvas is decorative. */\n  label?: string;\n  /** Brighten a soft spot under the pointer. Off under reduced motion. */\n  interactive?: boolean;\n  /** Radius of the pointer spot in CSS pixels. */\n  cursorRadius?: number;\n  /** Ease changes to the field's numeric params (e.g. a progress value) instead of jumping; snaps under reduced motion. */\n  ease?: boolean;\n  /** Called when an image or video source can't be loaded or read (e.g. cross-origin media without CORS). */\n  onError?: () => void;\n  className?: string;\n  style?: CSSProperties;\n  ref?: Ref<HTMLCanvasElement>;\n};\n\nexport function Quantize({\n  source,\n  label,\n  interactive = false,\n  cursorRadius = 120,\n  ease = false,\n  onError,\n  className,\n  style,\n  ref,\n  ...overrides\n}: QuantizeProps) {\n  const canvas = useRef<HTMLCanvasElement>(null);\n  const merged = useDitherTokens(overrides);\n  const { preference, scheme } = useColorScheme();\n  const colors = paletteColors(merged.palette, scheme);\n  const tokens = { ...merged, palette: colors };\n  const font = useDitherFont();\n  // Painted before the first frame (and during SSR) so there is no flash; `ink` + system\n  // follows the page's color-scheme via light-dark() because the server can't know it.\n  const background =\n    merged.palette === \"ink\" && preference === \"system\"\n      ? `light-dark(${INK_PALETTES.light[0]}, ${INK_PALETTES.dark[0]})`\n      : colors[0];\n  const latest = useRef({ source, tokens, cursorRadius, font, onError, ease });\n  const easer = useRef<Easer>(null);\n  const view = useRef<ViewHandle>(null);\n  const pointer = useRef<PointerTracker>(null);\n  useImperativeHandle(ref, () => canvas.current!, []);\n  useEffect(() => {\n    latest.current = { source, tokens, cursorRadius, font, onError, ease };\n    view.current?.invalidate();\n  });\n\n  const image = source.kind === \"image\" ? source : undefined;\n  useEffect(() => {\n    if (image) return watchImage(image.src, image.crossOrigin, () => view.current?.invalidate());\n  }, [image?.src, image?.crossOrigin]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const video = source.kind === \"video\" ? source.element : undefined;\n  useEffect(() => {\n    if (!video) return;\n    // Paused videos draw on demand: a new frame, seek, play (restarts the loop) or pause.\n    const events = [\"loadeddata\", \"seeked\", \"play\", \"pause\", \"error\"];\n    const wake = () => view.current?.invalidate();\n    events.forEach((e) => video.addEventListener(e, wake));\n    return () => events.forEach((e) => video.removeEventListener(e, wake));\n  }, [video]);\n\n  const text = source.kind === \"text\" ? source.element : undefined;\n  useEffect(() => {\n    if (!text) return;\n    const observer = new MutationObserver(() => view.current?.invalidate());\n    observer.observe(text, { characterData: true, childList: true, subtree: true });\n    return () => observer.disconnect();\n  }, [text]);\n\n  useEffect(() => {\n    if (!interactive) return;\n    const tracker = (pointer.current = trackPointer(canvas.current!, () => view.current?.invalidate()));\n    return () => {\n      tracker.dispose();\n      pointer.current = null;\n    };\n  }, [interactive]);\n\n  useEffect(() => {\n    const element = canvas.current!;\n    const handle = getScheduler().add({\n      element,\n      animated: () => {\n        const { source, tokens, ease } = latest.current;\n        if (source.kind === \"video\" && !source.element.paused && !source.element.ended) return true;\n        if (source.kind === \"text\" && source.scramble && tokens.speed > 0) return true;\n        if (source.kind === \"text\" && source.reveal && performance.now() < source.reveal.start + source.reveal.duration) return true;\n        if (source.kind !== \"field\") return !!pointer.current?.settling();\n        // radial, linear, progress and frame ignore time: they redraw only on change, pointer movement or easing.\n        return (\n          (tokens.speed > 0 && ![\"radial\", \"linear\", \"progress\", \"frame\"].includes(source.field)) ||\n          !!pointer.current?.settling() ||\n          (ease && !!source.params && !!easer.current?.settling(source.params))\n        );\n      },\n      render({ seconds, width, height, dpr, still }) {\n        const { tokens, cursorRadius, font, onError, ease } = latest.current;\n        let { source } = latest.current;\n        if (ease && source.kind === \"field\" && source.params) {\n          source = { ...source, params: (easer.current ??= createEaser()).step(source.params, seconds, still) };\n        }\n        const w = Math.round(width / (tokens.mode === \"ascii\" ? tokens.cell * GLYPH_ASPECT : tokens.cell));\n        const h = Math.round(height / tokens.cell);\n        const spot = pointer.current && !still ? pointer.current.step(seconds) : undefined;\n        const cursor = spot && ([...spot, cursorRadius / height] as [number, number, number, number]);\n        if (w <= 0 || h <= 0) return;\n        let values: Float32Array | undefined;\n        let indices: Uint8Array | undefined;\n        if (source.kind !== \"field\") {\n          const result =\n            source.kind === \"image\"\n              ? imageValues(source.src, source.crossOrigin, w, h, width / height)\n              : source.kind === \"video\"\n                ? videoValues(source.element, w, h, width / height)\n                : source.kind === \"plot\"\n                  ? plotValues(source.series, source.type, w, h)\n                  : textValues(source.element, w, h);\n          if (result === \"error\") onError?.();\n          if (typeof result === \"string\") return;\n          // ~12 new scrambles a second at speed 1.\n          values = source.kind === \"text\" && source.scramble ? scrambled(result, source.scramble, Math.floor(seconds * tokens.speed * 12)) : result;\n          if (source.kind === \"text\" && source.reveal) values = revealed(values, revealAmount(source.reveal, seconds * 1000, still));\n          // Error diffusion is for still images; video falls back to an ordered pattern.\n          if (source.kind === \"image\" && tokens.mode === \"dither\" && isDiffusion(tokens.pattern)) {\n            const { pattern, contrast, threshold, palette } = tokens;\n            indices = diffused(values, w, h, pattern, contrast, threshold, palette.length, () => view.current?.invalidate());\n            if (!indices) return;\n          }\n        }\n        draw(element, { source, tokens, width: w, height: h, time: seconds * tokens.speed, cursor, dpr, font, values, indices });\n      },\n    });\n    view.current = handle;\n    return handle.remove;\n  }, []);\n\n  return (\n    <canvas\n      ref={canvas}\n      className={className}\n      role={label ? \"img\" : undefined}\n      aria-label={label}\n      aria-hidden={label ? undefined : true}\n      style={{ display: \"block\", width: \"100%\", height: \"100%\", imageRendering: \"pixelated\", background, ...style }}\n    />\n  );\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/quantize.tsx"
    },
    {
      "path": "registry/engine/scheduler.ts",
      "content": "/** Browser primitives the scheduler depends on; injectable so tests can drive fakes. */\nexport interface SchedulerEnv {\n  requestFrame(callback: (ms: number) => void): void;\n  isHidden(): boolean;\n  onVisibilityChange(callback: () => void): void;\n  reducedMotion(): boolean;\n  onReducedMotionChange(callback: () => void): void;\n  /** Calls back with whether the element is on screen; returns a disposer. */\n  observeVisibility(element: Element, callback: (visible: boolean) => void): () => void;\n  /** Calls back with the element's CSS size; returns a disposer. */\n  observeSize(element: Element, callback: (width: number, height: number) => void): () => void;\n  devicePixelRatio(): number;\n}\n\nexport interface RenderInfo {\n  seconds: number;\n  width: number;\n  height: number;\n  /** Device pixel ratio, capped at 2. */\n  dpr: number;\n  /** Reduced motion is on: draw a static frame, no motion effects. */\n  still: boolean;\n}\n\nexport interface View {\n  element: Element;\n  /** Whether the view wants continuous frames (e.g. speed > 0). */\n  animated(): boolean;\n  render(info: RenderInfo): void;\n}\n\nexport interface ViewHandle {\n  /** Re-render on the next frame (tokens, source or anything else changed). */\n  invalidate(): void;\n  remove(): void;\n}\n\ninterface Entry {\n  view: View;\n  visible: boolean;\n  dirty: boolean;\n  width: number;\n  height: number;\n}\n\nexport function createScheduler(env: SchedulerEnv) {\n  const entries = new Set<Entry>();\n  let pending = false;\n\n  const wake = () => {\n    if (pending || env.isHidden()) return;\n    pending = true;\n    env.requestFrame(frame);\n  };\n\n  const invalidateAll = () => {\n    for (const e of entries) e.dirty = true;\n    wake();\n  };\n\n  function frame(ms: number) {\n    pending = false;\n    if (env.isHidden()) return;\n    const still = env.reducedMotion();\n    const dpr = Math.min(2, env.devicePixelRatio());\n    let again = false;\n    for (const e of entries) {\n      if (!e.visible || !e.width || !e.height) continue;\n      const animate = !still && e.view.animated();\n      if (animate || e.dirty) {\n        e.dirty = false;\n        e.view.render({ seconds: ms / 1000, width: e.width, height: e.height, dpr, still });\n      }\n      again ||= animate;\n    }\n    if (again) wake();\n  }\n\n  env.onVisibilityChange(wake);\n  env.onReducedMotionChange(invalidateAll);\n\n  return {\n    /** Re-render every view on the next frame (e.g. fonts finished loading). */\n    invalidateAll,\n    add(view: View): ViewHandle {\n      const e: Entry = { view, visible: false, dirty: true, width: 0, height: 0 };\n      entries.add(e);\n      const stopVisibility = env.observeVisibility(view.element, (visible) => {\n        e.visible = visible;\n        wake();\n      });\n      const stopSize = env.observeSize(view.element, (width, height) => {\n        Object.assign(e, { width, height, dirty: true });\n        wake();\n      });\n      return {\n        invalidate() {\n          e.dirty = true;\n          wake();\n        },\n        remove() {\n          entries.delete(e);\n          stopVisibility();\n          stopSize();\n        },\n      };\n    },\n  };\n}\n\nexport type Scheduler = ReturnType<typeof createScheduler>;\n\nfunction observerPool<O extends IntersectionObserver | ResizeObserver, A>(\n  create: (cb: (entries: Array<{ target: Element } & A>) => void) => O,\n) {\n  const callbacks = new Map<Element, (entry: A) => void>();\n  let observer: O | undefined;\n  return (element: Element, callback: (entry: A) => void) => {\n    observer ??= create((list) => list.forEach((entry) => callbacks.get(entry.target)?.(entry)));\n    callbacks.set(element, callback);\n    observer.observe(element);\n    return () => {\n      callbacks.delete(element);\n      observer?.unobserve(element);\n    };\n  };\n}\n\nexport function browserEnv(): SchedulerEnv {\n  const motion = matchMedia(\"(prefers-reduced-motion: reduce)\");\n  const intersect = observerPool<IntersectionObserver, IntersectionObserverEntry>((cb) => new IntersectionObserver(cb));\n  const resize = observerPool<ResizeObserver, ResizeObserverEntry>((cb) => new ResizeObserver(cb));\n  return {\n    requestFrame: (cb) => void requestAnimationFrame(cb),\n    isHidden: () => document.hidden,\n    onVisibilityChange: (cb) => document.addEventListener(\"visibilitychange\", cb),\n    reducedMotion: () => motion.matches,\n    onReducedMotionChange: (cb) => motion.addEventListener(\"change\", cb),\n    observeVisibility: (el, cb) => intersect(el, (entry) => cb(entry.isIntersecting)),\n    observeSize: (el, cb) => resize(el, (entry) => cb(entry.contentRect.width, entry.contentRect.height)),\n    devicePixelRatio: () => window.devicePixelRatio || 1,\n  };\n}\n\nlet shared: Scheduler | undefined;\n\n/** The page's single scheduler (one rAF loop for every view). */\nexport function getScheduler(): Scheduler {\n  return (shared ??= createScheduler(browserEnv()));\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/scheduler.ts"
    },
    {
      "path": "registry/engine/text.ts",
      "content": "import { sample } from \"./images\";\n\nconst cache = new WeakMap<HTMLElement, { key: string; values: Float32Array }>();\nlet raster: CanvasRenderingContext2D | null | undefined;\nlet fonts = 0;\nlet watching = false;\n\nfunction transform(text: string, style: CSSStyleDeclaration): string {\n  if (style.textTransform === \"uppercase\") return text.toUpperCase();\n  if (style.textTransform === \"lowercase\") return text.toLowerCase();\n  return text;\n}\n\n/**\n * Mask of the element's text as the browser laid it out (1 = glyph), fitted to cols × rows marks over the\n * element's box. Each word is drawn where a Range reports it, so wrapping, alignment and inline styles match the DOM.\n */\nexport function textValues(element: HTMLElement, cols: number, rows: number): Float32Array | \"loading\" {\n  const box = element.getBoundingClientRect();\n  const w = Math.ceil(box.width), h = Math.ceil(box.height);\n  if (!w || !h) return \"loading\";\n  if (!watching) {\n    // Text laid out in a fallback font moves once the web font arrives.\n    document.fonts?.addEventListener(\"loadingdone\", () => fonts++);\n    watching = true;\n  }\n  const root = getComputedStyle(element);\n  const key = `${element.textContent}|${root.font}|${root.fontSize}|${root.letterSpacing}|${w}x${h}|${cols}x${rows}|${fonts}`;\n  const hit = cache.get(element);\n  if (hit?.key === key) return hit.values;\n\n  raster ??= document.createElement(\"canvas\").getContext(\"2d\");\n  if (!raster) return \"loading\";\n  const ctx = raster;\n  ctx.canvas.width = w;\n  ctx.canvas.height = h;\n  ctx.fillStyle = \"#000\";\n  ctx.fillRect(0, 0, w, h);\n  ctx.fillStyle = \"#fff\";\n  ctx.textBaseline = \"middle\";\n  const range = document.createRange();\n  const draw = (text: string, rect: DOMRect) => ctx.fillText(text, rect.left - box.left, (rect.top + rect.bottom) / 2 - box.top);\n  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);\n  for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {\n    const style = getComputedStyle(node.parentElement ?? element);\n    ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;\n    ctx.letterSpacing = style.letterSpacing === \"normal\" ? \"0px\" : style.letterSpacing;\n    const text = transform(node.data, style);\n    for (const word of node.data.matchAll(/\\S+/g)) {\n      range.setStart(node, word.index);\n      range.setEnd(node, word.index + word[0].length);\n      const rects = range.getClientRects();\n      if (rects.length === 1) draw(text.slice(word.index, word.index + word[0].length), rects[0]);\n      else {\n        // The word breaks across lines: place each character on its own.\n        for (let i = word.index; i < word.index + word[0].length; i++) {\n          range.setStart(node, i);\n          range.setEnd(node, i + 1);\n          const rect = range.getClientRects()[0];\n          if (rect) draw(text[i], rect);\n        }\n      }\n    }\n  }\n  const values = sample(ctx.canvas, w, h, cols, rows, w / h, false);\n  cache.set(element, { key, values });\n  return values;\n}\n\n/** Stateless hash of (mark, tick) to [0, 1). */\nfunction hash(i: number, tick: number): number {\n  let h = Math.imul(i ^ Math.imul(tick + 1, 0x9e3779b1), 0x85ebca6b);\n  h ^= h >>> 13;\n  h = Math.imul(h, 0xc2b2ae35);\n  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;\n}\n\n/** Scramble a mask's lit marks: each gets a fresh random level per tick, `amount` 0 (as is) to 1 (fully random). Empty marks stay empty. */\nexport function scrambled(mask: Float32Array, amount: number, tick: number): Float32Array {\n  return mask.map((v, i) => (v > 0.02 ? v * (1 - amount * hash(i, tick)) : v));\n}\n\nexport interface Reveal {\n  /** `performance.now()` when the reveal started. */\n  start: number;\n  duration: number;\n  /** Dither out (shown → gone) instead of in. */\n  out?: boolean;\n}\n\n/** Share of the mask shown at `now` (ms): eased 0 → 1 over the run, or 1 → 0 when `out`. `still` jumps to the end. */\nexport function revealAmount({ start, duration, out }: Reveal, now: number, still: boolean): number {\n  const t = still ? 1 : Math.min(1, Math.max(0, (now - start) / duration));\n  const eased = t * t * (3 - 2 * t);\n  return out ? 1 - eased : eased;\n}\n\n/** Scale a mask so ordered dither lights only part of each glyph: the dithered fade. */\nexport function revealed(mask: Float32Array, amount: number): Float32Array {\n  return amount >= 1 ? mask : mask.map((v) => v * amount);\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/text.ts"
    },
    {
      "path": "registry/engine/trigger.ts",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type Trigger = \"mount\" | \"hover\" | \"inView\";\n\nexport interface TriggerState {\n  /** `performance.now()` when the current run started; null when not running. Same clock as frame times. */\n  start: number | null;\n  /** Fired at least once. */\n  fired: boolean;\n  /** The last run has finished. */\n  done: boolean;\n  /** Spread onto the element; set only for the hover trigger. */\n  onMouseEnter?: () => void;\n}\n\n/**\n * A `duration` ms run fired on mount (again when `key` changes), on hover, or the first time the element\n * scrolls into view. Does nothing while `enabled` is false (reduced motion, speed 0).\n */\nexport function useTrigger(element: Element | null, trigger: Trigger, duration: number, enabled: boolean, key?: unknown): TriggerState {\n  const [run, setRun] = useState<{ start: number; done: boolean } | null>(null);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const fire = () => {\n    if (!enabled) return;\n    clearTimeout(timer.current);\n    const start = performance.now();\n    setRun({ start, done: false });\n    timer.current = setTimeout(() => setRun({ start, done: true }), duration);\n  };\n  const latest = useRef(fire);\n  useEffect(() => {\n    latest.current = fire;\n  });\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  useEffect(() => {\n    if (!element || trigger === \"hover\") return;\n    if (trigger === \"mount\") {\n      const frame = requestAnimationFrame(() => latest.current());\n      return () => cancelAnimationFrame(frame);\n    }\n    const observer = new IntersectionObserver(([entry]) => {\n      if (!entry?.isIntersecting) return;\n      latest.current();\n      observer.disconnect();\n    });\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [element, trigger, key]);\n\n  return {\n    start: run && !run.done ? run.start : null,\n    fired: !!run,\n    done: !!run?.done,\n    onMouseEnter: trigger === \"hover\" ? fire : undefined,\n  };\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/trigger.ts"
    },
    {
      "path": "registry/engine/types.ts",
      "content": "import type { PlotType } from \"./plot\";\nimport type { Reveal } from \"./text\";\n\nexport type Mode = \"dither\" | \"ascii\";\nexport type Pattern = \"bayer2\" | \"bayer4\" | \"bayer8\" | \"blueNoise\" | \"atkinson\" | \"floyd\";\nexport type PaletteName = \"ink\" | \"gameboy\" | \"amber\" | \"phosphor\" | \"cga\" | \"riso\";\nexport type GlyphRampName = \"standard\" | \"blocks\" | \"binary\" | \"katakana\";\nexport type FieldName = \"noise\" | \"radial\" | \"linear\" | \"progress\" | \"rain\" | \"spin\" | \"shimmer\" | \"frame\";\n\nexport interface DitherTokens {\n  mode: Mode;\n  pattern: Pattern;\n  /** Mark size in CSS pixels. */\n  cell: number;\n  /** 2–4 colours, background → foreground. */\n  palette: PaletteName | string[];\n  /** Glyph ramp, background → foreground. */\n  glyphs: GlyphRampName | string;\n  /** Animation speed; 0 freezes. */\n  speed: number;\n  contrast: number;\n  threshold: number;\n}\n\nexport type Source =\n  | { kind: \"field\"; field: FieldName; params?: Record<string, number> }\n  | { kind: \"image\"; src: string; crossOrigin?: \"anonymous\" }\n  /** Keep the element rendered (e.g. visually hidden), not `display: none`: browsers may not decode a hidden paused video's frames. */\n  | { kind: \"video\"; element: HTMLVideoElement }\n  /** The element's text as laid out in the DOM; the canvas should cover the element's box exactly. */\n  | {\n      kind: \"text\";\n      element: HTMLElement;\n      /** 0–1: re-randomise lit marks every tick, for a scramble. */\n      scramble?: number;\n      /** Dither the text in (or out) over a timed run. */\n      reveal?: Reveal;\n    }\n  /** A data series drawn as bars or a line across the box. */\n  | { kind: \"plot\"; series: readonly number[]; type: PlotType };\n\n/** One render request: quantized size in marks, time in seconds. */\nexport interface Frame {\n  source: Source;\n  tokens: DitherTokens;\n  width: number;\n  height: number;\n  time: number;\n  /** Image sources: luminance 0–1 per mark, row-major, already fitted to width × height. */\n  values?: Float32Array;\n  /** Palette index per mark, already quantized (error diffusion); drawn as-is. */\n  indices?: Uint8Array;\n  /** Device pixel ratio (ASCII glyphs render at device resolution). */\n  dpr?: number;\n  /** Font family for ASCII glyphs. */\n  font?: string;\n  /** Soft bright spot: x, y in 0–1 uv, strength 0–1, radius as a fraction of height. */\n  cursor?: [number, number, number, number];\n}\n",
      "type": "registry:lib",
      "target": "lib/dotgrain/types.ts"
    }
  ],
  "type": "registry:lib"
}