1/* DOM/canvas shell for the shared `.tdtt-banner[data-fx="bold"]` FX layer. 2 * 3 * Hand-written, like overlay.js next to it: this file owns canvas discovery, 4 * sizing, the shared animation loop, visibility gating and reduced-motion -- 5 * every actual pixel is drawn by window.TDTT.bannerFxCore (generated from 6 * packages/shared/src/design/banner-fx.ts, unit tested there). Keep it that 7 * way: drawing logic added here is logic that cannot be tested. 8 * 9 * Load order: banner-fx-core.js, then this. If bannerFxCore isn't present 10 * (missing script tag, load-order slip), every entry point here is a no-op -- 11 * the CSS-only banner (no canvas) still reads as a finished design. 12 * 13 * window.TDTT.bannerFx.refresh() scans the document for banners that opted 14 * into the FX (`data-fx="bold"` + a `<canvas class="tdtt-banner__fx">` child) 15 * and registers any that aren't registered yet. It is safe -- and expected -- 16 * to call this on every render(): registration is idempotent (a registered 17 * canvas is tagged `data-fx-reg`), and a canvas that has fallen out of the 18 * document (the common case: a section replaced its banner's innerHTML) is 19 * dropped from the registry the next time it's touched, so nothing leaks and 20 * nothing animates orphaned off-DOM canvases. 21 * 22 * window.TDTT.bannerFx.setMotion(bool) is the manual override sitting next to 23 * `prefers-reduced-motion`. Either "off" state stops the shared rAF loop and 24 * leaves every registered banner painted with one static frame; either "on" 25 * state (motion enabled AND the OS preference isn't reduce) resumes it. 26 */ 27(function () { 28 'use strict'; 29 30 var STATIC_T = 4200; // fixed frame time used whenever motion is off 31 32 var registry = []; // [{ el, canvas, ctx, cfg, accent, state, w, h, visible }] 33 var io = null; 34 var rafId = null; 35 var loopStart = null; // performance.now() at the first tick of the current run 36 var resizeTimer = null; 37 var userMotionEnabled = true; 38 39 var reducedMotionMql = 40 typeof window.matchMedia === 'function' 41 ? window.matchMedia('(prefers-reduced-motion: reduce)') 42 : null; 43 44 function core() { 45 return window.TDTT && window.TDTT.bannerFxCore; 46 } 47 48 function effectiveMotion() { 49 if (!userMotionEnabled) return false; 50 if (reducedMotionMql && reducedMotionMql.matches) return false; 51 return true; 52 } 53 54 function getAccent(el) { 55 var cs = window.getComputedStyle(el); 56 var v = (cs.getPropertyValue('--fx-accent') || '').trim(); 57 if (!v) v = (cs.getPropertyValue('--ac') || '').trim(); 58 if (!v) v = '#0AE3F1'; 59 return v; 60 } 61 62 function sizeCanvas(canvas) { 63 var dpr = Math.min(window.devicePixelRatio || 1, 2); 64 var rect = canvas.getBoundingClientRect(); 65 var w = Math.max(1, Math.round(rect.width)); 66 var h = Math.max(1, Math.round(rect.height)); 67 canvas.width = Math.round(w * dpr); 68 canvas.height = Math.round(h * dpr); 69 var ctx = canvas.getContext('2d'); 70 if (!ctx) return null; 71 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 72 return { ctx: ctx, w: w, h: h }; 73 } 74 75 function ensureIO() { 76 if (io || typeof IntersectionObserver === 'undefined') return; 77 io = new IntersectionObserver( 78 function (ioEntries) { 79 for (var i = 0; i < ioEntries.length; i++) { 80 var ioEntry = ioEntries[i]; 81 var entry = findByCanvas(ioEntry.target); 82 if (!entry) continue; 83 entry.visible = ioEntry.isIntersecting; 84 } 85 if (effectiveMotion()) ensureLoopRunning(); 86 }, 87 { threshold: 0.02 } 88 ); 89 } 90 91 function findByCanvas(canvas) { 92 for (var i = 0; i < registry.length; i++) { 93 if (registry[i].canvas === canvas) return registry[i]; 94 } 95 return null; 96 } 97 98 function drawStatic(entry) { 99 var c = core(); 100 if (!c) return; 101 c.drawBannerFrame(entry.ctx, entry.w, entry.h, STATIC_T, entry.cfg, entry.accent, entry.state); 102 } 103 104 function drawAllStatic() { 105 pruneDetached(); 106 for (var i = 0; i < registry.length; i++) drawStatic(registry[i]); 107 } 108 109 function pruneDetached() { 110 if (!registry.length) return; 111 registry = registry.filter(function (entry) { 112 if (document.contains(entry.canvas)) return true; 113 if (io) { 114 try { 115 io.unobserve(entry.canvas); 116 } catch (e) { 117 /* already detached from the observer; ignore */ 118 } 119 } 120 return false;
121 }); 122 } 123 124 function tick(now) { 125 pruneDetached(); 126 if (!effectiveMotion() || !registry.length) { 127 rafId = null; 128 loopStart = null; 129 return; 130 } 131 if (loopStart === null) loopStart = now; 132 var t = now - loopStart; 133 var c = core(); 134 if (c) { 135 for (var i = 0; i < registry.length; i++) { 136 var entry = registry[i]; 137 if (!entry.visible) continue; 138 c.drawBannerFrame(entry.ctx, entry.w, entry.h, t, entry.cfg, entry.accent, entry.state); 139 } 140 } 141 rafId = requestAnimationFrame(tick); 142 } 143 144 function ensureLoopRunning() { 145 if (rafId !== null) return; 146 if (!effectiveMotion()) return; 147 if (!registry.length) return; 148 rafId = requestAnimationFrame(tick); 149 } 150 151 function stopLoop() { 152 if (rafId !== null) { 153 cancelAnimationFrame(rafId); 154 rafId = null; 155 } 156 loopStart = null; 157 } 158 159 function registerBanner(el) { 160 var canvas = el.querySelector('canvas.tdtt-banner__fx'); 161 if (!canvas || canvas.getAttribute('data-fx-reg') === '1') return; 162 163 var c = core(); 164 if (!c) return; 165 166 var sized = sizeCanvas(canvas); 167 if (!sized) return; // getContext('2d') failed -- skip, CSS-only banner stands 168 var accent = getAccent(el); 169 var variant = el.getAttribute('data-variant'); 170 var cfg = (c.PRESETS && c.PRESETS[variant]) || c.PRESETS.community; 171 var state = c.createBannerFxState(sized.w, sized.h, cfg, accent); 172 173 var entry = { 174 el: el, 175 canvas: canvas, 176 ctx: sized.ctx, 177 w: sized.w, 178 h: sized.h, 179 cfg: cfg, 180 accent: accent, 181 state: state, 182 visible: !io, // no IntersectionObserver support -> assume visible 183 }; 184 185 canvas.setAttribute('data-fx-reg', '1'); 186 registry.push(entry); 187 188 if (io) io.observe(canvas); 189 190 if (effectiveMotion()) { 191 ensureLoopRunning(); 192 } else { 193 drawStatic(entry); 194 } 195 } 196 197 function resizeEntry(entry) { 198 var c = core(); 199 if (!c) return; 200 var sized = sizeCanvas(entry.canvas); 201 if (!sized) return; // getContext('2d') failed -- leave the entry as-is 202 entry.ctx = sized.ctx; 203 entry.w = sized.w; 204 entry.h = sized.h; 205 // Reseed rather than resample -- orb/ember positions were chosen for the 206 // old dimensions and a resize (e.g. a rotation) can be a large jump. 207 entry.state = c.createBannerFxState(sized.w, sized.h, entry.cfg, entry.accent); 208 } 209 210 function onResize() { 211 pruneDetached(); 212 for (var i = 0; i < registry.length; i++) resizeEntry(registry[i]); 213 if (!effectiveMotion()) drawAllStatic(); 214 } 215 216 function scheduleResize() { 217 if (resizeTimer) clearTimeout(resizeTimer); 218 resizeTimer = setTimeout(onResize, 150); 219 } 220 221 function onMotionPrefChange() { 222 if (effectiveMotion()) { 223 loopStart = null; 224 ensureLoopRunning(); 225 } else { 226 stopLoop(); 227 drawAllStatic(); 228 } 229 } 230 231 function refresh() { 232 if (!core()) return; 233 ensureIO(); 234 pruneDetached(); 235 var els = document.querySelectorAll('.tdtt-banner[data-fx="bold"]'); 236 for (var i = 0; i < els.length; i++) registerBanner(els[i]); 237 } 238 239 function setMotion(enabled) { 240 userMotionEnabled = !!enabled; 241 onMotionPrefChange(); 242 } 243 244 function init() { 245 if (!core()) return; // no core loaded -- CSS-only banner stands, nothing to do 246 refresh(); 247 window.addEventListener('resize', scheduleResize); 248 if (reducedMotionMql) { 249 if (typeof reducedMotionMql.addEventListener === 'function') { 250 reducedMotionMql.addEventListener('change', onMotionPrefChange); 251 } else if (typeof reducedMotionMql.addListener === 'function') { 252 // Safari < 14 / older WebViews. 253 reducedMotionMql.addListener(onMotionPrefChange); 254 } 255 } 256 } 257 258 window.TDTT = window.TDTT || {}; 259 window.TDTT.bannerFx = { 260 refresh: refresh, 261 setMotion: setMotion, 262 }; 263 264 if (document.readyState === 'loading') { 265 document.addEventListener('DOMContentLoaded', init); 266 } else { 267 init(); 268 } 269})();
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.