rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1/* light.js: the light. First-party, dependency-free, and deletable:
2 remove this file and its script tag and the page is the still depth
3 design, because every property here has a default the stylesheet
4 already carries.
5
6 What it does: lights the page from where the sun is at this hour
7 (from the left in the morning, overhead at noon, from the right in
8 the evening); puts the pointer's position on the button under it, so
9 the button catches a sheen there; puts a press's position on the
10 button, for a mouse and for a touch, so the press darkens most where
11 it landed; and lets a card breathe: its rim brightens near the
12 pointer over about a second and fades when the pointer leaves. The
13 whole page's light can follow the pointer instead: set FOLLOW below
14 to true. It is off because every write of it restyles the page.
15
16 What it writes: --rst-light-x and --rst-light-y on the root, from -1
17 to 1; --rst-mx, --rst-my, --rst-px and --rst-py on a button, in the
18 button's own pixels; --rst-card-x, --rst-card-y and --rst-card-lit
19 on a card. Nothing else. It never writes on a switch, so a drag
20 rastrillo.js owns (data-rst-drag) never meets it.
21
22 What it never does: capture the pointer, read a sensor (no ambient
23 light, no tilt: both need a permission or a flag), or run under
24 prefers-reduced-motion or forced colours, where it adds no listener
25 and clears what it wrote if the setting turns on mid-session. One
26 animation frame loop, running only while something is still easing;
27 the hour is read again every five minutes; the loop pauses while the
28 page is hidden.
29
30 For a test: dispatch a CustomEvent "rst:light" on the document with
31 detail {x, y, hold}; hold keeps the light there against pointer
32 moves. */
33(function () {
34 "use strict";
35 var root = document.documentElement;
36 // Without matchMedia, or with one that cannot report a change (Safari
37 // before 14), the reduced-motion and forced-colours checks cannot be
38 // made or kept, so the page keeps the still design rather than a
39 // light that ignores them.
40 if (!window.matchMedia || !window.matchMedia("all").addEventListener) return;
41 var reduce = matchMedia("(prefers-reduced-motion: reduce)");
42 var forced = matchMedia("(forced-colors: active)");
43
44 // FOLLOW is C9's fallback: false drops the pointer-following root
45 // light for the hour-only light and keeps the per-element sheen. It
46 // is false because every root write restyles and repaints the whole
47 // document, about 12ms over 500 rows, and even at one write per
48 // WRITE_MS that put the CI runner at 4.15 to 4.20 ms a frame against
49 // a 4ms budget; hour-only measured 2.07 ms. The eased, rate-limited
50 // path below stays, so turning it back on is this one line
51 // (TestTheLightStaysWithinItsFrameBudget measures it).
52 var FOLLOW = false;
53 var WRITE_MS = 200;
54 var BTN = "[rst-btn],.rst-btn";
55 var CARD = "[rst-box],.rst-box,[rst-card],.rst-card,[rst-stats],.rst-stats,[rst-list],.rst-list";
56 // Time constants in ms: the light settles in about a third of a
57 // second, a card's spot follows at a walk, its brightness over about
58 // a second. Elapsed time, not a per-frame step, so 120 Hz is no faster.
59 var LIGHT_T = 125, CARD_T = 320, LIT_T = 400;
60
61 var on = false, tx = 0, ty = -1, lx = 0, ly = -1, wx = 0, wy = -1;
62 var raf = 0, last = 0, pending = null, pointer = false, hold = false, force = false, wrote = 0;
63 var cards = new WeakMap(), live = new Set(), timers = new Set(), clock = 0;
64
65 function sun() {
66 var d = new Date(), h = d.getHours() + d.getMinutes() / 60;
67 var t = Math.max(-1, Math.min(1, (h - 12) / 6));
68 return [t * 0.8, -1 + Math.abs(t) * 0.35];
69 }
70 function target() {
71 if (hold || (pointer && FOLLOW)) return;
72 var s = sun();
73 tx = s[0];
74 ty = s[1];
75 }
76 // Every path that wants a frame comes through here, so a timer or a
77 // move that fires after stop() or while the page is hidden schedules
78 // nothing.
79 function go() {
80 if (!on || document.hidden) return;
81 if (!raf) raf = requestAnimationFrame(step);
82 }
83 function ease(v, to, dt, T) {
84 return v + (to - v) * (1 - Math.exp(-dt / T));
85 }
86 function light(el, x, y) {
87 var o = cards.get(el);
88 if (!o) { o = { x: x, y: y, l: 0 }; cards.set(el, o); }
89 o.tx = x; o.ty = y; o.tl = 1;
90 live.add(el);
91 }
92 function dim(el) {
93 var o = cards.get(el);
94 if (o) { o.tl = 0; live.add(el); }
95 }
96
97 // One loop. Every read (the pending move's rectangles) happens before
98 // any write, and it reschedules itself only while something is still
99 // easing.
100 function step(now) {
101 raf = 0;
102 var dt = last ? Math.min(100, now - last) : 16;
103 last = now;
104 var moving = false;
105 if (pending) {
106 var e = pending;
107 pending = null;
108 var b = e.target.closest && e.target.closest(BTN);
109 var c = e.target.closest && e.target.closest(CARD);
110 var br = b && b.getBoundingClientRect();
111 var cr = c && c.getBoundingClientRect();
112 if (b) {
113 b.style.setProperty("--rst-mx", (e.clientX - br.left).toFixed(0) + "px");
114 b.style.setProperty("--rst-my", (e.clientY - br.top).toFixed(0) + "px");
115 }
116 live.forEach(function (k) { if (k !== c) dim(k); });
117 if (c) light(c, e.clientX - cr.left, e.clientY - cr.top);
118 }
119 if (!hold) {
120 lx = ease(lx, tx, dt, LIGHT_T);
121 ly = ease(ly, ty, dt, LIGHT_T);
122 }
123 if (force || ((Math.abs(lx - wx) > 0.01 || Math.abs(ly - wy) > 0.01) && now - wrote >= WRITE_MS)) {
124 force = false;
125 wx = lx; wy = ly; wrote = now;
126 root.style.setProperty("--rst-light-x", lx.toFixed(3));
127 root.style.setProperty("--rst-light-y", ly.toFixed(3));
128 }
129 if (Math.abs(tx - lx) > 0.002 || Math.abs(ty - ly) > 0.002) moving = true;
130 live.forEach(function (el) {
131 if (!el.isConnected) { live.delete(el); return; }
132 var o = cards.get(el);
133 o.x = ease(o.x, o.tx, dt, CARD_T);
134 o.y = ease(o.y, o.ty, dt, CARD_T);
135 o.l = ease(o.l, o.tl, dt, LIT_T);
136 var done = Math.abs(o.tx - o.x) < 0.5 && Math.abs(o.ty - o.y) < 0.5 && Math.abs(o.tl - o.l) < 0.004;
137 if (done) { o.x = o.tx; o.y = o.ty; o.l = o.tl; }
138 // A lit card that has settled is written once, not on every frame
139 // the root light moves: a card is a large paint.
140 if (!done || !o.still) {
141 el.style.setProperty("--rst-card-x", o.x.toFixed(1) + "px");
142 el.style.setProperty("--rst-card-y", o.y.toFixed(1) + "px");
143 el.style.setProperty("--rst-card-lit", o.l.toFixed(3));
144 }
145 o.still = done;
146 if (!done) moving = true; else if (o.tl === 0) live.delete(el);
147 });
148 if (moving) go(); else last = 0;
149 }
150
151 function onMove(e) {
152 if (e.pointerType !== "mouse") return;
153 if (FOLLOW && !hold) {
154 pointer = true;
155 // The light sits at the pointer, a little above the page.
156 tx = Math.max(-1, Math.min(1, (e.clientX / innerWidth - 0.5) * 2));
157 ty = Math.max(-1, Math.min(1, (e.clientY / innerHeight - 0.5) * 2)) * 0.6 - 0.4;
158 }
159 pending = e;
160 go();
161 }
162 // The press darkens most where it landed, for mouse and touch alike;
163 // a touch leaves pointer mode, and a tapped card catches the light
164 // briefly. Both rectangles are read before either element is written.
165 function onDown(e) {
166 var b = e.target.closest && e.target.closest(BTN);
167 var c = e.pointerType !== "mouse" && e.target.closest && e.target.closest(CARD);
168 var br = b && b.getBoundingClientRect();
169 var cr = c && c.getBoundingClientRect();
170 if (b) {
171 b.style.setProperty("--rst-px", (e.clientX - br.left).toFixed(0) + "px");
172 b.style.setProperty("--rst-py", (e.clientY - br.top).toFixed(0) + "px");
173 }
174 if (e.pointerType === "mouse") return;
175 leavePointer();
176 if (c) {
177 light(c, e.clientX - cr.left, e.clientY - cr.top);
178 var id = setTimeout(function () { timers.delete(id); dim(c); go(); }, 1400);
179 timers.add(id);
180 go();
181 }
182 }
183 function leavePointer() {
184 pointer = false;
185 target();
186 go();
187 }
188 // The queued move goes first: a move over a card in the same task as
189 // the leave would be read by the next frame and light the card the
190 // pointer has just left, and it would settle lit with no pointer on
191 // the page (Astra A6).
192 function onLeave() {
193 pending = null;
194 live.forEach(dim);
195 leavePointer();
196 }
197 function onVisibility() {
198 if (document.hidden) {
199 if (raf) cancelAnimationFrame(raf);
200 raf = 0; last = 0;
201 } else {
202 go();
203 }
204 }
205 // The test hook (C14): sets the light at once and, with hold, keeps
206 // it there against pointer moves until the next event without hold.
207 function onHook(e) {
208 var d = e.detail || {};
209 hold = !!d.hold;
210 if (typeof d.x === "number") { tx = lx = d.x; }
211 if (typeof d.y === "number") { ty = ly = d.y; }
212 force = true;
213 go();
214 }
215
216 function start() {
217 if (on) return;
218 on = true;
219 addEventListener("pointermove", onMove, { passive: true });
220 addEventListener("pointerdown", onDown, { passive: true });
221 addEventListener("keydown", leavePointer, { passive: true });
222 document.addEventListener("pointerleave", onLeave);
223 document.addEventListener("visibilitychange", onVisibility);
224 document.addEventListener("rst:light", onHook);
225 clock = setInterval(function () { target(); go(); }, 5 * 60 * 1000);
226 // Set, not eased, at start: easing in from straight above would be
227 // a root write a frame for a third of a second while the page
228 // loads, toward a position nobody saw it leave.
229 target();
230 lx = tx; ly = ty; force = true;
231 go();
232 }
233 // Reduced motion is total, and forced colours drop every background
234 // and shadow this file moves: under either, no listener, no frame, no
235 // timer, and every property this file wrote goes back to its default.
236 function stop() {
237 if (!on) return;
238 on = false;
239 removeEventListener("pointermove", onMove);
240 removeEventListener("pointerdown", onDown);
241 removeEventListener("keydown", leavePointer);
242 document.removeEventListener("pointerleave", onLeave);
243 document.removeEventListener("visibilitychange", onVisibility);
244 document.removeEventListener("rst:light", onHook);
245 clearInterval(clock);
246 timers.forEach(clearTimeout);
247 timers.clear();
248 if (raf) cancelAnimationFrame(raf);
249 raf = 0; last = 0; pending = null; pointer = false; hold = false; force = false; wrote = 0;
250 lx = wx = tx = 0; ly = wy = ty = -1;
251 live.clear();
252 // The cards' state goes with their properties: a card that had
253 // settled lit kept l = 1 and still = true here, so after a
254 // preference toggle the pointer returning to it found nothing to
255 // ease and wrote nothing, and the card stayed dark (Astra A7).
256 cards = new WeakMap();
257 root.style.removeProperty("--rst-light-x");
258 root.style.removeProperty("--rst-light-y");
259 // Every element this file ever wrote on carries one of its
260 // properties in its style attribute; no list of them is kept, so a
261 // button or card replaced by a fragment is not retained here.
262 var written = document.querySelectorAll('[style*="--rst-"]');
263 for (var i = 0; i < written.length; i++) {
264 ["--rst-mx", "--rst-my", "--rst-px", "--rst-py", "--rst-card-x", "--rst-card-y", "--rst-card-lit"].forEach(function (p) {
265 written[i].style.removeProperty(p);
266 });
267 }
268 }
269 function settle() {
270 if (reduce.matches || forced.matches) stop(); else start();
271 }
272 reduce.addEventListener("change", settle);
273 forced.addEventListener("change", settle);
274 settle();
275})();
276