import { FieldKit, bayer8, clamp } from "./fields.js";
import { gestaltArcSupport, sampleGlyph } from "./glyphs.js";
import {
  canvasGridLayout,
  normalizePreviewMode,
} from "./preview-layout.js";
import { SPRITES, getSprite, listSprites } from "./sprites.js";
import {
  validateFieldId,
  validatePixelShape,
  validatePixelSwitch,
  validateRecipe,
  validateSpriteId,
  validateTransition,
  validateEngineOptions,
} from "./config.js";

const TAU = Math.PI * 2;
const DEFAULT_PALETTE = Object.freeze({
  background: "#07080b",
  off: "#20232b",
  ink: "#f1f3f7",
  accent: "#8f9dff",
  glow: "#6678ff",
});

const POINTER_EFFECT_PROFILES = Object.freeze({
  "steer-light": { warp: 0.28, light: 1, surface: 0.35 },
  "steer-light-only": { warp: 0, light: 1, surface: 0 },
  "steer-highlight": { warp: 0.4, light: 0.9, surface: 0.72 },
  "bend-flow": { warp: 1, light: 0.35, surface: 0.6 },
  "bend-flow-and-light": { warp: 1, light: 0.9, surface: 0.58 },
  "bend-path-midpoint": { warp: 0.82, light: 0.45, surface: 0.7 },
  "bias-decorative-beam": { warp: 0.22, light: 0.75, surface: 0.45 },
  "edge-repel": { warp: -0.64, light: 0.4, surface: -0.3 },
  "edge-sparkle": { warp: 0.12, light: 0.9, surface: 0.82 },
  "expand-beacon": { warp: -0.36, light: 0.7, surface: 0.62 },
  "lateral-wind": { warp: 0.88, light: 0.3, surface: 0.44 },
  "lean-highlight": { warp: 0.3, light: 0.85, surface: 0.48 },
  "push-fragments": { warp: -0.72, light: 0.5, surface: 0.38 },
  "reveal-value": { warp: 0, light: 0.4, surface: 1 },
  "ripple-bias": { warp: 0.5, light: 0.5, surface: 0.72 },
  "separate-shells": { warp: -0.42, light: 0.55, surface: 0.5 },
  "tilt-light": { warp: 0.08, light: 1, surface: 0.15 },
  "turn-aperture": { warp: 0.55, light: 0.72, surface: 0.36 },
  "weak-echo": { warp: 0.18, light: 0.35, surface: 0.2 },
});

const PRESS_EFFECT_PROFILES = Object.freeze({
  acknowledge: { surface: 0.4 },
  "bar-squeeze": { surface: -0.38 },
  "contained-ripple": { surface: 0.78 },
  "core-pulse": { surface: 0.9 },
  "damped-vortex": { surface: 0.68 },
  "emit-inbound-ripple": { surface: 0.86 },
  "host-action": { surface: 0.18 },
  "host-retry": { surface: 0.28 },
  "mechanism-compress": { surface: -0.34 },
  "pinch-core": { surface: -0.5 },
  "spring-compress": { surface: 0.62 },
  "surface-ripple": { surface: 0.82 },
});

export const TRANSITIONS = Object.freeze([
  "field-morph",
  "seeded-dissolve",
  "radial-cascade",
  "angular-sweep",
  "scanline",
  "contour-trace",
  "path-draw",
  "axis-flip",
  "cluster-dissolve",
  "neighbor-ignite",
  "glitch-bands",
  "instant",
]);

export const PIXEL_SWITCHES = Object.freeze([
  "ordered-dither",
  "temporal-blue-noise",
  "threshold-hysteresis",
  "sdf-wavefront",
  "contour-trace",
  "curl-advect",
  "neighbor-propagation",
  "radial-cascade",
  "path-draw",
  "axis-flip",
  "seeded-dissolve",
  "field-morph",
]);

export const ORB_BACKGROUND_MODES = Object.freeze([
  "none",
  "solid",
  "pixelated",
]);

export const PIXEL_SHAPES = Object.freeze([
  "disc",
  "square",
  "diamond",
  "capsule",
  "line",
  "ring",
  "cross",
  "square-cross",
  "square-cross-ring",
]);

const DEFAULT_OPTIONS = Object.freeze({
  sprite: "ai.idle",
  seed: "joan-v5",
  gridSize: 68,
  speed: 1,
  density: 1,
  contrast: 1,
  fps: 60,
  pixelShape: null,
  pixelSwitch: null,
  transition: null,
  palette: null,
  nonErrorPalette: null,
  orbBoundary: "defined",
  orbBackgroundColor: null,
  orbBackgroundMode: "none",
  previewMode: "fit",
  background: true,
  offPixels: true,
  interactive: true,
  autoplay: true,
  autoResize: true,
  dprMax: 2,
  reducedMotion: "system",
  quality: "auto",
  ariaLive: null,
});

const clamp01 = (value) => clamp(value, 0, 1);
const fract = (value) => value - Math.floor(value);
const easeInOut = (value) => {
  const t = clamp01(value);
  return t * t * (3 - 2 * t);
};
const smoothRange = (edge0, edge1, value) =>
  edge0 === edge1
    ? value < edge0
      ? 0
      : 1
    : easeInOut((value - edge0) / (edge1 - edge0));
const expEase = (current, target, dt, tau) =>
  current + (target - current) * (1 - Math.exp(-dt / Math.max(0.001, tau)));

function hashNumber(value) {
  let h = 2166136261;
  const input = String(value);
  for (let index = 0; index < input.length; index += 1) {
    h ^= input.charCodeAt(index);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}

function hash01(value) {
  let n = value | 0;
  n ^= n >>> 16;
  n = Math.imul(n, 0x7feb352d);
  n ^= n >>> 15;
  n = Math.imul(n, 0x846ca68b);
  n ^= n >>> 16;
  return (n >>> 0) / 4294967296;
}

function normalizeTransition(value) {
  const id =
    typeof value === "string"
      ? value
      : value?.name || value?.type || value?.enter;
  const aliases = {
    dissolve: "seeded-dissolve",
    radial: "radial-cascade",
    bloom: "radial-cascade",
    spiral: "angular-sweep",
    wave: "scanline",
    shutter: "axis-flip",
    glitch: "glitch-bands",
    "sdf-wavefront": "contour-trace",
    "neighbor-propagation": "neighbor-ignite",
  };
  return TRANSITIONS.includes(id) ? id : aliases[id] || "field-morph";
}

function normalizePixelSwitch(value) {
  const id =
    typeof value === "string"
      ? value
      : value?.name || value?.type || value?.mode;
  const aliases = {
    "cluster-dissolve": "seeded-dissolve",
    "neighbor-ignite": "neighbor-propagation",
  };
  const normalized = aliases[id] || id;
  return PIXEL_SWITCHES.includes(normalized) ? normalized : "ordered-dither";
}

function normalizePixelShape(value) {
  const aliases = {
    circle: "disc",
    "rounded-square": "square",
    dot: "disc",
  };
  const normalized = aliases[value] || value;
  return PIXEL_SHAPES.includes(normalized) ? normalized : "disc";
}

const COMPOSITE_PIXEL_SHAPES = Object.freeze({
  "square-cross": Object.freeze(["square", "cross"]),
  "square-cross-ring": Object.freeze(["square", "cross", "ring"]),
});

function resolvePixelShape(shape, luminance) {
  const variants = COMPOSITE_PIXEL_SHAPES[shape];
  if (!variants) return shape;
  const bin = Math.min(8, Math.floor(clamp01(Number(luminance) || 0) * 8.999));
  if (variants.length === 2) return bin <= 4 ? variants[0] : variants[1];
  if (bin <= 4) return variants[0];
  if (bin <= 6) return variants[1];
  return variants[2];
}

function colorChannels(input, fallback) {
  const value = typeof input === "string" ? input.trim() : fallback;
  const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value);
  if (hex) {
    const raw =
      hex[1].length === 3
        ? hex[1]
            .split("")
            .map((part) => part + part)
            .join("")
        : hex[1];
    return [
      Number.parseInt(raw.slice(0, 2), 16),
      Number.parseInt(raw.slice(2, 4), 16),
      Number.parseInt(raw.slice(4, 6), 16),
    ];
  }
  const rgb = value?.match?.(/[\d.]+/g);
  if (rgb?.length >= 3) return rgb.slice(0, 3).map(Number);
  return colorChannels(fallback, "#ffffff");
}

function mixColor(from, to, amount, alpha = 1) {
  const t = clamp01(amount);
  const a = colorChannels(from, "#ffffff");
  const b = colorChannels(to, "#ffffff");
  return `rgba(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(
    a[1] + (b[1] - a[1]) * t,
  )},${Math.round(a[2] + (b[2] - a[2]) * t)},${clamp01(alpha).toFixed(3)})`;
}

function mixGlowColor(from, to, amount) {
  const t = clamp01(amount);
  const fromTransparent =
    String(from).trim().toLowerCase() === "transparent";
  const toTransparent = String(to).trim().toLowerCase() === "transparent";
  if (fromTransparent && toTransparent) return "transparent";
  if (fromTransparent) {
    const color = colorChannels(to, "#ffffff");
    return `rgba(${color[0]},${color[1]},${color[2]},${t.toFixed(3)})`;
  }
  if (toTransparent) {
    const color = colorChannels(from, "#ffffff");
    return `rgba(${color[0]},${color[1]},${color[2]},${(1 - t).toFixed(3)})`;
  }
  return mixColor(from, to, t, 1);
}

function xmlSafeString(value) {
  let output = "";
  for (const character of String(value)) {
    const codePoint = character.codePointAt(0);
    if (
      codePoint === 0x09 ||
      codePoint === 0x0a ||
      codePoint === 0x0d ||
      (codePoint >= 0x20 && codePoint <= 0xd7ff) ||
      (codePoint >= 0xe000 && codePoint <= 0xfffd) ||
      (codePoint >= 0x10000 && codePoint <= 0x10ffff)
    ) {
      output += character;
    } else {
      output += "\ufffd";
    }
  }
  return output;
}

