@motion-proto/live-tokens 0.56.1 → 0.58.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.
Files changed (95) hide show
  1. package/.claude/skills/live-tokens-create-component/SKILL.md +13 -2
  2. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +202 -0
  3. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +2 -2
  4. package/.claude/skills/live-tokens-pick-component/SKILL.md +1 -1
  5. package/CHANGELOG.md +192 -0
  6. package/README.md +1 -1
  7. package/bin/cli.mjs +2 -1
  8. package/dist-plugin/adjust/index.cjs +1 -0
  9. package/dist-plugin/adjust/index.d.cts +2 -2
  10. package/dist-plugin/adjust/index.d.ts +2 -2
  11. package/dist-plugin/adjust/index.js +1 -1
  12. package/dist-plugin/{chunk-TKZBVIW5.js → chunk-E5QYON4L.js} +59 -2
  13. package/dist-plugin/{chunk-PB2JTK2H.js → chunk-LW4SR7AZ.js} +39 -6
  14. package/dist-plugin/{chunk-YR6GPXW2.js → chunk-XWXIMTWZ.js} +0 -5
  15. package/dist-plugin/{chunk-D3ZVKOR4.js → chunk-Y5CNFSSV.js} +1 -0
  16. package/dist-plugin/{dataPaths-DBN0RPuT.d.cts → dataPaths-CRfD1LdA.d.cts} +3 -0
  17. package/dist-plugin/{dataPaths-DBN0RPuT.d.ts → dataPaths-CRfD1LdA.d.ts} +3 -0
  18. package/dist-plugin/fontPairing/index.cjs +4 -2
  19. package/dist-plugin/fontPairing/index.d.cts +3 -3
  20. package/dist-plugin/fontPairing/index.d.ts +3 -3
  21. package/dist-plugin/fontPairing/index.js +4 -3
  22. package/dist-plugin/generateColorsAndType/index.cjs +22 -9
  23. package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
  24. package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
  25. package/dist-plugin/generateColorsAndType/index.js +8 -5
  26. package/dist-plugin/index.cjs +158 -9
  27. package/dist-plugin/index.d.cts +1 -1
  28. package/dist-plugin/index.d.ts +1 -1
  29. package/dist-plugin/index.js +71 -4
  30. package/dist-plugin/migrateData/index.cjs +1 -0
  31. package/dist-plugin/migrateData/index.d.cts +1 -1
  32. package/dist-plugin/migrateData/index.d.ts +1 -1
  33. package/dist-plugin/migrateData/index.js +1 -1
  34. package/dist-plugin/{themeTypes-BAqtv4XO.d.cts → themeTypes-DSV3Zisf.d.cts} +12 -1
  35. package/dist-plugin/{themeTypes-BAqtv4XO.d.ts → themeTypes-DSV3Zisf.d.ts} +12 -1
  36. package/dist-plugin/tokensCssMigrations/index.cjs +58 -1
  37. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  38. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  39. package/dist-plugin/tokensCssMigrations/index.js +3 -3
  40. package/package.json +3 -1
  41. package/src/editor/core/fonts/applyFontPairing.ts +3 -2
  42. package/src/editor/core/fonts/fontLoader.ts +1 -0
  43. package/src/editor/core/fonts/fontMigration.ts +31 -1
  44. package/src/editor/core/palettes/paletteDerivation.ts +9 -2
  45. package/src/editor/core/sketch/maskField.ts +385 -0
  46. package/src/editor/core/sketch/sketchLayer.ts +1106 -0
  47. package/src/editor/core/sketch/sketchPresetService.ts +65 -0
  48. package/src/editor/core/sketch/sketchPresets.ts +345 -0
  49. package/src/editor/core/sketch/sketchStore.ts +190 -0
  50. package/src/editor/core/store/editorPersistence.ts +28 -12
  51. package/src/editor/core/store/editorTypes.ts +4 -1
  52. package/src/editor/core/store/editorViewStore.ts +2 -2
  53. package/src/editor/core/store/gradientSource.ts +15 -1
  54. package/src/editor/core/themes/parsers/gradient.ts +62 -4
  55. package/src/editor/core/themes/slices/gradients.ts +75 -14
  56. package/src/editor/core/themes/themeTypes.ts +6 -1
  57. package/src/editor/docs/Docs.svelte +4 -3
  58. package/src/editor/docs/chapters.ts +1 -0
  59. package/src/editor/docs/content/01-overview.md +2 -1
  60. package/src/editor/docs/content/editing-tokens.md +5 -1
  61. package/src/editor/docs/content/sketch-mode.md +183 -0
  62. package/src/editor/docs/content.generated.ts +3 -2
  63. package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
  64. package/src/editor/pages/EditorShell.svelte +43 -0
  65. package/src/editor/ui/EditorViewSwitcher.svelte +30 -17
  66. package/src/editor/ui/FontStackEditor.svelte +3 -0
  67. package/src/editor/ui/GradientEditor.svelte +38 -1
  68. package/src/editor/ui/UIReveal.svelte +7 -1
  69. package/src/editor/ui/UISegmentedControl.svelte +4 -8
  70. package/src/editor/ui/sections/GradientsSection.svelte +47 -1
  71. package/src/editor/ui/sketch/SketchDial.svelte +124 -0
  72. package/src/editor/ui/sketch/SketchPreview.svelte +119 -0
  73. package/src/editor/ui/sketch/SketchRange.svelte +185 -0
  74. package/src/editor/ui/sketch/SketchTab.svelte +1264 -0
  75. package/src/live-tokens/data/colors-and-type/autumn.json +17 -0
  76. package/src/live-tokens/data/colors-and-type/default.json +17 -0
  77. package/src/live-tokens/data/colors-and-type/halloween.json +17 -0
  78. package/src/live-tokens/data/colors-and-type/midnight-study.json +17 -0
  79. package/src/live-tokens/data/colors-and-type/ocean.json +17 -0
  80. package/src/live-tokens/data/colors-and-type/royal-velvet.json +17 -0
  81. package/src/live-tokens/data/colors-and-type/sketchy.json +2520 -0
  82. package/src/live-tokens/data/colors-and-type/spring-meadow.json +17 -0
  83. package/src/live-tokens/data/colors-and-type/sunset.json +17 -0
  84. package/src/live-tokens/data/themes/autumn.json +17 -0
  85. package/src/live-tokens/data/themes/halloween.json +17 -0
  86. package/src/live-tokens/data/themes/midnight-study.json +17 -0
  87. package/src/live-tokens/data/themes/ocean.json +17 -0
  88. package/src/live-tokens/data/themes/royal-velvet.json +17 -0
  89. package/src/live-tokens/data/themes/sketchy.json +3984 -0
  90. package/src/live-tokens/data/themes/spring-meadow.json +17 -0
  91. package/src/live-tokens/data/themes/sunset.json +17 -0
  92. package/src/live-tokens/data/tokens.generated.css +1 -0
  93. package/src/system/components/Card.svelte +12 -1
  94. package/src/system/components/SectionDivider.svelte +4 -0
  95. package/src/system/styles/tokens.css +15 -2
