@weasel-js/cursor 1.4.0-pre.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 orochi235
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @weasel-js/cursor
2
+
3
+ Tool cursors as authored glyphs.
4
+
5
+ ```ts
6
+ import { cursorFor } from '@weasel-js/cursor';
7
+
8
+ const tool = defineTool({
9
+ id: 'pencil',
10
+ cursor: cursorFor('pencil', { fallback: 'crosshair' }),
11
+ });
12
+ ```
13
+
14
+ A glyph is a set of SVG path `d` strings with paint roles and a hotspot in
15
+ glyph units. `cursorFor` bakes one into a CSS cursor value and memoizes it.
16
+
17
+ **Always pass a `fallback`.** If the browser rejects the image the element falls
18
+ back to that keyword, and with none declared it lands on `auto`.
19
+
20
+ Cursors ship as SVG with no bitmap fallback: Chrome rasterizes an SVG data-URI
21
+ cursor at device scale, so it is already crisp on a retina display. `bakeCursor`
22
+ throws above 128 CSS px rather than emitting a cursor the browser would drop
23
+ silently.
24
+
25
+ ## Authoring a glyph
26
+
27
+ Geometry lives in `scripts/glyphs/` and is generated to resolved literals:
28
+
29
+ ```
30
+ npm run gen:cursors # regenerate src/glyphs.ts
31
+ npm run proof:cursors # render every glyph over three grounds
32
+ ```
33
+
34
+ Proof a change before committing it. The proof sheet draws each glyph at 11x for
35
+ geometry and embeds the baked asset at 24 and 16 px — those are separate checks
36
+ and they disagree, which is the point. A glyph that reads at 11x and turns to
37
+ mush at 24 has failed.
38
+
39
+ The register is a filled silhouette with a white halo, deliberately unlike the
40
+ toolbar icon sets (`@weasel-js/ui`'s `ICON_PATHS`, core's tool icons), which are
41
+ `fill: none` outlines in `currentColor`. An unfilled glyph over dark artwork is
42
+ a dark outline around a dark hole, and a baked image has no cascade to inherit a
43
+ color from. Share the authoring discipline, not the paths.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * A cursor glyph: SVG path `d` strings tagged with a paint role, plus the
3
+ * hotspot in glyph units. The same record feeds both the data-URI baker and
4
+ * the Path2D painter, so `d` is the one geometry form neither has to translate.
5
+ */
6
+ interface CursorGlyph {
7
+ /** Side of the square viewBox the paths are authored in. */
8
+ readonly box: number;
9
+ /** Hotspot in glyph units, scaled to integer CSS px at bake time. */
10
+ readonly hotspot: readonly [number, number];
11
+ readonly paths: readonly CursorPath[];
12
+ }
13
+ type CursorPath =
14
+ /** A filled part of the silhouette. */
15
+ {
16
+ readonly role: 'ink';
17
+ readonly d: string;
18
+ }
19
+ /** An unfilled part of the silhouette — a handle, a wire, an arc. */
20
+ | {
21
+ readonly role: 'stroke';
22
+ readonly d: string;
23
+ readonly width: number;
24
+ }
25
+ /** A division inside the silhouette, drawn in the halo color. */
26
+ | {
27
+ readonly role: 'detail';
28
+ readonly d: string;
29
+ readonly width: number;
30
+ }
31
+ /** A literal color, for glyphs that carry a swatch. */
32
+ | {
33
+ readonly role: 'accent';
34
+ readonly d: string;
35
+ readonly fill: string;
36
+ };
37
+ /**
38
+ * Ink and halo are constants of the register rather than parameters: a
39
+ * self-contrasting glyph reads on white paper, dark chrome and mid-tone
40
+ * artwork alike precisely because it does not track the theme.
41
+ */
42
+ declare const CURSOR_INK = "#141418";
43
+ declare const CURSOR_HALO = "#ffffff";
44
+ declare const CURSOR_HALO_WIDTH = 2.6;
45
+ /**
46
+ * Chrome silently drops a cursor image above this size and falls back to the
47
+ * keyword after the comma, with no error anywhere. Measured on Chrome 152 /
48
+ * macOS 26.5; see the spec's "Measured browser behavior".
49
+ */
50
+ declare const CURSOR_MAX_CSS_PX = 128;
51
+ /**
52
+ * True when every authored path sits at least half a halo stroke inside the
53
+ * viewBox. A clipped halo is invisible at proof size and flattens the glyph's
54
+ * outline at cursor size, so it is worth failing loudly at authoring time
55
+ * rather than discovering it on a dark background.
56
+ */
57
+ declare function haloFitsInBox(glyph: CursorGlyph): boolean;
58
+
59
+ interface BakeOptions {
60
+ /** Rendered size in CSS px. Default 24. */
61
+ readonly size?: number;
62
+ /** Keyword drawn if the browser rejects the image. Default 'default'. */
63
+ readonly fallback?: string;
64
+ }
65
+ /**
66
+ * Render a glyph to a CSS cursor value.
67
+ *
68
+ * Ships SVG rather than a bitmap because Chrome rasterizes an SVG data-URI
69
+ * cursor at device scale — it is already crisp on a retina display, so there
70
+ * is no PNG pipeline and no `image-set()` here. See the spec.
71
+ */
72
+ declare function bakeCursor(glyph: CursorGlyph, opts?: BakeOptions): string;
73
+
74
+ declare const GLYPHS: {
75
+ readonly pencil: {
76
+ readonly box: 24;
77
+ readonly hotspot: readonly [5, 19];
78
+ readonly paths: readonly [{
79
+ readonly role: "ink";
80
+ readonly d: "M 5 19 L 7.5 16.5 L 16 8 L 19 11 L 10.5 19.5 L 8 19 Z";
81
+ }, {
82
+ readonly role: "detail";
83
+ readonly d: "M 14 6 L 19 11";
84
+ readonly width: 1.2;
85
+ }];
86
+ };
87
+ readonly pen: {
88
+ readonly box: 24;
89
+ readonly hotspot: readonly [5, 19];
90
+ readonly paths: readonly [{
91
+ readonly role: "ink";
92
+ readonly d: "M 5 19 L 8.5 9.5 L 13 5 L 18 10 L 13.5 14.5 Z";
93
+ }, {
94
+ readonly role: "detail";
95
+ readonly d: "M 8.4 13.9 L 12.2 10.1";
96
+ readonly width: 0.9;
97
+ }];
98
+ };
99
+ readonly eyedropper: {
100
+ readonly box: 24;
101
+ readonly hotspot: readonly [5, 19];
102
+ readonly paths: readonly [{
103
+ readonly role: "ink";
104
+ readonly d: "M 5 19 L 6.8 14.8 L 14.4 7.2 L 16.8 9.6 L 9.2 17.2 Z";
105
+ }, {
106
+ readonly role: "ink";
107
+ readonly d: "M 18.2 2.6 A 3.2 3.2 0 1 0 18.2 9 A 3.2 3.2 0 1 0 18.2 2.6 Z";
108
+ }];
109
+ };
110
+ };
111
+ /** Every glyph name in the set. */
112
+ type CursorGlyphName = keyof typeof GLYPHS;
113
+
114
+ /**
115
+ * The baked cursor string for a named glyph, memoized.
116
+ *
117
+ * The key space is bounded by the glyph set times the handful of sizes and
118
+ * fallbacks in use, so the cache needs no eviction.
119
+ */
120
+ declare function cursorFor(name: CursorGlyphName, opts?: BakeOptions): string;
121
+
122
+ export { type BakeOptions, CURSOR_HALO, CURSOR_HALO_WIDTH, CURSOR_INK, CURSOR_MAX_CSS_PX, type CursorGlyph, type CursorGlyphName, type CursorPath, GLYPHS, bakeCursor, cursorFor, haloFitsInBox };
package/dist/index.js ADDED
@@ -0,0 +1,176 @@
1
+ // src/types.ts
2
+ var CURSOR_INK = "#141418";
3
+ var CURSOR_HALO = "#ffffff";
4
+ var CURSOR_HALO_WIDTH = 2.6;
5
+ var CURSOR_MAX_CSS_PX = 128;
6
+ function extent(d) {
7
+ let min = Number.POSITIVE_INFINITY;
8
+ let max = Number.NEGATIVE_INFINITY;
9
+ let cx = 0;
10
+ let cy = 0;
11
+ const see = (v) => {
12
+ if (v < min) min = v;
13
+ if (v > max) max = v;
14
+ };
15
+ for (const [, cmd, args] of d.matchAll(/([A-Za-z])([^A-Za-z]*)/g)) {
16
+ if (cmd !== cmd.toUpperCase()) {
17
+ throw new Error(`cursor glyph path uses a relative command '${cmd}': ${d}`);
18
+ }
19
+ const nums = (args.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
20
+ if (cmd === "Z") continue;
21
+ if (cmd === "A") {
22
+ for (let i = 0; i + 7 <= nums.length; i += 7) {
23
+ const [rx, ry, , largeArc, sweep, x, y] = nums.slice(i, i + 7);
24
+ const r = Math.max(rx, ry);
25
+ const dx = x - cx;
26
+ const dy = y - cy;
27
+ const half = Math.hypot(dx, dy) / 2;
28
+ const off = Math.sqrt(Math.max(0, r * r - half * half));
29
+ const sign = largeArc === sweep ? -1 : 1;
30
+ const ux = half > 0 ? -dy / (half * 2) : 0;
31
+ const uy = half > 0 ? dx / (half * 2) : 0;
32
+ const ox = (cx + x) / 2 + sign * off * ux;
33
+ const oy = (cy + y) / 2 + sign * off * uy;
34
+ see(ox - r);
35
+ see(ox + r);
36
+ see(oy - r);
37
+ see(oy + r);
38
+ cx = x;
39
+ cy = y;
40
+ }
41
+ continue;
42
+ }
43
+ for (let i = 0; i + 2 <= nums.length; i += 2) {
44
+ cx = nums[i];
45
+ cy = nums[i + 1];
46
+ see(cx);
47
+ see(cy);
48
+ }
49
+ }
50
+ return { min, max };
51
+ }
52
+ function haloFitsInBox(glyph) {
53
+ const margin = CURSOR_HALO_WIDTH / 2;
54
+ return glyph.paths.every((p) => {
55
+ const { min, max } = extent(p.d);
56
+ return min >= margin && max <= glyph.box - margin;
57
+ });
58
+ }
59
+
60
+ // src/bake.ts
61
+ function renderHalo(p) {
62
+ switch (p.role) {
63
+ case "ink":
64
+ case "accent":
65
+ return `<path d="${p.d}" fill="${CURSOR_HALO}" stroke="${CURSOR_HALO}" stroke-width="${CURSOR_HALO_WIDTH}" stroke-linejoin="round"/>`;
66
+ case "stroke":
67
+ return `<path d="${p.d}" fill="none" stroke="${CURSOR_HALO}" stroke-width="${p.width + CURSOR_HALO_WIDTH}" stroke-linecap="round" stroke-linejoin="round"/>`;
68
+ // A detail IS halo-coloured and sits on top of the ink; it has no halo.
69
+ case "detail":
70
+ return "";
71
+ }
72
+ }
73
+ function renderInk(p) {
74
+ switch (p.role) {
75
+ case "ink":
76
+ return `<path d="${p.d}" fill="${CURSOR_INK}"/>`;
77
+ case "stroke":
78
+ return `<path d="${p.d}" fill="none" stroke="${CURSOR_INK}" stroke-width="${p.width}" stroke-linecap="round" stroke-linejoin="round"/>`;
79
+ case "detail":
80
+ return `<path d="${p.d}" fill="none" stroke="${CURSOR_HALO}" stroke-width="${p.width}" stroke-linecap="round"/>`;
81
+ case "accent":
82
+ return `<path d="${p.d}" fill="${p.fill}"/>`;
83
+ }
84
+ }
85
+ function bakeCursor(glyph, opts = {}) {
86
+ const size = opts.size ?? 24;
87
+ if (size > CURSOR_MAX_CSS_PX) {
88
+ throw new RangeError(
89
+ `cursor size ${size} exceeds the ${CURSOR_MAX_CSS_PX}px cap: the browser would drop the image and silently fall back. Use the painted tier.`
90
+ );
91
+ }
92
+ const body = glyph.paths.map(renderHalo).join("") + glyph.paths.map(renderInk).join("");
93
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${glyph.box} ${glyph.box}">${body}</svg>`;
94
+ const hx = Math.round(glyph.hotspot[0] / glyph.box * size);
95
+ const hy = Math.round(glyph.hotspot[1] / glyph.box * size);
96
+ const uri = `data:image/svg+xml,${encodeURIComponent(svg)}`;
97
+ return `url("${uri}") ${hx} ${hy}, ${opts.fallback ?? "default"}`;
98
+ }
99
+
100
+ // src/glyphs.ts
101
+ var GLYPHS = {
102
+ "pencil": {
103
+ "box": 24,
104
+ "hotspot": [
105
+ 5,
106
+ 19
107
+ ],
108
+ "paths": [
109
+ {
110
+ "role": "ink",
111
+ "d": "M 5 19 L 7.5 16.5 L 16 8 L 19 11 L 10.5 19.5 L 8 19 Z"
112
+ },
113
+ {
114
+ "role": "detail",
115
+ "d": "M 14 6 L 19 11",
116
+ "width": 1.2
117
+ }
118
+ ]
119
+ },
120
+ "pen": {
121
+ "box": 24,
122
+ "hotspot": [
123
+ 5,
124
+ 19
125
+ ],
126
+ "paths": [
127
+ {
128
+ "role": "ink",
129
+ "d": "M 5 19 L 8.5 9.5 L 13 5 L 18 10 L 13.5 14.5 Z"
130
+ },
131
+ {
132
+ "role": "detail",
133
+ "d": "M 8.4 13.9 L 12.2 10.1",
134
+ "width": 0.9
135
+ }
136
+ ]
137
+ },
138
+ "eyedropper": {
139
+ "box": 24,
140
+ "hotspot": [
141
+ 5,
142
+ 19
143
+ ],
144
+ "paths": [
145
+ {
146
+ "role": "ink",
147
+ "d": "M 5 19 L 6.8 14.8 L 14.4 7.2 L 16.8 9.6 L 9.2 17.2 Z"
148
+ },
149
+ {
150
+ "role": "ink",
151
+ "d": "M 18.2 2.6 A 3.2 3.2 0 1 0 18.2 9 A 3.2 3.2 0 1 0 18.2 2.6 Z"
152
+ }
153
+ ]
154
+ }
155
+ };
156
+
157
+ // src/registry.ts
158
+ var cache = /* @__PURE__ */ new Map();
159
+ function cursorFor(name, opts = {}) {
160
+ const size = opts.size ?? 24;
161
+ const fallback = opts.fallback ?? "default";
162
+ const key = `${name}|${size}|${fallback}`;
163
+ const hit = cache.get(key);
164
+ if (hit !== void 0) return hit;
165
+ const glyph = GLYPHS[name];
166
+ if (glyph === void 0) {
167
+ throw new Error(`unknown cursor glyph: ${String(name)}`);
168
+ }
169
+ const css = bakeCursor(glyph, { size, fallback });
170
+ cache.set(key, css);
171
+ return css;
172
+ }
173
+
174
+ export { CURSOR_HALO, CURSOR_HALO_WIDTH, CURSOR_INK, CURSOR_MAX_CSS_PX, GLYPHS, bakeCursor, cursorFor, haloFitsInBox };
175
+ //# sourceMappingURL=index.js.map
176
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/types.ts","../src/bake.ts","../src/glyphs.ts","../src/registry.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * A cursor glyph: SVG path `d` strings tagged with a paint role, plus the\n * hotspot in glyph units. The same record feeds both the data-URI baker and\n * the Path2D painter, so `d` is the one geometry form neither has to translate.\n */\nexport interface CursorGlyph {\n /** Side of the square viewBox the paths are authored in. */\n readonly box: number;\n /** Hotspot in glyph units, scaled to integer CSS px at bake time. */\n readonly hotspot: readonly [number, number];\n readonly paths: readonly CursorPath[];\n}\n\nexport type CursorPath =\n /** A filled part of the silhouette. */\n | { readonly role: 'ink'; readonly d: string }\n /** An unfilled part of the silhouette — a handle, a wire, an arc. */\n | { readonly role: 'stroke'; readonly d: string; readonly width: number }\n /** A division inside the silhouette, drawn in the halo color. */\n | { readonly role: 'detail'; readonly d: string; readonly width: number }\n /** A literal color, for glyphs that carry a swatch. */\n | { readonly role: 'accent'; readonly d: string; readonly fill: string };\n\n/**\n * Ink and halo are constants of the register rather than parameters: a\n * self-contrasting glyph reads on white paper, dark chrome and mid-tone\n * artwork alike precisely because it does not track the theme.\n */\nexport const CURSOR_INK = '#141418';\nexport const CURSOR_HALO = '#ffffff';\nexport const CURSOR_HALO_WIDTH = 2.6;\n\n/**\n * Chrome silently drops a cursor image above this size and falls back to the\n * keyword after the comma, with no error anywhere. Measured on Chrome 152 /\n * macOS 26.5; see the spec's \"Measured browser behavior\".\n */\nexport const CURSOR_MAX_CSS_PX = 128;\n\n/**\n * Axis-aligned bounds of an authored `d` string.\n *\n * Command-aware on purpose. Scraping every number out of the string instead\n * reads an arc's radii and its three flags as coordinates, which reports a\n * bogus `0` for every `A` ever written.\n *\n * An arc is bounded by the circle it lies on: its centre is recovered from the\n * endpoints and radius, then the whole circle is admitted. Expanding the arc's\n * *endpoint* by the radii instead over-reports whenever an endpoint sits at an\n * extreme of the circle, which is exactly where the half-circle idiom these\n * glyphs use puts it.\n *\n * The authored dialect is absolute `M`/`L`/`A`/`Z` with circular arcs. A\n * relative command would be measured as if absolute and silently under-report,\n * so it throws.\n */\nfunction extent(d: string): { min: number; max: number } {\n let min = Number.POSITIVE_INFINITY;\n let max = Number.NEGATIVE_INFINITY;\n let cx = 0;\n let cy = 0;\n const see = (v: number) => {\n if (v < min) min = v;\n if (v > max) max = v;\n };\n for (const [, cmd, args] of d.matchAll(/([A-Za-z])([^A-Za-z]*)/g)) {\n if (cmd !== cmd.toUpperCase()) {\n throw new Error(`cursor glyph path uses a relative command '${cmd}': ${d}`);\n }\n const nums = (args.match(/-?\\d+(?:\\.\\d+)?/g) ?? []).map(Number);\n if (cmd === 'Z') continue;\n if (cmd === 'A') {\n for (let i = 0; i + 7 <= nums.length; i += 7) {\n const [rx, ry, , largeArc, sweep, x, y] = nums.slice(i, i + 7);\n const r = Math.max(rx, ry);\n const dx = x - cx;\n const dy = y - cy;\n const half = Math.hypot(dx, dy) / 2;\n // Centre sits off the chord midpoint by this much, perpendicular to it.\n const off = Math.sqrt(Math.max(0, r * r - half * half));\n const sign = largeArc === sweep ? -1 : 1;\n const ux = half > 0 ? -dy / (half * 2) : 0;\n const uy = half > 0 ? dx / (half * 2) : 0;\n const ox = (cx + x) / 2 + sign * off * ux;\n const oy = (cy + y) / 2 + sign * off * uy;\n see(ox - r);\n see(ox + r);\n see(oy - r);\n see(oy + r);\n cx = x;\n cy = y;\n }\n continue;\n }\n // M and L: plain coordinate pairs.\n for (let i = 0; i + 2 <= nums.length; i += 2) {\n cx = nums[i];\n cy = nums[i + 1];\n see(cx);\n see(cy);\n }\n }\n return { min, max };\n}\n\n/**\n * True when every authored path sits at least half a halo stroke inside the\n * viewBox. A clipped halo is invisible at proof size and flattens the glyph's\n * outline at cursor size, so it is worth failing loudly at authoring time\n * rather than discovering it on a dark background.\n */\nexport function haloFitsInBox(glyph: CursorGlyph): boolean {\n const margin = CURSOR_HALO_WIDTH / 2;\n return glyph.paths.every((p) => {\n const { min, max } = extent(p.d);\n return min >= margin && max <= glyph.box - margin;\n });\n}\n","import {\n CURSOR_HALO,\n CURSOR_HALO_WIDTH,\n CURSOR_INK,\n CURSOR_MAX_CSS_PX,\n} from './types';\nimport type { CursorGlyph, CursorPath } from './types';\n\nexport interface BakeOptions {\n /** Rendered size in CSS px. Default 24. */\n readonly size?: number;\n /** Keyword drawn if the browser rejects the image. Default 'default'. */\n readonly fallback?: string;\n}\n\n/**\n * The halo pass. Every silhouette member is drawn once, wide, in halo colour\n * before any ink lands.\n *\n * It has to be a separate pass rather than a per-path `paint-order`: with each\n * path stroking its own halo, a later path's halo cuts a white trench through\n * an earlier path's fill wherever the two overlap. One pass under everything\n * gives the glyph a single continuous outline instead.\n */\nfunction renderHalo(p: CursorPath): string {\n switch (p.role) {\n case 'ink':\n case 'accent':\n return (\n `<path d=\"${p.d}\" fill=\"${CURSOR_HALO}\" stroke=\"${CURSOR_HALO}\"` +\n ` stroke-width=\"${CURSOR_HALO_WIDTH}\" stroke-linejoin=\"round\"/>`\n );\n case 'stroke':\n return (\n `<path d=\"${p.d}\" fill=\"none\" stroke=\"${CURSOR_HALO}\"` +\n ` stroke-width=\"${p.width + CURSOR_HALO_WIDTH}\"` +\n ` stroke-linecap=\"round\" stroke-linejoin=\"round\"/>`\n );\n // A detail IS halo-coloured and sits on top of the ink; it has no halo.\n case 'detail':\n return '';\n }\n}\n\nfunction renderInk(p: CursorPath): string {\n switch (p.role) {\n case 'ink':\n return `<path d=\"${p.d}\" fill=\"${CURSOR_INK}\"/>`;\n case 'stroke':\n return (\n `<path d=\"${p.d}\" fill=\"none\" stroke=\"${CURSOR_INK}\"` +\n ` stroke-width=\"${p.width}\" stroke-linecap=\"round\"` +\n ` stroke-linejoin=\"round\"/>`\n );\n case 'detail':\n return (\n `<path d=\"${p.d}\" fill=\"none\" stroke=\"${CURSOR_HALO}\"` +\n ` stroke-width=\"${p.width}\" stroke-linecap=\"round\"/>`\n );\n case 'accent':\n return `<path d=\"${p.d}\" fill=\"${p.fill}\"/>`;\n }\n}\n\n/**\n * Render a glyph to a CSS cursor value.\n *\n * Ships SVG rather than a bitmap because Chrome rasterizes an SVG data-URI\n * cursor at device scale — it is already crisp on a retina display, so there\n * is no PNG pipeline and no `image-set()` here. See the spec.\n */\nexport function bakeCursor(glyph: CursorGlyph, opts: BakeOptions = {}): string {\n const size = opts.size ?? 24;\n if (size > CURSOR_MAX_CSS_PX) {\n throw new RangeError(\n `cursor size ${size} exceeds the ${CURSOR_MAX_CSS_PX}px cap: the browser ` +\n `would drop the image and silently fall back. Use the painted tier.`,\n );\n }\n // Halos first, then ink; within each pass, source order is z-order.\n const body = glyph.paths.map(renderHalo).join('') + glyph.paths.map(renderInk).join('');\n const svg =\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${size}\" height=\"${size}\"` +\n ` viewBox=\"0 0 ${glyph.box} ${glyph.box}\">${body}</svg>`;\n const hx = Math.round((glyph.hotspot[0] / glyph.box) * size);\n const hy = Math.round((glyph.hotspot[1] / glyph.box) * size);\n const uri = `data:image/svg+xml,${encodeURIComponent(svg)}`;\n return `url(\"${uri}\") ${hx} ${hy}, ${opts.fallback ?? 'default'}`;\n}\n","// GENERATED by scripts/gen-cursors.mjs — do not edit by hand.\n// Filled silhouettes on a 24-unit box; the halo is applied at bake time.\n\nimport type { CursorGlyph } from './types';\n\nexport const GLYPHS = {\n \"pencil\": {\n \"box\": 24,\n \"hotspot\": [\n 5,\n 19\n ],\n \"paths\": [\n {\n \"role\": \"ink\",\n \"d\": \"M 5 19 L 7.5 16.5 L 16 8 L 19 11 L 10.5 19.5 L 8 19 Z\"\n },\n {\n \"role\": \"detail\",\n \"d\": \"M 14 6 L 19 11\",\n \"width\": 1.2\n }\n ]\n },\n \"pen\": {\n \"box\": 24,\n \"hotspot\": [\n 5,\n 19\n ],\n \"paths\": [\n {\n \"role\": \"ink\",\n \"d\": \"M 5 19 L 8.5 9.5 L 13 5 L 18 10 L 13.5 14.5 Z\"\n },\n {\n \"role\": \"detail\",\n \"d\": \"M 8.4 13.9 L 12.2 10.1\",\n \"width\": 0.9\n }\n ]\n },\n \"eyedropper\": {\n \"box\": 24,\n \"hotspot\": [\n 5,\n 19\n ],\n \"paths\": [\n {\n \"role\": \"ink\",\n \"d\": \"M 5 19 L 6.8 14.8 L 14.4 7.2 L 16.8 9.6 L 9.2 17.2 Z\"\n },\n {\n \"role\": \"ink\",\n \"d\": \"M 18.2 2.6 A 3.2 3.2 0 1 0 18.2 9 A 3.2 3.2 0 1 0 18.2 2.6 Z\"\n }\n ]\n }\n} as const satisfies Record<string, CursorGlyph>;\n\n/** Every glyph name in the set. */\nexport type CursorGlyphName = keyof typeof GLYPHS;\n","import { bakeCursor } from './bake';\nimport type { BakeOptions } from './bake';\nimport { GLYPHS } from './glyphs';\nimport type { CursorGlyphName } from './glyphs';\nimport type { CursorGlyph } from './types';\n\nconst cache = new Map<string, string>();\n\n/**\n * The baked cursor string for a named glyph, memoized.\n *\n * The key space is bounded by the glyph set times the handful of sizes and\n * fallbacks in use, so the cache needs no eviction.\n */\nexport function cursorFor(name: CursorGlyphName, opts: BakeOptions = {}): string {\n const size = opts.size ?? 24;\n const fallback = opts.fallback ?? 'default';\n const key = `${name}|${size}|${fallback}`;\n const hit = cache.get(key);\n if (hit !== undefined) return hit;\n\n // Widened deliberately: the key type says this is always defined, but the\n // guard is what makes a bad name from untyped JS throw instead of baking\n // `undefined` into a cursor string that silently does nothing.\n const glyph = (GLYPHS as Record<string, CursorGlyph | undefined>)[name];\n if (glyph === undefined) {\n throw new Error(`unknown cursor glyph: ${String(name)}`);\n }\n const css = bakeCursor(glyph, { size, fallback });\n cache.set(key, css);\n return css;\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@weasel-js/cursor",
3
+ "version": "1.4.0-pre.1",
4
+ "description": "Tool cursors as authored glyphs: bake one to a CSS url() cursor string, or paint it when it is too big to be one.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "author": "orochi235",
19
+ "homepage": "https://orochi235.github.io/weasel/",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/orochi235/weasel.git",
23
+ "directory": "packages/cursor"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/orochi235/weasel/issues"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsup"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "provenance": true
42
+ }
43
+ }