PageSourceSearch

https://www.blackhillsinfosec.com/_content/WebsiteBuilder.Blazor/j…/binary-tile-transition.js?v=7

js blackhillsinfosec.com collected 2026-09-24 09:07:12 UTC 35,700 bytes, 958 lines download raw bytes

1// Binary tile walk transitions — adapted from new_bnb_website PromoBinaryTransition.
2// Used when --site-bg-style is binary-aura for promo swaps, scroll entrance, and page-reveal.
3// Canvas grid of glyphs (0/1, hex, symbols…) walks on to cover, then walks off to reveal.
4
5import { isBinaryAuraMode, isConstrainedClient } from "./site-bg-style.js?v=4";
6
7const CELL_PX = 14;              // fixed glyph cell — bigger areas get more tiles, not bigger chars
8const WALK_IN_MS = 420;
9const WALK_OUT_MS = 500;
10const HOLD_MS = 120;
11
12const BIN = "01";
13const HEX = "0123456789ABCDEF";
14const SYMS = "{}[]<>/\\|;:$#@!&%^~=-+*.?_";
15const TOKENS = [
16    "0xDEAD", "0xBEEF", "0xC0DE", "CVE-2026", "TCP", "UDP",
17    "SYN", "ACK", "SSH", "DNS", "EXFIL", "INJECT", "PAYLOAD",
18    "NMAP", "ROOT", "HASH", "/bin/sh", "192.168", "10.0.0", ":443"
19];
20const BRAND_WORDS = [
21    "BACKDOORS", "BREACHES", "RECKAH", "ANTISYPHON", "WWHF",
22    "BHIS", "BLACKHILLS", "INFOSEC", "INCIDENT", "RESPONSE",
23    "HACKINFEST", "WILDWEST", "EXPLOIT", "DEFEND", "DETECT"
24];
25const HACK_PHRASES = [
26    "DECRYPTING...", "SCANNING...", "BREACH DETECTED",
27    "ACCESS GRANTED", "FIREWALL BYPASS", "ROOT ACQUIRED",
28    "PAYLOAD ARMED", "TUNNEL OPEN", "RECON COMPLETE",
29    "INJECTING...", "EXFILTRATING", "HASH CRACKED"
30];
31
32const GLYPH_MODES = [
33    "mixed", "mixed", "pureBinary", "hexDump", "brandScroll",
34    "hackTerminal", "symbolStorm", "countdown", "chase"
35];
36const LIGHT_GLYPH_MODES = ["mixed", "pureBinary", "hexDump", "symbolStorm"];
37
38const PATTERNS = [
39    orderRandom, orderDiagonalTL, orderDiagonalBR, orderDiagonalTR, orderDiagonalBL,
40    orderSpiralOut, orderSpiralIn, orderCathode,
41    orderColumnSweepLR, orderColumnSweepRL, orderRowSweepDown, orderRowSweepUp
42];
43
44const running = new WeakMap();
45
46export { isBinaryAuraMode, isConstrainedClient };
47
48function getPerfProfile(width, height) {
49    let prefersReducedMotion = false;
50    try {
51        prefersReducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
52    } catch { /* ignore */ }
53
54    const cores = Math.max(1, navigator.hardwareConcurrency || 4);
55    const memory = Math.max(1, navigator.deviceMemory || 4);
56    const area = Math.max(1, width * height);
57    const constrained = prefersReducedMotion || isConstrainedClient() || cores <= 4 || memory <= 4 || area >= 900000;
58    const medium = !constrained && (cores <= 8 || memory <= 8 || area >= 650000);
59    const tier = constrained ? "low" : (medium ? "medium" : "high");
60
61    // Character/cell size stays CELL_PX always — never enlarge glyphs on big surfaces.
62    // Tier only throttles glyph churn / DPR for perf.
63    if (tier === "low") {
64        return {
65            tier,
66            cellPx: CELL_PX,
67            glyphUpdateModulo: 10,
68            fontScale: 0.72,
69            maxDpr: 1,
70            forceGlyphMode: "pureBinary"
71        };
72    }
73    if (tier === "medium") {
74        return {
75            tier,
76            cellPx: CELL_PX,
77            glyphUpdateModulo: 7,
78            fontScale: 0.72,
79            maxDpr: 1.5,
80            useLightGlyphModes: true
81        };
82    }
83    return {
84        tier,
85        cellPx: CELL_PX,
86        glyphUpdateModulo: 4,
87        fontScale: 0.72,
88        maxDpr: 2
89    };
90}
91
92/** Fixed-size cells: cols/rows grow with the region so glyphs stay the same size. */
93function gridForSize(width, height, perf) {
94    const cell = Math.max(8, perf.cellPx || CELL_PX);
95    const cols = Math.max(1, Math.ceil(width / cell));
96    const rows = Math.max(1, Math.ceil(height / cell));
97    return { cols, rows };
98}
99
100/** Shared cell pitch across one or more panels (promo pair). */
101function gridsForPanels(sizes, perf) {
102    const cell = Math.max(8, perf.cellPx || CELL_PX);
103    const rows = Math.max(1, Math.ceil(Math.max(...sizes.map((s) => s.height)) / cell));
104    const colCounts = sizes.map((s) => Math.max(1, Math.ceil(s.width / cell)));
105    return { rows, colCounts };
106}
107
108/**
109 * Collect ink boxes for text lines + media inside host, in canvas-local coords
110 * (origin at canvas top-left = host left / offsetTop).
111 * Glyphs only paint where these boxes land — not a solid block square.
112 */
113function collectContentInkRects(host, offsetTop, canvasHeight) {
114    if (!host || canvasHeight < 4) return [];
115    const hostRect = host.getBoundingClientRect();
116    const canvasTop = hostRect.top + (offsetTop || 0);
117    const canvasBottom = canvasTop + canvasHeight;
118    const canvasLeft = hostRect.left;
119    const out = [];
120    const pad = 1;
121
122    function addViewportRect(r) {
123        if (!r || r.width < 2 || r.height < 2) return;
124        // Skip boxes outside the canvas band.
125        if (r.bottom <= canvasTop + 1 || r.top >= canvasBottom - 1) return;
126        if (r.right <= canvasLeft + 1 || r.left >= hostRect.right - 1) return;
127        out.push({
128            left: r.left - canvasLeft - pad,
129            top: r.top - canvasTop - pad,
130            right: r.right - canvasLeft + pad,
131            bottom: r.bottom - canvasTop + pad
132        });
133    }
134
135    function isSkippable(el) {
136        if (!el || el.nodeType !== 1) return true;
137        if (el.closest(".section-header")) return true;
138        if (el.matches && el.matches("script, style, noscript, .binary-tile-canvas, .scroll-hex-canvas, .promo-binary-canvas, .page-reveal-canvas, .page-reveal-binary-canvas")) {
139            return true;
140        }
141        return false;
142    }
143
144    // Media / replaced content.
145    const media = host.querySelectorAll(
146        "img, picture, video, svg, iframe, canvas, .card-grid-media, .image-block-frame, .feature-split-media, .site-hero-media"
147    );
148    for (const el of media) {
149        if (isSkippable(el)) continue;
150        if (el.matches && el.matches(".binary-tile-canvas, .scroll-hex-canvas, .promo-binary-canvas")) continue;
151        addViewportRect(el.getBoundingClientRect());
152    }
153
154    // Text line boxes (follows wrapped words, not the full paragraph square).
155    const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, {
156        acceptNode(node) {
157            if (!node || !node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
158            const parent = node.parentElement;
159            if (!parent || isSkippable(parent)) return NodeFilter.FILTER_REJECT;
160            if (parent.closest("script, style, noscript, .section-header")) return NodeFilter.FILTER_REJECT;
161            return NodeFilter.FILTER_ACCEPT;
162        }
163    });
164    const range = document.createRange();
165    let textNode = walker.nextNode();
166    let textBudget = 400;
167    while (textNode && textBudget-- > 0) {
168        try {
169            range.selectNodeContents(textNode);
170            const rects = range.getClientRects();
171            for (let i = 0; i < rects.length; i++) addViewportRect(rects[i]);
172        } catch { /* ignore */ }
173        textNode = walker.nextNode();
174    }
175
176    return out;
177}
178
179/** Mark grid cells whose center sits inside any ink rect. */
180function buildInkCellMask(cols, rows, width, height, inkRects) {
181    const total = cols * rows;
182    const mask = new Uint8Array(total);
183    if (!inkRects || inkRects.length === 0) {
184        mask.fill(1);
185        return mask;
186    }
187    const cellW = width / cols;
188    const cellH = height / rows;
189    for (let i = 0; i < total; i++) {
190        const c = i % cols;
191        const r = (i / cols) | 0;
192        const cx = (c + 0.5) * cellW;
193        const cy = (r + 0.5) * cellH;
194        for (let k = 0; k < inkRects.length; k++) {
195            const box = inkRects[k];
196            if (cx >= box.left && cx <= box.right && cy >= box.top && cy <= box.bottom) {
197                mask[i] = 1;
198                break;
199            }
200        }
201    }
202    // If measurement failed somehow, fall back to full cover.
203    let any = false;
204    for (let i = 0; i < total; i++) {
205        if (mask[i]) { any = true; break; }
206    }
207    if (!any) mask.fill(1);
208    return mask;
209}
210
211function resolveThemeBackgroundColor(el) {
212    let node = el;
213    while (node && node !== document.documentElement) {
214        const bg = (getComputedStyle(node).backgroundColor || "").trim();
215        if (isVisibleColor(bg)) return toOpaqueRgb(bg);
216        node = node.parentElement;
217    }
218    const rootBg = (getComputedStyle(document.documentElement).backgroundColor || "").trim();
219    if (isVisibleColor(rootBg)) return toOpaqueRgb(rootBg);
220    try {
221        const page = getComputedStyle(document.documentElement).getPropertyValue("--page-bg").trim();
222        if (page) return page;
223    } catch { /* ignore */ }
224    return "rgb(16, 18, 18)";
225}
226
227function resolveGlyphRgb() {
228    try {
229        const probe = document.createElement("span");
230        probe.style.color = "var(--aura-mid-color, var(--accent-gold, #f5b335))";
231        probe.hidden = true;
232        document.body.appendChild(probe);
233        const color = getComputedStyle(probe).color;
234        document.body.removeChild(probe);
235        const m = color && color.match(/[\d.]+/g);
236        if (m && m.length >= 3) {
237            return { r: +m[0], g: +m[1], b: +m[2] };
238        }
239    } catch { /* ignore */ }
240    return { r: 180, g: 230, b: 245 };
241}
242
243function isVisibleColor(value) {
244    return value && value !== "transparent" && value !== "rgba(0, 0, 0, 0)";
245}
246
247function toOpaqueRgb(color) {
248    const m = color.match(/^rgba?\(([^)]+)\)$/i);
249    if (!m) return color;
250    const parts = m[1].split(",").map((s) => s.trim());
251    if (parts.length < 3) return color;
252    const r = clampInt(parts[0], 0, 255);
253    const g = clampInt(parts[1], 0, 255);
254    const b = clampInt(parts[2], 0, 255);
255    return "rgb(" + r + ", " + g + ", " + b + ")";
256}
257
258function clampInt(v, min, max) {
259    let n = parseFloat(v);
260    if (!isFinite(n)) return min;
261    n = Math.round(n);
262    return n < min ? min : (n > max ? max : n);
263}
264
265function clamp(v, min, max) { return v < min ? min : (v > max ? max : v); }
266function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
267
268function createRng(seed) {
269    let x = (seed >>> 0) || 1;
270    return function () {
271        x ^= (x << 13) >>> 0;
272        x ^= x >>> 17;
273        x ^= (x << 5) >>> 0;
274        return (x >>> 0) / 4294967296;
275    };
276}
277
278function hashPulse(i, tick) {
279    return 0.5 + 0.5 * Math.sin((i * 0.37) + (tick * 0.41));
280}
281
282function shuffledIndices(total, rng) {
283    const arr = new Array(total);
284    for (let i = 0; i < total; i++) arr[i] = i;
285    for (let j = total - 1; j > 0; j--) {
286        const k = Math.floor(rng() * (j + 1));
287        const tmp = arr[j];
288        arr[j] = arr[k];
289        arr[k] = tmp;
290    }
291    return arr;
292}
293
294function buildScoredOrder(cols, rows, rng, scoreFn) {
295    const total = cols * rows;
296    const items = new Array(total);
297    for (let i = 0; i < total; i++) {
298        const c = i % cols;
299        const r = (i / cols) | 0;
300        items[i] = { idx: i, score: scoreFn(c, r) + rng() * 0.12 };
301    }
302    items.sort((a, b) => a.score - b.score);
303    return items.map((it) => it.idx);
304}
305
306function orderRandom(cols, rows, rng) { return shuffledIndices(cols * rows, rng); }
307function orderDiagonalTL(cols, rows, rng) {
308    return buildScoredOrder(cols, rows, rng, (c, r) => (c / Math.max(1, cols - 1)) + (r / Math.max(1, rows - 1)));
309}
310function orderDiagonalBR(cols, rows, rng) {
311    return buildScoredOrder(cols, rows, rng, (c, r) => (1 - c / Math.max(1, cols - 1)) + (1 - r / Math.max(1, rows - 1)));
312}
313function orderDiagonalTR(cols, rows, rng) {
314    return buildScoredOrder(cols, rows, rng, (c, r) => (1 - c / Math.max(1, cols - 1)) + (r / Math.max(1, rows - 1)));
315}
316function orderDiagonalBL(cols, rows, rng) {
317    return buildScoredOrder(cols, rows, rng, (c, r) => (c / Math.max(1, cols - 1)) + (1 - r / Math.max(1, rows - 1)));
318}
319function orderSpiralOut(cols, rows, rng) {
320    const cx = Math.max(1, (cols - 1) / 2);
321    const cy = Math.max(1, (rows - 1) / 2);
322    return buildScoredOrder(cols, rows, rng, (c, r) => {
323        const dx = (c - cx) / cx;
324        const dy = (r - cy) / cy;
325        return Math.sqrt(dx * dx + dy * dy);
326    });
327}
328function orderSpiralIn(cols, rows, rng) {
329    const cx = Math.max(1, (cols - 1) / 2);
330    const cy = Math.max(1, (rows - 1) / 2);
331    return buildScoredOrder(cols, rows, rng, (c, r) => {
332        const dx = (c - cx) / cx;
333        const dy = (r - cy) / cy;
334        return 2 - Math.sqrt(dx * dx + dy * dy);
335    });
336}
337function orderCathode(cols, rows, rng) {
338    const cx = Math.max(1, (cols - 1) / 2);
339    const cy = Math.max(1, (rows - 1) / 2);
340    return buildScoredOrder(cols, rows, rng, (c, r) => {
341        const dx = Math.abs(c - cx) / cx;
342        const dy = Math.abs(r - cy) / cy;
343        return Math.max(dx, dy);
344    });
345}
346function orderColumnSweepLR(cols, rows, rng) {
347    return buildScoredOrder(cols, rows, rng, (c) => c / Math.max(1, cols - 1));
348}
349function orderColumnSweepRL(cols, rows, rng) {
350    return buildScoredOrder(cols, rows, rng, (c) => 1 - c / Math.max(1, cols - 1));
351}
352function orderRowSweepDown(cols, rows, rng) {
353    return buildScoredOrder(cols, rows, rng, (c, r) => r / Math.max(1, rows - 1));
354}
355function orderRowSweepUp(cols, rows, rng) {
356    return buildScoredOrder(cols, rows, rng, (c, r) => 1 - r / Math.max(1, rows - 1));
357}
358
359function pickGlyphMode(rng, perf) {
360    if (perf && perf.forceGlyphMode) return perf.forceGlyphMode;
361    const pool = (perf && perf.useLightGlyphModes) ? LIGHT_GLYPH_MODES : GLYPH_MODES;
362    return pool[Math.floor(rng() * pool.length)];
363}
364
365function pickGlyph(idx, tick, mode, cols) {
366    const key = ((idx * 31) ^ (tick * 17) ^ 0x9e3779b9) >>> 0;
367    switch (mode) {
368        case "pureBinary":
369            return BIN.charAt((key + tick) & 1);
370        case "hexDump":
371            if (key % 12 === 0) return SYMS.charAt(key % SYMS.length);
372            return HEX.charAt((key + tick * 3) % HEX.length);
373        case "brandScroll": {
374            const word = BRAND_WORDS[(key + ((tick / 4) | 0)) % BRAND_WORDS.length];
375            const col = idx % (cols || 22);
376            if (key % 5 === 0) return BIN.charAt(key & 1);
377            return word.charAt((col + tick) % word.length);
378        }
379        case "hackTerminal": {
380            const phrase = HACK_PHRASES[(key + ((tick / 6) | 0)) % HACK_PHRASES.length];
381            if (key % 7 === 0) return HEX.charAt(key % HEX.length);
382            return phrase.charAt((idx + tick) % phrase.length);
383        }
384        case "symbolStorm":
385            if (key % 4 === 0) return BIN.charAt(key & 1);
386            if (key % 3 === 0) return HEX.charAt((key + tick) % HEX.length);
387            return SYMS.charAt((key + tick * 2) % SYMS.length);
388        case "countdown": {
389            const countVal = Math.abs(255 - ((idx + tick * 7) % 512));
390            const hexStr = countVal.toString(16).toUpperCase();
391            return hexStr.charAt((tick + idx) % Math.max(1, hexStr.length));
392        }
393        case "chase": {
394            const runner = ((tick * 3) % (cols || 22));
395            const col2 = idx % (cols || 22);
396            const dist = Math.abs(col2 - runner);
397            if (dist <= 1) return dist === 0 ? ">" : (col2 < runner ? "<" : ">");
398            if (key % 6 === 0) return SYMS.charAt(key % SYMS.length);
399            return BIN.charAt((key + tick) & 1);
400        }
401        default: {
402            const lane = key % 100;
403            if (lane < 40) return BIN.charAt(key & 1);
404            if (lane < 72) return HEX.charAt(key % HEX.length);
405            if (lane < 86) return SYMS.charAt(key % SYMS.length);
406            const token = TOKENS[key % TOKENS.length];
407            return token.charAt((tick + key) % token.length);
408        }
409    }
410}
411
412function ensureCanvas(host, width, height, perf, className, topPx) {
413    const cls = className || "binary-tile-canvas";
414    let canvas = host.querySelector(":scope > ." + cls.split(" ").join("."));
415    if (!canvas) {
416        canvas = document.createElement("canvas");
417        canvas.className = cls;
418        canvas.setAttribute("aria-hidden", "true");
419        host.appendChild(canvas);
420    }
421    const maxDpr = perf && perf.maxDpr ? perf.maxDpr : 2;
422    const dpr = Math.max(1, Math.min(maxDpr, window.devicePixelRatio || 1));
423    canvas.width = Math.floor(width * dpr);
424    canvas.height = Math.floor(height * dpr);
425    canvas.style.cssText =
426        "position:absolute;left:0;width:100%;pointer-events:none;z-index:29;border-radius:inherit;"
427        + "top:" + (topPx || 0) + "px;height:" + height + "px;";
428
429    const ctx = canvas.getContext("2d");
430    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
431    ctx.clearRect(0, 0, width, height);
432    ctx.textAlign = "center";
433    ctx.textBaseline = "middle";
434    return { canvas, ctx, width, height, host, perf, glyphRgb: resolveGlyphRgb() };
435}
436
437function clearCanvas(state) {
438    if (!state || !state.ctx) return;
439    state.ctx.clearRect(0, 0, state.width, state.height);
440}
441
442function removeCanvas(state) {
443    if (!state) return;
444    clearCanvas(state);
445    if (state.canvas && state.canvas.parentNode) {
446        state.canvas.parentNode.removeChild(state.canvas);
447    }
448}
449
450function drawTiles(state, cols, rows, pixelOn, glyphs, elapsed, themeBg, glyphMode, perf) {
451    const ctx = state.ctx;
452    const width = state.width;
453    const height = state.height;
454    const cellW = width / cols;
455    const cellH = height / rows;
456    const tick = (elapsed * 0.038) | 0;
457    const updateModulo = perf && perf.glyphUpdateModulo ? perf.glyphUpdateModulo : 4;
458    const fontScale = perf && perf.fontScale ? perf.fontScale : 0.74;
459    // Font tracks CELL_PX, not stretched cell boxes — tall/wide blocks keep the same glyph size.
460    const fontPx = Math.max(8, Math.floor((perf && perf.cellPx ? perf.cellPx : CELL_PX) * fontScale));
461    const base = state.glyphRgb || { r: 180, g: 230, b: 245 };
462
463    ctx.font = fontPx + 'px "Courier New", ui-monospace, monospace';
464    ctx.clearRect(0, 0, width, height);
465
466    for (let i = 0; i < pixelOn.length; i++) {
467        if (!pixelOn[i]) continue;
468
469        if (((tick + i * 3) % updateModulo) === 0) {
470            glyphs[i] = pickGlyph(i, tick, glyphMode, cols);
471        }
472
473        const x = i % cols;
474        const y = (i / cols) | 0;
475        const px0 = Math.floor(x * cellW);
476        const py0 = Math.floor(y * cellH);
477        const pw = Math.ceil((x + 1) * cellW) - px0;
478        const ph = Math.ceil((y + 1) * cellH) - py0;
479
480        ctx.globalAlpha = 1;
481        ctx.fillStyle = themeBg;
482        ctx.fillRect(px0, py0, pw, ph);
483
484        const flicker = 0.55 + 0.45 * hashPulse(i, tick);
485        const huePulse = hashPulse(i * 3 + 7, tick);
486        const r = clampInt(base.r + huePulse * 40, 0, 255);
487        const g = clampInt(base.g + huePulse * 25, 0, 255);
488        const b = clampInt(base.b + huePulse * 15, 0, 255);
489
490        ctx.fillStyle = "rgba(" + r + "," + g + "," + b + "," + (0.62 * flicker).toFixed(3) + ")";
491        ctx.fillText(glyphs[i], px0 + pw * 0.5, py0 + ph * 0.5);
492    }
493    ctx.globalAlpha = 1;
494}
495
496function stopHost(host) {
497    const state = running.get(host);
498    if (!state) return;
499    if (state.rafId) cancelAnimationFrame(state.rafId);
500    if (state.canvasState) removeCanvas(state.canvasState);
501    if (state.canvasStates) {
502        for (const cs of state.canvasStates) removeCanvas(cs);
503    }
504    running.delete(host);
505}
506
507/**
508 * Scroll / block entrance: hex-equivalent — start fully covered, show content
509 * underneath, then walk tiles out once. (Promo swaps still use playCoverReveal.)
510 * Glyphs stick to text-line / media ink boxes instead of a solid block square.
511 */
512export function revealRegion(host, rect, offsetTop, onDone, beforeAnimate) {
513    if (!host || !rect || rect.width < 4 || rect.height < 4) {
514        if (beforeAnimate) beforeAnimate();
515        if (onDone) onDone();
516        return;
517    }
518
519    // iOS Safari OOMs / watchdog-kills on full-block glyph canvases — skip cover.
520    if (isConstrainedClient()) {
521        if (beforeAnimate) beforeAnimate();
522        if (onDone) onDone();
523        return;
524    }
525
526    const prevPosition = host.style.position;
527    if (getComputedStyle(host).position === "static") {
528        host.style.position = "relative";
529    }
530
531    const top = offsetTop || 0;
532    const width = Math.max(4, rect.width);
533    const height = Math.max(4, rect.height);
534    let inkRects = [];
535    try {
536        inkRects = collectContentInkRects(host, top, height);
537    } catch { /* fall back to full mask */ }
538
539    playEntryReveal([{
540        el: host,
541        width,
542        height,
543        top,
544        inkRects
545    }], {
546        onReady: () => {
547            if (beforeAnimate) beforeAnimate();
548        },
549        onDone: () => {
550            if (prevPosition !== undefined) host.style.position = prevPosition || "";
551            if (onDone) onDone();
552        }
553    });
554}
555
556/**
557 * Promo slide swap: walk-in cover → onCovered (swap) → hold → walk-out.
558 * panels: array of { el, width?, height?, top? } (1 or 2 for paired rails).
559 */
560export function playCoverReveal(panels, { onCovered, onDone, onRaf } = {}) {
561    const list = (panels || []).filter((p) => p && p.el);
562    if (list.length === 0) {
563        if (onDone) onDone();
564        return;
565    }
566
567    for (const p of list) stopHost(p.el);
568
569    const sizes = list.map((p) => ({
570        el: p.el,
571        width: p.width != null ? Math.max(4, p.width) : Math.max(140, p.el.clientWidth || 0),
572        height: p.height != null ? Math.max(4, p.height) : Math.max(180, p.el.clientHeight || 0
572),
573        top: p.top || 0
574    }));
575    const totalW = sizes.reduce((s, x) => s + x.width, 0);
576    const maxH = Math.max(...sizes.map((x) => x.height));
577    const perf = getPerfProfile(totalW, maxH);
578    const { rows, colCounts } = gridsForPanels(sizes, perf);
579    const panelCount = sizes.length;
580    const panelTotals = colCounts.map((c) => c * rows);
581    const wideCols = colCounts.reduce((a, c) => a + c, 0);
582    const wideTotal = wideCols * rows;
583    const colOffsets = [];
584    {
585        let acc = 0;
586        for (let i = 0; i < panelCount; i++) {
587            colOffsets.push(acc);
588            acc += colCounts[i];
589        }
590    }
591    const themeBg = resolveThemeBackgroundColor(sizes[0].el);
592    const rng = createRng(((Date.now() ^ (totalW << 8) ^ (maxH << 4)) >>> 0) + 1);
593    const patternFn = PATTERNS[Math.floor(rng() * PATTERNS.length)];
594    const glyphMode = pickGlyphMode(rng, perf);
595    const wideOrder = patternFn(wideCols, rows, rng);
596
597    const canvasStates = sizes.map((s) =>
598        ensureCanvas(s.el, s.width, s.height, perf, "binary-tile-canvas promo-binary-canvas", s.top));
599    const pixelOns = panelTotals.map((n) => new Uint8Array(n));
600    const glyphsArr = panelTotals.map((n, pi) => {
601        const g = new Array(n);
602        for (let i = 0; i < n; i++) g[i] = pickGlyph(i + pi * 997, 0, glyphMode, colCounts[pi]);
603        return g;
604    });
605
606    function wideToPanel(wideIdx) {
607        const wx = wideIdx % wideCols;
608        const wy = (wideIdx / wideCols) | 0;
609        let side = panelCount - 1;
610        for (let i = 0; i < panelCount; i++) {
611            if (wx < colOffsets[i] + colCounts[i]) {
612                side = i;
613                break;
614            }
615        }
616        const localX = wx - colOffsets[side];
617        return { side, local: wy * colCounts[side] + localX, cols: colCounts[side] };
618    }
619
620    let walkInCount = 0;
621    let walkOutCount = 0;
622    let phase = 0;
623    let holdStart = 0;
624    let startTs = 0;
625    let rafId = 0;
626    let coveredFired = false;
627    const safety = setTimeout(() => finish(), WALK_IN_MS + HOLD_MS + WALK_OUT_MS + 2000);
628
629    function drawAll(elapsed) {
630        for (let i = 0; i < panelCount; i++) {
631            drawTiles(canvasStates[i], colCounts[i], rows, pixelOns[i], glyphsArr[i], elapsed, themeBg, glyphMode, perf);
632        }
633    }
634
635    function finish() {
636        clearTimeout(safety);
637        if (rafId) cancelAnimationFrame(rafId);
638        for (const cs of canvasStates) removeCanvas(cs);
639        for (const s of sizes) running.delete(s.el);
640        if (onDone) onDone();
641    }
642
643    function frame(ts) {
644        if (!startTs) startTs = ts;
645        const elapsed = ts - startTs;
646
647        if (phase === 0) {
648            const progress = clamp(elapsed / WALK_IN_MS, 0, 1);
649            const targetOn = Math.floor(easeInOutCubic(progress) * wideTotal);
650            while (walkInCount < targetOn && walkInCount < wideTotal) {
651                const cell = wideToPanel(wideOrder[walkInCount]);
652                pixelOns[cell.side][cell.local] = 1;
653                walkInCount++;
654            }
655            drawAll(elapsed);
656            if (walkInCount >= wideTotal) {
657                phase = 1;
658                holdStart = ts;
659                if (!coveredFired) {
660                    coveredFired = true;
661                    if (onCovered) onCovered();
662                }
663            }
664        } else if (phase === 1) {
665            drawAll(elapsed);
666            if (ts - holdStart >= HOLD_MS) {
667                phase = 2;
668                walkOutCount = 0;
669            }
670        } else {
671            const walkOutElapsed = ts - holdStart - HOLD_MS;
672            const progress2 = clamp(walkOutElapsed / WALK_OUT_MS, 0, 1);
673            const targetOff = Math.floor(easeInOutCubic(progress2) * wideTotal);
674            while (walkOutCount < targetOff && walkOutCount < wideTotal) {
675                const cell2 = wideToPanel(wideOrder[wideTotal - 1 - walkOutCount]);
676                pixelOns[cell2.side][cell2.local] = 0;
677                walkOutCount++;
678            }
679            drawAll(elapsed);
680            if (walkOutCount >= wideTotal) {
681                finish();
682                return;
683            }
684        }
685
686        rafId = requestAnimationFrame(frame);
687        if (onRaf) onRaf(rafId);
688        for (const s of sizes) {
689            running.set(s.el, { rafId, canvasStates });
690        }
691    }
692
693    rafId = requestAnimationFrame(frame);
694    if (onRaf) onRaf(rafId);
695    for (const s of sizes) running.set(s.el, { rafId, canvasStates });
696}
697
698/** Entry reveal: start fully covered, walk out (page-reveal / first promo paint). */
699export function playEntryReveal(panels, { onReady, onDone, onRaf } = {}) {
700    const list = (panels || []).filter((p) => p && p.el);
701    if (list.length === 0) {
702        if (onReady) onReady();
703        if (onDone) onDone();
704        return;
705    }
706
707    for (const p of list) stopHost(p.el);
708
709    const sizes = list.map((p) => ({
710        el: p.el,
711        width: p.width != null ? Math.max(4, p.width) : Math.max(140, p.el.clientWidth || 0),
712        height: p.height != null ? Math.max(4, p.height) : Math.max(120, p.el.clientHeight || 0
712),
713        top: p.top || 0,
714        inkRects: p.inkRects || null
715    }));
716    const totalW = sizes.reduce((s, x) => s + x.width, 0);
717    const maxH = Math.max(...sizes.map((x) => x.height));
718    const perf = getPerfProfile(totalW, maxH);
719    const { rows, colCounts } = gridsForPanels(sizes, perf);
720    const panelCount = sizes.length;
721    const panelTotals = colCounts.map((c) => c * rows);
722    const wideCols = colCounts.reduce((a, c) => a + c, 0);
723    const colOffsets = [];
724    {
725        let acc = 0;
726        for (let i = 0; i < panelCount; i++) {
727            colOffsets.push(acc);
728            acc += colCounts[i];
729        }
730    }
731    const themeBg = resolveThemeBackgroundColor(sizes[0].el);
732    const rng = createRng(((Date.now() ^ (totalW << 8) ^ (maxH << 4)) >>> 0) + 1);
733    const patternFn = PATTERNS[Math.floor(rng() * PATTERNS.length)];
734    const glyphMode = pickGlyphMode(rng, perf);
735    const wideOrder = patternFn(wideCols, rows, rng);
736
737    const cellMasks = sizes.map((s, pi) =>
738        buildInkCellMask(colCounts[pi], rows, s.width, s.height, s.inkRects));
739
740    function wideToPanel(wideIdx) {
741        const wx = wideIdx % wideCols;
742        const wy = (wideIdx / wideCols) | 0;
743        let side = panelCount - 1;
744        for (let i = 0; i < panelCount; i++) {
745            if (wx < colOffsets[i] + colCounts[i]) {
746                side = i;
747                break;
748            }
749        }
750        const localX = wx - colOffsets[side];
751        return { side, local: wy * colCounts[side] + localX };
752    }
753
754    // Only cells that sit on text/media ink participate in the walk.
755    const activeOrder = [];
756    for (let i = 0; i < wideOrder.length; i++) {
757        const cell = wideToPanel(wideOrder[i]);
758        if (cellMasks[cell.side][cell.local]) activeOrder.push(wideOrder[i]);
759    }
760    const activeTotal = activeOrder.length;
761
762    const canvasStates = sizes.map((s) =>
763        ensureCanvas(s.el, s.width, s.height, perf, "binary-tile-canvas promo-binary-canvas", s.top));
764    const pixelOns = panelTotals.map((n, pi) => {
765        const a = new Uint8Array(n);
766        const mask = cellMasks[pi];
767        for (let i = 0; i < n; i++) a[i] = mask[i] ? 1 : 0;
768        return a;
769    });
770    const glyphsArr = panelTotals.map((n, pi) => {
771        const g = new Array(n);
772        for (let i = 0; i < n; i++) g[i] = pickGlyph(i + pi * 997, 0, glyphMode, colCounts[pi]);
773        return g;
774    });
775
776    for (let i = 0; i < panelCount; i++) {
777        drawTiles(canvasStates[i], colCounts[i], rows, pixelOns[i], glyphsArr[i], 0, themeBg, glyphMode, perf);
778    }
779    // Content becomes visible under the ink-shaped cover, then tiles walk off once.
780    if (onReady) onReady();
781
782    let walkOutCount = 0;
783    let startTs = 0;
784    let rafId = 0;
785    const safety = setTimeout(() => finish(), WALK_OUT_MS + 1500);
786
787    function finish() {
788        clearTimeout(safety);
789        if (rafId) cancelAnimationFrame(rafId);
790        for (const cs of canvasStates) removeCanvas(cs);
791        for (const s of sizes) running.delete(s.el);
792        if (onDone) onDone();
793    }
794
795    function frame(ts) {
796        if (!startTs) startTs = ts;
797        const elapsed = ts - startTs;
798        const progress = clamp(elapsed / WALK_OUT_MS, 0, 1);
799        const targetOff = Math.floor(easeInOutCubic(progress) * activeTotal);
800        while (walkOutCount < targetOff && walkOutCount < activeTotal) {
801            const cell = wideToPanel(activeOrder[activeTotal - 1 - walkOutCount]);
802            pixelOns[cell.side][cell.local] = 0;
803            walkOutCount++;
804        }
805        for (let i = 0; i < panelCount; i++) {
806            drawTiles(canvasStates[i], colCounts[i], rows, pixelOns[i], glyphsArr[i], elapsed, themeBg, glyphMode, perf);
807        }
808        if (walkOutCount >= activeTotal) {
809            finish();
810            return;
811        }
812        rafId = requestAnimationFrame(frame);
813        if (onRaf) onRaf(rafId);
814        for (const s of sizes) running.set(s.el, { rafId, canvasStates });
815    }
816
817    rafId = requestAnimationFrame(frame);
818    if (onRaf) onRaf(rafId);
819    for (const s of sizes) running.set(s.el, { rafId, canvasStates });
820}
821
822/**
823 * Full-viewport page leave cover (walk-in), returns a handle with promote/abort.
824 */
825export function beginViewportCover({ onDone, startFullyCovered } = {}) {
826    // iPhone Safari: a full-viewport fillText grid at load is a hard crash.
827    if (isConstrainedClient()) {
828        let disposed = false;
829        const finish = () => {
830            if (disposed) return;
831            disposed = true;
832            if (typeof onDone === "function") {
833                try { onDone(); } catch { /* ignore */ }
834            }
835        };
836        if (startFullyCovered) {
837            // Match "already covered → reveal" without allocating a canvas.
838            queueMicrotask(finish);
839        }
840        return {
841            promoteToReveal() { finish(); },
842            abort: finish,
843            get disposed() { return disposed; },
844            get phase() { return disposed ? "done" : (startFullyCovered ? "reveal" : "leave"); }
845        };
846    }
847
848    const host = document.body;
849    stopHost(host);
850
851    const width = window.innerWidth;
852    const height = window.innerHeight;
853    const perf = getPerfProfile(width, height);
854    const { cols, rows } = gridForSize(width, height, perf);
855    const total = cols * rows;
856    const themeBg = resolveThemeBackgroundColor(document.documentElement);
857    const canvas = document.createElement("canvas");
858    canvas.className = "binary-tile-canvas page-reveal-binary-canvas";
859    canvas.setAttribute("aria-hidden", "true");
860    canvas.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:30;";
861    const dpr = Math.max(1, Math.min(perf.maxDpr || 2, window.devicePixelRatio || 1));
862    canvas.width = Math.floor(width * dpr);
863    canvas.height = Math.floor(height * dpr);
864    document.body.appendChild(canvas);
865    const ctx = canvas.getContext("2d");
866    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
867    ctx.textAlign = "center";
868    ctx.textBaseline = "middle";
869    const canvasState = { canvas, ctx, width, height, host, perf, glyphRgb: resolveGlyphRgb() };
870
871    const rng = createRng(((Date.now() ^ (width << 8)) >>> 0) + 1);
872    const patternFn = PATTERNS[Math.floor(rng() * PATTERNS.length)];
873    const glyphMode = pickGlyphMode(rng, perf);
874    const walkOrder = patternFn(cols, rows, rng);
875    const pixelOn = new Uint8Array(total);
876    const glyphs = new Array(total);
877    for (let i = 0; i < total; i++) glyphs[i] = pickGlyph(i, 0, glyphMode, cols);
878
879    let walkInCount = 0;
880    let walkOutCount = 0;
881    let phase = "leave"; // leave | hold | reveal
882    let startTs = 0;
883    let revealStart = 0;
884    let rafId = 0;
885    let disposed = false;
886
887    if (startFullyCovered) {
888        pixelOn.fill(1);
889        walkInCount = total;
890        phase = "reveal";
891        drawTiles(canvasState, cols, rows, pixelOn, glyphs, 0, themeBg, glyphMode, perf);
892    }
893
894    function dispose() {
895        if (disposed) return;
896        disposed = true;
897        if (rafId) cancelAnimationFrame(rafId);
898        if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
899        running.delete(host);
900        if (typeof onDone === "function") {
901            try { onDone(); } catch { /* ignore */ }
902        }
903    }
904
905    function frame(ts) {
906        if (disposed) return;
907        if (!startTs) startTs = ts;
908        const elapsed = ts - startTs;
909
910        if (phase === "leave") {
911            const progress = clamp(elapsed / WALK_IN_MS, 0, 1);
912            const targetOn = Math.floor(easeInOutCubic(progress) * total);
913            while (walkInCount < targetOn && walkInCount < total) {
914                pixelOn[walkOrder[walkInCount]] = 1;
915                walkInCount++;
916            }
917            drawTiles(canvasState, cols, rows, pixelOn, glyphs, elapsed, themeBg, glyphMode, perf);
918            if (walkInCount >= total) {
919                phase = "hold";
920            }
921        } else if (phase === "hold") {
922            drawTiles(canvasState, cols, rows, pixelOn, glyphs, elapsed, themeBg, glyphMode, perf);
923        } else if (phase === "reveal") {
924            if (!revealStart) revealStart = ts;
925            const revElapsed = ts - revealStart;
926            const progress = clamp(revElapsed / WALK_OUT_MS, 0, 1);
927            const targetOff = Math.floor(easeInOutCubic(progress) * total);
928            while (walkOutCount < targetOff && walkOutCount < total) {
929                pixelOn[walkOrder[total - 1 - walkOutCount]] = 0;
930                walkOutCount++;
931            }
932            drawTiles(canvasState, cols, rows, pixelOn, glyphs, elapsed, themeBg, glyphMode, perf);
933            if (walkOutCount >= total) {
934                dispose();
935                return;
936            }
937        }
938
939        rafId = requestAnimationFrame(frame);
940        running.set(host, { rafId, canvasState });
941    }
942
943    rafId = requestAnimationFrame(frame);
944    running.set(host, { rafId, canvasState });
945
946    return {
947        promoteToReveal() {
948            if (disposed) return;
949            pixelOn.fill(1);
950            walkOutCount = 0;
951            phase = "reveal";
952            revealStart = 0;
953        },
954        abort: dispose,
955        get disposed() { return disposed; },
956        get phase() { return phase; }
957    };
958}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.