Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
Add circuit generators that follow rendered text contours #3
Adds the user's Circuit Trace Generators to Hero Studio as Routed field and Seamless traces, composed around the actual rendered letters, showcase and badges. Open space between text lines and across the bottom remains available to the artwork. Colby (@kcknox), please review and approve before merging or releasing.
Routed field passes letter-contour obstacles to the upstream v9 router. DOM Range locates each rendered text run and its real line breaks; Canvas paints the loaded font at those positions, including cap trimming, letter spacing and inline accents. The resulting ink stencil is converted to compact scanline polygons, with adjustable clearance. There is no whole-column rectangle or reserved bottom strip. Actual solid objects such as the showcase and badges retain their own bounds.
Seamless traces keeps the upstream torus pattern and masks it against those same contours with a small soft edge. Both options use the page owner's colors, offer density/size/strength/clearance/seed controls in the wizard and Classic view, and preserve settings in snapshots, styles, change history and PNG provenance. Full-hero imports hide the generator.
Geometry is measured at the canonical export size and reused by the live hero, marquee and index-card previews. Font and image loading trigger remeasurement. Local/pup exports wait for the same artwork and report rendering errors. The homepage overlay already provides its own scrim; it does not change the baked routes.
The four engine files under public/vendor/circuit-trace-generators/ remain unmodified from adom/[email protected], source commit 2a73b150b944301c057318ae8a2e14d36fca514e. The adapter adds no external service or dependency. Pretext was investigated for text layout; this integration uses the existing browser layout and rendered font ink directly because it must follow exactly what the studio displays.
Validation: full npm test passed in the required Ubuntu WSL live mirror (Node 26.5.0 and headless Chromium): 23 syntax, 62 brand, 11 render-view, 15 renderer, 18 platform, and 36 circuit checks. The circuit suite independently compares the stencil against Chromium's painted DOM, checks open letter counters and visible traces in the lower quarter, exercises text/layout/seed changes and UI persistence, and verifies identical artwork in the studio and export for both generators. git diff --check passes. Both options were visually reviewed in the live studio with the user's current density, size, strength and layout settings.
This supersedes PR #2, whose file-set cannot be edited in place. It includes the complete 19-file integration against unchanged master 57a877dfa05beafd0eb3b9cdf8cf616da3a37df7 (0.22.1). No repository push, merge, version bump, deployment or package publication. Approval remains pending.
Diff Skip to comments (1)
@@ -1,43 +1,80 @@⋯ 25 unchanged lines ⋯ `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⋯ 9 unchanged lines ⋯ it, the driving AI may re-generate that page with a recorded attestation of the human's actual approval words; hard-fail layout lints lock every path. Full contract, drive verbs, and state reference: `SKILL.md`.+@@ -1,107 +1,109 @@⋯ 67 unchanged lines ⋯ "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" },⋯ 30 unchanged lines ⋯ "screenshots/hero.png" ] }+@@ -1,794 +1,804 @@⋯ 751 unchanged lines ⋯ .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 */⋯ 34 unchanged lines ⋯ body[data-render] main, body[data-render] #stage { display:block; padding:0; overflow:hidden; } body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; height:1000px; border-radius:0; border:none; box-shadow:none; }+@@ -1,374 +1,379 @@⋯ 127 unchanged lines ⋯ <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> ⋯ 68 unchanged lines ⋯ </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">⋯ 161 unchanged lines ⋯ <script src="js/wizard.js"></script> </body> </html>+@@ -1,1712 +1,1822 @@⋯ 375 unchanged lines ⋯ 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⋯ 210 unchanged lines ⋯ $('chk-author').checked = state.overlay.author; $('chk-slug').checked = state.overlay.slug; + scheduleTraces(); syncPreviews(); } ⋯ 381 unchanged lines ⋯ 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());⋯ 577 unchanged lines ⋯ } }); +/* 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 () {⋯ 141 unchanged lines ⋯ window._abDismissed = (state.approved || {}).ts; $('approvedbar').style.display = 'none'; });++@@ -0,0 +1,155 @@+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 => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[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 };+}+@@ -1,972 +1,980 @@⋯ 83 unchanged lines ⋯ { 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' }, ]; ⋯ 729 unchanged lines ⋯ 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')]);⋯ 1 unchanged line ⋯ 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)⋯ 137 unchanged lines ⋯ positionWizRing(); }); })();+@@ -1,520 +1,537 @@⋯ 517 unchanged lines ⋯ 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)); }+@@ -0,0 +1,13 @@+# 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.+@@ -0,0 +1,472 @@+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,+ };+ },+ };+}+@@ -0,0 +1,1831 @@+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 };+@@ -0,0 +1,1113 @@+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 };+@@ -0,0 +1,406 @@+// 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;+@@ -0,0 +1,2 @@+{"type":"module"}+@@ -1,34 +1,37 @@⋯ 16 unchanged lines ⋯ 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 });⋯ 9 unchanged lines ⋯ } finally { await browser.close(); }+@@ -1,152 +1,161 @@⋯ 59 unchanged lines ⋯ // 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.⋯ 84 unchanged lines ⋯ console.error('warning: cropped width ' + cw + ' is under the gate\'s 1600px floor; the finalize upscale will cost sharpness. A larger host window or higher devicePixelRatio fixes it.'); } console.log('OK ' + out + ' (' + cw + 'x' + ch + ' via host browser)');+@@ -1,1777 +1,1794 @@⋯ 99 unchanged lines ⋯ 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)⋯ 23 unchanged lines ⋯ '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() {⋯ 40 unchanged lines ⋯ 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'], };⋯ 167 unchanged lines ⋯ 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', };⋯ 538 unchanged lines ⋯ 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');⋯ 44 unchanged lines ⋯ } } 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 };⋯ 239 unchanged lines ⋯ 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 }));⋯ 460 unchanged lines ⋯ 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' +⋯ 110 unchanged lines ⋯ writeRegistry(); console.log('hero-studio on ' + PORT + ' (thread "' + AI_THREAD + '", ttl ' + (KEEP_ALIVE ? 'off' : TTL_HOURS + 'h') + ')'); });++@@ -0,0 +1,183 @@+// 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();+}+@@ -1,22 +1,24 @@⋯ 8 unchanged lines ⋯ 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), ];⋯ 4 unchanged lines ⋯ } console.log('\n' + (files.length - bad) + ' passed, ' + bad + ' failed'); process.exit(bad ? 1 : 0);+
Reviews
Comments
Log in to comment.