function escapeXml(value) {
  return xmlSafeString(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&apos;");
}

function escapeXmlAttribute(value) {
  return escapeXml(value)
    .replaceAll("\t", "&#9;")
    .replaceAll("\n", "&#10;")
    .replaceAll("\r", "&#13;");
}

const SVG_COLOR_KEYWORDS = new Set([
  "aqua",
  "black",
  "blue",
  "fuchsia",
  "gray",
  "green",
  "grey",
  "lime",
  "maroon",
  "navy",
  "olive",
  "orange",
  "purple",
  "red",
  "silver",
  "teal",
  "transparent",
  "white",
  "yellow",
]);

function safeSvgColor(value, fallback) {
  const candidate = typeof value === "string" ? value.trim() : "";
  const normalized = candidate.toLowerCase();
  const isHex = /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(candidate);
  const functionMatch =
    /^(rgb|rgba|hsl|hsla)\(([\d\s.,%+/-]+)\)$/i.exec(candidate);
  const isNumericColorFunction =
    Boolean(functionMatch) && /\d/.test(functionMatch[2]);

  if (
    isHex ||
    SVG_COLOR_KEYWORDS.has(normalized) ||
    isNumericColorFunction
  ) {
    return candidate;
  }
  return fallback;
}

const ORB_BACKGROUND_RUN_BASE = 128;

function orbBackgroundRowRun(gridSize, radius, row) {
  const size = Math.max(1, Math.round(Number(gridSize) || 1));
  const safeRadius = Math.max(0, Number(radius) || 0);
  const y = -1 + ((row + 0.5) * 2) / size;
  if (Math.abs(y) > safeRadius) return 0;
  const halfWidth = Math.sqrt(Math.max(0, safeRadius ** 2 - y ** 2));
  const start = Math.max(
    0,
    Math.ceil(((1 - halfWidth) * size) / 2 - 0.5),
  );
  const end = Math.min(
    size - 1,
    Math.floor(((1 + halfWidth) * size) / 2 - 0.5),
  );
  if (end < start) return 0;
  return start * ORB_BACKGROUND_RUN_BASE + (end - start + 1);
}

function drawPixelatedOrbBackground(
  context,
  gridSize,
  radius,
  offsetX,
  offsetY,
  cell,
) {
  for (let row = 0; row < gridSize; row += 1) {
    const run = orbBackgroundRowRun(gridSize, radius, row);
    const count = run % ORB_BACKGROUND_RUN_BASE;
    if (!count) continue;
    const start = (run - count) / ORB_BACKGROUND_RUN_BASE;
    context.fillRect(
      offsetX + start * cell,
      offsetY + row * cell,
      count * cell,
      cell,
    );
  }
}

function svgPixelatedOrbBackground(
  gridSize,
  radius,
  offset,
  cell,
  color,
) {
  const rows = [];
  for (let row = 0; row < gridSize; row += 1) {
    const run = orbBackgroundRowRun(gridSize, radius, row);
    const count = run % ORB_BACKGROUND_RUN_BASE;
    if (!count) continue;
    const start = (run - count) / ORB_BACKGROUND_RUN_BASE;
    rows.push(
      `<rect x="${(offset + start * cell).toFixed(3)}" y="${(
        offset + row * cell
      ).toFixed(3)}" width="${(count * cell).toFixed(3)}" height="${cell.toFixed(
        3,
      )}"/>`,
    );
  }
  if (!rows.length) return "";
  return `<g data-joan-layer="orb-background" data-joan-mode="pixelated" fill="${escapeXmlAttribute(
    color,
  )}" shape-rendering="crispEdges">${rows.join("")}</g>`;
}

function animatedSvgPixelatedOrbBackground(
  gridSize,
  radiusSamples,
  offset,
  cell,
  color,
  keyTimes,
  duration,
) {
  const rows = [];
  for (let row = 0; row < gridSize; row += 1) {
    const runs = Array.from(radiusSamples, (radius) =>
      orbBackgroundRowRun(gridSize, radius, row),
    );
    if (!runs.some(Boolean)) continue;
    const xValues = runs.map((run) => {
      const count = run % ORB_BACKGROUND_RUN_BASE;
      const start = count
        ? (run - count) / ORB_BACKGROUND_RUN_BASE
        : gridSize / 2;
      return (offset + start * cell).toFixed(3);
    });
    const widthValues = runs.map(
      (run) => ((run % ORB_BACKGROUND_RUN_BASE) * cell).toFixed(3),
    );
    rows.push(
      `<rect x="${xValues[0]}" y="${(offset + row * cell).toFixed(
        3,
      )}" width="${widthValues[0]}" height="${cell.toFixed(3)}"><animate attributeName="x" values="${xValues.join(
        ";",
      )}" keyTimes="${keyTimes}" dur="${duration}s" calcMode="discrete" repeatCount="indefinite"/><animate attributeName="width" values="${widthValues.join(
        ";",
      )}" keyTimes="${keyTimes}" dur="${duration}s" calcMode="discrete" repeatCount="indefinite"/></rect>`,
    );
  }
  if (!rows.length) return "";
  return `<g data-joan-layer="orb-background" data-joan-mode="pixelated" fill="${escapeXmlAttribute(
    color,
  )}" shape-rendering="crispEdges">${rows.join("")}</g>`;
}

function svgPalette(recipe, override) {
  const palette = recipePalette(recipe, override);
  return {
    background: safeSvgColor(
      palette.background,
      DEFAULT_PALETTE.background,
    ),
    off: safeSvgColor(palette.off, DEFAULT_PALETTE.off),
    ink: safeSvgColor(palette.ink, DEFAULT_PALETTE.ink),
    accent: safeSvgColor(palette.accent, DEFAULT_PALETTE.accent),
    glow: safeSvgColor(palette.glow, DEFAULT_PALETTE.glow),
  };
}

function svgShapeMarkup(
  shape,
  cx,
  cy,
  width,
  height,
  attributes = "",
  children = "",
  angle = 0,
  strokeWidth = null,
) {
  const x = Number(cx);
  const y = Number(cy);
  const w = Number(width);
  const h = Number(height);
  const attrs = attributes ? ` ${attributes}` : "";
  if (shape === "square") {
    return `<rect x="${(x - w / 2).toFixed(2)}" y="${(
      y -
      h / 2
    ).toFixed(2)}" width="${w.toFixed(2)}" height="${h.toFixed(
      2,
    )}"${attrs}>${children}</rect>`;
  }
  if (shape === "diamond") {
    const half = w * 0.52;
    return `<path d="M ${x.toFixed(2)} ${(y - half).toFixed(2)} L ${(
      x + half
    ).toFixed(2)} ${y.toFixed(2)} L ${x.toFixed(2)} ${(y + half).toFixed(
      2,
    )} L ${(x - half).toFixed(2)} ${y.toFixed(2)} Z"${attrs}>${children}</path>`;
  }
  if (shape === "capsule") {
    return `<rect x="${(x - w / 2).toFixed(2)}" y="${(
      y -
      h * 0.32
    ).toFixed(2)}" width="${w.toFixed(2)}" height="${(h * 0.64).toFixed(
      2,
    )}" rx="${(h * 0.32).toFixed(2)}"${attrs}>${children}</rect>`;
  }
  if (shape === "line") {
    return `<rect x="${(x - w / 2).toFixed(2)}" y="${(
      y -
      h * 0.11
    ).toFixed(2)}" width="${w.toFixed(2)}" height="${(h * 0.22).toFixed(
      2,
    )}" transform="rotate(${angle.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(
      2,
    )})"${attrs}>${children}</rect>`;
  }
  if (shape === "ring") {
    const ringStroke = Number.isFinite(Number(strokeWidth))
      ? Number(strokeWidth)
      : Math.max(0.12, Math.min(w, h) * 0.14);
    return `<ellipse cx="${x.toFixed(2)}" cy="${y.toFixed(2)}" rx="${(
      w / 2
    ).toFixed(2)}" ry="${(h / 2).toFixed(
      2,
    )}" fill="none" stroke-width="${ringStroke.toFixed(
      3,
    )}" vector-effect="non-scaling-stroke"${attrs}>${children}</ellipse>`;
  }
  if (shape === "cross") {
    return `<g${attrs}><rect x="${(x - w / 2).toFixed(2)}" y="${(
      y -
      h * 0.1
    ).toFixed(2)}" width="${w.toFixed(2)}" height="${(h * 0.2).toFixed(
      2,
    )}"/><rect x="${(x - w * 0.1).toFixed(2)}" y="${(
      y -
      h / 2
    ).toFixed(2)}" width="${(w * 0.2).toFixed(2)}" height="${h.toFixed(
      2,
    )}"/>${children}</g>`;
  }
  return `<circle cx="${x.toFixed(2)}" cy="${y.toFixed(2)}" r="${(
    w / 2
  ).toFixed(2)}"${attrs}>${children}</circle>`;
}

function animatedSvgShapeMarkup(
  shape,
  width,
  height,
  luminances,
  keyTimes,
  duration,
  angle = 0,
  ringStrokeWidths = [],
) {
  const initialRingStroke = Number(ringStrokeWidths[0]) || null;
  const ringStrokeAnimation = ringStrokeWidths.length
    ? `<animate attributeName="stroke-width" values="${ringStrokeWidths.join(
        ";",
      )}" keyTimes="${keyTimes}" dur="${duration}s" calcMode="discrete" repeatCount="indefinite"/>`
    : "";
  const variants = COMPOSITE_PIXEL_SHAPES[shape];
  if (!variants) {
    return svgShapeMarkup(
      shape,
      0,
      0,
      width,
      height,
      "",
      shape === "ring" ? ringStrokeAnimation : "",
      angle,
      initialRingStroke,
    );
  }
  const resolved = Array.from(luminances, (luminance) =>
    resolvePixelShape(shape, luminance),
  );
  const layers = variants.map((variant) => {
    const visibility = resolved.map((active) => (active === variant ? "1" : "0"));
    const values = visibility.join(";");
    return `<g data-joan-shape="${variant}" opacity="${visibility[0]}"><animate attributeName="opacity" values="${values}" keyTimes="${keyTimes}" dur="${duration}s" calcMode="discrete" repeatCount="indefinite"/>${svgShapeMarkup(
      variant,
      0,
      0,
      width,
      height,
      "",
      variant === "ring" ? ringStrokeAnimation : "",
      angle,
      initialRingStroke,
    )}</g>`;
  });
  return `<g data-joan-composite="${shape}">${layers.join("")}</g>`;
}

function recipeValue(recipe, key, fallback) {
  const direct = recipe?.[key];
  if (direct !== undefined) return direct;
  if (recipe?.render?.[key] !== undefined) return recipe.render[key];
  if (recipe?.pixel?.[key] !== undefined) return recipe.pixel[key];
  if (key === "pixelShape" && recipe?.pixel?.shape !== undefined) {
    return recipe.pixel.shape;
  }
  if (key === "pixelScale" && recipe?.pixel?.scale !== undefined) {
    return recipe.pixel.scale;
  }
  return fallback;
}

function recipePalette(recipe, override) {
  const source = recipe?.palette || {};
  return {
    ...DEFAULT_PALETTE,
    ...source,
    off: source.off || source.shadow || DEFAULT_PALETTE.off,
    glow: source.glow || source.accent || DEFAULT_PALETTE.glow,
    ...(override || {}),
  };
}

function resolvePaletteOverride(recipe, candidate) {
  if (!candidate || typeof candidate !== "object") return null;
  const { variants, ...base } = candidate;
  const paletteName = recipe?.palette?.name;
  const variant =
    paletteName &&
    variants &&
    typeof variants === "object" &&
    variants[paletteName] &&
    typeof variants[paletteName] === "object"
      ? variants[paletteName]
      : null;
  return variant ? { ...variant, ...base } : base;
}

function paletteOverrideFor(recipe, options) {
  const globalPalette = resolvePaletteOverride(recipe, options?.palette);
  const nonErrorPalette =
    recipe?.id !== "status.error" &&
    options?.nonErrorPalette &&
    typeof options.nonErrorPalette === "object"
      ? resolvePaletteOverride(recipe, options.nonErrorPalette)
      : null;

  if (!globalPalette) return nonErrorPalette;
  if (!nonErrorPalette) return globalPalette;
  return { ...globalPalette, ...nonErrorPalette };
}

function activePalette(recipe, options) {
  return recipePalette(recipe, paletteOverrideFor(recipe, options));
}

function activeSvgPalette(recipe, options) {
  return svgPalette(recipe, paletteOverrideFor(recipe, options));
}

function mixPalette(from, to, amount) {
  const t = clamp01(amount);
  return {
    background: mixColor(from.background, to.background, t, 1),
    off: mixColor(from.off, to.off, t, 1),
    ink: mixColor(from.ink, to.ink, t, 1),
    accent: mixColor(from.accent, to.accent, t, 1),
    glow: mixGlowColor(from.glow, to.glow, t),
  };
}

function recipePixelSwitch(recipe, override) {
  if (override && typeof override === "object") {
    return { ...override, mode: normalizePixelSwitch(override) };
  }
  if (override) return { mode: normalizePixelSwitch(override) };
  if (recipe?.pixelSwitch && typeof recipe.pixelSwitch === "object") {
    return {
      ...recipe.pixelSwitch,
      mode: normalizePixelSwitch(recipe.pixelSwitch),
    };
  }
  return { mode: normalizePixelSwitch(recipe?.pixelSwitch) };
}

function recipePixelScale(recipe) {
  const scale = clamp(
    Number(recipeValue(recipe, "pixelScale", 0.76)),
    0.24,
    1.12,
  );
  const gap = clamp(Number(recipe?.pixel?.gap) || 0, 0, 0.72);
  return scale * (1 - gap);
}

function semanticLabel(recipe) {
  return (
    recipe?.semantic?.label ||
    recipe?.labels?.aria ||
    recipe?.labels?.default ||
    recipe?.label ||
    recipe?.id ||
    "Procedural AI glyph"
  );
}

function signalPayloadValue(type, payload = {}) {
  if (Array.isArray(payload.results)) return payload.results.length;
  if (type === "search.results" && Number.isFinite(Number(payload.results))) {
    return Number(payload.results);
  }
  if (type === "handoff.accepted" && payload.accepted !== undefined) {
    return payload.accepted ? 1 : 0;
  }
  if (type === "tool.wait" && payload.waiting !== undefined) {
    return payload.waiting ? 1 : 0;
  }
  const candidates = [
    payload.value,
    payload.energy,
    payload.count,
    payload.depth,
    payload.wait,
    payload.strength,
  ];
  for (const candidate of candidates) {
    const value = Number(candidate);
    if (Number.isFinite(value)) return value;
  }
  return 0.7;
}

function copyPixelBuffer(engine, recipe, recipeSource = engine.recipeSource) {
  return {
    recipe,
    recipeSource,
    phase: engine.phase,
    stateElapsed: engine.stateElapsed,
    values: engine.values.slice(),
    velocities: engine.velocities.slice(),
    targets: engine.targets.slice(),
    luminance: engine.luminance.slice(),
    gates: engine.gates.slice(),
    dwell: engine.dwell.slice(),
    afterglow: engine.afterglow.slice(),
    neighborBuffer: engine.neighborBuffer.slice(),
  };
}

function withFieldOverride(recipe, field) {
  return {
    ...recipe,
    field,
    fieldMix: [
      {
        field,
        weight: 1,
        scale: 1,
        blend: "replace",
        phase: 0,
        speed: 1,
        contrast: 1,
      },
    ],
  };
}

/**
 * Produces a stable 0..1 activation delay for every transition strategy.
 * Exported so product teams can preview or synchronize the engine's wavefronts.
 */
export function transitionDelay(
  transition,
  x,
  y,
  random = 0.5,
  origin = { x: 0, y: 0 },
) {
  const id = normalizeTransition(transition);
  const nx = clamp01((x + 1) * 0.5);
  const ny = clamp01((y + 1) * 0.5);
  const radius = clamp01(Math.hypot(x - origin.x, y - origin.y) / Math.SQRT2);
  const angle = fract((Math.atan2(y - origin.y, x - origin.x) + Math.PI) / TAU);

  switch (id) {
    case "seeded-dissolve":
      return random;
    case "radial-cascade":
      return clamp01(radius * 0.86 + random * 0.14);
    case "angular-sweep":
      return clamp01(angle * 0.78 + radius * 0.16 + random * 0.06);
    case "scanline":
      return clamp01(ny * 0.82 + Math.sin(x * 9) * 0.05 + random * 0.13);
    case "contour-trace":
      return clamp01(Math.abs(radius - 0.52) * 1.65 + random * 0.08);
    case "path-draw":
      return clamp01((nx * 0.42 + ny * 0.58) * 0.9 + random * 0.1);
    case "axis-flip":
      return clamp01((Math.floor(nx * 12) % 2) * 0.18 + ny * 0.72 + random * 0.1);
    case "cluster-dissolve":
      return clamp01(
        fract(Math.sin(Math.floor(x * 5) * 91.7 + Math.floor(y * 5) * 43.1) * 991.31) *
          0.72 +
          random * 0.28,
      );
    case "neighbor-ignite":
      return clamp01(radius * 0.58 + (nx + ny) * 0.16 + random * 0.1);
    case "glitch-bands":
      return clamp01(fract(Math.floor(ny * 11) * 0.381 + random * 0.29));
    case "instant":
      return 0;
    case "field-morph":
    default:
      return clamp01(random * 0.42 + radius * 0.28 + ny * 0.3);
  }
}

/**
 * Canvas 2D renderer for deterministic, semantic pixel glyphs.
 *
 * The pipeline is:
 * glyph/SDF → procedural field → semantic envelope → transition wavefront →
 * ordered/temporal quantizer → hysteresis/dwell gate → spring → pixel renderer.
 */
export class JoanGlyphEngine extends EventTarget {
  static sprites = SPRITES;

  static listSprites() {
    return listSprites();
  }

  constructor(canvas, options = {}) {
    super();
    if (!canvas?.getContext) {
      throw new TypeError("JoanGlyphEngine requires a canvas element.");
    }

    this.canvas = canvas;
    this.context = canvas.getContext("2d", {
      alpha: true,
      desynchronized: true,
    });
    if (!this.context) throw new Error("Canvas 2D is not available.");

    const normalizedOptions = validateEngineOptions(options, {
      strict: true,
      coerce: false,
      allowUnknown: false,
    });
    this.options = { ...DEFAULT_OPTIONS, ...normalizedOptions };
    if (!Object.hasOwn(normalizedOptions, "orbBackgroundMode")) {
      this.options.orbBackgroundMode = this.options.orbBackgroundColor
        ? "solid"
        : "none";
    }
    if (Object.hasOwn(options, "paletteOverride")) {
      this.options.palette =
        options.paletteOverride && typeof options.paletteOverride === "object"
          ? { ...options.paletteOverride }
          : null;
    }
    if (
      this.options.nonErrorPalette &&
      typeof this.options.nonErrorPalette === "object"
    ) {
      this.options.nonErrorPalette = { ...this.options.nonErrorPalette };
    }
    this.options.gridSize = Math.round(
      clamp(Number(this.options.gridSize) || 68, 8, 96),
    );
    this.options.speed = clamp(Number(this.options.speed) || 1, 0.05, 5);
    this.options.density = clamp(Number(this.options.density) || 1, 0.35, 1.6);
    this.options.previewMode = normalizePreviewMode(this.options.previewMode);

    this.seed = String(this.options.seed);
    this.seedHash = hashNumber(this.seed);
    this.fieldKit = new FieldKit(this.seed);
    this.customGlyphs = new Map();
    this.glyphSampler = (id, x, y, time, context) =>
      this.sampleCustomGlyph(id, x, y, time, context);
    this.fieldOptionsScratch = {};
    this.recipeMetadataCache = new WeakMap();
    this.semanticEnvelopeCache = new WeakMap();
    this.paintColorCache = {
      key: null,
      off: null,
      source: null,
      target: null,
      steady: null,
      transition: null,
    };
    this.pixelSwitchCache = { recipe: null, override: null, value: null };
    this.clock = 0;
    this.phase = 0;
    this.stateElapsed = 0;
    this.progress = clamp01(Number(this.options.progress) || 0);
    this.audioLevel = 0;
    this.audioTarget = 0;
    this.activityEnergy = 0;
    this.press = 0;
    this.running = false;
    this.destroyed = false;
    this.visible = true;
    this.documentVisible = true;
    this.intersectionVisible = true;
    this.frameHandle = null;
    this.frameTick = null;
    this.lastFrameTime = null;
    this.lastPaintTime = -Infinity;
    this.simulationAccumulator = 0;
    this.simulationStep =
      1 / Math.min(60, clamp(Number(this.options.fps) || 60, 1, 120));
    this.maxSimulationSteps = 12;
    this.maxCatchUpSeconds = 0.2;
    this.qualitySamplePhase = 0;
    this.transitionElapsed = Infinity;
    this.transitionDuration = 0;
    this.transitionName = "instant";
    this.transitionSerial = 0;
    this.completedTransitionSerial = 0;
    this.pendingSpriteTransition = null;
    this.preservedTransition = null;
    this.reducedCrossfade = null;
    this.reducedFrameHandle = null;
    this.configurationBatchDepth = 0;
    this.configurationNeedsRender = false;
    this.pendingConfigurationDetail = null;
    this.impulses = [];
    this.signals = new Map();
    this.semanticSignals = new Map();
    this.semanticContext = Object.create(null);
    this.signalState = {
      accepted: false,
      direction: this.options.direction || "down",
      hits: 0,
      retry: 0,
      rotationSpeed: 1,
      segments: 8,
      signalStrength: 0,
      steps: 12,
      targetGlyph: this.options.targetGlyph || null,
      toolPhase: 0,
    };
    this.listeners = [];
    this.interactionListeners = [];
    this.qualityTier = this.options.quality === "auto" ? "high" : this.options.quality;
    this.qualityPressure = 0;
    this.qualityRecovery = 0;
    this.stats = {
      fps: 0,
      activePixels: 0,
      frameMs: 0,
      resolution: 0,
      simulationSteps: 0,
      sampledPixels: 0,
      quality: this.qualityTier,
    };
    this.fpsAccumulator = { time: 0, frames: 0 };
    this.pointerSampleScratch = { x: 0, y: 0, speed: 0 };
    this.sampleContextScratch = Object.create(null);
    this.sampleToScratch = Object.create(null);
    this.sampleFromScratch = Object.create(null);
    this.pointer = {
      x: 0,
      y: 0,
      targetX: 0,
      targetY: 0,
      speed: 0,
      inside: false,
      down: false,
      lastX: 0,
      lastY: 0,
      lastTime: 0,
    };

    this.reducedMotion = this.resolveReducedMotion();
    this.currentRecipe = this.resolveRecipe(this.options.sprite);
    this.recipeSource = this.currentRecipe;
    this.fieldOverride =
      this.options.fieldMode === "recipe" ? null : this.options.field || null;
    if (this.fieldOverride) {
      this.currentRecipe = withFieldOverride(
        this.currentRecipe,
        this.fieldOverride,
      );
    }
    this.previousRecipe = this.currentRecipe;
    this.allocate(this.options.gridSize);
    this.bindCanvas();
    this.observe();
    this.resize(true);
    this.updateAria();
    this.renderFrame(0, 0, true);
    if (this.options.autoplay && !this.reducedMotion) this.play();
  }

  resolveReducedMotion() {
    if (this.options.reducedMotion === true) return true;
    if (this.options.reducedMotion === false) return false;
    return Boolean(
      typeof window !== "undefined" &&
        window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches,
    );
  }

  resolveRecipe(input) {
    if (input && typeof input === "object") return validateRecipe(input);
    const id = validateSpriteId(input ?? "ai.idle");
    return getSprite(id);
  }

  allocate(size) {
    const count = size * size;
    this.gridSize = size;
    this.values = new Float32Array(count);
    this.velocities = new Float32Array(count);
    this.targets = new Float32Array(count);
    this.luminance = new Float32Array(count);
    this.gates = new Uint8Array(count);
    this.dwell = new Float32Array(count);
    this.afterglow = new Float32Array(count);
    this.random = new Float32Array(count);
    this.xs = new Float32Array(count);
    this.ys = new Float32Array(count);
    this.neighborBuffer = new Float32Array(count);
    this.transitionMixes = new Float32Array(count);
    this.transitionMixes.fill(1);

    for (let y = 0; y < size; y += 1) {
      for (let x = 0; x < size; x += 1) {
        const index = y * size + x;
        this.xs[index] = ((x + 0.5) / size) * 2 - 1;
        this.ys[index] = ((y + 0.5) / size) * 2 - 1;
        this.random[index] = hash01(index ^ this.seedHash);
        this.dwell[index] = this.random[index] * 0.08;
      }
    }
    this.stats.resolution = size;
  }

  bind(target, type, handler, options) {
    target?.addEventListener?.(type, handler, options);
    this.listeners.push(() => target?.removeEventListener?.(type, handler, options));
  }

  bindInteraction(target, type, handler, options) {
    target?.addEventListener?.(type, handler, options);
    this.interactionListeners.push(() =>
      target?.removeEventListener?.(type, handler, options),
    );
  }

  activate(options = {}) {
    const x = clamp(Number(options.x) || 0, -1, 1);
    const y = clamp(Number(options.y) || 0, -1, 1);
    const source = options.source || "programmatic";
    const pressConfig = this.currentRecipe?.interactions?.press || {};

    if (pressConfig.enabled !== false) {
      this.press = 1;
      const pressStrength = clamp(Number(pressConfig.strength ?? 0.07), 0, 1);
      this.emitImpulse("press", {
        x,
        y,
        energy: clamp01(
          options.energy === undefined
            ? 0.42 + pressStrength * 6.8
            : Number(options.energy),
        ),
        life: clamp(
          options.life === undefined
            ? Number(pressConfig.durationMs) / 1000 || 0.18
            : Number(options.life),
          0.1,
          4,
        ),
      });
    }
    if (typeof pressConfig.emits === "string" && pressConfig.emits) {
      this.signal(pressConfig.emits, {
        value: 1,
        x,
        y,
        source,
      });
    }
    this.emit("activate", {
      sprite: this.currentRecipe.id,
      x,
      y,
      source,
      key: options.key || null,
    });
    this.renderWhenPaused();
    return this;
  }

  bindCanvas() {
    if (!this.options.interactive) return;

    const move = (event) => {
      const rect = this.canvas.getBoundingClientRect();
      if (!rect.width || !rect.height) return;
      const now = event.timeStamp / 1000 || performance.now() / 1000;
      const nextX = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
      const nextY = clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1);
      const dt = Math.max(1 / 240, now - (this.pointer.lastTime || now));
      const distance = Math.hypot(nextX - this.pointer.lastX, nextY - this.pointer.lastY);
      this.pointer.speed = Math.min(
        2,
        this.pointer.speed * 0.5 + (distance / dt) * 0.035,
      );
      this.pointer.targetX = nextX;
      this.pointer.targetY = nextY;
      this.pointer.lastX = nextX;
      this.pointer.lastY = nextY;
      this.pointer.lastTime = now;
      this.pointer.inside = true;
    };
    const enter = (event) => {
      this.pointer.inside = true;
      move(event);
    };
    const leave = () => {
      this.pointer.inside = false;
      this.pointer.down = false;
      this.pointer.targetX = 0;
      this.pointer.targetY = 0;
    };
    const down = (event) => {
      move(event);
      this.pointer.down = true;
      this.activate({
        x: this.pointer.targetX,
        y: this.pointer.targetY,
        source: "pointer",
      });
      this.canvas.setPointerCapture?.(event.pointerId);
    };
    const up = (event) => {
      this.pointer.down = false;
      this.canvas.releasePointerCapture?.(event.pointerId);
    };
    const keydown = (event) => {
      const isEnter = event.key === "Enter";
      const isSpace = event.key === " " || event.key === "Spacebar";
      if ((!isEnter && !isSpace) || event.repeat) return;
      if (isSpace) event.preventDefault?.();
      this.activate({
        source: "keyboard",
        key: isEnter ? "Enter" : "Space",
      });
    };

    this.bindInteraction(this.canvas, "pointermove", move, { passive: true });
    this.bindInteraction(this.canvas, "pointerenter", enter, { passive: true });
    this.bindInteraction(this.canvas, "pointerleave", leave, { passive: true });
    this.bindInteraction(this.canvas, "pointerdown", down, { passive: true });
    this.bindInteraction(this.canvas, "pointerup", up, { passive: true });
    this.bindInteraction(this.canvas, "pointercancel", up, { passive: true });
    this.bindInteraction(this.canvas, "keydown", keydown);
  }

  observe() {
    if (typeof document !== "undefined") {
      this.documentVisible = !document.hidden;
      this.visible = this.documentVisible && this.intersectionVisible;
    }
    if (this.options.autoResize && typeof ResizeObserver !== "undefined") {
      this.resizeObserver = new ResizeObserver(() => this.resize());
      this.resizeObserver.observe(this.canvas);
    }

    if (typeof IntersectionObserver !== "undefined") {
      this.intersectionObserver = new IntersectionObserver((entries) => {
        const wasVisible = this.visible;
        this.intersectionVisible = entries[0]?.isIntersecting !== false;
        this.visible = this.documentVisible && this.intersectionVisible;
        if (this.visible !== wasVisible) {
          this.lastFrameTime = null;
          this.simulationAccumulator = 0;
          if (this.visible) this.scheduleFrame();
          else this.cancelScheduledFrame();
        }
      });
      this.intersectionObserver.observe(this.canvas);
    }

    if (typeof window !== "undefined") {
      const media = window.matchMedia?.("(prefers-reduced-motion: reduce)");
      if (media) {
        this.motionMedia = media;
        const update = () => {
          if (this.options.reducedMotion !== "system") return;
          this.reducedMotion = media.matches;
          if (this.reducedMotion) {
            this.pause();
            this.renderFrame(this.clock, 0, true);
          } else if (this.options.autoplay) {
            this.play();
          }
        };
        this.bind(media, "change", update);
      }
      this.bind(document, "visibilitychange", () => {
        const wasVisible = this.visible;
        this.documentVisible = !document.hidden;
        this.visible = this.documentVisible && this.intersectionVisible;
        this.lastFrameTime = null;
        if (this.visible !== wasVisible) {
          this.simulationAccumulator = 0;
          if (this.visible) this.scheduleFrame();
          else this.cancelScheduledFrame();
        }
      });
    }
  }

  resize(force = false) {
    const rect = this.canvas.getBoundingClientRect?.() || {};
    const cssWidth = Math.max(
      1,
      Number(rect.width || this.canvas.clientWidth || this.canvas.width || 512),
    );
    const cssHeight = Math.max(
      1,
      Number(rect.height || this.canvas.clientHeight || this.canvas.height || 512),
    );
    const dpr = Math.min(
      this.options.dprMax,
      typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
    );
    const nextWidth = Math.round(cssWidth * dpr);
    const nextHeight = Math.round(cssHeight * dpr);
    if (
      force ||
      this.canvas.width !== nextWidth ||
      this.canvas.height !== nextHeight ||
      this.width !== cssWidth ||
      this.height !== cssHeight
    ) {
      this.canvas.width = nextWidth;
      this.canvas.height = nextHeight;
      this.width = cssWidth;
      this.height = cssHeight;
      this.dpr = dpr;
      this.renderScaleX = nextWidth / cssWidth;
      this.renderScaleY = nextHeight / cssHeight;
      this.context.setTransform(
        this.renderScaleX,
        0,
        0,
        this.renderScaleY,
        0,
        0,
      );
      this.context.imageSmoothingEnabled = false;
      this.paint();
    }
  }

  updateAria() {
    const label = semanticLabel(this.currentRecipe);
    if (this.options.interactive) {
      this.canvas.setAttribute?.("role", "button");
      this.canvas.setAttribute?.("tabindex", "0");
    } else {
      this.canvas.setAttribute?.("role", "img");
      this.canvas.removeAttribute?.("tabindex");
    }
    this.canvas.setAttribute?.("aria-label", `${label} — animated procedural glyph`);
    if (this.options.ariaLive) {
      const announcement =
        this.currentRecipe?.semantic?.announce ||
        this.currentRecipe?.labels?.live ||
        "polite";
      if (["polite", "assertive", "off"].includes(announcement)) {
        this.options.ariaLive.setAttribute?.("aria-live", announcement);
      }
      this.options.ariaLive.textContent = label;
    }
  }

  emit(type, detail) {
    if (type === "configchange" && this.configurationBatchDepth > 0) {
      this.pendingConfigurationDetail = {
        ...(this.pendingConfigurationDetail || {}),
        ...(detail || {}),
      };
      return;
    }
    let event;
    if (typeof CustomEvent === "function") {
      event = new CustomEvent(type, { detail });
    } else {
      event = new Event(type);
      Object.defineProperty(event, "detail", { value: detail });
    }
    this.dispatchEvent(event);
  }

  on(type, listener, options) {
    this.addEventListener(type, listener, options);
    return () => this.removeEventListener(type, listener, options);
  }

  isTransitionActive() {
    return (
      this.transitionDuration > 0 &&
      this.transitionElapsed < this.transitionDuration &&
      this.previousRecipe !== this.currentRecipe
    );
  }

  finishTransition(reason = "completed") {
    const serial = this.transitionSerial;
    if (!serial || this.completedTransitionSerial === serial) {
      this.previousRecipe = this.currentRecipe;
      this.transitionMixes?.fill?.(1);
      return null;
    }
    const detail = {
      from: this.previousRecipe?.id || null,
      to: this.currentRecipe?.id || null,
      duration: this.transitionDuration,
      elapsed: Math.min(this.transitionElapsed, this.transitionDuration),
      transition: this.transitionName,
      status: reason === "interrupted" ? "interrupted" : "completed",
      reason,
      serial,
    };
    this.transitionElapsed = this.transitionDuration;
    this.previousRecipe = this.currentRecipe;
    this.transitionMixes?.fill?.(1);
    this.completedTransitionSerial = serial;
    this.emit("transitioncomplete", detail);
    return detail;
  }

  whenTransitionComplete(options = {}) {
    const signal = options.signal;
    if (!this.isTransitionActive()) {
      return Promise.resolve({
        from: this.previousRecipe?.id || null,
        to: this.currentRecipe?.id || null,
        duration: this.transitionDuration,
        elapsed: this.transitionElapsed,
        transition: this.transitionName,
        status: "completed",
        reason: "already-settled",
        serial: this.transitionSerial,
      });
    }
    const serial = this.transitionSerial;
    return new Promise((resolve, reject) => {
      const abort = () => {
        cleanup();
        const error = new Error("Transition wait was aborted.");
        error.name = "AbortError";
        reject(error);
      };
      const complete = (event) => {
        if (event.detail?.serial !== serial) return;
        cleanup();
        resolve(event.detail);
      };
      const cleanup = () => {
        this.removeEventListener("transitioncomplete", complete);
        signal?.removeEventListener?.("abort", abort);
      };
      if (signal?.aborted) {
        abort();
        return;
      }
      this.addEventListener("transitioncomplete", complete);
      signal?.addEventListener?.("abort", abort, { once: true });
    });
  }

  transitionTo(sprite, options = {}) {
    const target =
      typeof sprite === "string" ? validateSpriteId(sprite) : validateRecipe(sprite);
    const targetId = typeof target === "string" ? target : target.id;
    const signal = options.signal;
    return new Promise((resolve, reject) => {
      const abort = () => {
        cleanup();
        const error = new Error(`Transition to "${targetId}" was aborted.`);
        error.name = "AbortError";
        reject(error);
      };
      const complete = (event) => {
        if (event.detail?.to !== targetId) return;
        cleanup();
        resolve(event.detail);
      };
      const cleanup = () => {
        this.removeEventListener("transitioncomplete", complete);
        signal?.removeEventListener?.("abort", abort);
      };
      if (signal?.aborted) {
        abort();
        return;
      }
      this.addEventListener("transitioncomplete", complete);
      signal?.addEventListener?.("abort", abort, { once: true });
      try {
        const { signal: _signal, ...transitionOptions } = options;
        this.setSprite(target, transitionOptions);
      } catch (error) {
        cleanup();
        reject(error);
      }
    });
  }

  clearSemanticSignals() {
    this.semanticSignals.clear();
    this.semanticContext = Object.create(null);
    this.sampleContextScratch = Object.create(null);
    this.signalState.accepted = false;
    this.signalState.hits = 0;
    this.signalState.retry = 0;
    this.signalState.signalStrength = 0;
    this.signalState.steps = 12;
  }

  applyRecipeSignal(type, payload, rawValue) {
    const declaration = this.currentRecipe?.interactions?.signals?.find?.(
      (signal) => signal.name === type,
    );
    if (!declaration?.mapsTo) return null;

    let mappedValue;
    if (Array.isArray(declaration.values)) {
      const candidate = String(
        payload.value ?? payload.direction ?? rawValue,
      ).toLowerCase();
      mappedValue = declaration.values.includes(candidate)
        ? candidate
        : declaration.values[0];
    } else {
      const [minimum = 0, maximum = 1] = declaration.range || [0, 1];
      mappedValue = clamp(Number(rawValue), Number(minimum), Number(maximum));
      const previous = Number(this.semanticContext[declaration.mapsTo]);
      if (declaration.monotonic && Number.isFinite(previous)) {
        mappedValue = Math.max(previous, mappedValue);
      }
      if (declaration.latches && Number.isFinite(previous) && previous > 0) {
        mappedValue = Math.max(previous, mappedValue);
      }
    }

    const entry = {
      declaration,
      value: mappedValue,
      decaySeconds: Math.max(0, Number(declaration.decayMs) || 0) / 1000,
    };
    this.semanticSignals.set(declaration.mapsTo, entry);
    this.semanticContext[declaration.mapsTo] = mappedValue;

    switch (declaration.mapsTo) {
      case "hitCount":
        this.signalState.hits = Math.max(0, Math.round(Number(mappedValue) || 0));
        break;
      case "accepted":
        this.signalState.accepted = Number(mappedValue) > 0;
        break;
      case "retryEnergy":
        this.signalState.retry = clamp01(Number(mappedValue) || 0);
        break;
      case "signalStrength":
        this.signalState.signalStrength = clamp01(Number(mappedValue) || 0);
        break;
      case "direction":
        this.signalState.direction = String(mappedValue);
        break;
      default:
        break;
    }
    return entry;
  }

  restorePixelBuffer(snapshot) {
    if (!snapshot || snapshot.values?.length !== this.values.length) return false;
    this.values.set(snapshot.values);
    this.velocities.set(snapshot.velocities);
    this.targets.set(snapshot.targets);
    this.luminance.set(snapshot.luminance);
    this.gates.set(snapshot.gates);
    this.dwell.set(snapshot.dwell);
    this.afterglow.set(snapshot.afterglow);
    this.neighborBuffer.set(snapshot.neighborBuffer);
    this.phase = snapshot.phase;
    this.stateElapsed = snapshot.stateElapsed;
    return true;
  }

  cancelReducedCrossfade() {
    if (this.reducedFrameHandle !== null && typeof cancelAnimationFrame === "function") {
      cancelAnimationFrame(this.reducedFrameHandle);
    }
    this.reducedFrameHandle = null;
    this.reducedCrossfade = null;
  }

  startReducedCrossfade(sourceSnapshot) {
    if (
      !sourceSnapshot ||
      this.currentRecipe?.reducedMotion?.transition === "instant" ||
      typeof requestAnimationFrame !== "function"
    ) {
      return false;
    }

    this.settleGrid(this.clock, { completeTransition: false });
    const targetSnapshot = copyPixelBuffer(
      this,
      this.currentRecipe,
      this.recipeSource,
    );
    this.restorePixelBuffer(sourceSnapshot);
    this.previousRecipe = sourceSnapshot.recipe || this.previousRecipe;
    this.transitionElapsed = 0;
    this.transitionMixes.fill(0);
    const crossfade = {
      source: sourceSnapshot,
      target: targetSnapshot,
      startedAt: null,
      duration: Math.max(0.001, this.transitionDuration),
    };
    this.reducedCrossfade = crossfade;

    const tick = (timestamp) => {
      if (this.destroyed || this.reducedCrossfade !== crossfade) return;
      if (crossfade.startedAt === null) crossfade.startedAt = timestamp;
      const progress = easeInOut(
        (timestamp - crossfade.startedAt) / 1000 / crossfade.duration,
      );
      let activePixels = 0;
      for (let index = 0; index < this.values.length; index += 1) {
        this.values[index] =
          crossfade.source.values[index] +
          (crossfade.target.values[index] - crossfade.source.values[index]) *
            progress;
        this.luminance[index] =
          crossfade.source.luminance[index] +
          (crossfade.target.luminance[index] -
            crossfade.source.luminance[index]) *
            progress;
        this.transitionMixes[index] = progress;
        if (this.values[index] > 0.32) activePixels += 1;
      }
      this.transitionElapsed = progress * crossfade.duration;
      this.stats.activePixels = activePixels;
      this.paint();

      if (progress < 1) {
        this.reducedFrameHandle = requestAnimationFrame(tick);
        return;
      }
      this.restorePixelBuffer(crossfade.target);
      this.reducedCrossfade = null;
      this.reducedFrameHandle = null;
      this.transitionElapsed = this.transitionDuration;
      this.finishTransition("reduced-motion-crossfade");
      this.paint();
    };

    this.reducedFrameHandle = requestAnimationFrame(tick);
    return true;
  }

  resume(options = {}) {
    if (this.currentRecipe?.id !== "status.paused") return this;
    const pausedRecipe = this.currentRecipe;
    const snapshot = this.preservedTransition;
    const destination =
      options.sprite ||
      snapshot?.recipeSource ||
      snapshot?.recipe ||
      getSprite("ai.idle");
    const exitDuration =
      Number(pausedRecipe?.transition?.exitDurationMs) /
        1000 || Number(pausedRecipe?.transition?.durationMs) / 1000 || 0.28;

    this.setSprite(destination, {
      duration: options.duration ?? exitDuration,
      transition: options.transition || pausedRecipe?.transition?.exit || "axis-flip",
      preservePhase: true,
      forceTransition: true,
      skipPreserveCapture: true,
      skipPausedRender: true,
    });
    const restored = this.restorePixelBuffer(snapshot);
    this.previousRecipe = pausedRecipe;
    this.transitionElapsed = 0;
    this.preservedTransition = null;
    this.paint();
    this.emit("resume", {
      from: pausedRecipe.id,
      to: this.currentRecipe.id,
      restoredBuffer: restored,
      source: options.source || "signal",
    });
    return this;
  }

  setSprite(sprite, options = {}) {
    const transitionActive = this.isTransitionActive();
    if (
      transitionActive &&
      this.currentRecipe?.transition?.interruptible === false &&
      !options.forceTransition
    ) {
      this.pendingSpriteTransition = {
        sprite,
        options: { ...options },
      };
      this.emit("transitionqueued", {
        from: this.currentRecipe?.id,
        to: typeof sprite === "string" ? sprite : sprite?.id,
      });
      return this;
    }
    if (this.reducedCrossfade) this.cancelReducedCrossfade();
    if (transitionActive) this.finishTransition("interrupted");

    const resolved =
      sprite === this.currentRecipe && this.recipeSource
        ? this.recipeSource
        : this.resolveRecipe(sprite);
    if (!resolved) throw new RangeError(`Unknown sprite: ${sprite}`);
    if (options.useRecipeFields) this.fieldOverride = null;
    const previousSource = this.recipeSource || this.currentRecipe;
    this.recipeSource = resolved;
    const next = this.fieldOverride
      ? withFieldOverride(resolved, this.fieldOverride)
      : resolved;
    const previous = this.currentRecipe;
    const reducedSourceSnapshot =
      this.reducedMotion && previous
        ? copyPixelBuffer(this, previous, previousSource)
        : null;
    const transitionConfig = next.transition || {};
    const preserveBuffer =
      options.preserveBuffer ?? transitionConfig.preserveBuffer ?? false;
    if (preserveBuffer && previous && !options.skipPreserveCapture) {
      this.preservedTransition = copyPixelBuffer(
        this,
        previous,
        previousSource,
      );
    } else if (!options.skipPreserveCapture) {
      this.preservedTransition = null;
    }
    this.previousRecipe = previous || next;
    this.currentRecipe = next;
    this.transitionSerial += 1;
    this.stateElapsed = 0;
    this.clearSemanticSignals();
    this.transitionElapsed = 0;
    const explicitTransition = options.transition || this.options.transition;
    const explicitTransitionConfig =
      explicitTransition && typeof explicitTransition === "object"
        ? explicitTransition
        : {};
    this.transitionDuration = this.reducedMotion
      ? clamp(
          Number(options.duration) ||
            Number(next?.reducedMotion?.transitionMs) / 1000 ||
            0.12,
          0,
          0.2,
        )
      : clamp(
          Number(options.duration) ||
            Number(explicitTransitionConfig.duration) ||
            Number(explicitTransitionConfig.durationMs) / 1000 ||
            Number(transitionConfig.duration) ||
            Number(transitionConfig.durationMs) / 1000 ||
            0.42,
          0,
          3,
        );
    const explicitTransitionName =
      explicitTransition && typeof explicitTransition === "object"
        ? explicitTransition.name ||
          explicitTransition.type ||
          explicitTransition.enter
        : explicitTransition;
    this.transitionName = normalizeTransition(
      (explicitTransitionName
        ? validateTransition(explicitTransitionName)
        : null) ||
        transitionConfig.name ||
        transitionConfig.type ||
        transitionConfig,
    );
    if (options.immediate) {
      this.transitionElapsed = this.transitionDuration;
      this.transitionName = "instant";
    }
    this.transitionMixes.fill(
      !options.immediate &&
        this.transitionDuration > 0 &&
        this.previousRecipe !== this.currentRecipe
        ? 0
        : 1,
    );
    const preservePhase =
      options.preservePhase ??
      explicitTransitionConfig.preservePhase ??
      transitionConfig.preservePhase ??
      true;
    if (!preservePhase) this.phase = 0;
    const reducedCrossfadeStarted =
      this.reducedMotion &&
      !options.immediate &&
      this.transitionDuration > 0 &&
      this.startReducedCrossfade(reducedSourceSnapshot);
    this.updateAria();
    this.emit("spritechange", {
      from: previous?.id,
      to: next.id,
      label: semanticLabel(next),
    });
    if (!this.running) {
      if (this.configurationBatchDepth > 0) {
        this.configurationNeedsRender = true;
      } else if (
        reducedCrossfadeStarted ||
        preserveBuffer ||
        options.skipPausedRender
      ) {
        this.paint();
      } else {
        this.renderFrame(this.clock, 1 / 60, true);
      }
    }
    if (
      this.configurationBatchDepth === 0 &&
      !this.isTransitionActive() &&
      !reducedCrossfadeStarted
    ) {
      this.finishTransition(options.immediate ? "immediate" : "settled");
    }
    return this;
  }

  setField(field) {
    const canonicalField = validateFieldId(field);
    this.fieldOverride = canonicalField;
    this.currentRecipe = withFieldOverride(
      this.recipeSource || this.currentRecipe,
      canonicalField,
    );
    this.emit("configchange", { field: canonicalField });
    if (!this.running && this.configurationBatchDepth === 0) {
      this.renderFrame(this.clock, 1 / 60, true);
    } else if (this.configurationBatchDepth > 0) {
      this.configurationNeedsRender = true;
    }
    return this;
  }

  useRecipeFields() {
    if (!this.fieldOverride) return this;
    this.fieldOverride = null;
    this.setSprite(this.recipeSource || this.currentRecipe, {
      transition: "field-morph",
      preservePhase: true,
      useRecipeFields: true,
    });
    this.emit("configchange", { field: null, fieldMode: "recipe" });
    return this;
  }

  setPixelSwitch(pixelSwitch) {
    const requested =
      typeof pixelSwitch === "string"
        ? pixelSwitch
        : pixelSwitch?.name || pixelSwitch?.type || pixelSwitch?.mode;
    const mode = validatePixelSwitch(requested);
    this.options.pixelSwitch =
      pixelSwitch && typeof pixelSwitch === "object"
        ? { ...pixelSwitch, mode }
        : mode;
    this.emit("configchange", { pixelSwitch: this.options.pixelSwitch });
    this.renderWhenPaused();
    return this;
  }

  setPixelShape(pixelShape) {
    const canonicalShape = validatePixelShape(pixelShape);
    this.options.pixelShape = canonicalShape;
    this.emit("configchange", { pixelShape: canonicalShape });
    this.renderWhenPaused();
    return this;
  }

  setResolution(size) {
    const next = Math.round(clamp(Number(size) || this.gridSize, 8, 96));
    if (next !== this.gridSize) {
      this.options.gridSize = next;
      this.allocate(next);
      this.emit("configchange", { gridSize: next });
      if (this.configurationBatchDepth > 0) {
        this.configurationNeedsRender = true;
      } else {
        this.renderFrame(this.clock, 1 / 60, true);
      }
    }
    return this;
  }

  setSeed(seed) {
    this.seed = String(seed || "joan-v5");
    this.seedHash = hashNumber(this.seed);
    this.fieldKit.reseed?.(this.seed);
    for (let index = 0; index < this.random.length; index += 1) {
      this.random[index] = hash01(index ^ this.seedHash);
    }
    this.emit("configchange", { seed: this.seed });
    this.renderWhenPaused();
    return this;
  }

  setProgress(value) {
    this.progress = clamp01(Number(value) || 0);
    this.signal("progress", { value: this.progress });
    return this;
  }

  setAudioLevel(value) {
    this.audioTarget = clamp01(Number(value) || 0);
    if (!this.running) this.audioLevel = this.audioTarget;
    this.renderWhenPaused();
    return this;
  }

  setNonErrorPalette(palette, options = {}) {
    this.options.nonErrorPalette =
      palette && typeof palette === "object" ? { ...palette } : null;
    if (options.render !== false) this.renderWhenPaused();
    return this;
  }

  configure(options = {}, configureOptions = {}) {
    const strict = configureOptions.strict !== false;
    const normalized = validateEngineOptions(options, {
      strict,
      coerce: !strict,
      allowUnknown: false,
    });
    const wasInteractive = Boolean(this.options.interactive);
    const wasReducedMotion = this.reducedMotion;
    this.configurationBatchDepth += 1;
    this.configurationNeedsRender = true;
    try {
      if (normalized.seed !== undefined) this.setSeed(normalized.seed);
      if (normalized.gridSize !== undefined) {
        this.setResolution(normalized.gridSize);
      }
      if (
        normalized.fieldMode === "recipe" ||
        (Object.hasOwn(normalized, "field") && normalized.field === null)
      ) {
        this.fieldOverride = null;
        this.emit("configchange", { field: null, fieldMode: "recipe" });
      } else if (normalized.field !== undefined) {
        this.setField(normalized.field);
      }
      if (Object.hasOwn(normalized, "pixelShape")) {
        if (normalized.pixelShape === null) {
          this.options.pixelShape = null;
          this.emit("configchange", { pixelShape: null });
        } else {
          this.setPixelShape(normalized.pixelShape);
        }
      }
      if (Object.hasOwn(normalized, "pixelSwitch")) {
        if (normalized.pixelSwitch === null) {
          this.options.pixelSwitch = null;
          this.emit("configchange", { pixelSwitch: null });
        } else {
          this.setPixelSwitch(normalized.pixelSwitch);
        }
      }

      const directlyAssigned = [
        "ariaLive",
        "autoResize",
        "autoplay",
        "background",
        "contrast",
        "density",
        "direction",
        "dprMax",
        "fps",
        "interactive",
        "offPixels",
        "orbBoundary",
        "orbBackgroundColor",
        "orbBackgroundMode",
        "previewMode",
        "quality",
        "reducedMotion",
        "speed",
        "targetGlyph",
        "transition",
      ];
      for (const key of directlyAssigned) {
        if (Object.hasOwn(normalized, key)) this.options[key] = normalized[key];
      }
      if (
        Object.hasOwn(normalized, "orbBackgroundColor") &&
        !Object.hasOwn(normalized, "orbBackgroundMode")
      ) {
        this.options.orbBackgroundMode = normalized.orbBackgroundColor
          ? this.options.orbBackgroundMode === "pixelated"
            ? "pixelated"
            : "solid"
          : "none";
      }
      if (Object.hasOwn(normalized, "reducedMotion")) {
        this.reducedMotion = this.resolveReducedMotion();
      }
      this.options.previewMode = normalizePreviewMode(this.options.previewMode);
      if (Object.hasOwn(normalized, "palette")) {
        this.options.palette = normalized.palette
          ? { ...normalized.palette }
          : null;
      }
      if (Object.hasOwn(normalized, "paletteOverride")) {
        this.options.palette = normalized.paletteOverride
          ? { ...normalized.paletteOverride }
          : null;
      }
      if (Object.hasOwn(normalized, "nonErrorPalette")) {
        this.options.nonErrorPalette = normalized.nonErrorPalette
          ? { ...normalized.nonErrorPalette }
          : null;
      }
      if (Object.hasOwn(normalized, "progress")) {
        this.progress = normalized.progress;
      }
      if (normalized.sprite !== undefined) {
        this.setSprite(normalized.sprite, {
          transition: normalized.transition || undefined,
          preservePhase: true,
          useRecipeFields: normalized.fieldMode === "recipe",
        });
      } else if (!this.fieldOverride && normalized.fieldMode === "recipe") {
        this.currentRecipe = this.recipeSource || this.currentRecipe;
      }

      if (
        normalized.interactive !== undefined &&
        Boolean(this.options.interactive) !== wasInteractive
      ) {
        for (const remove of this.interactionListeners.splice(0)) remove();
        if (this.options.interactive) this.bindCanvas();
        this.updateAria();
      }
      if (normalized.autoResize === false && this.resizeObserver) {
        this.resizeObserver.disconnect();
        this.resizeObserver = null;
      } else if (
        normalized.autoResize === true &&
        !this.resizeObserver &&
        typeof ResizeObserver !== "undefined"
      ) {
        this.resizeObserver = new ResizeObserver(() => this.resize());
        this.resizeObserver.observe(this.canvas);
      }
      if (Object.hasOwn(normalized, "quality")) {
        this.qualityTier =
          normalized.quality === "auto" ? "high" : normalized.quality;
        this.stats.quality = this.qualityTier;
      }
      if (
        Object.hasOwn(normalized, "quality") ||
        Object.hasOwn(normalized, "fps")
      ) {
        this.qualityPressure = 0;
        this.qualityRecovery = 0;
        this.qualitySamplePhase = 0;
      }
      if (Object.hasOwn(normalized, "fps")) {
        this.simulationStep =
          1 / Math.min(60, clamp(Number(this.options.fps) || 60, 1, 120));
        this.simulationAccumulator = 0;
      }
    } finally {
      this.configurationBatchDepth -= 1;
    }

    const detail = {
      ...(this.pendingConfigurationDetail || {}),
      configured: Object.keys(normalized),
    };
    this.pendingConfigurationDetail = null;
    if (this.configurationBatchDepth === 0 && this.configurationNeedsRender) {
      this.configurationNeedsRender = false;
      if (this.running) this.paint();
      else this.renderFrame(this.clock, 1 / 60, true);
    }
    this.emit("configchange", detail);
    this.updateAria();
    if (this.reducedMotion || normalized.autoplay === false) {
      this.pause();
    } else if (
      normalized.autoplay === true ||
      (wasReducedMotion && Object.hasOwn(normalized, "reducedMotion"))
    ) {
      this.play();
    }
    return this;
  }

  setOptions(options = {}) {
    return this.configure(options, { strict: false });
  }

  signal(type, payload = {}) {
    const rawValue = signalPayloadValue(type, payload);
    const value = clamp01(rawValue);
    const mapped = this.applyRecipeSignal(type, payload, rawValue);
    if (type === "audio.level") {
      this.audioTarget = value;
      if (!this.running) this.audioLevel = value;
    } else if (type === "progress") {
      this.progress = clamp01(Number(mapped?.value ?? payload.value) || 0);
    } else {
      this.activityEnergy = Math.max(this.activityEnergy, value);
      const life = clamp(Number(payload.life) || 1.2, 0.1, 12);
      this.signals.set(type, { age: 0, life, value, payload: { ...payload } });
      if (type === "search.hit") {
        this.signalState.hits = Math.min(8, this.signalState.hits + 1);
      } else if (type === "search.results") {
        this.signalState.hits = Math.max(
          0,
          Math.round(Number(mapped?.value ?? rawValue) || 0),
        );
      } else if (type === "network.retry") {
        this.signalState.retry = value;
      } else if (type === "tool.step") {
        this.signalState.steps = Math.max(
          1,
          Math.round(Number(payload.steps ?? payload.step) || this.signalState.steps + 1),
        );
      } else if (type === "handoff.accepted") {
        this.signalState.accepted = payload.accepted !== false;
      } else if (type === "transfer.direction" || type === "direction") {
        this.signalState.direction = String(
          payload.direction || payload.value || "down",
        );
      }
      if (payload.direction) this.signalState.direction = String(payload.direction);
      if (payload.targetGlyph) this.signalState.targetGlyph = payload.targetGlyph;
      if (payload.segments) {
        this.signalState.segments = Math.max(1, Math.round(Number(payload.segments)));
      }
      if (payload.rotationSpeed) {
        this.signalState.rotationSpeed = Number(payload.rotationSpeed) || 1;
      }
      this.signalState.signalStrength = Math.max(
        this.signalState.signalStrength,
        value,
      );
      this.emitImpulse(type, {
        x: Number(payload.x ?? this.pointer.x),
        y: Number(payload.y ?? this.pointer.y),
        energy: value,
        life: Number(payload.life) || 1.2,
      });
    }
    this.emit("signal", { type, payload });
    if (type === "resume" && value > 0) {
      this.resume({ source: payload.source || "signal" });
      return this;
    }
    this.renderWhenPaused();
    return this;
  }

  renderWhenPaused() {
    if (this.configurationBatchDepth > 0) {
      this.configurationNeedsRender = true;
      return;
    }
    if (!this.running && !this.destroyed) this.renderOnce(this.clock);
  }

  emitImpulse(type, options = {}) {
    this.impulses.push({
      type,
      x: clamp(Number(options.x) || 0, -1, 1),
      y: clamp(Number(options.y) || 0, -1, 1),
      age: 0,
      life: clamp(Number(options.life) || 1, 0.1, 4),
      energy: clamp01(Number(options.energy) || 0.7),
    });
    if (this.impulses.length > 12) this.impulses.shift();
  }

  registerGlyph(id, source, dimensions = {}) {
    if (!id || (!source && source !== 0)) {
      throw new TypeError("registerGlyph requires an id and sampler or bitmap.");
    }
    if (typeof source === "function") {
      this.customGlyphs.set(id, source);
      return this;
    }

    const rows = Array.isArray(source) && Array.isArray(source[0]) ? source : null;
    const width = Math.max(1, dimensions.width || rows?.[0]?.length || 1);
    const height = Math.max(1, dimensions.height || rows?.length || 1);
    const flat = rows ? rows.flat() : Array.from(source);
    this.customGlyphs.set(id, (x, y) => {
      const px = Math.round(clamp01((x + 1) * 0.5) * (width - 1));
      const py = Math.round(clamp01((y + 1) * 0.5) * (height - 1));
      return clamp01(Number(flat[py * width + px]) || 0);
    });
    return this;
  }

  async loadGlyphFile(file, options = {}) {
    if (typeof document === "undefined") {
      throw new Error("Image glyph loading requires a browser.");
    }
    const id = options.id || "custom";
    const resolution = Math.round(clamp(options.resolution || 48, 8, 128));
    const url = URL.createObjectURL(file);
    try {
      const image = await new Promise((resolve, reject) => {
        const element = new Image();
        element.onload = () => resolve(element);
        element.onerror = () => reject(new Error("The glyph image could not be decoded."));
        element.src = url;
      });
      const scratch = document.createElement("canvas");
      scratch.width = resolution;
      scratch.height = resolution;
      const context = scratch.getContext("2d", { willReadFrequently: true });
      const scale = Math.min(resolution / image.width, resolution / image.height) * 0.84;
      const width = image.width * scale;
      const height = image.height * scale;
      context.clearRect(0, 0, resolution, resolution);
      context.drawImage(
        image,
        (resolution - width) / 2,
        (resolution - height) / 2,
        width,
        height,
      );
      const pixels = context.getImageData(0, 0, resolution, resolution).data;
      const bitmap = new Float32Array(resolution * resolution);
      for (let index = 0; index < bitmap.length; index += 1) {
        const offset = index * 4;
        const luminance =
          (pixels[offset] * 0.2126 +
            pixels[offset + 1] * 0.7152 +
            pixels[offset + 2] * 0.0722) /
          255;
        bitmap[index] = (pixels[offset + 3] / 255) * Math.max(0.3, luminance);
      }
      this.registerGlyph(id, bitmap, { width: resolution, height: resolution });
      const base = getSprite(options.baseSprite || "ai.generating") || this.currentRecipe;
      this.setSprite(
        {
          ...base,
          id,
          glyph: id,
          label: options.label || "Custom glyph",
          semantic: {
            ...(base.semantic || {}),
            label: options.label || "Custom glyph",
            category: "custom",
          },
        },
        { transition: options.transition || "field-morph" },
      );
      return id;
    } finally {
      URL.revokeObjectURL(url);
    }
  }

  sampleCustomGlyph(id, x, y, time, context) {
    const sampler = this.customGlyphs.get(id);
    return sampler
      ? clamp01(Number(sampler(x, y, time, context)) || 0)
      : sampleGlyph(id, x, y, time, context);
  }

  scheduleFrame() {
    if (
      !this.running ||
      this.destroyed ||
      !this.visible ||
      this.frameHandle !== null ||
      typeof requestAnimationFrame !== "function"
    ) {
      return;
    }
    this.frameHandle = requestAnimationFrame(this.frameTick);
  }

  cancelScheduledFrame() {
    if (
      this.frameHandle !== null &&
      typeof cancelAnimationFrame === "function"
    ) {
      cancelAnimationFrame(this.frameHandle);
    }
    this.frameHandle = null;
  }

  play() {
    if (this.running || this.destroyed || this.reducedMotion) return this;
    this.running = true;
    this.lastFrameTime = null;
    if (!this.frameTick) {
      this.frameTick = (timestamp) => {
        this.frameHandle = null;
        if (!this.running || this.destroyed || !this.visible) return;
        const now = timestamp / 1000;
        if (this.lastFrameTime === null) {
          this.lastFrameTime = now;
          this.scheduleFrame();
          return;
        }
        const minInterval = 1 / clamp(Number(this.options.fps) || 60, 1, 120);
        const elapsed = now - this.lastPaintTime;
        if (elapsed + 0.002 >= minInterval) {
          const dt = Math.max(0, now - this.lastFrameTime);
          this.lastFrameTime = now;
          this.lastPaintTime = now;
          this.renderFrame(now, dt);
        }
        this.scheduleFrame();
      };
    }
    this.scheduleFrame();
    this.emit("play", { sprite: this.currentRecipe.id });
    return this;
  }

  pause() {
    if (!this.running) return this;
    this.running = false;
    this.cancelScheduledFrame();
    this.emit("pause", { sprite: this.currentRecipe.id });
    return this;
  }

  toggle() {
    return this.running ? this.pause() : this.play();
  }

  updateDynamics(dt) {
    const speed = this.options.speed;
    this.clock += dt;
    this.phase += dt * speed;
    const toolHold = clamp01(Number(this.semanticContext.stepHold) || 0);
    this.signalState.toolPhase += dt * speed * (1 - toolHold);
    this.stateElapsed += dt;
    this.transitionElapsed += dt;
    this.pointer.x = expEase(
      this.pointer.x,
      this.pointer.inside ? this.pointer.targetX : 0,
      dt,
      0.13,
    );
    this.pointer.y = expEase(
      this.pointer.y,
      this.pointer.inside ? this.pointer.targetY : 0,
      dt,
      0.13,
    );
    this.pointer.speed = expEase(this.pointer.speed, 0, dt, 0.2);
    const pressConfig = this.currentRecipe?.interactions?.press || {};
    const pressStrength = clamp(Number(pressConfig.strength ?? 0.07), 0, 1);
    const pressDuration = clamp(
      Number(pressConfig.durationMs) / 1000 || 0.18,
      0.08,
      1.2,
    );
    this.press = expEase(
      this.press,
      this.pointer.down ? clamp(0.2 + pressStrength * 3.6, 0.2, 1) : 0,
      dt,
      pressDuration * 0.72,
    );
    this.audioLevel = expEase(this.audioLevel, this.audioTarget, dt, 0.09);
    this.audioTarget = expEase(this.audioTarget, 0, dt, 0.28);
    this.activityEnergy = expEase(this.activityEnergy, 0, dt, 0.5);
    this.signalState.retry = expEase(this.signalState.retry, 0, dt, 0.75);
    this.signalState.signalStrength = expEase(
      this.signalState.signalStrength,
      0,
      dt,
      0.5,
    );

    if (this.impulses.length) {
      for (const impulse of this.impulses) impulse.age += dt;
      this.impulses = this.impulses.filter((impulse) => impulse.age < impulse.life);
    }
    for (const [name, signal] of this.signals) {
      signal.age += dt;
      if (signal.age >= signal.life) this.signals.delete(name);
    }
    for (const [mapsTo, semanticSignal] of this.semanticSignals) {
      if (
        semanticSignal.decaySeconds > 0 &&
        typeof semanticSignal.value === "number"
      ) {
        semanticSignal.value = expEase(
          semanticSignal.value,
          0,
          dt,
          semanticSignal.decaySeconds,
        );
        if (Math.abs(semanticSignal.value) < 0.001) semanticSignal.value = 0;
        this.semanticContext[mapsTo] = semanticSignal.value;
      }
    }

    if (
      this.previousRecipe !== this.currentRecipe &&
      this.completedTransitionSerial !== this.transitionSerial &&
      this.transitionElapsed >= this.transitionDuration
    ) {
      this.finishTransition("completed");
    }

    if (
      this.pendingSpriteTransition &&
      this.transitionElapsed >= this.transitionDuration
    ) {
      const pending = this.pendingSpriteTransition;
      this.pendingSpriteTransition = null;
      this.setSprite(pending.sprite, {
        ...pending.options,
        forceTransition: true,
      });
    }

    const timeline = this.currentRecipe?.timeline;
    const timelineEnd =
      (Number(timeline?.durationMs) + Number(timeline?.holdMs || 0)) / 1000;
    if (
      timeline?.next &&
      Number.isFinite(timelineEnd) &&
      timelineEnd > 0 &&
      this.stateElapsed >= timelineEnd
    ) {
      const completed = this.currentRecipe;
      this.setSprite(timeline.next, {
        duration:
          Number(completed?.transition?.exitDurationMs) / 1000 || undefined,
        transition: completed?.transition?.exit || "field-morph",
        forceTransition: true,
      });
      this.emit("timelinecomplete", {
        from: completed.id,
        to: this.currentRecipe.id,
      });
    }
  }

  recipeMetadata(recipe) {
    const key = recipe && typeof recipe === "object" ? recipe : this.currentRecipe;
    const cached = this.recipeMetadataCache.get(key);
    if (cached) return cached;
    const meaning = String(key?.semantic?.meaning || "").toLowerCase();
    const category = String(key?.semantic?.category || "").toLowerCase();
    const metadata = {
      meaning,
      category,
      toolAction: /executing a tool|tool action/.test(meaning),
      deliberateReasoning: /deliberate.*reasoning/.test(meaning),
      voiceOutput:
        /voice output|speaking/.test(meaning) ||
        Number(key?.composition?.voice) > 0,
      listening: /capturing voice|listening/.test(meaning),
      presence:
        category === "presence" ||
        /calm.*presence|ready and available/.test(meaning),
      ambientReasoning: category === "ambient" && /reasoning/.test(meaning),
    };
    this.recipeMetadataCache.set(key, metadata);
    return metadata;
  }

  resolvedPixelSwitch(recipe = this.currentRecipe) {
    const override = this.options.pixelSwitch;
    if (
      this.pixelSwitchCache.recipe !== recipe ||
      this.pixelSwitchCache.override !== override
    ) {
      this.pixelSwitchCache.recipe = recipe;
      this.pixelSwitchCache.override = override;
      this.pixelSwitchCache.value = recipePixelSwitch(recipe, override);
    }
    return this.pixelSwitchCache.value;
  }

  semanticEnvelope(recipe, time, metadata = this.recipeMetadata(recipe)) {
    const cacheKey = recipe && typeof recipe === "object" ? recipe : this.currentRecipe;
    let cached = this.semanticEnvelopeCache.get(cacheKey);
    if (cached?.time === time && cached.audioLevel === this.audioLevel) {
      return cached.value;
    }
    const gentleBreath =
      0.6 * Math.sin(time * 0.9) + 0.4 * Math.sin(time * 0.333 + 1.3);
    const syllable =
      0.5 + 0.5 * Math.sin(time * 9) * Math.sin(time * 3.7 + 1.1);
    const gate = Math.max(
      0,
      Math.sin(time * 2.3) + 0.35 * Math.sin(time * 5.9),
    );
    const voice = (0.25 + 0.75 * syllable) * Math.min(1, gate);
    let value;
    if (metadata.voiceOutput) {
      value = clamp01(this.audioLevel || voice * 0.8);
    } else if (metadata.listening) {
      value = clamp01(this.audioLevel * 0.8 + 0.2);
    } else if (metadata.presence) {
      value = 0.5 + gentleBreath * 0.14;
    } else if (
      metadata.deliberateReasoning ||
      metadata.ambientReasoning
    ) {
      const cycle = fract(time * 0.22);
      value = cycle < 0.45
        ? 0.5 - 0.5 * Math.cos(Math.PI * (cycle / 0.45))
        : 0.5 + 0.5 * Math.cos(Math.PI * ((cycle - 0.45) / 0.55));
    } else {
      value = 0.5 + gentleBreath * 0.08;
    }
    if (!cached) {
      cached = { time, value, audioLevel: this.audioLevel };
      this.semanticEnvelopeCache.set(cacheKey, cached);
    } else {
      cached.time = time;
      cached.value = value;
      cached.audioLevel = this.audioLevel;
    }
    return value;
  }

  orbVisualRadius(recipe, time = this.clock) {
    const composition = recipe?.composition || {};
    if (composition.gestaltBoundary !== true) return 0;

    if (composition.mode === "field-orb") {
      const baseRadius = clamp(Number(composition.radius) || 0.86, 0.45, 0.98);
      const breathe = clamp(Number(composition.breathe) || 0, 0, 0.22);
      const envelope = this.semanticEnvelope(
        recipe,
        time,
        this.recipeMetadata(recipe),
      );
      return baseRadius * (1 + (envelope - 0.5) * breathe * 0.7);
    }

    const cycleMs =
      Number(recipe?.timeline?.macroCycleMs) ||
      Number(recipe?.timeline?.durationMs) ||
      14400;
    const phase = this.reducedMotion
      ? clamp01(Number(recipe?.reducedMotion?.phase) || 0.18)
      : fract(time / Math.max(0.001, cycleMs / 1000));
    const breath = Math.sin(phase * TAU * 2 - Math.PI / 2);
    const baseRadius = clamp(Number(composition.radius) || 0.67, 0.45, 0.98);
    return baseRadius + breath * 0.022;
  }

  orbBackgroundRadius(recipe, time = this.clock) {
    const radius = this.orbVisualRadius(recipe, time);
    if (radius <= 0) return 0;
    const composition = recipe?.composition || {};
    const normalizedCell = 2 / Math.max(1, this.gridSize);
    const halfPixelDiagonal =
      normalizedCell * recipePixelScale(recipe) * (Math.SQRT2 / 2);
    const clearance = normalizedCell * 0.25;
    const motionClearance =
      composition.mode === "field-orb"
        ? clamp(Number(composition.radius) || 0.86, 0.45, 0.98) *
          clamp(Number(composition.breathe) || 0, 0, 0.22) *
          0.7
        : recipe?.glyph === "idle"
          ? 0.044
          : 0;
    return Math.min(
      1.1,
      radius + motionClearance + halfPixelDiagonal + clearance,
    );
  }

  impulseAt(x, y) {
    let energy = 0;
    for (const impulse of this.impulses) {
      const distance = Math.hypot(x - impulse.x, y - impulse.y);
      const life = 1 - impulse.age / impulse.life;
      const wave = Math.exp(
        -Math.abs(distance - impulse.age * (0.7 + impulse.energy * 0.5)) * 18,
      );
      energy += wave * life * impulse.energy;
    }
    return clamp01(energy);
  }

  recipeSample(recipe, x, y, time, index, result = null) {
    const shellEnergy = clamp01(Number(this.semanticContext.shellEnergy) || 0);
    const metadata = this.recipeMetadata(recipe);
    const baseTime = metadata.toolAction ? this.signalState.toolPhase : time;
    const semanticSpeed =
      metadata.deliberateReasoning
        ? 1 + shellEnergy * 0.55
        : 1;
    const recipeTime =
      baseTime *
      clamp(Number(recipe?.speed) || 1, 0.02, 8) *
      semanticSpeed;
    const pointerConfig = recipe?.interactions?.pointer;
    const pointerEnabled =
      this.options.interactive &&
      this.gridSize >= Number(pointerConfig?.minSize ?? 20) &&
      !this.reducedMotion &&
      recipe?.interactions !== false &&
      pointerConfig?.enabled !== false;
    const pointerStrength = clamp(Number(pointerConfig?.strength ?? 0.16), 0, 1);
    const pointerEffect = pointerConfig?.effect || "steer-light";
    const pointerProfile = POINTER_EFFECT_PROFILES[pointerEffect] || {
      warp: 0.35,
      light: 0.5,
      surface: 0.45,
    };
    const pointer = this.pointerSampleScratch;
    pointer.x = pointerEnabled ? this.pointer.x : 0;
    pointer.y = pointerEnabled ? this.pointer.y : 0;
    pointer.speed = pointerEnabled ? this.pointer.speed : 0;
    const envelope = this.semanticEnvelope(recipe, time, metadata);
    const distanceToPointer = Math.hypot(x - pointer.x, y - pointer.y);
    const pointerInfluence =
      pointerEnabled && this.pointer.inside
        ? Math.max(0, 1 - distanceToPointer / 1.15)
        : 0;
    const warp =
      pointerInfluence *
      pointerStrength *
      (0.075 + pointer.speed * 0.15) *
      pointerProfile.warp;
    const sampleX = x - (x - pointer.x) * warp;
    const sampleY = y - (y - pointer.y) * warp;
    const impulse = this.impulseAt(x, y);
    const context = this.sampleContextScratch;
    context.accepted = this.signalState.accepted;
    context.age = this.stateElapsed;
    context.audioLevel = this.audioLevel;
    context.direction = this.signalState.direction;
    context.motionDirection = recipe?.pixelSwitch?.direction || "field";
    context.elapsed = this.stateElapsed;
    context.energy = clamp01(this.activityEnergy + impulse);
    context.envelope = envelope;
    context.handoffAccepted = this.signalState.accepted;
    context.hits = this.signalState.hits;
    context.glyphSampler = this.glyphSampler;
    context.pointer = pointer;
    context.pointerEffect = pointerEffect;
    context.pointerInfluence = pointerInfluence;
    context.press = this.press;
    context.pressEffect =
      recipe?.interactions?.press?.effect || "spring-compress";
    context.progress = this.progress;
    context.random = this.random[index];
    context.retry = this.signalState.retry;
    context.rotationSpeed = this.signalState.rotationSpeed;
    context.resolution = this.gridSize;
    context.segments = this.signalState.segments;
    context.size = this.gridSize;
    context.seed = this.seedHash;
    context.signalStrength = this.signalState.signalStrength;
    context.signals = this.signals;
    context.steps = this.signalState.steps;
    context.targetGlyph = this.signalState.targetGlyph;
    context.reducedMotion = this.reducedMotion;
    context.reducedPhase = recipe?.reducedMotion?.phase;
    const timelineCycleMs =
      Number(recipe?.timeline?.macroCycleMs) ||
      Number(recipe?.timeline?.durationMs) ||
      1000;
    context.timelinePhase = this.reducedMotion
      ? clamp01(Number(recipe?.reducedMotion?.phase) || 0.25)
      : fract(time / Math.max(0.001, timelineCycleMs / 1000));
    Object.assign(context, this.semanticContext);
    const composition = recipe?.composition || {};
    const compositionMode = composition.mode || "glyph";
    const usesGestaltBoundary =
      composition.gestaltBoundary === true &&
      this.options.orbBoundary === "gestalt";
    context.orbBoundary = usesGestaltBoundary ? "gestalt" : "defined";
    const authoredGestaltOpenness = Number(composition.gestaltOpenness);
    context.gestaltOpenness = clamp(
      Number.isFinite(authoredGestaltOpenness)
        ? authoredGestaltOpenness
        : 0.5,
      0,
      1,
    );
    const radius = Math.hypot(sampleX, sampleY);
    const baseOrbRadius = clamp(Number(composition.radius) || 0.86, 0.45, 0.98);
    const breathe = clamp(Number(composition.breathe) || 0, 0, 0.22);
    const orbRadius =
      baseOrbRadius * (1 + (envelope - 0.5) * breathe * 0.7);
    const orbEdge = clamp(1.15 / this.gridSize, 0.022, 0.095);
    const orbMask =
      1 - smoothRange(orbRadius - orbEdge, orbRadius + orbEdge, radius);
    const glyphId = recipe?.glyph || "idle";
    const sampledGlyph =
      compositionMode === "field-orb"
        ? orbMask
        : this.sampleCustomGlyph(
            glyphId,
            sampleX,
            sampleY,
            recipeTime,
            context,
          );
    const glyph = clamp01(sampledGlyph);
    const layers =
      Array.isArray(recipe?.fieldMix) && recipe.fieldMix.length
        ? recipe.fieldMix
        : [
            {
              field:
                recipe?.field ||
                recipe?.fields?.[0]?.id ||
                recipe?.fields?.[0]?.type ||
                "fbm",
              weight: 1,
              scale: 1,
              speed: 1,
              phase: 0,
              contrast: 1,
              blend: "replace",
            },
          ];
    let field = 0.5;
    let accumulatedWeight = 0;
    for (let layerIndex = 0; layerIndex < layers.length; layerIndex += 1) {
      const layer = layers[layerIndex];
      const weight = clamp(Number(layer.weight) || 0, 0, 1);
      const scale = clamp(Number(layer.scale) || 1, 0.05, 24);
      const layerTime = recipeTime;
      const fieldOptions = Object.assign(this.fieldOptionsScratch, context, layer);
      let sampled = this.fieldKit.sample(
        layer.field || layer.id || layer.type || recipe?.field || "fbm",
        sampleX * scale,
        sampleY * scale,
        layerTime,
        fieldOptions,
      );
      sampled = clamp01(sampled);
      const blend = layer.blend || "add";
      if (accumulatedWeight === 0) {
        // The primary layer establishes the full 0..1 range. Weight describes
        // how much later layers may influence it, not how much it is dimmed.
        field = sampled;
      } else if (blend === "replace") {
        field = field * (1 - weight) + sampled * weight;
      } else if (blend === "screen") {
        const screened = 1 - (1 - field) * (1 - sampled);
        field = field * (1 - weight) + screened * weight;
      } else if (blend === "multiply") {
        const multiplied = field * sampled;
        field = field * (1 - weight) + multiplied * weight;
      } else if (blend === "max" || blend === "lighten") {
        field = field * (1 - weight) + Math.max(field, sampled) * weight;
      } else if (blend === "soft-light") {
        const softened =
          sampled < 0.5
            ? field - (1 - 2 * sampled) * field * (1 - field)
            : field +
              (2 * sampled - 1) *
                (Math.sqrt(Math.max(0, field)) - field);
        field = field * (1 - weight) + softened * weight;
      } else {
        // Add detail around the midpoint so an additive layer creates texture
        // without washing the entire stack toward white.
        field += (sampled - 0.5) * weight * 1.35;
      }
      accumulatedWeight += weight;
    }
    const fieldContrast = clamp(
      Number(composition.fieldContrast) ||
        Number(recipe?.fieldContrast) ||
        1.16,
      0.5,
      3,
    );
    field = clamp01((field - 0.5) * fieldContrast + 0.5);
    const radialWindow = clamp01((1.12 - radius) / 0.22);
    const ambientMix = clamp(
      Number(
        recipe?.ambientMix ??
          recipe?.fields?.[0]?.mix ??
          0.055 + (Number(recipe?.density) || 0.72) * 0.1,
      ),
      0,
      0.4,
    );
    const detail = clamp(
      Number(
        composition.fieldDetail ??
          recipe?.detail ??
          recipe?.fields?.[0]?.detail ??
          0.68,
      ),
      0,
      1.5,
    );
    const pressStrength = clamp(
      Number(recipe?.interactions?.press?.strength ?? 0.07),
      0,
      1,
    );
    const pressEffect =
      recipe?.interactions?.press?.effect || "spring-compress";
    const pressProfile = PRESS_EFFECT_PROFILES[pressEffect] || { surface: 0.55 };
    const interaction =
      impulse *
        radialWindow *
        clamp(pressStrength * 6.85, 0, 1) *
        pressProfile.surface +
      pointerInfluence *
        pointerStrength *
        0.22 *
        pointerProfile.surface;
    const recipeThreshold = clamp(Number(recipe?.threshold) || 0.5, 0, 1);
    const recipeDensity = clamp(Number(recipe?.density) || 0.72, 0, 1.4);
    const recipeBias = (0.5 - recipeThreshold) * 0.32 + (recipeDensity - 0.72) * 0.1;

    let raw;
    if (compositionMode === "field-orb") {
      const normalizedX = sampleX / Math.max(0.001, orbRadius);
      const normalizedY = sampleY / Math.max(0.001, orbRadius);
      const normalZ = Math.sqrt(
        Math.max(0, 1 - normalizedX * normalizedX - normalizedY * normalizedY),
      );
      const lighting = composition.lighting || "sphere";
      let shade = 0;
      if (lighting !== "flat") {
        let lightX =
          lighting === "crescent"
            ? -0.92
            : lighting === "gibbous"
              ? -0.55
              : -0.42;
        let lightY =
          lighting === "crescent"
            ? -0.24
            : lighting === "gibbous"
              ? -0.3
              : -0.48;
        let lightZ =
          lighting === "crescent"
            ? 0.32
            : lighting === "gibbous"
              ? 0.82
              : 0.76;
        const lightOrbit = clamp(Number(composition.lightOrbit) || 0, 0, 0.8);
        lightX += Math.cos(recipeTime * 0.6) * lightOrbit;
        lightY += Math.sin(recipeTime * 0.43) * lightOrbit;
        if (pointerEnabled && this.pointer.inside) {
          const lightSteer =
            clamp(pointerStrength / 0.16, 0, 2) *
            0.28 *
            pointerProfile.light;
          lightX += pointer.x * lightSteer;
          lightY += pointer.y * lightSteer;
        }
        const lightLength = Math.hypot(lightX, lightY, lightZ) || 1;
        lightX /= lightLength;
        lightY /= lightLength;
        lightZ /= lightLength;
        shade = Math.max(
          0,
          normalizedX * lightX + normalizedY * lightY + normalZ * lightZ,
        );
        shade = Math.pow(shade, lighting === "crescent" ? 1.58 : 0.88);
      }
      const centeredField = (field - 0.5) * detail;
      const voiceGlow =
        clamp(Number(composition.voice) || 0, 0, 0.5) * envelope * 0.62;
      const breathGlow = breathe * (envelope - 0.5) * 0.52;
      const surface =
        lighting === "flat"
          ? 0.5 + centeredField * 0.92 + voiceGlow + breathGlow
          : lighting === "crescent"
          ? shade * (0.76 + centeredField * 1.08 + voiceGlow) + breathGlow
          : 0.15 +
            shade * 0.58 +
            centeredField * 0.72 +
            voiceGlow +
            breathGlow;
      if (usesGestaltBoundary) {
        const cell = 2 / this.gridSize;
        const boundaryBand = clamp(cell * 3.05, 0.055, 0.2);
        const arcSupport = gestaltArcSupport(
          Math.atan2(sampleY, sampleX),
          context,
          context.gestaltOpenness,
        );
        const edgeBand =
          smoothRange(
            orbRadius - boundaryBand * 1.14,
            orbRadius - boundaryBand * 0.4,
            radius,
          ) * orbMask;
        const renderedSurface = surface * orbMask;
        const cueNeed =
          1 -
          smoothRange(0.42, 0.74, clamp01(surface + recipeBias));
        const impliedEdge =
          edgeBand *
          arcSupport *
          (0.47 + field * 0.14 + shade * 0.05) *
          cueNeed;
        raw = clamp01(
          Math.max(renderedSurface, impliedEdge) +
            interaction +
            recipeBias,
        );
      } else {
        const authoredRim = Number(composition.rim);
        const rimWidth = clamp(
          Number.isFinite(authoredRim) ? authoredRim : 0.045,
          0.012,
          0.12,
        );
        const rimDistance = Math.abs(radius - orbRadius);
        const rim =
          1 - smoothRange(rimWidth, rimWidth + orbEdge, rimDistance);
        const rimValue = rim * (0.46 + field * 0.18);
        raw = clamp01(
          Math.max(surface * orbMask, rimValue) + interaction + recipeBias,
        );
      }
    } else {
      const crispGlyph = smoothRange(0.16, 0.76, glyph);
      const scaffoldDensity = clamp(
        Number(recipe?.pixel?.silhouetteFloor) || 0.38,
        0.12,
        0.72,
      );
      const gx = index % this.gridSize;
      const gy = Math.floor(index / this.gridSize);
      const scaffoldThreshold = bayer8(
        gx + (this.seedHash & 7),
        gy + ((this.seedHash >>> 3) & 7),
      );
      const coreGlyph = smoothRange(0.56, 0.9, glyph);
      const scaffold =
        scaffoldThreshold < scaffoldDensity
          ? coreGlyph * (0.9 + field * 0.08)
          : 0;
      const texturedFill = clamp01(0.1 + field * (0.82 + detail * 0.32));
      const faintCoverage =
        Math.min(glyph, 0.5) * (0.68 + field * 0.18);
      const surface = Math.max(
        faintCoverage,
        crispGlyph * texturedFill,
        scaffold,
      );
      const ambient =
        field *
        radialWindow *
        ambientMix *
        (1 - crispGlyph * 0.82) *
        (0.72 + envelope * 0.24 + impulse * 0.5);
      raw = clamp01(
        Math.max(surface, ambient) + interaction + recipeBias,
      );
    }
    const output = result || {};
    output.raw = raw;
    output.field = field;
    output.glyph = glyph;
    output.envelope = envelope;
    output.impulse = impulse;
    output.pointerInfluence = pointerInfluence;
    return output;
  }

  directionalThreshold(direction, x, y, time) {
    switch (direction) {
      case "inward":
      case "reverse-to-center":
        return 1 - clamp01(Math.hypot(x, y) / Math.SQRT2);
      case "radial":
      case "radial-tail":
      case "outward-audio":
      case "outward-beacon":
      case "bounded-burst":
      case "core-to-target":
      case "shell-to-shell":
        return clamp01(Math.hypot(x, y) / Math.SQRT2);
      case "radar-sweep":
        return fract(
          fract((Math.atan2(y, x) + Math.PI) / TAU) - time * 0.12,
        );
      case "bottom-to-contour":
        return 1 - clamp01((y + 1) * 0.5);
      case "diagonal-fracture":
      case "source-to-destination":
      case "tail-to-head":
        return (
          clamp01((x + 1) * 0.5) + clamp01((y + 1) * 0.5)
        ) * 0.5;
      case "counter-flow":
        return 1 - clamp01((x + 1) * 0.5);
      case "columns":
      case "gear-step":
        return fract(clamp01((x + 1) * 0.5) * 8);
      case "aperture":
      case "bridge-and-retreat":
        return Math.abs(clamp01((x + 1) * 0.5) - 0.5) * 2;
      case "data-direction":
        if (this.signalState.direction === "up") {
          return clamp01((y + 1) * 0.5);
        }
        if (this.signalState.direction === "left") {
          return clamp01((x + 1) * 0.5);
        }
        if (this.signalState.direction === "right") {
          return 1 - clamp01((x + 1) * 0.5);
        }
        return 1 - clamp01((y + 1) * 0.5);
      case "audio-surface":
      case "contour-forward":
      case "surface-drift":
        return fract(
          clamp01((x + 1) * 0.5) * 0.55 +
            clamp01((y + 1) * 0.5) * 0.3 -
            time * 0.08,
        );
      case "field":
      default:
        return 0.5;
    }
  }

  switchThreshold(config, index, x, y, time, raw) {
    const mode = config.mode;
    if (mode === "threshold-hysteresis") return 0.5;
    const random = this.random[index];
    if (mode === "seeded-dissolve") return random;
    const size = this.gridSize;
    const gx = index % size;
    const gy = Math.floor(index / size);
    const temporalRate = clamp(Number(config.temporalRate ?? 1), 0, 8);
    const phase = Math.floor(time * 12 * temporalRate) & 15;
    const dither = String(config.dither || "bayer8");
    const ordered =
      dither === "blue-noise64"
        ? hash01(
            (gx + gy * 64) ^
              this.seedHash ^
              (dither.startsWith("temporal") ? phase * 0x9e3779b9 : 0),
          )
        : bayer8(gx, gy);
    const directional = this.directionalThreshold(
      config.direction || "field",
      x,
      y,
      time,
    );
    const directedOrdered = clamp01(ordered * 0.86 + directional * 0.14);

    switch (mode) {
      case "temporal-blue-noise":
        return clamp01(
          directedOrdered * 0.52 +
            hash01(index ^ this.seedHash ^ (phase * 0x9e3779b9)) * 0.48,
        );
      case "sdf-wavefront":
        return clamp01(
          directedOrdered * 0.78 +
            Math.abs(
              Math.hypot(x, y) / Math.SQRT2 -
                fract(time * 0.35 * temporalRate),
            ) * 0.22,
        );
      case "contour-trace":
        return clamp01(
          directedOrdered * 0.82 + Math.abs(fract(raw * 4) - 0.5) * 0.18,
        );
      case "curl-advect":
        return clamp01(
          directedOrdered +
            Math.sin(x * 8 + y * 5 - time * 2 * temporalRate) * 0.09,
        );
      case "neighbor-propagation":
        return clamp01(directedOrdered - this.neighborBuffer[index] * 0.16);
      case "radial-cascade":
        return clamp01(
          directedOrdered * 0.8 +
            (Math.hypot(x, y) / Math.SQRT2) * 0.2,
        );
      case "path-draw":
        return clamp01(directedOrdered * 0.78 + ((x + y + 2) * 0.25) * 0.22);
      case "axis-flip":
        return clamp01(directedOrdered * 0.86 + (gx % 2) * 0.08);
      case "field-morph":
        return clamp01(directedOrdered * 0.68 + random * 0.32);
      case "ordered-dither":
      default:
        return directedOrdered;
    }
  }

  updateNeighborBuffer(neighborhood = 4) {
    const size = this.gridSize;
    const diagonalWeight = clamp((Number(neighborhood) - 4) / 4, 0, 1);
    for (let y = 0; y < size; y += 1) {
      for (let x = 0; x < size; x += 1) {
        const index = y * size + x;
        let total = 0;
        let count = 0;
        if (x > 0) {
          total += this.gates[index - 1];
          count += 1;
        }
        if (x < size - 1) {
          total += this.gates[index + 1];
          count += 1;
        }
        if (y > 0) {
          total += this.gates[index - size];
          count += 1;
        }
        if (y < size - 1) {
          total += this.gates[index + size];
          count += 1;
        }
        if (diagonalWeight > 0) {
          if (x > 0 && y > 0) {
            total += this.gates[index - size - 1] * diagonalWeight;
            count += diagonalWeight;
          }
          if (x < size - 1 && y > 0) {
            total += this.gates[index - size + 1] * diagonalWeight;
            count += diagonalWeight;
          }
          if (x > 0 && y < size - 1) {
            total += this.gates[index + size - 1] * diagonalWeight;
            count += diagonalWeight;
          }
          if (x < size - 1 && y < size - 1) {
            total += this.gates[index + size + 1] * diagonalWeight;
            count += diagonalWeight;
          }
        }
        this.neighborBuffer[index] = count ? total / count : 0;
      }
    }
  }

  evaluateGrid(dt, options = {}) {
    const recipe = this.currentRecipe;
    const previous = this.previousRecipe || recipe;
    const samplingStride = options.forceFull
      ? 1
      : this.qualityTier === "low"
        ? 3
        : this.qualityTier === "balanced"
          ? 2
          : 1;
    const samplingPhase = samplingStride === 1
      ? 0
      : this.qualitySamplePhase % samplingStride;
    if (samplingStride > 1) {
      this.qualitySamplePhase = (samplingPhase + 1) % samplingStride;
    } else {
      this.qualitySamplePhase = 0;
    }
    const transitionProgress =
      this.transitionDuration <= 0
        ? 1
        : clamp01(this.transitionElapsed / this.transitionDuration);
    const pixelSwitch = this.resolvedPixelSwitch(recipe);
    const densityBias = (this.options.density - 1) * 0.17;
    const contrast = this.options.contrast;
    const hysteresis = clamp(
      Number(
        recipe?.hysteresis ??
          recipe?.pixelSwitch?.hysteresis ??
          recipe?.pixel?.hysteresis ??
          0.055,
      ),
      0.005,
      0.2,
    );
    const rise = clamp(
      Number(recipe?.rise ?? recipe?.pixel?.rise ?? 0.075),
      0.025,
      0.5,
    );
    const fall = clamp(
      Number(recipe?.fall ?? recipe?.pixel?.fall ?? 0.14),
      0.025,
      0.7,
    );
    const origin =
      this.pointer.inside && !this.reducedMotion
        ? { x: this.pointer.x, y: this.pointer.y }
        : { x: 0, y: 0 };

    if (pixelSwitch.mode === "neighbor-propagation") {
      this.updateNeighborBuffer(pixelSwitch.neighborhood);
    }
    const maximumFlashes = clamp(
      Number(pixelSwitch.maxContrastFlashesPerSecond ?? 3),
      0.5,
      12,
    );
    const minimumDwell = 0.5 / maximumFlashes;

    let activePixels = 0;
    let sampledPixels = 0;
    for (let index = 0; index < this.values.length; index += 1) {
      this.dwell[index] = Math.max(0, this.dwell[index] - dt);
      if (samplingStride === 1 || index % samplingStride === samplingPhase) {
        sampledPixels += 1;
        const x = this.xs[index];
        const y = this.ys[index];
        const to = this.recipeSample(
          recipe,
          x,
          y,
          this.phase,
          index,
          this.sampleToScratch,
        );
        let raw = to.raw;
        let field = to.field;
        let localProgress = 1;
        if (transitionProgress < 1 && previous !== recipe) {
          const from = this.recipeSample(
            previous,
            x,
            y,
            this.phase,
            index,
            this.sampleFromScratch,
          );
          const delay = transitionDelay(
            this.transitionName,
            x,
            y,
            this.random[index],
            origin,
          );
          localProgress = this.reducedMotion
            ? easeInOut(transitionProgress)
            : easeInOut((transitionProgress - delay * 0.58) / 0.42);
          raw = from.raw + (to.raw - from.raw) * localProgress;
          field = from.field + (to.field - from.field) * localProgress;
        }
        this.transitionMixes[index] = localProgress;
        raw = clamp01((raw - 0.5) * contrast + 0.5 + densityBias);
        this.luminance[index] = clamp01(field * 0.45 + raw * 0.55);

        const threshold = this.switchThreshold(
          pixelSwitch,
          index,
          x,
          y,
          this.phase,
          raw,
        );
        const gate = this.gates[index];
        if (this.dwell[index] <= 0) {
          if (!gate && raw > threshold + hysteresis) {
            this.gates[index] = 1;
            this.dwell[index] = 0.045 + this.random[index] * 0.075;
            this.afterglow[index] = 1;
          } else if (
            gate &&
            !pixelSwitch.monotonic &&
            raw < threshold - hysteresis
          ) {
            this.gates[index] = 0;
            this.dwell[index] = Math.max(
              minimumDwell,
              0.06 + this.random[index] * 0.1,
            );
          }
        }
      }

      this.afterglow[index] = Math.max(
        this.gates[index],
        this.afterglow[index] * Math.exp(-dt / fall),
      );
      const target = this.gates[index] ? 1 : this.afterglow[index] * 0.18;
      this.targets[index] = target;
      const tau = target > this.values[index] ? rise : fall;
      const stiffness = 1 - Math.exp(-dt / tau);
      const damping = Math.exp(-dt * (target > this.values[index] ? 11 : 8));
      this.velocities[index] =
        (this.velocities[index] + (target - this.values[index]) * stiffness * 22) *
        damping;
      this.values[index] = clamp01(
        this.values[index] + this.velocities[index] * dt,
      );
      if (this.values[index] > 0.32) activePixels += 1;
    }
    this.stats.activePixels = activePixels;
    this.stats.sampledPixels = sampledPixels;
  }

  settleGrid(time = this.clock, options = {}) {
    const requestedTime = Number.isFinite(Number(time)) ? Number(time) : this.clock;
    let representativeTime = requestedTime;
    if (this.reducedMotion && requestedTime === 0) {
      const reducedPhase = clamp(
        Number(this.currentRecipe?.reducedMotion?.phase) || 0.25,
        0,
        1,
      );
      const cycleMs =
        Number(this.currentRecipe?.timeline?.macroCycleMs) ||
        Number(this.currentRecipe?.timeline?.durationMs) ||
        4000;
      representativeTime = (cycleMs / 1000) * reducedPhase;
    }
    this.clock = requestedTime;
    this.phase = representativeTime * this.options.speed;
    this.transitionElapsed = this.transitionDuration;
    this.dwell.fill(0);
    this.evaluateGrid(1 / 60, { forceFull: true });

    let activePixels = 0;
    for (let index = 0; index < this.values.length; index += 1) {
      const target = this.gates[index] ? 1 : 0;
      this.values[index] = target;
      this.targets[index] = target;
      this.velocities[index] = 0;
      this.afterglow[index] = target;
      if (target) activePixels += 1;
    }
    this.stats.activePixels = activePixels;
    if (options.completeTransition !== false) this.finishTransition("settled");
  }

  drawPixel(shape, context, x, y, width, height, value, angle = 0) {
    const scale = 0.18 + value * 0.82;
    const halfWidth = (width * scale) / 2;
    const halfHeight = (height * scale) / 2;

    switch (shape) {
      case "square":
        context.fillRect(
          x - halfWidth,
          y - halfHeight,
          halfWidth * 2,
          halfHeight * 2,
        );
        break;
      case "diamond":
        context.save();
        context.translate(x, y);
        context.rotate(Math.PI / 4);
        context.fillRect(
          -halfWidth * 0.74,
          -halfHeight * 0.74,
          halfWidth * 1.48,
          halfHeight * 1.48,
        );
        context.restore();
        break;
      case "capsule": {
        const radius = Math.min(halfWidth, halfHeight);
        context.beginPath();
        context.roundRect?.(
          x - halfWidth,
          y - halfHeight * 0.64,
          halfWidth * 2,
          halfHeight * 1.28,
          radius,
        );
        if (!context.roundRect) {
          context.ellipse(x, y, halfWidth, halfHeight * 0.64, 0, 0, TAU);
        }
        context.fill();
        break;
      }
      case "line":
        context.save();
        context.translate(x, y);
        if (angle) context.rotate(angle);
        context.fillRect(-halfWidth, -halfHeight * 0.22, halfWidth * 2, halfHeight * 0.44);
        context.restore();
        break;
      case "ring":
        context.lineWidth = Math.max(0.12, Math.min(width, height) * 0.14);
        context.beginPath();
        context.ellipse(x, y, halfWidth, halfHeight, 0, 0, TAU);
        context.stroke();
        break;
      case "cross":
        context.fillRect(
          x - halfWidth,
          y - halfHeight * 0.2,
          halfWidth * 2,
          halfHeight * 0.4,
        );
        context.fillRect(
          x - halfWidth * 0.2,
          y - halfHeight,
          halfWidth * 0.4,
          halfHeight * 2,
        );
        break;
      case "disc":
      default:
        context.beginPath();
        context.ellipse(x, y, halfWidth, halfHeight, 0, 0, TAU);
        context.fill();
        break;
    }
  }

  paint() {
    const context = this.context;
    const width = this.width || this.canvas.width / (this.dpr || 1);
    const height = this.height || this.canvas.height / (this.dpr || 1);
    const transitionActive = this.isTransitionActive();
    const transitionProgress = transitionActive
      ? easeInOut(this.transitionElapsed / Math.max(0.001, this.transitionDuration))
      : 1;
    const targetPalette = activePalette(this.currentRecipe, this.options);
    const sourcePalette = transitionActive
      ? activePalette(this.previousRecipe || this.currentRecipe, this.options)
      : targetPalette;
    const palette = transitionActive
      ? mixPalette(sourcePalette, targetPalette, transitionProgress)
      : targetPalette;
    const pixelShape = normalizePixelShape(
      this.options.pixelShape ||
        recipeValue(this.currentRecipe, "pixelShape", "disc"),
    );
    const switchMode = this.resolvedPixelSwitch(this.currentRecipe).mode;
    const { stageSize, offsetX, offsetY, cellSize: cell } = canvasGridLayout(
      this.options.previewMode,
      this.gridSize,
      width,
      height,
    );
    const targetPixelScale = recipePixelScale(this.currentRecipe);
    const sourcePixelScale = transitionActive
      ? recipePixelScale(this.previousRecipe || this.currentRecipe)
      : targetPixelScale;
    const pixel =
      cell *
      (sourcePixelScale +
        (targetPixelScale - sourcePixelScale) * transitionProgress);

    context.save();
    context.setTransform(
      this.renderScaleX || this.dpr || 1,
      0,
      0,
      this.renderScaleY || this.dpr || 1,
      0,
      0,
    );
    context.clearRect(0, 0, width, height);
    if (this.options.background && palette.background !== "transparent") {
      context.fillStyle = palette.background;
      context.fillRect(0, 0, width, height);
    }
    context.imageSmoothingEnabled = false;

    const orbBackgroundColor = safeSvgColor(
      this.options.orbBackgroundColor,
      "transparent",
    );
    if (
      this.options.orbBackgroundMode !== "none" &&
      orbBackgroundColor !== "transparent"
    ) {
      const sourceRecipe = transitionActive
        ? this.previousRecipe || this.currentRecipe
        : this.currentRecipe;
      const sourceRadius = this.orbBackgroundRadius(sourceRecipe, this.clock);
      const targetRadius = this.orbBackgroundRadius(
        this.currentRecipe,
        this.clock,
      );
      const sourceVisible = sourceRadius > 0;
      const targetVisible = targetRadius > 0;
      const orbOpacity = transitionActive
        ? (sourceVisible ? 1 - transitionProgress : 0) +
          (targetVisible ? transitionProgress : 0)
        : targetVisible
          ? 1
          : 0;
      const orbRadius =
        sourceVisible && targetVisible
          ? sourceRadius + (targetRadius - sourceRadius) * transitionProgress
          : targetVisible
            ? targetRadius
            : sourceRadius;
      if (orbOpacity > 0.001 && orbRadius > 0) {
        context.globalAlpha = clamp01(orbOpacity);
        context.fillStyle = orbBackgroundColor;
        if (this.options.orbBackgroundMode === "pixelated") {
          drawPixelatedOrbBackground(
            context,
            this.gridSize,
            orbRadius,
            offsetX,
            offsetY,
            cell,
          );
        } else {
          context.beginPath();
          context.arc(
            offsetX + stageSize / 2,
            offsetY + stageSize / 2,
            (stageSize * orbRadius) / 2,
            0,
            TAU,
          );
          context.fill();
        }
        context.globalAlpha = 1;
      }
    }

    if (this.options.offPixels) {
      const offColorKey = `${palette.off}|${palette.ink}`;
      if (this.paintColorCache.off?.key !== offColorKey) {
        this.paintColorCache.off = {
          key: offColorKey,
          value: mixColor(palette.off, palette.ink, 0.05, 0.58),
        };
      }
      context.fillStyle = this.paintColorCache.off.value;
      const offSize = Math.max(0.16, pixel * 0.18);
      for (let index = 0; index < this.values.length; index += 1) {
        const gx = index % this.gridSize;
        const gy = Math.floor(index / this.gridSize);
        const cx = offsetX + (gx + 0.5) * cell;
        const cy = offsetY + (gy + 0.5) * cell;
        context.fillRect(cx - offSize / 2, cy - offSize / 2, offSize, offSize);
      }
    }

    const colorKey = [
      sourcePalette.ink,
      sourcePalette.accent,
      targetPalette.ink,
      targetPalette.accent,
    ].join("|");
    if (this.paintColorCache.key !== colorKey) {
      const source = Array.from({ length: 9 }, (_, index) =>
        mixColor(sourcePalette.ink, sourcePalette.accent, index / 8, 1),
      );
      const target = Array.from({ length: 9 }, (_, index) =>
        mixColor(targetPalette.ink, targetPalette.accent, index / 8, 1),
      );
      this.paintColorCache.key = colorKey;
      this.paintColorCache.source = source;
      this.paintColorCache.target = target;
      this.paintColorCache.steady = [target];
      this.paintColorCache.transition = null;
    }
    if (transitionActive && !this.paintColorCache.transition) {
      const source = this.paintColorCache.source;
      const target = this.paintColorCache.target;
      this.paintColorCache.transition = Array.from(
        { length: 9 },
        (_, transitionIndex) =>
          Array.from({ length: 9 }, (_, colorIndex) =>
            mixColor(
              source[colorIndex],
              target[colorIndex],
              transitionIndex / 8,
              1,
            ),
          ),
      );
    }
    const transitionColorBins = transitionActive
      ? this.paintColorCache.transition
      : this.paintColorCache.steady;
    const glowFade = clamp((72 - this.gridSize) / 24, 0, 1);
    const glowEnabled =
      this.qualityTier !== "low" &&
      this.gridSize <= 48 &&
      glowFade > 0.04 &&
      recipeValue(this.currentRecipe, "glow", true) !== false &&
      String(palette.glow).trim().toLowerCase() !== "transparent";
    if (glowEnabled) {
      context.shadowColor = palette.glow;
      const qualityGlow = this.qualityTier === "balanced" ? 0.58 : 1;
      context.shadowBlur = Math.max(0.25, cell * 0.52 * glowFade * qualityGlow);
    }

    for (let index = 0; index < this.values.length; index += 1) {
      const value = this.values[index];
      if (value < 0.035) continue;
      const gx = index % this.gridSize;
      const gy = Math.floor(index / this.gridSize);
      const cx = offsetX + (gx + 0.5) * cell;
      const cy = offsetY + (gy + 0.5) * cell;
      const luminance = this.luminance[index];
      const renderedShape = resolvePixelShape(pixelShape, luminance);
      const bin = Math.min(8, Math.floor(luminance * 8.999));
      const alpha = clamp01(value * (0.72 + luminance * 0.28));
      context.globalAlpha = alpha;
      const transitionBin = transitionActive
        ? Math.min(8, Math.round(this.transitionMixes[index] * 8))
        : 0;
      const color = transitionColorBins[transitionBin][bin];
      context.fillStyle = color;
      context.strokeStyle = color;

      let widthScale = 1;
      let heightScale = 1;
      if (
        switchMode === "axis-flip" ||
        (transitionActive && this.transitionName === "axis-flip")
      ) {
        heightScale = Math.max(0.07, Math.sin(value * Math.PI * 0.5));
      } else if (switchMode === "path-draw") {
        widthScale = 0.35 + value * 0.65;
      } else if (switchMode === "radial-cascade") {
        widthScale = heightScale = 0.55 + value * 0.45;
      }
      const angle =
        renderedShape === "line"
          ? Math.atan2(this.ys[index], this.xs[index]) + Math.PI / 2
          : 0;
      this.drawPixel(
        renderedShape,
        context,
        cx,
        cy,
        pixel * widthScale,
        pixel * heightScale,
        value,
        angle,
      );
    }
    context.globalAlpha = 1;
    context.shadowBlur = 0;
    context.restore();
  }

  updateAdaptiveQuality(frameMs) {
    if (this.options.quality !== "auto") {
      this.qualityTier = this.options.quality;
      this.stats.quality = this.qualityTier;
      return;
    }
    const budget = 1000 / clamp(Number(this.options.fps) || 60, 1, 120);
    if (frameMs > budget * 1.12) {
      this.qualityPressure += 1;
      this.qualityRecovery = Math.max(0, this.qualityRecovery - 2);
    } else if (frameMs < budget * 0.62) {
      this.qualityRecovery += 1;
      this.qualityPressure = Math.max(0, this.qualityPressure - 1);
    } else {
      this.qualityPressure = Math.max(0, this.qualityPressure - 0.25);
      this.qualityRecovery = Math.max(0, this.qualityRecovery - 0.5);
    }

    let next = this.qualityTier;
    if (this.qualityPressure >= 18) {
      next = next === "high" ? "balanced" : "low";
      this.qualityPressure = 0;
      this.qualityRecovery = 0;
    } else if (this.qualityRecovery >= 180) {
      next = next === "low" ? "balanced" : "high";
      this.qualityPressure = 0;
      this.qualityRecovery = 0;
    }
    if (next !== this.qualityTier) {
      const previous = this.qualityTier;
      this.qualityTier = next;
      this.emit("qualitychange", {
        from: previous,
        to: next,
        frameMs,
        budgetMs: budget,
      });
    }
    this.stats.quality = this.qualityTier;
  }

  renderFrame(time = 0, dt = 1 / 60, force = false) {
    if (this.destroyed) return this.stats;
    const started =
      typeof performance !== "undefined" ? performance.now() : Date.now();
    const elapsed = this.reducedMotion && !force
      ? 0
      : clamp(Number(dt) || 0, 0, this.maxCatchUpSeconds);
    let simulationSteps = 0;
    let simulatedElapsed = 0;
    if (force) {
      this.simulationAccumulator = 0;
      this.settleGrid(time);
    } else {
      this.simulationAccumulator = Math.min(
        this.maxCatchUpSeconds,
        this.simulationAccumulator + elapsed,
      );
      while (
        this.simulationAccumulator + 1e-9 >= this.simulationStep &&
        simulationSteps < this.maxSimulationSteps
      ) {
        this.updateDynamics(this.simulationStep);
        this.simulationAccumulator -= this.simulationStep;
        simulationSteps += 1;
        simulatedElapsed += this.simulationStep;
      }
      if (simulationSteps === this.maxSimulationSteps) {
        this.simulationAccumulator %= this.simulationStep;
      }
      if (simulationSteps > 0) {
        // Global clocks retain fixed 60 Hz determinism, while the expensive
        // field/grid pass is coalesced to the painted frame. This prevents a
        // slow frame from multiplying its own workload during catch-up.
        this.evaluateGrid(simulatedElapsed);
      } else {
        this.stats.sampledPixels = 0;
      }
    }
    this.stats.simulationSteps = simulationSteps;
    this.paint();

    const ended =
      typeof performance !== "undefined" ? performance.now() : Date.now();
    this.stats.frameMs = ended - started;
    this.updateAdaptiveQuality(this.stats.frameMs);
    this.fpsAccumulator.time += elapsed;
    this.fpsAccumulator.frames += 1;
    if (this.fpsAccumulator.time >= 0.5) {
      this.stats.fps = Math.round(
        this.fpsAccumulator.frames / this.fpsAccumulator.time,
      );
      this.fpsAccumulator.time = 0;
      this.fpsAccumulator.frames = 0;
      this.emit("stats", { ...this.stats });
    }
    return { ...this.stats };
  }

  renderOnce(time = this.clock) {
    return this.renderFrame(time, 0, true);
  }

  inspect() {
    const transitionProgress = this.transitionDuration <= 0
      ? 1
      : clamp01(this.transitionElapsed / this.transitionDuration);
    return {
      sprite: this.currentRecipe?.id || null,
      recipeSource: this.recipeSource?.id || null,
      previousSprite: this.previousRecipe?.id || null,
      fieldMode: this.fieldOverride ? "override" : "recipe",
      field: this.fieldOverride || this.currentRecipe?.field || null,
      running: this.running,
      visible: this.visible,
      destroyed: this.destroyed,
      reducedMotion: this.reducedMotion,
      transition: {
        active: this.isTransitionActive(),
        name: this.transitionName,
        progress: transitionProgress,
        elapsed: this.transitionElapsed,
        duration: this.transitionDuration,
        pending:
          typeof this.pendingSpriteTransition?.sprite === "string"
            ? this.pendingSpriteTransition.sprite
            : this.pendingSpriteTransition?.sprite?.id || null,
      },
      signals: Array.from(this.signals, ([type, signal]) => ({
        type,
        value: signal.value,
        age: signal.age,
        life: signal.life,
      })),
      semanticSignals: Object.fromEntries(
        Array.from(this.semanticSignals, ([name, signal]) => [
          name,
          signal.value,
        ]),
      ),
      palette: { ...activePalette(this.currentRecipe, this.options) },
      orbBoundary: this.options.orbBoundary,
      orbBackgroundColor: this.options.orbBackgroundColor,
      orbBackgroundMode: this.options.orbBackgroundMode,
      quality: {
        requested: this.options.quality,
        effective: this.qualityTier,
      },
      stats: { ...this.stats },
    };
  }

  exportConfig() {
    return {
      package: "@joan/procedural-glyph-engine",
      version: "5.0.0",
      sprite: this.currentRecipe.id,
      seed: this.seed,
      gridSize: this.gridSize,
      speed: this.options.speed,
      density: this.options.density,
      contrast: this.options.contrast,
      fps: this.options.fps,
      field: this.currentRecipe.field,
      fieldMode: this.fieldOverride ? "override" : "recipe",
      pixelShape: this.options.pixelShape || null,
      pixelSwitch:
        this.options.pixelSwitch && typeof this.options.pixelSwitch === "object"
          ? { ...this.options.pixelSwitch }
          : this.options.pixelSwitch || null,
      transition:
        this.options.transition && typeof this.options.transition === "object"
          ? {
              ...this.options.transition,
              name: validateTransition(this.options.transition),
            }
          : this.options.transition || null,
      palette: activePalette(this.currentRecipe, this.options),
      paletteOverride:
        this.options.palette && typeof this.options.palette === "object"
          ? { ...this.options.palette }
          : null,
      nonErrorPalette:
        this.options.nonErrorPalette &&
        typeof this.options.nonErrorPalette === "object"
          ? { ...this.options.nonErrorPalette }
          : null,
      orbBoundary: this.options.orbBoundary,
      orbBackgroundColor: this.options.orbBackgroundColor,
      orbBackgroundMode: this.options.orbBackgroundMode,
      progress: this.progress,
      background: this.options.background,
      offPixels: this.options.offPixels,
      interactive: this.options.interactive,
      autoplay: this.options.autoplay,
      autoResize: this.options.autoResize,
      dprMax: this.options.dprMax,
      quality: this.options.quality,
      reducedMotion: this.options.reducedMotion,
      direction: this.options.direction || null,
      targetGlyph: this.options.targetGlyph || null,
    };
  }

  toSVG(options = {}) {
    const size = Math.round(clamp(Number(options.size) || 512, 64, 4096));
    const palette = activeSvgPalette(this.currentRecipe, this.options);
    const shape = normalizePixelShape(
      this.options.pixelShape ||
        recipeValue(this.currentRecipe, "pixelShape", "disc"),
    );
    const margin = size * 0.075;
    const stage = size - margin * 2;
    const cell = stage / this.gridSize;
    const pixel = cell * recipePixelScale(this.currentRecipe);
    const switchMode = recipePixelSwitch(
      this.currentRecipe,
      this.options.pixelSwitch,
    ).mode;
    const parts = [
      `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="${escapeXmlAttribute(
        semanticLabel(this.currentRecipe),
      )}">`,
      `<title>${escapeXml(semanticLabel(this.currentRecipe))}</title>`,
    ];
    if (options.background !== false && palette.background !== "transparent") {
      parts.push(
        `<rect width="${size}" height="${size}" fill="${escapeXmlAttribute(
          palette.background,
        )}"/>`,
      );
    }
    const orbBackgroundColor = safeSvgColor(
      this.options.orbBackgroundColor,
      "transparent",
    );
    const orbRadius = this.orbBackgroundRadius(this.currentRecipe, this.clock);
    if (
      this.options.orbBackgroundMode !== "none" &&
      orbBackgroundColor !== "transparent" &&
      orbRadius > 0
    ) {
      if (this.options.orbBackgroundMode === "pixelated") {
        parts.push(
          svgPixelatedOrbBackground(
            this.gridSize,
            orbRadius,
            margin,
            cell,
            orbBackgroundColor,
          ),
        );
      } else {
        parts.push(
          `<circle data-joan-layer="orb-background" data-joan-mode="solid" cx="${(
            size / 2
          ).toFixed(2)}" cy="${(size / 2).toFixed(2)}" r="${(
            (stage * orbRadius) /
            2
          ).toFixed(2)}" fill="${escapeXmlAttribute(orbBackgroundColor)}"/>`,
        );
      }
    }
    if (options.offPixels ?? this.options.offPixels) {
      const offSize = Math.max(0.16, pixel * 0.18);
      parts.push(
        `<g fill="${escapeXmlAttribute(
          palette.off,
        )}" opacity=".58"><g data-joan-layer="off-pixels">`,
      );
      for (let index = 0; index < this.values.length; index += 1) {
        const gx = index % this.gridSize;
        const gy = Math.floor(index / this.gridSize);
        const cx = margin + (gx + 0.5) * cell;
        const cy = margin + (gy + 0.5) * cell;
        parts.push(
          `<rect x="${(cx - offSize / 2).toFixed(2)}" y="${(
            cy -
            offSize / 2
          ).toFixed(2)}" width="${offSize.toFixed(2)}" height="${offSize.toFixed(
            2,
          )}"/>`,
        );
      }
      parts.push("</g></g>");
    }
    parts.push(
      `<g data-joan-layer="active-pixels" fill="${escapeXmlAttribute(
        palette.ink,
      )}" stroke="${escapeXmlAttribute(palette.ink)}">`,
    );
    for (let index = 0; index < this.values.length; index += 1) {
      const value = this.values[index];
      if (value < 0.035) continue;
      const gx = index % this.gridSize;
      const gy = Math.floor(index / this.gridSize);
      const cx = margin + (gx + 0.5) * cell;
      const cy = margin + (gy + 0.5) * cell;
      const scale = 0.18 + value * 0.82;
      let widthScale = 1;
      let heightScale = 1;
      if (switchMode === "axis-flip") {
        heightScale = Math.max(0.07, Math.sin(value * Math.PI * 0.5));
      } else if (switchMode === "path-draw") {
        widthScale = 0.35 + value * 0.65;
      } else if (switchMode === "radial-cascade") {
        widthScale = heightScale = 0.55 + value * 0.45;
      }
      const width = pixel * scale * widthScale;
      const height = pixel * scale * heightScale;
      const opacity = clamp01(value * (0.72 + this.luminance[index] * 0.28));
      const colorBin = Math.min(
        8,
        Math.floor(this.luminance[index] * 8.999),
      );
      const color = mixColor(
        palette.ink,
        palette.accent,
        colorBin / 8,
        1,
      );
      const attributes = `opacity="${opacity.toFixed(3)}"`;
      const renderedShape = resolvePixelShape(shape, this.luminance[index]);
      const ringStrokeWidth =
        renderedShape === "ring"
          ? Math.max(
              0.12,
              Math.min(pixel * widthScale, pixel * heightScale) * 0.14,
            )
          : null;
      const angle =
        renderedShape === "line"
          ? ((Math.atan2(this.ys[index], this.xs[index]) + Math.PI / 2) * 180) /
            Math.PI
          : 0;
      parts.push(
        `<g data-joan-cell="${index}" data-joan-shape="${renderedShape}" fill="${escapeXmlAttribute(color)}" stroke="${escapeXmlAttribute(
          color,
        )}">${svgShapeMarkup(
          renderedShape,
          cx,
          cy,
          width,
          height,
          attributes,
          "",
          angle,
          ringStrokeWidth,
        )}</g>`,
      );
    }
    parts.push("</g></svg>");
    return parts.join("");
  }

  async toAnimatedSVG(options = {}) {
    if (typeof document === "undefined") {
      throw new Error("Animated SVG export requires a browser canvas.");
    }
    const duration = clamp(Number(options.duration) || 2.4, 0.5, 8);
    const fps = Math.round(clamp(Number(options.fps) || 12, 4, 30));
    const steps = Math.ceil(duration * fps);
    const frames = steps + 1;
    const gridSize = Math.min(
      this.gridSize,
      Math.round(clamp(Number(options.maxGridSize) || 48, 12, 64)),
    );
    const scratchCanvas = document.createElement("canvas");
    scratchCanvas.width = 512;
    scratchCanvas.height = 512;
    const clone = new JoanGlyphEngine(scratchCanvas, {
      ...this.exportConfig(),
      sprite: this.currentRecipe,
      gridSize,
      autoplay: false,
      autoResize: false,
      interactive: false,
      reducedMotion: false,
      dprMax: 1,
      quality: "low",
    });
    clone.customGlyphs = new Map(this.customGlyphs);
    clone.progress = this.progress;
    clone.audioLevel = this.audioLevel;
    const samples = Array.from(
      { length: gridSize * gridSize },
      () => new Float32Array(frames),
    );
    const luminanceSamples = Array.from(
      { length: gridSize * gridSize },
      () => new Float32Array(frames),
    );
    const orbRadiusSamples = new Float32Array(frames);
    try {
      for (let frame = 0; frame < steps; frame += 1) {
        if (frame > 0) clone.renderFrame(frame / fps, 1 / fps, false);
        orbRadiusSamples[frame] = clone.orbBackgroundRadius(
          clone.currentRecipe,
          frame / fps,
        );
        for (let index = 0; index < clone.values.length; index += 1) {
          samples[index][frame] = clone.values[index];
          luminanceSamples[index][frame] = clone.luminance[index];
        }
        if (frame % 8 === 7) {
          await new Promise((resolve) => setTimeout(resolve, 0));
        }
      }
      const closureFrames = Math.min(3, Math.max(1, Math.floor(steps * 0.12)));
      for (
        let frame = Math.max(1, steps - closureFrames);
        frame < steps;
        frame += 1
      ) {
        const amount = easeInOut(
          (frame - (steps - closureFrames)) / closureFrames,
        );
        orbRadiusSamples[frame] +=
          (orbRadiusSamples[0] - orbRadiusSamples[frame]) * amount;
      }
      orbRadiusSamples[steps] = orbRadiusSamples[0];
      for (let index = 0; index < samples.length; index += 1) {
        for (
          let frame = Math.max(1, steps - closureFrames);
          frame < steps;
          frame += 1
        ) {
          const amount = easeInOut(
            (frame - (steps - closureFrames)) / closureFrames,
          );
          samples[index][frame] +=
            (samples[index][0] - samples[index][frame]) * amount;
          luminanceSamples[index][frame] +=
            (luminanceSamples[index][0] - luminanceSamples[index][frame]) *
            amount;
        }
        samples[index][steps] = samples[index][0];
        luminanceSamples[index][steps] = luminanceSamples[index][0];
      }
      const size = Math.round(clamp(Number(options.size) || 512, 64, 2048));
      const palette = activeSvgPalette(this.currentRecipe, this.options);
      const margin = size * 0.075;
      const stage = size - margin * 2;
      const cell = stage / gridSize;
      const pixel = cell * recipePixelScale(this.currentRecipe);
      const shape = normalizePixelShape(
        this.options.pixelShape ||
          recipeValue(this.currentRecipe, "pixelShape", "disc"),
      );
      const switchMode = recipePixelSwitch(
        this.currentRecipe,
        this.options.pixelSwitch,
      ).mode;
      const keyTimes = Array.from({ length: frames }, (_, index) =>
        (index / Math.max(1, frames - 1)).toFixed(4),
      ).join(";");
      const parts = [
        `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="${escapeXmlAttribute(
          `${semanticLabel(this.currentRecipe)} animation`,
        )}">`,
        `<title>${escapeXml(semanticLabel(this.currentRecipe))}</title>`,
      ];
      if (options.background !== false && palette.background !== "transparent") {
        parts.push(
          `<rect width="${size}" height="${size}" fill="${escapeXmlAttribute(
            palette.background,
          )}"/>`,
        );
      }
      const orbBackgroundColor = safeSvgColor(
        this.options.orbBackgroundColor,
        "transparent",
      );
      const maximumOrbRadius = Math.max(...orbRadiusSamples);
      if (
        this.options.orbBackgroundMode !== "none" &&
        orbBackgroundColor !== "transparent" &&
        maximumOrbRadius > 0
      ) {
        if (this.options.orbBackgroundMode === "pixelated") {
          parts.push(
            animatedSvgPixelatedOrbBackground(
              gridSize,
              orbRadiusSamples,
              margin,
              cell,
              orbBackgroundColor,
              keyTimes,
              duration,
            ),
          );
        } else {
          const radiusValues = Array.from(orbRadiusSamples, (radius) =>
            ((stage * radius) / 2).toFixed(3),
          ).join(";");
          parts.push(
            `<circle data-joan-layer="orb-background" data-joan-mode="solid" cx="${(
              size / 2
            ).toFixed(2)}" cy="${(size / 2).toFixed(2)}" r="${(
              (stage * orbRadiusSamples[0]) /
              2
            ).toFixed(3)}" fill="${escapeXmlAttribute(
              orbBackgroundColor,
            )}"><animate attributeName="r" values="${radiusValues}" keyTimes="${keyTimes}" dur="${duration}s" calcMode="linear" repeatCount="indefinite"/></circle>`,
          );
        }
      }
      if (options.offPixels ?? this.options.offPixels) {
        const offSize = Math.max(0.16, pixel * 0.18);
        parts.push(
          `<g data-joan-layer="off-pixels" fill="${escapeXmlAttribute(
            palette.off,
          )}" opacity=".58">`,
        );
        for (let index = 0; index < samples.length; index += 1) {
          const gx = index % gridSize;
          const gy = Math.floor(index / gridSize);
          const cx = margin + (gx + 0.5) * cell;
          const cy = margin + (gy + 0.5) * cell;
          parts.push(
            `<rect x="${(cx - offSize / 2).toFixed(2)}" y="${(
              cy -
              offSize / 2
            ).toFixed(2)}" width="${offSize.toFixed(
              2,
            )}" height="${offSize.toFixed(2)}"/>`,
          );
        }
        parts.push("</g>");
      }
      parts.push(
        `<g data-joan-layer="active-pixels" fill="${escapeXmlAttribute(
          palette.ink,
        )}" stroke="${escapeXmlAttribute(palette.ink)}">`,
      );
      for (let index = 0; index < samples.length; index += 1) {
        const values = samples[index];
        const luminances = luminanceSamples[index];
        let maximum = 0;
        for (let frame = 0; frame < frames; frame += 1) {
          maximum = Math.max(maximum, values[frame]);
        }
        if (maximum < 0.03) continue;
        const gx = index % gridSize;
        const gy = Math.floor(index / gridSize);
        const cx = margin + (gx + 0.5) * cell;
        const cy = margin + (gy + 0.5) * cell;
        const opacityValues = Array.from(values, (value, frame) =>
          clamp01(value * (0.72 + luminances[frame] * 0.28)).toFixed(3),
        );
        const opacities = opacityValues.join(";");
        const ringStrokeWidths = [];
        const scales = Array.from(values, (value) => {
          const scale = 0.18 + clamp01(value) * 0.82;
          let widthScale = 1;
          let heightScale = 1;
          if (switchMode === "axis-flip") {
            heightScale = Math.max(
              0.07,
              Math.sin(clamp01(value) * Math.PI * 0.5),
            );
          } else if (switchMode === "path-draw") {
            widthScale = 0.35 + clamp01(value) * 0.65;
          } else if (switchMode === "radial-cascade") {
            widthScale = heightScale = 0.55 + clamp01(value) * 0.45;
          }
          ringStrokeWidths.push(
            Math.max(
              0.12,
              Math.min(pixel * widthScale, pixel * heightScale) * 0.14,
            ).toFixed(3),
          );
          return `${(scale * widthScale).toFixed(3)} ${(
            scale * heightScale
          ).toFixed(3)}`;
        }).join(";");
        const initialScales = scales.split(";")[0].split(" ");
        const colors = Array.from(luminances, (luminance) =>
          mixColor(palette.ink, palette.accent, luminance, 1),
        );
        const colorValues = colors
          .map((color) => escapeXmlAttribute(color))
          .join(";");
        const angle =
          shape === "line"
            ? ((Math.atan2(gy + 0.5 - gridSize / 2, gx + 0.5 - gridSize / 2) +
                Math.PI / 2) *
                180) /
              Math.PI
            : 0;
        const opacityAnimation = `<animate attributeName="opacity" values="${opacities}" keyTimes="${keyTimes}" dur="${duration}s" calcMode="discrete" repeatCount="indefinite"/>`;
        const scaleAnimation = `<animateTransform attributeName="transform" type="scale" values="${scales}" keyTimes="${keyTimes}" dur="${duration}s" calcMode="discrete" repeatCount="indefinite"/>`;
        const fillAnimation = `<animate attributeName="fill" values="${colorValues}" keyTimes="${keyTimes}" dur="${duration}s" calcMode="discrete" repeatCount="indefinite"/>`;
        const strokeAnimation = `<animate attributeName="stroke" values="${colorValues}" keyTimes="${keyTimes}" dur="${duration}s" calcMode="discrete" repeatCount="indefinite"/>`;
        const localShape = animatedSvgShapeMarkup(
          shape,
          pixel,
          pixel,
          luminances,
          keyTimes,
          duration,
          angle,
          ringStrokeWidths,
        );
        parts.push(
          `<g data-joan-cell="${index}" data-joan-pixel-shape="${shape}" transform="translate(${cx.toFixed(2)} ${cy.toFixed(
            2,
          )})" fill="${escapeXmlAttribute(colors[0])}" stroke="${escapeXmlAttribute(
            colors[0],
          )}" opacity="${opacityValues[0]}">${opacityAnimation}${fillAnimation}${strokeAnimation}<g transform="scale(${initialScales[0]} ${initialScales[1]})">${scaleAnimation}${localShape}</g></g>`,
        );
      }
      parts.push("</g></svg>");
      return parts.join("");
    } finally {
      clone.destroy();
    }
  }

  async downloadSVG(
    filename = `${this.currentRecipe.id.replaceAll(".", "-")}.svg`,
    options,
  ) {
    const source = this.toSVG(options);
    if (typeof document === "undefined") return source;
    const blob = new Blob([source], { type: "image/svg+xml" });
    const url = URL.createObjectURL(blob);
    try {
      const anchor = document.createElement("a");
      anchor.href = url;
      anchor.download = filename;
      anchor.click();
    } finally {
      URL.revokeObjectURL(url);
    }
    return source;
  }

  async downloadAnimatedSVG(
    filename = `${this.currentRecipe.id.replaceAll(".", "-")}.animated.svg`,
    options,
  ) {
    const source = await this.toAnimatedSVG(options);
    const blob = new Blob([source], { type: "image/svg+xml" });
    const url = URL.createObjectURL(blob);
    try {
      const anchor = document.createElement("a");
      anchor.href = url;
      anchor.download = filename;
      anchor.click();
    } finally {
      URL.revokeObjectURL(url);
    }
    return source;
  }

  toDataURL(type = "image/png", quality) {
    return this.canvas.toDataURL(type, quality);
  }

  toBlob(type = "image/png", quality) {
    return new Promise((resolve, reject) => {
      this.canvas.toBlob?.(
        (blob) => (blob ? resolve(blob) : reject(new Error("Canvas export failed."))),
        type,
        quality,
      );
    });
  }

  async downloadPNG(filename = `${this.currentRecipe.id.replaceAll(".", "-")}.png`) {
    if (typeof document === "undefined") return this.toBlob();
    const blob = await this.toBlob();
    const url = URL.createObjectURL(blob);
    try {
      const anchor = document.createElement("a");
      anchor.href = url;
      anchor.download = filename;
      anchor.click();
    } finally {
      URL.revokeObjectURL(url);
    }
    return blob;
  }

  destroy() {
    if (this.destroyed) return;
    this.pause();
    this.cancelReducedCrossfade();
    this.destroyed = true;
    for (const remove of this.listeners.splice(0)) remove();
    for (const remove of this.interactionListeners.splice(0)) remove();
    this.resizeObserver?.disconnect();
    this.intersectionObserver?.disconnect();
    this.customGlyphs.clear();
    this.impulses.length = 0;
    this.emit("destroy", {});
  }
}

export function createGlyph(target, options = {}) {
  const canvas =
    typeof target === "string"
      ? typeof document !== "undefined"
        ? document.querySelector(target)
        : null
      : target;
  if (!canvas) {
    throw new TypeError(`No canvas matched "${String(target)}".`);
  }
  return new JoanGlyphEngine(canvas, options);
}

export function createProceduralGlyph(input = {}, options = {}) {
  if (input?.canvas) {
    const { canvas, ...engineOptions } = input;
    return createGlyph(canvas, engineOptions);
  }
  return createGlyph(input, options);
}

export function mountProceduralGlyph(target, options = {}) {
  return createGlyph(target, options);
}

export { SPRITES, getSprite, listSprites };
export default JoanGlyphEngine;
