PageSourceSearch

https://topdowntabletop.com/shared/banner-fx.js

js topdowntabletop.com collected 2026-10-08 09:16:05 UTC 8,328 bytes, 269 lines download raw bytes

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.