@@ -0,0 +1,385 @@
1
+ /**
2
+ * The ink coverage field: a Perlin noise tile, levelled, posterised and
3
+ * blurred, handed out as a greyscale PNG that CSS masks with `mask-mode:
4
+ * luminance` — white keeps the fill, black erases it.
5
+ *
6
+ * The field is built here rather than by `feTurbulence` because the filter
7
+ * primitive's output is not a value anyone can reason about: it lands in a
8
+ * narrow band around its own midpoint (about 0.24 to 0.77 for fractal noise,
9
+ * whatever the octaves), so a dial that walks a cut across 0 to 1 spends most
10
+ * of its travel outside the field entirely and the little that lands inside
11
+ * comes out as one flat mid-grey. Generated here, the tile is stretched onto
12
+ * its own measured range before any dial sees it, so Min and Max always have
13
+ * the whole field to work with and always read as levels.
14
+ *
15
+ * Everything is integer maths on a Float32Array with no DOM, so what a test
16
+ * asserts is what the browser paints.
17
+ */
18
+ import type { SketchSettings } from './sketchPresets';
19
+
20
+ /** What the tile spans in page px. Blobs are fitted a whole number to the
21
+ tile, which is what lets the lattice wrap; the dial's px reading is the
22
+ nearest whole fit, never more than a few px off what it says. */
23
+ export const MASK_TILE = 600;
24
+
25
+ /** Page px per sample. The field is blobs and gradients, so painting it at half
26
+ resolution and letting the browser scale it up costs nothing visible and
27
+ quarters both the work and the data URI. */
28
+ const SAMPLE_PX = 2;
29
+ const RASTER = MASK_TILE / SAMPLE_PX;
30
+
31
+ /** The stage previews, in the order the field is built. */
32
+ export type MaskStage = 'noise' | 'output' | 'blur';
33
+
34
+ /* ---------------------------------------------------------------- noise --- */
35
+
36
+ function mulberry32(seed: number): () => number {
37
+ let a = seed >>> 0;
38
+ return () => {
39
+ a = (a + 0x6d2b79f5) >>> 0;
40
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
41
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
42
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Perlin noise on a lattice that wraps every `period` cells, so a tile drawn
48
+ * from it meets itself at both edges. Returns roughly -0.7 to 0.7.
49
+ *
50
+ * Hand-rolled because the wrap is the whole requirement and no established
51
+ * noise package offers one: `simplex-noise` and its neighbours all sample an
52
+ * endless plane. The usual way to tile them is to walk two circles through 4D
53
+ * noise, and it degenerates at the ratio this effect works at — a tile only
54
+ * four or five blobs across makes those circles smaller than one noise feature,
55
+ * and the field comes back as a regular grid of dots instead of cloud. A
56
+ * lattice takes its period as an integer and wraps exactly at any size.
57
+ *
58
+ * Gradients are drawn from the whole circle, one per lattice point, rather than
59
+ * from the eight compass directions the textbook version uses. Eight leaves
60
+ * every blob squared off against the axes.
61
+ */
62
+ const GRAD_COUNT = 256;
63
+
64
+ function makePerlin(seed: number): (x: number, y: number, period: number) => number {
65
+ const rand = mulberry32(seed);
66
+ const perm = new Uint8Array(512);
67
+ const src = new Uint8Array(256);
68
+ for (let i = 0; i < 256; i++) src[i] = i;
69
+ for (let i = 255; i > 0; i--) {
70
+ const j = Math.floor(rand() * (i + 1));
71
+ const t = src[i]; src[i] = src[j]; src[j] = t;
72
+ }
73
+ for (let i = 0; i < 512; i++) perm[i] = src[i & 255];
74
+
75
+ const gx = new Float32Array(GRAD_COUNT), gy = new Float32Array(GRAD_COUNT);
76
+ for (let i = 0; i < GRAD_COUNT; i++) {
77
+ const a = rand() * Math.PI * 2;
78
+ gx[i] = Math.cos(a); gy[i] = Math.sin(a);
79
+ }
80
+
81
+ const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);
82
+ const lerp = (t: number, a: number, b: number) => a + t * (b - a);
83
+
84
+ return (x, y, period) => {
85
+ const x0 = Math.floor(x), y0 = Math.floor(y);
86
+ const xf = x - x0, yf = y - y0;
87
+ const p = Math.max(1, Math.round(period));
88
+ const xi = ((x0 % p) + p) % p, yi = ((y0 % p) + p) % p;
89
+ const xi1 = (xi + 1) % p, yi1 = (yi + 1) % p;
90
+
91
+ const g = (cx: number, cy: number, dx: number, dy: number) => {
92
+ const h = perm[(perm[cx & 255] + cy) & 255];
93
+ return gx[h] * dx + gy[h] * dy;
94
+ };
95
+ const u = fade(xf), v = fade(yf);
96
+ return lerp(v,
97
+ lerp(u, g(xi, yi, xf, yf), g(xi1, yi, xf - 1, yf)),
98
+ lerp(u, g(xi, yi1, xf, yf - 1), g(xi1, yi1, xf - 1, yf - 1)));
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The raw field: octaves of Perlin, each half the size and half the weight of
104
+ * the one before, stretched onto its own measured range.
105
+ *
106
+ * `veined` folds each octave at zero before summing. The fold puts a crease
107
+ * wherever the octave crossed zero, which is the marbled look; plain sum is
108
+ * cloud.
109
+ */
110
+ function rawField(s: SketchSettings, seed: number, raster: number, cells: number): Float32Array {
111
+ const noise = makePerlin(seed);
112
+ const veined = s.maskGrain === 'turbulence';
113
+ const octaves = Math.max(1, Math.round(s.maskOctaves));
114
+ const out = new Float32Array(raster * raster);
115
+ const step = cells / raster;
116
+
117
+ let weightSum = 0;
118
+ for (let o = 0; o < octaves; o++) weightSum += 1 / (1 << o);
119
+
120
+ for (let y = 0; y < raster; y++) {
121
+ for (let x = 0; x < raster; x++) {
122
+ let sum = 0;
123
+ for (let o = 0; o < octaves; o++) {
124
+ const f = 1 << o;
125
+ const n = noise(x * step * f, y * step * f, cells * f);
126
+ sum += (veined ? Math.abs(n) : n) / f;
127
+ }
128
+ out[y * raster + x] = sum / weightSum;
129
+ }
130
+ }
131
+ return normalise(out);
132
+ }
133
+
134
+ /**
135
+ * Stretch the field onto 0..1 by its own 0.5th and 99.5th percentiles.
136
+ *
137
+ * This is the difference between levels that work and a grey wash. Noise of any
138
+ * kind piles up around its middle, and how far it reaches depends on the grain
139
+ * and the octave count, so a fixed range would be wrong for every setting but
140
+ * one. Measured per tile, Min and Max always address a field that runs black to
141
+ * white, and the two dials mean the same thing at every other setting.
142
+ *
143
+ * The percentiles rather than the extremes, so one freak pixel cannot flatten
144
+ * the rest of the tile.
145
+ */
146
+ function normalise(f: Float32Array): Float32Array {
147
+ const sorted = Float32Array.from(f).sort();
148
+ const lo = sorted[Math.floor(0.005 * (sorted.length - 1))];
149
+ const hi = sorted[Math.ceil(0.995 * (sorted.length - 1))];
150
+ const span = hi - lo;
151
+ if (span <= 1e-6) return f.fill(0.5);
152
+ for (let i = 0; i < f.length; i++) f[i] = Math.min(1, Math.max(0, (f[i] - lo) / span));
153
+ return f;
154
+ }
155
+
156
+ /* ------------------------------------------------------------- levelling --- */
157
+
158
+ /** Output levels. The field is stretched into the gap between the handles, so
159
+ the pair states the range the mask paints: nothing is barer than Min or
160
+ denser than Max, and the field keeps its own shape in between.
161
+
162
+ Clamping instead makes Min the value most of the field sits AT rather than
163
+ its rare floor, because the field arrives centred on its own middle: half of
164
+ it is below 0.5, so a floor of 0.6 piles that half onto 0.6 and the fill
165
+ comes out a flat wash at the floor with a thin bright tail above it. */
166
+ function applyOutput(f: Float32Array, min: number, max: number): Float32Array {
167
+ const span = max - min;
168
+ for (let i = 0; i < f.length; i++) f[i] = min + f[i] * span;
169
+ return f;
170
+ }
171
+
172
+ function posterise(f: Float32Array, steps: number): Float32Array {
173
+ if (steps < 2) return f;
174
+ for (let i = 0; i < f.length; i++) {
175
+ const band = Math.min(steps - 1, Math.floor(f[i] * steps));
176
+ f[i] = band / (steps - 1);
177
+ }
178
+ return f;
179
+ }
180
+
181
+ /** Three box passes, wrapped at the tile edges so the blur cannot draw a rim
182
+ where the tile repeats.
183
+
184
+ The width is the one the filter spec derives for three boxes to land on a
185
+ gaussian of a given deviation, so the dial's px are the px it gets. */
186
+ function blur(f: Float32Array, raster: number, std: number): Float32Array {
187
+ const radius = Math.round((std * 3 * Math.sqrt(2 * Math.PI) / 4 - 1) / 2);
188
+ if (radius < 1) return f;
189
+ let cur = f;
190
+ for (let pass = 0; pass < 3; pass++) cur = boxPass(cur, raster, radius);
191
+ return cur;
192
+ }
193
+
194
+ function boxPass(f: Float32Array, n: number, radius: number): Float32Array {
195
+ const mid = new Float32Array(f.length);
196
+ const width = radius * 2 + 1;
197
+ for (let y = 0; y < n; y++) {
198
+ const row = y * n;
199
+ let sum = 0;
200
+ for (let i = -radius; i <= radius; i++) sum += f[row + ((i % n) + n) % n];
201
+ for (let x = 0; x < n; x++) {
202
+ mid[row + x] = sum / width;
203
+ sum += f[row + ((x + radius + 1) % n)] - f[row + ((x - radius + n) % n)];
204
+ }
205
+ }
206
+ const out = new Float32Array(f.length);
207
+ for (let x = 0; x < n; x++) {
208
+ let sum = 0;
209
+ for (let i = -radius; i <= radius; i++) sum += mid[(((i % n) + n) % n) * n + x];
210
+ for (let y = 0; y < n; y++) {
211
+ out[y * n + x] = sum / width;
212
+ sum += mid[((y + radius + 1) % n) * n + x] - mid[((y - radius + n) % n) * n + x];
213
+ }
214
+ }
215
+ return out;
216
+ }
217
+
218
+ /* ---------------------------------------------------------------- field --- */
219
+
220
+ /** The raw field, kept across calls. The tab asks for the same tile at four
221
+ different stages on every dial move, and the noise is the expensive part of
222
+ all four; the levels, the posterising and the blur are one pass each. */
223
+ let rawCache: { key: string; field: Float32Array } | null = null;
224
+
225
+ function cachedRaw(s: SketchSettings, seed: number): Float32Array {
226
+ const key = [s.maskBlob, s.maskOctaves, s.maskGrain, seed].join('|');
227
+ if (rawCache?.key !== key) {
228
+ const cells = Math.max(1, Math.round(MASK_TILE / s.maskBlob));
229
+ rawCache = { key, field: rawField(s, seed, RASTER, cells) };
230
+ }
231
+ return rawCache.field;
232
+ }
233
+
234
+ /**
235
+ * The finished field, 0 (bare) to 1 (inked), row-major.
236
+ *
237
+ * Three stages and no fourth: the last one IS the result, so the strip of
238
+ * previews in the tab accounts for the whole of what the dials do.
239
+ */
240
+ export function buildMaskField(
241
+ s: SketchSettings, seed = 9, through?: MaskStage,
242
+ ): { field: Float32Array; raster: number } {
243
+ const raw = cachedRaw(s, seed);
244
+ if (through === 'noise') return { field: raw, raster: RASTER };
245
+
246
+ const levelled = applyOutput(
247
+ posterise(Float32Array.from(raw), s.maskPosterize), s.maskOutputMin, s.maskOutputMax,
248
+ );
249
+ if (through === 'output') return { field: levelled, raster: RASTER };
250
+
251
+ return { field: blur(levelled, RASTER, s.maskSoftness / SAMPLE_PX), raster: RASTER };
252
+ }
253
+
254
+ /* ------------------------------------------------------------------ png --- */
255
+
256
+ const CRC_TABLE = (() => {
257
+ const t = new Int32Array(256);
258
+ for (let n = 0; n < 256; n++) {
259
+ let c = n;
260
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
261
+ t[n] = c;
262
+ }
263
+ return t;
264
+ })();
265
+
266
+ function crc32(bytes: Uint8Array): number {
267
+ let c = -1;
268
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
269
+ return (c ^ -1) >>> 0;
270
+ }
271
+
272
+ function adler32(bytes: Uint8Array): number {
273
+ let a = 1, b = 0;
274
+ for (let i = 0; i < bytes.length; i++) {
275
+ a = (a + bytes[i]) % 65521;
276
+ b = (b + a) % 65521;
277
+ }
278
+ return ((b << 16) | a) >>> 0;
279
+ }
280
+
281
+ /** Deflate's stored form: no compression, which needs no compressor. A field
282
+ this smooth would give a real deflate most of its size back, but nothing in
283
+ the platform offers one synchronously, and the alternative — the browser's
284
+ own PNG encoder — is a canvas, which no test can reach. */
285
+ function storedDeflate(raw: Uint8Array): Uint8Array {
286
+ const MAX = 65535;
287
+ const blocks = Math.max(1, Math.ceil(raw.length / MAX));
288
+ const out = new Uint8Array(2 + raw.length + blocks * 5 + 4);
289
+ let p = 0;
290
+ out[p++] = 0x78; out[p++] = 0x01;
291
+ for (let i = 0; i < blocks; i++) {
292
+ const start = i * MAX;
293
+ const len = Math.min(MAX, raw.length - start);
294
+ out[p++] = i === blocks - 1 ? 1 : 0;
295
+ out[p++] = len & 0xff; out[p++] = (len >> 8) & 0xff;
296
+ out[p++] = ~len & 0xff; out[p++] = (~len >> 8) & 0xff;
297
+ out.set(raw.subarray(start, start + len), p);
298
+ p += len;
299
+ }
300
+ const sum = adler32(raw);
301
+ out[p++] = (sum >>> 24) & 0xff; out[p++] = (sum >>> 16) & 0xff;
302
+ out[p++] = (sum >>> 8) & 0xff; out[p++] = sum & 0xff;
303
+ return out;
304
+ }
305
+
306
+ const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
307
+
308
+ function base64(bytes: Uint8Array): string {
309
+ let out = '';
310
+ for (let i = 0; i < bytes.length; i += 3) {
311
+ const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
312
+ out += BASE64[a >> 2];
313
+ out += BASE64[((a & 3) << 4) | ((b ?? 0) >> 4)];
314
+ out += i + 1 < bytes.length ? BASE64[((b & 15) << 2) | ((c ?? 0) >> 6)] : '=';
315
+ out += i + 2 < bytes.length ? BASE64[c & 63] : '=';
316
+ }
317
+ return out;
318
+ }
319
+
320
+ function chunk(type: string, data: Uint8Array): Uint8Array {
321
+ const body = new Uint8Array(4 + data.length);
322
+ for (let i = 0; i < 4; i++) body[i] = type.charCodeAt(i);
323
+ body.set(data, 4);
324
+ const out = new Uint8Array(body.length + 8);
325
+ const view = new DataView(out.buffer);
326
+ view.setUint32(0, data.length);
327
+ out.set(body, 4);
328
+ view.setUint32(out.length - 4, crc32(body));
329
+ return out;
330
+ }
331
+
332
+ /** An 8-bit greyscale PNG of the field. */
333
+ export function fieldToPng(field: Float32Array, raster: number): string {
334
+ const raw = new Uint8Array((raster + 1) * raster);
335
+ for (let y = 0; y < raster; y++) {
336
+ raw[y * (raster + 1)] = 0;
337
+ for (let x = 0; x < raster; x++) {
338
+ const v = field[y * raster + x];
339
+ raw[y * (raster + 1) + 1 + x] = Math.round(Math.min(1, Math.max(0, v)) * 255);
340
+ }
341
+ }
342
+ const ihdr = new Uint8Array(13);
343
+ const view = new DataView(ihdr.buffer);
344
+ view.setUint32(0, raster);
345
+ view.setUint32(4, raster);
346
+ ihdr[8] = 8; // bit depth
347
+ ihdr[9] = 0; // greyscale
348
+ const parts = [
349
+ new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
350
+ chunk('IHDR', ihdr),
351
+ chunk('IDAT', storedDeflate(raw)),
352
+ chunk('IEND', new Uint8Array(0)),
353
+ ];
354
+ const png = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
355
+ let at = 0;
356
+ for (const p of parts) { png.set(p, at); at += p.length; }
357
+ return `data:image/png;base64,${base64(png)}`;
358
+ }
359
+
360
+ /** Keyed on the dials the field is built from, so the fill, the icons and the
361
+ preview share one build and a dial that has nothing to do with coverage
362
+ never triggers another. The tab holds four of them at once, the field at
363
+ three stages beside the finished one, with headroom over that. */
364
+ const KEYS = [
365
+ 'maskBlob', 'maskOctaves', 'maskGrain', 'maskOutputMin', 'maskOutputMax',
366
+ 'maskPosterize', 'maskSoftness',
367
+ ] as const;
368
+
369
+ const CACHE_MAX = 6;
370
+ const cache = new Map<string, string>();
371
+
372
+ /** The field as a `url(...)` for `mask-image`. */
373
+ export function buildMaskUri(s: SketchSettings, seed = 9, through?: MaskStage): string {
374
+ const key = [...KEYS.map((k) => s[k]), seed, through ?? 'all'].join('|');
375
+ const hit = cache.get(key);
376
+ if (hit) return hit;
377
+
378
+ const { field, raster } = buildMaskField(s, seed, through);
379
+ // Single quotes: base64 has none of its own, and the value has to survive
380
+ // being set from an inline style attribute as well as from a stylesheet.
381
+ const uri = `url('${fieldToPng(field, raster)}')`;
382
+ if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value!);
383
+ cache.set(key, uri);
384
+ return uri;
385
+ }