reviewzy 0.3.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.
@@ -1,24 +1,70 @@
1
- /* ambient layer: the cursor-reactive dot grid and the trailing pointer ring.
2
- Two of the three sanctioned ambient effects, on by default, desktop fine-pointer only,
3
- silent under `prefers-reduced-motion`.
4
-
5
- Load as `<script type="module" src="cursor.js"></script>` after cursor.css. The rules
6
- module is imported relative to THIS file, so the pair works from any subpath with no
7
- page-side configuration. Serving is not optional: module imports die on file://. */
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. */
8
6
 
9
7
  import { SEMANTIC_POINTER, SEMANTIC_BLOCKED, collectCursorSelectors } from './cursor-rules.js';
10
8
 
11
- const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
12
- // the CSS hides the ring at `(hover: none), (pointer: coarse)`, so the boot gate has to
13
- // agree with it: a stylus is a fine pointer that cannot hover, and booting there armed the
14
- // hidden cursor for a ring the stylesheet had already hidden - a page with no pointer at all
15
- const fine = matchMedia('(pointer: fine) and (hover: hover)').matches;
16
-
17
- // ---- cursor-reactive dot grid ----
18
- {
19
- // The canvas is created here rather than pasted into every page. It is fixed at z-index -1,
20
- // so DOM position changes nothing about what it paints, and a hand-pasted element is a step
21
- // every page can skip: five of this system's own pages shipped with no ambient layer at all.
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 = () => {
22
68
  let canvas = document.getElementById('bg-dots');
23
69
  if (!canvas) {
24
70
  canvas = document.createElement('canvas');
@@ -27,45 +73,38 @@ const fine = matchMedia('(pointer: fine) and (hover: hover)').matches;
27
73
  document.body.prepend(canvas);
28
74
  }
29
75
  const ctx = canvas.getContext('2d');
30
- const interactive = fine && !reduced; // touch / reduced get a still grid
31
76
  const gap = 40, R = 150;
32
- const mouse = { x: 0, y: 0, active: false };
33
- let w, h, dots = [], scheduled = false;
34
-
35
- /* THE GRID READS ITS TWO COLOURS OFF THE TOKENS, never a baked constant. The shipped
36
- constants were `[54, 56, 71]` and `[104, 187, 239]`, which are dark-theme `--line` and
37
- `--accent` resolved, so a Latte page painted a dark grid on a near-white surface.
38
- `getComputedStyle().color` serializes an oklch value back as oklch in Chrome 152, so
39
- the resolver is a 1x1 canvas: fillStyle takes any CSS colour the engine knows and
40
- getImageData hands back the sRGB bytes. `var(--x)` is NOT a colour to fillStyle, so the
41
- custom property is read as text first; a value fillStyle rejects leaves the previous
42
- one standing, which is what the sentinel catches. */
43
- const swatch = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
44
- const resolve = (prop, fallback) => {
45
- const css = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
46
- if (!css) return fallback;
47
- swatch.fillStyle = '#000000';
48
- swatch.fillStyle = css;
49
- if (swatch.fillStyle === '#000000' && css !== '#000000') return fallback;
50
- swatch.fillRect(0, 0, 1, 1);
51
- const d = swatch.getImageData(0, 0, 1, 1).data;
52
- return [d[0], d[1], d[2]];
53
- };
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;
54
81
  let base = [54, 56, 71], lit = [104, 187, 239];
55
- /* The resting alpha is a token so a page whose SUBJECT is the grid can raise it
56
- without a second grid or a copy of this module. The lift keeps its 0.80 peak
57
- whatever the floor, so a raised floor shortens the spotlight's travel rather
58
- than pushing it past the top of the ramp. */
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
59
84
  const REST = 0.24, PEAK = 0.80;
60
85
  let rest = REST;
61
86
  const readTokens = () => {
62
- base = resolve('--line', base);
63
- lit = resolve('--accent', lit);
64
- const raw = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dot-rest'));
87
+ base = resolveColour('--line', base);
88
+ lit = resolveColour('--accent', lit);
89
+ const raw = parseFloat(getComputedStyle(root).getPropertyValue('--dot-rest'));
65
90
  rest = Number.isFinite(raw) ? Math.min(Math.max(raw, 0), PEAK) : REST;
66
91
  };
67
- // the pitch, so CSS can align to the grid instead of restating 40 with no guard
68
- document.documentElement.style.setProperty('--dot-gap', gap + 'px');
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;
69
108
 
70
109
  const build = () => {
71
110
  const dpr = Math.min(devicePixelRatio || 1, 2);
@@ -74,285 +113,1637 @@ const fine = matchMedia('(pointer: fine) and (hover: hover)').matches;
74
113
  canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
75
114
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
76
115
  dots = [];
77
- const cols = Math.ceil(w / gap) + 1, rows = Math.ceil(h / gap) + 1;
78
- const ox = (w - (cols - 1) * gap) / 2, oy = (h - (rows - 1) * gap) / 2;
79
- for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) dots.push({ bx: ox + x * gap, by: oy + y * gap });
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
+ }
80
121
  };
