← Commit history
README.md+36
@@ -26,6 +26,42 @@ adom-wiki page hero <owner>/<slug> --image docs/hero.png `hero-studio doctor` verifies both render paths (local headless Chrome, or the host browser via the desktop bridge) and prints exact fixes. +## Circuit Trace Generators++The Background step and Classic controls include two options from+[Circuit Trace Generators](https://wiki.adom.inc/adom/circuit-trace-generators):++- **Routed field** (`circuit-field`): the v9 continuous router uses the rendered+  letter shapes, showcase, and badges as obstacles. Moving the showcase, changing+  the headline, or switching to a mirrored layout recomposes the field.+- **Seamless traces** (`circuit-tile`): the torus router retains its repeating+  geometry, with a soft mask clearing those same content areas.++Both continue through open space at the bottom, use the page owner's+primary/background colors (neutral ink until a guide resolves), and expose+Density, Trace size, Strength, Clear space, a numeric Seed, and New pattern.+The controls live below the theme picker; no imported full background is needed.+The original Generated traces option remains unchanged.++```bash+hero-studio --ai-thread "hero circuit review" set theme=circuit-field circuitSeed=1234+hero-studio --ai-thread "hero circuit review" set circuit.density=0.8 circuit.scale=1.4 circuit.strength=0.22 circuit.clearance=8+```++Settings and seed survive snapshots, saved styles, and PNG provenance. Geometry+is measured at the export size, then reused in the live hero, wiki marquee, and+index cards. DOM Range measures actual wrapped text runs; Canvas paints their+loaded fonts into a contour stencil. Empty column space remains available to+the router. The homepage overlay supplies its own scrim, so no bottom strip is+removed from the artwork. Font and showcase loading remeasure the composition. Full-hero+imports hide the generators. These options render **still artwork**: the source+playground's animation/reveal systems do not run behind a PNG preview.++The source engines are bundled, with their upstream revision recorded under+`public/vendor/circuit-trace-generators/README.md`; previews need no external+generator service. Run `npm run test:traces` with Hero Studio's usual Chrome and+Puppeteer renderer dependencies for geometry, API, wizard, and export checks.+ ## Scale: the human gates the style once  For fleets of similar pages, gate the LOOK, not every part: pose one
package.json+2−1
@@ -68,7 +68,8 @@     "test:render": "node test/render-view.mjs",     "test:renderer": "node test/renderer.mjs",     "test:syntax": "node test/syntax.mjs",-    "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs && node test/platform.mjs",+    "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs && node test/platform.mjs && node test/circuit-traces.mjs",+    "test:traces": "node test/circuit-traces.mjs",     "prepublishOnly": "node test/syntax.mjs",     "test:platform": "node test/platform.mjs"   },
public/hero.css+9
@@ -752,6 +752,15 @@ body[data-mode=component] .mode-app { display:none; }  .hero[data-theme=circuitry] #circuitcanvas { display:block; } +/* A separate transparent composition layer; no baked text or showcase. */+.trace-art { display:none; position:absolute; inset:0; width:100%; height:100%; pointer-events:none; z-index:0; }+.hero[data-theme=circuit-field] .trace-art, .hero[data-theme=circuit-tile] .trace-art { display:block; }+.hero.fullimport .trace-art { display:none; }+.hero[data-theme=circuit-field] .bg, .hero[data-theme=circuit-tile] .bg,+#hero.branded[data-theme=circuit-field] .bg, #hero.branded[data-theme=circuit-tile] .bg {+  background:var(--brand-bg, #0d1117);+}+ .hero.no-chrome .window .titlebar { display:none; }  /* bare (#34.1): the import IS the artwork; no frame, no bg, no shadow, alpha kept */
public/index.html+4
@@ -128,9 +128,12 @@       <div class="swatch sw-aurora" data-v="aurora" data-tip="Aurora: layered soft radials"></div>       <div class="swatch sw-mesh" data-v="mesh" data-tip="Mesh: conic color fields, angle follows the gradient slider"></div>       <div class="swatch sw-circuitry" data-v="circuitry" data-tip="Circuitry: procedurally generated PCB traces, gently different every roll"></div>+      <button class="swatch sw-circuit-field" data-v="circuit-field" aria-label="Routed field" data-tip="Continuous traces routed around your text and showcase"></button>+      <button class="swatch sw-circuit-tile" data-v="circuit-tile" aria-label="Seamless traces" data-tip="Seamless routed tile with quiet space around your composition"></button>       <div class="swatch sw-image" data-v="image" id="sw-image" data-tip="Imported background art: your own brand texture as the theme (tile or cover). Import below; composed text, layouts, and the linter all still apply"></div>     </div>     <button class="btn" id="btn-reroll" style="display:none" data-tip="Re-roll the procedural pattern">Reroll circuitry pattern</button>+    <div id="trace-controls"></div>     <input type="file" id="bg-file" accept="image/*" data-tip="Import background art for the image theme">     <label class="chk"><input type="checkbox" id="bg-cover"> Cover (scale to fill) instead of tiling</label> @@ -205,6 +208,7 @@     </div>     <div id="hero" class="hero" data-theme="midnight" data-layout="bleed-bottom">       <div class="bg"></div>+      <img class="trace-art" id="trace-art" alt="" aria-hidden="true">        <div class="textzone">         <div class="wordmark" id="el-logo" data-tip="Wordmark: adom-org pages get the house mark automatically; other owners show nothing here">
public/js/app.js+109−1
@@ -376,7 +376,8 @@ function render() {   scheduleLint();   renderPageFields();   if (state.theme === 'circuitry') requestAnimationFrame(drawCircuitry);-  $('btn-reroll').style.display = state.theme === 'circuitry' ? '' : 'none';+  $('btn-reroll').style.display = state.theme === 'circuitry' || isTraceTheme() ? '' : 'none';+  renderTraceControls($('trace-controls'));    // marquee overlay preview (ask 5): today's REAL homepage treatment,   // composited over the hero so redundancy and balance are judgeable@@ -593,6 +594,7 @@ function render() {   $('chk-author').checked = state.overlay.author;   $('chk-slug').checked = state.overlay.slug; +  scheduleTraces();   syncPreviews(); } @@ -980,6 +982,9 @@ function computeLint() {   const H = hero.getBoundingClientRect();   if (H.width < 60) return null;   const items = [];+  if (isTraceTheme() && !(state.showcase && state.showcase.full) && window.__traceError) {+    items.push({ id: 'circuit-render', level: 'fail', el: null, msg: 'Circuit traces could not render: ' + window.__traceError });+  }   const rel = (r) => ({ left: r.left - H.left, top: r.top - H.top, right: r.right - H.left, bottom: r.bottom - H.top, width: r.width, height: r.height });   const vis = (el) => el && el.offsetParent && el.getBoundingClientRect().width > 2;   const boxOf = (el) => rel(el.getBoundingClientRect());@@ -1563,6 +1568,109 @@ document.addEventListener('keydown', (e) => {   } }); +/* Context-aware trace generators. Keep the existing circuitry theme intact. */+function isTraceTheme() { return state && ['circuit-field', 'circuit-tile'].includes(state.theme); }+const TRACE_DEFAULTS = { density: 0.8, scale: 1.4, strength: 0.22, clearance: 8 };+let traceModule, traceKey = '', traceRevision = 0;+window.__traceReady = Promise.resolve();+window.__tracePending = false;+window.__traceError = '';+function measureTraceKeepouts(mod, clearance) {+  const hero = $('hero'), win = hero.querySelector('.window');+  const heroStyle = hero.style.cssText, winStyle = win.style.cssText;+  // Measure the live composition at the export size. Pixel-sized borders and+  // font rounding otherwise shift a grid cell between the wizard and export.+  // Restore synchronously, before paint; no duplicate DOM or changed UI state.+  try {+    for (const [key, value] of Object.entries({ width:'1600px', height:'1000px', 'max-width':'none', 'aspect-ratio':'auto', border:'none', flex:'none' })) hero.style.setProperty(key,value,'important');+    fitWindowToShowcase();+    return mod.keepouts(hero, clearance);+  } finally { hero.style.cssText = heroStyle; win.style.cssText = winStyle; }+}+function scheduleTraces() {+  const revision = ++traceRevision;+  const img = $('trace-art');+  if (!isTraceTheme() || (state.showcase && state.showcase.full)) {+    img.removeAttribute('src'); traceKey = ''; window.__tracePending = false; window.__traceError = '';+    window.__traceReady = Promise.resolve();+    return;+  }+  window.__tracePending = true;+  window.__traceReady = (async () => {+    // Font loading and showcase fitting must finish before measuring keepouts.+    traceModule ||= import('./circuit-traces.mjs');+    const [mod] = await Promise.all([traceModule, document.fonts.ready]);+    if (!RENDER_VIEW && !document.hidden) await new Promise(resolve => requestAnimationFrame(resolve));+    if (revision !== traceRevision) return;+    const hero = $('hero');+    if (hero.getBoundingClientRect().width < 10) return;+    const settings = { ...TRACE_DEFAULTS, ...state.circuit };+    const colors = (guideBrand() || {}).colors || {};+    const spec = { theme: state.theme, seed: state.circuitSeed, settings,+      boxes: measureTraceKeepouts(mod, settings.clearance),+      color: colors.primary || '#8b949e', background: colors.background || '#0d1117' };+    const key = JSON.stringify(spec);+    if (key !== traceKey) {+      const art = mod.composeTraces(spec);+      img.src = art.src;+      await img.decode();+      if (revision !== traceRevision) return;+      traceKey = key;+      img.dataset.routes = art.count;+    }+    window.__traceError = '';+    window.__tracePending = false;+    syncPreviews();+  })().catch(e => {+    if (revision !== traceRevision) return;+    traceKey = ''; img.removeAttribute('src');+    window.__tracePending = false; window.__traceError = String(e.message || e);+    console.error('Circuit traces:', e); scheduleLint();+  });+}+new ResizeObserver(() => { if (state) scheduleTraces(); }).observe($('hero'));+document.fonts.addEventListener('loadingdone', () => { if (state) scheduleTraces(); });+$('showcase-img').addEventListener('load', () => { if (state) scheduleTraces(); });++// One control builder for Classic and the wizard. Local feedback during a drag;+// persist on change so a slider never creates a queue of expensive reroutes.+function renderTraceControls(host) {+  if (!host) return;+  const active = isTraceTheme();+  host.hidden = !active;+  if (!active) { host.textContent = ''; return; }+  if (host.contains(document.activeElement)) return;+  host.textContent = ''; host.className = 'trace-controls';+  const full = !!(state.showcase && state.showcase.full);+  const note = document.createElement('div'); note.className = 'hint';+  note.textContent = full ? 'Full hero import is active. Switch it off to compose traces around your content.'+    : 'Follows the shapes of your letters and showcase. Clear space adjusts the breathing room around them.';+  host.appendChild(note);+  const config = { ...TRACE_DEFAULTS, ...state.circuit };+  for (const [key, label, min, max, step] of [['density','Density',0.25,1.25,0.05], ['scale','Trace size',0.8,2.5,0.1], ['strength','Strength',0.05,0.5,0.01], ['clearance','Clear space',8,40,2]]) {+    const lab = document.createElement('label'); lab.textContent = label;+    const input = document.createElement('input'); input.type = 'range'; input.min = min; input.max = max; input.step = step;+    input.value = config[key]; input.disabled = full; input.setAttribute('aria-label', label);+    const output = document.createElement('output'); output.textContent = input.value;+    input.addEventListener('input', () => { output.textContent = input.value; });+    input.addEventListener('change', () => {+      state.circuit = { ...TRACE_DEFAULTS, ...state.circuit, [key]: Number(input.value) };+      render(); send({ circuit: { [key]: Number(input.value) } });+    });+    lab.append(input, output); host.appendChild(lab);+  }+  const label = document.createElement('label'); label.textContent = 'Seed';+  const seed = document.createElement('input'); seed.type = 'number'; seed.min = '0'; seed.max = '2147483646'; seed.step = '1';+  seed.value = state.circuitSeed; seed.disabled = full; seed.setAttribute('aria-label', 'Trace seed');+  seed.addEventListener('change', () => {+    if (!seed.value || !seed.checkValidity()) { seed.value = state.circuitSeed; return; }+    state.circuitSeed = Number(seed.value); render(); send({ circuitSeed: state.circuitSeed });+  });+  label.appendChild(seed); host.appendChild(label);+  const reroll = document.createElement('button'); reroll.className = 'btn small'; reroll.textContent = 'New pattern'; reroll.disabled = full;+  reroll.addEventListener('click', () => post('reroll').catch(() => {})); host.appendChild(reroll);+}+ /* procedural circuitry background (seeded, gently different every roll) */ function mulberry32(a) {   return function () {
public/js/circuit-traces.mjsadded+154
@@ -0,0 +1,154 @@+import { generateCircuitTile, tileSvg } from '../vendor/circuit-trace-generators/circuit-background.js';+import { generateCircuitField, pathData } from '../vendor/circuit-trace-generators/circuit-geometry-v9.js';++// Work in hero coordinates, never viewport pixels: a thumbnail and a PNG use+// the same routes. Only the composition's measured keepouts change geometry.+export const WIDTH = 1000, HEIGHT = 625;+export const DEFAULTS = Object.freeze({ density: 0.8, scale: 1.4, strength: 0.22, clearance: 8 });+const attr = value => String(value).replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&apos;' }[c]));+const dataUrl = svg => 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);++// Range supplies the browser's actual line breaks and positions, including+// inline accents, cap trimming and overflowing words. Canvas supplies ink at+// those positions, using the already loaded font (not the layout column).+function paintTextInk(ctx, el, origin, sx, sy, clearance) {+  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);+  const range = document.createRange();+  const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });+  for (let node; (node = walker.nextNode());) {+    const style = getComputedStyle(node.parentElement);+    if (style.visibility === 'hidden' || Number(style.opacity) === 0) continue;+    ctx.save();+    ctx.scale(sx, sy);+    ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;+    ctx.fontKerning = style.fontKerning;+    ctx.fontStretch = style.fontStretch;+    ctx.fontVariantCaps = style.fontVariantCaps;+    ctx.letterSpacing = style.letterSpacing === 'normal' ? '0px' : style.letterSpacing;+    ctx.wordSpacing = style.wordSpacing;+    ctx.direction = style.direction;+    ctx.textAlign = style.direction === 'rtl' ? 'right' : 'left';+    ctx.textBaseline = 'alphabetic';+    ctx.lineJoin = 'round';+    ctx.lineWidth = 2 * clearance / sx;+    let line;+    const flush = () => {+      if (!line || !line.text.trim()) return;+      const text = style.textTransform === 'uppercase' ? line.text.toUpperCase()+        : style.textTransform === 'lowercase' ? line.text.toLowerCase() : line.text;+      const metrics = ctx.measureText(text);+      // Range's inline font box spans fontBoundingBoxAscent/Descent, even+      // with text-box-trim. The alphabetic baseline is inside that box.+      const fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;+      const y = line.top - origin.top + (line.height - fontHeight) / 2 + metrics.fontBoundingBoxAscent;+      const x = (style.direction === 'rtl' ? line.right : line.left) - origin.left;+      if (clearance > 0) ctx.strokeText(text, x, y);+      ctx.fillText(text, x, y);+    };+    for (const { segment, index } of segmenter.segment(node.data)) {+      range.setStart(node, index); range.setEnd(node, index + segment.length);+      const rect = range.getBoundingClientRect();+      if (!rect.width || !rect.height) continue;+      if (line && Math.abs(line.top - rect.top) > 1) { flush(); line = null; }+      if (!line) line = { text:'', top:rect.top, height:rect.height, left:rect.left, right:rect.right };+      line.text += segment;+      line.left = Math.min(line.left, rect.left); line.right = Math.max(line.right, rect.right);+    }+    flush(); ctx.restore();+  }+}++// Convert the painted ink into disjoint scanline polygons on a two-unit grid.+// Merge equal spans vertically. This keeps curved/stepped letter contours and+// counters without a font parser, external service, or thousands of obstacles.+function inkRings(ctx) {+  const step = 2, pixels = ctx.getImageData(0, 0, WIDTH, HEIGHT).data;+  const rectangles = []; let active = new Map();+  for (let y = 0; y < HEIGHT; y += step) {+    const next = new Map(); let start = -1;+    for (let x = 0; x <= WIDTH; x += step) {+      let ink = false;+      for (let dy = 0; !ink && dy < step && y + dy < HEIGHT; dy++) {+        for (let dx = 0; dx < step && x + dx < WIDTH; dx++) {+          if (pixels[((y + dy) * WIDTH + x + dx) * 4 + 3] > 16) { ink = true; break; }+        }+      }+      if (ink && start < 0) start = x;+      if (!ink && start >= 0) {+        const key = `${start}:${x}`, rect = active.get(key) || { x:start, y, right:x, bottom:y };+        rect.bottom = Math.min(HEIGHT, y + step); next.set(key, rect);+        if (!active.has(key)) rectangles.push(rect);+        start = -1;+      }+    }+    active = next;+  }+  return rectangles.map(r => [[r.x,r.y],[r.right,r.y],[r.right,r.bottom],[r.x,r.bottom],[r.x,r.y]]);+}++export function keepouts(hero, clearance = DEFAULTS.clearance) {+  const base = hero.getBoundingClientRect();+  const sx = WIDTH / (base.width - hero.clientLeft * 2);+  const sy = HEIGHT / (base.height - hero.clientTop * 2);+  const boxes = [];+  const visible = el => el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden';+  const canvas = document.createElement('canvas'); canvas.width = WIDTH; canvas.height = HEIGHT;+  const ctx = canvas.getContext('2d', { willReadFrequently:true });+  const origin = { left:base.left + hero.clientLeft, top:base.top + hero.clientTop };+  for (const selector of ['.h-title', '.h-sub', '.h-desc', '.c-name', '.c-mpn', '.c-spec', '.slug-br']) {+    const el = hero.querySelector(selector);+    if (el && visible(el)) paintTextInk(ctx, el, origin, sx, sy, clearance);+  }+  const rings = inkRings(ctx);+  if (rings.length) boxes.push({ id:'text-ink', type:'path', space:'world', rings });+  // These objects really are solid shapes. Never include a text container or+  // the simulated homepage overlay, which adds its own scrim at display time.+  const selectors = ['.window', '.comp-side', '.pill-tr', '.c-chips .c-family', '.wordmark:not(.noadom) .wmark'];+  for (const selector of selectors) {+    for (const el of hero.querySelectorAll(selector)) {+      if (!visible(el)) continue;+      const r = el.getBoundingClientRect();+      if (r.width < 1 || r.height < 1) continue;+      // Conservative 4-unit grid removes subpixel differences between surfaces.+      const x = Math.max(0, Math.floor(((r.left - base.left - hero.clientLeft) * sx - clearance) / 4) * 4);+      const y = Math.max(0, Math.floor(((r.top - base.top - hero.clientTop) * sy - clearance) / 4) * 4);+      const right = Math.min(WIDTH, Math.ceil(((r.right - base.left - hero.clientLeft) * sx + clearance) / 4) * 4);+      const bottom = Math.min(HEIGHT, Math.ceil(((r.bottom - base.top - hero.clientTop) * sy + clearance) / 4) * 4);+      if (right > x && bottom > y) boxes.push({ id: selector.slice(1), x, y, width: right - x, height: bottom - y });+    }+  }+  return boxes;+}++export function composeTraces({ theme, seed, settings = {}, boxes = [], color = '#8b949e', background = '#0d1117' }) {+  const config = { ...DEFAULTS, ...settings };+  const { density, scale, strength } = config;+  let art, defs = '', count;+  if (theme === 'circuit-tile') {+    const tile = generateCircuitTile({ seed: String(seed), density });+    const svg = tileSvg(tile, { traceColor: color, holeColor: background });+    const w = tile.width * scale, h = tile.height * scale;+    defs = `<pattern id="tile" width="${w}" height="${h}" patternUnits="userSpaceOnUse"><image width="${w}" height="${h}" href="${attr(dataUrl(svg))}"/></pattern>`;+    art = `<rect width="${WIDTH}" height="${HEIGHT}" fill="url(#tile)"/>`;+    count = tile.traces.length;+  } else {+    const field = generateCircuitField({+      seed: String(seed), width: WIDTH + 24, height: HEIGHT + 24,+      featureScale: scale, fill: 0.668 * density, obstaclePreset: 'none',+      obstacleMode: 'avoid', obstacleClearance: 0,+      obstacles: boxes.map(b => b.rings ? b : ({ ...b, type: 'rect', space: 'world', x: b.x + b.width / 2, y: b.y + b.height / 2 })),+    });+    art = field.traces.map(t => `<path d="${pathData(t.points)}"/>`+      + [t.points[0], t.points[t.points.length - 1]].map(([x,y]) => `<circle cx="${x}" cy="${y}" r="${2.55 * scale}" stroke-width="${1.58 * scale}"/>`).join('')).join('');+    art = `<g fill="none" stroke="${attr(color)}" stroke-width="${1.58 * scale}" stroke-linecap="butt" stroke-linejoin="miter">${art}</g>`;+    count = field.traces.length;+  }+  // Tile geometry remains seamless; soften it around the same keepouts the+  // field router avoids. Masked SVG is self-contained inside one cloned img.+  const holes = boxes.map(b => b.rings+    ? `<path d="${b.rings.map(r => pathData(r) + 'Z').join('')}" fill="black"/>`+    : `<rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="8" fill="black"/>`).join('');+  if (theme === 'circuit-tile') defs += `<filter id="soft" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation="3"/></filter><mask id="quiet" maskUnits="userSpaceOnUse" x="0" y="0" width="${WIDTH}" height="${HEIGHT}"><rect width="${WIDTH}" height="${HEIGHT}" fill="white"/><g filter="url(#soft)">${holes}</g>${holes}</mask>`;+  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}"><defs>${defs}</defs><g opacity="${strength}"${theme === 'circuit-tile' ? ' mask="url(#quiet)"' : ''}>${art}</g></svg>`;+  return { src: dataUrl(svg), count };+}
public/js/wizard.js+8−1
@@ -84,6 +84,8 @@ const WIZ_THEMES = [   { v: 'aurora',    label: 'Aurora',           sw: 'sw-aurora' },   { v: 'mesh',      label: 'Color mesh',       sw: 'sw-mesh' },   { v: 'circuitry', label: 'Generated traces', sw: 'sw-circuitry' },+  { v: 'circuit-field', label: 'Routed field', sw: 'sw-circuit-field' },+  { v: 'circuit-tile', label: 'Seamless traces', sw: 'sw-circuit-tile' },   { v: 'image',     label: 'Your image',       sw: 'sw-image' }, ]; @@ -819,7 +821,7 @@ function wizSignature() {   const l = state.lint || [];   const v = state.showcase || {};   return JSON.stringify([wizStep, wizExpanded, state.page, state.layout, state.theme,-    state.headline, state.subtitle, state.accentWords, state.overlay, state.confirm,+    state.headline, state.subtitle, state.accentWords, state.overlay, state.confirm, state.circuit, state.circuitSeed,     state.flags, state.marqueePreview, state.gatesDone, wizUploads.length,     v.full, (v.src || '').length, l.filter(x => x.level === 'fail').length,     l.filter(x => x.level === 'warn').length, document.body.classList.contains('show-ctx')]);@@ -827,6 +829,11 @@ function wizSignature() { function wizardRender() {   if (!document.body.classList.contains('wizard')) return;   if (!state || !pages.length || !$('wdock')) return;+  let traces = $('wtrace-controls');+  if (!traces) { traces = document.createElement('div'); traces.id = 'wtrace-controls'; $('wdock').appendChild(traces); }+  if (wizStep === 'background') renderTraceControls(traces);+  else traces.hidden = true;+  $('wdock').classList.toggle('has-trace-controls', wizStep === 'background' && isTraceTheme());   const sig = wizSignature();   if (sig === wizLastSig) return;   const focused = document.activeElement && $('wdock').contains(document.activeElement)
public/studio.css+16
@@ -518,3 +518,19 @@ body.branded .sw-circuitry { background: var(--brand-bg); }   background: color-mix(in srgb, #f0a33c 12%, transparent); } #wcards .card.wiz-confirm-cta .card-label { color: #f0a33c; font-weight: 700; }+/* Circuit options use the owner's colors in the picker as well as the art. */+.sw-circuit-field, .sw-circuit-tile {+  background-color:var(--brand-bg, #0d1117);+  background-image:repeating-linear-gradient(135deg, transparent 0 13px, color-mix(in srgb, var(--brand-primary, #00b8b0) 45%, transparent) 14px 15px, transparent 16px 26px);+}+.sw-circuit-tile { background-size:28px 28px; background-image:repeating-linear-gradient(90deg, transparent 0 12px, var(--brand-primary, #00b8b0) 13px 14px, transparent 15px 28px); }+.trace-controls { display:flex; flex-wrap:wrap; gap:10px 16px; padding:12px; align-items:center; }+.trace-controls[hidden] { display:none; }+.trace-controls label { display:flex; align-items:center; gap:8px; font-size:12px; }+.trace-controls input[type=range] { width:95px; }+.trace-controls input[type=number] { width:115px; }+.trace-controls button { width:auto; }+.trace-controls output { min-width:28px; font-variant-numeric:tabular-nums; }+.trace-controls .hint { flex-basis:100%; }+#wtrace-controls { flex:0 0 auto; max-height:140px; overflow:auto; }+#wdock.has-trace-controls { height:min(55vh, calc(var(--dock-h, min(44vh, 330px)) + 90px)); }
public/vendor/circuit-trace-generators/README.mdadded+12
@@ -0,0 +1,12 @@+# Circuit Trace Generators++Unmodified source from [adom/circuit-trace-generators](https://wiki.adom.inc/adom/circuit-trace-generators),+version 0.3.0, repo commit `2a73b150b944301c057318ae8a2e14d36fca514e`.+Authored by AdityaAngajala; retained here for the requested Hero Studio integration.++`circuit-background.js` supplies the torus tile router. `circuit-geometry-v9.js`+supplies the continuous field router and imports the obstacle and conductor+modules. Hero Studio calls only their pure geometry functions, never their+animation or mounting APIs. The adapter lives in `../../js/circuit-traces.mjs`.+These files are bundled for offline previews and identical local/pup exports.+The package.json here marks the upstream browser modules as ESM for Node tests.
public/vendor/circuit-trace-generators/circuit-animation-conductor.jsadded+471
@@ -0,0 +1,471 @@+const SVG_NS = "http://www.w3.org/2000/svg";++function svgElement(name, attributes = {}) {+  const element = document.createElementNS(SVG_NS, name);+  for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));+  return element;+}++function pathData(points) {+  return points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");+}++function integer(random, min, max) {+  return Math.floor(random() * (max - min + 1)) + min;+}++function weighted(random, entries) {+  let total = 0;+  for (const [, weight] of entries) total += weight;+  let roll = random() * total;+  for (const [value, weight] of entries) {+    roll -= weight;+    if (roll <= 0) return value;+  }+  return entries.at(-1)?.[0];+}++function shuffle(random, values) {+  for (let index = values.length - 1; index > 0; index -= 1) {+    const swap = integer(random, 0, index);+    [values[index], values[swap]] = [values[swap], values[index]];+  }+  return values;+}++function compareTraceDistance(a, b) {+  return a.distance - b.distance || a.index - b.index;+}++function compareTraceKey(distance, index, ranked) {+  return distance - ranked.distance || index - ranked.index;+}++function siftDownTraceDistance(heap, index) {+  while (true) {+    const left = index * 2 + 1;+    if (left >= heap.length) return;+    const right = left + 1;+    let largest = left;+    if (right < heap.length && compareTraceDistance(heap[left], heap[right]) < 0) largest = right;+    if (compareTraceDistance(heap[index], heap[largest]) >= 0) return;+    [heap[index], heap[largest]] = [heap[largest], heap[index]];+    index = largest;+  }+}++function nearestTraces(traces, origin, count) {+  if (!(count > 0)) return [];+  const nearest = [];+  for (let index = 0; index < traces.length; index += 1) {+    const trace = traces[index];+    const midpoint = trace.points[Math.floor(trace.points.length / 2)];+    const distance = Math.hypot(midpoint[0] - origin.x, midpoint[1] - origin.y);+    if (nearest.length < count) {+      nearest.push({ trace, distance, index });+      let child = nearest.length - 1;+      while (child > 0) {+        const parent = Math.floor((child - 1) / 2);+        if (compareTraceDistance(nearest[parent], nearest[child]) >= 0) break;+        [nearest[parent], nearest[child]] = [nearest[child], nearest[parent]];+        child = parent;+      }+    } else if (compareTraceKey(distance, index, nearest[0]) < 0) {+      nearest[0] = { trace, distance, index };+      siftDownTraceDistance(nearest, 0);+    }+  }+  return nearest.sort(compareTraceDistance);+}++export function createConductorAnimation(svg, field, config, random) {+  const ambientLayer = svgElement("g", {+    "data-animation": "conductor-ambient", fill: "none", "pointer-events": "none",+  });+  const signalLayer = svgElement("g", {+    "data-animation": "conductor-signals", fill: "none", "pointer-events": "none",+  });+  svg.append(ambientLayer, signalLayer);++  let timer;+  let waveTimer;+  let activeSignals = 0;+  let ambientCurrentCount = 0;+  let ambientNodeCount = 0;+  let wavesTriggered = 0;+  let pointer = null;+  let lastPointerWake = 0;+  const scheduled = new Set();+  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;++  function life() {+    return Math.max(0, Math.min(1.5, config.liveliness));+  }++  function featureScale() {+    return Math.max(0.25, config.featureScale);+  }++  function trackTimeout(callback, delay) {+    const timeout = window.setTimeout(() => {+      scheduled.delete(timeout);+      callback();+    }, Math.max(0, delay));+    scheduled.add(timeout);+    return timeout;+  }++  function clearScheduled() {+    for (const timeout of scheduled) window.clearTimeout(timeout);+    scheduled.clear();+  }++  function nearestTrace(origin = pointer) {+    if (!origin || !field.traces.length) return null;+    let result = null;+    let best = Infinity;+    const attempts = Math.min(72, field.traces.length);+    for (let attempt = 0; attempt < attempts; attempt += 1) {+      const trace = field.traces[integer(random, 0, field.traces.length - 1)];+      const point = trace.points[Math.floor(trace.points.length / 2)];+      const distance = Math.hypot(point[0] - origin.x, point[1] - origin.y);+      if (distance < best) {+        best = distance;+        result = trace;+      }+    }+    return result;+  }++  function flashNode(point, color, delay = 0, intensity = 1) {+    if (delay > 0) {+      trackTimeout(() => flashNode(point, color, 0, intensity), delay);+      return;+    }+    if (!signalLayer.isConnected || reducedMotion || !config.motion || !config.nodeFlashes) return;+    const scale = featureScale();+    const radius = config.viaOuterRadius * scale;+    const ring = svgElement("circle", {+      cx: point[0], cy: point[1], r: radius * 0.7,+      fill: "none", stroke: color,+      "stroke-width": Math.max(0.8, config.traceWidth * scale * 0.85),+      opacity: 0,+    });+    ring.style.filter = `drop-shadow(0 0 ${3.5 * scale}px ${color})`;+    signalLayer.appendChild(ring);+    const duration = 480 + intensity * 220;+    ring.animate(+      [+        { opacity: 0, transform: "scale(.45)" },+        { offset: 0.18, opacity: Math.min(1, 0.55 + intensity * 0.25), transform: "scale(.9)" },+        { opacity: 0, transform: `scale(${2.2 + intensity * 0.7})` },+      ],+      { duration, easing: "cubic-bezier(.16,.8,.3,1)", fill: "forwards" },+    );+    ring.style.transformBox = "fill-box";+    ring.style.transformOrigin = "center";+    trackTimeout(() => ring.remove(), duration + 40);+  }++  function spawnSignal({+    nearPointer = false,+    trace = null,+    delay = 0,+    reverse,+    color,+    intensity = 1,+    lengthScale = 1,+    speedScale = 1,+    endpointFlash = false,+  } = {}) {+    if (delay > 0) {+      trackTimeout(() => spawnSignal({+        nearPointer, trace, reverse, color, intensity, lengthScale, speedScale, endpointFlash,+      }), delay);+      return true;+    }+    if (!config.motion || reducedMotion || activeSignals >= config.maxSignals || !field.traces.length) return false;+    const selected = trace || (nearPointer && nearestTrace()) || field.traces[integer(random, 0, field.traces.length - 1)];+    const reversed = reverse ?? random() < 0.5;+    const points = reversed ? [...selected.points].reverse() : selected.points;+    const selectedColor = color || weighted(random, config.signalColors);+    const scale = featureScale();+    const requestedLength = config.signalLength * scale * lengthScale;+    const signalLength = Math.max(12 * scale, Math.min(requestedLength, selected.length * 0.72));+    const signalSpeed = config.signalSpeed * scale * (0.78 + life() * 0.27) * speedScale;+    const duration = Math.max(460, ((selected.length + signalLength) / signalSpeed) * 1000);+    const dashGap = selected.length + signalLength;+    const group = svgElement("g", { "data-signal": "" });+    const halo = svgElement("path", {+      d: pathData(points), stroke: selectedColor,+      "stroke-width": (5.2 + intensity * 2.2) * scale,+      "stroke-linecap": "round",+      "stroke-dasharray": `${signalLength} ${dashGap}`,+      "stroke-dashoffset": signalLength,+      opacity: 0,+    });+    const core = svgElement("path", {+      d: pathData(points), stroke: selectedColor,+      "stroke-width": (1.25 + intensity * 1.25) * scale,+      "stroke-linecap": "round",+      "stroke-dasharray": `${signalLength} ${dashGap}`,+      "stroke-dashoffset": signalLength,+      opacity: 0,+    });+    halo.style.filter = `blur(${1.6 * scale}px) drop-shadow(0 0 ${4.5 * scale}px ${selectedColor})`;+    core.style.filter = `drop-shadow(0 0 ${2.8 * scale}px ${selectedColor})`;+    if (config.signalHalos) group.appendChild(halo);+    group.appendChild(core);+    signalLayer.appendChild(group);+    activeSignals += 1;+    const movement = [+      { strokeDashoffset: signalLength, opacity: 0 },+      { offset: 0.08, opacity: 0.92 },+      { offset: 0.84, opacity: 0.72 },+      { strokeDashoffset: -selected.length, opacity: 0 },+    ];+    if (config.signalHalos) {+      halo.animate(movement.map((frame) => ({ ...frame, opacity: (frame.opacity || 0) * 0.22 * intensity })), {+        duration, easing: "linear", fill: "forwards",+      });+    }+    core.animate(movement, { duration, easing: "linear", fill: "forwards" });+    if (endpointFlash) {+      flashNode(points[0], selectedColor, 0, intensity);+      flashNode(points.at(-1), selectedColor, duration * 0.87, intensity * 0.8);+    }+    trackTimeout(() => {+      group.remove();+      activeSignals = Math.max(0, activeSignals - 1);+    }, duration + 100);+    return true;+  }++  function spawnConvoy({ nearPointer = false, trace = null } = {}) {+    if (!config.motion || !config.signalConvoys || reducedMotion || !field.traces.length) return;+    const selected = trace || (nearPointer && nearestTrace()) || field.traces[integer(random, 0, field.traces.length - 1)];+    const count = Math.max(2, Math.min(4, Math.round(2 + life() + random())));+    const selectedColor = weighted(random, config.signalColors);+    const reverse = random() < 0.5;+    for (let index = 0; index < count; index += 1) {+      spawnSignal({+        trace: selected,+        reverse,+        color: selectedColor,+        delay: index * (78 + random() * 54),+        intensity: 0.62 + index * 0.08,+        lengthScale: 0.28 + random() * 0.22,+        speedScale: 0.92 + random() * 0.18,+        endpointFlash: index === 0,+      });+    }+  }++  function ripple(origin, color) {+    if (!signalLayer.isConnected) return;+    const scale = featureScale();+    const startRadius = config.pitch * scale * 0.8;+    const ring = svgElement("circle", {+      cx: origin.x, cy: origin.y, r: startRadius,+      fill: "none", stroke: color,+      "stroke-width": Math.max(0.7, config.traceWidth * scale * 0.72),+      opacity: 0.72,+    });+    ring.style.filter = `drop-shadow(0 0 ${4 * scale}px ${color})`;+    signalLayer.appendChild(ring);+    ring.animate(+      [+        { opacity: 0.72, transform: "scale(.35)" },+        { offset: 0.3, opacity: 0.34 },+        { opacity: 0, transform: "scale(6)" },+      ],+      { duration: 980, easing: "cubic-bezier(.12,.72,.18,1)", fill: "forwards" },+    );+    ring.style.transformBox = "fill-box";+    ring.style.transformOrigin = "center";+    trackTimeout(() => ring.remove(), 1040);+  }++  function spawnWave(origin = null) {+    if (!config.motion || !config.signalWaves || reducedMotion || !field.traces.length) return false;+    const source = origin || {+      x: field.width * (0.14 + random() * 0.72),+      y: field.height * (0.14 + random() * 0.72),+    };+    const selectedColor = weighted(random, config.signalColors);+    ripple(source, selectedColor);+    const count = Math.min(field.traces.length, Math.max(6, Math.round(6 + life() * 8)));+    const ranked = nearestTraces(field.traces, source, count);+    const outerDistance = ranked[count - 1]?.distance || 1;+    for (let index = 0; index < count; index += 1) {+      const { trace, distance } = ranked[index];+      const start = trace.points[0];+      const end = trace.points.at(-1);+      const reverse = Math.hypot(end[0] - source.x, end[1] - source.y)+        < Math.hypot(start[0] - source.x, start[1] - source.y);+      spawnSignal({+        trace,+        reverse,+        color: selectedColor,+        delay: 90 + distance / outerDistance * 560 + index * 14,+        intensity: 0.64 + (1 - distance / outerDistance) * 0.34,+        lengthScale: 0.44 + random() * 0.2,+        speedScale: 1.02 + random() * 0.18,+        endpointFlash: index % 4 === 0,+      });+    }+    wavesTriggered += 1;+    return true;+  }++  function seedAmbientLife() {+    ambientCurrentCount = 0;+    ambientNodeCount = 0;+    if (!config.motion || reducedMotion || life() <= 0.02 || !field.traces.length) return;+    const scale = featureScale();+    const eligible = field.traces.filter((trace) => trace.length > 28 * scale);+    const currents = config.ambientCurrents+      ? Math.min(eligible.length, Math.max(1, Math.round(config.ambientCurrentCount * life())))+      : 0;+    shuffle(random, eligible);+    for (const trace of eligible.slice(0, currents)) {+      const color = weighted(random, config.signalColors);+      const dash = Math.max(12 * scale, trace.length * (0.07 + random() * 0.1));+      const current = svgElement("path", {+        d: pathData(random() < 0.5 ? [...trace.points].reverse() : trace.points),+        stroke: color,+        "stroke-width": config.traceWidth * scale * (0.9 + random() * 0.55),+        "stroke-linecap": "round",+        "stroke-dasharray": `${dash} ${trace.length + dash}`,+        "stroke-dashoffset": dash,+        opacity: 0,+      });+      current.style.filter = `drop-shadow(0 0 ${2.2 * scale}px ${color})`;+      ambientLayer.appendChild(current);+      const duration = (6400 + random() * 9000) / (0.72 + life() * 0.35);+      current.animate(+        [+          { strokeDashoffset: dash, opacity: 0 },+          { offset: 0.14, opacity: 0.08 + life() * 0.055 },+          { offset: 0.78, opacity: 0.05 + life() * 0.04 },+          { strokeDashoffset: -trace.length, opacity: 0 },+        ],+        { duration, delay: -random() * duration, iterations: Infinity, easing: "linear" },+      );+      ambientCurrentCount += 1;+    }++    const nodes = config.ambientNodes+      ? Math.min(field.traces.length, Math.max(1, Math.round(config.ambientNodeCount * life())))+      : 0;+    for (let index = 0; index < nodes; index += 1) {+      const trace = field.traces[integer(random, 0, field.traces.length - 1)];+      const point = random() < 0.5 ? trace.points[0] : trace.points.at(-1);+      const color = weighted(random, config.signalColors);+      const node = svgElement("circle", {+        cx: point[0], cy: point[1],+        r: config.viaOuterRadius * scale * (0.34 + random() * 0.18),+        fill: color, opacity: 0,+      });+      node.style.filter = `drop-shadow(0 0 ${3.2 * scale}px ${color})`;+      node.style.transformBox = "fill-box";+      node.style.transformOrigin = "center";+      ambientLayer.appendChild(node);+      const duration = 2600 + random() * 4300;+      node.animate(+        [+          { opacity: 0, transform: "scale(.65)" },+          { offset: 0.48, opacity: 0.16 + life() * 0.11, transform: "scale(1.22)" },+          { opacity: 0, transform: "scale(.72)" },+        ],+        { duration, delay: -random() * duration, iterations: Infinity, easing: "ease-in-out" },+      );+      ambientNodeCount += 1;+    }+  }++  function pointerMoved(origin) {+    pointer = origin;+    if (!pointer) return;+    const wakeInterval = config.pointerWakeInterval / Math.max(0.55, 0.55 + life() * 0.5);+    if (config.motion && config.pointerWake && !reducedMotion && pointer.at - lastPointerWake > wakeInterval) {+      lastPointerWake = pointer.at;+      spawnSignal({+        nearPointer: true,+        intensity: 0.5 + life() * 0.16,+        lengthScale: 0.34,+        speedScale: 1.3,+      });+    }+  }++  function clicked(origin) {+    pointer = origin;+    spawnWave(pointer);+    if (config.signalConvoys) trackTimeout(() => spawnConvoy({ nearPointer: true }), 160);+  }++  function stop() {+    window.clearInterval(timer);+    window.clearInterval(waveTimer);+    timer = undefined;+    waveTimer = undefined;+    clearScheduled();+  }++  function start() {+    stop();+    if (!config.motion || reducedMotion || life() <= 0.02) return;+    seedAmbientLife();+    const interval = Math.max(130, config.signalInterval / (0.55 + life() * 0.78));+    timer = window.setInterval(() => {+      if (document.hidden) return;+      const nearPointer = pointer && performance.now() - pointer.at < 3200;+      if (config.signalConvoys && random() < 0.12 + life() * 0.13) spawnConvoy({ nearPointer });+      else spawnSignal({+        nearPointer,+        intensity: 0.62 + random() * 0.34,+        lengthScale: 0.52 + random() * 0.58,+        speedScale: 0.86 + random() * 0.34,+        endpointFlash: random() < 0.12,+      });+    }, interval);+    if (config.signalWaves) {+      const intervalForWaves = Math.max(2800, config.waveInterval / (0.66 + life() * 0.48));+      waveTimer = window.setInterval(() => {+        if (!document.hidden) spawnWave();+      }, intervalForWaves);+    }+    if (config.signalConvoys) trackTimeout(() => spawnConvoy(), 360);+    if (config.signalWaves) trackTimeout(() => spawnWave(), 1450);+  }++  function destroy() {+    stop();+    ambientLayer.remove();+    signalLayer.remove();+  }++  return {+    start,+    stop,+    destroy,+    pointerMoved,+    clicked,+    trigger: spawnWave,+    get stats() {+      return {+        system: "original-conductor",+        activeSignals,+        ambientCurrentCount,+        ambientNodeCount,+        wavesTriggered,+        reducedMotion,+        signalHalos: config.signalHalos,+        signalConvoys: config.signalConvoys,+        signalWaves: config.signalWaves,+        pointerWake: config.pointerWake,+      };+    },+  };+}
public/vendor/circuit-trace-generators/circuit-background.jsadded+1830
@@ -0,0 +1,1830 @@+const SVG_NS = "http://www.w3.org/2000/svg";++// Geometry constants measured from the reference artwork (adom.inc hero):+// a 114 x 57 cell routing grid with a 150/19 ~= 7.895 unit pitch, 1.58 unit+// traces, and 3.34 / 1.76 annular vias sitting exactly on grid nodes.+const DEFAULTS = Object.freeze({+  seed: "traces",+  tileWidth: 900,+  tileHeight: 450,+  pitch: 150 / 19,+  scale: 1000 / 900,+  density: 1.05,+  traceColor: "#151b25",+  traceWidth: 1.58,+  viaOuterRadius: 3.34,+  viaInnerRadius: 1.76,+  // --- generation tuning -----------------------------------------------+  formations: 96, // terminal groups (via ladders/rows) per tile+  busWidth: 1, // scales pins per formation => bundle width+  turnPenalty: 0.55, // higher = longer, calmer runs+  weave: 0.22, // preference for 2-lane spacing between neighbors+  cramp: 0.4, // aversion to 1-lane spacing outside bundles+  horizontalBias: 0.6, // 0.5 = isotropic, higher = more horizontal flow+  netReach: 42, // max cells a net may span+  jogBias: 5, // preferred endpoint misalignment => staircase jogs+  stubChance: 0, // leftover pins that get short escape stubs+  inlineViaChance: 0.03, // per-step chance of a mid-trace via+  combs: 8, // max tap-comb motifs per tile+  signalColors: [+    ["#00e6dc", 0.6],+    ["#64abff", 0.25],+    ["#c5b3ff", 0.15],+  ],+  signals: true,+  interactive: true,+  signalInterval: 240, // passive spawn cadence — a fresh signal appears near the cursor this often+  maxSignals: 12, // fill target for the ambient fountain (maxBurstSignals is the hard ceiling)+  spawnRadius: 110, // spawn scatter around the pointer (soft jitter, not a hard cutoff)+  signalSpeed: 420,+  signalLength: 120,+  burstBoost: 7, // click: how many extra signals join at once+  hopSurvival: 0.4, // per-hop survival for PASSIVE signals; low = short-lived, high = long chains+  burstSurvival: 0.72, // per-hop survival for CLICKED signals — tuned independently of hopSurvival+  hopRadius: 440, // a surviving signal hops to a via within this range+  colorDrift: 0.18, // chance per hop that a chain crossfades to a new color+  maxBurstSignals: 25,+});++// 8 directions, clockwise from east; even indices are cardinal.+const DIR8 = [+  [1, 0],+  [1, 1],+  [0, 1],+  [-1, 1],+  [-1, 0],+  [-1, -1],+  [0, -1],+  [1, -1],+];++// Walker start-direction weights derived from horizontalBias (0.5 = even).+function startDirectionWeights(horizontalBias) {+  const ew = 0.48 * horizontalBias;+  const ns = 0.24 * (1 - horizontalBias);+  const diagonal = Math.max(0.005, (1 - 2 * ew - 2 * ns) / 4);+  return [+    [0, ew],+    [4, ew],+    [2, ns],+    [6, ns],+    [1, diagonal],+    [3, diagonal],+    [5, diagonal],+    [7, diagonal],+  ];+}++let instanceCount = 0;++function svgEl(name, attrs = {}) {+  const node = document.createElementNS(SVG_NS, name);+  for (const [key, value] of Object.entries(attrs)) {+    node.setAttribute(key, String(value));+  }+  return node;+}++function hashSeed(input) {+  let hash = 2166136261;+  for (const char of String(input)) {+    hash ^= char.charCodeAt(0);+    hash = Math.imul(hash, 16777619);+  }+  return hash >>> 0;+}++function mulberry32(seed) {+  return function random() {+    let value = (seed += 0x6d2b79f5);+    value = Math.imul(value ^ (value >>> 15), value | 1);+    value ^= value + Math.imul(value ^ (value >>> 7), value | 61);+    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;+  };+}++function weightedChoice(random, entries) {+  let roll = random();+  for (const [value, weight] of entries) {+    roll -= weight;+    if (roll <= 0) return value;+  }+  return entries[0][0];+}++// --- routing engine ---------------------------------------------------------+//+// Occupancy lives on a doubled ("half step") grid wrapped on a torus: every+// cell step claims its end node and the midpoint it crosses, which blocks+// same-lane overlap and any crossing (two diagonals share a midpoint, a+// diagonal crosses a cardinal only through a node). The torus wrap is what+// makes the finished tile seamless.++function createRouter(random, columns, rows) {+  const wide = columns * 2;+  const tall = rows * 2;+  // Occupancy as a flat typed array — this sits in the A* hot path.+  const occupied = new Uint8Array(wide * tall);+  const viaNodes = new Uint8Array(wide * tall);++  const halfKey = (hx, hy) => (((hy % tall) + tall) % tall) * wide + (((hx % wide) + wide) % wide);+  const nodeKey = (x, y) => halfKey(x * 2, y * 2);++  function claim(trace, key) {+    occupied[key] = 1;+    trace.claims.push(key);+    trace.ownKeys.add(key);+  }++  return {+    columns,+    rows,+    nodeKey,+    nodeFree: (x, y) => !occupied[nodeKey(x, y)],+    crowding(x, y) {+      let count = 0;+      for (let dy = -4; dy <= 4; dy += 2) {+        for (let dx = -4; dx <= 4; dx += 2) {+          if (occupied[nodeKey(x + dx, y + dy)]) count += 1;+        }+      }+      return count;+    },+    stepFree(x, y, direction) {+      const [dx, dy] = DIR8[direction];+      return (+        !occupied[halfKey(x * 2 + dx, y * 2 + dy)] &&+        !occupied[halfKey(x * 2 + dx * 2, y * 2 + dy * 2)]+      );+    },+    // Like stepFree, but the destination node may be the (already-claimed)+    // goal pin the route is allowed to land on.+    routeStepFree(x, y, direction, goalKey) {+      const [dx, dy] = DIR8[direction];+      if (occupied[halfKey(x * 2 + dx, y * 2 + dy)]) return false;+      const node = halfKey(x * 2 + dx * 2, y * 2 + dy * 2);+      return node === goalKey || !occupied[node];+    },+    // How close the nearest parallel neighbor sits, perpendicular to travel:+    // 1 = one lane away (cramped), 2 = two lanes away (the reference's even+    // weave), 0 = open field. The A* cost prefers 2 and avoids 1.+    laneNeighbors(x, y, direction) {+      const [dx, dy] = DIR8[direction];+      if (occupied[nodeKey(x - dy, y + dx)] || occupied[nodeKey(x + dy, y - dx)]) return 1;+      if (occupied[nodeKey(x - dy * 2, y + dx * 2)] || occupied[nodeKey(x + dy * 2, y - dx * 2)]) return 2;+      return 0;+    },+    claimNode(trace, x, y) {+      claim(trace, nodeKey(x, y));+    },+    claimStep(trace, x, y, direction) {+      const [dx, dy] = DIR8[direction];+      claim(trace, halfKey(x * 2 + dx, y * 2 + dy));+      let count = 1;+      const node = halfKey(x * 2 + dx * 2, y * 2 + dy * 2);+      if (!occupied[node]) {+        claim(trace, node);+        count = 2;+      }+      trace.stepClaims.push(count);+    },+    // Remove the record's most recent step and exactly the claims it made.+    // Only valid while nothing later than that step has claimed (sealEnd+    // never trims past the record's own last via).+    unclaimLastStep(record) {+      const count = record.stepClaims.pop() || 0;+      for (let index = 0; index < count; index += 1) {+        const key = record.claims.pop();+        occupied[key] = 0;+        record.ownKeys.delete(key);+      }+      const step = record.steps.pop();+      record.end = [record.end[0] - DIR8[step][0], record.end[1] - DIR8[step][1]];+    },+    // A via may not sit on or cardinally beside another via (their annular+    // rings would overlap at one-pitch spacing), and the four diagonal+    // midpoints around it must not carry someone else's trace.+    viaAllowed(trace, x, y) {+      for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) {+        if (viaNodes[nodeKey(x + dx, y + dy)]) return false;+      }+      for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {+        const key = halfKey(x * 2 + dx, y * 2 + dy);+        if (occupied[key] && !trace.ownKeys.has(key)) return false;+      }+      return true;+    },+    placeVia(trace, x, y) {+      viaNodes[nodeKey(x, y)] = 1;+      trace.viaCells.push([x, y]);+      trace.viaSteps.add(trace.steps.length);+      for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {+        const key = halfKey(x * 2 + dx, y * 2 + dy);+        if (!occupied[key]) claim(trace, key);+      }+    },+    rollback(trace) {+      for (const key of trace.claims) occupied[key] = 0;+      for (const [x, y] of trace.viaCells) viaNodes[nodeKey(x, y)] = 0;+    },+  };+}++function newTraceRecord(x, y) {+  return {+    start: [x, y],+    end: [x, y],+    steps: [],+    claims: [],+    stepClaims: [], // claims added per step, so ends can be trimmed precisely+    ownKeys: new Set(),+    viaCells: [],+    viaSteps: new Set(),+  };+}++// Every trace must end on a via. If the end position has no clearance, trim+// the trace back one step at a time (never past its own last via) until a+// via fits. Returns false when no compliant ending exists.+function sealEnd(router, record, minSteps) {+  for (;;) {+    if (record.viaSteps.has(record.steps.length)) return true;+    const [x, y] = record.end;+    if (router.viaAllowed(record, x, y)) {+      router.placeVia(record, x, y);+      return true;+    }+    const floor = Math.max(minSteps, record.viaSteps.size ? Math.max(...record.viaSteps) : 0);+    if (record.steps.length <= floor) return false;+    router.unclaimLastStep(record);+  }+}++// --- A* net router ----------------------------------------------------------+//+// Routes run between terminal formations the way a human lays out a board:+// they escape a pin, join a corridor beside existing traces (hug bonus), pay+// for every 45 degree turn (long calm runs), pay a small surcharge for+// sustained diagonals (the reference uses short 45 degree jogs), and finish+// into the far pin. States are (node, incoming direction) so turn costs are+// exact. Everything wraps on the torus.++class MinHeap {+  constructor() {+    this.costs = [];+    this.values = [];+  }++  get size() {+    return this.costs.length;+  }++  push(cost, value) {+    const { costs, values } = this;+    let index = costs.length;+    costs.push(cost);+    values.push(value);+    while (index > 0) {+      const parent = (index - 1) >> 1;+      if (costs[parent] <= costs[index]) break;+      [costs[parent], costs[index]] = [costs[index], costs[parent]];+      [values[parent], values[index]] = [values[index], values[parent]];+      index = parent;+    }+  }++  pop() {+    const { costs, values } = this;+    const top = values[0];+    const lastCost = costs.pop();+    const lastValue = values.pop();+    if (costs.length) {+      costs[0] = lastCost;+      values[0] = lastValue;+      let index = 0;+      for (;;) {+        const left = index * 2 + 1;+        const right = left + 1;+        let smallest = index;+        if (left < costs.length && costs[left] < costs[smallest]) smallest = left;+        if (right < costs.length && costs[right] < costs[smallest]) smallest = right;+        if (smallest === index) break;+        [costs[smallest], costs[index]] = [costs[index], costs[smallest]];+        [values[smallest], values[index]] = [values[index], values[smallest]];+        index = smallest;+      }+    }+    return top;+  }+}++function createPathfinder(router, columns, rows, config) {+  const turnPenalty = config.turnPenalty;+  const crampPenalty = config.cramp;+  const weaveBonus = config.weave;+  const stateCount = columns * rows * 8;+  const bestCost = new Float64Array(stateCount);+  const seen = new Int32Array(stateCount);+  const closed = new Int32Array(stateCount);+  const parent = new Int32Array(stateCount);+  let generation = 0;++  const wrapX = (value) => ((value % columns) + columns) % columns;+  const wrapY = (value) => ((value % rows) + rows) % rows;++  // start: {x, y, direction}; goal: {x, y, arrivals: Set<direction>}+  // bundleMode flips the lane preference: followers rejoining their bundle+  // WANT to ride one lane off their neighbor.+  return function route(random, start, goal, why = {}, bundleMode = false) {+    generation += 1;+    const heap = new MinHeap();+    const goalX = wrapX(goal.x);+    const goalY = wrapY(goal.y);+    const goalKey = router.nodeKey(goalX, goalY);++    const heuristic = (x, y) => {+      let dx = Math.abs(x - goalX);+      dx = Math.min(dx, columns - dx);+      let dy = Math.abs(y - goalY);+      dy = Math.min(dy, rows - dy);+      return Math.max(dx, dy) + 0.42 * Math.min(dx, dy);+    };++    const startX = wrapX(start.x);+    const startY = wrapY(start.y);+    // Seed the facing direction free, and the two perpendiculars at a small+    // cost — a pin whose lane is congested can still hook out sideways.+    const h0 = heuristic(startX, startY);+    for (const [turn, cost] of [[0, 0], [2, 0.9], [6, 0.9]]) {+      const startState = (startY * columns + startX) * 8 + ((start.direction + turn) % 8);+      seen[startState] = generation;+      bestCost[startState] = cost;+      parent[startState] = -1;+      heap.push(cost + h0, startState);+    }++    const straightestFirst = heuristic(startX, startY);+    let goalState = -1;+    let expansions = 0;+    const TURNS = [0, 1, 7];++    // Weighted A* (heuristic scaled below): meaningfully faster, and the mild+    // suboptimality reads as organic hand-routing rather than error.+    while (heap.size && expansions < 9000) {+      expansions += 1;+      const state = heap.pop();+      if (closed[state] === generation) continue;+      closed[state] = generation;+      const direction = state % 8;+      const nodeIndex = (state - direction) / 8;+      const x = nodeIndex % columns;+      const y = (nodeIndex - x) / columns;++      if (x === goalX && y === goalY) {+        if (goal.arrivals.has(direction)) {+          goalState = state;+          break;+        }+        continue;+      }++      const cost = bestCost[state];+      for (const turn of TURNS) {+        const next = (direction + turn) % 8;+        if (!router.routeStepFree(x, y, next, goalKey)) continue;+        const nx = wrapX(x + DIR8[next][0]);+        const ny = wrapY(y + DIR8[next][1]);+        let stepCost = next % 2 === 0 ? 1 : 1.414;+        if (turn !== 0) stepCost += turnPenalty;+        const lane = router.laneNeighbors(nx, ny, next);+        if (bundleMode) {+          if (lane === 1) stepCost -= 0.22;+        } else if (lane === 1) stepCost += crampPenalty;+        else if (lane === 2) stepCost -= weaveBonus;+        stepCost += random() * 0.05;+        const nextState = (ny * columns + nx) * 8 + next;+        const total = cost + stepCost;+        if (seen[nextState] === generation && bestCost[nextState] <= total) continue;+        seen[nextState] = generation;+        bestCost[nextState] = total;+        parent[nextState] = state;+        heap.push(total + heuristic(nx, ny) * 1.2, nextState);+      }+    }++    if (goalState < 0) {+      why.reason = heap.size ? "expansions" : "exhausted";+      return null;+    }+    const directions = [];+    let cursor = goalState;+    while (parent[cursor] >= 0) {+      directions.push(cursor % 8);+      cursor = parent[cursor];+    }+    directions.reverse();+    // Reject absurd detours (torus wrap can produce them when hemmed in).+    if (directions.length > straightestFirst * 2.4 + 12) {+      why.reason = "detour";+      return null;+    }+    return directions;+  };+}++// --- terminal formations ----------------------------------------------------+//+// The reference pattern's vias are not sprinkled at random: they sit in small+// deliberate formations — diagonal ladders (staggered bus fanouts), rows, and+// singles — and traces escape them on a consistent side. Formations are placed+// with best-candidate sampling so the tile keeps even coverage and breathing+// room between groups.++function placeFormations(random, router, columns, rows, count, config) {+  const formations = [];+  const centers = [];+  const busWidth = config.busWidth;+  const horizontalEscape = Math.min(0.95, config.horizontalBias + 0.02);+  const rowSplit = 0.52 + 0.26 * config.horizontalBias;+  const wrapDist = (a, b, m) => {+    const d = Math.abs(a - b) % m;+    return Math.min(d, m - d);+  };++  for (let index = 0; index < count; index += 1) {+    let best = null;+    let bestClearance = -1;+    for (let candidate = 0; candidate < 8; candidate += 1) {+      const x = Math.floor(random() * columns);+      const y = Math.floor(random() * rows);+      let clearance = Infinity;+      for (const [cx, cy] of centers) {+        clearance = Math.min(+          clearance,+          Math.hypot(wrapDist(x, cx, columns), wrapDist(y, cy, rows)),+        );+      }+      if (clearance > bestClearance) {+        bestClearance = clearance;+        best = [x, y];+      }+    }++    const [x, y] = best;+    const roll = random();+    const pins = [];+    let signature;+    if (roll < 0.52) {+      // Diagonal via ladder — the staggered bus-fanout motif.+      const length = Math.max(2, Math.min(8, Math.round((3 + random() * 4) * busWidth)));+      const slope = random() < 0.5 ? -1 : 1;+      const escape = random() < horizontalEscape ? 0 : 4;+      signature = `ladder${slope}`;+      for (let k = 0; k < length; k += 1) pins.push({ x: x + k, y: y + k * slope, escape });+    } else if (roll < rowSplit) {+      // Vertical via row (2-cell spacing), horizontal escapes.+      const length = Math.max(2, Math.min(6, Math.round((2 + random() * 2) * busWidth)));+      const escape = random() < 0.5 ? 0 : 4;+      signature = "vrow";+      for (let k = 0; k < length; k += 1) pins.push({ x, y: y + k * 2, escape });+    } else if (roll < 0.78) {+      // Horizontal via row, vertical escapes.+      const length = Math.max(2, Math.min(6, Math.round((2 + random() * 2) * busWidth)));+      const escape = random() < 0.5 ? 2 : 6;+      signature = "hrow";+      for (let k = 0; k < length; k += 1) pins.push({ x: x + k * 2, y, escape });+    } else {+      signature = "solo";+      pins.push({ x, y, escape: [0, 4, 2, 6][Math.floor(random() * 4)] });+    }++    // Reserve the pin nodes now; the via graphics are only placed when a net+    // or stub actually connects, so a formation that stays unconnected leaves+    // no orphaned via row behind.+    const record = newTraceRecord(pins[0].x, pins[0].y);+    let placeable = true;+    for (const pin of pins) {+      if (!router.nodeFree(pin.x, pin.y)) {+        placeable = false;+        break;+      }+      router.claimNode(record, pin.x, pin.y);+    }+    if (!placeable) {+      router.rollback(record);+      continue;+    }+    centers.push([x, y]);+    formations.push({ pins: pins.map((pin) => ({ ...pin, used: false })), record, signature });+  }+  return formations;+}++// Start a trace record at a pin with its mandatory via already in place.+// Returns null when the pin has no via clearance (the pin is then skipped —+// every trace must begin on a via).+function startAtPin(router, pin) {+  const record = newTraceRecord(pin.x, pin.y);+  if (!router.viaAllowed(record, pin.x, pin.y)) return null;+  router.placeVia(record, pin.x, pin.y);+  return record;+}++function signedWrap(delta, modulus) {+  let d = ((delta % modulus) + modulus) % modulus;+  if (d > modulus / 2) d -= modulus;+  return d;+}++// A route may enter a pin from the facing side or hook in from up to 90+// degrees off — only a straight-through backside entry is forbidden.+const ARRIVALS = DIR8.map((_, escape) => {+  const reverse = (escape + 4) % 8;+  return new Set([+    (reverse + 6) % 8,+    (reverse + 7) % 8,+    reverse,+    (reverse + 1) % 8,+    (reverse + 2) % 8,+  ]);+});++// Route bundles of nets between compatible formations. Only the FIRST pin+// pair is routed with A*; the remaining pins get pure translated copies of+// the leader. Translation is what the reference does: neighbors that bend at+// the same places, producing nested chevrons and group staircases. Followers+// that hit an obstruction stop early and drop their via there — a natural+// fanout.+function routeNets(random, router, route, formations, addRecord, stats, config) {+  const majorFor = (escape) => (escape === 0 || escape === 4 ? 0 : 1);+  const sameNode = (x1, y1, x2, y2) =>+    router.nodeKey(x1, y1) === router.nodeKey(x2, y2);++  for (const formation of formations) {+    const minePins = formation.pins.filter((pin) => !pin.used);+    if (!minePins.length) continue;++    const escape = formation.pins[0].escape;+    const axis = majorFor(escape);+    // Find a partner formation whose pins face back toward this one; a+    // partner with the same shape nests perfectly under translation.+    let partner = null;+    let partnerScore = Infinity;+    for (const other of formations) {+      if (other === formation) continue;+      const otherFree = other.pins.filter((pin) => !pin.used);+      if (!otherFree.length) continue;+      if (other.pins[0].escape !== (escape + 4) % 8) continue;+      const dx = signedWrap(other.pins[0].x - formation.pins[0].x, router.columns);+      const dy = signedWrap(other.pins[0].y - formation.pins[0].y, router.rows);+      const major = axis === 0 ? dx : dy;+      const minor = axis === 0 ? dy : dx;+      const forward = escape === 0 || escape === 2 ? major : -major;+      if (forward < 8 || forward > config.netReach) continue;+      if (Math.abs(minor) > 12) continue;+      // Prefer a few cells of misalignment: it forces the staircase jogs the+      // reference is full of. Perfect alignment yields sterile laser lines.+      let score =+        Math.abs(Math.abs(minor) - config.jogBias) * 0.8 ++        Math.abs(forward - 22) * 0.3 ++        random() * 5;+      if (other.signature === formation.signature) score -= 4;+      if (score < partnerScore) {+        partnerScore = score;+        partner = other;+      }+    }+    if (!partner) continue;++    const minorOf = (pin) => (axis === 0 ? pin.y : pin.x);+    const mine = minePins.sort((a, b) => minorOf(a) - minorOf(b));+    const theirs = partner.pins+      .filter((pin) => !pin.used)+      .sort((a, b) => minorOf(a) - minorOf(b));+    const pairCount = Math.min(mine.length, theirs.length);+    if (!pairCount) continue;++    // Leader. Both pin vias go in before routing — every trace begins and+    // ends on a via, so a net is only viable if both terminals have+    // clearance.+    const leadFrom = mine[0];+    const leadTo = theirs[0];+    const leadRecord = startAtPin(router, leadFrom);+    if (!leadRecord) continue;+    if (!router.viaAllowed(leadRecord, leadTo.x, leadTo.y)) {+      router.rollback(leadRecord);+      continue;+    }+    router.placeVia(leadRecord, leadTo.x, leadTo.y);+    const why = {};+    const directions = route(+      random,+      { x: leadFrom.x, y: leadFrom.y, direction: leadFrom.escape },+      { x: leadTo.x, y: leadTo.y, arrivals: ARRIVALS[leadTo.escape] },+      why,+    );+    if (!directions) {+      router.rollback(leadRecord);+      stats.leaderFails += 1;+      stats[why.reason] = (stats[why.reason] || 0) + 1;+      continue;+    }+    stats.leaders += 1;+    stats.followerTries += pairCount - 1;+    {+      let x = leadFrom.x;+      let y = leadFrom.y;+      for (const step of directions) {+        router.claimStep(leadRecord, x, y, step);+        x += DIR8[step][0];+        y += DIR8[step][1];+        leadRecord.steps.push(step);+      }+      leadRecord.end = [x, y];+      leadFrom.used = true;+      leadTo.used = true;+      addRecord(leadRecord);+    }++    // Followers: translated copies pin-by-pin. A follower whose translated+    // corridor is blocked early gets rerouted with A* in bundle mode so it+    // wraps the obstacle and rejoins the group. Every follower starts on its+    // pin via and seals its far end with a via (trimming back if needed).+    for (let index = 1; index < pairCount; index += 1) {+      const from = mine[index];+      const to = theirs[index];+      const goalKey = router.nodeKey(to.x, to.y);++      const record = startAtPin(router, from);+      if (!record) {+        stats.followerFails += 1;+        continue;+      }++      const clearSteps = () => {+        while (record.steps.length) router.unclaimLastStep(record);+      };+      const layTranslation = () => {+        let x = from.x;+        let y = from.y;+        for (const step of directions) {+          if (!router.routeStepFree(x, y, step, goalKey)) break;+          router.claimStep(record, x, y, step);+          x += DIR8[step][0];+          y += DIR8[step][1];+          record.steps.push(step);+          if (sameNode(x, y, to.x, to.y)) break;+        }+        record.end = [x, y];+      };++      layTranslation();+      let reached = sameNode(record.end[0], record.end[1], to.x, to.y);+      if (!reached && record.steps.length < Math.max(6, Math.floor(directions.length * 0.6))) {+        clearSteps();+        const alt = route(+          random,+          { x: from.x, y: from.y, direction: from.escape },+          { x: to.x, y: to.y, arrivals: ARRIVALS[to.escape] },+          {},+          true,+        );+        if (alt) {+          let x = from.x;+          let y = from.y;+          for (const step of alt) {+            router.claimStep(record, x, y, step);+            x += DIR8[step][0];+            y += DIR8[step][1];+            record.steps.push(step);+          }+          record.end = [x, y];+          reached = true;+          stats.followersRerouted += 1;+        } else {+          layTranslation();+          reached = sameNode(record.end[0], record.end[1], to.x, to.y);+        }+      }++      if (record.steps.length < 4 || !sealEnd(router, record, 4)) {+        router.rollback(record);+        stats.followerFails += 1;+        continue;+      }+      from.used = true;+      if (sameNode(record.end[0], record.end[1], to.x, to.y)) {+        to.used = true;+        stats.followersFull += 1;+      } else {+        stats.followersPartial += 1;+      }+      addRecord(record);+    }+  }+}++// Tap combs: short perpendicular stubs dropping off a long run at a regular+// pitch, each ending in a via — the power-rail tap motif that punctuates the+// reference artwork.+function tapCombs(random, router, records, addRecord, config) {+  const runs = [];+  for (const record of records) {+    let x = record.start[0];+    let y = record.start[1];+    let runX = x;+    let runY = y;+    let runDir = -1;+    let runLen = 0;+    const flush = () => {+      if (runDir >= 0 && runDir % 2 === 0 && runLen >= 8) {+        runs.push({ x: runX, y: runY, dir: runDir, len: runLen });+      }+    };+    for (const step of record.steps) {+      if (step === runDir) runLen += 1;+      else {+        flush();+        runDir = step;+        runX = x;+        runY = y;+        runLen = 1;+      }+      x += DIR8[step][0];+      y += DIR8[step][1];+    }+    flush();+  }++  let combs = 0;+  for (const run of runs) {+    if (combs >= config.combs || random() >= 0.28) continue;+    const [dx, dy] = DIR8[run.dir];+    const side = random() < 0.5 ? 1 : -1;+    const px = -dy * side;+    const py = dx * side;+    const tapDir = DIR8.findIndex(([tx, ty]) => tx === px && ty === py);+    const teeth = 2 + Math.floor(random() * 3);+    // Depth 2-3: the junction via and tip via need a two-cell gap to clear+    // each other's annular rings.+    const depth = 2 + (random() < 0.35 ? 1 : 0);+    const span = teeth * 2;+    if (run.len < span + 2) continue;+    const offset = 1 + Math.floor(random() * (run.len - span - 1));+    const made = [];+    for (let tooth = 0; tooth < teeth; tooth += 1) {+      const bx = run.x + dx * (offset + tooth * 2);+      const by = run.y + dy * (offset + tooth * 2);+      // The tap starts ON the parent trace (a T junction, marked with its+      // own via) and steps outward to a tip via.+      const record = newTraceRecord(bx, by);+      if (!router.viaAllowed(record, bx, by)) continue;+      router.placeVia(record, bx, by);+      let cx = bx;+      let cy = by;+      for (let d = 0; d < depth; d += 1) {+        if (!router.stepFree(cx, cy, tapDir)) break;+        router.claimStep(record, cx, cy, tapDir);+        cx += px;+        cy += py;+        record.steps.push(tapDir);+      }+      record.end = [cx, cy];+      if (record.steps.length < 2 || !sealEnd(router, record, 2)) {+        router.rollback(record);+        continue;+      }+      made.push(record);+    }+    if (made.length >= 2) {+      for (const record of made) addRecord(record);+      combs += 1;+    } else {+      for (const record of made) router.rollback(record);+    }+  }+}++// Short bare-ended escape stubs for pins that never got a net — the reference+// is full of these little launches.+function routeStubs(random, router, formations, addRecord, config) {+  for (const formation of formations) {+    for (const pin of formation.pins) {+      if (pin.used || random() >= config.stubChance) continue;+      const record = startAtPin(router, pin);+      if (!record) continue;+      let x = pin.x;+      let y = pin.y;+      let direction = pin.escape;+      const length = 2 + Math.floor(random() * 5);+      for (let step = 0; step < length; step += 1) {+        if (step > 0 && random() < 0.2) {+          const jog = (direction + (random() < 0.5 ? 1 : 7)) % 8;+          if (router.stepFree(x, y, jog)) direction = jog;+        }+        if (!router.stepFree(x, y, direction)) break;+        router.claimStep(record, x, y, direction);+        x += DIR8[direction][0];+        y += DIR8[direction][1];+        record.steps.push(direction);+      }+      record.end = [x, y];+      if (record.steps.length < 2 || !sealEnd(router, record, 2)) {+        router.rollback(record);+        continue;+      }+      pin.used = true;+      addRecord(record);+    }+  }+}++// Translated companions for a filler walk — same idea as the net followers:+// staggered trims and via ladders at the ends, bending wherever the base+// bends. In the reference almost nothing travels alone.+function replicateWalk(random, router, base, addRecord) {+  if (random() >= 0.55 || base.steps.length < 8) return;+  let horizontalSteps = 0;+  let verticalSteps = 0;+  for (const step of base.steps) {+    if (step === 0 || step === 4) horizontalSteps += 1;+    else if (step === 2 || step === 6) verticalSteps += 1;+  }+  const horizontal = horizontalSteps >= verticalSteps;+  const spacing = random() < 0.7 ? 1 : 2;+  const side = random() < 0.5 ? 1 : -1;+  const shift = horizontal ? [0, spacing * side] : [spacing * side, 0];+  const copies = 1 + Math.floor(random() * 3);+  const trimStartUnit = random() < 0.5 ? 1 : 0;+  const trimEndUnit = random() < 0.8 ? 1 : 2;++  for (let copy = 1; copy <= copies; copy += 1) {+    const trimStart = copy * trimStartUnit;+    const trimEnd = copy * trimEndUnit;+    if (base.steps.length - trimStart - trimEnd < 5) break;+    let x = base.start[0] + shift[0] * copy;+    let y = base.start[1] + shift[1] * copy;+    for (let index = 0; index < trimStart; index += 1) {+      x += DIR8[base.steps[index]][0];+      y += DIR8[base.steps[index]][1];+    }+    if (!router.nodeFree(x, y) || !router.viaAllowed(SCRATCH_RECORD, x, y)) continue;+    const record = newTraceRecord(x, y);+    router.claimNode(record, x, y);+    router.placeVia(record, x, y); // every trace starts on a via+    for (let index = trimStart; index < base.steps.length - trimEnd; index += 1) {+      const step = base.steps[index];+      if (!router.stepFree(x, y, step)) break;+      router.claimStep(record, x, y, step);+      x += DIR8[step][0];+      y += DIR8[step][1];+      record.steps.push(step);+    }+    record.end = [x, y];+    if (record.steps.length < 5 || !sealEnd(router, record, 5)) {+      router.rollback(record);+      continue;+    }+    addRecord(record);+  }+}++// --- filler walker ----------------------------------------------------------+//+// A small amount of free-roaming texture between the routed bundles, using the+// same collision rules. Kept from the earlier engine but used sparingly.++const SCRATCH_RECORD = { ownKeys: new Set() };++function walkTrace(random, router, config, startWeights) {+  let sx = -1;+  let sy = -1;+  let bestCrowding = Infinity;+  for (let attempt = 0, found = 0; attempt < 24 && found < 3; attempt += 1) {+    const x = Math.floor(random() * router.columns);+    const y = Math.floor(random() * router.rows);+    if (!router.nodeFree(x, y) || !router.viaAllowed(SCRATCH_RECORD, x, y)) continue;+    found += 1;+    const crowding = router.crowding(x, y);+    if (crowding < bestCrowding) {+      bestCrowding = crowding;+      sx = x;+      sy = y;+    }+  }+  if (sx < 0) return null;++  const trace = newTraceRecord(sx, sy);+  router.claimNode(trace, sx, sy);+  router.placeVia(trace, sx, sy); // every trace starts on a via++  let direction = weightedChoice(random, startWeights);+  let lastCardinal = direction % 2 === 0 ? direction : (direction + (random() < 0.5 ? 1 : 7)) % 8;+  const budget = 14 + Math.floor(random() * 22);+  let x = sx;+  let y = sy;+  let sinceVia = 4;+  let turnStreak = 0; // consecutive same-side turns; capped to avoid closed loops++  while (trace.steps.length < budget) {+    const cardinal = direction % 2 === 0;+    const runLength = cardinal+      ? 3 + Math.floor(random() * random() * 9)+      : 1 + Math.floor(random() * 3);++    for (let step = 0; step < runLength && trace.steps.length < budget; step += 1) {+      if (!router.stepFree(x, y, direction)) break;+      router.claimStep(trace, x, y, direction);+      x += DIR8[direction][0];+      y += DIR8[direction][1];+      trace.steps.push(direction);+      sinceVia += 1;+      if (+        cardinal &&+        sinceVia > 4 &&+        trace.steps.length < budget - 2 &&+        random() < config.inlineViaChance &&+        router.viaAllowed(trace, x, y)+      ) {+        router.placeVia(trace, x, y);+        sinceVia = 0;+      }+    }++    if (trace.steps.length >= budget) break;++    let next;+    if (cardinal) {+      // Curling the same way repeatedly closes octagon loops; alternate.+      const side = Math.abs(turnStreak) >= 3 ? -Math.sign(turnStreak) : random() < 0.5 ? 1 : -1;+      next = (direction + (side > 0 ? 1 : 7)) % 8;+    } else {+      const optionA = (direction + 1) % 8;+      const optionB = (direction + 7) % 8;+      const preferPrevious = (optionA === lastCardinal || optionB === lastCardinal) && random() < 0.7;+      next = preferPrevious ? lastCardinal : random() < 0.5 ? optionA : optionB;+    }+    if (!router.stepFree(x, y, next)) {+      const mirrored = (direction + (8 - ((next - direction + 8) % 8))) % 8;+      if (!router.stepFree(x, y, mirrored)) break;+      next = mirrored;+    }+    const turnSide = ((next - direction + 8) % 8) === 1 ? 1 : -1;+    turnStreak = Math.sign(turnStreak) === turnSide ? turnStreak + turnSide : turnSide;+    direction = next;+    if (direction % 2 === 0) lastCardinal = direction;+  }++  trace.end = [x, y];+  if (trace.steps.length < 7 || !sealEnd(router, trace, 7)) {+    router.rollback(trace);+    return null;+  }+  return trace;+}++function toTileTrace(record, pitch) {+  const points = [[record.start[0], record.start[1]]];+  let previous = -1;+  let [x, y] = record.start;+  for (const step of record.steps) {+    x += DIR8[step][0];+    y += DIR8[step][1];+    if (step === previous) points[points.length - 1] = [x, y];+    else points.push([x, y]);+    previous = step;+  }+  const scaled = points.map(([px, py]) => [px * pitch, py * pitch]);+  let length = 0;+  for (let index = 1; index < scaled.length; index += 1) {+    length += Math.hypot(+      scaled[index][0] - scaled[index - 1][0],+      scaled[index][1] - scaled[index - 1][1],+    );+  }+  return {+    points: scaled,+    length,+    vias: record.viaCells.map(([vx, vy]) => [vx * pitch, vy * pitch]),+  };+}++export function generateCircuitTile(options = {}) {+  const config = { ...DEFAULTS, ...options };+  const random = mulberry32(hashSeed(config.seed));+  const columns = Math.round(config.tileWidth / config.pitch);+  const rows = Math.round(config.tileHeight / config.pitch);+  const router = createRouter(random, columns, rows);+  const route = createPathfinder(router, columns, rows, config);+  const startWeights = startDirectionWeights(config.horizontalBias);++  const records = [];+  let routedSteps = 0;+  const addRecord = (record) => {+    records.push(record);+    routedSteps += record.steps.length;+  };++  // 1. Terminal formations: via ladders, rows, singles.+  const formations = placeFormations(+    random,+    router,+    columns,+    rows,+    Math.round(config.formations * config.density),+    config,+  );+  for (const formation of formations) records.push(formation.record);++  // 2. Nets between formations — the flow. Two passes so pins left over from+  // the first pairing find another partner.+  const stats = {+    formations: formations.length,+    leaders: 0,+    leaderFails: 0,+    followerTries: 0,+    followersFull: 0,+    followersPartial: 0,+    followersRerouted: 0,+    followerFails: 0,+  };+  routeNets(random, router, route, formations, addRecord, stats, config);+  routeNets(random, router, route, formations, addRecord, stats, config);++  // 3. Escape stubs for leftover pins.+  routeStubs(random, router, formations, addRecord, config);++  // 4. A sprinkle of free-roaming filler until the reference ink density.+  const targetSteps = Math.round(4800 * config.density);+  let attempts = 0;+  while (routedSteps < targetSteps && attempts < 3200) {+    attempts += 1;+    const trace = walkTrace(random, router, config, startWeights);+    if (trace) {+      addRecord(trace);+      replicateWalk(random, router, trace, addRecord);+    }+  }++  // 5. Tap combs hanging off the long runs (nets and filler alike).+  if (config.combs > 0) tapCombs(random, router, [...records], addRecord, config);++  return {+    width: columns * config.pitch,+    height: rows * config.pitch,+    pitch: config.pitch,+    stats,+    traces: records.map((record) => toTileTrace(record, config.pitch)),+  };+}++export function pathData(points, offsetX = 0, offsetY = 0) {+  return points+    .map(([x, y], index) => `${index ? "L" : "M"}${(x + offsetX).toFixed(2)},${(y + offsetY).toFixed(2)}`)+    .join("");+}++// Standalone seamless tile: the geometry is drawn 3 x 3 around the viewBox and+// clipped, so traces that wrap the torus edge reappear on the opposite side.+export function tileSvg(tile, options = {}) {+  const config = { ...DEFAULTS, ...options };+  const { width, height } = tile;+  const body = [];+  for (const trace of tile.traces) {+    if (trace.points.length < 2) continue;+    body.push(+      `<path d="${pathData(trace.points)}" fill="none" stroke="${config.traceColor}" ` ++        `stroke-width="${config.traceWidth}" stroke-linecap="butt" stroke-linejoin="miter"/>`,+    );+  }+  const vias = [];+  for (const trace of tile.traces) {+    for (const [cx, cy] of trace.vias) {+      vias.push(+        `<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${config.viaOuterRadius}" fill="${config.traceColor}"/>` ++          `<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${config.viaInnerRadius}" fill="${config.holeColor || config.background || "#0d1117"}"/>`,+      );+    }+  }+  const uses = [];+  for (let row = -1; row <= 1; row += 1) {+    for (let column = -1; column <= 1; column += 1) {+      uses.push(`<use href="#tile" transform="translate(${column * width}, ${row * height})"/>`);+    }+  }+  const backgroundRect = config.background+    ? `<rect width="${width}" height="${height}" fill="${config.background}"/>`+    : "";+  return (+    `<?xml version="1.0" encoding="UTF-8"?>\n` ++    `<svg xmlns="${SVG_NS}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` ++    `<defs><g id="tile">${body.join("")}${vias.join("")}</g>` ++    `<clipPath id="edge"><rect width="${width}" height="${height}"/></clipPath></defs>` ++    `${backgroundRect}<g clip-path="url(#edge)">${uses.join("")}</g></svg>`+  );+}++// --- signal routing ---------------------------------------------------------+//+// Signals ride the real centerlines. Each trace is resampled to ~3.5 unit+// spacing and indexed in a spatial hash; when a pulse reaches the end of a+// trace it looks for a nearby, direction-aligned point on another trace (in+// this tile or a neighbor) and hops there, exactly like the reference site.++const SIGNAL_SAMPLE = 3.5;+const HOP_RADIUS = 3.5;+const HASH_CELL = 8;+const signalIndexCache = new WeakMap();++function ensureSignalIndex(tile) {+  let index = signalIndexCache.get(tile);+  if (index) return index;+  const dense = tile.traces.map((trace) => {+    if (trace.points.length < 2) return [];+    const points = [];+    for (let segment = 1; segment < trace.points.length; segment += 1) {+      const [ax, ay] = trace.points[segment - 1];+      const [bx, by] = trace.points[segment];+      const steps = Math.max(1, Math.round(Math.hypot(bx - ax, by - ay) / SIGNAL_SAMPLE));+      for (let step = segment === 1 ? 0 : 1; step <= steps; step += 1) {+        points.push([ax + ((bx - ax) * step) / steps, ay + ((by - ay) * step) / steps]);+      }+    }+    return points;+  });+  const buckets = new Map();+  dense.forEach((points, traceIndex) => {+    points.forEach((point, pointIndex) => {+      const key = `${Math.floor(point[0] / HASH_CELL)}:${Math.floor(point[1] / HASH_CELL)}`;+      let bucket = buckets.get(key);+      if (!bucket) buckets.set(key, (bucket = []));+      bucket.push([traceIndex, pointIndex]);+    });+  });+  // Trace endpoints (all mandated vias) — the launch pads for via hopping.+  const endpoints = [];+  dense.forEach((points, traceIndex) => {+    if (points.length < 6) return;+    endpoints.push({ trace: traceIndex, point: 0, direction: 1, x: points[0][0], y: points[0][1] });+    const last = points.length - 1;+    endpoints.push({ trace: traceIndex, point: last, direction: -1, x: points[last][0], y: points[last][1] });+  });+  index = { dense, buckets, endpoints };+  signalIndexCache.set(tile, index);+  return index;+}++function findHop(tile, absX, absY, dirX, dirY, visited) {+  const { dense, buckets } = ensureSignalIndex(tile);+  const baseTileX = Math.floor(absX / tile.width);+  const baseTileY = Math.floor(absY / tile.height);+  let best = null;++  for (let tileY = baseTileY - 2; tileY <= baseTileY + 2; tileY += 1) {+    for (let tileX = baseTileX - 2; tileX <= baseTileX + 2; tileX += 1) {+      const localX = absX - tileX * tile.width;+      const localY = absY - tileY * tile.height;+      const minCellX = Math.floor((localX - HOP_RADIUS) / HASH_CELL);+      const maxCellX = Math.floor((localX + HOP_RADIUS) / HASH_CELL);+      const minCellY = Math.floor((localY - HOP_RADIUS) / HASH_CELL);+      const maxCellY = Math.floor((localY + HOP_RADIUS) / HASH_CELL);+      for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {+        for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {+          const bucket = buckets.get(`${cellX}:${cellY}`);+          if (!bucket) continue;+          for (const [traceIndex, pointIndex] of bucket) {+            if (visited.has(`${traceIndex}:${tileX}:${tileY}`)) continue;+            const point = dense[traceIndex][pointIndex];+            const distance = Math.hypot(point[0] - localX, point[1] - localY);+            if (distance >= HOP_RADIUS) continue;+            if (distance >= 1.2) {+              const toward =+                ((point[0] - localX) * dirX + (point[1] - localY) * dirY) / (distance || 1);+              if (toward < 0.35) continue;+            }+            for (const hopDirection of [1, -1]) {+              const ahead = pointIndex + hopDirection * 3;+              if (ahead < 0 || ahead >= dense[traceIndex].length) continue;+              const segX = dense[traceIndex][ahead][0] - point[0];+              const segY = dense[traceIndex][ahead][1] - point[1];+              const segLength = Math.hypot(segX, segY) || 1;+              const score = (segX * dirX + segY * dirY) / segLength - distance * 0.05;+              if (!best || score > best.score) {+                best = {+                  trace: traceIndex,+                  tileX,+                  tileY,+                  point: pointIndex,+                  direction: hopDirection,+                  score,+                };+              }+            }+          }+        }+      }+    }+  }+  return best && best.score > 0.1 ? best : null;+}++function buildSignalPath(tile, start, budget, minLength) {+  const { dense } = ensureSignalIndex(tile);+  const visited = new Set();+  const points = [];+  let travelled = 0;+  let cursor = { ...start };++  // The walk always finishes at a trace end (a mandated via) — the budget+  // only limits further mid-route hops, never cuts a pulse off mid-trace.+  for (;;) {+    const tracePoints = dense[cursor.trace];+    visited.add(`${cursor.trace}:${cursor.tileX}:${cursor.tileY}`);+    const offsetX = cursor.tileX * tile.width;+    const offsetY = cursor.tileY * tile.height;+    for (+      let index = cursor.point;+      index >= 0 && index < tracePoints.length;+      index += cursor.direction+    ) {+      const x = tracePoints[index][0] + offsetX;+      const y = tracePoints[index][1] + offsetY;+      if (points.length) {+        const last = points[points.length - 1];+        travelled += Math.hypot(x - last[0], y - last[1]);+      }+      points.push([x, y]);+    }+    if (points.length < 2 || travelled >= budget) break;++    const last = points[points.length - 1];+    const before = points[points.length - 2];+    const stepLength = Math.hypot(last[0] - before[0], last[1] - before[1]) || 1;+    const hop = findHop(+      tile,+      last[0],+      last[1],+      (last[0] - before[0]) / stepLength,+      (last[1] - before[1]) / stepLength,+      visited,+    );+    if (!hop) break;+    cursor = hop;+  }++  return travelled >= minLength ? { points, length: travelled } : null;+}++// --- DOM mount ---------------------------------------------------------------++export function createCircuitBackground(mount, options = {}) {+  if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");++  const id = `circuit-${++instanceCount}`;+  let config = { ...DEFAULTS, ...options };+  let random = mulberry32(hashSeed(`${config.seed}:signals`));+  let tile = generateCircuitTile(config);+  let svg;+  let usesGroup;+  let signalGroup;+  let resizeObserver;+  let activeSignals = 0;+  let signalTimer;+  let epoch = 0; // invalidates in-flight signal timeouts across regenerations+  let tilesAcross = 1;+  let tilesDown = 1;+  let topOffset = -config.tileHeight / 2;+  let pointer = null;+  const interactionTarget = mount.parentElement || mount;+  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;++  const originalPosition = mount.style.position;+  const computedPosition = getComputedStyle(mount).position;+  if (computedPosition === "static") mount.style.position = "relative";+  mount.style.overflow = "hidden";++  function buildSvg() {+    svg?.remove();+    epoch += 1;+    activeSignals = 0;+    tile = generateCircuitTile(config);+    random = mulberry32(hashSeed(`${config.seed}:signals`));+    svg = svgEl("svg", {+      "aria-hidden": "true",+      focusable: "false",+      xmlns: SVG_NS,+    });+    svg.dataset.circuitBackground = "";+    Object.assign(svg.style, {+      position: "absolute",+      left: "0",+      top: "0",+      display: "block",+      maxWidth: "none",+      pointerEvents: "none",+      overflow: "visible",+    });++    const defs = svgEl("defs");+    const tileGroup = svgEl("g", { id: `${id}-tile` });+    const traceGroup = svgEl("g", {+      fill: "none",+      stroke: config.traceColor,+      "stroke-width": config.traceWidth,+      "stroke-linecap": "butt",+      "stroke-linejoin": "miter",+    });+    const viaGroup = svgEl("g");+    for (const trace of tile.traces) {+      if (trace.points.length >= 2) {+        traceGroup.appendChild(svgEl("path", { d: pathData(trace.points) }));+      }+      for (const [cx, cy] of trace.vias) {+        viaGroup.appendChild(+          svgEl("circle", { cx, cy, r: config.viaOuterRadius, fill: config.traceColor }),+        );+        viaGroup.appendChild(+          svgEl("circle", {+            cx,+            cy,+            r: config.viaInnerRadius,+            fill: "var(--circuit-hole, #0d1117)",+          }),+        );+      }+    }+    tileGroup.append(traceGroup, viaGroup);+    defs.appendChild(tileGroup);+    svg.appendChild(defs);++    usesGroup = svgEl("g");+    signalGroup = svgEl("g", { fill: "none" });+    svg.append(usesGroup, signalGroup);+    mount.prepend(svg);+    layout();+  }++  function layout() {+    const width = Math.max(1, mount.clientWidth);+    const height = Math.max(1, mount.clientHeight);+    const tilePixelWidth = tile.width * config.scale;+    const tilePixelHeight = tile.height * config.scale;+    tilesAcross = Math.max(1, Math.ceil(width / tilePixelWidth));+    tilesDown = Math.max(2, Math.ceil(height / tilePixelHeight) + 1);+    topOffset = -tile.height / 2;+    const viewWidth = tilesAcross * tile.width;+    const viewHeight = tilesDown * tile.height;++    svg.setAttribute("width", `${viewWidth * config.scale}`);+    svg.setAttribute("height", `${viewHeight * config.scale}`);+    svg.setAttribute("viewBox", `0 ${-topOffset} ${viewWidth} ${viewHeight}`);+    usesGroup.replaceChildren();+    // The extra ring of tiles paints the traces that wrap in from neighbors.+    for (let row = -1; row <= tilesDown; row += 1) {+      for (let column = -1; column <= tilesAcross; column += 1) {+        usesGroup.appendChild(+          svgEl("use", {+            href: `#${id}-tile`,+            transform: `translate(${column * tile.width}, ${row * tile.height})`,+          }),+        );+      }+    }+  }++  // Pointer-biased launch pad: candidates are scored by the distance from+  // the ACTUAL launch point (a trace end, not the trace middle) to the+  // cursor, plus spawnRadius worth of jitter — so spawns cluster within+  // roughly spawnRadius of the pointer without a hard cutoff.+  function pickStart() {+    const { dense } = ensureSignalIndex(tile);+    const nearPointer = pointer && performance.now() - pointer.at < 4000;+    let choice = null;+    let bestScore = Infinity;+    const tries = nearPointer ? 40 : 4;+    for (let attempt = 0; attempt < tries; attempt += 1) {+      const trace = Math.floor(random() * dense.length);+      const tileX = Math.floor(random() * tilesAcross);+      const tileY = Math.floor(random() * tilesDown);+      if (dense[trace].length < 6) continue;+      const fromStart = random() < 0.5;+      const candidate = {+        trace,+        tileX,+        tileY,+        point: fromStart ? 0 : dense[trace].length - 1,+        direction: fromStart ? 1 : -1,+      };+      if (!nearPointer) return candidate;+      const [padX, padY] = dense[trace][candidate.point];+      const score =+        Math.hypot(padX + tileX * tile.width - pointer.x, padY + tileY * tile.height - pointer.y) ++        config.spawnRadius * random();+      if (score < bestScore) {+        bestScore = score;+        choice = candidate;+      }+    }+    return choice;+  }++  // Launch pads (trace-end vias) near an ended signal, closest first, so the+  // replacement reads as the signal hopping vias onto a neighboring trace.+  function nearbyEndpointStarts(absX, absY, excludeTrace) {+    const { endpoints } = ensureSignalIndex(tile);+    const baseTileX = Math.floor(absX / tile.width);+    const baseTileY = Math.floor(absY / tile.height);+    const candidates = [];+    for (let tileY = baseTileY - 1; tileY <= baseTileY + 1; tileY += 1) {+      for (let tileX = baseTileX - 1; tileX <= baseTileX + 1; tileX += 1) {+        const localX = absX - tileX * tile.width;+        const localY = absY - tileY * tile.height;+        for (const endpoint of endpoints) {+          if (endpoint.trace === excludeTrace) continue;+          const distance = Math.hypot(endpoint.x - localX, endpoint.y - localY);+          if (distance > 2 && distance < config.hopRadius) {+            candidates.push({ endpoint, tileX, tileY, distance: distance + random() * 20 });+          }+        }+      }+    }+    candidates.sort((a, b) => a.distance - b.distance);+    return candidates.slice(0, 6).map((pick) => ({+      trace: pick.endpoint.trace,+      tileX: pick.tileX,+      tileY: pick.tileY,+      point: pick.endpoint.point,+      direction: pick.endpoint.direction,+    }));+  }++  // phase (0..1) fast-forwards the animation so boot signals are already+  // mid-journey. A chain keeps its speedFactor, color AND survival rate across+  // hops — the signal that entered blue exits blue, and a click-born chain+  // keeps decaying at burstSurvival while ambient chains use hopSurvival.+  // Occasionally (colorDrift) a leg crossfades to a new color over its whole+  // run, so palettes shift gradually, never pop.+  function launchSignal(path, phase = 0, speedFactor, chainColor, survival) {+    const factor = speedFactor ?? 0.85 + random() * 0.3;+    const surviveChance = survival ?? config.hopSurvival;+    const colorFrom = chainColor ?? weightedChoice(random, config.signalColors);+    const colorTo =+      random() < config.colorDrift ? weightedChoice(random, config.signalColors) : colorFrom;+    const d = pathData(path.points);+    const speed = config.signalSpeed * factor;+    const head = config.signalLength;+    const tail = head * 2.2;+    const length = path.length;+    const total = length + tail;+    const duration = (total / speed) * 1000;+    const group = svgEl("g");+    // Faint persistent wake along the whole route.+    const wake = svgEl("path", { d, stroke: colorFrom, "stroke-width": 2, opacity: 0 });+    // Soft glow under the head — a wide translucent stroke is far cheaper+    // than a drop-shadow filter re-rasterizing every frame.+    const glow = svgEl("path", {+      d,+      stroke: colorFrom,+      "stroke-width": 7,+      "stroke-linecap": "round",+      "stroke-opacity": 0.1,+      "stroke-dasharray": `${head} ${total}`,+      "stroke-dashoffset": head,+    });+    // Long dim comet tail behind the head.+    const comet = svgEl("path", {+      d,+      stroke: colorFrom,+      "stroke-width": 2,+      "stroke-linecap": "round",+      "stroke-opacity": 0.16,+      "stroke-dasharray": `${tail} ${total}`,+      "stroke-dashoffset": tail,+    });+    // Bright head.+    const pulse = svgEl("path", {+      d,+      stroke: colorFrom,+      "stroke-width": 2.4,+      "stroke-linecap": "round",+      "stroke-dasharray": `${head} ${total}`,+      "stroke-dashoffset": head,+      "stroke-opacity": 0.72,+    });+    group.append(wake, glow, comet, pulse);+    signalGroup.appendChild(group);+    activeSignals += 1;++    // All layers share the same leading edge; each runs from its own dash+    // length down to -(total - itsDash) so they exit together. Color rides+    // along in the same keyframes when this leg is a crossfade.+    const timing = { duration, easing: "linear", fill: "forwards" };+    const animations = [+      pulse.animate(+        [+          { strokeDashoffset: head, stroke: colorFrom },+          { strokeDashoffset: head - total, stroke: colorTo },+        ],+        timing,+      ),+      glow.animate(+        [+          { strokeDashoffset: head, stroke: colorFrom },+          { strokeDashoffset: head - total, stroke: colorTo },+        ],+        timing,+      ),+      comet.animate(+        [+          { strokeDashoffset: tail, stroke: colorFrom },+          { strokeDashoffset: tail - total, stroke: colorTo },+        ],+        timing,+      ),+      wake.animate(+        [+          { opacity: 0, stroke: colorFrom },+          { opacity: 0.06 },+          { opacity: 0.06 },+          { opacity: 0, stroke: colorTo },+        ],+        { duration, fill: "forwards" },+      ),+    ];+    const headArrival = (duration * length) / total;+    const skip = Math.min(phase, 0.95) * headArrival;+    if (skip > 0) for (const animation of animations) animation.currentTime = skip;++    // The signal is logically done when its head reaches the final via (the+    // tail after that is just residue): blip, release its population slot,+    // and — in the same beat — launch its successor from a nearby via. The+    // handoff is synchronous so motion never pauses.+    const [endX, endY] = path.points[path.points.length - 1];+    const myEpoch = epoch;+    window.setTimeout(() => {+      if (myEpoch !== epoch) return;+      activeSignals -= 1;+      const blip = svgEl("circle", {+        cx: endX,+        cy: endY,+        r: 2,+        fill: "none",+        stroke: colorTo,+        "stroke-width": 1.5,+        "stroke-opacity": 0.6,+      });+      group.appendChild(blip);+      blip.animate(+        [+          { r: 2, strokeOpacity: 0.6 },+          { r: 8, strokeOpacity: 0 },+        ],+        { duration: 500, easing: "ease-out", fill: "forwards" },+      );+      // The successor inherits the color the chain ARRIVED with.+      continueChain(endX, endY, path.startTrace, factor, colorTo, surviveChance);+    }, headArrival - skip);++    window.setTimeout(() => {+      group.remove();+    }, duration + 620 - skip);+  }++  // Every signal is mortal: on each via hop it rolls against its chain's+  // survival rate (hopSurvival for ambient chains, burstSurvival for+  // click-born ones — passed in so a chain keeps its own decay for life).+  // Survivors continue synchronously from a nearby via, inheriting speed and+  // color (arrive blue → depart blue); the rest just die with their arrival+  // blip. Replenishment is NOT done here — the ambient spawn timer refills+  // the population near the cursor, so a death simply lowers the census and+  // the next tick fountains a fresh signal in. Low survival => most signals+  // die after one leg => the fountain stays busy near the pointer.+  function continueChain(endX, endY, excludeTrace, speedFactor, chainColor, survival) {+    if (!config.signals || reducedMotion || document.hidden) return;+    if (random() >= (survival ?? config.hopSurvival)) return;+    if (activeSignals >= config.maxBurstSignals) return;+    spawnSignal(nearbyEndpointStarts(endX, endY, excludeTrace), 0, speedFactor, chainColor, survival);+  }++  // preferred: array of nearby launch pads tried in order (short legs are+  // fine for chain continuations); falls back to random picks elsewhere.+  function spawnSignal(preferred, phase = 0, speedFactor, chainColor, survival) {+    if (!config.signals || reducedMotion) return false;+    if (activeSignals >= config.maxBurstSignals) return false;+    const preferredCount = preferred ? preferred.length : 0;+    const candidates = preferred ? [...preferred] : [];+    for (let attempt = 0; attempt < 8; attempt += 1) {+      const start = pickStart();+      if (start) candidates.push(start);+    }+    for (let index = 0; index < candidates.length; index += 1) {+      const minLength = index < preferredCount ? 60 : 140;+      const path = buildSignalPath(tile, candidates[index], 450 + random() * 900, minLength);+      if (path) {+        path.startTrace = candidates[index].trace;+        launchSignal(path, phase, speedFactor, chainColor, survival);+        return true;+      }+    }+    return false;+  }++  // Fill the population up to maxSignals with fast-forwarded signals, so a+  // fresh board (or a tab returning to view after its chains drained) is+  // already mid-journey instead of visibly cold-starting.+  function prewarm() {+    if (!config.signals || reducedMotion || document.hidden) return;+    let guard = 0;+    while (activeSignals < config.maxSignals && guard < config.maxSignals + 2) {+      guard += 1;+      if (!spawnSignal(null, random() * 0.85)) break;+    }+  }++  // The passive fountain, like the reference site: a steady cadence of new+  // signals near the cursor (pickStart biases toward the pointer when it moved+  // recently, spreads across the board when idle), refilling toward+  // maxSignals. Because signals are mortal, the census keeps dipping below the+  // target and the timer keeps topping it up — so fresh signals keep appearing+  // at the pointer. The balance the user wants is fast spawn + short life:+  // a small signalInterval with a low hopSurvival gives a lively cursor+  // fountain; long life lets the population sit at the cap and spawn rarely.+  function startSignals() {+    window.clearInterval(signalTimer);+    if (!config.signals || reducedMotion) return;+    prewarm();+    signalTimer = window.setInterval(() => {+      if (!document.hidden && activeSignals < config.maxSignals) spawnSignal(null);+    }, config.signalInterval);+  }++  function burstAt(x, y) {+    const { dense } = ensureSignalIndex(tile);+    const candidates = [];+    const baseTileX = Math.floor(x / tile.width);+    const baseTileY = Math.floor(y / tile.height);+    for (let tileY = baseTileY - 1; tileY <= baseTileY + 1; tileY += 1) {+      for (let tileX = baseTileX - 1; tileX <= baseTileX + 1; tileX += 1) {+        if (tileX < -1 || tileX > tilesAcross || tileY < -1 || tileY > tilesDown) continue;+        const localX = x - tileX * tile.width;+        const localY = y - tileY * tile.height;+        for (let trace = 0; trace < dense.length; trace += 1) {+          let bestPoint = -1;+          let bestDistance = 40;+          for (let point = 0; point < dense[trace].length; point += 1) {+            const distance = Math.hypot(dense[trace][point][0] - localX, dense[trace][point][1] - localY);+            if (distance < bestDistance) {+              bestDistance = distance;+              bestPoint = point;+            }+          }+          if (bestPoint >= 0) candidates.push({ trace, tileX, tileY, point: bestPoint, distance: bestDistance });+        }+      }+    }+    candidates.sort((a, b) => a.distance - b.distance);+    let spawned = 0;+    for (const candidate of candidates.slice(0, Math.ceil(config.burstBoost / 2) + 1)) {+      for (const direction of [1, -1]) {+        if (activeSignals >= config.maxBurstSignals || spawned >= config.burstBoost) return;+        const path = buildSignalPath(+          tile,+          { trace: candidate.trace, tileX: candidate.tileX, tileY: candidate.tileY, point: candidate.point, direction },+          260 + random() * 380,+          50,+        );+        if (path) {+          path.startTrace = candidate.trace;+          // Clicked chains decay at burstSurvival, independent of the ambient+          // hopSurvival, so a click can ripple long even when passive life is short.+          launchSignal(path, 0, undefined, undefined, config.burstSurvival);+          spawned += 1;+        }+      }+    }+  }++  function onPointerMove(event) {+    const rect = mount.getBoundingClientRect();+    pointer = {+      x: (event.clientX - rect.left) / config.scale,+      y: (event.clientY - rect.top) / config.scale - topOffset,+      at: performance.now(),+    };+  }++  function onClick(event) {+    if (!config.interactive || !config.signals || reducedMotion) return;+    const rect = mount.getBoundingClientRect();+    burstAt(+      (event.clientX - rect.left) / config.scale,+      (event.clientY - rect.top) / config.scale - topOffset,+    );+  }++  function onVisibility() {+    if (!document.hidden) prewarm();+  }++  function regenerate(nextOptions = {}) {+    config = { ...config, ...nextOptions };+    buildSvg();+    startSignals();+    return api;+  }++  function exportSvg() {+    const clone = svg.cloneNode(true);+    clone.querySelectorAll("g[fill='none'] > g").forEach((node) => node.remove());+    clone.removeAttribute("style");+    clone.setAttribute("width", String(tilesAcross * tile.width * config.scale));+    clone.setAttribute("height", String(tilesDown * tile.height * config.scale));+    return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;+  }++  function exportTileSvg(tileOptions = {}) {+    return tileSvg(tile, { ...config, ...tileOptions });+  }++  function destroy() {+    epoch += 1;+    window.clearInterval(signalTimer);+    document.removeEventListener("visibilitychange", onVisibility);+    resizeObserver?.disconnect();+    interactionTarget.removeEventListener("pointermove", onPointerMove);+    interactionTarget.removeEventListener("click", onClick);+    svg?.remove();+    if (computedPosition === "static") mount.style.position = originalPosition;+  }++  const api = {+    regenerate,+    spawnSignal,+    exportSvg,+    exportTileSvg,+    destroy,+    get tile() {+      return tile;+    },+  };+  buildSvg();+  resizeObserver = new ResizeObserver(layout);+  resizeObserver.observe(mount);+  if (config.interactive) {+    interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });+    interactionTarget.addEventListener("click", onClick);+  }+  // Hidden tabs let the census drain (the timer no-ops); refill on return.+  document.addEventListener("visibilitychange", onVisibility);+  startSignals();+  return api;+}++// Named animation presets. Each is a partial config merged over the current+// settings; only signal/animation knobs are touched so generation geometry is+// left alone. `signals: false` gives a fully static board.+const ANIMATION_PRESETS = Object.freeze({+  "None": { signals: false },+  "Fireflies": {+    // A few long-lived signals drifting/bouncing across the whole screen.+    signals: true,+    signalInterval: 850,+    maxSignals: 5,+    spawnRadius: 200,+    hopSurvival: 0.92,+    burstSurvival: 0.9,+    colorDrift: 0.12,+  },+  "Swarm": {+    // Same persistent drift, but a dense population.+    signals: true,+    signalInterval: 360,+    maxSignals: 18,+    spawnRadius: 200,+    hopSurvival: 0.9,+    burstSurvival: 0.9,+    colorDrift: 0.18,+  },+  "Cursor sparks": {+    // Really short life + very fast spawning tight around the cursor.+    signals: true,+    signalInterval: 90,+    maxSignals: 14,+    spawnRadius: 35,+    hopSurvival: 0.1,+    burstSurvival: 0.6,+    colorDrift: 0.15,+  },+  "Slow streams": {+    // Longer-lived flowing chains at a relaxed cadence.+    signals: true,+    signalInterval: 560,+    maxSignals: 8,+    spawnRadius: 130,+    hopSurvival: 0.72,+    burstSurvival: 0.82,+    colorDrift: 0.2,+  },+});++export { DEFAULTS, ANIMATION_PRESETS };
public/vendor/circuit-trace-generators/circuit-geometry-v9.jsadded+1112
@@ -0,0 +1,1112 @@+import { createConductorAnimation } from "./circuit-animation-conductor.js?v=1";+import {+  createObstacleField,+  obstacleEdgeWeight,+  obstacleOutlineTraces,+} from "./circuit-obstacles-v9.js?v=1";++const SVG_NS = "http://www.w3.org/2000/svg";++const DEFAULTS = Object.freeze({+  seed: "nitrogen",+  pitch: 7.895,+  scale: 1000 / 900,+  density: 1,+  fill: 0.668,+  routeLength: 0.78,+  horizontalFlow: 0.82,+  flowAngle: 0,+  swirl: 0.28,+  swirlScale: 0.8,+  diagonalFlow: 0.58,+  jogLength: 0.72,+  channelAttraction: 0.78,+  longFlow: 0.18,+  edgeDensity: 0.22,+  obstaclePreset: "none",+  obstacleMode: "avoid",+  obstacleScale: 0.66,+  obstacleX: 0.5,+  obstacleY: 0.66,+  obstacleClearance: 1.1,+  obstaclePull: 0.82,+  obstacles: Object.freeze([]),+  traceColor: "#151b25",+  traceWidth: 1.58,+  featureScale: 1,+  viaOuterRadius: 3.34,+  viaInnerRadius: 1.76,+  motion: true,+  interactive: true,+  animationSystem: "conductor",+  liveliness: 0.82,+  signalHalos: true,+  signalConvoys: true,+  signalWaves: true,+  pointerWake: true,+  nodeFlashes: true,+  ambientCurrents: true,+  ambientNodes: true,+  signalColors: [+    ["#00e6dc", 0.6],+    ["#64abff", 0.25],+    ["#c5b3ff", 0.15],+  ],+  signalInterval: 420,+  signalSpeed: 800,+  signalLength: 110,+  maxSignals: 28,+  ambientCurrentCount: 18,+  ambientNodeCount: 22,+  waveInterval: 6200,+  pointerWakeInterval: 170,+  sweepDuration: 15200,+  sweepAngle: 18,+  sweepWidth: 0.075,+  bloomInterval: 7600,+  bloomDuration: 4800,+  pointerRadius: 0.19,+  atmosphereStrength: 1,+});++const ANIMATION_PRESETS = Object.freeze({+  none: Object.freeze({+    animationSystem: "none",+    motion: false,+    liveliness: 0,+    atmosphereStrength: 0,+  }),+  conductor: Object.freeze({+    animationSystem: "conductor",+    motion: true,+    liveliness: 0.82,+    signalHalos: true,+    signalConvoys: true,+    signalWaves: true,+    pointerWake: true,+    nodeFlashes: true,+    ambientCurrents: true,+    ambientNodes: true,+    signalColors: [+      ["#00e6dc", 0.6],+      ["#64abff", 0.25],+      ["#c5b3ff", 0.15],+    ],+    signalInterval: 420,+    signalSpeed: 800,+    signalLength: 110,+    maxSignals: 28,+    ambientCurrentCount: 18,+    ambientNodeCount: 22,+    waveInterval: 6200,+    pointerWakeInterval: 170,+    atmosphereStrength: 1,+  }),+  adom: Object.freeze({+    animationSystem: "adom",+    motion: true,+    liveliness: 0.88,+    sweepDuration: 15200,+    sweepAngle: 18,+    sweepWidth: 0.075,+    bloomInterval: 7600,+    bloomDuration: 4800,+    pointerRadius: 0.19,+    atmosphereStrength: 1,+  }),+});++const DIRECTIONS = [[1, 0], [0, 1], [-1, 0], [0, -1]];+let instanceCount = 0;++function hashSeed(value) {+  let hash = 2166136261;+  for (const char of String(value)) {+    hash ^= char.charCodeAt(0);+    hash = Math.imul(hash, 16777619);+  }+  return hash >>> 0;+}++function mulberry32(seed) {+  return function random() {+    let value = (seed += 0x6d2b79f5);+    value = Math.imul(value ^ (value >>> 15), value | 1);+    value ^= value + Math.imul(value ^ (value >>> 7), value | 61);+    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;+  };+}++function integer(random, min, max) {+  return Math.floor(random() * (max - min + 1)) + min;+}++function weighted(random, entries) {+  let total = 0;+  for (const [, weight] of entries) total += weight;+  let roll = random() * total;+  for (const [value, weight] of entries) {+    roll -= weight;+    if (roll <= 0) return value;+  }+  return entries.at(-1)?.[0];+}++function shuffle(random, values) {+  for (let index = values.length - 1; index > 0; index -= 1) {+    const swap = integer(random, 0, index);+    [values[index], values[swap]] = [values[swap], values[index]];+  }+  return values;+}++function cellIndex(x, y, columns) {+  return y * columns + x;+}++function runLength(random, perpendicular, config, vector = [1, 0]) {+  const localDiagonalness = diagonalness(vector);+  let result;+  if (perpendicular) {+    result = random() < 0.22 ? integer(random, 4, 7) : integer(random, 1, 4);+    const scale = config.jogLength + (1 - config.jogLength) * localDiagonalness;+    return Math.max(1, Math.round(result * scale));+  }+  const roll = random();+  if (roll < 0.14) result = integer(random, 16, 28);+  else if (roll < 0.52) result = integer(random, 9, 16);+  else result = integer(random, 4, 10);+  const scale = 1 - localDiagonalness * config.diagonalFlow * 0.9;+  return Math.max(1, Math.round(result * scale));+}++function traceBudget(random, columns, rows, config) {+  const ceiling = Math.max(12, Math.min(86, Math.floor(Math.hypot(columns, rows) * 0.52)));+  const roll = random();+  let result;+  if (roll < 0.2) result = integer(random, Math.min(42, ceiling), ceiling);+  else if (roll < 0.65) result = integer(random, 24, Math.min(50, ceiling));+  else result = integer(random, 12, Math.min(28, ceiling));+  return Math.max(6, Math.round(result * config.routeLength));+}++function createFlowField(config, columns, rows) {+  const random = mulberry32(hashSeed(`${config.seed}:${columns}x${rows}:flow-field`));+  const minimumDimension = Math.min(columns, rows);+  const count = Math.max(2, Math.min(5, Math.round(columns * rows / 5200)));+  const vortices = [];+  for (let index = 0; index < count; index += 1) {+    const radius = minimumDimension * config.swirlScale * (0.55 + random() * 0.45);+    vortices.push({+      x: columns * (0.08 + random() * 0.84),+      y: rows * (0.08 + random() * 0.84),+      radius: Math.max(8, radius),+      spin: random() < 0.5 ? -1 : 1,+      strength: 0.72 + random() * 0.56,+    });+  }+  return { vortices };+}++function flowVector(x, y, angleDegrees, field, config) {+  const radians = angleDegrees * Math.PI / 180;+  let vx = Math.cos(radians);+  let vy = Math.sin(radians);+  for (const vortex of field.vortices) {+    const dx = x - vortex.x;+    const dy = y - vortex.y;+    const distance = Math.hypot(dx, dy);+    if (distance < 0.0001) continue;+    const falloff = Math.exp(-(distance * distance) / (2 * vortex.radius * vortex.radius));+    const influence = config.swirl * vortex.strength * falloff;+    vx += -dy / distance * vortex.spin * influence;+    vy += dx / distance * vortex.spin * influence;+  }+  const length = Math.hypot(vx, vy) || 1;+  return [vx / length, vy / length];+}++function closestDirection(vector) {+  let bestDirection = 0;+  let bestScore = -Infinity;+  for (let direction = 0; direction < DIRECTIONS.length; direction += 1) {+    const score = DIRECTIONS[direction][0] * vector[0] + DIRECTIONS[direction][1] * vector[1];+    if (score > bestScore) {+      bestScore = score;+      bestDirection = direction;+    }+  }+  return bestDirection;+}++function diagonalness(vector) {+  return 1 - Math.abs(Math.abs(vector[0]) - Math.abs(vector[1]));+}++function lookAhead(x, y, direction, blocked, local, columns, rows) {+  const [dx, dy] = DIRECTIONS[direction];+  let open = 0;+  for (let step = 1; step <= 5; step += 1) {+    const nx = x + dx * step;+    const ny = y + dy * step;+    if (nx < 0 || ny < 0 || nx >= columns || ny >= rows) break;+    const index = cellIndex(nx, ny, columns);+    if (blocked[index] || local.has(index)) break;+    open += 1;+  }+  return open;+}++function growOne(start, initialDirection, limit, blocked, local, columns, rows, random, config, field, angleDegrees, travelSign = 1, obstacle = null) {+  const points = [start];+  let [x, y] = start;+  let direction = initialDirection;+  const initialVector = flowVector(x, y, angleDegrees, field, config);+  const initialTarget = [initialVector[0] * travelSign, initialVector[1] * travelSign];+  let remaining = runLength(random, false, config, initialTarget);++  while (points.length <= limit) {+    const candidates = [];+    const directions = [direction, (direction + 1) % 4, (direction + 3) % 4];+    const sampledVector = flowVector(x, y, angleDegrees, field, config);+    const targetVector = [sampledVector[0] * travelSign, sampledVector[1] * travelSign];+    const primaryDirection = closestDirection(targetVector);+    for (const candidate of directions) {+      const [dx, dy] = DIRECTIONS[candidate];+      const nx = x + dx;+      const ny = y + dy;+      // Keep every endpoint (and therefore every via) fully inside the canvas.+      if (nx < 1 || ny < 1 || nx >= columns - 1 || ny >= rows - 1) continue;+      const index = cellIndex(nx, ny, columns);+      if (blocked[index] || local.has(index)) continue;+      const open = lookAhead(x, y, candidate, blocked, local, columns, rows);+      if (!open) continue;+      const straight = candidate === direction;+      let weight = straight ? (remaining > 0 ? 15 : 1.6) : (remaining > 0 ? 0.12 : 4.2);+      const alignment = dx * targetVector[0] + dy * targetVector[1];+      if (alignment < -0.16) continue;+      weight *= 0.45 + open * 0.34;+      let neighbors = 0;+      if (candidate % 2 === 0) {+      weight *= 0.45 + Math.max(0, alignment) * 2.2;+        if (ny > 0 && blocked[cellIndex(nx, ny - 1, columns)]) neighbors += 1;+        if (ny < rows - 1 && blocked[cellIndex(nx, ny + 1, columns)]) neighbors += 1;+      } else {+        if (nx > 0 && blocked[cellIndex(nx - 1, ny, columns)]) neighbors += 1;+        if (nx < columns - 1 && blocked[cellIndex(nx + 1, ny, columns)]) neighbors += 1;+      }+      weight *= 1 + neighbors * config.channelAttraction;+      weight *= obstacleEdgeWeight(+        obstacle,+        nx,+        ny,+        dx,+        dy,+        columns,+        Math.max(0, Number(config.obstaclePull) || 0),+      );+      candidates.push([candidate, weight]);+    }+    if (!candidates.length) break;+    const nextDirection = weighted(random, candidates);+    if (nextDirection !== direction) {+      direction = nextDirection;+      remaining = runLength(random, direction % 2 !== primaryDirection % 2, config, targetVector);+    } else {+      remaining -= 1;+    }+    x += DIRECTIONS[direction][0];+    y += DIRECTIONS[direction][1];+    const index = cellIndex(x, y, columns);+    local.add(index);+    points.push([x, y]);+  }+  return points;+}++function chamfer(path) {+  if (path.length < 3) return path;+  const result = [path[0]];+  for (let index = 1; index < path.length - 1; index += 1) {+    const previous = path[index - 1];+    const current = path[index];+    const next = path[index + 1];+    const ax = current[0] - previous[0];+    const ay = current[1] - previous[1];+    const bx = next[0] - current[0];+    const by = next[1] - current[1];+    if (ax * bx + ay * by === 0 && (ax !== bx || ay !== by)) continue;+    result.push(current);+  }+  result.push(path.at(-1));+  return result;+}++function simplify(path) {+  if (path.length < 3) return path;+  const result = [path[0]];+  for (let index = 1; index < path.length - 1; index += 1) {+    const previous = result.at(-1);+    const current = path[index];+    const next = path[index + 1];+    const ax = Math.sign(current[0] - previous[0]);+    const ay = Math.sign(current[1] - previous[1]);+    const bx = Math.sign(next[0] - current[0]);+    const by = Math.sign(next[1] - current[1]);+    if (ax !== bx || ay !== by) result.push(current);+  }+  result.push(path.at(-1));+  return result;+}++function pathLength(points) {+  let length = 0;+  for (let index = 1; index < points.length; index += 1) {+    length += Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]);+  }+  return length;+}++function commitTrace(cells, state) {+  if (cells.length < 6) return false;+  const { blocked, columns, rows, pitch, traces } = state;+  for (const [x, y] of cells) blocked[cellIndex(x, y, columns)] = 1;+  state.occupied += cells.length;+  const shaped = simplify(chamfer(cells));+  const points = shaped.map(([x, y]) => [x * pitch, y * pitch]);+  traces.push({+    points,+    length: pathLength(points),+    viaStart: true,+    viaEnd: true,+  });+  return true;+}++function makeInteriorTrace(start, state, random, config) {+  const { blocked, columns, rows } = state;+  const startIndex = cellIndex(start[0], start[1], columns);+  if (blocked[startIndex]) return false;+  const followsPrimary = random() < config.horizontalFlow;+  const angleDegrees = config.flowAngle + (followsPrimary ? 0 : 90);+  const startVector = flowVector(start[0], start[1], angleDegrees, state.flowField, config);+  const direction = closestDirection(startVector);+  const budget = traceBudget(random, columns, rows, config);+  const forwardBudget = Math.max(3, Math.floor(budget * (0.42 + random() * 0.16)));+  const local = new Set([startIndex]);+  const forward = growOne(start, direction, forwardBudget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, 1, state.obstacle);+  const backward = growOne(start, (direction + 2) % 4, budget - forwardBudget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, -1, state.obstacle);+  const cells = backward.slice(1).reverse().concat(forward);+  return commitTrace(cells, state);+}++function makeEdgeTrace(start, direction, state, random, config, options = {}) {+  const { blocked, columns, rows } = state;+  const startIndex = cellIndex(start[0], start[1], columns);+  if (blocked[startIndex]) return false;+  const local = new Set([startIndex]);+  const baseBudget = integer(random, 10, Math.max(14, Math.min(48, Math.floor(Math.hypot(columns, rows) * 0.3))));+  const budget = options.budget ?? Math.max(6, Math.round(baseBudget * config.routeLength));+  const angleDegrees = options.angleDegrees ?? config.flowAngle + (random() < config.horizontalFlow ? 0 : 90);+  const sampledVector = flowVector(start[0], start[1], angleDegrees, state.flowField, config);+  const inward = DIRECTIONS[direction][0] * sampledVector[0] + DIRECTIONS[direction][1] * sampledVector[1];+  const cells = growOne(start, direction, budget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, inward >= 0 ? 1 : -1, state.obstacle);+  if (cells.length < (options.minimumLength ?? 6)) return false;+  return commitTrace(cells, state);+}++export function generateCircuitField(options = {}) {+  const config = { ...DEFAULTS, ...options };+  const width = Math.max(240, config.width || 900);+  const height = Math.max(180, config.height || 450);+  const featureScale = Math.max(0.25, config.featureScale);+  const pitch = config.pitch * featureScale;+  const columns = Math.max(30, Math.floor(width / pitch));+  const rows = Math.max(22, Math.floor(height / pitch));+  const random = mulberry32(hashSeed(`${config.seed}:${columns}x${rows}`));+  const normalizedWidth = (columns - 1) * pitch;+  const normalizedHeight = (rows - 1) * pitch;+  const obstacle = createObstacleField(config, {+    width: normalizedWidth,+    height: normalizedHeight,+    pitch,+    columns,+    rows,+  });+  const state = {+    blocked: obstacle ? obstacle.blocked.slice() : new Uint8Array(columns * rows),+    columns,+    rows,+    pitch,+    traces: [],+    occupied: 0,+    obstacle,+    flowField: createFlowField(config, columns, rows),+  };+  const outlines = obstacleOutlineTraces(obstacle, {+    pitch,+    viaOuterRadius: config.viaOuterRadius * featureScale,+  });+  state.traces.push(...outlines);++  const flowRadians = config.flowAngle * Math.PI / 180;+  const absoluteX = Math.abs(Math.cos(flowRadians));+  const absoluteY = Math.abs(Math.sin(flowRadians));+  const primaryHorizontalWeight = absoluteX / (absoluteX + absoluteY || 1);+  const horizontalWeight = config.horizontalFlow * primaryHorizontalWeight + (1 - config.horizontalFlow) * (1 - primaryHorizontalWeight);+  const verticalWeight = 1 - horizontalWeight;+  const horizontalCarriers = [];+  for (let y = 1; y < rows - 1; y += 1) {+    horizontalCarriers.push([[1, y], 0], [[columns - 2, y], 2]);+  }+  shuffle(random, horizontalCarriers);+  const horizontalTarget = Math.round(rows * config.longFlow * horizontalWeight);+  let horizontalCarrierCount = 0;+  for (const [start, direction] of horizontalCarriers) {+    if (horizontalCarrierCount >= horizontalTarget) break;+    const minimumBudget = Math.max(12, Math.round(columns * 0.58 * config.routeLength));+    const maximumBudget = Math.max(minimumBudget, Math.round(columns * 1.02 * config.routeLength));+    const budget = integer(random, minimumBudget, maximumBudget);+    if (makeEdgeTrace(start, direction, state, random, config, {+      budget,+      angleDegrees: config.flowAngle,+      minimumLength: Math.round(columns * 0.3),+    })) horizontalCarrierCount += 1;+  }++  const verticalCarriers = [];+  for (let x = 1; x < columns - 1; x += 1) {+    verticalCarriers.push([[x, 1], 1], [[x, rows - 2], 3]);+  }+  shuffle(random, verticalCarriers);+  const verticalTarget = Math.round(columns * config.longFlow * verticalWeight * 0.34);+  let verticalCarrierCount = 0;+  for (const [start, direction] of verticalCarriers) {+    if (verticalCarrierCount >= verticalTarget) break;+    const minimumBudget = Math.max(12, Math.round(rows * 0.58 * config.routeLength));+    const maximumBudget = Math.max(minimumBudget, Math.round(rows * 1.02 * config.routeLength));+    const budget = integer(random, minimumBudget, maximumBudget);+    if (makeEdgeTrace(start, direction, state, random, config, {+      angleDegrees: config.flowAngle,+      budget,+      minimumLength: Math.round(rows * 0.3),+    })) verticalCarrierCount += 1;+  }++  const edgeSeeds = [];+  for (let x = 1; x < columns - 1; x += 1) {+    edgeSeeds.push([[x, 1], 1], [[x, rows - 2], 3]);+  }+  for (let y = 1; y < rows - 1; y += 1) {+    edgeSeeds.push([[1, y], 0], [[columns - 2, y], 2]);+  }+  shuffle(random, edgeSeeds);+  const edgeTarget = Math.round((columns + rows) * config.edgeDensity);+  let edgeCount = 0;+  for (const [start, direction] of edgeSeeds) {+    if (edgeCount >= edgeTarget) break;+    if (makeEdgeTrace(start, direction, state, random, config)) edgeCount += 1;+  }++  const interiorColumns = columns - 2;+  const interiorRows = rows - 2;+  const seeds = shuffle(random, Array.from(+    { length: interiorColumns * interiorRows },+    (_, index) => (Math.floor(index / interiorColumns) + 1) * columns + index % interiorColumns + 1,+  ));+  // Density controls occupied routing cells only. Feature scale changes the+  // physical size of the whole motif without secretly changing this target.+  const targetFill = Math.min(0.82, Math.max(0.18, config.fill * config.density));+  const fillCapacity = obstacle+    ? Math.max(1, interiorColumns * interiorRows - obstacle.blockedCells)+    : columns * rows;+  for (const index of seeds) {+    if (state.occupied / fillCapacity >= targetFill) break;+    if (state.blocked[index]) continue;+    makeInteriorTrace([index % columns, Math.floor(index / columns)], state, random, config);+  }++  return {+    width: normalizedWidth,+    height: normalizedHeight,+    columns,+    rows,+    fill: state.occupied / fillCapacity,+    traces: state.traces,+    obstacle: obstacle ? Object.freeze({+      count: obstacle.count,+      blockedCells: obstacle.blockedCells,+      outlineTraceCount: outlines.length,+      mode: obstacle.mode,+    }) : null,+  };+}++export function pathData(points) {+  return points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");+}++function svgElement(name, attributes = {}) {+  const element = document.createElementNS(SVG_NS, name);+  for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));+  return element;+}++export function createCircuitBackground(mount, options = {}) {+  if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");+  const id = `circuit-field-${++instanceCount}`;+  let config = { ...DEFAULTS, ...options };+  let field;+  let svg;+  let motionCanvas;+  let motionContext;+  let brandGradient;+  let conductor;+  let resizeObserver;+  let resizeTimer;+  let frameRequest;+  let frameCount = 0;+  let activeTraces = 0;+  let bloomsTriggered = 0;+  let visuals = [];+  let blooms = [];+  let pointer = null;+  let motionRandom;+  let lastSize = "";+  let sceneStart = 0;+  let lastFrameTime = 0;+  let lastPaintTime = 0;+  let nextBloomAt = 0;+  let bloomCursor = 0;+  let focus = { x: 0, y: 0 };+  let rendererPixelRatio = 1;+  let targetFrameRate = 60;+  let frameInterval = 1000 / targetFrameRate;+  let energyBudget = 0;+  let sparkBudget = 0;+  let nodeBudget = 0;+  let drawnEnergyTraces = 0;+  let drawnSparks = 0;+  let drawnNodes = 0;+  const energyCandidates = [];+  const sparkCandidates = [];+  const nodeCandidates = [];+  const interactionTarget = mount.parentElement || mount;+  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;+  const originalPosition = mount.style.position;+  if (getComputedStyle(mount).position === "static") mount.style.position = "relative";+  mount.style.overflow = "hidden";++  function life() {+    return Math.max(0, Math.min(1.5, config.liveliness));+  }++  function featureScale() {+    return Math.max(0.25, config.featureScale);+  }++  function clamp(value, minimum = 0, maximum = 1) {+    return Math.max(minimum, Math.min(maximum, value));+  }++  function colorAt(position) {+    if (position < 0.46) return "#00e6dc";+    if (position < 0.76) return "#39b8ff";+    return "#8c6bf7";+  }++  function addVia(group, point) {+    const scale = featureScale();+    group.append(+      svgElement("circle", { cx: point[0], cy: point[1], r: config.viaOuterRadius * scale, fill: config.traceColor }),+      svgElement("circle", { cx: point[0], cy: point[1], r: config.viaInnerRadius * scale, fill: "var(--circuit-hole, #0d1117)" }),+    );+  }++  function build(force = false) {+    const pixelWidth = Math.max(1, mount.clientWidth);+    const pixelHeight = Math.max(1, mount.clientHeight);+    const worldWidth = pixelWidth / config.scale;+    const worldHeight = pixelHeight / config.scale;+    const pitch = config.pitch * featureScale();+    const size = `${Math.floor(worldWidth / pitch)}x${Math.floor(worldHeight / pitch)}`;+    if (!force && size === lastSize) return false;+    lastSize = size;+    activeTraces = 0;+    visuals = [];+    blooms = [];+    conductor?.destroy();+    conductor = undefined;+    motionCanvas?.remove();+    motionCanvas = undefined;+    motionContext = undefined;+    brandGradient = undefined;+    svg?.remove();+    field = generateCircuitField({ ...config, width: worldWidth, height: worldHeight });+    const motionSeed = config.animationSystem === "conductor"+      ? `${config.seed}:${size}:signals`+      : `${config.seed}:${size}:adom-motion`;+    motionRandom = mulberry32(hashSeed(motionSeed));+    svg = svgElement("svg", {+      id, "aria-hidden": true, focusable: false,+      viewBox: `0 0 ${field.width} ${field.height}`,+      preserveAspectRatio: "none",+    });+    Object.assign(svg.style, {+      position: "absolute", inset: "0", width: "100%", height: "100%",+      display: "block", pointerEvents: "none",+    });++    const traceLayer = svgElement("g", { "data-static-traces": "" });+    for (const trace of field.traces) {+      traceLayer.appendChild(svgElement("path", {+        d: pathData(trace.points), fill: "none", stroke: config.traceColor,+        "stroke-width": config.traceWidth * config.featureScale,+        "stroke-linecap": "butt", "stroke-linejoin": "miter",+      }));+      if (trace.viaStart) addVia(traceLayer, trace.points[0]);+      if (trace.viaEnd) addVia(traceLayer, trace.points.at(-1));+    }++    if (config.animationSystem === "none") {+      svg.append(traceLayer);+      mount.prepend(svg);+      return true;+    }++    if (config.animationSystem === "conductor") {+      svg.append(traceLayer);+      mount.prepend(svg);+      conductor = createConductorAnimation(svg, field, config, motionRandom);+      return true;+    }++    const angle = config.sweepAngle * Math.PI / 180;+    const axisX = Math.cos(angle);+    const axisY = Math.sin(angle);+    let projectionMin = Infinity;+    let projectionMax = -Infinity;+    const prepared = field.traces.map((trace, index) => {+      const midpoint = trace.points[Math.floor(trace.points.length / 2)];+      const projection = midpoint[0] * axisX + midpoint[1] * axisY;+      projectionMin = Math.min(projectionMin, projection);+      projectionMax = Math.max(projectionMax, projection);+      return { trace, index, midpoint, projection };+    });+    const projectionSpan = Math.max(1, projectionMax - projectionMin);+    const scale = featureScale();++    for (const preparedTrace of prepared) {+      const { trace, index, midpoint } = preparedTrace;+      const normalizedProjection = (preparedTrace.projection - projectionMin) / projectionSpan;+      const directionBias = trace.points[0][0] <= trace.points.at(-1)[0] ? 1 : -1;+      const sparkLength = clamp(trace.length * (0.1 + motionRandom() * 0.06), 10 * scale, 58 * scale);+      const endpoint = trace.points[(index + Math.round(normalizedProjection * 5)) % 2 ? 0 : trace.points.length - 1];+      let canvasPath = null;+      if (typeof Path2D === "function") {+        canvasPath = new Path2D();+        trace.points.forEach(([x, y], pointIndex) => {+          if (pointIndex) canvasPath.lineTo(x, y);+          else canvasPath.moveTo(x, y);+        });+      }+      visuals.push({+        trace, midpoint, projection: normalizedProjection,+        canvasPath, endpoint, nodeColor: colorAt(normalizedProjection),+        sparkLength, directionBias,+        charge: 0, phase: motionRandom(), sparkEligible: motionRandom() > 0.34,+        energyOpacity: 0, sparkOpacity: 0, nodeOpacity: 0,+      });+    }++    svg.append(traceLayer);+    mount.prepend(svg);++    const traceCount = visuals.length;+    targetFrameRate = traceCount > 1500 ? 24 : traceCount > 900 ? 30 : traceCount > 420 ? 45 : 60;+    frameInterval = 1000 / targetFrameRate;+    energyBudget = traceCount > 1500 ? 420 : traceCount > 900 ? 500 : traceCount > 420 ? 620 : traceCount;+    sparkBudget = traceCount > 1500 ? 28 : traceCount > 900 ? 36 : traceCount > 420 ? 46 : 58;+    nodeBudget = traceCount > 1500 ? 48 : traceCount > 900 ? 64 : traceCount > 420 ? 82 : 104;+    const deviceRatio = Math.max(1, window.devicePixelRatio || 1);+    rendererPixelRatio = Math.min(deviceRatio, traceCount > 1200 ? 1 : traceCount > 600 ? 1.25 : 1.5);++    motionCanvas = document.createElement("canvas");+    motionCanvas.setAttribute("aria-hidden", "true");+    motionCanvas.setAttribute("data-animation", "routed-field-canvas");+    Object.assign(motionCanvas.style, {+      position: "absolute", inset: "0", width: "100%", height: "100%",+      display: "block", pointerEvents: "none", mixBlendMode: "screen", zIndex: "1",+    });+    motionCanvas.width = Math.max(1, Math.round(pixelWidth * rendererPixelRatio));+    motionCanvas.height = Math.max(1, Math.round(pixelHeight * rendererPixelRatio));+    mount.appendChild(motionCanvas);+    motionContext = motionCanvas.getContext("2d", { alpha: true, desynchronized: true })+      || motionCanvas.getContext("2d");+    if (motionContext) {+      motionContext.setTransform(+        motionCanvas.width / field.width, 0,+        0, motionCanvas.height / field.height, 0, 0,+      );+      brandGradient = motionContext.createLinearGradient(+        0, field.height * 0.15, field.width, field.height * 0.85,+      );+      brandGradient.addColorStop(0, "#00e6dc");+      brandGradient.addColorStop(0.6, "#39b8ff");+      brandGradient.addColorStop(1, "#8c6bf7");+    }+    focus = { x: field.width * 0.5, y: field.height * 0.5 };+    return true;+  }++  function spawnBloom(origin = null, intensity = 1) {+    if (config.animationSystem !== "adom" || !config.motion || reducedMotion || !field?.traces.length) return false;+    const source = origin || { x: field.width * 0.24, y: field.height * 0.62 };+    const angle = Math.atan2(source.y - field.height * 0.5, source.x - field.width * 0.5);+    blooms.push({+      x: clamp(source.x, 0, field.width),+      y: clamp(source.y, 0, field.height),+      startedAt: performance.now(),+      duration: config.bloomDuration / clamp(life(), 0.55, 1.35),+      intensity: clamp(intensity, 0.35, 1.4),+      spin: angle / (Math.PI * 2) + motionRandom() * 0.18,+    });+    if (blooms.length > 3) blooms.shift();+    bloomsTriggered += 1;+    return true;+  }++  function spawnAutomaticBloom() {+    if (!visuals.length) return;+    const stride = Math.max(1, Math.floor(visuals.length * 0.381966));+    bloomCursor = (bloomCursor + stride) % visuals.length;+    const anchor = visuals[bloomCursor].midpoint;+    spawnBloom({ x: anchor[0], y: anchor[1] }, 0.78 + motionRandom() * 0.18);+  }++  function energyFromBloom(visual, bloom, timestamp, maximumDimension) {+    const progress = (timestamp - bloom.startedAt) / bloom.duration;+    if (progress < 0 || progress > 1) return 0;+    const dx = visual.midpoint[0] - bloom.x;+    const dy = visual.midpoint[1] - bloom.y;+    const distance = Math.hypot(dx, dy) / maximumDimension;+    const angle = (Math.atan2(dy, dx) / (Math.PI * 2) + 1 + bloom.spin) % 1;+    const arrival = distance * 1.08 + angle * 0.105;+    const distanceFromFront = progress - arrival;+    const crest = Math.exp(-(distanceFromFront * distanceFromFront) / (2 * 0.052 * 0.052));+    const afterglow = distanceFromFront > 0 && distanceFromFront < 0.24+      ? (1 - distanceFromFront / 0.24) ** 2 * 0.23+      : 0;+    return (crest + afterglow) * bloom.intensity;+  }++  function onPointerMove(event) {+    if (!field) return;+    const rect = mount.getBoundingClientRect();+    pointer = {+      x: (event.clientX - rect.left) / rect.width * field.width,+      y: (event.clientY - rect.top) / rect.height * field.height,+      at: performance.now(),+    };+    conductor?.pointerMoved(pointer);+  }++  function onPointerLeave() {+    if (pointer) pointer.at = performance.now() - 1800;+  }++  function onClick(event) {+    onPointerMove(event);+    if (config.animationSystem === "conductor") conductor?.clicked(pointer);+    else spawnBloom(pointer, 1.18);+  }++  function strokeCanvasPath(context, visual) {+    if (visual.canvasPath) {+      context.stroke(visual.canvasPath);+      return;+    }+    context.beginPath();+    visual.trace.points.forEach(([x, y], index) => {+      if (index) context.lineTo(x, y);+      else context.moveTo(x, y);+    });+    context.stroke();+  }++  function renderFrame(timestamp) {+    if (!motionCanvas?.isConnected || !motionContext || !visuals.length) return;+    frameRequest = window.requestAnimationFrame(renderFrame);+    if (document.hidden) return;+    const sincePaint = lastPaintTime ? timestamp - lastPaintTime : frameInterval;+    if (sincePaint < frameInterval) return;+    lastPaintTime = lastPaintTime+      ? timestamp - (sincePaint % frameInterval)+      : timestamp;+    frameCount += 1;+    const frameDelta = lastFrameTime ? clamp(timestamp - lastFrameTime, 8, 50) : 16;+    lastFrameTime = timestamp;++    const strength = life();+    const maximumDimension = Math.max(field.width, field.height);+    const elapsed = timestamp - sceneStart;+    const sweepPhase = ((elapsed % config.sweepDuration) + config.sweepDuration) % config.sweepDuration / config.sweepDuration;+    const sweepActive = sweepPhase < 0.73;+    const sweepFront = sweepActive ? -0.14 + sweepPhase / 0.73 * 1.28 : 2;+    const sweepWidth = config.sweepWidth / clamp(strength, 0.65, 1.25);++    if (timestamp >= nextBloomAt) {+      spawnAutomaticBloom();+      nextBloomAt = timestamp + config.bloomInterval / clamp(0.72 + strength * 0.28, 0.72, 1.12);+    }+    blooms = blooms.filter((bloom) => timestamp - bloom.startedAt <= bloom.duration);++    const pointerAge = pointer ? timestamp - pointer.at : Infinity;+    const pointerPresence = clamp(1 - pointerAge / 1450);+    const automaticFocus = {+      x: field.width * (0.5 + Math.sin(elapsed * 0.000095) * 0.27),+      y: field.height * (0.48 + Math.sin(elapsed * 0.000071 + 1.4) * 0.21),+    };+    const focusTarget = pointerPresence > 0.02 ? pointer : automaticFocus;+    const focusEase = pointerPresence > 0.02 ? 0.105 : 0.018;+    focus.x += (focusTarget.x - focus.x) * focusEase;+    focus.y += (focusTarget.y - focus.y) * focusEase;++    energyCandidates.length = 0;+    sparkCandidates.length = 0;+    nodeCandidates.length = 0;+    let active = 0;+    for (const visual of visuals) {+      const delta = sweepFront - visual.projection;+      const crest = sweepActive+        ? Math.exp(-(delta * delta) / (2 * sweepWidth * sweepWidth))+        : 0;+      const trail = sweepActive && delta > 0 && delta < 0.28+        ? (1 - delta / 0.28) ** 2 * 0.24+        : 0;+      let bloomEnergy = 0;+      for (const bloom of blooms) {+        bloomEnergy = Math.max(bloomEnergy, energyFromBloom(visual, bloom, timestamp, maximumDimension));+      }+      let pointerEnergy = 0;+      if (pointerPresence > 0.001) {+        const distance = Math.hypot(visual.midpoint[0] - pointer.x, visual.midpoint[1] - pointer.y);+        const radius = maximumDimension * config.pointerRadius;+        pointerEnergy = Math.exp(-(distance * distance) / (2 * radius * radius)) * pointerPresence * 0.42;+      }+      const ambient = 0.018 + (Math.sin(timestamp * 0.0005 + visual.phase * Math.PI * 2) + 1) * 0.006;+      const target = clamp((crest * 0.96 + trail + bloomEnergy + pointerEnergy + ambient) * strength, 0, 1.18);+      const response = target > visual.charge ? 1 - Math.exp(-frameDelta / 72) : 1 - Math.exp(-frameDelta / 610);+      visual.charge += (target - visual.charge) * response;++      const energyOpacity = clamp((visual.charge - 0.018) * 0.72, 0, 0.74);+      const sparkOpacity = visual.sparkEligible+        ? clamp((visual.charge - 0.25) * 1.48, 0, 0.92)+        : 0;+      const nodePulse = 0.82 + Math.sin(timestamp * 0.0031 + visual.phase * Math.PI * 2) * 0.18;+      const nodeOpacity = clamp((visual.charge - 0.34) * 1.65 * nodePulse, 0, 0.88);++      visual.energyOpacity = energyOpacity;+      visual.sparkOpacity = sparkOpacity;+      visual.nodeOpacity = nodeOpacity;+      if (energyOpacity > 0.008) energyCandidates.push(visual);+      if (sparkOpacity > 0.01) sparkCandidates.push(visual);+      if (nodeOpacity > 0.01) nodeCandidates.push(visual);+      if (visual.charge > 0.13) active += 1;+    }+    activeTraces = active;++    energyCandidates.sort((a, b) => b.energyOpacity - a.energyOpacity);+    sparkCandidates.sort((a, b) => b.sparkOpacity - a.sparkOpacity);+    nodeCandidates.sort((a, b) => b.nodeOpacity - a.nodeOpacity);+    drawnEnergyTraces = Math.min(energyBudget, energyCandidates.length);+    drawnSparks = Math.min(sparkBudget, sparkCandidates.length);+    drawnNodes = Math.min(nodeBudget, nodeCandidates.length);++    const context = motionContext;+    context.setTransform(1, 0, 0, 1, 0, 0);+    context.clearRect(0, 0, motionCanvas.width, motionCanvas.height);+    context.setTransform(+      motionCanvas.width / field.width, 0,+      0, motionCanvas.height / field.height, 0, 0,+    );++    const focusWash = context.createRadialGradient(+      focus.x, focus.y, 0,+      focus.x, focus.y, maximumDimension * 0.54,+    );+    focusWash.addColorStop(0, "rgba(0,230,220,.16)");+    focusWash.addColorStop(0.35, "rgba(57,184,255,.075)");+    focusWash.addColorStop(0.72, "rgba(140,107,247,.025)");+    focusWash.addColorStop(1, "rgba(140,107,247,0)");+    context.globalCompositeOperation = "source-over";+    context.globalAlpha = (0.58 + pointerPresence * 0.24) * clamp(strength, 0, 1.2);+    context.fillStyle = focusWash;+    context.fillRect(0, 0, field.width, field.height);++    context.globalCompositeOperation = "lighter";+    context.strokeStyle = brandGradient;+    context.lineWidth = config.traceWidth * featureScale() * 1.18;+    context.lineCap = "round";+    context.lineJoin = "miter";+    context.setLineDash([]);+    for (let index = 0; index < drawnEnergyTraces; index += 1) {+      const visual = energyCandidates[index];+      context.globalAlpha = visual.energyOpacity;+      strokeCanvasPath(context, visual);+    }++    context.lineCap = "round";+    context.lineJoin = "round";+    for (let index = 0; index < drawnSparks; index += 1) {+      const visual = sparkCandidates[index];+      const distance = visual.trace.length + visual.sparkLength;+      const travel = (timestamp * (0.052 + strength * 0.026) + visual.phase * distance) % distance;+      context.setLineDash([visual.sparkLength, distance]);+      context.lineDashOffset = -travel * visual.directionBias;+      context.lineWidth = config.traceWidth * featureScale() * 3.15;+      context.globalAlpha = visual.sparkOpacity * 0.16;+      strokeCanvasPath(context, visual);+      context.lineWidth = config.traceWidth * featureScale() * 1.42;+      context.globalAlpha = visual.sparkOpacity;+      strokeCanvasPath(context, visual);+    }+    context.setLineDash([]);+    context.lineDashOffset = 0;++    const nodeRadius = config.viaInnerRadius * featureScale() * 0.78;+    for (let index = 0; index < drawnNodes; index += 1) {+      const visual = nodeCandidates[index];+      context.fillStyle = visual.nodeColor;+      context.globalAlpha = visual.nodeOpacity * 0.14;+      context.beginPath();+      context.arc(visual.endpoint[0], visual.endpoint[1], nodeRadius * 2.65, 0, Math.PI * 2);+      context.fill();+      context.globalAlpha = visual.nodeOpacity;+      context.beginPath();+      context.arc(visual.endpoint[0], visual.endpoint[1], nodeRadius, 0, Math.PI * 2);+      context.fill();+    }+    context.globalAlpha = 1;+    context.globalCompositeOperation = "source-over";+  }++  function stopMotion() {+    window.cancelAnimationFrame(frameRequest);+    conductor?.stop();+    frameRequest = undefined;+    activeTraces = 0;+    lastFrameTime = 0;+    lastPaintTime = 0;+  }++  function startMotion() {+    stopMotion();+    if (config.animationSystem === "none" || !config.motion || reducedMotion || life() <= 0.02) return;+    if (config.animationSystem === "conductor") {+      conductor?.start();+      return;+    }+    const now = performance.now();+    sceneStart = now - 760;+    nextBloomAt = now + 1180;+    frameCount = 0;+    spawnBloom({ x: field.width * 0.2, y: field.height * 0.64 }, 0.88);+    frameRequest = window.requestAnimationFrame(renderFrame);+  }++  function regenerate(nextOptions = {}) {+    stopMotion();+    config = { ...config, ...nextOptions };+    lastSize = "";+    build(true);+    startMotion();+    return api;+  }++  function exportSvg() {+    const clone = svg.cloneNode(true);+    clone.querySelectorAll("[data-animation]").forEach((layer) => layer.remove());+    clone.removeAttribute("style");+    return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;+  }++  function destroy() {+    stopMotion();+    conductor?.destroy();+    conductor = undefined;+    window.clearTimeout(resizeTimer);+    resizeObserver?.disconnect();+    interactionTarget.removeEventListener("pointermove", onPointerMove);+    interactionTarget.removeEventListener("pointerleave", onPointerLeave);+    interactionTarget.removeEventListener("click", onClick);+    motionCanvas?.remove();+    motionCanvas = undefined;+    motionContext = undefined;+    svg?.remove();+    mount.style.position = originalPosition;+  }++  const api = {+    regenerate,+    triggerBloom: spawnBloom,+    triggerAnimation(origin = null) {+      return config.animationSystem === "conductor"+        ? conductor?.trigger(origin)+        : spawnBloom(origin, 1.12);+    },+    exportSvg,+    destroy,+    get field() { return field; },+    get animationStats() {+      if (config.animationSystem === "none") {+        return {+          system: "none",+          animationNodes: 0,+          frameCount: 0,+          reducedMotion,+        };+      }+      if (config.animationSystem === "conductor") return conductor?.stats;+      return {+        system: "adom-routed-field",+        activeTraces,+        activeBlooms: blooms.length,+        bloomsTriggered,+        frameCount,+        renderer: "canvas",+        animationNodes: motionCanvas ? 1 : 0,+        traceCount: visuals.length,+        targetFrameRate,+        rendererPixelRatio,+        energyBudget,+        sparkBudget,+        nodeBudget,+        drawnEnergyTraces,+        drawnSparks,+        drawnNodes,+        reducedMotion,+      };+    },+  };+  build(true);+  resizeObserver = new ResizeObserver(() => {+    window.clearTimeout(resizeTimer);+    resizeTimer = window.setTimeout(() => {+      if (build()) {+        stopMotion();+        startMotion();+      }+    }, 220);+  });+  resizeObserver.observe(mount);+  if (config.interactive) {+    interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });+    interactionTarget.addEventListener("pointerleave", onPointerLeave, { passive: true });+    interactionTarget.addEventListener("click", onClick);+  }+  startMotion();+  return api;+}++export { DEFAULTS, ANIMATION_PRESETS };
public/vendor/circuit-trace-generators/circuit-obstacles-v9.jsadded+405
@@ -0,0 +1,405 @@+// Worker-safe obstacle geometry for circuit-background-v9.+// The ADOM contour is sourced from adom-elements/src/adom-white.svg.++const ADOM_PATH = "M1065.5,25.22h-58.49c-2.98,0-5.68,1.87-6.73,4.65l-44.4,117.54c-.06.17-.31.17-.37,0l-45.44-117.59c-1.07-2.75-3.76-4.6-6.72-4.6h-58.79c-4.09,0-7.4,3.31-7.4,7.4v35.06l-.03-.12c-1.24,4.99-5.89,6-5.89,6,0,0-4.51,1.02-7.61-2.53v.03c-17.82-29.81-50.31-48.93-89.04-48.93-41.5,0-75.86,21.85-92.76,55.23v-.07c-2.86,5.19-8.74,5.13-8.74,5.13,0,0-5.63-.01-8.41-4.61-16.49-32.68-50.56-52.59-94.79-52.59h-73.46c-4.09,0-7.4,3.31-7.4,7.4v121.27s0,.03,0,.04c-.95,5.15-4.75,5.94-4.75,5.94,0,0-3.88.85-6.77-3.32l.05.23-46.05-126.83c-1.03-2.84-3.75-4.74-6.77-4.74h-57.14c-3.01,0-5.73,1.9-6.76,4.73l-68.67,188.38c-1.05,2.88-.18,6.21,2.4,8.19,1.31,1.01,2.98,1.47,4.64,1.47h34.96s6.43,0,6.43,0c2.5,0,4.83-1.39,5.9-3.65,8.49-17.86,26.5-29.64,48.37-29.64s39.71,11.73,48.19,29.53c1.1,2.32,3.49,3.76,6.06,3.76h120.78c31.22,0,57.85-9.95,77.02-28.77,7.38-7.25,13.43-15.72,18.05-25.1v.09c2.74-5.19,8.37-5.13,8.37-5.13,0,0,5.43,0,8.08,4.64,4.88,9.88,11.26,18.78,18.91,26.42,18.6,18.6,44.61,29.78,74.26,29.78,38.74,0,71.23-19.18,89.05-49.07l-.02.07c3.1-3.55,7.61-2.53,7.61-2.53,0,0,4.65,1,5.89,6l.03-.12v36.32c0,4.09,3.31,7.4,7.4,7.4h45.79c4.09,0,7.4-3.31,7.4-7.4v-86.26c0-.23.32-.28.39-.06l30.37,88.86c1,2.91,3.74,4.87,6.81,4.87h39.69c3.08,0,5.82-1.96,6.81-4.87l30.1-88.02c.07-.21.39-.16.39.06v85.43c0,4.09,3.31,7.4,7.4,7.4h45.79c4.09,0,7.4-3.31,7.4-7.4V32.62c0-4.09-3.31-7.4-7.4-7.4ZM372.31,147.57c-.15.21-.5.57-1.1.57h-30.33c-.61,0-.95-.35-1.1-.56-.15-.21-.38-.65-.19-1.23l15.06-45.79c.27-.81.92-.94,1.29-.94h0c.37,0,1.02.12,1.29.93l15.26,45.8c.19.58-.04,1.01-.19,1.23ZM525.72,173.84h-14.52c-.86,0-1.56-.7-1.56-1.56v-92.16c0-.86.7-1.56,1.56-1.56h14.52c30.81,0,48.48,17.47,48.48,47.92s-17.67,47.36-48.48,47.36ZM734.6,173.26c-26.51,0-42.33-17.71-42.34-47.36,0-14.59,3.88-26.68,11.21-34.98,7.33-8.29,18.09-12.67,31.13-12.67,12.11,0,22.28,3.84,29.58,11.13.5.5.98,1.01,1.45,1.54,7.4,8.33,11.31,20.42,11.31,34.97,0,29.21-16.22,47.36-42.34,47.36Z";++const COMMAND = /^[a-z]$/i;+const NUMBER = /[a-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?/gi;++function cubicPoint(start, first, second, end, t) {+  const inverse = 1 - t;+  const a = inverse * inverse * inverse;+  const b = 3 * inverse * inverse * t;+  const c = 3 * inverse * t * t;+  const d = t * t * t;+  return [+    a * start[0] + b * first[0] + c * second[0] + d * end[0],+    a * start[1] + b * first[1] + c * second[1] + d * end[1],+  ];+}++function flattenSvgPath(data, curveSteps = 14) {+  const tokens = data.match(NUMBER) || [];+  const rings = [];+  let index = 0;+  let command = null;+  let current = [0, 0];+  let start = [0, 0];+  let previousControl = null;+  let previousCommand = null;+  let ring = [];++  const number = () => Number(tokens[index++]);+  const point = (relative) => {+    const result = [number(), number()];+    if (relative) {+      result[0] += current[0];+      result[1] += current[1];+    }+    return result;+  };+  const finishRing = () => {+    if (ring.length < 3) return;+    const last = ring.at(-1);+    if (last[0] !== start[0] || last[1] !== start[1]) ring.push(start.slice());+    rings.push(ring);+    ring = [];+  };++  while (index < tokens.length) {+    if (COMMAND.test(tokens[index])) command = tokens[index++];+    if (!command) throw new Error("Invalid ADOM path data");+    const relative = command === command.toLowerCase();+    const upper = command.toUpperCase();++    if (upper === "M") {+      if (ring.length) finishRing();+      current = point(relative);+      start = current.slice();+      ring = [current.slice()];+      command = relative ? "l" : "L";+      previousControl = null;+    } else if (upper === "L") {+      current = point(relative);+      ring.push(current.slice());+      previousControl = null;+    } else if (upper === "H") {+      const x = number() + (relative ? current[0] : 0);+      current = [x, current[1]];+      ring.push(current.slice());+      previousControl = null;+    } else if (upper === "V") {+      const y = number() + (relative ? current[1] : 0);+      current = [current[0], y];+      ring.push(current.slice());+      previousControl = null;+    } else if (upper === "C") {+      const first = point(relative);+      const second = point(relative);+      const end = point(relative);+      const origin = current;+      for (let step = 1; step <= curveSteps; step += 1) {+        ring.push(cubicPoint(origin, first, second, end, step / curveSteps));+      }+      current = end;+      previousControl = second;+    } else if (upper === "S") {+      const reflected = previousControl && (previousCommand === "C" || previousCommand === "S")+        ? [2 * current[0] - previousControl[0], 2 * current[1] - previousControl[1]]+        : current.slice();+      const second = point(relative);+      const end = point(relative);+      const origin = current;+      for (let step = 1; step <= curveSteps; step += 1) {+        ring.push(cubicPoint(origin, reflected, second, end, step / curveSteps));+      }+      current = end;+      previousControl = second;+    } else if (upper === "Z") {+      finishRing();+      current = start.slice();+      previousControl = null;+      command = null;+    } else {+      throw new Error(`Unsupported SVG path command: ${command}`);+    }+    previousCommand = upper;+  }+  if (ring.length) finishRing();+  return rings;+}++function boundsOfRings(rings) {+  let minX = Infinity;+  let minY = Infinity;+  let maxX = -Infinity;+  let maxY = -Infinity;+  for (const ring of rings) {+    for (const [x, y] of ring) {+      minX = Math.min(minX, x);+      minY = Math.min(minY, y);+      maxX = Math.max(maxX, x);+      maxY = Math.max(maxY, y);+    }+  }+  return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };+}++const ADOM_RINGS = flattenSvgPath(ADOM_PATH);+const ADOM_BOUNDS = boundsOfRings(ADOM_RINGS);++function pointInRing(x, y, ring) {+  let inside = false;+  for (let current = 0, previous = ring.length - 1; current < ring.length; previous = current, current += 1) {+    const [cx, cy] = ring[current];+    const [px, py] = ring[previous];+    if ((cy > y) === (py > y)) continue;+    const crossing = (px - cx) * (y - cy) / (py - cy || Number.EPSILON) + cx;+    if (x < crossing) inside = !inside;+  }+  return inside;+}++function pointInRings(x, y, rings) {+  let inside = false;+  for (const ring of rings) if (pointInRing(x, y, ring)) inside = !inside;+  return inside;+}++function transformWordmark(spec, width, height) {+  const targetWidth = Math.max(24, width * Number(spec.width ?? spec.scale ?? 0.66));+  const targetHeight = targetWidth * ADOM_BOUNDS.height / ADOM_BOUNDS.width;+  const centerX = width * Number(spec.x ?? 0.5);+  const centerY = height * Number(spec.y ?? 0.66);+  const scale = targetWidth / ADOM_BOUNDS.width;+  return ADOM_RINGS.map((ring) => ring.map(([x, y]) => [+    centerX + (x - ADOM_BOUNDS.minX - ADOM_BOUNDS.width / 2) * scale,+    centerY + (y - ADOM_BOUNDS.minY - ADOM_BOUNDS.height / 2) * scale,+  ]));+}++function resolveValue(value, extent, normalized) {+  return Number(value || 0) * (normalized ? extent : 1);+}++function compileGenericObstacle(spec, width, height) {+  const normalized = spec.space !== "world";+  const centerX = resolveValue(spec.x ?? 0.5, width, normalized);+  const centerY = resolveValue(spec.y ?? 0.5, height, normalized);+  const obstacleWidth = Math.max(0, resolveValue(spec.width ?? 0.2, width, normalized));+  const obstacleHeight = Math.max(0, resolveValue(spec.height ?? 0.2, height, normalized));++  if (spec.type === "rect") {+    const left = centerX - obstacleWidth / 2;+    const top = centerY - obstacleHeight / 2;+    const ring = [[left, top], [left + obstacleWidth, top], [left + obstacleWidth, top + obstacleHeight], [left, top + obstacleHeight], [left, top]];+    return { id: spec.id || "rect", rings: [ring], contains: (x, y) => x >= left && x <= left + obstacleWidth && y >= top && y <= top + obstacleHeight };+  }+  if (spec.type === "ellipse") {+    const rx = obstacleWidth / 2;+    const ry = obstacleHeight / 2;+    const ring = Array.from({ length: 97 }, (_, index) => {+      const angle = index / 96 * Math.PI * 2;+      return [centerX + Math.cos(angle) * rx, centerY + Math.sin(angle) * ry];+    });+    return { id: spec.id || "ellipse", rings: [ring], contains: (x, y) => ((x - centerX) / (rx || 1)) ** 2 + ((y - centerY) / (ry || 1)) ** 2 <= 1 };+  }+  const sourceRings = spec.type === "polygon" ? [spec.points || []] : spec.rings || [];+  const rings = sourceRings.map((ring) => {+    const transformed = ring.map(([x, y]) => [resolveValue(x, width, normalized), resolveValue(y, height, normalized)]);+    if (transformed.length && transformed.at(-1).some((value, axis) => value !== transformed[0][axis])) transformed.push(transformed[0].slice());+    return transformed;+  }).filter((ring) => ring.length >= 4);+  return { id: spec.id || spec.type || "shape", rings, contains: (x, y) => pointInRings(x, y, rings) };+}++function normalizeSpecs(config) {+  const specs = Array.isArray(config.obstacles) ? config.obstacles.slice() : [];+  if (config.obstaclePreset === "adom") {+    specs.unshift({+      id: "adom-wordmark",+      type: "wordmark",+      preset: "adom",+      x: Number(config.obstacleX ?? 0.5),+      y: Number(config.obstacleY ?? 0.66),+      width: Number(config.obstacleScale ?? 0.66),+    });+  }+  return specs.filter((spec) => spec && spec.enabled !== false);+}++function compileObstacle(spec, width, height) {+  if (spec.type === "wordmark" || spec.preset === "adom") {+    const rings = transformWordmark(spec, width, height);+    return { id: spec.id || "adom-wordmark", rings, contains: (x, y) => pointInRings(x, y, rings) };+  }+  return compileGenericObstacle(spec, width, height);+}++function dilateMask(source, columns, rows, radius) {+  if (radius <= 0) return source.slice();+  const result = source.slice();+  const offsets = [];+  for (let dy = -radius; dy <= radius; dy += 1) {+    for (let dx = -radius; dx <= radius; dx += 1) {+      if (dx * dx + dy * dy <= radius * radius) offsets.push([dx, dy]);+    }+  }+  for (let y = 1; y < rows - 1; y += 1) {+    for (let x = 1; x < columns - 1; x += 1) {+      if (!source[y * columns + x]) continue;+      for (const [dx, dy] of offsets) {+        const nx = x + dx;+        const ny = y + dy;+        if (nx > 0 && ny > 0 && nx < columns - 1 && ny < rows - 1) result[ny * columns + nx] = 1;+      }+    }+  }+  return result;+}++function distanceField(blocked, columns, rows, band) {+  const unseen = 65535;+  const distance = new Uint16Array(columns * rows);+  distance.fill(unseen);+  const queue = new Int32Array(columns * rows);+  let head = 0;+  let tail = 0;+  for (let index = 0; index < blocked.length; index += 1) {+    if (!blocked[index]) continue;+    distance[index] = 0;+    queue[tail++] = index;+  }+  while (head < tail) {+    const index = queue[head++];+    const nextDistance = distance[index] + 1;+    if (nextDistance > band) continue;+    const x = index % columns;+    const y = Math.floor(index / columns);+    const neighbors = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]];+    for (const [nx, ny] of neighbors) {+      if (nx < 0 || ny < 0 || nx >= columns || ny >= rows) continue;+      const next = ny * columns + nx;+      if (distance[next] <= nextDistance) continue;+      distance[next] = nextDistance;+      queue[tail++] = next;+    }+  }+  return distance;+}++function rasterizeObstacle(mask, obstacle, pitch, columns, rows) {+  for (let row = 1; row < rows - 1; row += 1) {+    const worldY = row * pitch;+    const intersections = [];+    for (const ring of obstacle.rings) {+      for (let current = 0, previous = ring.length - 1; current < ring.length; previous = current, current += 1) {+        const [cx, cy] = ring[current];+        const [px, py] = ring[previous];+        if ((cy > worldY) === (py > worldY)) continue;+        intersections.push(cx + (px - cx) * (worldY - cy) / (py - cy || Number.EPSILON));+      }+    }+    intersections.sort((left, right) => left - right);+    for (let pair = 0; pair + 1 < intersections.length; pair += 2) {+      const first = Math.max(1, Math.ceil(intersections[pair] / pitch));+      const last = Math.min(columns - 2, Math.floor(intersections[pair + 1] / pitch));+      for (let column = first; column <= last; column += 1) mask[row * columns + column] = 1;+    }+  }+}++export function createObstacleField(config, dimensions) {+  const { width, height, pitch, columns, rows } = dimensions;+  const specs = normalizeSpecs(config);+  if (!specs.length) return null;+  const compiled = specs.map((spec) => compileObstacle(spec, width, height)).filter((item) => item.rings.length);+  if (!compiled.length) return null;++  const inside = new Uint8Array(columns * rows);+  for (const obstacle of compiled) rasterizeObstacle(inside, obstacle, pitch, columns, rows);+  const viaClearance = Math.max(0, Number(config.viaOuterRadius) || 0) / pitch;+  const radius = Math.max(1, Math.ceil(Math.max(0, Number(config.obstacleClearance) || 0) + viaClearance));+  const blocked = dilateMask(inside, columns, rows, radius);+  const band = Math.max(4, Math.ceil(6 + (Number(config.obstaclePull) || 0) * 2));+  const distance = distanceField(blocked, columns, rows, band + 1);+  let blockedCells = 0;+  for (let y = 1; y < rows - 1; y += 1) {+    for (let x = 1; x < columns - 1; x += 1) blockedCells += blocked[y * columns + x];+  }+  return {+    blocked,+    distance,+    band,+    rings: compiled.flatMap((obstacle) => obstacle.rings.map((ring) => ({ id: obstacle.id, points: ring }))),+    count: compiled.length,+    blockedCells,+    mode: config.obstacleMode || "avoid",+  };+}++function sampleClosedRing(points, spacing) {+  const ring = points.slice();+  if (ring.length > 1 && ring[0][0] === ring.at(-1)[0] && ring[0][1] === ring.at(-1)[1]) ring.pop();+  if (ring.length < 3) return [];+  const segments = [];+  let total = 0;+  for (let index = 0; index < ring.length; index += 1) {+    const start = ring[index];+    const end = ring[(index + 1) % ring.length];+    const length = Math.hypot(end[0] - start[0], end[1] - start[1]);+    segments.push({ start, end, length, at: total });+    total += length;+  }+  const count = Math.max(12, Math.ceil(total / spacing));+  const result = [];+  let segmentIndex = 0;+  for (let sample = 0; sample < count; sample += 1) {+    const target = sample / count * total;+    while (segmentIndex < segments.length - 1 && segments[segmentIndex].at + segments[segmentIndex].length < target) segmentIndex += 1;+    const segment = segments[segmentIndex];+    const local = (target - segment.at) / (segment.length || 1);+    result.push([+      segment.start[0] + (segment.end[0] - segment.start[0]) * local,+      segment.start[1] + (segment.end[1] - segment.start[1]) * local,+    ]);+  }+  return result;+}++export function obstacleOutlineTraces(field, options) {+  if (!field || !field.mode.includes("outline")) return [];+  const pitch = options.pitch;+  const spacing = Math.max(2, pitch * 0.48);+  const viaRadius = Math.max(0, Number(options.viaOuterRadius) || 0);+  const traces = [];+  for (let ringIndex = 0; ringIndex < field.rings.length; ringIndex += 1) {+    const ring = field.rings[ringIndex];+    const sampled = sampleClosedRing(ring.points, spacing);+    if (sampled.length < 12) continue;+    let seam = 0;+    for (let index = 1; index < sampled.length; index += 1) {+      if (sampled[index][1] > sampled[seam][1] || (sampled[index][1] === sampled[seam][1] && sampled[index][0] > sampled[seam][0])) seam = index;+    }+    const gap = Math.min(Math.floor(sampled.length * 0.14), Math.max(2, Math.ceil((viaRadius * 2.4 + pitch * 0.45) / spacing)));+    const start = (seam + Math.ceil(gap / 2)) % sampled.length;+    const points = [];+    for (let offset = 0; offset <= sampled.length - gap; offset += 1) points.push(sampled[(start + offset) % sampled.length]);+    if (points.length < 6) continue;+    let length = 0;+    for (let index = 1; index < points.length; index += 1) length += Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]);+    traces.push({+      points,+      length,+      viaStart: true,+      viaEnd: true,+      kind: "obstacle-outline",+      obstacleId: ring.id,+      ringIndex,+    });+  }+  return traces;+}++export function obstacleEdgeWeight(field, x, y, dx, dy, columns, pull) {+  if (!field || !field.mode.includes("wrap") || pull <= 0) return 1;+  const index = y * columns + x;+  const distance = field.distance[index];+  if (!distance || distance > field.band) return 1;+  const left = field.distance[index - 1];+  const right = field.distance[index + 1];+  const up = field.distance[index - columns];+  const down = field.distance[index + columns];+  const gx = Math.min(right, field.band + 1) - Math.min(left, field.band + 1);+  const gy = Math.min(down, field.band + 1) - Math.min(up, field.band + 1);+  const magnitude = Math.hypot(gx, gy);+  if (magnitude < 0.001) return 1;+  const tangent = Math.abs((-gy * dx + gx * dy) / magnitude);+  const normal = Math.abs((gx * dx + gy * dy) / magnitude);+  const proximity = 1 - (distance - 1) / Math.max(1, field.band);+  const attraction = 1 + pull * proximity * (0.55 + tangent * tangent * 3.4);+  const approachPenalty = 1 - Math.min(0.68, pull * proximity * normal * 0.34);+  return Math.max(0.16, attraction * approachPenalty);+}++export const ADOM_WORDMARK_ASPECT = ADOM_BOUNDS.width / ADOM_BOUNDS.height;
public/vendor/circuit-trace-generators/package.jsonadded+1
@@ -0,0 +1 @@+{"type":"module"}
render-hero.mjs+2
@@ -17,6 +17,8 @@ try {   await page.setViewport({ width: 1680, height: 1080, deviceScaleFactor: 2 });   await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });   await new Promise(r => setTimeout(r, 1200)); // fonts + images settle+  await page.waitForFunction(() => !window.__tracePending, { timeout: 30000 });+  await page.evaluate(async () => { await window.__traceReady; if (window.__traceError) throw new Error(window.__traceError); });   const hero = await page.$('#hero');   const box = await hero.boundingBox();   await page.screenshot({ path: out, clip: box });
render-pup.mjs+8
@@ -60,6 +60,14 @@ if (!ready) die('render view never became ready (#hero not found at ' + url + ') // host allows it, then let fonts and images settle. await evalIn('window.resizeTo(1720, 1180)'); await sleep(2000);+let tracesReady = false;+for (let i = 0; i < 30 && !tracesReady; i++) {+  const r = await evalIn("window.__traceError ? 'HS-TRACE-ERROR' : window.__tracePending ? 'HS-TRACE-WAIT' : 'HS-TRACE-READY'");+  if (r && r.includes('HS-TRACE-ERROR')) die('circuit composition failed; read the studio console');+  if (r && r.includes('HS-TRACE-READY')) tracesReady = true;+  else await sleep(1000);+}+if (!tracesReady) die('circuit composition did not settle before export');  // 5. measure #hero in viewport pixels + devicePixelRatio. Numbers only, // joined with commas: no quotes means no escaping ambiguity in the transport.
server.js+19−4
@@ -100,6 +100,7 @@ let state = {   slotSize: null,         // measured showcase slot in export px, posted back by the UI ({w,h})   bgImage: { src: '', cover: false },  // imported background art for theme 'image' (hero-studio#24)   circuitSeed: Date.now() % 2147483647,  // procedural circuitry theme seed; /reroll re-rolls+  circuit: { density: 0.8, scale: 1.4, strength: 0.22, clearance: 8 },   fontSize: { title: 8, sub: 2.5, desc: 1.35, leading: 1.02, gap: 1 },  // cqw sizes + title line-height + text-block gap (em); author-adjustable, defaults tuned for dock legibility   headline: '',    // what BAKES as the hero title; '' = use the page title (hero-studio#33: title != headline)   subtitle: '',    // the text under the title; '' = the page brief's first sentence (wizard round 3)@@ -129,6 +130,7 @@ const POSE_TRACKED = [   'overlay.pill', 'overlay.scores', 'overlay.author', 'overlay.slug', 'overlay.logo',   'fontSize.title', 'fontSize.sub', 'fontSize.desc', 'fontSize.leading', 'fontSize.gap', 'headline', 'subtitle', 'accentWords',   'showcase.chrome', 'showcase.full', 'showcase.cover',+  'circuitSeed', 'circuit.density', 'circuit.scale', 'circuit.strength', 'circuit.clearance', ]; const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o); function snapshotPose() {@@ -175,7 +177,7 @@ const PRESETS = {  const ENUMS = {   layout: ['bleed-bottom', 'bleed-right', 'bleed-top', 'bleed-corner-br', 'bleed-corner-tr', 'contained', 'mirror', 'showcase'],-  theme: ['midnight', 'pcb', 'onion', 'pill', 'grid', 'gradient', 'aurora', 'mesh', 'circuitry', 'image'],+  theme: ['midnight', 'pcb', 'onion', 'pill', 'grid', 'gradient', 'aurora', 'mesh', 'circuitry', 'circuit-field', 'circuit-tile', 'image'],   titleMode: ['overlay-only', 'image-only', 'both'],   mode: ['app', 'component'], };@@ -349,7 +351,7 @@ process.on('SIGTERM', () => { deregisterInstance(); process.exit(0); }); process.on('SIGINT', () => { deregisterInstance(); process.exit(0); });  const MIME = {-  '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',+  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',   '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',   '.woff2': 'font/woff2', };@@ -894,7 +896,8 @@ function route(req, res, url, p) {       show: state.show, guides: false,       showcase: { chrome: (state.showcase || {}).chrome || '', cover: !!(state.showcase || {}).cover },       mode: state.mode, comp: state.mode === 'component' ? { imgSrc: state.comp.imgSrc, layout: state.comp.layout, mpn: state.comp.mpn } : undefined,-      circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined,+      circuitSeed: state.theme.startsWith('circuit') ? state.circuitSeed : undefined,+      circuit: state.circuit,     };     const rec = { name, style, gate: Object.assign({}, state.approved, { thread: AI_THREAD, savedAt: new Date().toISOString() }) };     const dir = path.join(INSTANCES_DIR, 'styles');@@ -945,6 +948,16 @@ function route(req, res, url, p) {         }       }       const pageChanged = typeof body.page === 'string' && body.page !== state.page;+      const badTrace = 'circuit' in body && (!body.circuit || typeof body.circuit !== 'object' || Array.isArray(body.circuit)+        || Object.entries(body.circuit).some(([key, value]) => {+          const ranges = { density: [0.25, 1.25], scale: [0.8, 2.5], strength: [0.05, 0.5], clearance: [8, 40] };+          const range = Object.hasOwn(ranges, key) ? ranges[key] : null;+          return !range || typeof value !== 'number' || !Number.isFinite(value) || value < range[0] || value > range[1];+        }));+      if (badTrace || ('circuitSeed' in body && (!Number.isInteger(body.circuitSeed) || body.circuitSeed < 0 || body.circuitSeed >= 2147483647))) {+        res.writeHead(400, { 'Content-Type': 'application/json' });+        return res.end(JSON.stringify({ ok: false, error: 'invalid circuit settings', valid: { density: [0.25,1.25], scale: [0.8,2.5], strength: [0.05,0.5], clearance: [8,40], circuitSeed: 'integer 0..2147483646' } }));+      }       const turningFullOn = body.showcase && body.showcase.full && !(state.showcase && state.showcase.full);       if (turningFullOn && !body.overlay) {         body.overlay = { pill: false, scores: false, author: false, slug: false, logo: false };@@ -1190,8 +1203,10 @@ function route(req, res, url, p) {     return res.end(JSON.stringify({ ok: true, hint: 'solving in the open surface; read state.fontSize.title (and the fit toast) in about a second. state.fitPending stays set until a surface answers: if it is still there after a few seconds, no surface is actually responding' }));   }   if (p === '/reroll' && req.method === 'POST') {+    const before = snapshotPose();     poseRev += 1;     state.circuitSeed = (state.circuitSeed * 48271 + Date.now()) % 2147483647;+    notePoseDiff(before);     broadcast('state', state);     res.writeHead(200, { 'Content-Type': 'application/json' });     return res.end(JSON.stringify({ ok: true, seed: state.circuitSeed }));@@ -1658,7 +1673,7 @@ function route(req, res, url, p) {       const branding = g.owner || String(state.page || '').split('/')[0] || null;       const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia,         branding, brand_sha: g.sha || null, brand_version: g.version || null, brand_status: g.status || 'missing',-        state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, showcase_used: !!(state.showcase && state.showcase.src), showcase_full: !!(state.showcase && state.showcase.full), showcase: showcaseSaved ? { path: showcaseSaved.path, sha256: showcaseSaved.sha256, w: showcaseSaved.w, h: showcaseSaved.h, chrome: showcaseSaved.chrome, full: showcaseSaved.full, cover: showcaseSaved.cover } : undefined, circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined, prior: req._priorProvenance || undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });+        state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, showcase_used: !!(state.showcase && state.showcase.src), showcase_full: !!(state.showcase && state.showcase.full), showcase: showcaseSaved ? { path: showcaseSaved.path, sha256: showcaseSaved.sha256, w: showcaseSaved.w, h: showcaseSaved.h, chrome: showcaseSaved.chrome, full: showcaseSaved.full, cover: showcaseSaved.cover } : undefined, circuitSeed: state.theme.startsWith('circuit') ? state.circuitSeed : undefined, circuit: state.circuit }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined, prior: req._priorProvenance || undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });       const py = 'import sys\n' +         'from PIL import Image\n' +         'from PIL.PngImagePlugin import PngInfo\n' +
test/circuit-traces.mjsadded+182
@@ -0,0 +1,182 @@+// Exercise the real surface + read-only export; no Generate gate or wiki writes.+import assert from 'node:assert/strict';+import { spawn } from 'node:child_process';+import http from 'node:http';+import net from 'node:net';+import fs from 'node:fs';+import path from 'node:path';+import { fileURLToPath } from 'node:url';+import { loadPuppeteer, findChrome } from '../doctor.mjs';+import { composeTraces } from '../public/js/circuit-traces.mjs';+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');+const screenshotDir = process.env.TRACE_SCREENSHOTS;+if (screenshotDir) fs.mkdirSync(screenshotDir, { recursive: true });+let passed = 0;+const ok = (name, value) => { assert.ok(value, name); console.log('  ok   ' + name); passed++; };+const spec = { seed: 1234, color:'#e0a020', boxes:[{x:30,y:40,width:380,height:240}] };+for (const theme of ['circuit-field', 'circuit-tile']) {+  const a = composeTraces({ ...spec, theme });+  ok(theme + ' has real routes', a.count > 20);+  ok(theme + ' is deterministic', a.src === composeTraces({ ...spec, theme }).src);+  ok(theme + ' rerolls', a.src !== composeTraces({ ...spec, theme, seed: 1235 }).src);+}+const registry = http.createServer((req,res) => { res.writeHead(404, { 'Content-Type':'application/json' }); res.end('{}'); });+await new Promise(r => registry.listen(0, '127.0.0.1', r));+const port = await new Promise(r => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => r(p)); }); });+const base = 'http://127.0.0.1:' + port;+const server = spawn(process.execPath, ['server.js','--port',String(port),'--ai-thread','circuit integration test ' + port], {+  cwd:ROOT, env:{...process.env, ADOM_WIKI_REGISTRY:'http://127.0.0.1:' + registry.address().port}, stdio:'pipe',+});+let browser;+try {+  let up = false;+  for (let i=0; i<80 && !up; i++) { try { up=(await fetch(base+'/state')).ok; } catch {} if(!up) await new Promise(r=>setTimeout(r,100)); }+  assert.ok(up, 'test server starts');+  const patch = async body => {+    const r = await fetch(base+'/state',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({aiThread:'circuit tests',...body})});+    return r;+  };+  for (const bad of [{circuit:{scale:0}}, {circuit:{density:99}}, {circuit:{clearance:-1}}, {circuit:{strength:'0.2'}}, {circuit:null}, {circuitSeed:1.5}, {circuit:{surprise:true}}]) {+    ok('rejects ' + JSON.stringify(bad), (await patch(bad)).status === 400);+  }+  const showcase = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="600" height="450"><rect width="600" height="450" fill="#29364c"/><circle cx="300" cy="200" r="125" fill="#5780a0"/></svg>');+  await patch({ theme:'circuit-field', circuitSeed:1234, headline:'Traces with purpose', subtitle:'Make room for the story', layout:'contained', showcase:{src:showcase,full:false}, show:{logo:false,description:false}, guide:{status:'confirmed',sha:'test',brand:{colors:{primary:'#e0a020',background:'#142030'}}} });+  browser = await (await loadPuppeteer()).launch({executablePath:findChrome(),headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});+  const page = await browser.newPage();+  await page.setViewport({width:1680,height:1080,deviceScaleFactor:1});+  const errors=[]; page.on('pageerror', e=>errors.push(e.message));+  await page.goto(base+'/?render=1',{waitUntil:'networkidle2'});+  const settled = async () => {+    await page.waitForFunction(() => typeof state !== 'undefined' && state && !window.__tracePending);+    await page.evaluate(async()=>{await window.__traceReady; if(window.__traceError) throw Error(window.__traceError);});+  };+  const setLocal = async body => { await page.evaluate(p=>{wizMerge(state,p);render();},body); await settled(); };+  await settled();+  const src = () => page.$eval('#trace-art',im=>im.src);+  const field = await src();+  ok('brand color in generated SVG', decodeURIComponent(field).includes('#e0a020'));+  ok('preview clones carry the exact artwork', await page.$$eval('#contexts .trace-art',els=>els.length>0 && els.every(el=>el.src===document.querySelector('#trace-art').src)));+  ok('glyph contours and showcase are measured without column/bottom rectangles', await page.evaluate(async()=>{+    const m=await import('./js/circuit-traces.mjs'); const boxes=m.keepouts(document.querySelector('#hero'));+    return ['text-ink','window'].every(id=>boxes.some(b=>b.id===id))+      && !boxes.some(b=>['textzone','ctextzone','homepage-band','lower'].includes(b.id))+      && boxes.find(b=>b.id==='text-ink').rings.length>20;+  }));+  const bottomInk = () => page.evaluate(()=>{+    const cv=document.createElement('canvas');cv.width=1000;cv.height=625;+    const ctx=cv.getContext('2d');ctx.drawImage(document.querySelector('#trace-art'),0,0);+    return ctx.getImageData(25,485,950,100).data.filter((v,i)=>i%4===3 && v>10).length;+  });+  ok('field routes continue through the lower quarter',await bottomInk()>500);+  // Independently compare the stencil against Chromium's painted DOM, not+  // another Canvas measurement. This catches wrong baselines/font metrics,+  // cap trimming, kerning, inline accents and wrapped-line reconstruction.+  const inkStyle = await page.addStyleTag({content:`+    html,body,#hero { background:black!important; }+    #hero > :not(.textzone) { visibility:hidden!important; }+    #hero .wordmark { visibility:hidden!important; }+    #hero .textzone * { color:white!important;-webkit-text-fill-color:white!important;background:none!important;text-shadow:none!important; }+  `});+  const domInk = await (await page.$('#hero')).screenshot({encoding:'base64'});+  await inkStyle.evaluate(el=>el.remove());+  await inkStyle.dispose();+  ok('ink stencil covers the actual browser-painted glyphs',await page.evaluate(async png=>{+    const m=await import('./js/circuit-traces.mjs'),b=m.keepouts(document.querySelector('#hero'),0).find(b=>b.id==='text-ink');+    const mask=new Uint8Array(1000*625);+    for(const r of b.rings) for(let y=r[0][1];y<r[2][1];y++) for(let x=r[0][0];x<r[1][0];x++) mask[y*1000+x]=1;+    const image=new Image();image.src='data:image/png;base64,'+png;await image.decode();+    const cv=document.createElement('canvas');cv.width=1000;cv.height=625;+    const ctx=cv.getContext('2d');ctx.drawImage(image,0,0,1000,625);+    const pixels=ctx.getImageData(0,0,1000,625).data;let ink=0,miss=0;+    for(let i=0;i<mask.length;i++) if(pixels[i*4]>100){ink++;if(!mask[i])miss++;}+    return ink>1000 && miss/ink<0.015;+  },domInk));+  await setLocal({layout:'mirror'});+  ok('mirrored composition reroutes the field', field !== await src());+  const mirrored=await src();+  await setLocal({marqueePreview:true});+  ok('homepage overlay toggle preserves geometry', mirrored === await src());+  await setLocal({layout:'contained',marqueePreview:false});+  ok('returning to a composition reproduces it', field === await src());+  await setLocal({fontSize:{title:11},headline:'A longer headline wraps into more lines'});+  ok('text changes update the keepout', field !== await src());+  await setLocal({headline:'Traces with purpose',fontSize:{title:8}});+  if(screenshotDir) await (await page.$('#hero')).screenshot({path:path.join(screenshotDir,'field.png')});+  await setLocal({theme:'circuit-tile'});+  ok('tile artwork loads in browser', await page.$eval('#trace-art',im=>im.complete && im.naturalWidth===1000));+  ok('tile pixels are visible outside the showcase', await page.evaluate(()=>{+    const cv=document.createElement('canvas');cv.width=1000;cv.height=625;+    const ctx=cv.getContext('2d');ctx.drawImage(document.querySelector('#trace-art'),0,0);+    const data=ctx.getImageData(0,0,1000,625).data;+    let ink=0; for(let i=3;i<data.length;i+=4) if(data[i]>10) ink++;+    return ink>1000 && ctx.getImageData(600,160,1,1).data[3]===0;+  }));+  ok('tile continues through the lower quarter',await bottomInk()>500);+  // A large O must retain its counter; a layout/line/character rectangle+  // cannot pass this. Range and font measurements must follow the actual ink.+  await setLocal({headline:'O',show:{subtitle:false},fontSize:{title:20}});+  ok('letter contours preserve open counters and unused column space',await page.evaluate(async()=>{+    const m=await import('./js/circuit-traces.mjs');const hero=document.querySelector('#hero');+    const text=m.keepouts(hero,0).find(b=>b.id==='text-ink');+    const range=document.createRange();range.selectNodeContents(document.querySelector('#el-title'));+    const r=range.getBoundingClientRect(),h=hero.getBoundingClientRect();+    const x=(r.left+r.width/2-h.left)*1000/h.width;+    // Cap-height O's counter is above the baseline, not the line box center.+    const y=(document.querySelector('#el-title').getBoundingClientRect().top+r.width*0.45-h.top)*625/h.height;+    const contains=(px,py)=>text.rings.some(r=>px>=r[0][0] && px<r[1][0] && py>=r[0][1] && py<r[2][1]);+    return text.rings.length>20 && !contains(x,y) && !contains(390,y)+      && text.rings.some(r=>r[0][0]<x && r[1][0]>x);+  }));+  await setLocal({headline:'Traces with purpose',show:{subtitle:true},fontSize:{title:8}});+  if(screenshotDir) await (await page.$('#hero')).screenshot({path:path.join(screenshotDir,'tile.png')});+  const tile=await src();+  await setLocal({circuitSeed:4321});+  ok('seed control changes visible tile',tile!==await src());+  await setLocal({showcase:{full:true}});+  ok('full imports disable traces',await page.$eval('#trace-art',im=>!im.getAttribute('src') && getComputedStyle(im).display==='none'));+  await setLocal({showcase:{full:false},theme:'midnight'});+  ok('existing themes clear the trace layer',await page.$eval('#trace-art',im=>!im.getAttribute('src')));+  // Real wizard interaction + API persistence, separate from read-only export.+  const ui=await browser.newPage(); await ui.setViewport({width:1440,height:1000});+  await ui.goto(base+'/',{waitUntil:'networkidle2'});+  await ui.waitForFunction(()=>typeof state!=='undefined' && state);+  await ui.evaluate(()=>{wizStep='background';wizardRender();});+  await ui.locator('#wcards .card:has(.sw-circuit-tile)').click();+  await ui.waitForFunction(()=>state.theme==='circuit-tile' && !window.__tracePending);+  ok('wizard offers both generators',await ui.$$eval('#wcards .card',els=>['Routed field','Seamless traces'].every(s=>els.some(el=>el.textContent.includes(s)))));+  await ui.$eval('#wtrace-controls input[aria-label="Trace seed"]',el=>{el.value='7654';el.dispatchEvent(new Event('change',{bubbles:true}));});+  await new Promise(r=>setTimeout(r,500));+  ok('wizard persists seed to API',(await (await fetch(base+'/state')).json()).circuitSeed===7654);+  await ui.waitForFunction(()=>!window.__tracePending);+  const uiArt=await ui.$eval('#trace-art',im=>im.src);+  await page.bringToFront(); await page.reload({waitUntil:'networkidle2'}); await settled();+  ok('wizard and export use identical artwork',uiArt===await src());+  await ui.bringToFront(); await ui.setViewport({width:1100,height:800});+  await new Promise(r=>setTimeout(r,300)); await ui.waitForFunction(()=>!window.__tracePending);+  ok('resizing preserves the seeded tile',uiArt===await ui.$eval('#trace-art',im=>im.src));+  await ui.setViewport({width:1440,height:1000});+  await ui.locator('#wcards .card:has(.sw-circuit-field)').click();+  await ui.waitForFunction(()=>state.theme==='circuit-field' && !window.__tracePending);+  const uiField=await ui.$eval('#trace-art',im=>im.src);+  await page.bringToFront(); await page.reload({waitUntil:'networkidle2'}); await settled();+  ok('routed field also matches the export',uiField===await src());+  await ui.bringToFront();+  const status=await (await fetch(base+'/changes')).json();+  ok('change ledger records seed',JSON.stringify(status).includes('circuitSeed'));+  if(process.env.TRACE_SHOWCASE) {+    await patch({showcase:{src:'data:image/png;base64,'+fs.readFileSync(process.env.TRACE_SHOWCASE).toString('base64'),full:false},headline:'Circuit traces',subtitle:'Made for your composition',fontSize:{title:8},guide:{status:'confirmed',brand:{colors:{primary:'#00b8b1',background:'#0d1117'}}}});+    await ui.waitForFunction(()=>state.headline==='Circuit traces' && !window.__tracePending);+  }+  if(screenshotDir) {+    await ui.screenshot({path:path.join(screenshotDir,'wizard-field.png')});+    await (await ui.$('#hero')).screenshot({path:path.join(screenshotDir,'field.png')});+    await ui.locator('#wcards .card:has(.sw-circuit-tile)').click();+    await ui.waitForFunction(()=>state.theme==='circuit-tile' && !window.__tracePending);+    await ui.screenshot({path:path.join(screenshotDir,'wizard.png')});+    await (await ui.$('#hero')).screenshot({path:path.join(screenshotDir,'tile.png')});+  }+  ok('no browser runtime errors', errors.length===0);+  console.log('\n'+passed+' circuit checks passed');+} finally {+  if(browser) await browser.close(); server.kill(); registry.close();+}
test/syntax.mjs+2−1
@@ -9,7 +9,8 @@ import path from 'path'; import { fileURLToPath } from 'url'; const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const files = [-  ...fs.readdirSync(path.join(ROOT, 'public/js')).filter((f) => f.endsWith('.js')).map((f) => 'public/js/' + f),+  ...fs.readdirSync(path.join(ROOT, 'public/js')).filter((f) => /\.m?js$/.test(f)).map((f) => 'public/js/' + f),+  ...fs.readdirSync(path.join(ROOT, 'public/vendor/circuit-trace-generators')).filter((f) => f.endsWith('.js')).map((f) => 'public/vendor/circuit-trace-generators/' + f),   ...fs.readdirSync(ROOT).filter((f) => /\.(m|c)?js$/.test(f)),   ...fs.readdirSync(path.join(ROOT, 'test')).filter((f) => /\.m?js$/.test(f)).map((f) => 'test/' + f), ];