reviewzy 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1749 @@
1
+ /* ambient layer, cursor v3: the jelly mark lit up with speed, its tube trail in
2
+ the comet blue, leaving the mark 4px narrower than it is across the motion and stopping
3
+ 2px short of its keyline, the parting dot grid, droplets and click. Desktop fine-pointer
4
+ only, silent under `prefers-reduced-motion`. Rules, mechanisms and anti-patterns: cursor.md.
5
+ Load as `<script type="module" src="cursor.js"></script>` after cursor.css. */
6
+
7
+ import { SEMANTIC_POINTER, SEMANTIC_BLOCKED, collectCursorSelectors } from './cursor-rules.js';
8
+
9
+ const reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
10
+ // the gate is the media query outside which the sheet hides #cursor and stops hiding the
11
+ // native cursor, so no device arms the hidden cursor for a mark the stylesheet already hid.
12
+ // Both queries are watched: a visitor switching reduced motion on mid-session gets the
13
+ // native cursor back at once, from the module standing down and from the sheet alike, and
14
+ // the drawn one back on the next move after switching it off again (a page booted under
15
+ // reduced motion never builds it)
16
+ const fineQuery = matchMedia('(pointer: fine) and (hover: hover)');
17
+ // a list that carries a `change` listener is never read: in Chrome a `.matches` read can eat
18
+ // the list's next change event (cursor.md, the anti-patterns), so the gate reads through
19
+ // lists of its own that nothing listens on
20
+ const reducedNow = matchMedia(reducedQuery.media), fineNow = matchMedia(fineQuery.media);
21
+ const motionAllowed = () => fineNow.matches && !reducedNow.matches;
22
+ const onGateChange = fn => {
23
+ reducedQuery.addEventListener('change', fn);
24
+ fineQuery.addEventListener('change', fn);
25
+ };
26
+ const root = document.documentElement;
27
+
28
+ /* Tokens resolve through a 1x1 canvas: getComputedStyle serializes oklch back
29
+ as oklch, while fillStyle takes any colour the engine knows. A value
30
+ fillStyle rejects leaves the previous one standing, hence the sentinel. */
31
+ const swatch = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
32
+ const resolveColour = (prop, fallback) => {
33
+ const css = getComputedStyle(root).getPropertyValue(prop).trim();
34
+ if (!css) return fallback;
35
+ swatch.fillStyle = '#000000';
36
+ swatch.fillStyle = css;
37
+ if (swatch.fillStyle === '#000000' && css !== '#000000') return fallback;
38
+ swatch.clearRect(0, 0, 1, 1);
39
+ swatch.fillRect(0, 0, 1, 1);
40
+ const d = swatch.getImageData(0, 0, 1, 1).data;
41
+ return [d[0], d[1], d[2]];
42
+ };
43
+ const onThemeChange = fn => new MutationObserver(fn).observe(root, { attributeFilter: ['data-theme'] });
44
+
45
+ /* Every spring here is underdamped (zeta < 1). With its target held over a step, its exact
46
+ solution maps the offset from the target and the velocity through one matrix, so a frame
47
+ of any length lands where the continuous spring would be: no step size, the same curve at
48
+ every refresh rate. dt is in the unit omega's radians are per. */
49
+ const springMatrix = (omega, zeta, dt) => {
50
+ const damping = zeta * omega, wd = omega * Math.sqrt(1 - zeta * zeta);
51
+ const decay = Math.exp(-damping * dt), cos = Math.cos(wd * dt), sin = Math.sin(wd * dt) / wd;
52
+ return {
53
+ xx: decay * (cos + damping * sin), xv: decay * sin,
54
+ vx: -decay * omega * omega * sin, vv: decay * (cos - damping * sin),
55
+ };
56
+ };
57
+
58
+ // an input the page receives after a frame that passed its stamp is still played from its stamp,
59
+ // this far back at most; an older one is played from this far back
60
+ const MAX_LATE_MS = 64;
61
+
62
+ // ---- the dot grid: a still texture, a spotlight at the pointer, and a wake ----
63
+ // the grid's move handler, built only when motion is allowed at boot. It rides the one
64
+ // module-level pointermove listener that also looks for a late sheet, so a boot during a move
65
+ // still pushes the grid on that move
66
+ let gridTrack = null;
67
+ const bootGrid = () => {
68
+ let canvas = document.getElementById('bg-dots');
69
+ if (!canvas) {
70
+ canvas = document.createElement('canvas');
71
+ canvas.id = 'bg-dots';
72
+ canvas.setAttribute('aria-hidden', 'true');
73
+ document.body.prepend(canvas);
74
+ }
75
+ const ctx = canvas.getContext('2d');
76
+ const gap = 40, R = 150;
77
+ // `fromX`/`fromY`: where the next push measures from; `slack`: the speed cap's unspent allowance
78
+ const mouse = { x: 0, y: 0, t: 0, active: false, fromX: 0, fromY: 0, slack: 0 };
79
+ let w, h, dots = [], cols = 0, rows = 0, originX = 0, originY = 0;
80
+ let scheduled = false, flowing = false, lastFrame = 0;
81
+ let base = [54, 56, 71], lit = [104, 187, 239];
82
+ // the lift keeps its peak whatever the floor, so a raised --dot-rest shortens
83
+ // the spotlight's travel instead of pushing it past the top of the ramp
84
+ const REST = 0.24, PEAK = 0.80;
85
+ let rest = REST;
86
+ const readTokens = () => {
87
+ base = resolveColour('--line', base);
88
+ lit = resolveColour('--accent', lit);
89
+ const raw = parseFloat(getComputedStyle(root).getPropertyValue('--dot-rest'));
90
+ rest = Number.isFinite(raw) ? Math.min(Math.max(raw, 0), PEAK) : REST;
91
+ };
92
+ root.style.setProperty('--dot-gap', gap + 'px');
93
+
94
+ /* THE WAKE. A moving pointer pushes every dot inside WAKE_R away from itself, by the
95
+ distance it moved since its last move (speed times the interval, so a stroke's whole
96
+ push follows its length rather than the event rate), and each dot rides a damped spring
97
+ back to its home: omega 0.02 rad/ms at zeta 0.5. A dot that settles is snapped to zero,
98
+ so the resting frame is byte-identical to the one before the wake. A parted dot also
99
+ lights up by how far it sits from home, so the wake glows while it swings back. */
100
+ const WAKE_R = 110, WAKE_K = 0.012, WAKE_MAX = 14, OMEGA = 0.02, ZETA = 0.5, WAKE_SPEED = 3;
101
+ const EPS = 0.02;
102
+ // the dots play this long at most past the last frame or move: a quiet stall is skipped, and a
103
+ // stall the pointer moves through plays its real time
104
+ const MAX_FLOW_MS = 50;
105
+ // the speed cap is an allowance of WAKE_SPEED per ms that carries this much of it unspent, so
106
+ // stamps rounded to whole ms, which can put two samples' travel in one interval, push in full
107
+ const WAKE_SLACK_MS = 1;
108
+
109
+ const build = () => {
110
+ const dpr = Math.min(devicePixelRatio || 1, 2);
111
+ w = innerWidth; h = innerHeight;
112
+ canvas.width = w * dpr; canvas.height = h * dpr;
113
+ canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
114
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
115
+ dots = [];
116
+ cols = Math.ceil(w / gap) + 1; rows = Math.ceil(h / gap) + 1;
117
+ originX = (w - (cols - 1) * gap) / 2; originY = (h - (rows - 1) * gap) / 2;
118
+ for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
119
+ dots.push({ bx: originX + x * gap, by: originY + y * gap, ox: 0, oy: 0, vx: 0, vy: 0, pushed: false });
120
+ }
121
+ };
122
+ const draw = () => {
123
+ ctx.clearRect(0, 0, w, h);
124
+ const on = motionAllowed() && mouse.active;
125
+ const shx = on ? (mouse.x - w / 2) / w * 10 : 0, shy = on ? (mouse.y - h / 2) / h * 10 : 0;
126
+ for (const d of dots) {
127
+ const px = d.bx + d.ox + shx, py = d.by + d.oy + shy;
128
+ let f = 0;
129
+ if (on) {
130
+ const dist = Math.hypot(px - mouse.x, py - mouse.y);
131
+ if (dist < R) f = (1 - dist / R) ** 2;
132
+ }
133
+ if (d.ox || d.oy) f = Math.max(f, Math.min(1, Math.hypot(d.ox, d.oy) / WAKE_MAX) * 0.7);
134
+ const a = rest + f * (PEAK - rest), r = 1.15 + f * 1.45;
135
+ const c = f ? base.map((b, i) => Math.round(b + (lit[i] - b) * f)) : base;
136
+ ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2);
137
+ ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${a})`; ctx.fill();
138
+ }
139
+ };
140
+ const schedule = () => { if (!scheduled && !flowing) { scheduled = true; requestAnimationFrame(() => { scheduled = false; draw(); }); } };
141
+
142
+ const resting = d => !d.ox && !d.oy && !d.vx && !d.vy;
143
+ const stepDot = (d, k) => {
144
+ const { ox, oy, vx, vy } = d;
145
+ d.ox = k.xx * ox + k.xv * vx; d.vx = k.vx * ox + k.vv * vx;
146
+ d.oy = k.xx * oy + k.xv * vy; d.vy = k.vx * oy + k.vv * vy;
147
+ // the cap is a wall: a dot that reaches it stops there with its outward speed gone, so
148
+ // a scribble held in one place cannot fling it further
149
+ const off = Math.hypot(d.ox, d.oy);
150
+ if (off > WAKE_MAX) {
151
+ const outward = (d.vx * d.ox + d.vy * d.oy) / off;
152
+ if (outward > 0) { d.vx -= outward * d.ox / off; d.vy -= outward * d.oy / off; }
153
+ d.ox *= WAKE_MAX / off; d.oy *= WAKE_MAX / off;
154
+ }
155
+ };
156
+
157
+ // the instant the dots' clock stops at until the next frame or move
158
+ const flowsUntil = () => Math.max(lastFrame, mouse.t) + MAX_FLOW_MS;
159
+
160
+ // the spring loop runs only while a dot is off its home, and parks on the resting frame.
161
+ // `lastFrame` is the instant the dots stand at: the push that woke the loop, then each
162
+ // frame, or a move landing more than MAX_FLOW_MS past it (catchUp). A frame stamped before
163
+ // that push (an input landing after the vsync it is dispatched in) steps nothing.
164
+ const flow = now => {
165
+ const dt = Math.max(0, Math.min(now, flowsUntil()) - lastFrame);
166
+ lastFrame = Math.max(lastFrame, now);
167
+ const k = springMatrix(OMEGA, ZETA, dt);
168
+ let moving = false;
169
+ for (const d of dots) {
170
+ if (resting(d)) continue;
171
+ stepDot(d, k);
172
+ // a dot pushed since the last frame is never snapped on it: a weak push at the wake's
173
+ // edge adds up with the next one at every refresh rate, not only where frames are rare
174
+ if (!d.pushed && Math.abs(d.ox) < EPS && Math.abs(d.oy) < EPS && Math.abs(d.vx) < EPS / 16 && Math.abs(d.vy) < EPS / 16) {
175
+ d.ox = d.oy = d.vx = d.vy = 0;
176
+ } else moving = true;
177
+ d.pushed = false;
178
+ }
179
+ draw();
180
+ if (moving) requestAnimationFrame(flow);
181
+ else flowing = false;
182
+ };
183
+ // a move landing more than MAX_FLOW_MS after the instant the dots stand at first brings them
184
+ // to it on their own clock, skipping the stall between as a frame would, so a push is never
185
+ // folded back more than MAX_FLOW_MS nor across a stall the clock skipped
186
+ const catchUp = at => {
187
+ if (!flowing || at - lastFrame <= MAX_FLOW_MS) return;
188
+ const k = springMatrix(OMEGA, ZETA, Math.min(at, flowsUntil()) - lastFrame);
189
+ for (const d of dots) if (!resting(d)) stepDot(d, k);
190
+ lastFrame = at;
191
+ };
192
+ const part = (x, y, travel, at) => {
193
+ // the dots are drawn under the parallax shift, so the wake is centred where they paint
194
+ x -= (mouse.x - w / 2) / w * 10; y -= (mouse.y - h / 2) / h * 10;
195
+ const push = travel * WAKE_K;
196
+ // only the grid cells the wake can reach are visited: the pitch is regular, so the
197
+ // index window is arithmetic, and a parted dot sits at most WAKE_MAX from its cell
198
+ const reach = WAKE_R + WAKE_MAX;
199
+ const x0 = Math.max(0, Math.floor((x - reach - originX) / gap)), x1 = Math.min(cols - 1, Math.ceil((x + reach - originX) / gap));
200
+ const y0 = Math.max(0, Math.floor((y - reach - originY) / gap)), y1 = Math.min(rows - 1, Math.ceil((y + reach - originY) / gap));
201
+ // a push wakes the loop at its own instant; one landing while the loop runs meets each
202
+ // dot where it stands at `at`, and is folded by the spring's own motion to the instant
203
+ // the dots stand at, back to it, or forward from a stamp up to MAX_LATE_MS before it, so
204
+ // the dots move from `at` on as if the push had landed then
205
+ if (!flowing) { flowing = true; lastFrame = at; requestAnimationFrame(flow); }
206
+ const since = Math.max(at - lastFrame, -MAX_LATE_MS);
207
+ const ahead = springMatrix(OMEGA, ZETA, since), back = springMatrix(OMEGA, ZETA, -since);
208
+ for (let row = y0; row <= y1; row++) for (let col = x0; col <= x1; col++) {
209
+ const d = dots[row * cols + col];
210
+ const dx = d.bx + ahead.xx * d.ox + ahead.xv * d.vx - x, dy = d.by + ahead.xx * d.oy + ahead.xv * d.vy - y;
211
+ const dist = Math.hypot(dx, dy);
212
+ if (dist >= WAKE_R || dist < 0.5) continue;
213
+ const f = (1 - dist / WAKE_R) ** 2 * push;
214
+ const kx = dx / dist * f, ky = dy / dist * f;
215
+ d.ox += back.xv * kx; d.vx += back.vv * kx;
216
+ d.oy += back.xv * ky; d.vy += back.vv * ky;
217
+ d.pushed = true;
218
+ }
219
+ };
220
+
221
+ readTokens(); build(); draw();
222
+ if (motionAllowed()) {
223
+ gridTrack = e => {
224
+ if (!motionAllowed()) return;
225
+ // one push per move the pointer made, coalesced into this event or not, each at the
226
+ // move's own instant held inside [the last move's, now], so the pushes are the same
227
+ // however the browser batches the moves into dispatches
228
+ const batch = e.getCoalescedEvents ? e.getCoalescedEvents() : [];
229
+ for (const c of batch.length ? batch : [e]) {
230
+ const at = Math.min(Math.max(c.timeStamp, mouse.t), performance.now());
231
+ catchUp(at);
232
+ // a gap past 80ms is a new stroke, never a 1000px jump to push with
233
+ const stroke = mouse.active && mouse.t && at - mouse.t <= 80;
234
+ // a sample sharing the instant of the one before it moves the pointer and pushes
235
+ // nothing: the next later one pushes for both, so no travel is lost to stamps in whole ms
236
+ if (stroke && at === mouse.t) { mouse.x = c.clientX; mouse.y = c.clientY; continue; }
237
+ if (stroke) {
238
+ // the speed cap as a distance, so it binds the same at every event rate
239
+ const allowance = mouse.slack + WAKE_SPEED * (at - mouse.t);
240
+ const travel = Math.min(Math.hypot(c.clientX - mouse.fromX, c.clientY - mouse.fromY), allowance);
241
+ mouse.slack = Math.min(allowance - travel, WAKE_SPEED * WAKE_SLACK_MS);
242
+ part(c.clientX, c.clientY, travel, at);
243
+ } else mouse.slack = 0;
244
+ mouse.x = mouse.fromX = c.clientX; mouse.y = mouse.fromY = c.clientY; mouse.t = at; mouse.active = true;
245
+ }
246
+ schedule();
247
+ };
248
+ root.addEventListener('mouseleave', () => { mouse.active = false; schedule(); });
249
+ // a gate closing mid-session puts every dot home now, not after its swing
250
+ onGateChange(() => {
251
+ if (motionAllowed()) return;
252
+ for (const d of dots) d.ox = d.oy = d.vx = d.vy = 0;
253
+ mouse.active = false;
254
+ schedule();
255
+ });
256
+ }
257
+ addEventListener('resize', () => { build(); draw(); });
258
+ onThemeChange(() => { readTokens(); schedule(); });
259
+ };
260
+
261
+ // ---- the jelly drop (replaces the native cursor) ----
262
+ const bootMark = () => {
263
+ const host = document.createElement('div');
264
+ host.id = 'cursor';
265
+ host.setAttribute('aria-hidden', 'true');
266
+ const canvas = document.createElement('canvas');
267
+ canvas.id = 'cursor-paint';
268
+ host.append(canvas);
269
+ document.body.appendChild(host);
270
+ const ctx = canvas.getContext('2d');
271
+ // the bloom is its own element under the host: a canvas blend mode composites only
272
+ // against the canvas, so the one way to lighten the page without shading it is the
273
+ // halo's, a CSS mix-blend-mode on a node the page composites
274
+ const bloom = document.createElement('div');
275
+ bloom.id = 'cursor-glow';
276
+ bloom.setAttribute('aria-hidden', 'true');
277
+ document.body.appendChild(bloom);
278
+
279
+ const rootStyle = getComputedStyle(root);
280
+ const token = (name, fallback) => parseFloat(rootStyle.getPropertyValue(name)) || fallback;
281
+ const SIZE = {
282
+ idle: token('--cursor-size-idle', 18),
283
+ hover: token('--cursor-size-hover', 24),
284
+ press: token('--cursor-size-press', 19),
285
+ pressIdle: token('--cursor-size-press-idle', 14),
286
+ blocked: token('--cursor-size-blocked', 24),
287
+ };
288
+ const RIPPLE_MS = token('--dur-far', 260);
289
+ const RIPPLE_R0 = 10, RIPPLE_R1 = 36; // a 20px ring opening to 72px
290
+ const SHOW_MS = token('--dur-near', 180), HIDE_MS = SHOW_MS * 0.6; // the mark's own fade, an exit at 0.6x
291
+ const STROKE = 2, DOT_R = 2, KEYLINE = 2.6;
292
+ // the trail stops GAP_PX short of the keyline's outer edge, so a dark ring of one width
293
+ // always separates the mark from its trail: the hole cut under the trail is the mark's
294
+ // outline grown by half the keyline plus the gap
295
+ const GAP_PX = 2, HOLE_PX = KEYLINE / 2 + GAP_PX;
296
+ const TAU = Math.PI * 2;
297
+
298
+ // the system's --ease-out, cubic-bezier(0.16, 1, 0.3, 1), solved for x by Newton's method
299
+ const cubicBezier = (x1, y1, x2, y2) => {
300
+ const at = (a, b, t) => ((1 - 3 * b + 3 * a) * t * t + (3 * b - 6 * a) * t + 3 * a) * t;
301
+ const slope = (a, b, t) => 3 * (1 - 3 * b + 3 * a) * t * t + 2 * (3 * b - 6 * a) * t + 3 * a;
302
+ return x => {
303
+ let t = x;
304
+ for (let i = 0; i < 8; i++) {
305
+ const d = slope(x1, x2, t);
306
+ if (Math.abs(d) < 1e-6) break;
307
+ t -= (at(x1, x2, t) - x) / d;
308
+ }
309
+ return at(y1, y2, Math.min(1, Math.max(0, t)));
310
+ };
311
+ };
312
+ const easeOut = cubicBezier(0.16, 1, 0.3, 1);
313
+
314
+ // The trail is a time window, as long as the last TAIL_MS of travel, that drains
315
+ // into the ring when the pointer stops and is dropped once that drain has run the window
316
+ // out: nothing is left at rest. Stillness past DRAIN_MS is not travel: the window is
317
+ // measured on a clock that stops with the pointer, while the path it holds ages in real
318
+ // time, so a pointer that stops between steps leaves a shorter tail than its path alone.
319
+ // At a crawl the window stretches until it holds the mark's larger half size of path
320
+ // past the hole, so a short tail shows behind the mark; CRAWL_MS caps it at a second of
321
+ // travel. A run of travel begins from rest or from a stop, and its tail grows from
322
+ // nothing as path accrues past the hole (see drawTrail).
323
+ const TAIL_MS = 240, CRAWL_MS = 1000;
324
+ // the centre line gets a point at least every ARC_PX of path and every STEP_MS of time.
325
+ // MAX_STEPS holds about 8000px of path in one window that way; a window wanting more is
326
+ // laid proportionally coarser, never cut short
327
+ const ARC_PX = 2, STEP_MS = 2, MAX_STEPS = 4096;
328
+ // a span at least GAP_MIN_PX long and over GAP_RATIO times the chords on both sides of it
329
+ // is samples the browser lost; each side's direction is read over GAP_TANGENT_PX of path,
330
+ // and the fill lays points about a neighbouring chord apart: at least GAP_SPACING_PX, at
331
+ // most GAP_MAX_POINTS of them
332
+ const GAP_MIN_PX = 24, GAP_RATIO = 3, GAP_TANGENT_PX = 8, GAP_SPACING_PX = 4, GAP_MAX_POINTS = 64;
333
+ // the drawn centre line and the recorded half sizes are low-passed over this much arc,
334
+ // resampled every RESAMPLE_PX
335
+ const SMOOTH_PX = 8, RESAMPLE_PX = 2;
336
+ // a pointer quiet this long has stopped and the tube starts to drain; a mouse creeping a
337
+ // pixel every 80ms is still travelling
338
+ const DRAIN_MS = 90;
339
+ const TRAIL_PEAK = 0.9; // effective alpha at the head
340
+ const TEXT_PEAK = 0.6; // over text, so the glyph just behind the caret shows through
341
+ const TRIM_PX = 4; // the trail starts this much narrower than the mark is across the motion
342
+ const TANGENT_PX = 1; // a grid point's direction is read over at least this much path
343
+ const HEAD_TANGENT_PX = 16; // and the head's over this much path behind it
344
+ const EXIT_TANGENT_PX = 2; // and the line's where it leaves the hole over this much each side
345
+ // past where the line leaves the hole the tube eases from the mark's own width across the
346
+ // motion to the narrow axis its samples recorded over this much path
347
+ const START_EASE_PX = 16;
348
+ // the start reads across the line, not the head, once the exit lies this far past the
349
+ // hole's edge and the turn there clears the hole by as much (startWidth). A smaller scale
350
+ // reads the line sooner at a hairpin, and sooner on a fast shake's turns that barely clear
351
+ // the hole, where the line read jumps from frame to frame
352
+ const START_RAMP_PX = 3.3;
353
+ const PIXEL_ALPHA = 0.5 / 255; // a stamp fainter than this rounds to nothing on the cleared canvas
354
+ const gridX = new Float32Array(MAX_STEPS + 1), gridY = new Float32Array(MAX_STEPS + 1);
355
+ const gridAge = new Float32Array(MAX_STEPS + 1);
356
+ const gridDist = new Float32Array(MAX_STEPS + 1); // path length back from the head
357
+ const gridEnd = new Float32Array(MAX_STEPS + 1); // where that point's run of travel ends, as path length back from the head
358
+ const gridPause = new Float32Array(MAX_STEPS + 1); // stillness past DRAIN_MS, summed over the gaps since that point was laid
359
+ const pauseAfter = []; // the same per path point, filled each frame
360
+ const knots = []; // where each run of path points at one position starts, filled each frame
361
+ const spanX = [0, 0, 0, 0], spanY = [0, 0, 0, 0]; // the four control points of the span being resampled
362
+ // the mark's half sizes under its squeeze, less the trim, as each point's samples recorded
363
+ // them, low-passed along the path; the tube's half width there (before the age fade and the
364
+ // run caps); and the trail's peak alpha there (lower over text)
365
+ const gridW = new Float32Array(MAX_STEPS + 1), gridH = new Float32Array(MAX_STEPS + 1);
366
+ const gridAcross = new Float32Array(MAX_STEPS + 1);
367
+ const gridPeak = new Float32Array(MAX_STEPS + 1);
368
+ const gridTx = new Float32Array(MAX_STEPS + 1), gridTy = new Float32Array(MAX_STEPS + 1);
369
+ const gridT = new Float64Array(MAX_STEPS + 1); // the instant each point was laid at
370
+ const gridArc = new Float64Array(MAX_STEPS + 1); // path length from the tail, for the low-pass
371
+ // the centre line and the half sizes resampled evenly by arc, before and after the
372
+ // low-pass, and its full-width kernel
373
+ let evenX = new Float64Array(4096), evenY = new Float64Array(4096), evenW = new Float64Array(4096), evenH = new Float64Array(4096);
374
+ let lowX = new Float64Array(4096), lowY = new Float64Array(4096), lowW = new Float64Array(4096), lowH = new Float64Array(4096);
375
+ const kernel = new Float64Array(Math.floor(6 * SMOOTH_PX / RESAMPLE_PX) + 1);
376
+
377
+ // Jelly: stretch along the velocity, area held, through a spring loose enough
378
+ // to swing past round into a squash when the pointer stops.
379
+ const JELLY_MAX = 0.42, JELLY_OMEGA = 20, JELLY_ZETA = 0.28, VEL_WINDOW_MS = 24, STILL_MS = 40;
380
+ const JELLY_GAIN = 0.28, JELLY_CURVE = 0.75; // stretch at 1000px/s, and how it grows past that
381
+ const SHAKE_KICK = 9;
382
+ // the motion between two frames of a running loop plays this long at most, however many
383
+ // inputs split it: a stalled tab plays on from where it was instead of jumping ahead
384
+ const MAX_STEP_S = 0.064;
385
+ const SHAPE_OMEGA = 30, SHAPE_ZETA = 0.55;
386
+
387
+ // The light-up: the mark's ink brightens and a bloom grows under it with speed, both on
388
+ // an eased tone rather than the raw interval, so a stop drifts down instead of popping.
389
+ // The bloom's box is its largest radius; it scales down from there.
390
+ const TONE_MS = 120, FULL_SPEED = 1.4, GLOW_R0 = 14, GLOW_R1 = 20, GLOW_PEAK = 0.105;
391
+ const GLOW_BOX = GLOW_R0 + GLOW_R1;
392
+
393
+ // Droplets shed off the head only past SPRAY_MIN px/ms: SPRAY_RATE * x^2 / (x + SPRAY_EASE)
394
+ // droplets a ms, x the speed past SPRAY_MIN, so a stroke just past it sheds a few and the
395
+ // rate's slope nears SPRAY_RATE per px/ms further up; their orange share ramps from none at
396
+ // SPRAY_MIN to ORANGE_MAX at SPRAY_FULL. The cap bounds the shed and refuses a new droplet
397
+ // rather than evicting a live one: every particle plays its whole life, and a click's
398
+ // splash is never refused.
399
+ const SPRAY_MIN = 2.5, SPRAY_EASE = 1, SPRAY_FULL = 4, SPRAY_RATE = 0.08, SPRAY_CAP = 96, ORANGE_MAX = 0.5;
400
+
401
+ let dpr = 1;
402
+ let ink = [98, 186, 241], litInk = [190, 225, 250], keyline = [43, 45, 60], refuse = [233, 120, 130];
403
+ let spray = [190, 225, 250], sprayHot = [240, 170, 120], rippleInk = [160, 215, 250];
404
+ let sprite = null;
405
+ // the light-up moves the ink by its tone times the widest channel gap to the lit ink, and
406
+ // the bloom shows nothing under 0.02 of it: a tone below this rounds to the plain ink
407
+ let toneRest = 0.003;
408
+
409
+ const rgb = c => `rgb(${c[0]},${c[1]},${c[2]})`;
410
+ const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
411
+
412
+ // one soft disc, stamped along the path: overlapping stamps shade the trail
413
+ // like a tube, bright in the middle and soft at the edge, with no seams
414
+ const buildSprite = (core, edge) => {
415
+ const s = document.createElement('canvas');
416
+ s.width = s.height = 64;
417
+ const g = s.getContext('2d');
418
+ const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
419
+ grad.addColorStop(0, `rgba(${core[0]},${core[1]},${core[2]},1)`);
420
+ grad.addColorStop(0.35, `rgba(${core[0]},${core[1]},${core[2]},1)`);
421
+ grad.addColorStop(0.75, `rgba(${edge[0]},${edge[1]},${edge[2]},0.85)`);
422
+ grad.addColorStop(1, `rgba(${edge[0]},${edge[1]},${edge[2]},0)`);
423
+ g.fillStyle = grad;
424
+ g.fillRect(0, 0, 64, 64);
425
+ return s;
426
+ };
427
+ const readColours = () => {
428
+ ink = resolveColour('--cursor-ink', ink);
429
+ litInk = resolveColour('--cursor-lit', litInk);
430
+ keyline = resolveColour('--cursor-keyline', keyline);
431
+ refuse = resolveColour('--cursor-refuse', refuse);
432
+ spray = resolveColour('--cursor-spray', spray);
433
+ sprayHot = resolveColour('--cursor-spray-hot', sprayHot);
434
+ rippleInk = resolveColour('--cursor-ripple', rippleInk);
435
+ sprite = buildSprite(resolveColour('--cursor-trail-core', ink), resolveColour('--cursor-trail-edge', ink));
436
+ toneRest = Math.min(0.02, 0.5 / Math.max(1, ...ink.map((c, i) => Math.abs(litInk[i] - c))));
437
+ };
438
+ const sizeCanvas = () => {
439
+ dpr = Math.min(devicePixelRatio || 1, 2);
440
+ canvas.width = Math.round(innerWidth * dpr);
441
+ canvas.height = Math.round(innerHeight * dpr);
442
+ canvas.style.width = innerWidth + 'px';
443
+ canvas.style.height = innerHeight + 'px';
444
+ dirty = null;
445
+ request();
446
+ };
447
+ // a window dragged to another monitor changes the ratio with no resize event
448
+ const watchRatio = () => {
449
+ matchMedia(`(resolution: ${devicePixelRatio}dppx)`).addEventListener('change', () => { sizeCanvas(); watchRatio(); }, { once: true });
450
+ };
451
+
452
+ // ---- state ----
453
+ const usable = list => list.filter(sel => {
454
+ try { host.matches(sel); return true; } catch { return false; }
455
+ }).join(', ');
456
+ const found = collectCursorSelectors(document.styleSheets);
457
+ const hitPointer = usable([...found.pointer, SEMANTIC_POINTER]);
458
+ const hitInert = usable(found.inert);
459
+ const hitBlocked = usable([...found.blocked, SEMANTIC_BLOCKED]);
460
+
461
+ /* A field is its resolved type, asked of an input only: `el.type` answers
462
+ `text` for a missing or unknown attribute, which is what renders, while
463
+ `<a>`, `<li>` and `<link>` reflect a `type="text"` attribute verbatim. */
464
+ const TEXT_TYPES = ['text', 'search', 'email', 'password', 'url', 'tel', 'number'];
465
+ const isField = el => (el instanceof HTMLInputElement ? TEXT_TYPES.includes(el.type)
466
+ : el instanceof HTMLTextAreaElement) || el.matches('[contenteditable="true"]');
467
+
468
+ const range = document.createRange();
469
+ // the runs that are the element's own: its text nodes and those of its inline
470
+ // descendants, never a block child's, whose glyphs retarget the event to it
471
+ // anyway. A hovered page or body therefore costs its direct children, not
472
+ // every run beneath them.
473
+ let walkRoot = null;
474
+ const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
475
+ acceptNode: n => n.nodeType === Node.TEXT_NODE ? NodeFilter.FILTER_ACCEPT
476
+ : n === walkRoot || getComputedStyle(n).display.startsWith('inline') ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_REJECT,
477
+ });
478
+ // the caret promises glyphs under the pointer, so hit-test text runs, never a
479
+ // box: a block heading's box is mostly empty space. A run's rect is the glyph
480
+ // box, shorter than its line box, so the leading above and below a run counts
481
+ // as the run's, and so does the gap between two of the element's own runs
482
+ // stacked (an inline child taller than its line opens one wider than the
483
+ // leading), or a caret moving down through prose would swell back into the
484
+ // ring at every line; a block child's margin is no such gap
485
+ const overText = (el, x, y) => {
486
+ if (isField(el)) return true;
487
+ // CodeMirror-style editors hide glyphs behind pointer-events:none spans
488
+ if (el.closest('[contenteditable="true"]')) return true;
489
+ const line = parseFloat(getComputedStyle(el).lineHeight);
490
+ let above = -Infinity, below = Infinity;
491
+ walkRoot = el;
492
+ walker.currentNode = el;
493
+ for (let n = walker.nextNode(); n && el.contains(n); n = walker.nextNode()) {
494
+ if (!n.textContent.trim()) continue;
495
+ range.selectNodeContents(n);
496
+ for (const r of range.getClientRects()) {
497
+ if (x < r.left || x > r.right) continue;
498
+ const leading = Number.isFinite(line) ? Math.max(0, (line - r.height) / 2) : 0;
499
+ if (y >= r.top - leading && y <= r.bottom + leading) return true;
500
+ if (r.bottom <= y) above = Math.max(above, r.bottom);
501
+ else if (r.top >= y) below = Math.min(below, r.top);
502
+ }
503
+ }
504
+ return Number.isFinite(line) && below - above <= line * 1.5;
505
+ };
506
+
507
+ function stateFor(el, x, y) {
508
+ if (!el) return '';
509
+ if (hitBlocked && el.closest(hitBlocked)) return 'blocked';
510
+ const click = hitPointer && el.closest(hitPointer);
511
+ if (click) {
512
+ // a pointer switched back off outranks the clickable rule only from its own element or below
513
+ const off = hitInert && el.closest(hitInert);
514
+ if (!off || !(off === click || click.contains(off))) return 'hover';
515
+ }
516
+ return overText(el, x, y) ? 'text' : '';
517
+ }
518
+
519
+ const pointer = { x: 0, y: 0 };
520
+ let state = '', caretSource = null, caretH = 20;
521
+ let placed = false, proven = false, detached = false, bailed = false, gated = false;
522
+ let pressedState = null, rootRetries = 0;
523
+ let frame = 0, applyQueued = false, queuedTarget = null, dirty = null;
524
+ // `last` is the instant the motion stands at, `frameAt` that of the last frame or wake. A
525
+ // parked loop requests no frames and keeps the goal its springs were heading for: a hidden
526
+ // mark parks with them still moving
527
+ let last = 0, frameAt = 0, parked = true, parkedGoal = null;
528
+
529
+ const inViewTransition = () => document.getAnimations().some(a =>
530
+ a.effect && a.effect.pseudoElement === '::view-transition-new(root)');
531
+
532
+ function apply(el) {
533
+ const target = el || document.elementFromPoint(pointer.x, pointer.y);
534
+ // mid view transition every hit test answers the root; hold the state and
535
+ // re-derive, and a root answer outliving the retries with no swap is honest
536
+ if (target === root) {
537
+ if (rootRetries++ < 5 || inViewTransition()) {
538
+ setTimeout(() => { if (!host.classList.contains('press')) queueApply(null); }, 80);
539
+ return;
540
+ }
541
+ }
542
+ rootRetries = 0;
543
+ state = stateFor(target, pointer.x, pointer.y);
544
+ host.classList.toggle('hover', state === 'hover');
545
+ host.classList.toggle('blocked', state === 'blocked');
546
+ host.classList.toggle('text', state === 'text');
547
+ if (state === 'text' && target && target !== caretSource) {
548
+ caretSource = target;
549
+ // past 20px type a proportional caret reads as a rule drawn on the page
550
+ const size = parseFloat(getComputedStyle(target).fontSize);
551
+ if (size) caretH = Math.min(Math.round(size * 1.2), 24);
552
+ } else if (state !== 'text') caretSource = null;
553
+ }
554
+ // overText reads client rects, which forces layout: once a frame, never once a move
555
+ function queueApply(el) {
556
+ queuedTarget = el;
557
+ applyQueued = true;
558
+ request();
559
+ }
560
+
561
+ // ---- motion ----
562
+ const samples = []; // { x, y, t, pause, net, w, h, peak, fill?, start? }, oldest first
563
+ let behind = null; // the path's point before the oldest sample kept: the last one dropped at another position, or the end of its fill
564
+ const ripples = []; // { x, y, t0 }
565
+ const drops = []; // { x, y, vx, vy, born, life, r, hot }
566
+ const spring = () => ({ x: 0, v: 0 });
567
+ const hw = spring(), hh = spring(), fill = spring(), dot = spring(), slash = spring(), red = spring();
568
+ const jp = spring(), jq = spring(); // stretch as a traceless tensor: amount and axis in one vector
569
+ hw.x = hh.x = SIZE.idle / 2; dot.x = 1;
570
+ let tone = 0; // the eased speed the light-up answers
571
+ // the mark's and trail's own fade, in and out with `visible`: droplets and ripples keep
572
+ // their own alpha, so a pointer leaving the window never cuts one short
573
+ let shown = 0;
574
+ let headX = 1, headY = 0; // the last direction the head travelled
575
+ let left = false; // the pointer left the window: the next move starts a new stroke
576
+ let lastEvent = null, sprayDebt = 0, hotDebt = 0, lastSpeed = 0;
577
+
578
+ // k: a springMatrix over the step
579
+ const step = (s, target, k) => {
580
+ const off = s.x - target;
581
+ s.x = target + k.xx * off + k.xv * s.v;
582
+ s.v = k.vx * off + k.vv * s.v;
583
+ };
584
+ const rest = (s, target, eps) => {
585
+ if (Math.abs(s.x - target) < eps && Math.abs(s.v) < eps * 20) { s.x = target; s.v = 0; return true; }
586
+ return false;
587
+ };
588
+
589
+ // the pointer's position at time t, linear between samples
590
+ const posAt = t => {
591
+ const n = samples.length;
592
+ if (t >= samples[n - 1].t) return [samples[n - 1].x, samples[n - 1].y];
593
+ if (t <= samples[0].t) return [samples[0].x, samples[0].y];
594
+ let i = n - 1;
595
+ while (i > 0 && samples[i - 1].t > t) i--;
596
+ const a = samples[i - 1], b = samples[i];
597
+ const u = b.t > a.t ? (t - a.t) / (b.t - a.t) : 1;
598
+ return [a.x + (b.x - a.x) * u, a.y + (b.y - a.y) * u];
599
+ };
600
+
601
+ // a pointer quiet for STILL_MS counts as stopped, and stays stopped until its next move
602
+ const pointerStill = now => samples.length < 2 || now - samples[samples.length - 1].t > STILL_MS;
603
+
604
+ // the pointer's velocity in px/ms over the last VEL_WINDOW_MS of samples, reaching back no
605
+ // earlier than `from`. The window ends at the newest sample, never at `now`, and spans
606
+ // samples rather than events, so the read is the same at 125Hz and 1000Hz, however the
607
+ // moves were batched, and a fast mouse's whole-pixel steps average out over it
608
+ function velocity(from) {
609
+ const head = samples[samples.length - 1];
610
+ const span = Math.min(VEL_WINDOW_MS, head.t - Math.max(from, samples[0].t));
611
+ if (span <= 1) return [0, 0];
612
+ const [ox, oy] = posAt(head.t - span);
613
+ return [(head.x - ox) / span, (head.y - oy) / span];
614
+ }
615
+
616
+ function jellyTarget(now) {
617
+ if (pointerStill(now)) return [0, 0, 0];
618
+ const [mx, my] = velocity(-Infinity);
619
+ const vx = mx * 1000, vy = my * 1000;
620
+ const v = Math.hypot(vx, vy);
621
+ const amount = Math.min(JELLY_MAX, JELLY_GAIN * Math.pow(v / 1000, JELLY_CURVE));
622
+ const theta = Math.atan2(vy, vx);
623
+ return [amount * Math.cos(2 * theta), amount * Math.sin(2 * theta), v / 1000];
624
+ }
625
+
626
+ function targets() {
627
+ if (state === 'text') return { w: 1, h: caretH / 2, f: 1, d: 0, sl: 0, r: 0 };
628
+ const pressed = host.classList.contains('press');
629
+ let size = SIZE.idle, f = 0, d = 1, sl = 0, r = 0;
630
+ // the hover fill sits over the text under the pointer: 12% of --cursor-ink, 26% pressed
631
+ if (state === 'hover') { size = pressed ? SIZE.press : SIZE.hover; f = pressed ? 0.26 : 0.12; d = 0; }
632
+ else if (state === 'blocked') { size = SIZE.blocked; d = 0; sl = 1; r = 1; }
633
+ else if (pressed) size = SIZE.pressIdle;
634
+ return { w: size / 2, h: size / 2, f, d, sl, r };
635
+ }
636
+
637
+ // what a sample records of the mark: its half sizes under the jelly's squeeze across its
638
+ // stretch (a ring at speed is narrower across than its size, so a fast stroke draws a
639
+ // narrower tube), less half the trim, floored at the caret's own half width so its line
640
+ // moving down stays that line; and the trail's peak alpha there. The squeeze is the narrow
641
+ // axis, so the recorded width does not depend on the direction the head had
642
+ const record = () => {
643
+ const squeeze = 1 / jellyStretch(shapeRoundness());
644
+ return {
645
+ w: Math.max(1, hw.x * squeeze - TRIM_PX / 2), h: Math.max(1, hh.x * squeeze - TRIM_PX / 2),
646
+ peak: state === 'text' ? TEXT_PEAK : TRAIL_PEAK,
647
+ };
648
+ };
649
+
650
+ // samples[i]'s direction of travel, read over GAP_TANGENT_PX of path on the side `dir`
651
+ // points to (-1 behind it, +1 ahead), and the sample that reach ends at; null with none,
652
+ // and null when the reach would cross a pause: a hand that stopped may leave another way
653
+ function travelAt(i, dir) {
654
+ const p = samples[i];
655
+ for (let j = i + dir; j >= 0 && j < samples.length; j += dir) {
656
+ if (samples[dir < 0 ? j + 1 : j].pause > 0) return null;
657
+ const dx = (samples[j].x - p.x) * dir, dy = (samples[j].y - p.y) * dir, d = Math.hypot(dx, dy);
658
+ if (d >= GAP_TANGENT_PX) return { x: dx / d, y: dy / d, at: j };
659
+ }
660
+ return null;
661
+ }
662
+
663
+ // the first chord from samples[i] toward `dir` that moves at all; 0 with none
664
+ function chordAt(i, dir) {
665
+ for (let j = i; j + dir >= 0 && j + dir < samples.length; j += dir) {
666
+ const c = Math.hypot(samples[j + dir].x - samples[j].x, samples[j + dir].y - samples[j].y);
667
+ if (c > 0) return c;
668
+ }
669
+ return 0;
670
+ }
671
+
672
+ // points along a cubic Hermite from a to b, leaving a along `from` and arriving at b along
673
+ // `to` (a missing one is the other mirrored across the chord, close to the circular arc
674
+ // through both for a gentle turn), about `near` apart; each is timed and sized at its
675
+ // share of the way from a to b
676
+ function hermite(a, b, chord, from, to, near) {
677
+ const ex = (b.x - a.x) / chord, ey = (b.y - a.y) / chord;
678
+ const mirror = d => { const along = 2 * (d.x * ex + d.y * ey); return { x: along * ex - d.x, y: along * ey - d.y }; };
679
+ const ta = from || mirror(to), tb = to || mirror(from);
680
+ const m = Math.min(GAP_MAX_POINTS, Math.ceil(chord / Math.max(near, GAP_SPACING_PX)) - 1);
681
+ const points = [];
682
+ for (let k = 1; k <= m; k++) {
683
+ const u = k / (m + 1), u2 = u * u, u3 = u2 * u;
684
+ const h00 = 2 * u3 - 3 * u2 + 1, h10 = (u3 - 2 * u2 + u) * chord, h01 = 3 * u2 - 2 * u3, h11 = (u3 - u2) * chord;
685
+ points.push({
686
+ x: h00 * a.x + h10 * ta.x + h01 * b.x + h11 * tb.x, y: h00 * a.y + h10 * ta.y + h01 * b.y + h11 * tb.y,
687
+ t: a.t + (b.t - a.t) * u, pause: 0,
688
+ w: a.w + (b.w - a.w) * u, h: a.h + (b.h - a.h) * u, peak: a.peak + (b.peak - a.peak) * u,
689
+ });
690
+ }
691
+ return points;
692
+ }
693
+
694
+ // The points filling the span into samples[i] when it is a stretch the browser lost
695
+ // samples over (GAP_MIN_PX, and GAP_RATIO times the chords on both sides): drawn as one
696
+ // chord, the spline would put a whole turn at each end of it. Times and sizes run evenly
697
+ // across the fill as they would across the bare span, so only positions change. A span
698
+ // ending in a pause passes the same test only once a chord ahead of it is known: a start
699
+ // from rest has a first chord short beside the next and fails it, while a stall that lost
700
+ // samples mid-travel is a long chord between short ones. The result is kept on the
701
+ // sample once the direction ahead is read off samples that can no longer change; from
702
+ // then on the fill never reshapes as the window drops the samples behind it.
703
+ function fillAt(i) {
704
+ const b = samples[i];
705
+ if (b.fill !== undefined) return b.fill;
706
+ const a = samples[i - 1], newest = samples.length - 1;
707
+ const chord = Math.hypot(b.x - a.x, b.y - a.y);
708
+ if (chord < GAP_MIN_PX) {
709
+ if (i < newest) b.fill = null;
710
+ return null;
711
+ }
712
+ const ahead = chordAt(i, 1);
713
+ if (b.pause > 0 && !ahead) return null;
714
+ const near = Math.max(chordAt(i - 1, -1), ahead);
715
+ const from = travelAt(i - 1, -1), to = travelAt(i, 1);
716
+ const lost = near > 0 && chord > GAP_RATIO * near && (from || to);
717
+ const points = lost ? hermite(a, b, chord, from, to, near) : null;
718
+ if (to && to.at < newest) b.fill = points;
719
+ return points;
720
+ }
721
+
722
+ // the samples with their fills, then the pointer now. A filled span is taken for travel
723
+ // whose samples were lost rather than a stop, so the pause at its far end ends no run of
724
+ // travel: the tube runs through the fill unpinched
725
+ function pathPoints(now) {
726
+ const pts = [];
727
+ for (let i = 0; i < samples.length; i++) {
728
+ const fill = i ? fillAt(i) : null;
729
+ if (fill) pts.push(...fill);
730
+ pts.push(fill && samples[i].pause > 0 ? { ...samples[i], pause: 0 } : samples[i]);
731
+ }
732
+ pts.push({ x: pointer.x, y: pointer.y, t: now, pause: 0, ...record() });
733
+ return pts;
734
+ }
735
+
736
+ // the grid's instants from t0 to now: inside each span, evenly in time, as many as its
737
+ // chord asks for at ARC_PX and its time at STEP_MS, carried across spans so a span asking
738
+ // for less than one point shares one with the next
739
+ function layGrid(pts, t0, now) {
740
+ const wanted = (a, b) => {
741
+ const lo = Math.max(a.t, t0), hi = Math.min(b.t, now);
742
+ if (hi <= lo) return 0;
743
+ const share = (hi - lo) / (b.t - a.t);
744
+ return Math.max(Math.hypot(b.x - a.x, b.y - a.y) * share / ARC_PX, (hi - lo) / STEP_MS);
745
+ };
746
+ let total = 0;
747
+ for (let i = 1; i < pts.length; i++) total += wanted(pts[i - 1], pts[i]);
748
+ const scale = Math.min(1, (MAX_STEPS - 1) / total);
749
+ let count = 0, laid = 0;
750
+ gridT[0] = t0;
751
+ for (let i = 1; i < pts.length; i++) {
752
+ const a = pts[i - 1], b = pts[i];
753
+ const want = wanted(a, b) * scale;
754
+ if (want <= 0) continue;
755
+ const lo = Math.max(a.t, t0), hi = Math.min(b.t, now);
756
+ for (let k = Math.floor(laid) + 1; k < laid + want; k++) gridT[++count] = lo + (hi - lo) * (k - laid) / want;
757
+ laid += want;
758
+ }
759
+ gridT[++count] = now;
760
+ return count;
761
+ }
762
+
763
+ /* The centre line. The path is a uniform cubic B-spline over the samples: its bend is
764
+ continuous along the whole path, which passes a third of the way from each sample
765
+ toward the midpoint of its two neighbours, so a fast stroke's sparse samples draw one
766
+ curve rather than straight runs joined at bends. Each span runs over the time between
767
+ its two samples, so ages stay true to when the pointer was there, and over a run of
768
+ samples at one position the path holds still for as long as the run lasts. A stretch
769
+ where the browser lost samples is filled first (fillAt), so its spacing changes
770
+ gradually and the spline has no turn to pile up at either end. The grid lays a point at
771
+ least every ARC_PX of each span's chord and every STEP_MS of its time (layGrid), so a
772
+ fast stretch draws in short chords and a jump that took a fraction of a millisecond
773
+ still gets its points. The positions and the recorded sizes then pass through one
774
+ low-pass along the path (smoothAlongPath), and the tube's widths are laid on the result
775
+ (layWidths). A new sample reshapes the last two spans and, through the low-pass, the
776
+ line and its width up to 3 x SMOOTH_PX of path behind them, and an unsettled fill
777
+ (fillAt) across its whole gap; the tail's last 3 x SMOOTH_PX moves as the window
778
+ drains it. */
779
+ function buildGrid(now, win) {
780
+ const pts = pathPoints(now);
781
+ const n = pts.length;
782
+ const t0 = Math.max(pts[0].t, now - win);
783
+ if (n < 2 || now <= t0) return 0;
784
+ // the still time the pointer has spent since each sample, summed from the head back
785
+ pauseAfter.length = n;
786
+ pauseAfter[n - 1] = 0;
787
+ for (let i = n - 2; i >= 0; i--) pauseAfter[i] = pauseAfter[i + 1] + pts[i + 1].pause;
788
+ knots.length = 0;
789
+ for (let i = 0; i < n; i++) if (!i || pts[i].x !== pts[i - 1].x || pts[i].y !== pts[i - 1].y) knots.push(i);
790
+ const lastKnot = knots.length - 1;
791
+ // span k's four control points: the first point of each run at one position. Before the
792
+ // first stands `behind`, so a sample leaving the window never reshapes what is still
793
+ // drawn; with none dropped yet, the mirror of the second, which starts the curve on the
794
+ // first sample. Past the head stands the mirror of the one before it, which ends the
795
+ // curve on the head along the last chord with no bend
796
+ const loadSpan = k => {
797
+ for (let c = 0; c < 4; c++) {
798
+ const i = k - 1 + c;
799
+ if (i < 0 || i > lastKnot) continue;
800
+ const p = pts[knots[i]];
801
+ spanX[c] = p.x; spanY[c] = p.y;
802
+ }
803
+ if (k === 0) {
804
+ spanX[0] = behind ? behind.x : 2 * spanX[1] - spanX[2];
805
+ spanY[0] = behind ? behind.y : 2 * spanY[1] - spanY[2];
806
+ }
807
+ if (k + 1 === lastKnot) { spanX[3] = 2 * spanX[2] - spanX[1]; spanY[3] = 2 * spanY[2] - spanY[1]; }
808
+ };
809
+ const count = layGrid(pts, t0, now);
810
+ let k = 0, loaded = -1, b = 0;
811
+ for (let j = 0; j <= count; j++) {
812
+ const t = gridT[j];
813
+ while (k < lastKnot && t >= pts[knots[k + 1]].t) k++;
814
+ let x, y;
815
+ if (k === lastKnot) {
816
+ x = pts[knots[k]].x; y = pts[knots[k]].y;
817
+ } else {
818
+ if (k !== loaded) { loaded = k; loadSpan(k); }
819
+ const leave = pts[knots[k + 1] - 1].t, arrive = pts[knots[k + 1]].t;
820
+ const u = t > leave ? (t - leave) / (arrive - leave) : 0;
821
+ const v = 1 - u, u2 = u * u, u3 = u2 * u;
822
+ const b0 = v * v * v / 6, b1 = (3 * u3 - 6 * u2 + 4) / 6, b2 = (-3 * u3 + 3 * u2 + 3 * u + 1) / 6, b3 = u3 / 6;
823
+ x = b0 * spanX[0] + b1 * spanX[1] + b2 * spanX[2] + b3 * spanX[3];
824
+ y = b0 * spanY[0] + b1 * spanY[1] + b2 * spanY[2] + b3 * spanY[3];
825
+ }
826
+ gridX[j] = x; gridY[j] = y; gridAge[j] = now - t;
827
+ // the recorded sizes ride the samples that bracket this instant
828
+ while (b < n - 2 && t > pts[b + 1].t) b++;
829
+ const s0 = pts[b], s1 = pts[b + 1];
830
+ const f = s1.t > s0.t ? Math.min(1, Math.max(0, (t - s0.t) / (s1.t - s0.t))) : 1;
831
+ gridW[j] = s0.w + (s1.w - s0.w) * f;
832
+ gridH[j] = s0.h + (s1.h - s0.h) * f;
833
+ gridPeak[j] = s0.peak + (s1.peak - s0.peak) * f;
834
+ gridPause[j] = pauseAfter[b + 1] + Math.min(s1.pause, s1.t - t);
835
+ }
836
+ smoothAlongPath(count);
837
+ // each grid point's direction, read over a chord of at least TANGENT_PX around it, so a
838
+ // slow pointer's sub-pixel jitter cannot swing the width the recorded sizes give it
839
+ let tx = headX, ty = headY;
840
+ for (let j = 0; j <= count; j++) {
841
+ let a = j, c = j, dx = 0, dy = 0, len = 0;
842
+ while (len < TANGENT_PX && (a > 0 || c < count) && c - a < 16) {
843
+ a = Math.max(0, a - 1); c = Math.min(count, c + 1);
844
+ dx = gridX[c] - gridX[a]; dy = gridY[c] - gridY[a]; len = Math.hypot(dx, dy);
845
+ }
846
+ if (len >= TANGENT_PX / 2) { tx = dx / len; ty = dy / len; }
847
+ gridTx[j] = tx; gridTy[j] = ty;
848
+ }
849
+ readHeadDirection(count);
850
+ gridDist[count] = 0;
851
+ for (let j = count - 1; j >= 0; j--) gridDist[j] = gridDist[j + 1] + Math.hypot(gridX[j + 1] - gridX[j], gridY[j + 1] - gridY[j]);
852
+ // a run of travel ends where the pointer stopped (a pause was recorded): the path
853
+ // before that stop is a drained tube of its own, and the run's tail is the path since
854
+ let stop = count;
855
+ for (let j = count; j >= 0; j--) {
856
+ while (stop >= 0 && gridPause[stop] <= gridPause[j] + 1e-3) stop--;
857
+ gridEnd[j] = stop >= 0 ? gridDist[stop] : gridDist[0];
858
+ }
859
+ layWidths(count);
860
+ return count;
861
+ }
862
+
863
+ // the head's direction, read over HEAD_TANGENT_PX of path behind it: the hole's edge and
864
+ // the window's floor are the mark's extent along it, so a slow hand's per-sample wobble,
865
+ // which the low-pass leaves at the head, must not swing it. Past half the reach, it stops
866
+ // where the path turned back on itself (a point whose own direction opposes the chord
867
+ // read so far), so once the new leg is half the reach long a hairpin's old leg no longer
868
+ // pulls the head's direction off it; the first half is never cut, since a slow hand's
869
+ // wobble there turns single points sideways
870
+ function readHeadDirection(count) {
871
+ let a = count, dx = 0, dy = 0, len = 0;
872
+ const turnsBack = j => len >= HEAD_TANGENT_PX / 2 && gridTx[j] * dx + gridTy[j] * dy < 0;
873
+ while (len < HEAD_TANGENT_PX && a > 0 && !turnsBack(a - 1)) {
874
+ a--;
875
+ dx = gridX[count] - gridX[a]; dy = gridY[count] - gridY[a]; len = Math.hypot(dx, dy);
876
+ }
877
+ if (len >= TANGENT_PX / 2) { gridTx[count] = dx / len; gridTy[count] = dy / len; }
878
+ headX = gridTx[count]; headY = gridTy[count];
879
+ }
880
+
881
+ // the tube's half width at each grid point: the recorded half sizes across the path, and
882
+ // from the head back to where the line leaves the hole the start, so the visible tube
883
+ // leaves the ring at the ring's width less the trim even where the jelly lags a turn or
884
+ // the line doubles back inside the ring at a hairpin. The start is the mark's extent
885
+ // across the tube where it leaves the hole, less half the trim (startWidth), read on the
886
+ // first frame that draws the newest sample and kept on it: a tube draining after a stop
887
+ // or a stall holds the width the hand left it with while the jelly relaxes or the ring
888
+ // grows over a target. Past that point the width eases to the recorded one over
889
+ // START_EASE_PX along a smoothstep, so it has no kink where the two meet, and the narrow
890
+ // axis behind keeps a turn from swelling the tube
891
+ function layWidths(count) {
892
+ const edge = holeEdge(), out = holeExit(count);
893
+ const leaves = Math.max(edge, gridDist[out]);
894
+ const newest = samples[samples.length - 1];
895
+ if (newest.start === undefined) newest.start = startWidth(count, edge, out);
896
+ const start = newest.start;
897
+ for (let j = 0; j <= count; j++) {
898
+ const recorded = Math.hypot(gridW[j] * gridTy[j], gridH[j] * gridTx[j]);
899
+ const u = Math.min(1, Math.max(0, (gridDist[j] - leaves) / START_EASE_PX));
900
+ gridAcross[j] = recorded + (start - recorded) * (1 - u * u * (3 - 2 * u));
901
+ }
902
+ }
903
+
904
+ // where the line, back from the head, first leaves the hole (outsideHole): the index of the
905
+ // first grid point outside it, so the start reaches every point that could show past the
906
+ // ring; the line's far end when it never leaves
907
+ function holeExit(count) {
908
+ for (let j = count - 1; j >= 0; j--) if (outsideHole(gridX[j], gridY[j])) return j;
909
+ return 0;
910
+ }
911
+
912
+ // the start: the mark's half extent, less half the trim, across the direction the tube
913
+ // takes where it leaves the hole, at grid point `out`. A line leaving at the hole's edge
914
+ // runs on from the head, and its own direction there bends with whatever turn the low-pass
915
+ // rounds just outside the hole, so the start reads across the head's direction; a line
916
+ // leaving past the edge has turned inside the hole, and the start reads across the line
917
+ // where it leaves, once that turn clears the hole. The line's read takes over along a
918
+ // smoothstep as the exit lies up to START_RAMP_PX past the edge and as the turn clears the
919
+ // hole by up to START_RAMP_PX. The exit is a grid point, so a drifting exit still moves the
920
+ // width a grid step at a time, and a turn dipping back inside the hole moves the exit to
921
+ // the leg beyond it at once, which can switch the start from the head's read to that leg's
922
+ function startWidth(count, edge, out) {
923
+ const past = Math.min(1, Math.max(0, (gridDist[out] - edge) / START_RAMP_PX));
924
+ const head = trimmedAcross(-headY, headX);
925
+ if (past === 0) return head;
926
+ const u = past * Math.min(1, clearance(out) / START_RAMP_PX);
927
+ return head + (startAcross(count, out) - head) * u * u * (3 - 2 * u);
928
+ }
929
+
930
+ // how far the line clears the hole past its exit at grid point `out`: the farthest it lies
931
+ // outside the hole while it stays outside, over the START_EASE_PX of path the start eases
932
+ // across. The scan stops at the first point START_RAMP_PX or more out and returns its
933
+ // distance, so the result can pass START_RAMP_PX; the caller clamps it
934
+ function clearance(out) {
935
+ let most = 0;
936
+ for (let k = out; k >= 0 && gridDist[k] - gridDist[out] <= START_EASE_PX && most < START_RAMP_PX; k--) {
937
+ const gap = outlineDistance(gridX[k] - pointer.x, gridY[k] - pointer.y) - HOLE_PX;
938
+ if (gap <= 0) break;
939
+ most = Math.max(most, gap);
940
+ }
941
+ return most;
942
+ }
943
+
944
+ // the start across the line where it leaves the hole, its direction read over
945
+ // EXIT_TANGENT_PX of path each side of grid point `out`: at a hairpin the line curves
946
+ // within a few px of that point, and a longer read takes in the turn
947
+ function startAcross(count, out) {
948
+ let a = out, c = Math.min(count, out + 1);
949
+ while (a > 0 && gridDist[a] - gridDist[out] < EXIT_TANGENT_PX) a--;
950
+ while (c < count && gridDist[out] - gridDist[c] < EXIT_TANGENT_PX) c++;
951
+ const dx = gridX[c] - gridX[a], dy = gridY[c] - gridY[a], len = Math.hypot(dx, dy);
952
+ return len > 1e-6 ? trimmedAcross(-dy / len, dx / len) : trimmedAcross(-headY, headX);
953
+ }
954
+
955
+ // the mark's half extent along a direction less half the trim, floored at a pixel so the
956
+ // caret's line moving down stays a line
957
+ const trimmedAcross = (nx, ny) => Math.max(1, support(nx, ny) - TRIM_PX / 2);
958
+
959
+ const fit = (a, n) => a.length >= n ? a : new Float64Array(Math.max(n, 2 * a.length));
960
+
961
+ // A Gaussian over the centre line and the half sizes by arc length, sigma SMOOTH_PX,
962
+ // narrowed to a third of the path left to the head and to the tail, so both ends stay
963
+ // where they are: the head stays on the pointer at the mark's own trimmed size while a
964
+ // slow stroke's per-sample wobble, which the spline passes a good share of, averages out,
965
+ // and a burst after a stall widens or narrows the tube over the arc the line bends over,
966
+ // not over the few px between its samples. The filter runs on an even resample of the
967
+ // path, read back at each grid point's own arc length through a cubic: a linear read-back
968
+ // would bend the line only at the resample points.
969
+ function smoothAlongPath(count) {
970
+ gridArc[0] = 0;
971
+ for (let j = 1; j <= count; j++) gridArc[j] = gridArc[j - 1] + Math.hypot(gridX[j] - gridX[j - 1], gridY[j] - gridY[j - 1]);
972
+ const n = Math.ceil(gridArc[count] / RESAMPLE_PX) + 1;
973
+ if (n < 3) return;
974
+ const h = gridArc[count] / (n - 1);
975
+ evenX = fit(evenX, n); evenY = fit(evenY, n); evenW = fit(evenW, n); evenH = fit(evenH, n);
976
+ lowX = fit(lowX, n); lowY = fit(lowY, n); lowW = fit(lowW, n); lowH = fit(lowH, n);
977
+ resampleEvenly(count, n, h);
978
+ lowPass(n, h);
979
+ readBack(count, n, h);
980
+ }
981
+
982
+ function resampleEvenly(count, n, h) {
983
+ let seg = 0;
984
+ for (let k = 0; k < n; k++) {
985
+ const at = k === n - 1 ? gridArc[count] : k * h;
986
+ while (seg < count - 1 && gridArc[seg + 1] < at) seg++;
987
+ const len = gridArc[seg + 1] - gridArc[seg], u = len > 0 ? Math.min(1, (at - gridArc[seg]) / len) : 1;
988
+ evenX[k] = gridX[seg] + (gridX[seg + 1] - gridX[seg]) * u;
989
+ evenY[k] = gridY[seg] + (gridY[seg + 1] - gridY[seg]) * u;
990
+ evenW[k] = gridW[seg] + (gridW[seg + 1] - gridW[seg]) * u;
991
+ evenH[k] = gridH[seg] + (gridH[seg + 1] - gridH[seg]) * u;
992
+ }
993
+ }
994
+
995
+ function lowPass(n, h) {
996
+ const reach = Math.floor(3 * SMOOTH_PX / h);
997
+ for (let q = 0; q <= reach; q++) kernel[q] = Math.exp(-0.5 * (q * h / SMOOTH_PX) ** 2);
998
+ for (let k = 0; k < n; k++) {
999
+ const sigma = Math.min(SMOOTH_PX, (n - 1 - k) * h / 3, k * h / 3);
1000
+ if (sigma < h / 2) { lowX[k] = evenX[k]; lowY[k] = evenY[k]; lowW[k] = evenW[k]; lowH[k] = evenH[k]; continue; }
1001
+ const full = sigma === SMOOTH_PX;
1002
+ const taps = full ? reach : Math.floor(3 * sigma / h), c = -0.5 * (h / sigma) ** 2;
1003
+ let sx = evenX[k], sy = evenY[k], sw = evenW[k], sh = evenH[k], total = 1;
1004
+ for (let q = 1; q <= taps; q++) {
1005
+ const weight = full ? kernel[q] : Math.exp(c * q * q);
1006
+ sx += weight * (evenX[k - q] + evenX[k + q]); sy += weight * (evenY[k - q] + evenY[k + q]);
1007
+ sw += weight * (evenW[k - q] + evenW[k + q]); sh += weight * (evenH[k - q] + evenH[k + q]);
1008
+ total += 2 * weight;
1009
+ }
1010
+ lowX[k] = sx / total; lowY[k] = sy / total; lowW[k] = sw / total; lowH[k] = sh / total;
1011
+ }
1012
+ }
1013
+
1014
+ // Catmull-Rom through the filtered resample, at each grid point's arc length
1015
+ function readBack(count, n, h) {
1016
+ for (let j = 0; j <= count; j++) {
1017
+ const at = Math.min(n - 1, gridArc[j] / h), k1 = Math.min(n - 2, Math.floor(at)), g = at - k1;
1018
+ const k0 = Math.max(0, k1 - 1), k2 = k1 + 1, k3 = Math.min(n - 1, k1 + 2);
1019
+ const g2 = g * g, g3 = g2 * g;
1020
+ const c0 = -0.5 * g3 + g2 - 0.5 * g, c1 = 1.5 * g3 - 2.5 * g2 + 1, c2 = -1.5 * g3 + 2 * g2 + 0.5 * g, c3 = 0.5 * g3 - 0.5 * g2;
1021
+ gridX[j] = c0 * lowX[k0] + c1 * lowX[k1] + c2 * lowX[k2] + c3 * lowX[k3];
1022
+ gridY[j] = c0 * lowY[k0] + c1 * lowY[k1] + c2 * lowY[k2] + c3 * lowY[k3];
1023
+ gridW[j] = c0 * lowW[k0] + c1 * lowW[k1] + c2 * lowW[k2] + c3 * lowW[k3];
1024
+ gridH[j] = c0 * lowH[k0] + c1 * lowH[k1] + c2 * lowH[k2] + c3 * lowH[k3];
1025
+ }
1026
+ }
1027
+
1028
+ // the path where the trail's front edge sits, `edge` px behind the head: its age, and
1029
+ // that age less the time the pointer has stood still since it was laid (past DRAIN_MS,
1030
+ // the pauses recorded on the samples plus the current one). The taper's clock starts
1031
+ // at the second, so travel time alone never dims the trail at the hole however slowly
1032
+ // it goes, a stop drains the tube instead of holding it until the window empties, and a
1033
+ // move after a pause keeps the old path at the level it drained to rather than
1034
+ // restarting its clock
1035
+ function edgeAge(count, edge, still) {
1036
+ let j = count;
1037
+ while (j > 0 && gridDist[j] < edge) j--;
1038
+ const span = gridDist[j] - gridDist[j + 1];
1039
+ const u = span > 0 ? (edge - gridDist[j + 1]) / span : 1;
1040
+ const age = gridAge[j + 1] + (gridAge[j] - gridAge[j + 1]) * Math.min(1, u);
1041
+ return { age, age0: Math.max(0, age - gridPause[j] - Math.max(0, still - DRAIN_MS)) };
1042
+ }
1043
+
1044
+ const grow = (b, x, y, r) => {
1045
+ if (!b) return [x - r, y - r, x + r, y + r];
1046
+ b[0] = Math.min(b[0], x - r); b[1] = Math.min(b[1], y - r);
1047
+ b[2] = Math.max(b[2], x + r); b[3] = Math.max(b[3], y + r);
1048
+ return b;
1049
+ };
1050
+
1051
+ // the jelly's stretch along its axis, `a`; it squeezes to 1/a across it
1052
+ function jellyStretch(roundness) {
1053
+ return 1 + Math.min(JELLY_MAX, Math.hypot(jp.x, jq.x)) * roundness;
1054
+ }
1055
+ // the jelly's axis, as an angle
1056
+ const jellyAngle = () => Math.atan2(jq.x, jp.x) / 2;
1057
+ // the jelly as a symmetric tensor: stretch `a` along the axis, 1/a across it
1058
+ function jellyTensor(roundness) {
1059
+ const phi = jellyAngle();
1060
+ const a = jellyStretch(roundness), b = 1 / a;
1061
+ const c = Math.cos(phi), s = Math.sin(phi);
1062
+ return [a * c * c + b * s * s, (a - b) * c * s, a * s * s + b * c * c];
1063
+ }
1064
+ const shapeRoundness = () => Math.min(hw.x, hh.x) / Math.max(hw.x, hh.x, 1e-3);
1065
+ // the jelly's matrix, centred on the pointer, in device pixels
1066
+ function jellyMatrix(x, y, T) {
1067
+ return [T[0] * dpr, T[1] * dpr, T[1] * dpr, T[2] * dpr, x * dpr, y * dpr];
1068
+ }
1069
+ const shape = (w, h, inset) => {
1070
+ const iw = Math.max(0, w - inset), ih = Math.max(0, h - inset);
1071
+ ctx.beginPath();
1072
+ ctx.roundRect(-iw, -ih, 2 * iw, 2 * ih, Math.min(iw, ih));
1073
+ };
1074
+ // the live mark's half extent along a direction: the support function of the drawn
1075
+ // rounded rect (corner radius = its smaller half size) under its jelly tensor
1076
+ function support(vx, vy) {
1077
+ const T = jellyTensor(shapeRoundness());
1078
+ const mx = T[0] * vx + T[1] * vy, my = T[1] * vx + T[2] * vy;
1079
+ const rho = Math.min(hw.x, hh.x);
1080
+ return (hw.x - rho) * Math.abs(mx) + (hh.x - rho) * Math.abs(my) + rho * Math.hypot(mx, my);
1081
+ }
1082
+ // the hole's reach from the pointer along a direction: the support of the mark's outline
1083
+ // grown by HOLE_PX, which equals the outline's radius on its axes and overstates it
1084
+ // between them
1085
+ const holeReach = (vx, vy) => support(vx, vy) + HOLE_PX;
1086
+ // how far behind the head the trail's front edge sits
1087
+ const holeEdge = () => holeReach(headX, headY);
1088
+ // whether a point lies outside the hole: farther than HOLE_PX from the mark's drawn
1089
+ // outline. Within the jelly's narrow half size plus HOLE_PX of the pointer it is inside,
1090
+ // and past the hole's reach toward it outside; between the two the distance is solved
1091
+ function outsideHole(x, y) {
1092
+ const dx = x - pointer.x, dy = y - pointer.y, d = Math.hypot(dx, dy);
1093
+ if (d <= Math.min(hw.x, hh.x) / jellyStretch(shapeRoundness()) + HOLE_PX) return false;
1094
+ if (d > holeReach(dx / d, dy / d)) return true;
1095
+ return outlineDistance(dx, dy) > HOLE_PX;
1096
+ }
1097
+
1098
+ // the distance from a point, given from the pointer, to the mark's drawn outline (zero
1099
+ // inside). The outline is the jelly's image of a rounded rect whose corner radius is its
1100
+ // smaller half size: an ellipse swept along a segment, none when the mark is round, so the
1101
+ // distance is the least distance to that ellipse over the sweep, convex along the segment
1102
+ function outlineDistance(dx, dy) {
1103
+ // the drawn half sizes: a spring overshooting toward the caret can pass zero
1104
+ const w = Math.max(1e-3, hw.x), h = Math.max(1e-3, hh.x);
1105
+ const rho = Math.min(w, h), a = jellyStretch(shapeRoundness());
1106
+ const phi = jellyAngle(), c = Math.cos(phi), s = Math.sin(phi);
1107
+ const lx = w - rho, ly = h - rho;
1108
+ // the point and the sweep's half length along the jelly's axis and across it
1109
+ const pu = dx * c + dy * s, pv = dy * c - dx * s;
1110
+ const su = a * (lx * c + ly * s), sv = (ly * c - lx * s) / a;
1111
+ const gap = t => ellipseGap(rho * a, rho / a, Math.abs(pu - t * su), Math.abs(pv - t * sv));
1112
+ return su || sv ? convexMin(gap) : gap(0);
1113
+ }
1114
+
1115
+ // the distance from a point to a filled ellipse with semi-axes e0, e1 > 0, the point
1116
+ // given in the ellipse's first quadrant: zero inside, else the root of a function of one
1117
+ // variable that falls monotonically, bisected (Eberly, "Distance from a Point to an
1118
+ // Ellipse, an Ellipsoid, or a Hyperellipsoid"). The bisection's bracket holds only with
1119
+ // the major axis first, so swapped axes are mirrored across the diagonal, point included
1120
+ function ellipseGap(e0, e1, y0, y1) {
1121
+ if (e0 < e1) return ellipseGap(e1, e0, y1, y0);
1122
+ const z0 = y0 / e0, z1 = y1 / e1;
1123
+ if (z0 * z0 + z1 * z1 <= 1) return 0;
1124
+ const r = (e0 / e1) ** 2, n = r * z0;
1125
+ let lo = z1 - 1, hi = Math.hypot(n, z1) - 1, s = lo;
1126
+ for (let i = 0; i < 64; i++) {
1127
+ s = (lo + hi) / 2;
1128
+ if (s === lo || s === hi) break;
1129
+ const q0 = n / (s + r), q1 = z1 / (s + 1);
1130
+ if (q0 * q0 + q1 * q1 > 1) lo = s; else hi = s;
1131
+ }
1132
+ return Math.hypot(r * y0 / (s + r) - y0, y1 / (s + 1) - y1);
1133
+ }
1134
+
1135
+ // the least value of a convex function over [-1, 1], by golden-section search to 1e-6
1136
+ function convexMin(f) {
1137
+ const g = (Math.sqrt(5) - 1) / 2;
1138
+ let lo = -1, hi = 1, p = hi - g * (hi - lo), q = lo + g * (hi - lo), fp = f(p), fq = f(q);
1139
+ while (hi - lo > 1e-6) {
1140
+ if (fp <= fq) { hi = q; q = p; fq = fp; p = hi - g * (hi - lo); fp = f(p); }
1141
+ else { lo = p; p = q; fp = fq; q = lo + g * (hi - lo); fq = f(q); }
1142
+ }
1143
+ return Math.min(fp, fq);
1144
+ }
1145
+
1146
+ // the net clock: sample time less the stillness past DRAIN_MS. Its head runs DRAIN_MS past
1147
+ // the newest sample and stops there, so a longer stop neither stretches the window nor drops
1148
+ // the samples the tube is still draining from, and the first move after it finds the window
1149
+ // as it was
1150
+ const netHead = now => {
1151
+ const newest = samples[samples.length - 1];
1152
+ return newest.net + Math.min(now - newest.t, DRAIN_MS);
1153
+ };
1154
+ // a pointer still for DRAIN_MS plus the window has drained the tube: its stroke is over
1155
+ const strokeOver = (t, win) => t - samples[samples.length - 1].t > DRAIN_MS + win;
1156
+
1157
+ // how far back the window reaches now, on the net clock: TAIL_MS, or the time it takes the
1158
+ // path to reach the mark's larger half size past the hole when that is longer, CRAWL_MS at
1159
+ // most. The reach ends inside the segment that completes it, so a flick that settles
1160
+ // into a crawl keeps only that much of its path past TAIL_MS, never its whole length.
1161
+ // The span leaves the pauses out, so the sample that ends a pause does not lengthen the
1162
+ // window by it and swell the tube that was draining
1163
+ function windowMs(now) {
1164
+ const floor = holeEdge() + Math.max(hw.x, hh.x);
1165
+ let d = 0, net = samples[samples.length - 1].net;
1166
+ for (let i = samples.length - 1; i > 0 && d < floor; i--) {
1167
+ const seg = Math.hypot(samples[i].x - samples[i - 1].x, samples[i].y - samples[i - 1].y);
1168
+ const need = floor - d;
1169
+ net = seg > need ? samples[i].net - (samples[i].net - samples[i - 1].net) * need / seg : samples[i - 1].net;
1170
+ d += seg;
1171
+ }
1172
+ return Math.min(CRAWL_MS, Math.max(TAIL_MS, netHead(now) - net));
1173
+ }
1174
+
1175
+ // the tube: soft discs stamped along the grid, spaced by distance so the density,
1176
+ // and with it the alpha, holds at every speed, each stamp's alpha solved from the
1177
+ // overlap. A stamp's radius is the grid's half width there (layWidths: the start where
1178
+ // the line leaves the hole, the ring's width less the trim as the hand last moved,
1179
+ // easing to the recorded half sizes read across the path; the caret's trimmed half
1180
+ // height moving sideways, its own half width moving down), so over text the trail is a
1181
+ // line downward and a caret-tall tube sideways, and a turn keeps the past its width. The
1182
+ // stamping runs up to the pointer; the hole cut afterwards removes the mark's own share.
1183
+ // Two caps keep the tail from showing more than the path it has. A stamp is never
1184
+ // brighter than the front edge, so the part of the path that stood hidden in the hole
1185
+ // at a stop cannot come out brighter than the tube that drained beside it. And a
1186
+ // stamp's radius is held to the path left behind it in its run of travel (gridEnd) and,
1187
+ // inside the hole, to the hypotenuse of its depth in the hole and the run's path past
1188
+ // the hole, so the tail is a half disc of that path's radius at the hole's edge,
1189
+ // tapering to a point at the run's old end, and a run begun after a stop grows from
1190
+ // nothing like a stroke from rest: a full-radius disc a pixel past the hole's edge is
1191
+ // a crescent hugging the ring. It reports whether any stamp could set a pixel.
1192
+ function drawTrail(count, edge, win, front, box) {
1193
+ let carry = 0, drawn = false;
1194
+ const life = Math.max(1, win - front.age0);
1195
+ const frontP = Math.max(0, 1 - (front.age - front.age0) / life);
1196
+ for (let j = 0; j < count; j++) {
1197
+ const ax = gridX[j], ay = gridY[j], bx = gridX[j + 1], by = gridY[j + 1];
1198
+ const seg = Math.hypot(bx - ax, by - ay);
1199
+ if (seg < 1e-3) continue;
1200
+ const across = gridAcross[j];
1201
+ let at = carry;
1202
+ while (at <= seg) {
1203
+ const u = at / seg;
1204
+ const age = gridAge[j] + (gridAge[j + 1] - gridAge[j]) * u;
1205
+ const p = Math.max(0, Math.min(frontP, 1 - (age - front.age0) / life));
1206
+ const dist = gridDist[j] + (gridDist[j + 1] - gridDist[j]) * u;
1207
+ const runLeft = gridEnd[j] - dist;
1208
+ const r = Math.min(across * Math.pow(p, 0.6), runLeft, Math.hypot(Math.max(0, edge - dist), Math.max(0, gridEnd[j] - edge)));
1209
+ const spacing = Math.max(0.7, 0.32 * r);
1210
+ if (r > 0.3) {
1211
+ const overlap = Math.max(1, (2 * r) / spacing);
1212
+ const A = (gridPeak[j] + (gridPeak[j + 1] - gridPeak[j]) * u) * Math.pow(p, 0.9);
1213
+ ctx.globalAlpha = shown * (1 - Math.pow(1 - A, 1 / overlap));
1214
+ const x = ax + (bx - ax) * u, y = ay + (by - ay) * u;
1215
+ ctx.drawImage(sprite, (x - r) * dpr, (y - r) * dpr, 2 * r * dpr, 2 * r * dpr);
1216
+ box.b = grow(box.b, x, y, r + 1);
1217
+ if (ctx.globalAlpha >= PIXEL_ALPHA) drawn = true;
1218
+ }
1219
+ at += spacing;
1220
+ }
1221
+ carry = at - seg;
1222
+ }
1223
+ ctx.globalAlpha = 1;
1224
+ return drawn;
1225
+ }
1226
+
1227
+ // the hole: the mark's live outline, grown under the unit transform as the keyline is
1228
+ // drawn, so the trail's front edge is the mark's back outline GAP_PX outside its keyline
1229
+ // whatever the jelly does to the shape
1230
+ function cutHole(x, y, T) {
1231
+ ctx.globalCompositeOperation = 'destination-out';
1232
+ ctx.setTransform(...jellyMatrix(x, y, T));
1233
+ shape(hw.x, hh.x, 0);
1234
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1235
+ ctx.fill();
1236
+ ctx.lineWidth = 2 * HOLE_PX;
1237
+ ctx.stroke();
1238
+ ctx.globalCompositeOperation = 'source-over';
1239
+ }
1240
+
1241
+ function placeGlow(x, y) {
1242
+ const r = GLOW_R0 + GLOW_R1 * tone;
1243
+ bloom.style.transform = `translate3d(${x - GLOW_BOX}px, ${y - GLOW_BOX}px, 0) scale(${(r / GLOW_BOX).toFixed(4)})`;
1244
+ bloom.style.opacity = tone < 0.02 ? '0' : (GLOW_PEAK * tone * shown).toFixed(4);
1245
+ }
1246
+
1247
+ function drawMark(x, y, T, box) {
1248
+ const w = hw.x, h = hh.x;
1249
+ const M = jellyMatrix(x, y, T);
1250
+ const refusal = Math.min(1, Math.max(0, red.x));
1251
+ // the stroke and the dot light up with speed; the fill stays the plain ink, so the text
1252
+ // under the pointer sits under the same 12% or 26% of --accent at every speed
1253
+ const colour = rgb(mix(mix(ink, litInk, tone), refuse, refusal));
1254
+ const fillColour = rgb(mix(ink, refuse, refusal));
1255
+ const unit = () => ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1256
+ // strokes are laid under the unit transform, so the line keeps its width
1257
+ // however far the path is stretched
1258
+ // the ink path is inset before the stretch, so across a full stretch its
1259
+ // outer edge creeps 0.3px past the shape; KEYLINE still leaves a 1px keyline there
1260
+ ctx.globalAlpha = shown;
1261
+ ctx.setTransform(...M); shape(w, h, 0); unit();
1262
+ ctx.lineWidth = KEYLINE;
1263
+ ctx.strokeStyle = rgb(keyline);
1264
+ ctx.stroke();
1265
+ const f = Math.min(1, Math.max(0, fill.x));
1266
+ if (f > 0.004) {
1267
+ ctx.globalAlpha = shown * f;
1268
+ ctx.fillStyle = fillColour;
1269
+ ctx.setTransform(...M); shape(w, h, 0); unit();
1270
+ ctx.fill();
1271
+ ctx.globalAlpha = shown;
1272
+ }
1273
+ if (w > STROKE / 2 + 0.05 && h > STROKE / 2 + 0.05 && f < 0.999) {
1274
+ ctx.setTransform(...M); shape(w, h, STROKE / 2); unit();
1275
+ ctx.lineWidth = STROKE;
1276
+ ctx.strokeStyle = colour;
1277
+ ctx.stroke();
1278
+ }
1279
+ const sl = Math.min(1, Math.max(0, slash.x));
1280
+ if (sl > 0.004) {
1281
+ const k = Math.max(0, Math.min(w, h) - STROKE) * Math.SQRT1_2;
1282
+ ctx.globalAlpha = shown * sl;
1283
+ ctx.setTransform(...M);
1284
+ ctx.beginPath(); ctx.moveTo(-k, -k); ctx.lineTo(k, k);
1285
+ unit();
1286
+ ctx.lineCap = 'butt';
1287
+ ctx.lineWidth = STROKE + 2; ctx.strokeStyle = rgb(keyline); ctx.stroke();
1288
+ ctx.lineWidth = STROKE; ctx.strokeStyle = colour; ctx.stroke();
1289
+ ctx.globalAlpha = shown;
1290
+ }
1291
+ const d = Math.min(1, Math.max(0, dot.x));
1292
+ if (d > 0.004) {
1293
+ ctx.globalAlpha = shown * d;
1294
+ ctx.beginPath(); ctx.arc(x, y, DOT_R + 1, 0, TAU);
1295
+ ctx.fillStyle = rgb(keyline); ctx.fill();
1296
+ ctx.beginPath(); ctx.arc(x, y, DOT_R, 0, TAU);
1297
+ ctx.fillStyle = colour; ctx.fill();
1298
+ }
1299
+ ctx.globalAlpha = 1;
1300
+ const extent = Math.max(w, h) * (1 + JELLY_MAX) + 3;
1301
+ box.b = grow(box.b, x, y, extent);
1302
+ }
1303
+
1304
+ // the click: one 1px ring leaving the release point on --ease-out, its page
1305
+ // keyline outside it, opening from 20px to 72px as it fades
1306
+ function drawRipples(now, box) {
1307
+ for (let i = ripples.length - 1; i >= 0; i--) {
1308
+ const rp = ripples[i];
1309
+ const u = (now - rp.t0) / RIPPLE_MS;
1310
+ if (u >= 1) { ripples.splice(i, 1); continue; }
1311
+ const e = easeOut(u);
1312
+ const r = RIPPLE_R0 + (RIPPLE_R1 - RIPPLE_R0) * e;
1313
+ ctx.globalAlpha = 0.9 * (1 - e);
1314
+ ctx.lineWidth = 1;
1315
+ ctx.beginPath(); ctx.arc(rp.x, rp.y, r + 1, 0, TAU);
1316
+ ctx.strokeStyle = rgb(keyline); ctx.stroke();
1317
+ ctx.beginPath(); ctx.arc(rp.x, rp.y, r, 0, TAU);
1318
+ ctx.strokeStyle = rgb(rippleInk); ctx.stroke();
1319
+ box.b = grow(box.b, rp.x, rp.y, r + 3);
1320
+ }
1321
+ ctx.globalAlpha = 1;
1322
+ }
1323
+
1324
+ // each droplet lives its own life and fades as the square; its speed keeps DROP_DRAG of
1325
+ // itself per millisecond and it travels the exact integral of that decay on its own clock,
1326
+ // from its birth, so the spray goes the same distance at every refresh rate and an input
1327
+ // that moves the springs' clock never shortens a droplet already flying. A stalled tab
1328
+ // plays on, as the springs do, from where it was
1329
+ const DROP_DRAG = 0.998, DROP_DRAG_LOG = Math.log(DROP_DRAG);
1330
+ const fly = (d, ms) => {
1331
+ const drag = DROP_DRAG ** ms, travel = (drag - 1) / DROP_DRAG_LOG;
1332
+ d.x += d.vx * travel; d.y += d.vy * travel;
1333
+ d.vx *= drag; d.vy *= drag;
1334
+ };
1335
+ const dropExpired = now => {
1336
+ for (let i = drops.length - 1; i >= 0; i--) if ((now - drops[i].born) / drops[i].life >= 1) drops.splice(i, 1);
1337
+ };
1338
+ function drawDrops(now, box) {
1339
+ dropExpired(now);
1340
+ ctx.strokeStyle = rgb(keyline);
1341
+ ctx.lineWidth = 1;
1342
+ for (let i = drops.length - 1; i >= 0; i--) {
1343
+ const d = drops[i];
1344
+ const u = (now - d.born) / d.life;
1345
+ fly(d, Math.min(MAX_STEP_S * 1000, now - d.at));
1346
+ d.at = now;
1347
+ ctx.globalAlpha = (1 - u) * (1 - u);
1348
+ ctx.fillStyle = rgb(d.hot ? sprayHot : spray);
1349
+ ctx.beginPath(); ctx.arc(d.x, d.y, d.r * (1 - u * 0.5), 0, TAU);
1350
+ ctx.fill();
1351
+ ctx.stroke();
1352
+ box.b = grow(box.b, d.x, d.y, d.r + 2);
1353
+ }
1354
+ ctx.globalAlpha = 1;
1355
+ }
1356
+ // a droplet is born at the instant of the pointer sample that sheds it, not when its
1357
+ // handler runs, so neither when the browser dispatches that sample (up to MAX_LATE_MS after
1358
+ // a frame passed its stamp) nor how many samples it coalesces into one event moves the spray,
1359
+ // short of the cap
1360
+ const spawn = (x, y, vx, vy, life, r, share, born) => {
1361
+ // the share is paid as a running debt, so the orange count is exact, never a coin flip
1362
+ hotDebt += share;
1363
+ const hot = hotDebt >= 1;
1364
+ if (hot) hotDebt -= 1;
1365
+ const d = { x, y, vx, vy, born, at: born, life, r, hot };
1366
+ // one born before the motion's last step flies to it, as that step's frame would have flown it
1367
+ if (born < last) { fly(d, last - born); d.at = last; }
1368
+ drops.push(d);
1369
+ };
1370
+ const orangeShare = v => ORANGE_MAX * Math.min(1, Math.max(0, (v - SPRAY_MIN) / (SPRAY_FULL - SPRAY_MIN)));
1371
+ // droplets shed off the head when the pointer is fast: backwards along the motion with
1372
+ // a sideways scatter, the way spray leaves a stone skipped across water. It runs once per
1373
+ // recorded sample and reads the pointer's velocity over the samples of its stroke, so the
1374
+ // spray depends on neither how the moves were batched nor a single whole-pixel step
1375
+ const shed = (x, y, t) => {
1376
+ const prev = lastEvent;
1377
+ // a gap past 80ms is a new stroke, not a speed; the caret sheds nothing
1378
+ const fresh = !prev || t - prev.t > 80 || state === 'text';
1379
+ lastEvent = { x, y, t, from: fresh ? t : prev.from };
1380
+ if (fresh) { sprayDebt = 0; lastSpeed = 0; return; }
1381
+ const dt = Math.max(0, t - prev.t);
1382
+ const [vx, vy] = velocity(lastEvent.from);
1383
+ const v = Math.hypot(vx, vy);
1384
+ lastSpeed = v;
1385
+ if (v <= SPRAY_MIN) { sprayDebt = 0; return; }
1386
+ const past = v - SPRAY_MIN;
1387
+ sprayDebt += SPRAY_RATE * past * past / (past + SPRAY_EASE) * dt;
1388
+ const share = orangeShare(v);
1389
+ const ux = vx / v, uy = vy / v;
1390
+ const born = instant(t);
1391
+ while (sprayDebt >= 1) {
1392
+ sprayDebt -= 1;
1393
+ // a droplet whose life ended by this one's birth holds no slot, drawn yet or not
1394
+ if (drops.length >= SPRAY_CAP) dropExpired(born);
1395
+ if (drops.length >= SPRAY_CAP) continue;
1396
+ const back = 0.05 + Math.random() * 0.12, side = (Math.random() - 0.5) * 0.5;
1397
+ spawn(x - ux * Math.random() * 6, y - uy * Math.random() * 6,
1398
+ -ux * back - uy * side, -uy * back + ux * side,
1399
+ 320 + Math.random() * 220, 1.4 + Math.random() * 1.4, share, born);
1400
+ }
1401
+ };
1402
+ // a click drops a stone: a ring of droplets leaves the release point under the ripple
1403
+ const splash = (x, y, at) => {
1404
+ // a click at rest is a slow click: the last speed counts only within the stroke gap
1405
+ const share = lastEvent && at - lastEvent.t <= 80 ? orangeShare(lastSpeed) : 0;
1406
+ for (let i = 0; i < 10; i++) {
1407
+ const a = (i + Math.random() * 0.6) / 10 * TAU;
1408
+ const v = 0.12 + Math.random() * 0.1;
1409
+ spawn(x, y, Math.cos(a) * v, Math.sin(a) * v, 320 + Math.random() * 120, 1.5 + Math.random() * 1.1, share, at);
1410
+ }
1411
+ };
1412
+
1413
+ // standing down for a finger or a closed gate is temporary, a thrown error is not. Arming
1414
+ // restores `cursor-v3` too, in case a page script rewrote <html>'s class since boot
1415
+ const arm = () => { if (!bailed && !gated) root.classList.add('cursor-v3', 'custom-cursor'); };
1416
+ addEventListener('error', () => { bailed = true; root.classList.remove('custom-cursor'); });
1417
+ onGateChange(e => {
1418
+ gated = !motionAllowed();
1419
+ if (!gated) return;
1420
+ respond(e.timeStamp, () => { host.classList.remove('visible', 'press'); samples.length = 0; });
1421
+ // reduced motion asked for mid-flight outranks a droplet's life; the stand-down is
1422
+ // the finger's, so the first mouse move after the gate reopens re-arms
1423
+ detached = true;
1424
+ root.classList.remove('custom-cursor');
1425
+ drops.length = 0; ripples.length = 0;
1426
+ lastEvent = null;
1427
+ });
1428
+
1429
+ // a frame wanted with no input behind it (a resize, a theme, a retry)
1430
+ function request() {
1431
+ if (parked) reach(performance.now());
1432
+ if (!frame) frame = requestAnimationFrame(tick);
1433
+ }
1434
+ // a move at `at` wakes a parked loop at its instant; on a running loop it lands on the
1435
+ // frame's goal, as every move of a stroke does, so a stroke's moves never split a frame
1436
+ function moved(at) {
1437
+ if (parked) reach(at);
1438
+ request();
1439
+ }
1440
+ // a press, a release, a leave, a scroll, a gate: what it changes starts at its own instant,
1441
+ // whether the loop runs or sleeps. The first statement of its handler, so the springs it
1442
+ // reads stand at that instant; `change` is what it does to the goal. Stamped before the
1443
+ // motion's last step, the springs stay there and gain the change's motion since its instant.
1444
+ // Returns that instant
1445
+ function respond(at, change = () => {}) {
1446
+ const t = instant(at);
1447
+ reach(t);
1448
+ const before = t < last && goalAt(last);
1449
+ change();
1450
+ if (before) retarget(before, goalAt(last), last - t);
1451
+ request();
1452
+ return t;
1453
+ }
1454
+
1455
+ // an input's instant: its stamp, MAX_LATE_MS before the motion's last step at the earliest,
1456
+ // and never past now (a stamp on another clock)
1457
+ function instant(at) {
1458
+ return Math.min(Math.max(at, last - MAX_LATE_MS), performance.now());
1459
+ }
1460
+ // Brings the motion from `last` to an input's instant on the goal it had until then, or
1461
+ // leaves it at `last` for one stamped before it (respond then plays its change). A
1462
+ // parked loop's springs stood at their goal, or moved on unseen behind a hidden mark toward
1463
+ // a goal no input changed, so one exact step, snapped where a ticking loop's frames would
1464
+ // have snapped it, lands them where that loop would be. A running loop steps like a frame
1465
+ // ending at the input, inside the frame's clamp; the next frame steps from there.
1466
+ function reach(at) {
1467
+ const t = Math.max(instant(at), last);
1468
+ if (parked) {
1469
+ if (parkedGoal && t > last) {
1470
+ advance((t - last) / 1000, parkedGoal);
1471
+ settle(parkedGoal);
1472
+ }
1473
+ frameAt = t;
1474
+ } else if (t > last) {
1475
+ advance(stepTo(t), goalAt(t));
1476
+ }
1477
+ last = t;
1478
+ parked = false;
1479
+ }
1480
+ // the seconds from `last` to t that fall inside the first MAX_STEP_S after the last frame,
1481
+ // so the steps between two frames add up to MAX_STEP_S at most
1482
+ const stepTo = t => {
1483
+ const end = frameAt + MAX_STEP_S * 1000;
1484
+ return (Math.min(t, end) - Math.min(last, end)) / 1000;
1485
+ };
1486
+ const park = goal => { parked = true; parkedGoal = goal; };
1487
+
1488
+ const goalAt = now => {
1489
+ const [tp, tq, v] = jellyTarget(now);
1490
+ return { ...targets(), jp: tp, jq: tq, tone: Math.min(1, v / FULL_SPEED), shown: host.classList.contains('visible') ? 1 : 0 };
1491
+ };
1492
+
1493
+ // each spring, the goal field it heads for, and its matrix in `matrices`
1494
+ const springs = [[hw, 'w', 'size'], [hh, 'h', 'size'], [fill, 'f', 'layer'], [dot, 'd', 'layer'],
1495
+ [slash, 'sl', 'layer'], [red, 'r', 'layer'], [jp, 'jp', 'wobble'], [jq, 'jq', 'wobble']];
1496
+ const matrices = dt => ({
1497
+ size: springMatrix(SHAPE_OMEGA, SHAPE_ZETA, dt), layer: springMatrix(SHAPE_OMEGA, 0.9, dt),
1498
+ wobble: springMatrix(JELLY_OMEGA, JELLY_ZETA, dt),
1499
+ });
1500
+ const fade = (from, goal, ms) => goal ? Math.min(1, from + ms / SHOW_MS) : Math.max(0, from - ms / HIDE_MS);
1501
+
1502
+ // every spring and ease over dt seconds, the goal held across the step
1503
+ function advance(dt, goal) {
1504
+ const k = matrices(dt);
1505
+ for (const [s, field, kind] of springs) step(s, goal[field], k[kind]);
1506
+ tone = goal.tone + (tone - goal.tone) * Math.exp(-dt * 1000 / TONE_MS);
1507
+ shown = fade(shown, goal.shown, dt * 1000);
1508
+ }
1509
+
1510
+ // adds to a spring what one resting at zero does under k heading for `target` from speed `v`:
1511
+ // the springs are linear, so that is what a goal change or a kick that long ago adds now
1512
+ const superpose = (s, target, v, k) => {
1513
+ const r = { x: 0, v };
1514
+ step(r, target, k);
1515
+ s.x += r.x; s.v += r.v;
1516
+ };
1517
+ // a goal change `ms` before the motion's last step, played from its own instant: the springs
1518
+ // and the light-up's ease are linear in their goal
1519
+ function retarget(from, to, ms) {
1520
+ const k = matrices(ms / 1000);
1521
+ for (const [s, field, kind] of springs) superpose(s, to[field] - from[field], 0, k[kind]);
1522
+ tone += (to.tone - from.tone) * (1 - Math.exp(-ms / TONE_MS));
1523
+ if (from.shown === to.shown) return;
1524
+ // the fade is linear short of its ends: rewound to the change on the old goal and replayed on
1525
+ // the new; one standing at its end is taken to have stood there since the change
1526
+ const atChange = shown === from.shown ? shown : Math.min(1, Math.max(0, fade(shown, from.shown, -ms)));
1527
+ shown = fade(atChange, to.shown, ms);
1528
+ }
1529
+
1530
+ // snaps whatever has come to rest onto its goal; true once nothing that shows still moves
1531
+ function settle(goal) {
1532
+ const toneSettled = Math.abs(tone - goal.tone) < (goal.tone ? 0.003 : toneRest);
1533
+ if (toneSettled) tone = goal.tone;
1534
+ // a mark drawing no toned ink (the caret's filled line, the refusal red) shows the
1535
+ // light-up only through the bloom, which shows nothing under 0.02: there the ease runs on
1536
+ // unseen, still for the park, and a wake's exact step carries it
1537
+ const tonedInk = goal.r < 1 && (goal.f < 0.999 || goal.d > 0.004 || goal.sl > 0.004);
1538
+ const toneStill = toneSettled || (!goal.tone && !tonedInk && tone < 0.02);
1539
+ // 0.003 of stretch is 0.03px on the idle ring: below that the wobble is done. The stretch
1540
+ // moves an outline by about itself times the mark's smaller half size (the tensor scales
1541
+ // it by the roundness), so a thinner mark coming to rest, the caret's line, is done at
1542
+ // the stretch that moves it 0.03px
1543
+ const thin = 0.003 * (SIZE.idle / 2) / Math.max(Math.min(goal.w, goal.h), 1);
1544
+ const wobbleRest = goal.jp || goal.jq ? 0.003 : Math.max(0.003, thin);
1545
+ return [rest(hw, goal.w, 0.01), rest(hh, goal.h, 0.01), rest(fill, goal.f, 0.002), rest(dot, goal.d, 0.002),
1546
+ rest(slash, goal.sl, 0.002), rest(red, goal.r, 0.002), rest(jp, goal.jp, wobbleRest), rest(jq, goal.jq, wobbleRest),
1547
+ toneStill, shown === goal.shown]
1548
+ .every(Boolean);
1549
+ }
1550
+
1551
+ function tick() {
1552
+ frame = 0;
1553
+ const now = performance.now();
1554
+ const dt = stepTo(now);
1555
+ last = frameAt = now;
1556
+ if (applyQueued) { applyQueued = false; apply(queuedTarget); }
1557
+
1558
+ // drop samples the window has passed, keeping one to interpolate the tail from
1559
+ const win = samples.length ? windowMs(now) : TAIL_MS;
1560
+ while (samples.length > 1 && samples[1].net < netHead(now) - win) {
1561
+ const gone = samples.shift(), lead = samples[0].fill;
1562
+ if (gone.x !== samples[0].x || gone.y !== samples[0].y) behind = lead ? lead[lead.length - 1] : gone;
1563
+ }
1564
+ if (samples.length && strokeOver(now, win)) samples.length = 0;
1565
+ const goal = goalAt(now);
1566
+ advance(dt, goal);
1567
+ const settled = settle(goal);
1568
+
1569
+ if (dirty) {
1570
+ const pad = 2;
1571
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1572
+ ctx.clearRect(Math.floor((dirty[0] - pad) * dpr), Math.floor((dirty[1] - pad) * dpr),
1573
+ Math.ceil((dirty[2] - dirty[0] + 2 * pad) * dpr), Math.ceil((dirty[3] - dirty[1] + 2 * pad) * dpr));
1574
+ }
1575
+ const box = { b: null };
1576
+ if (!placed) { dirty = null; park(goal); return; }
1577
+
1578
+ const x = pointer.x, y = pointer.y;
1579
+ const T = jellyTensor(shapeRoundness());
1580
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1581
+ const count = samples.length ? buildGrid(now, win) : 0;
1582
+ let trailShows = false;
1583
+ if (count && sprite) {
1584
+ const edge = holeEdge();
1585
+ const still = now - samples[samples.length - 1].t;
1586
+ const front = edgeAge(count, edge, still);
1587
+ trailShows = drawTrail(count, edge, win, front, box);
1588
+ if (trailShows) cutHole(x, y, T);
1589
+ }
1590
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1591
+ drawDrops(now, box);
1592
+ drawRipples(now, box);
1593
+ placeGlow(x, y);
1594
+ drawMark(x, y, T, box);
1595
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1596
+ dirty = box.b;
1597
+ host.dataset.x = x;
1598
+ host.dataset.y = y;
1599
+ // the hidden cursor arms only once the mark has drawn, so a mark that dies
1600
+ // at boot never leaves the page cursorless
1601
+ if (!proven) { proven = true; arm(); }
1602
+
1603
+ // the loop parks on the first frame that will repeat. A still pointer's goal stays put
1604
+ // until the next input and its tube never grows back, so with droplets and ripples
1605
+ // (their own alpha) gone, that frame is one whose mark is hidden (shown 0, the trail and
1606
+ // the bloom gone with it), or one with every spring at rest and no stamp that shows.
1607
+ const hidden = shown === 0 && goal.shown === 0;
1608
+ const unchanging = !ripples.length && !drops.length && pointerStill(now) && (hidden || (settled && !trailShows));
1609
+ if (unchanging) park(goal);
1610
+ else frame = requestAnimationFrame(tick);
1611
+ }
1612
+
1613
+ readColours();
1614
+ sizeCanvas();
1615
+ watchRatio();
1616
+ addEventListener('resize', sizeCanvas);
1617
+ onThemeChange(() => { readColours(); request(); });
1618
+
1619
+ // the native cursor hides on the first real mouse move, never at boot: a
1620
+ // touchscreen laptop matches (pointer: fine) too
1621
+ addEventListener('pointermove', e => {
1622
+ if (e.pointerType !== 'mouse' || gated) return;
1623
+ const batch = e.getCoalescedEvents ? e.getCoalescedEvents() : [];
1624
+ const moves = batch.length ? batch : [e];
1625
+ const at = moves[0].timeStamp;
1626
+ moved(at);
1627
+ // a return from outside the window starts a new stroke: joining it to the old one
1628
+ // would draw a tube across a stretch the pointer never travelled. A stroke also ends at
1629
+ // its own instant, whether or not a frame ran past it
1630
+ if (left) { left = false; samples.length = 0; }
1631
+ else if (samples.length && strokeOver(at, windowMs(at))) samples.length = 0;
1632
+ for (const c of moves) {
1633
+ const tail = samples[samples.length - 1];
1634
+ if (tail && c.timeStamp <= tail.t) { tail.x = c.clientX; tail.y = c.clientY; }
1635
+ else {
1636
+ if (!tail) behind = null;
1637
+ const pause = tail ? Math.max(0, c.timeStamp - tail.t - DRAIN_MS) : 0;
1638
+ samples.push({ x: c.clientX, y: c.clientY, t: c.timeStamp, pause, net: tail ? tail.net + c.timeStamp - tail.t - pause : 0, ...record() });
1639
+ }
1640
+ shed(c.clientX, c.clientY, c.timeStamp);
1641
+ }
1642
+ pointer.x = e.clientX; pointer.y = e.clientY;
1643
+ if (!placed || detached) {
1644
+ placed = true; detached = false;
1645
+ if (proven) arm();
1646
+ }
1647
+ host.classList.add('visible');
1648
+ queueApply(e.target instanceof Element ? e.target : null);
1649
+ }, { passive: true });
1650
+
1651
+ // the trail fades with the mark and ages out; the next move drops it
1652
+ root.addEventListener('mouseleave', e => {
1653
+ respond(e.timeStamp, () => host.classList.remove('visible'));
1654
+ left = true;
1655
+ lastEvent = null;
1656
+ });
1657
+ // not while a press is held: the pressed element owns the pointer through a
1658
+ // capture, and a drag's own scroll would re-derive the state off the raw point
1659
+ addEventListener('scroll', e => {
1660
+ if (host.classList.contains('press')) return;
1661
+ respond(e.timeStamp);
1662
+ queueApply(null);
1663
+ }, { passive: true, capture: true });
1664
+
1665
+ // capture on the press pair, so a handler that stops propagation cannot strand the mark
1666
+ addEventListener('pointerdown', e => {
1667
+ if (e.pointerType !== 'mouse') {
1668
+ respond(e.timeStamp, () => host.classList.remove('visible'));
1669
+ detached = true;
1670
+ root.classList.remove('custom-cursor');
1671
+ return;
1672
+ }
1673
+ if (e.button !== 0 || gated) return;
1674
+ respond(e.timeStamp, () => host.classList.add('press'));
1675
+ pressedState = state;
1676
+ }, { capture: true });
1677
+
1678
+ // a press lost to blur, cancel or a drag must not leave its click feedback for the next button
1679
+ const release = e => {
1680
+ pressedState = null;
1681
+ if (!host.classList.contains('press')) return;
1682
+ respond(e.timeStamp, () => host.classList.remove('press'));
1683
+ };
1684
+ addEventListener('pointerup', e => {
1685
+ if (e.pointerType !== 'mouse') return;
1686
+ const at = respond(e.timeStamp);
1687
+ const was = pressedState;
1688
+ release(e);
1689
+ if (was === 'blocked') {
1690
+ // a refused click shakes its head: a sideways kick into the jelly, centre unmoved
1691
+ superpose(jp, 0, SHAKE_KICK, matrices((last - at) / 1000).wobble);
1692
+ } else if (was === 'hover') {
1693
+ ripples.push({ x: e.clientX, y: e.clientY, t0: at });
1694
+ splash(e.clientX, e.clientY, at);
1695
+ }
1696
+ queueApply(null); // the click's handler may have removed or moved the target
1697
+ }, { capture: true });
1698
+ addEventListener('pointercancel', release, { capture: true });
1699
+ addEventListener('blur', release);
1700
+
1701
+ // a native drag brings the OS cursor and a ghost back, and no `cursor: none` outranks either
1702
+ document.querySelectorAll('a[href], img').forEach(el => { el.draggable = false; });
1703
+ addEventListener('dragstart', e => {
1704
+ release(e);
1705
+ if (root.classList.contains('custom-cursor')) e.preventDefault();
1706
+ }, { capture: true });
1707
+ /* A press held on `a[href]` hands the cursor to the link-drag machinery before
1708
+ any dragstart exists, and refusing mousedown is already too late, so the
1709
+ pointerdown default is refused and focus set by hand. `focusVisible: false`
1710
+ or every click paints the keyboard ring. Primary mouse only: the middle
1711
+ button opens a tab and the right one the context menu. */
1712
+ addEventListener('pointerdown', e => {
1713
+ if (e.pointerType !== 'mouse' || e.button !== 0) return;
1714
+ if (!root.classList.contains('custom-cursor')) return;
1715
+ const link = e.target instanceof Element && e.target.closest('a[href]');
1716
+ if (!link) return;
1717
+ e.preventDefault();
1718
+ link.focus({ preventScroll: true, focusVisible: false });
1719
+ }, { capture: true });
1720
+ };
1721
+
1722
+ /* A browser cache can serve another version's cursor.css beside this file, and a page can
1723
+ load the sheet after the module runs (a script-inserted link, a media or preload swap, an
1724
+ async module). The layer boots only once its own sheet applies, told by a marker no v1 or
1725
+ v2 sheet declares, and marks the page `cursor-v3`, which the sheet's hidden-cursor rule
1726
+ needs beside `custom-cursor`. Until then it looks again on window load and on every
1727
+ mouse move, so a late sheet still gets the whole layer and a legacy one never does. An
1728
+ async module can also run before `<body>` exists; the layer then boots on load or on the
1729
+ first mouse move once the body is there. */
1730
+ const ownSheet = () => getComputedStyle(root).getPropertyValue('--cursor-v3') !== '';
1731
+ let booted = false;
1732
+ const boot = () => {
1733
+ if (booted || !document.body || !ownSheet()) return;
1734
+ booted = true;
1735
+ root.classList.add('cursor-v3');
1736
+ bootGrid();
1737
+ if (motionAllowed()) bootMark();
1738
+ };
1739
+ // the grid's track, and the late look for the sheet. pointermove, never mousemove: a refused
1740
+ // pointerdown default (the link guard, the drawn scrollbar's thumb) suppresses every
1741
+ // compatibility mouse event until the release, which froze the grid for a press-drag.
1742
+ // Registered before the ring's, so on each move the grid reads the pointer first
1743
+ addEventListener('pointermove', e => {
1744
+ if (e.pointerType !== 'mouse') return;
1745
+ boot();
1746
+ if (gridTrack) gridTrack(e);
1747
+ }, { passive: true });
1748
+ addEventListener('load', boot, { once: true });
1749
+ boot();