reviewzy 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ // cursor-rules.js - pure, DOM-free, dynamic-imported by the ring at boot.
2
+ const UNUSABLE = /::|&/; // a pseudo-element is a box closest() can never return
3
+ const STATE_PSEUDO = /:(?:hover|active|focus|focus-visible|focus-within|visited|link|target)\b/g;
4
+
5
+ // clickable with no cursor declaration anywhere: the browser draws the pointer itself
6
+ export const SEMANTIC_POINTER = [
7
+ 'a[href]', 'area[href]', 'button', 'summary', 'select', 'label[for]',
8
+ 'input[type="checkbox"]', 'input[type="radio"]', 'input[type="range"]',
9
+ 'input[type="color"]', 'input[type="file"]', 'input[type="submit"]',
10
+ 'input[type="reset"]', 'input[type="button"]',
11
+ '[role="button"]', '[role="link"]', '[role="tab"]',
12
+ '[role="menuitem"]', '[role="option"]', '[role="switch"]',
13
+ ].join(', ');
14
+ export const SEMANTIC_BLOCKED = ':disabled, [aria-disabled="true"]';
15
+
16
+ // split on the commas separating selectors, not the ones inside :is() or an attribute value
17
+ export function splitSelectorList(text) {
18
+ const parts = [];
19
+ let depth = 0, quote = '', start = 0;
20
+ for (let i = 0; i < text.length; i++) {
21
+ const c = text[i];
22
+ if (quote) { if (c === quote && text[i - 1] !== '\\') quote = ''; continue; }
23
+ if (c === '"' || c === "'") quote = c;
24
+ else if (c === '(' || c === '[') depth++;
25
+ else if (c === ')' || c === ']') depth--;
26
+ else if (c === ',' && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
27
+ }
28
+ parts.push(text.slice(start));
29
+ return parts.map(s => s.trim()).filter(Boolean);
30
+ }
31
+
32
+ /* Every selector any rule gives one of `values` for `prop`, de-duplicated. It NARROWS a
33
+ walk, never decides one: the computed style still has the last word, so a selector
34
+ whose rule lives under a media query the viewport does not match costs one match and
35
+ nothing else. A state pseudo is stripped so `.x:hover { position: sticky }` still names
36
+ `.x`. The alternative, reading `getComputedStyle` for every element in the document,
37
+ forces rendering inside any `content-visibility` subtree it crosses, which Chrome
38
+ reports and this system's collapsed rows all carry. */
39
+ export function collectPropSelectors(sheets, prop, values) {
40
+ const found = new Set();
41
+ const visit = rules => {
42
+ for (const rule of rules || []) {
43
+ if (rule.selectorText && rule.style && values.includes(rule.style[prop])) {
44
+ for (const part of splitSelectorList(rule.selectorText)) {
45
+ if (UNUSABLE.test(part)) continue;
46
+ const clean = part.replace(STATE_PSEUDO, '').trim();
47
+ if (clean) found.add(clean);
48
+ }
49
+ }
50
+ if (rule.cssRules) visit(rule.cssRules); // @media, @supports, nesting
51
+ }
52
+ };
53
+ for (const sheet of sheets || []) {
54
+ try { visit(sheet.cssRules); } catch { /* cross-origin sheet */ }
55
+ }
56
+ return [...found];
57
+ }
58
+
59
+ // `inert` is the `cursor: default` set: it lets a rule that switches a clickable class back off
60
+ // (a current breadcrumb, a readonly field) outrank the pointer entry it was written to override.
61
+ export function collectCursorSelectors(sheets) {
62
+ const buckets = { pointer: new Set(), inert: new Set(), blocked: new Set() };
63
+ const bucketFor = c => c === 'pointer' ? buckets.pointer
64
+ : c === 'default' ? buckets.inert
65
+ : c === 'not-allowed' ? buckets.blocked : null;
66
+ const visit = rules => {
67
+ for (const rule of rules || []) {
68
+ const bucket = rule.selectorText && rule.style ? bucketFor(rule.style.cursor) : null;
69
+ if (bucket) for (const part of splitSelectorList(rule.selectorText)) {
70
+ if (UNUSABLE.test(part)) continue;
71
+ const clean = part.replace(STATE_PSEUDO, '').trim(); // folds `.x{default} .x:hover{pointer}`
72
+ if (!clean) continue;
73
+ for (const b of Object.values(buckets)) b.delete(clean); // document order stands in for the cascade
74
+ bucket.add(clean);
75
+ }
76
+ if (rule.cssRules) visit(rule.cssRules); // @media, @supports, CSS nesting
77
+ }
78
+ };
79
+ for (const sheet of sheets || []) {
80
+ try { visit(sheet.cssRules); } catch { /* cross-origin sheet, nothing worth a broken boot */ }
81
+ }
82
+ return { pointer: [...buckets.pointer], inert: [...buckets.inert], blocked: [...buckets.blocked] };
83
+ }
@@ -0,0 +1,105 @@
1
+ /* ============================================================
2
+ the ambient layer
3
+ The dot grid and the pointer ring. Load after components.css;
4
+ cursor.js boots both.
5
+ ============================================================ */
6
+
7
+ /* ---- Cursor-reactive dot-grid background (sanctioned ambient; desktop only) ----
8
+ Canvas created and painted on demand by cursor.js; sits behind all content via
9
+ background propagation. Solid cards keep it to negative space. */
10
+ #bg-dots { position: fixed; inset: 0; z-index: -1; pointer-events: none; }
11
+
12
+ /* ---- Trailing pointer ring (desktop fine-pointer only) ----
13
+ ONE node is the whole cursor. It TIGHTENS over a target rather than growing, which keeps it
14
+ off the small ones, and over text it morphs into the caret bar itself. No dot in the middle
15
+ and no second node for the caret: another mark under the pointer competes with the thing
16
+ being pointed at, and a fade between two nodes throws away the morph.
17
+ Five states: idle, `.hover`, `.press`, `.blocked` over anything refusing the click, and
18
+ `.text` over a glyph run or a text field. `.text` is gated by a hit test against the glyphs,
19
+ never by tag name (see the JS).
20
+ EVERY STATE IS A WIDTH/HEIGHT CHANGE, never `transform: scale()`: a scaled 1px border
21
+ renders sub-pixel, so a ring that tightens by scale fades as it tightens. The ring is fixed
22
+ and childless, so nothing here touches page layout. */
23
+ #cursor {
24
+ position: fixed;
25
+ top: 0; left: 0;
26
+ z-index: 10000;
27
+ pointer-events: none;
28
+ opacity: 0;
29
+ transition: opacity var(--dur-near) var(--ease);
30
+ --caret-h: 20px;
31
+ }
32
+ #cursor.visible { opacity: 1; }
33
+
34
+ #cursor-ring {
35
+ position: fixed;
36
+ top: 0; left: 0;
37
+ width: 26px; height: 26px;
38
+ border: 1px solid var(--accent);
39
+ border-radius: 50%;
40
+ will-change: transform;
41
+ /* AN ACCENT HAIRLINE IS INVISIBLE OVER THE ACCENT FILLS - buttons, active chips, the sidebar
42
+ cursor, bar fills - and those are exactly the things worth pointing at, so a keyline in the
43
+ PAGE BACKGROUND rides just outside it. Whatever is underneath, exactly one of the two reads:
44
+ on the page the keyline is the background and only the hairline shows, on an accent fill the
45
+ hairline is the fill and only the keyline shows. It follows the shape through the caret
46
+ morph, and it is the surface colour rather than a dark one for the light theme's sake, where
47
+ a dark edge reads as a smudge and swallows the 1px line it was meant to protect. */
48
+ box-shadow: 0 0 0 1px var(--bg);
49
+ transition: width var(--dur-near) var(--ease), height var(--dur-near) var(--ease),
50
+ border-radius var(--dur-near) var(--ease), border-color var(--dur-tint) var(--ease),
51
+ background var(--dur-tint) var(--ease);
52
+ }
53
+
54
+ /* 4px per state: the tighten only has to be legible next to the state before it, and a deep one
55
+ reads as the ring flinching away from the target. The fill jumps as well, because size alone
56
+ stops separating press from hover once the steps are this small. */
57
+ #cursor.hover #cursor-ring { width: 22px; height: 22px; background: color-mix(in srgb, var(--accent) 12%, transparent); }
58
+ #cursor.hover.press #cursor-ring { width: 18px; height: 18px; background: color-mix(in srgb, var(--accent) 26%, transparent); }
59
+
60
+ /* refuses the click: holds idle size on purpose. Not doing the tighten is the signal */
61
+ #cursor.blocked #cursor-ring { border-color: var(--danger); }
62
+
63
+ /* the ring IS the caret over text: the circle squeezes into the bar, and the transparent border
64
+ lets the fill through the full 2px. The JS drops the trail here, so it stands under the
65
+ pointer, where the click will put the real caret. */
66
+ #cursor.text #cursor-ring {
67
+ width: 2px; height: var(--caret-h);
68
+ border-radius: 1px;
69
+ border-color: transparent;
70
+ background: var(--accent);
71
+ }
72
+
73
+ /* one-shot on release over a target, removed on animationend. Placed by left/top so the keyframe
74
+ owns the transform outright. Carries the ring's keyline: it fires over targets, and those are
75
+ the fills it would vanish into */
76
+ .cursor-pulse {
77
+ position: fixed;
78
+ width: 26px; height: 26px;
79
+ margin: -13px 0 0 -13px;
80
+ border: 1px solid var(--accent);
81
+ border-radius: 50%;
82
+ box-shadow: 0 0 0 1px var(--bg);
83
+ will-change: transform;
84
+ animation: cursor-pulse var(--dur-far) var(--ease-out) forwards;
85
+ }
86
+ /* Starts at 6.2px across, near enough to a point that the ripple reads as leaving the click
87
+ rather than as the pressed ring let go. The transform scales the stroke with it, so a 1px
88
+ border would draw a quarter-pixel line at that size and the small start would not paint at
89
+ all; the border-width runs the other way so the start still draws. Sampled every 2ms across
90
+ the run, the drawn stroke spans 0.96 to 2.6px, against 0.24px at the start without it. The
91
+ spread is Chrome flooring the animated border-width to whole pixels. */
92
+ @keyframes cursor-pulse {
93
+ from { transform: scale(0.24); border-width: 4px; opacity: 0.85; }
94
+ to { transform: scale(2.1); border-width: 0.5px; opacity: 0; }
95
+ }
96
+
97
+ /* `-webkit-user-drag`: a native drag puts the OS cursor and a ghost image back on screen, and
98
+ neither obeys `cursor: none`. The JS cancels dragstart too, for the engines ignoring this,
99
+ and refuses the pointerdown on a link, which is earlier than either.
100
+ Selection survives - it stops elements being dragged, not text being picked. */
101
+ html.custom-cursor, html.custom-cursor * { cursor: none !important; -webkit-user-drag: none; }
102
+ /* Unscoped: a native drag ghost carries its own OS cursor and defeats the ring
103
+ the moment it starts, class or no class. Selection survives. */
104
+ a, img { -webkit-user-drag: none; }
105
+ @media (hover: none), (pointer: coarse) { #cursor { display: none; } }
@@ -0,0 +1,358 @@
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://. */
8
+
9
+ import { SEMANTIC_POINTER, SEMANTIC_BLOCKED, collectCursorSelectors } from './cursor-rules.js';
10
+
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.
22
+ let canvas = document.getElementById('bg-dots');
23
+ if (!canvas) {
24
+ canvas = document.createElement('canvas');
25
+ canvas.id = 'bg-dots';
26
+ canvas.setAttribute('aria-hidden', 'true');
27
+ document.body.prepend(canvas);
28
+ }
29
+ const ctx = canvas.getContext('2d');
30
+ const interactive = fine && !reduced; // touch / reduced get a still grid
31
+ 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
+ };
54
+ 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. */
59
+ const REST = 0.24, PEAK = 0.80;
60
+ let rest = REST;
61
+ const readTokens = () => {
62
+ base = resolve('--line', base);
63
+ lit = resolve('--accent', lit);
64
+ const raw = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dot-rest'));
65
+ rest = Number.isFinite(raw) ? Math.min(Math.max(raw, 0), PEAK) : REST;
66
+ };
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');
69
+
70
+ const build = () => {
71
+ const dpr = Math.min(devicePixelRatio || 1, 2);
72
+ w = innerWidth; h = innerHeight;
73
+ canvas.width = w * dpr; canvas.height = h * dpr;
74
+ canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
75
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
76
+ 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 });
80
+ };
81
+ const draw = () => {
82
+ 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
85
+ for (const d of dots) {
86
+ const px = d.bx + shx, py = d.by + shy;
87
+ let a = rest, r = 1.15, c = base;
88
+ if (on) {
89
+ 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
+ }
95
+ }
96
+ ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2);
97
+ ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${a})`; ctx.fill();
98
+ }
99
+ };
100
+ const schedule = () => { if (!scheduled) { scheduled = true; requestAnimationFrame(() => { scheduled = false; draw(); }); } };
101
+
102
+ 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(); });
116
+ }
117
+ 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
+ }
122
+
123
+ // ---- trailing pointer ring (replaces the native cursor) ----
124
+ if (fine && !reduced) {
125
+ const host = document.createElement('div');
126
+ host.id = 'cursor';
127
+ host.setAttribute('aria-hidden', 'true');
128
+ const ring = document.createElement('div');
129
+ ring.id = 'cursor-ring';
130
+ host.append(ring);
131
+ 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
141
+ }).join(', ');
142
+ const found = collectCursorSelectors(document.styleSheets);
143
+ const hitPointer = usable([...found.pointer, SEMANTIC_POINTER]);
144
+ const hitInert = usable(found.inert);
145
+ const hitBlocked = usable([...found.blocked, SEMANTIC_BLOCKED]);
146
+
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
152
+ 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;
159
+ }
160
+ }
161
+ return false;
162
+ };
163
+
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
+ function stateFor(el, x, y) {
200
+ if (!el) return '';
201
+ if (hitBlocked && el.closest(hitBlocked)) return 'blocked';
202
+ const click = hitPointer && el.closest(hitPointer);
203
+ if (click) {
204
+ // a rule switching the pointer back off only outranks the clickable one from its own
205
+ // element or below it
206
+ const off = hitInert && el.closest(hitInert);
207
+ if (!off || !(off === click || click.contains(off))) return 'hover';
208
+ }
209
+ return overText(el, x, y) ? 'text' : '';
210
+ }
211
+
212
+ // el === null means "work out what is under the pointer now", the only honest answer once
213
+ // something other than the pointer moved
214
+ function apply(el) {
215
+ 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;
223
+ }
224
+ rootRetries = 0;
225
+ const state = stateFor(target, pointer.x, pointer.y);
226
+ host.classList.toggle('hover', state === 'hover');
227
+ host.classList.toggle('blocked', state === 'blocked');
228
+ host.classList.toggle('text', state === 'text');
229
+ if (state === 'text' && target && target !== caretSource) {
230
+ 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
234
+ const size = parseFloat(getComputedStyle(target).fontSize);
235
+ if (size) host.style.setProperty('--caret-h', Math.min(Math.round(size * 1.2), 24) + 'px');
236
+ } else if (state !== 'text') caretSource = null;
237
+ }
238
+
239
+ // overText reads client rects, which forces layout: once a frame, never once a move
240
+ function schedule(el) {
241
+ queuedTarget = el;
242
+ if (applyQueued) return;
243
+ applyQueued = true;
244
+ requestAnimationFrame(() => { applyQueued = false; apply(queuedTarget); });
245
+ }
246
+
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);
255
+ }
256
+
257
+ addEventListener('pointermove', e => {
258
+ if (e.pointerType !== 'mouse') return;
259
+ pointer.x = e.clientX; pointer.y = e.clientY;
260
+ if (!placed || detached) {
261
+ 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
264
+ }
265
+ host.classList.add('visible');
266
+ schedule(e.target instanceof Element ? e.target : null);
267
+ draw();
268
+ }, { passive: true });
269
+
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);
281
+ }, { passive: true, capture: true });
282
+
283
+ // capture on the press pair: a handler that stops propagation must not strand the ring
284
+ 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');
289
+ return;
290
+ }
291
+ pressedOnTarget = host.classList.contains('hover');
292
+ host.classList.add('press');
293
+ }, { capture: true });
294
+
295
+ const release = () => host.classList.remove('press');
296
+ 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);
318
+ }, { capture: true });
319
+ 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.
328
+ 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();
332
+ }, { 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
347
+ if (e.pointerType !== 'mouse' || e.button !== 0) return;
348
+ if (!document.documentElement.classList.contains('custom-cursor')) return;
349
+ const link = e.target instanceof Element && e.target.closest('a[href]');
350
+ if (!link) return;
351
+ 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
+ link.focus({ preventScroll: true, focusVisible: false });
356
+ }, { capture: true });
357
+ addEventListener('blur', release);
358
+ }