81
122
  const draw = () => {
82
123
  ctx.clearRect(0, 0, w, h);
83
- const on = interactive && mouse.active;
84
- const shx = on ? (mouse.x - w / 2) / w * 10 : 0, shy = on ? (mouse.y - h / 2) / h * 10 : 0; // parallax
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;
85
126
  for (const d of dots) {
86
- const px = d.bx + shx, py = d.by + shy;
87
- let a = rest, r = 1.15, c = base;
127
+ const px = d.bx + d.ox + shx, py = d.by + d.oy + shy;
128
+ let f = 0;
88
129
  if (on) {
89
130
  const dist = Math.hypot(px - mouse.x, py - mouse.y);
90
- if (dist < R) {
91
- const f = (1 - dist / R) ** 2; // tight, eased spotlight
92
- a = rest + f * (PEAK - rest); r = 1 + f * 1.6;
93
- c = base.map((b, i) => Math.round(b + (lit[i] - b) * f));
94
- }
131
+ if (dist < R) f = (1 - dist / R) ** 2;
95
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;
96
136
  ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2);
97
137
  ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${a})`; ctx.fill();
98
138
  }
99
139
  };
100
- const schedule = () => { if (!scheduled) { scheduled = true; requestAnimationFrame(() => { scheduled = false; draw(); }); } };
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
+ };
101
220
 
102
221
  readTokens(); build(); draw();
103
- if (interactive) {
104
- /* Every pointer track in this file rides `pointermove`, never `mousemove`.
105
- Cancelling a `pointerdown` default suppresses the compatibility mouse
106
- events for that pointer's whole active state, and this file cancels one
107
- on `a[href]` while the drawn scrollbar cancels one on its thumb, so both
108
- the grid and the ring froze for the length of any press-drag. Measured on
109
- the reference sheet, 8 moves under a held press: mousemove 0, pointermove
110
- 8, against 8 and 8 with no button down. */
111
- addEventListener('pointermove', e => {
112
- if (e.pointerType !== 'mouse') return;
113
- mouse.x = e.clientX; mouse.y = e.clientY; mouse.active = true; schedule();
114
- }, { passive: true });
115
- document.documentElement.addEventListener('mouseleave', () => { mouse.active = false; schedule(); });
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
+ });
116
256
  }
117
257
  addEventListener('resize', () => { build(); draw(); });
118
- // the theme toggle writes data-theme on the root, and the grid's two colours move with it
119
- new MutationObserver(() => { readTokens(); schedule(); })
120
- .observe(document.documentElement, { attributeFilter: ['data-theme'] });
121
- }
258
+ onThemeChange(() => { readTokens(); schedule(); });
259
+ };
122
260
 
123
- // ---- trailing pointer ring (replaces the native cursor) ----
124
- if (fine && !reduced) {
261
+ // ---- the jelly drop (replaces the native cursor) ----
262
+ const bootMark = () => {
125
263
  const host = document.createElement('div');
126
264
  host.id = 'cursor';
127
265
  host.setAttribute('aria-hidden', 'true');
128
- const ring = document.createElement('div');
129
- ring.id = 'cursor-ring';
130
- host.append(ring);
266
+ const canvas = document.createElement('canvas');
267
+ canvas.id = 'cursor-paint';
268
+ host.append(canvas);
131
269
  document.body.appendChild(host);
132
- // the native cursor is hidden on the first real mouse move, never here: a touchscreen laptop
133
- // matches (pointer: fine) too, and hiding it at boot leaves the page cursorless until the
134
- // visitor happens to move something
135
-
136
- // the rules module is a static relative import, so the sweep is ready before the first
137
- // event: no site-root specifier to get wrong, no console error on a page served from a
138
- // subpath, and no window where the ring answers neutral for everything
139
- const usable = list => list.filter(sel => { // one bad selector poisons a whole
140
- try { ring.matches(sel); return true; } catch { return false; } // closest() list
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; }
141
455
  }).join(', ');
142
456
  const found = collectCursorSelectors(document.styleSheets);
143
457
  const hitPointer = usable([...found.pointer, SEMANTIC_POINTER]);
144
458
  const hitInert = usable(found.inert);
145
459
  const hitBlocked = usable([...found.blocked, SEMANTIC_BLOCKED]);
146
460
 
147
- const field = 'input[type="text"], input[type="search"], input[type="email"], input[type="password"], input[type="url"], input[type="tel"], input[type="number"], textarea, [contenteditable="true"]';
148
- const probe = document.createRange();
149
- // the caret promises "text under the pointer", so hit-test the glyph runs: a block-level
150
- // heading or span stretches to its container and most of its box is empty. only the hovered
151
- // element's own text nodes count, since a child's glyphs would have retargeted the event
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
152
485
  const overText = (el, x, y) => {
153
- if (el.matches(field)) return true; // a field is text everywhere, empty or not
154
- for (const n of el.childNodes) {
155
- if (n.nodeType !== Node.TEXT_NODE || !n.textContent.trim()) continue;
156
- probe.selectNodeContents(n);
157
- for (const r of probe.getClientRects()) {
158
- if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true;
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);
159
502
  }
160
503
  }
161
- return false;
504
+ return Number.isFinite(line) && below - above <= line * 1.5;
162
505
  };
163
506
 
164
- // share of the gap closed per frame. The ring is the aim, so it settles in ~4 frames rather
165
- // than ~13, and it still trails on a sweep
166
- const LERP = 0.35;
167
- const pointer = { x: 0, y: 0 }, trail = { x: 0, y: 0 };
168
- let placed = false, proven = false, drawId = 0, applyQueued = false, queuedTarget = null;
169
- let rootRetries = 0;
170
- let caretSource = null, pressedOnTarget = false, detached = false, bailed = false;
171
- const pulses = new Set(); // in-flight ripples, re-spawned when a view transition swallows them
172
-
173
- // standing down for a finger is temporary; a ring that threw is not. Arming has to be
174
- // repeatable or the first stand-down is permanent: the ring keeps painting and the OS arrow
175
- // comes back for the life of the page, taking the dragstart guard with it
176
- const arm = () => { if (!bailed) document.documentElement.classList.add('custom-cursor'); };
177
-
178
- // the ring is written here and nowhere else, so a burst of moves costs one style write per
179
- // frame; the loop ends the frame it catches up, which is also why the trail can never sit
180
- // stranded behind a pointer that stopped moving
181
- function paint() {
182
- drawId = 0;
183
- // over text the ring IS the caret, and a caret lagging behind the pointer stands where the
184
- // click will NOT put it, so that state closes the gap in one frame
185
- const lerp = host.classList.contains('text') ? 1 : LERP;
186
- const dx = pointer.x - trail.x, dy = pointer.y - trail.y;
187
- if (lerp === 1 || (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1)) { trail.x = pointer.x; trail.y = pointer.y; }
188
- else { trail.x += dx * lerp; trail.y += dy * lerp; drawId = requestAnimationFrame(paint); }
189
- ring.style.transform = `translate3d(${trail.x}px, ${trail.y}px, 0) translate(-50%, -50%)`;
190
- // the native cursor hides only after this write painted a frame, so a ring that dies at
191
- // boot never leaves the page cursorless
192
- if (!proven) { proven = true; arm(); }
193
- }
194
- const draw = () => { if (!drawId) drawId = requestAnimationFrame(paint); };
195
-
196
- // if anything throws after the class went on, the native cursor comes back and stays back
197
- addEventListener('error', () => { bailed = true; document.documentElement.classList.remove('custom-cursor'); });
198
-
199
507
  function stateFor(el, x, y) {
200
508
  if (!el) return '';
201
509
  if (hitBlocked && el.closest(hitBlocked)) return 'blocked';
202
510
  const click = hitPointer && el.closest(hitPointer);
203
511
  if (click) {
204
- // a rule switching the pointer back off only outranks the clickable one from its own
205
- // element or below it
512
+ // a pointer switched back off outranks the clickable rule only from its own element or below
206
513
  const off = hitInert && el.closest(hitInert);
207
514
  if (!off || !(off === click || click.contains(off))) return 'hover';
208
515
  }
209
516
  return overText(el, x, y) ? 'text' : '';
210
517
  }
211
518
 
212
- // el === null means "work out what is under the pointer now", the only honest answer once
213
- // something other than the pointer moved
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
+
214
532
  function apply(el) {
215
533
  const target = el || document.elementFromPoint(pointer.x, pointer.y);
216
- // a root view transition answers the root element for every point while it
217
- // runs, and its animations lag the swap's first frame, so probing them here
218
- // can miss the transition entirely. The root answer is never a useful state
219
- // anyway: re-derive until hit testing names a real element again.
220
- if (!el && target === document.documentElement) {
221
- if (rootRetries++ < 5) setTimeout(() => schedule(null), 80);
222
- return;
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
+ }
223
541
  }
224
542
  rootRetries = 0;
225
- const state = stateFor(target, pointer.x, pointer.y);
543
+ state = stateFor(target, pointer.x, pointer.y);
226
544
  host.classList.toggle('hover', state === 'hover');
227
545
  host.classList.toggle('blocked', state === 'blocked');
228
546
  host.classList.toggle('text', state === 'text');
229
547
  if (state === 'text' && target && target !== caretSource) {
230
548
  caretSource = target;
231
- // the bar tracks the font only so far: past display sizes a proportional caret stops
232
- // reading as a cursor and starts reading as a rule drawn on the page, so it stops just
233
- // above the idle ring's own 26px
549
+ // past 20px type a proportional caret reads as a rule drawn on the page
234
550
  const size = parseFloat(getComputedStyle(target).fontSize);
235
- if (size) host.style.setProperty('--caret-h', Math.min(Math.round(size * 1.2), 24) + 'px');
551
+ if (size) caretH = Math.min(Math.round(size * 1.2), 24);
236
552
  } else if (state !== 'text') caretSource = null;
237
553
  }
238
-
239
554
  // overText reads client rects, which forces layout: once a frame, never once a move
240
- function schedule(el) {
555
+ function queueApply(el) {
241
556
  queuedTarget = el;
242
- if (applyQueued) return;
243
557
  applyQueued = true;
244
- requestAnimationFrame(() => { applyQueued = false; apply(queuedTarget); });
558
+ request();
245
559
  }
246
560
 
247
- function pulse(x, y) { // placed by left/top, not a transform, so
248
- const p = document.createElement('div'); // the keyframe's scale has nothing to fight
249
- p.className = 'cursor-pulse';
250
- p.style.left = x + 'px';
251
- p.style.top = y + 'px';
252
- p.addEventListener('animationend', () => { p.remove(); pulses.delete(p); });
253
- pulses.add(p);
254
- host.appendChild(p);
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);
255
953
  }
256
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
257
1621
  addEventListener('pointermove', e => {
258
- if (e.pointerType !== 'mouse') return;
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
+ }
259
1642
  pointer.x = e.clientX; pointer.y = e.clientY;
260
1643
  if (!placed || detached) {
261
1644
  placed = true; detached = false;
262
- trail.x = pointer.x; trail.y = pointer.y;
263
- if (proven) arm(); // the mouse is back after a finger took over
1645
+ if (proven) arm();
264
1646
  }
265
1647
  host.classList.add('visible');
266
- schedule(e.target instanceof Element ? e.target : null);
267
- draw();
1648
+ queueApply(e.target instanceof Element ? e.target : null);
268
1649
  }, { passive: true });
269
1650
 
270
- document.documentElement.addEventListener('mouseleave', () => host.classList.remove('visible'));
271
- /* NOT WHILE A PRESS IS HELD. `schedule(null)` means "work out what is under the
272
- pointer now", and a held press is exactly when that is the wrong question: the
273
- pressed element owns the pointer through a capture, and the drag's own scroll
274
- fires every frame, so the captured target was overwritten and re-derived from
275
- the raw point. Dragging the drawn scrollbar 17px off its 6px lane therefore
276
- dropped `hover`, and `press` alone is pixel-identical to idle, so the ring
277
- went back to its resting size mid-drag with the page still scrolling. The next
278
- real move re-derives it either way. */
279
- addEventListener('scroll', () => {
280
- if (!host.classList.contains('press')) schedule(null);
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);
281
1663
  }, { passive: true, capture: true });
282
1664
 
283
- // capture on the press pair: a handler that stops propagation must not strand the ring
1665
+ // capture on the press pair, so a handler that stops propagation cannot strand the mark
284
1666
  addEventListener('pointerdown', e => {
285
- if (e.pointerType !== 'mouse') { // a finger took over: stand down and give
286
- detached = true; // the native cursor back
287
- host.classList.remove('visible');
288
- document.documentElement.classList.remove('custom-cursor');
1667
+ if (e.pointerType !== 'mouse') {
1668
+ respond(e.timeStamp, () => host.classList.remove('visible'));
1669
+ detached = true;
1670
+ root.classList.remove('custom-cursor');
289
1671
  return;
290
1672
  }
291
- pressedOnTarget = host.classList.contains('hover');
292
- host.classList.add('press');
1673
+ if (e.button !== 0 || gated) return;
1674
+ respond(e.timeStamp, () => host.classList.add('press'));
1675
+ pressedState = state;
293
1676
  }, { capture: true });
294
1677
 
295
- const release = () => host.classList.remove('press');
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
+ };
296
1684
  addEventListener('pointerup', e => {
297
- release();
298
- if (pressedOnTarget && e.pointerType === 'mouse') pulse(e.clientX, e.clientY);
299
- pressedOnTarget = false;
300
- schedule(null); // the click's handler may have removed or moved the target
301
- }, { capture: true });
302
- // A page view transition (the theme swap) crossfades the live page in, which
303
- // multiplies a pulse spawned at pointerup down to near-invisible for its first
304
- // 100ms, and the capture freezes it in the old snapshot at its first, smallest
305
- // frame. The cursor owes the ripple in every state, so one re-check after the
306
- // click: if a root transition is running, the in-flight pulses are re-spawned
307
- // into the live side, where the ramp has mostly run its course by the time
308
- // the ripple grows. No transition, no re-spawn: the check rides every click
309
- // but only acts while a pulse exists.
310
- const inViewTransition = () => document.getAnimations().some(a =>
311
- a.effect && a.effect.pseudoElement === '::view-transition-new(root)');
312
- document.addEventListener('click', () => {
313
- setTimeout(() => {
314
- if (!inViewTransition() || !pulses.size) return;
315
- pulses.forEach(p => { p.remove(); pulses.delete(p); });
316
- pulse(pointer.x, pointer.y);
317
- }, 50);
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
318
1697
  }, { capture: true });
319
1698
  addEventListener('pointercancel', release, { capture: true });
320
- // a native drag brings the OS cursor and a ghost image back, and no `cursor: none` outranks
321
- // either, so the drag has to not start. The cost is dragging a link or an image out of the
322
- // page; keep the class gate, because once a finger takes over there is no ring to protect.
323
- // Links and images start an HTML5 drag on press-move in every engine, and the
324
- // `-webkit-user-drag: none` CSS only reaches Blink: the first dragstart frame has already
325
- // painted the OS cursor. Mark them not draggable, the property the engine itself reads;
326
- // the dragstart guard below stays for dynamically added content and for engines that
327
- // ignore the attribute.
1699
+ addEventListener('blur', release);
1700
+
1701
+ // a native drag brings the OS cursor and a ghost back, and no `cursor: none` outranks either
328
1702
  document.querySelectorAll('a[href], img').forEach(el => { el.draggable = false; });
329
- addEventListener('dragstart', (e) => {
330
- release();
331
- if (document.documentElement.classList.contains('custom-cursor')) e.preventDefault();
1703
+ addEventListener('dragstart', e => {
1704
+ release(e);
1705
+ if (root.classList.contains('custom-cursor')) e.preventDefault();
332
1706
  }, { capture: true });
333
- // NONE OF THE THREE GUARDS ABOVE RUNS FOR A LINK HELD STILL. A press on `a[href]` hands
334
- // the cursor to the browser's link-drag machinery before any dragstart exists to cancel,
335
- // so the platform pointer paints on top of the ring for as long as the button is held.
336
- // Measured against targets identical but for their tag: only `a[href]` does it; `a`
337
- // without href, `button` and a plain box stay clean, and the page-side state is untouched
338
- // throughout (class on, `press` on, no pointercancel, no blur, no dragstart).
339
- // REFUSING THE MOUSEDOWN DEFAULT IS TOO LATE, measured: the cursor is already taken by
340
- // then. `pointerdown` is the first event of the sequence, and its default action is what
341
- // produces the compatibility mouse events the drag hangs off. Click, activation and
342
- // navigation all survive it; focus and a selection STARTING inside the link do not, so
343
- // the focus is set by hand here.
344
- addEventListener('pointerdown', (e) => {
345
- // primary mouse only: touch scrolls with this default, the middle button opens the link
346
- // in a tab and the right button raises the context menu
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 => {
347
1713
  if (e.pointerType !== 'mouse' || e.button !== 0) return;
348
- if (!document.documentElement.classList.contains('custom-cursor')) return;
1714
+ if (!root.classList.contains('custom-cursor')) return;
349
1715
  const link = e.target instanceof Element && e.target.closest('a[href]');
350
1716
  if (!link) return;
351
1717
  e.preventDefault();
352
- // `focusVisible: false` or every click paints the keyboard ring: a script focus is
353
- // treated as one, and the refused default is what would have marked it as a mouse
354
- // focus. Tab still rings, and an engine that ignores the option only over-rings.
355
1718
  link.focus({ preventScroll: true, focusVisible: false });
356
1719
  }, { capture: true });
357
- addEventListener('blur', release);
358
- }
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();