domotion-svg 0.10.1 → 0.11.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.
@@ -4,7 +4,7 @@
4
4
  * Takes captured SVG frame content and composes them into a single
5
5
  * animated SVG with CSS keyframe transitions.
6
6
  */
7
- import { type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
7
+ import { type CursorAtResolver, type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
8
8
  import type { MagicMove } from "./magic-move.js";
9
9
  export interface AnimationFrame {
10
10
  /** SVG content for this frame (from dom-to-svg) */
@@ -244,6 +244,14 @@ export interface AnimationConfig {
244
244
  * uses `selector`; otherwise pass undefined / null.
245
245
  */
246
246
  resolveSelector?: SelectorResolver;
247
+ /**
248
+ * DM-1106: auto cursor-TYPE hit-tester — given a viewport point and frame
249
+ * index, returns the cursor keyword under it (the caller builds this from the
250
+ * per-frame captured trees via `cursorAtPoint`). When provided, the overlay
251
+ * paints the matching glyph per element and switches at boundary crossings;
252
+ * when omitted, the overlay paints the single arrow.
253
+ */
254
+ resolveCursorAt?: CursorAtResolver;
247
255
  /**
248
256
  * Canvas background color painted behind every frame (a full-viewport
249
257
  * `<rect>`). Mirrors the single-frame path's `transparentRootBgRect`
@@ -305,8 +305,8 @@ export function generateAnimatedSvg(config) {
305
305
  frameStarts.push(acc);
306
306
  acc += frameAdvanceMs(f);
307
307
  }
308
- const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
309
- overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
308
+ const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null, config.resolveCursorAt ?? null);
309
+ overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration, resolved.cursorTimeline);
310
310
  }
311
311
  // Canvas background rect — only when a non-transparent background is given.
312
312
  // Default (none / transparent) emits nothing so the SVG composites over the
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Cursor glyph catalog (DM-1106).
3
+ *
4
+ * Maps every CSS `cursor` keyword to a small SVG glyph drawn from Lucide icons
5
+ * (MIT-licensed, https://lucide.dev) — scaled, rotated, and occasionally
6
+ * composited (an arrow + a badge, the way real OS drag-drop cursors look) to
7
+ * approximate what a browser paints. Lucide's line-art look is intentionally
8
+ * OS-agnostic: it doesn't pixel-match macOS / Windows / X11, but it reads as the
9
+ * right cursor at a glance and renders crisply at any scale (the whole point of
10
+ * the SVG pipeline).
11
+ *
12
+ * Each glyph is authored in Lucide's native 24×24 coordinate box. The renderer
13
+ * paints a white halo under a dark stroke so the cursor stays legible on any
14
+ * background (mirroring the white-outlined arrow the overlay already used). The
15
+ * `hotspot` is the point IN THAT 24×24 BOX that aligns to the cursor's (x, y) —
16
+ * e.g. the arrow tip, the I-beam center — so callers translate the glyph by
17
+ * `(x − hotspotX·scale, y − hotspotY·scale)`.
18
+ *
19
+ * This module is pure markup generation (no DOM); the overlay (DM-1106 phase 2)
20
+ * picks a glyph per captured `cursor` value and animates its position.
21
+ */
22
+ /** A glyph authored in the Lucide 24×24 box. */
23
+ export interface CursorGlyph {
24
+ /** Inner SVG markup (paths/lines/circles) in the 24×24 Lucide coordinate box. */
25
+ body: string;
26
+ /** Filled silhouette (white fill + dark outline, like a classic arrow) vs.
27
+ * stroked line-art (dark stroke + white halo, the Lucide default look). */
28
+ fill?: boolean;
29
+ /** The point in the 24×24 box that lands on the cursor coordinate. */
30
+ hotspot: [number, number];
31
+ /** Optional rotation (degrees, about the box center 12,12) — e.g. vertical-text. */
32
+ rotate?: number;
33
+ }
34
+ /**
35
+ * Every CSS `cursor` keyword → its glyph. `auto`, `default`, `inherit`,
36
+ * `initial`, and an unresolved `url(...)` fallback all resolve to the arrow
37
+ * here; the overlay resolves `auto` to `text` / `default` per Chrome BEFORE
38
+ * looking up this table (DM-1106 phase 2). `none` is an empty glyph.
39
+ */
40
+ export declare const CURSOR_GLYPHS: Record<string, CursorGlyph>;
41
+ /** Canonical display order, grouped by the MDN cursor categories. */
42
+ export declare const CURSOR_CATEGORIES: {
43
+ title: string;
44
+ values: string[];
45
+ }[];
46
+ /**
47
+ * Render a cursor glyph as an SVG `<g>`, positioned so its hotspot sits at
48
+ * `(x, y)` and the 24-box is scaled to `size` px. Paints a white halo under the
49
+ * dark glyph (filled glyphs use a white fill + dark outline). Returns "" for an
50
+ * empty glyph (`none`).
51
+ */
52
+ export declare function cursorGlyphSvg(value: string, x: number, y: number, size?: number, color?: string): string;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Cursor glyph catalog (DM-1106).
3
+ *
4
+ * Maps every CSS `cursor` keyword to a small SVG glyph drawn from Lucide icons
5
+ * (MIT-licensed, https://lucide.dev) — scaled, rotated, and occasionally
6
+ * composited (an arrow + a badge, the way real OS drag-drop cursors look) to
7
+ * approximate what a browser paints. Lucide's line-art look is intentionally
8
+ * OS-agnostic: it doesn't pixel-match macOS / Windows / X11, but it reads as the
9
+ * right cursor at a glance and renders crisply at any scale (the whole point of
10
+ * the SVG pipeline).
11
+ *
12
+ * Each glyph is authored in Lucide's native 24×24 coordinate box. The renderer
13
+ * paints a white halo under a dark stroke so the cursor stays legible on any
14
+ * background (mirroring the white-outlined arrow the overlay already used). The
15
+ * `hotspot` is the point IN THAT 24×24 BOX that aligns to the cursor's (x, y) —
16
+ * e.g. the arrow tip, the I-beam center — so callers translate the glyph by
17
+ * `(x − hotspotX·scale, y − hotspotY·scale)`.
18
+ *
19
+ * This module is pure markup generation (no DOM); the overlay (DM-1106 phase 2)
20
+ * picks a glyph per captured `cursor` value and animates its position.
21
+ */
22
+ // ── Lucide icon bodies (verbatim path data from lucide-icons/lucide) ──────────
23
+ const L = {
24
+ arrow: `<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/>`,
25
+ pointer: `<path d="M22 14a8 8 0 0 1-8 8"/><path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1"/><path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10"/><path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>`,
26
+ ibeam: `<path d="M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1"/><path d="M7 22h1a4 4 0 0 0 4-4"/><path d="M7 2h1a4 4 0 0 1 4 4"/>`,
27
+ menu: `<path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/>`,
28
+ spinner: `<path d="M21 12a9 9 0 1 1-6.219-8.56"/>`,
29
+ plus: `<path d="M5 12h14"/><path d="M12 5v14"/>`,
30
+ shortcut: `<path d="m15 14 5-5-5-5"/><path d="M4 20v-7a4 4 0 0 1 4-4h12"/>`,
31
+ ban: `<circle cx="12" cy="12" r="10"/><path d="M4.929 4.929 19.07 19.071"/>`,
32
+ move: `<path d="M12 2v20"/><path d="m15 19-3 3-3-3"/><path d="m19 9 3 3-3 3"/><path d="M2 12h20"/><path d="m5 9-3 3 3 3"/><path d="m9 5 3-3 3 3"/>`,
33
+ hand: `<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>`,
34
+ handGrab: `<path d="M18 11.5V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1.4"/><path d="M14 10V8a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 9.9V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v5"/><path d="M6 14a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-4a8 8 0 0 1-8-8 2 2 0 1 1 4 0"/>`,
35
+ moveH: `<path d="m18 8 4 4-4 4"/><path d="M2 12h20"/><path d="m6 8-4 4 4 4"/>`,
36
+ moveV: `<path d="M12 2v20"/><path d="m8 18 4 4 4-4"/><path d="m8 6 4-4 4 4"/>`,
37
+ diag: `<path d="M5 5 19 19"/><path d="M5 10 5 5 10 5"/><path d="M19 14 19 19 14 19"/>`, // ↖↘ (nwse): TL–BR line, arrowheads pointing NW + SE
38
+ diag2: `<path d="M19 5 5 19"/><path d="M14 5 19 5 19 10"/><path d="M10 19 5 19 5 14"/>`, // ↗↙ (nesw): TR–BL line, arrowheads pointing NE + SW
39
+ zoomIn: `<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/>`,
40
+ zoomOut: `<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="8" x2="14" y1="11" y2="11"/>`,
41
+ crosshair: `<line x1="22" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="22" y2="2"/>`,
42
+ cell: `<rect x="4" y="4" width="16" height="16" rx="1"/><line x1="12" x2="12" y1="2" y2="22"/><line x1="2" x2="22" y1="12" y2="12"/>`,
43
+ // Hand-drawn "?" (lucide circle-help was unavailable on main); used as a badge.
44
+ question: `<path d="M9 9a3 3 0 1 1 4.2 2.75c-.9.5-1.2 1-1.2 1.75"/><path d="M12 17h.01"/>`,
45
+ };
46
+ /**
47
+ * Compose the macOS-style arrow with a small badge tucked at its lower-right —
48
+ * the shape real OS cursors use for copy (+), alias (shortcut), no-drop (∅),
49
+ * context-menu (☰), and progress (spinner). The badge sits in a white rounded
50
+ * chip for contrast against the arrow and the page.
51
+ */
52
+ function arrowBadge(badgeBody) {
53
+ // Badge: a 10×10 white chip at (12.5, 12.5)–(23.5, 23.5) with the icon scaled
54
+ // into it. The icon is drawn in its own 24-box then mapped into the chip.
55
+ return `${L.arrow}<g transform="translate(12.5 12.5)"><rect x="0" y="0" width="11" height="11" rx="2.5" fill="#fff" stroke="#1a1a1a" stroke-width="1"/><g transform="translate(1.4 1.4) scale(0.342)" fill="none" stroke="#1a1a1a" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">${badgeBody}</g></g>`;
56
+ }
57
+ const ARROW = { body: L.arrow, fill: true, hotspot: [4.5, 4.5] };
58
+ const ARROW_TIP = [4.5, 4.5];
59
+ /**
60
+ * Every CSS `cursor` keyword → its glyph. `auto`, `default`, `inherit`,
61
+ * `initial`, and an unresolved `url(...)` fallback all resolve to the arrow
62
+ * here; the overlay resolves `auto` to `text` / `default` per Chrome BEFORE
63
+ * looking up this table (DM-1106 phase 2). `none` is an empty glyph.
64
+ */
65
+ export const CURSOR_GLYPHS = {
66
+ // General
67
+ default: ARROW,
68
+ auto: ARROW,
69
+ none: { body: "", hotspot: [12, 12] },
70
+ // Links & status
71
+ "context-menu": { body: arrowBadge(L.menu), fill: true, hotspot: ARROW_TIP },
72
+ help: { body: arrowBadge(L.question), fill: true, hotspot: ARROW_TIP },
73
+ pointer: { body: L.pointer, hotspot: [8, 2] },
74
+ progress: { body: arrowBadge(L.spinner), fill: true, hotspot: ARROW_TIP },
75
+ wait: { body: L.spinner, hotspot: [12, 12] },
76
+ // Selection
77
+ cell: { body: L.cell, hotspot: [12, 12] },
78
+ crosshair: { body: L.crosshair, hotspot: [12, 12] },
79
+ text: { body: L.ibeam, hotspot: [12, 12] },
80
+ "vertical-text": { body: L.ibeam, hotspot: [12, 12], rotate: 90 },
81
+ // Drag & drop
82
+ alias: { body: arrowBadge(L.shortcut), fill: true, hotspot: ARROW_TIP },
83
+ copy: { body: arrowBadge(L.plus), fill: true, hotspot: ARROW_TIP },
84
+ move: { body: L.move, hotspot: [12, 12] },
85
+ "no-drop": { body: arrowBadge(L.ban), fill: true, hotspot: ARROW_TIP },
86
+ "not-allowed": { body: L.ban, hotspot: [12, 12] },
87
+ grab: { body: L.hand, hotspot: [12, 12] },
88
+ grabbing: { body: L.handGrab, hotspot: [12, 12] },
89
+ // Resizing & scrolling
90
+ "all-scroll": { body: `${L.move}<circle cx="12" cy="12" r="1.6" fill="#1a1a1a" stroke="none"/>`, hotspot: [12, 12] },
91
+ "col-resize": { body: `${L.moveH}<line x1="12" y1="4" x2="12" y2="20"/>`, hotspot: [12, 12] },
92
+ "row-resize": { body: `${L.moveV}<line x1="4" y1="12" x2="20" y2="12"/>`, hotspot: [12, 12] },
93
+ "e-resize": { body: L.moveH, hotspot: [12, 12] },
94
+ "w-resize": { body: L.moveH, hotspot: [12, 12] },
95
+ "ew-resize": { body: L.moveH, hotspot: [12, 12] },
96
+ "n-resize": { body: L.moveV, hotspot: [12, 12] },
97
+ "s-resize": { body: L.moveV, hotspot: [12, 12] },
98
+ "ns-resize": { body: L.moveV, hotspot: [12, 12] },
99
+ "ne-resize": { body: L.diag2, hotspot: [12, 12] },
100
+ "sw-resize": { body: L.diag2, hotspot: [12, 12] },
101
+ "nesw-resize": { body: L.diag2, hotspot: [12, 12] },
102
+ "nw-resize": { body: L.diag, hotspot: [12, 12] },
103
+ "se-resize": { body: L.diag, hotspot: [12, 12] },
104
+ "nwse-resize": { body: L.diag, hotspot: [12, 12] },
105
+ // Zoom
106
+ "zoom-in": { body: L.zoomIn, hotspot: [11, 11] },
107
+ "zoom-out": { body: L.zoomOut, hotspot: [11, 11] },
108
+ };
109
+ /** Canonical display order, grouped by the MDN cursor categories. */
110
+ export const CURSOR_CATEGORIES = [
111
+ { title: "General", values: ["auto", "default", "none"] },
112
+ { title: "Links & status", values: ["context-menu", "help", "pointer", "progress", "wait"] },
113
+ { title: "Selection", values: ["cell", "crosshair", "text", "vertical-text"] },
114
+ { title: "Drag & drop", values: ["alias", "copy", "move", "no-drop", "not-allowed", "grab", "grabbing"] },
115
+ { title: "Resizing & scrolling", values: ["all-scroll", "col-resize", "row-resize", "n-resize", "e-resize", "s-resize", "w-resize", "ew-resize", "ns-resize", "ne-resize", "nw-resize", "se-resize", "sw-resize", "nesw-resize", "nwse-resize"] },
116
+ { title: "Zooming", values: ["zoom-in", "zoom-out"] },
117
+ ];
118
+ /**
119
+ * Render a cursor glyph as an SVG `<g>`, positioned so its hotspot sits at
120
+ * `(x, y)` and the 24-box is scaled to `size` px. Paints a white halo under the
121
+ * dark glyph (filled glyphs use a white fill + dark outline). Returns "" for an
122
+ * empty glyph (`none`).
123
+ */
124
+ export function cursorGlyphSvg(value, x, y, size = 22, color = "#1a1a1a") {
125
+ const g = CURSOR_GLYPHS[value] ?? CURSOR_GLYPHS.default;
126
+ if (g.body === "")
127
+ return "";
128
+ const s = size / 24;
129
+ const tx = x - g.hotspot[0] * s;
130
+ const ty = y - g.hotspot[1] * s;
131
+ const rot = g.rotate ? ` rotate(${g.rotate} 12 12)` : "";
132
+ const inner = g.fill
133
+ // Filled silhouette: white fill + dark outline (classic arrow look).
134
+ ? `<g fill="#fff" stroke="${color}" stroke-width="1.4" stroke-linejoin="round">${g.body}</g>`
135
+ // Line-art: white halo stroke under the dark stroke.
136
+ : `<g fill="none" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" stroke-width="3.4">${g.body}</g><g stroke="${color}" stroke-width="1.7">${g.body}</g></g>`;
137
+ return `<g transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)}) scale(${s.toFixed(4)})"><g transform="${rot.trim() || "translate(0 0)"}">${inner}</g></g>`;
138
+ }
@@ -14,8 +14,19 @@
14
14
  * the caller supplies. The frame index is computed from the event's `t`
15
15
  * (each frame's start/end time is derived from the animation timing).
16
16
  *
17
+ * Cursor TYPE (DM-1106): by default the overlay paints the right cursor for
18
+ * whatever is under it — arrow over body, hand over a link, I-beam over text,
19
+ * resize arrows over a resizer, etc. — switching exactly at element boundaries
20
+ * as the pointer moves, matching what the browser showed. The keyword comes
21
+ * from the captured `cursor` field (resolved per Blink, including `auto`); the
22
+ * caller supplies a `resolveCursorAt(x, y, frameIndex)` hit-tester (built from
23
+ * the per-frame trees) and the glyphs come from `cursor-glyphs.ts`. A per-event
24
+ * `cursor` override forces a specific glyph. Without a resolver the overlay
25
+ * falls back to the single arrow (back-compat).
26
+ *
17
27
  * See docs/13-cursor-overlay.md for the design.
18
28
  */
29
+ import type { CapturedElement } from "../capture/types.js";
19
30
  export interface CursorStyle {
20
31
  /** Pointer variant. v1: only `mouse` is rendered (touch falls through to the same arrow glyph). */
21
32
  pointer: "mouse" | "touch";
@@ -58,6 +69,8 @@ export interface CursorMoveEvent {
58
69
  dx: number;
59
70
  dy: number;
60
71
  };
72
+ /** DM-1106: force a specific cursor keyword for this move (skip auto hit-test). */
73
+ cursor?: string;
61
74
  }
62
75
  export interface CursorClickEvent {
63
76
  type: "click";
@@ -72,6 +85,8 @@ export interface CursorShowEvent {
72
85
  t: number;
73
86
  x: number;
74
87
  y: number;
88
+ /** DM-1106: force a specific cursor keyword from this point (skip auto hit-test). */
89
+ cursor?: string;
75
90
  }
76
91
  export interface CursorHideEvent {
77
92
  type: "hide";
@@ -90,11 +105,14 @@ export type SelectorResolver = (sel: string, frameIndex: number) => {
90
105
  w: number;
91
106
  h: number;
92
107
  } | null;
108
+ /** DM-1106: hit-tester for the cursor TYPE at a viewport point in a given frame. */
109
+ export type CursorAtResolver = (x: number, y: number, frameIndex: number) => string;
93
110
  interface KeyframePoint {
94
111
  t: number;
95
112
  x: number;
96
113
  y: number;
97
114
  visible: boolean;
115
+ cursor?: string;
98
116
  }
99
117
  interface ResolvedClick {
100
118
  t: number;
@@ -103,21 +121,48 @@ interface ResolvedClick {
103
121
  button: "primary" | "secondary" | "middle";
104
122
  style: CursorStyle;
105
123
  }
124
+ /** A cursor-keyword timeline entry: from time `t` the active glyph is `cursor`
125
+ * (or null = cursor hidden). DM-1106. */
126
+ export interface CursorTimelineEntry {
127
+ t: number;
128
+ cursor: string | null;
129
+ }
130
+ /**
131
+ * DM-1106: the effective cursor keyword at viewport point (x, y) for a captured
132
+ * tree — the LAST element in paint order (DFS pre-order) whose box contains the
133
+ * point. `cursor` inherits and was resolved per element at capture, so the
134
+ * topmost element's stored value (or `default` when omitted) is what Chrome
135
+ * painted. A z-index-agnostic approximation: good for the nested-element and
136
+ * later-sibling-on-top cases a cursor overlay cares about.
137
+ */
138
+ export declare function cursorAtPoint(roots: CapturedElement[], x: number, y: number): string;
106
139
  /**
107
140
  * Resolve a script into absolute-coord position keyframes + click pulses.
108
141
  * Caller passes a `resolveSelector(sel, frameIndex)` if the script uses
109
142
  * selectors; otherwise pass `null` and selector events become no-ops with
110
143
  * a console warning.
111
144
  */
112
- export declare function resolveCursorScript(overlay: CursorOverlay, totalDurationMs: number, frameStartTimes: number[], resolveSelector: SelectorResolver | null): {
145
+ export declare function resolveCursorScript(overlay: CursorOverlay, totalDurationMs: number, frameStartTimes: number[], resolveSelector: SelectorResolver | null,
146
+ /** DM-1106: auto cursor-type hit-tester. When provided, the result includes a
147
+ * `cursorTimeline` driving per-glyph switching; when null, the overlay paints
148
+ * the single arrow (back-compat). */
149
+ resolveCursorAt?: CursorAtResolver | null): {
113
150
  positions: KeyframePoint[];
114
151
  clicks: ResolvedClick[];
115
152
  style: CursorStyle;
153
+ cursorTimeline: CursorTimelineEntry[] | null;
116
154
  };
117
155
  /**
118
156
  * Emit the `<g class="cursor-overlay">` markup for an already-resolved
119
157
  * timeline. Returns "" when the timeline has no positions or every keyframe
120
158
  * is invisible.
159
+ *
160
+ * When `cursorTimeline` is provided (DM-1106), the pointer's GLYPH switches over
161
+ * time to match what was under it: each distinct keyword gets a glyph drawn
162
+ * hotspot-at-origin inside the shared position-animated group, with a discrete
163
+ * opacity track that turns it on only during its windows (and visibility folds
164
+ * in as the all-glyphs-off `null` state). Without a timeline, the single white
165
+ * arrow paints (back-compat).
121
166
  */
122
- export declare function cursorOverlayMarkup(positions: KeyframePoint[], clicks: ResolvedClick[], style: CursorStyle, totalDurationMs: number): string;
167
+ export declare function cursorOverlayMarkup(positions: KeyframePoint[], clicks: ResolvedClick[], style: CursorStyle, totalDurationMs: number, cursorTimeline?: CursorTimelineEntry[] | null): string;
123
168
  export {};
@@ -14,8 +14,41 @@
14
14
  * the caller supplies. The frame index is computed from the event's `t`
15
15
  * (each frame's start/end time is derived from the animation timing).
16
16
  *
17
+ * Cursor TYPE (DM-1106): by default the overlay paints the right cursor for
18
+ * whatever is under it — arrow over body, hand over a link, I-beam over text,
19
+ * resize arrows over a resizer, etc. — switching exactly at element boundaries
20
+ * as the pointer moves, matching what the browser showed. The keyword comes
21
+ * from the captured `cursor` field (resolved per Blink, including `auto`); the
22
+ * caller supplies a `resolveCursorAt(x, y, frameIndex)` hit-tester (built from
23
+ * the per-frame trees) and the glyphs come from `cursor-glyphs.ts`. A per-event
24
+ * `cursor` override forces a specific glyph. Without a resolver the overlay
25
+ * falls back to the single arrow (back-compat).
26
+ *
17
27
  * See docs/13-cursor-overlay.md for the design.
18
28
  */
29
+ import { cursorGlyphSvg } from "./cursor-glyphs.js";
30
+ /**
31
+ * DM-1106: the effective cursor keyword at viewport point (x, y) for a captured
32
+ * tree — the LAST element in paint order (DFS pre-order) whose box contains the
33
+ * point. `cursor` inherits and was resolved per element at capture, so the
34
+ * topmost element's stored value (or `default` when omitted) is what Chrome
35
+ * painted. A z-index-agnostic approximation: good for the nested-element and
36
+ * later-sibling-on-top cases a cursor overlay cares about.
37
+ */
38
+ export function cursorAtPoint(roots, x, y) {
39
+ let best = null;
40
+ const visit = (n) => {
41
+ if (n.width > 0 && n.height > 0 && x >= n.x && x < n.x + n.width && y >= n.y && y < n.y + n.height)
42
+ best = n;
43
+ const kids = n.children;
44
+ if (kids != null)
45
+ for (const c of kids)
46
+ visit(c);
47
+ };
48
+ for (const r of roots)
49
+ visit(r);
50
+ return best?.cursor ?? "default";
51
+ }
19
52
  const DEFAULT_STYLE = {
20
53
  pointer: "mouse",
21
54
  cursorFill: "rgb(255, 255, 255)",
@@ -32,7 +65,11 @@ const DEFAULT_STYLE = {
32
65
  * selectors; otherwise pass `null` and selector events become no-ops with
33
66
  * a console warning.
34
67
  */
35
- export function resolveCursorScript(overlay, totalDurationMs, frameStartTimes, resolveSelector) {
68
+ export function resolveCursorScript(overlay, totalDurationMs, frameStartTimes, resolveSelector,
69
+ /** DM-1106: auto cursor-type hit-tester. When provided, the result includes a
70
+ * `cursorTimeline` driving per-glyph switching; when null, the overlay paints
71
+ * the single arrow (back-compat). */
72
+ resolveCursorAt = null) {
36
73
  const baseStyle = { ...DEFAULT_STYLE, ...overlay.style };
37
74
  const events = [...overlay.events].sort((a, b) => a.t - b.t);
38
75
  const positions = [];
@@ -40,8 +77,8 @@ export function resolveCursorScript(overlay, totalDurationMs, frameStartTimes, r
40
77
  let curX = 0;
41
78
  let curY = 0;
42
79
  let visible = false;
43
- const pushKey = (t, x, y, vis) => {
44
- positions.push({ t, x, y, visible: vis });
80
+ const pushKey = (t, x, y, vis, cursor) => {
81
+ positions.push({ t, x, y, visible: vis, cursor });
45
82
  curX = x;
46
83
  curY = y;
47
84
  visible = vis;
@@ -58,7 +95,7 @@ export function resolveCursorScript(overlay, totalDurationMs, frameStartTimes, r
58
95
  };
59
96
  for (const ev of events) {
60
97
  if (ev.type === "show") {
61
- pushKey(ev.t, ev.x, ev.y, true);
98
+ pushKey(ev.t, ev.x, ev.y, true, ev.cursor);
62
99
  }
63
100
  else if (ev.type === "hide") {
64
101
  pushKey(ev.t, curX, curY, false);
@@ -73,11 +110,12 @@ export function resolveCursorScript(overlay, totalDurationMs, frameStartTimes, r
73
110
  // interpolate to the target over `dur`, so it slides rather than popping
74
111
  // in mid-frame. (Both the first-positioning and subsequent-move cases
75
112
  // emit the same start keyframe — DM-1073 collapsed a no-op branch here.)
76
- pushKey(ev.t, curX, curY, true);
77
- pushKey(ev.t + dur, target.x, target.y, true);
113
+ // The `cursor` override (if any) rides the whole slide.
114
+ pushKey(ev.t, curX, curY, true, ev.cursor);
115
+ pushKey(ev.t + dur, target.x, target.y, true, ev.cursor);
78
116
  }
79
117
  else {
80
- pushKey(ev.t, target.x, target.y, true);
118
+ pushKey(ev.t, target.x, target.y, true, ev.cursor);
81
119
  }
82
120
  }
83
121
  else if (ev.type === "click") {
@@ -96,9 +134,69 @@ export function resolveCursorScript(overlay, totalDurationMs, frameStartTimes, r
96
134
  // through the whole loop.
97
135
  if (positions[positions.length - 1].t < totalDurationMs) {
98
136
  const last = positions[positions.length - 1];
99
- positions.push({ t: totalDurationMs, x: last.x, y: last.y, visible: last.visible });
137
+ positions.push({ t: totalDurationMs, x: last.x, y: last.y, visible: last.visible, cursor: last.cursor });
138
+ }
139
+ const cursorTimeline = resolveCursorAt != null
140
+ ? buildCursorTimeline(positions, totalDurationMs, frameForT, resolveCursorAt)
141
+ : null;
142
+ return { positions, clicks, style: baseStyle, cursorTimeline };
143
+ }
144
+ /** Interpolated cursor state at time `t` from the position keyframes. Position
145
+ * lerps within a keyframe interval; visibility + the per-segment cursor
146
+ * override are step-held from the interval's start keyframe. */
147
+ function stateAtTime(positions, t) {
148
+ if (t <= positions[0].t)
149
+ return positions[0];
150
+ const n = positions.length;
151
+ for (let i = 0; i < n - 1; i++) {
152
+ const a = positions[i], b = positions[i + 1];
153
+ if (t >= a.t && t <= b.t) {
154
+ const span = b.t - a.t;
155
+ const f = span > 0 ? (t - a.t) / span : 0;
156
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, visible: a.visible, cursor: a.cursor };
157
+ }
158
+ }
159
+ return positions[n - 1];
160
+ }
161
+ /**
162
+ * Build the cursor-keyword timeline (DM-1106). Samples the position timeline,
163
+ * resolving the cursor at each sample (an override wins; otherwise hit-test the
164
+ * frame under the point); emits an entry whenever the keyword changes, with the
165
+ * change time bisection-refined so the glyph switches AT the element boundary
166
+ * the pointer crosses (not at the next coarse sample). `null` = cursor hidden.
167
+ */
168
+ function buildCursorTimeline(positions, totalDurationMs, frameForT, resolveCursorAt) {
169
+ const cursorAtTime = (t) => {
170
+ const s = stateAtTime(positions, t);
171
+ if (!s.visible)
172
+ return null;
173
+ return s.cursor ?? resolveCursorAt(s.x, s.y, frameForT(t));
174
+ };
175
+ const step = Math.max(8, Math.min(40, totalDurationMs / 400));
176
+ const timeline = [];
177
+ let prev = cursorAtTime(0);
178
+ timeline.push({ t: 0, cursor: prev });
179
+ for (let t = step; t <= totalDurationMs; t += step) {
180
+ const c = cursorAtTime(t);
181
+ if (c !== prev) {
182
+ // Refine the crossing time in (t-step, t] so the switch lands on the
183
+ // boundary, not the sample grid.
184
+ let lo = t - step, hi = t;
185
+ for (let k = 0; k < 14; k++) {
186
+ const mid = (lo + hi) / 2;
187
+ if (cursorAtTime(mid) === prev)
188
+ lo = mid;
189
+ else
190
+ hi = mid;
191
+ }
192
+ timeline.push({ t: Math.min(totalDurationMs, hi), cursor: c });
193
+ prev = c;
194
+ }
195
+ }
196
+ if (timeline[timeline.length - 1].t < totalDurationMs) {
197
+ timeline.push({ t: totalDurationMs, cursor: prev });
100
198
  }
101
- return { positions, clicks, style: baseStyle };
199
+ return timeline;
102
200
  }
103
201
  function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
104
202
  if (ev.to != null)
@@ -125,8 +223,15 @@ function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
125
223
  * Emit the `<g class="cursor-overlay">` markup for an already-resolved
126
224
  * timeline. Returns "" when the timeline has no positions or every keyframe
127
225
  * is invisible.
226
+ *
227
+ * When `cursorTimeline` is provided (DM-1106), the pointer's GLYPH switches over
228
+ * time to match what was under it: each distinct keyword gets a glyph drawn
229
+ * hotspot-at-origin inside the shared position-animated group, with a discrete
230
+ * opacity track that turns it on only during its windows (and visibility folds
231
+ * in as the all-glyphs-off `null` state). Without a timeline, the single white
232
+ * arrow paints (back-compat).
128
233
  */
129
- export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs) {
234
+ export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs, cursorTimeline = null) {
130
235
  if (positions.length === 0 || totalDurationMs <= 0)
131
236
  return "";
132
237
  const totalSec = totalDurationMs / 1000;
@@ -139,21 +244,42 @@ export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs) {
139
244
  }
140
245
  // SMIL animateTransform requires keyTimes to start at 0 and end at 1; the
141
246
  // resolveCursorScript anchor at t=0 and t=totalDurationMs guarantees this.
142
- // Visibility timeline: opacity flips between 0 and 1 at keyframes whose
143
- // `visible` differs from the previous one. Use `discrete` calc-mode for
144
- // step-wise transitions (no interpolation between 0 and 1).
145
- const visValues = [];
146
- for (const p of positions)
147
- visValues.push(p.visible ? "1" : "0");
148
- const cursorPath = macosCursorPath(style.cursorScale);
247
+ const posAnim = `<animateTransform attributeName="transform" type="translate" values="${valueStrs.join("; ")}" keyTimes="${keyTimes.join("; ")}" dur="${totalSec}s" repeatCount="indefinite" fill="freeze" />`;
149
248
  // Pulse SVG fragments — one per click, with timing keyed off `t`.
150
249
  const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, totalDurationMs)).join("\n");
151
- return ` <g class="cursor-overlay" pointer-events="none">
152
- <g class="cursor-arrow" opacity="0">
153
- <animateTransform attributeName="transform" type="translate" values="${valueStrs.join("; ")}" keyTimes="${keyTimes.join("; ")}" dur="${totalSec}s" repeatCount="indefinite" fill="freeze" />
250
+ let pointerGroup;
251
+ if (cursorTimeline != null && cursorTimeline.length > 0) {
252
+ // DM-1106: one glyph per distinct keyword, each toggled by a discrete
253
+ // opacity track derived from the keyword timeline. The shared parent group
254
+ // carries the position animation; glyphs are hotspot-at-origin so each lands
255
+ // correctly regardless of its own hotspot.
256
+ const size = 22 * (style.cursorScale || 1);
257
+ const tKeyTimes = cursorTimeline.map((e) => (e.t / totalDurationMs).toFixed(4));
258
+ const kinds = Array.from(new Set(cursorTimeline.map((e) => e.cursor).filter((c) => c != null)));
259
+ const glyphLayers = kinds.map((kind) => {
260
+ const glyph = cursorGlyphSvg(kind, 0, 0, size, style.cursorStroke);
261
+ const opVals = cursorTimeline.map((e) => (e.cursor === kind ? "1" : "0"));
262
+ return ` <g opacity="0">
263
+ <animate attributeName="opacity" values="${opVals.join(";")}" keyTimes="${tKeyTimes.join(";")}" dur="${totalSec}s" repeatCount="indefinite" calcMode="discrete" fill="freeze" />
264
+ ${glyph}
265
+ </g>`;
266
+ }).join("\n");
267
+ pointerGroup = ` <g class="cursor-pointer">
268
+ ${posAnim}
269
+ ${glyphLayers}
270
+ </g>`;
271
+ }
272
+ else {
273
+ // Legacy single-arrow path (no auto cursor-type resolver supplied).
274
+ const visValues = positions.map((p) => (p.visible ? "1" : "0"));
275
+ pointerGroup = ` <g class="cursor-arrow" opacity="0">
276
+ ${posAnim}
154
277
  <animate attributeName="opacity" values="${visValues.join(";")}" keyTimes="${keyTimes.join(";")}" dur="${totalSec}s" repeatCount="indefinite" calcMode="discrete" fill="freeze" />
155
- ${cursorPath}
156
- </g>
278
+ ${macosCursorPath(style.cursorScale)}
279
+ </g>`;
280
+ }
281
+ return ` <g class="cursor-overlay" pointer-events="none">
282
+ ${pointerGroup}
157
283
  ${pulseMarkup}
158
284
  </g>`;
159
285
  }
@@ -1,3 +1,4 @@
1
1
  export { buildMagicMove, type MagicMove, type MagicMoveSlide, } from "./magic-move.js";
2
2
  export { generateAnimatedSvg, type AnimationConfig, type AnimationFrame, type AnimationOverlay, type TypingOverlay, type TapOverlay, type SvgOverlay, type IntraFrameAnimation, } from "./animator.js";
3
- export { cursorOverlayMarkup, resolveCursorScript, type CursorOverlay, type CursorEvent, type CursorMoveEvent, type CursorClickEvent, type CursorShowEvent, type CursorHideEvent, type CursorStyle, type SelectorResolver, } from "./cursor-overlay.js";
3
+ export { cursorOverlayMarkup, resolveCursorScript, cursorAtPoint, type CursorOverlay, type CursorEvent, type CursorMoveEvent, type CursorClickEvent, type CursorShowEvent, type CursorHideEvent, type CursorStyle, type CursorTimelineEntry, type SelectorResolver, type CursorAtResolver, } from "./cursor-overlay.js";
4
+ export { CURSOR_GLYPHS, CURSOR_CATEGORIES, cursorGlyphSvg, type CursorGlyph } from "./cursor-glyphs.js";
@@ -5,4 +5,5 @@
5
5
  // typing / tap / SVG / cursor overlays.
6
6
  export { buildMagicMove, } from "./magic-move.js";
7
7
  export { generateAnimatedSvg, } from "./animator.js";
8
- export { cursorOverlayMarkup, resolveCursorScript, } from "./cursor-overlay.js";
8
+ export { cursorOverlayMarkup, resolveCursorScript, cursorAtPoint, } from "./cursor-overlay.js";
9
+ export { CURSOR_GLYPHS, CURSOR_CATEGORIES, cursorGlyphSvg } from "./cursor-glyphs.js";