@motion-proto/live-tokens 0.65.1 → 0.67.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 (39) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/dist-plugin/{chunk-NDJJORKJ.js → chunk-T4PMCFJN.js} +220 -114
  3. package/dist-plugin/index.cjs +253 -129
  4. package/dist-plugin/index.js +27 -9
  5. package/dist-plugin/migrateData/index.cjs +220 -114
  6. package/dist-plugin/migrateData/index.js +1 -1
  7. package/package.json +8 -1
  8. package/src/editor/bootstrap.ts +12 -0
  9. package/src/editor/core/productionPulse.ts +1 -1
  10. package/src/editor/core/sketch/index.ts +78 -21
  11. package/src/editor/core/sketch/maskField.ts +200 -62
  12. package/src/editor/core/sketch/sketchLayer.ts +10 -4
  13. package/src/editor/core/sketch/sketchRegistry.ts +98 -0
  14. package/src/editor/core/sketch/sketchStore.ts +110 -31
  15. package/src/editor/core/sketch/sketchStyleService.ts +3 -0
  16. package/src/editor/core/sketch/sketchStyles.ts +63 -96
  17. package/src/editor/core/themes/themeInit.ts +4 -13
  18. package/src/editor/docs/content/sketch-mode.md +82 -13
  19. package/src/editor/docs/content.generated.ts +1 -1
  20. package/src/editor/ui/sketch/SketchTab.svelte +219 -74
  21. package/src/live-tokens/data/sketch-styles/dashed.json +47 -0
  22. package/src/live-tokens/data/sketch-styles/dry.json +47 -0
  23. package/src/live-tokens/data/sketch-styles/hatched.json +47 -0
  24. package/src/live-tokens/data/sketch-styles/marker.json +47 -0
  25. package/src/live-tokens/data/sketch-styles/napkin.json +47 -0
  26. package/src/live-tokens/data/sketch-styles/pencil.json +47 -0
  27. package/src/live-tokens/data/sketch-styles/whiteboard.json +47 -0
  28. package/src/live-tokens/data/themes/autumn.json +4 -4
  29. package/src/live-tokens/data/themes/halloween.json +4 -4
  30. package/src/live-tokens/data/themes/midnight-study.json +4 -4
  31. package/src/live-tokens/data/themes/ocean.json +4 -4
  32. package/src/live-tokens/data/themes/royal-velvet.json +4 -4
  33. package/src/live-tokens/data/themes/sketchy.json +4 -4
  34. package/src/live-tokens/data/themes/spring-meadow.json +4 -4
  35. package/src/live-tokens/data/themes/sunset.json +4 -4
  36. package/src/system/components/Button.svelte +2 -2
  37. package/src/system/components/IconButton.svelte +2 -2
  38. package/src/system/styles/fonts.css +6 -6
  39. package/template/src/main.ts +14 -1
@@ -1,18 +1,42 @@
1
- import { derived, type Readable } from 'svelte/store';
2
- import { SKETCH_STYLES } from './sketchStyles';
3
- import { selectSketchStyle, setSketchEnabled, sketchEnabled, sketchStyleName } from './sketchStore';
1
+ import { derived, get, type Readable } from 'svelte/store';
2
+ import { THEME_SKETCH_ID } from './sketchStyles';
3
+ import { lookById, sketchLooks, type SketchLook } from './sketchRegistry';
4
+ import {
5
+ sameLook,
6
+ selectSketchStyle,
7
+ selectThemeSketchStyle,
8
+ setSketchEnabled,
9
+ sketchEnabled,
10
+ sketchStyleName,
11
+ themeSketchStyle,
12
+ } from './sketchStore';
4
13
 
5
- export interface SketchLook {
6
- /** What `setSketch` takes. */
7
- id: string;
8
- label: string;
9
- blurb: string;
10
- }
14
+ export { sketchLooks, registerSketchLook } from './sketchRegistry';
15
+ export type { SketchLook, SketchLookSource, RegisterSketchLookInput } from './sketchRegistry';
16
+
17
+ /** A look as one picker row. The theme's own look is offered this way too, and
18
+ it belongs to no pool entry, so the fields only a pool entry has are off. */
19
+ export type SketchLookRow = Omit<SketchLook, 'settings' | 'source'>;
11
20
 
12
- /** The shipped sketchstyles. A picker adds its own "None" row: off is a state
13
- of the effect, not one of the looks. */
14
- export const SKETCH_LOOKS: readonly SketchLook[] = Object.entries(SKETCH_STYLES).map(
15
- ([id, style]) => ({ id, label: style.label, blurb: style.blurb }),
21
+ /**
22
+ * The look the open theme carries, as one more row for a picker: same shape as
23
+ * a shipped look, and `setSketch` takes its id like any other. Null when the
24
+ * theme carries no sketchstyle, and null when what it carries IS one of the
25
+ * shipped looks, since that look's own row already names it.
26
+ *
27
+ * Without this row the theme's look is a one-way door: a visitor lands on it,
28
+ * picks Pencil, and nothing can take them back. It is also the only thing that
29
+ * can name that look, which no shipped label can do honestly. A theme tuned off
30
+ * `marker` still carries the label "Marker", so a row built from the style's
31
+ * own label would sit beside the shipped Marker claiming to be it.
32
+ */
33
+ export const themeSketchLook: Readable<SketchLookRow | null> = derived(
34
+ [themeSketchStyle, sketchLooks],
35
+ ([style, looks]) => {
36
+ if (!style) return null;
37
+ if (looks.some((look) => sameLook(look.settings, style))) return null;
38
+ return { id: THEME_SKETCH_ID, label: 'Theme', blurb: 'The look this theme carries.' };
39
+ },
16
40
  );
17
41
 
18
42
  /**
@@ -27,20 +51,20 @@ export const SKETCH_LOOKS: readonly SketchLook[] = Object.entries(SKETCH_STYLES)
27
51
  */
28
52
  export type SketchPick =
29
53
  | { state: 'off' }
30
- | { state: 'look'; look: SketchLook }
54
+ | { state: 'look'; look: SketchLookRow }
31
55
  | { state: 'adjusted' };
32
56
 
33
57
  export const sketchPick: Readable<SketchPick> = derived(
34
- [sketchEnabled, sketchStyleName],
35
- ([on, name]): SketchPick => {
58
+ [sketchEnabled, sketchStyleName, sketchLooks, themeSketchLook],
59
+ ([on, name, looks, themeLook]): SketchPick => {
36
60
  if (!on) return { state: 'off' };
37
- const look = SKETCH_LOOKS.find((l) => l.id === name);
38
- return look ? { state: 'look', look } : { state: 'adjusted' };
61
+ const look = looks.find((l) => l.id === name) ?? (themeLook?.id === name ? themeLook : undefined);
62
+ return look ? { state: 'look', look: { id: look.id, label: look.label, blurb: look.blurb } } : { state: 'adjusted' };
39
63
  },
40
64
  );
41
65
 
42
66
  /**
43
- * Draw the page with one of the shipped looks, or `null` for none.
67
+ * Draw the page with one of the looks in the pool, or `null` for none.
44
68
  *
45
69
  * The only supported way for a consumer to drive the effect. Reaching for
46
70
  * `applySketchLayer` instead paints a stylesheet the store does not know it
@@ -52,8 +76,16 @@ export function setSketch(id: string | null): void {
52
76
  setSketchEnabled(false);
53
77
  return;
54
78
  }
55
- if (!(id in SKETCH_STYLES)) {
56
- throw new Error(`Unknown sketchstyle "${id}". Ids come from SKETCH_LOOKS.`);
79
+ if (id === THEME_SKETCH_ID) {
80
+ if (!get(themeSketchStyle)) {
81
+ throw new Error('No theme sketchstyle to draw with. `themeSketchLook` is null unless a theme carries one.');
82
+ }
83
+ selectThemeSketchStyle();
84
+ setSketchEnabled(true);
85
+ return;
86
+ }
87
+ if (!lookById(id)) {
88
+ throw new Error(`Unknown sketchstyle "${id}". Ids come from sketchLooks and themeSketchLook.`);
57
89
  }
58
90
  selectSketchStyle(id);
59
91
  setSketchEnabled(true);
@@ -68,3 +100,28 @@ export function setSketch(id: string | null): void {
68
100
  * key directly is not an option worth offering — it is ours to rename.
69
101
  */
70
102
  export { hasPersistedSketchState } from './sketchStore';
103
+
104
+ /**
105
+ * Draw the page with the sketchstyle a theme carries, unless this browser has
106
+ * already decided for itself.
107
+ *
108
+ * The route from a saved theme to a built page. Hand it the theme's
109
+ * `sketchStyle` field, raw: a built site has no theme API, so it reads its own
110
+ * theme JSON and this hydrates what it finds. Absent, `null`, or anything that
111
+ * is not an object all mean the same thing, which is no sketch.
112
+ *
113
+ * Call it before mounting, the way dev boot does (`bootstrap.ts` awaits
114
+ * `initializeTheme` first), so the look is up on the first frame rather than
115
+ * arriving over a crisp page.
116
+ *
117
+ * A visitor who has recorded a pick of their own keeps it, None included: this
118
+ * seeds an undecided browser and never overwrites a decided one, so it is safe
119
+ * to call on every boot. `themeSketchLook` is populated either way, so a picker
120
+ * can offer the theme's look as a row whether or not this painted it.
121
+ */
122
+ export { seedSketchFromTheme } from './sketchStore';
123
+
124
+ /** The dial set a theme's `sketchStyle` field holds, for a consumer typing the
125
+ value it pulled out of its own theme JSON. `seedSketchFromTheme` takes it
126
+ raw, so nothing has to be cast to hand it over. */
127
+ export type { SketchStyle } from './sketchStyles';
@@ -21,16 +21,120 @@
21
21
  */
22
22
  import type { SketchStyle } from './sketchStyles';
23
23
 
24
- /** What the tile spans in page px. Blobs are fitted a whole number to the
25
- tile, which is what lets the lattice wrap; the dial's px reading is the
26
- nearest whole fit, never more than a few px off what it says. */
27
- export const MASK_TILE = 600;
24
+ /** Samples across the tile, on both axes. Fixed, whatever the blobs measure:
25
+ the field is blobs and gradients, so the browser stretching the tile back to
26
+ page px costs nothing visible, and the data URI stays one size. */
27
+ const RASTER = 300;
28
28
 
29
- /** Page px per sample. The field is blobs and gradients, so painting it at half
30
- resolution and letting the browser scale it up costs nothing visible and
31
- quarters both the work and the data URI. */
32
- const SAMPLE_PX = 2;
33
- const RASTER = MASK_TILE / SAMPLE_PX;
29
+ /** Blobs per tile, and so how far the field runs before it repeats. Held in
30
+ this band rather than taken from the blob size alone: under two the lattice
31
+ is smaller than a pattern, and over forty a blob is down to seven samples
32
+ across and the finest octave starts to alias. */
33
+ const CELLS = { min: 2, max: 40 };
34
+
35
+ /** The tile the band is aimed at, in page px. A blob wants enough of them
36
+ around it that the repeat is not a thing you can see. */
37
+ const TARGET_TILE = 600;
38
+
39
+ /** How far the cell count is allowed to drift from the tile it is aimed at to
40
+ buy a degree of rotation, as degrees per e-fold. A tilt the dial cannot
41
+ reach is worth more than a tile at exactly 600px. */
42
+ const CELL_DRIFT = 12;
43
+
44
+ type Vec = readonly [number, number];
45
+
46
+ /**
47
+ * Where the page's own axes land on the noise lattice, and the tile that puts
48
+ * them there.
49
+ *
50
+ * The field wraps by reducing every lattice point modulo the pair, so both
51
+ * vectors have to be whole cells. Axis-aligned that is no constraint at all —
52
+ * a tile some whole number of blobs wide — but a tilted pattern only meets
53
+ * itself if the turn lands the page's axes back on the lattice, which most
54
+ * angles do not. The dial is answered with the nearest pair that does, and
55
+ * `angle` reports what that came out as rather than what was asked for.
56
+ *
57
+ * Rotation is dropped when the two blob sizes match: the field under it is the
58
+ * same in every direction, so turning it only draws a different tile of the
59
+ * same thing, and the fit is exact left alone.
60
+ */
61
+ export interface MaskLattice {
62
+ /** The page's x axis in cells, then its y axis. */
63
+ vx: Vec;
64
+ vy: Vec;
65
+ /** The tile in page px. */
66
+ w: number;
67
+ h: number;
68
+ /** The turn the pair actually describes, in degrees. */
69
+ angle: number;
70
+ }
71
+
72
+ export function maskLattice(s: SketchStyle): MaskLattice {
73
+ const ask = s.maskBlobX === s.maskBlobY ? 0 : s.maskAngle;
74
+ const rad = ask * (Math.PI / 180);
75
+ const cos = Math.cos(rad), sin = Math.sin(rad);
76
+ const dx: Vec = [cos / s.maskBlobX, -sin / s.maskBlobY];
77
+ const dy: Vec = [sin / s.maskBlobX, cos / s.maskBlobY];
78
+ const vx = fit(dx, ask, (v) => turnOf(-v[1] * s.maskBlobY, v[0] * s.maskBlobX));
79
+ let vy = fit(dy, ask, (v) => turnOf(v[0] * s.maskBlobX, v[1] * s.maskBlobY));
80
+ // Two vectors along one line describe no tile at all. The turn of the first
81
+ // is the whole look, so the second is the one that gives way.
82
+ if (vx[0] * vy[1] - vx[1] * vy[0] === 0) vy = [-vx[1], vx[0]];
83
+ return {
84
+ vx,
85
+ vy,
86
+ w: Math.hypot(...vx) / Math.hypot(...dx),
87
+ h: Math.hypot(...vy) / Math.hypot(...dy),
88
+ angle: turnOf(-vx[1] * s.maskBlobY, vx[0] * s.maskBlobX),
89
+ };
90
+ }
91
+
92
+ /** A direction as a turn from level, 0 to 180: a stretch has a slant, not a
93
+ heading, so pointing back the way it came is the same slant. */
94
+ function turnOf(rise: number, run: number): number {
95
+ return (((Math.atan2(rise, run) * (180 / Math.PI)) % 180) + 180) % 180;
96
+ }
97
+
98
+ /**
99
+ * The whole-cell vector that lands the tile nearest the turn the dial asks for,
100
+ * weighing that against the number of cells it spends getting there.
101
+ *
102
+ * Scored on the turn the page ends up showing, never on the one the vector
103
+ * makes against the lattice. The two part company as soon as the blob sizes
104
+ * do: at seven to one, a vector a degree off square on the lattice comes out
105
+ * eight degrees off on the page, and a fit that reads the lattice believes it
106
+ * has landed.
107
+ */
108
+ function fit(d: Vec, ask: number, turn: (v: Vec) => number): Vec {
109
+ const length = Math.hypot(...d);
110
+ const unit: Vec = [d[0] / length, d[1] / length];
111
+ const target = Math.min(CELLS.max, Math.max(CELLS.min, TARGET_TILE * length));
112
+ let best: Vec = [1, 0];
113
+ let bestCost = Infinity;
114
+ for (let cells = CELLS.min; cells <= CELLS.max; cells++) {
115
+ const v: Vec = [Math.round(unit[0] * cells), Math.round(unit[1] * cells)];
116
+ const spent = Math.hypot(...v);
117
+ if (spent === 0) continue;
118
+ const off = Math.abs(turn(v) - ask);
119
+ const cost = Math.min(off, 180 - off) + CELL_DRIFT * Math.abs(Math.log(spent / target));
120
+ if (cost < bestCost) { bestCost = cost; best = v; }
121
+ }
122
+ return best;
123
+ }
124
+
125
+ /**
126
+ * What the tile spans in page px, as `mask-size` takes it.
127
+ *
128
+ * A whole number of blobs along each of the page's axes, at exactly the size
129
+ * the dials state, so the number on the Scale dial is the number of pixels a
130
+ * blob measures on the page. The tile used to be a fixed 600px with the blobs
131
+ * fitted to it, which meant the dial could only reach the sizes that divide
132
+ * 600: it said 250px and painted 300px, and there was nothing above that.
133
+ */
134
+ export function maskTile(s: SketchStyle): { w: number; h: number } {
135
+ const { w, h } = maskLattice(s);
136
+ return { w, h };
137
+ }
34
138
 
35
139
  /** The stage previews, in the order the field is built. */
36
140
  export type MaskStage = 'noise' | 'levels' | 'blur';
@@ -48,8 +152,9 @@ function mulberry32(seed: number): () => number {
48
152
  }
49
153
 
50
154
  /**
51
- * Perlin noise on a lattice that wraps every `period` cells, so a tile drawn
52
- * from it meets itself at both edges. Returns roughly -0.7 to 0.7.
155
+ * Perlin noise on a lattice that wraps on the tile's own two vectors, so a tile
156
+ * drawn from it meets itself at both edges however the pattern is turned.
157
+ * Returns roughly -0.7 to 0.7.
53
158
  *
54
159
  * Hand-rolled because the wrap is the whole requirement and no established
55
160
  * noise package offers one: `simplex-noise` and its neighbours all sample an
@@ -57,7 +162,8 @@ function mulberry32(seed: number): () => number {
57
162
  * noise, and it degenerates at the ratio this effect works at — a tile only
58
163
  * four or five blobs across makes those circles smaller than one noise feature,
59
164
  * and the field comes back as a regular grid of dots instead of cloud. A
60
- * lattice takes its period as an integer and wraps exactly at any size.
165
+ * lattice wraps exactly at any size, and on any pair of whole-cell vectors,
166
+ * which is what lets the pattern be turned.
61
167
  *
62
168
  * Gradients are drawn from the whole circle, one per lattice point, rather than
63
169
  * from the eight compass directions the textbook version uses. Eight leaves
@@ -65,7 +171,16 @@ function mulberry32(seed: number): () => number {
65
171
  */
66
172
  const GRAD_COUNT = 256;
67
173
 
68
- function makePerlin(seed: number): (x: number, y: number, period: number) => number {
174
+ /** One octave's tile vectors, and the determinant that reduces against them. */
175
+ interface Wrap { ax: number; ay: number; bx: number; by: number; det: number }
176
+
177
+ function wrapOn(vx: Vec, vy: Vec, octave: number): Wrap {
178
+ const ax = vx[0] * octave, ay = vx[1] * octave;
179
+ const bx = vy[0] * octave, by = vy[1] * octave;
180
+ return { ax, ay, bx, by, det: ax * by - bx * ay };
181
+ }
182
+
183
+ function makePerlin(seed: number): (x: number, y: number, w: Wrap) => number {
69
184
  const rand = mulberry32(seed);
70
185
  const perm = new Uint8Array(512);
71
186
  const src = new Uint8Array(256);
@@ -85,21 +200,26 @@ function makePerlin(seed: number): (x: number, y: number, period: number) => num
85
200
  const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);
86
201
  const lerp = (t: number, a: number, b: number) => a + t * (b - a);
87
202
 
88
- return (x, y, period) => {
203
+ /** The gradient at a lattice point, taken at that point's stand-in inside the
204
+ tile. Two points a tile apart reduce to the same stand-in and so carry the
205
+ same gradient, which is the whole of the wrap: it holds on any pair of
206
+ vectors, not only on the two an axis-aligned tile runs along. */
207
+ const g = (i: number, j: number, w: Wrap, dx: number, dy: number) => {
208
+ const alpha = Math.floor((i * w.by - w.bx * j) / w.det);
209
+ const beta = Math.floor((w.ax * j - i * w.ay) / w.det);
210
+ const cx = i - alpha * w.ax - beta * w.bx;
211
+ const cy = j - alpha * w.ay - beta * w.by;
212
+ const h = perm[(perm[cx & 255] + cy) & 255];
213
+ return gx[h] * dx + gy[h] * dy;
214
+ };
215
+
216
+ return (x, y, w) => {
89
217
  const x0 = Math.floor(x), y0 = Math.floor(y);
90
218
  const xf = x - x0, yf = y - y0;
91
- const p = Math.max(1, Math.round(period));
92
- const xi = ((x0 % p) + p) % p, yi = ((y0 % p) + p) % p;
93
- const xi1 = (xi + 1) % p, yi1 = (yi + 1) % p;
94
-
95
- const g = (cx: number, cy: number, dx: number, dy: number) => {
96
- const h = perm[(perm[cx & 255] + cy) & 255];
97
- return gx[h] * dx + gy[h] * dy;
98
- };
99
219
  const u = fade(xf), v = fade(yf);
100
220
  return lerp(v,
101
- lerp(u, g(xi, yi, xf, yf), g(xi1, yi, xf - 1, yf)),
102
- lerp(u, g(xi, yi1, xf, yf - 1), g(xi1, yi1, xf - 1, yf - 1)));
221
+ lerp(u, g(x0, y0, w, xf, yf), g(x0 + 1, y0, w, xf - 1, yf)),
222
+ lerp(u, g(x0, y0 + 1, w, xf, yf - 1), g(x0 + 1, y0 + 1, w, xf - 1, yf - 1)));
103
223
  };
104
224
  }
105
225
 
@@ -111,22 +231,31 @@ function makePerlin(seed: number): (x: number, y: number, period: number) => num
111
231
  * wherever the octave crossed zero, which is the marbled look; plain sum is
112
232
  * cloud.
113
233
  */
114
- function rawField(s: SketchStyle, seed: number, raster: number, cells: number): Float32Array {
234
+ function rawField(
235
+ s: SketchStyle, seed: number, raster: number, lattice: MaskLattice,
236
+ ): Float32Array {
115
237
  const noise = makePerlin(seed);
116
238
  const veined = s.maskGrain === 'turbulence';
117
239
  const octaves = Math.max(1, Math.round(s.maskOctaves));
118
240
  const out = new Float32Array(raster * raster);
119
- const step = cells / raster;
241
+ const wraps = Array.from({ length: octaves }, (_, o) => wrapOn(lattice.vx, lattice.vy, 1 << o));
120
242
 
121
243
  let weightSum = 0;
122
244
  for (let o = 0; o < octaves; o++) weightSum += 1 / (1 << o);
123
245
 
246
+ // A sample's place in the tile, carried onto the lattice by the tile's own
247
+ // two vectors. Square on, they are (cells, 0) and (0, cells) and this is the
248
+ // walk it always was; turned, they lean, and so does the field.
124
249
  for (let y = 0; y < raster; y++) {
250
+ const ty = y / raster;
125
251
  for (let x = 0; x < raster; x++) {
252
+ const tx = x / raster;
253
+ const a = tx * lattice.vx[0] + ty * lattice.vy[0];
254
+ const b = tx * lattice.vx[1] + ty * lattice.vy[1];
126
255
  let sum = 0;
127
256
  for (let o = 0; o < octaves; o++) {
128
257
  const f = 1 << o;
129
- const n = noise(x * step * f, y * step * f, cells * f);
258
+ const n = noise(a * f, b * f, wraps[o]);
130
259
  sum += (veined ? Math.abs(n) : n) / f;
131
260
  }
132
261
  out[y * raster + x] = sum / weightSum;
@@ -200,50 +329,53 @@ function posterise(f: Float32Array, steps: number): Float32Array {
200
329
 
201
330
  Three boxes at the width the filter spec derives land on a gaussian of a
202
331
  given deviation, which is what makes the dial's px the px it gets. But a box
203
- radius is a whole number of samples and a sample is two page px, so rounding
204
- to one put the dial on a 2px ladder with a dead zone at the bottom: every
205
- setting under 2.5px came out perfectly sharp, and everything from 2.5 to
206
- 4.2px came out identical. Mixing the two radii either side of the exact one
207
- puts the dial back on a continuous scale, and radius zero is the field
208
- itself, so the bottom of the travel eases in instead of switching on. */
209
- function blur(f: Float32Array, raster: number, std: number): Float32Array {
332
+ radius is a whole number of samples, so rounding to one put the dial on a
333
+ ladder with a dead zone at the bottom: every small setting came out
334
+ perfectly sharp and a spread of them came out identical. Mixing the two
335
+ radii either side of the exact one puts the dial back on a continuous scale,
336
+ and radius zero is the field itself, so the bottom of the travel eases in
337
+ instead of switching on.
338
+
339
+ Run one axis at a time, since a sample is a different number of page px
340
+ across than it is down whenever the two blob sizes disagree. A gaussian is
341
+ separable, so a pass along each axis is the same blur the square one gave. */
342
+ function blur(f: Float32Array, raster: number, stdX: number, stdY: number): Float32Array {
343
+ return blurAxis(blurAxis(f, raster, stdX, 1), raster, stdY, raster);
344
+ }
345
+
346
+ function blurAxis(f: Float32Array, n: number, std: number, stride: number): Float32Array {
210
347
  const exact = (std * 3 * Math.sqrt(2 * Math.PI) / 4 - 1) / 2;
211
348
  if (exact <= 0) return f;
212
349
  const lower = Math.floor(exact);
213
350
  const mix = exact - lower;
214
- const low = lower < 1 ? f : boxes(f, raster, lower);
351
+ const low = lower < 1 ? f : boxes(f, n, lower, stride);
215
352
  if (mix < 1e-6) return low;
216
- const high = boxes(f, raster, lower + 1);
353
+ const high = boxes(f, n, lower + 1, stride);
217
354
  const out = new Float32Array(f.length);
218
355
  for (let i = 0; i < f.length; i++) out[i] = low[i] + (high[i] - low[i]) * mix;
219
356
  return out;
220
357
  }
221
358
 
222
- function boxes(f: Float32Array, raster: number, radius: number): Float32Array {
359
+ function boxes(f: Float32Array, n: number, radius: number, stride: number): Float32Array {
223
360
  let cur = f;
224
- for (let pass = 0; pass < 3; pass++) cur = boxPass(cur, raster, radius);
361
+ for (let pass = 0; pass < 3; pass++) cur = boxPass(cur, n, radius, stride);
225
362
  return cur;
226
363
  }
227
364
 
228
- function boxPass(f: Float32Array, n: number, radius: number): Float32Array {
229
- const mid = new Float32Array(f.length);
230
- const width = radius * 2 + 1;
231
- for (let y = 0; y < n; y++) {
232
- const row = y * n;
233
- let sum = 0;
234
- for (let i = -radius; i <= radius; i++) sum += f[row + ((i % n) + n) % n];
235
- for (let x = 0; x < n; x++) {
236
- mid[row + x] = sum / width;
237
- sum += f[row + ((x + radius + 1) % n)] - f[row + ((x - radius + n) % n)];
238
- }
239
- }
365
+ /** One pass along a single axis. `stride` is 1 across a row and the raster width
366
+ down a column, so one sliding window serves both. */
367
+ function boxPass(f: Float32Array, n: number, radius: number, stride: number): Float32Array {
240
368
  const out = new Float32Array(f.length);
241
- for (let x = 0; x < n; x++) {
369
+ const lineStep = stride === 1 ? n : 1;
370
+ const width = radius * 2 + 1;
371
+ for (let line = 0; line < n; line++) {
372
+ const base = line * lineStep;
373
+ const at = (i: number) => f[base + ((((i % n) + n) % n)) * stride];
242
374
  let sum = 0;
243
- for (let i = -radius; i <= radius; i++) sum += mid[(((i % n) + n) % n) * n + x];
244
- for (let y = 0; y < n; y++) {
245
- out[y * n + x] = sum / width;
246
- sum += mid[((y + radius + 1) % n) * n + x] - mid[((y - radius + n) % n) * n + x];
375
+ for (let i = -radius; i <= radius; i++) sum += at(i);
376
+ for (let i = 0; i < n; i++) {
377
+ out[base + i * stride] = sum / width;
378
+ sum += at(i + radius + 1) - at(i - radius);
247
379
  }
248
380
  }
249
381
  return out;
@@ -257,11 +389,12 @@ function boxPass(f: Float32Array, n: number, radius: number): Float32Array {
257
389
  let rawCache: { key: string; field: Float32Array } | null = null;
258
390
 
259
391
  function cachedRaw(s: SketchStyle, seed: number): Float32Array {
260
- const key = [s.maskBlob, s.maskOctaves, s.maskGrain, seed].join('|');
261
- if (rawCache?.key !== key) {
262
- const cells = Math.max(1, Math.round(MASK_TILE / s.maskBlob));
263
- rawCache = { key, field: rawField(s, seed, RASTER, cells) };
264
- }
392
+ const lattice = maskLattice(s);
393
+ // Keyed on the tile vectors rather than the blob sizes: most of the Scale
394
+ // dial's travel paints the same lattice at another size, and the field is
395
+ // the expensive half of the build.
396
+ const key = [...lattice.vx, ...lattice.vy, s.maskOctaves, s.maskGrain, seed].join('|');
397
+ if (rawCache?.key !== key) rawCache = { key, field: rawField(s, seed, RASTER, lattice) };
265
398
  return rawCache.field;
266
399
  }
267
400
 
@@ -284,7 +417,11 @@ export function buildMaskField(
284
417
  );
285
418
  if (through === 'levels') return { field: levelled, raster: RASTER };
286
419
 
287
- return { field: blur(levelled, RASTER, s.maskSoftness / SAMPLE_PX), raster: RASTER };
420
+ const tile = maskTile(s);
421
+ return {
422
+ field: blur(levelled, RASTER, s.maskSoftness / (tile.w / RASTER), s.maskSoftness / (tile.h / RASTER)),
423
+ raster: RASTER,
424
+ };
288
425
  }
289
426
 
290
427
  /* ------------------------------------------------------------------ png --- */
@@ -398,7 +535,8 @@ export function fieldToPng(field: Float32Array, raster: number): string {
398
535
  never triggers another. The tab holds four of them at once, the field at
399
536
  three stages beside the finished one, with headroom over that. */
400
537
  const KEYS = [
401
- 'maskBlob', 'maskOctaves', 'maskGrain', 'maskOutputMin', 'maskOutputMax',
538
+ 'maskBlobX', 'maskBlobY', 'maskAngle', 'maskOctaves', 'maskGrain',
539
+ 'maskOutputMin', 'maskOutputMax',
402
540
  'maskPosterize', 'maskSoftness',
403
541
  ] as const;
404
542
 
@@ -15,7 +15,7 @@
15
15
  * data-sketch present = on, absent = off
16
16
  */
17
17
  import { getSyncedDocuments } from '../cssVarSync';
18
- import { buildMaskUri, MASK_TILE } from './maskField';
18
+ import { buildMaskUri, maskTile } from './maskField';
19
19
  import type { SketchStyle } from './sketchStyles';
20
20
 
21
21
  const DEFS_ATTR = 'data-sketch-defs';
@@ -664,6 +664,10 @@ export function buildStylesheet(s: SketchStyle): string {
664
664
  // can no longer flatten the corners of every other one.
665
665
  const registrations = `@property --sketch-radius{syntax:"<length>";inherits:true;initial-value:0px;}`;
666
666
 
667
+ // Two sizes, not one: the field is painted at whatever the blob dials
668
+ // multiply out to, and they part company as soon as the pair is unlinked.
669
+ const tile = maskTile(s);
670
+
667
671
  const vars =
668
672
  `${on}{` +
669
673
  `--sketch-stroke-width:${s.strokeWidth}px;` +
@@ -674,7 +678,8 @@ export function buildStylesheet(s: SketchStyle): string {
674
678
  `--sketch-fill-filter:url(#${ID}-fill-0);` +
675
679
  `--sketch-stroke-filter:url(#${ID}-stroke-0);` +
676
680
  `--sketch-mask:${s.maskOn || s.iconMaskOn ? buildMaskUri(s) : 'none'};` +
677
- `--sketch-mask-tile:${MASK_TILE}px;` +
681
+ `--sketch-mask-tile-w:${Number(tile.w.toFixed(2))}px;` +
682
+ `--sketch-mask-tile-h:${Number(tile.h.toFixed(2))}px;` +
678
683
  `--sketch-icon-mask-tile:${Math.round(s.iconMaskScale * 100)}%;` +
679
684
  // Named here rather than on the icons themselves, so an ancestor asking
680
685
  // for the soft bank resolves it against a value it can actually see.
@@ -852,7 +857,7 @@ export function buildStylesheet(s: SketchStyle): string {
852
857
  // width of the bleed; every other part's token is --shadow-none anyway.
853
858
  (bleed === 0 ? 'box-shadow:var(--sketch-shadow, none);' : '') +
854
859
  `filter:var(--sketch-fill-filter);` +
855
- (s.maskOn ? coverage('var(--sketch-mask-tile) var(--sketch-mask-tile)', '--sketch-mask-pos') : '') +
860
+ (s.maskOn ? coverage('var(--sketch-mask-tile-w) var(--sketch-mask-tile-h)', '--sketch-mask-pos') : '') +
856
861
  `transform:translate(` +
857
862
  `calc(var(--sketch-jx, 0) * var(--sketch-jit-x, 0px)),` +
858
863
  `calc(var(--sketch-jy, 0) * var(--sketch-jit-y, 0px))` +
@@ -1008,7 +1013,8 @@ export function buildStylesheet(s: SketchStyle): string {
1008
1013
  // lands wholly inside a patch and comes out either untouched or gone.
1009
1014
  // Shrinking the tile puts several blotches across it, which is the same
1010
1015
  // move the icon mask makes for a glyph.
1011
- `--sketch-mask-tile:${Math.round(MASK_TILE * 0.3)}px;` +
1016
+ `--sketch-mask-tile-w:${Math.round(tile.w * 0.3)}px;` +
1017
+ `--sketch-mask-tile-h:${Math.round(tile.h * 0.3)}px;` +
1012
1018
  `--sketch-jit-rot:calc(var(--sketch-jit-rot-base) * 1.6);` +
1013
1019
  `--sketch-jit-x:calc(var(--sketch-jit-x-base) * 0.5);` +
1014
1020
  `--sketch-jit-y:calc(var(--sketch-jit-y-base) * 0.5);` +
@@ -0,0 +1,98 @@
1
+ import { derived, get, writable, type Readable } from 'svelte/store';
2
+ import { SKETCH_STYLES, hydrateSketchStyle, type SketchStyle } from './sketchStyles';
3
+
4
+ /** Where a look came from, which is what decides the affordances on offer.
5
+ `shipped` is served by the package: it has a file, but not one in this
6
+ project, so there is nothing here to save over or delete until the editor
7
+ writes a copy. `file` lives in this project's data tree, so the editor can
8
+ save over it and delete it. `registered` was handed to `bootLiveTokens`, so
9
+ it is real on a built site but owns no file the editor could touch. */
10
+ export type SketchLookSource = 'shipped' | 'file' | 'registered';
11
+
12
+ export interface SketchLook {
13
+ /** What `setSketch` takes. A saved sketchstyle's file slug is its id, so a
14
+ look picked in dev keeps working once the site is built. */
15
+ id: string;
16
+ label: string;
17
+ blurb: string;
18
+ settings: SketchStyle;
19
+ source: SketchLookSource;
20
+ }
21
+
22
+ export interface RegisterSketchLookInput {
23
+ id: string;
24
+ label: string;
25
+ blurb?: string;
26
+ settings: unknown;
27
+ /** Only `replaceRegisteredLooks` sets this. A listing carries the package's
28
+ own sketchstyles as well as the project's, and the two differ in what the
29
+ editor may do to them, so the row's origin has to survive the trip. */
30
+ source?: SketchLookSource;
31
+ }
32
+
33
+ const SHIPPED: ReadonlyMap<string, SketchLook> = new Map(
34
+ Object.entries(SKETCH_STYLES).map(([id, style]) => [
35
+ id,
36
+ { id, label: style.label, blurb: style.blurb, settings: style, source: 'shipped' as const },
37
+ ]),
38
+ );
39
+
40
+ /** Seeded with the shipped looks, then written by `registerSketchLook`. A Map
41
+ because re-setting an existing key keeps its position, so a project's own
42
+ `pencil` lands where the shipped Pencil sat rather than at the end of the
43
+ grid. */
44
+ const looks = writable(new Map(SHIPPED));
45
+
46
+ /**
47
+ * Every sketchstyle on offer: the shipped looks, plus whatever this project
48
+ * registered over and beside them. A store rather than a constant, because
49
+ * registration happens after this module is imported.
50
+ *
51
+ * A picker adds its own "None" row, since off is a state of the effect rather
52
+ * than one of the looks. A theme's own look is not here either; `themeSketchLook`
53
+ * carries that one row.
54
+ */
55
+ export const sketchLooks: Readable<SketchLook[]> = derived(looks, (m) => [...m.values()]);
56
+
57
+ export function lookById(id: string): SketchLook | undefined {
58
+ return get(looks).get(id);
59
+ }
60
+
61
+ function put(m: Map<string, SketchLook>, look: RegisterSketchLookInput, source: SketchLookSource) {
62
+ // Hydrated on the way in: a consumer hands over raw JSON it imported, so this
63
+ // is the only place a look stored under a retired dial name gets carried
64
+ // forward, the same job `seedSketchFromTheme` does for a theme.
65
+ const settings = hydrateSketchStyle(look.settings);
66
+ return m.set(look.id, {
67
+ id: look.id,
68
+ label: look.label,
69
+ blurb: look.blurb ?? settings.blurb ?? '',
70
+ settings,
71
+ source,
72
+ });
73
+ }
74
+
75
+ export function registerSketchLook(look: RegisterSketchLookInput): void {
76
+ looks.update((m) => put(new Map(m), look, 'registered'));
77
+ }
78
+
79
+ /** Ids the last file listing claimed. The sweep below removes these and nothing
80
+ else, so a look handed to `bootLiveTokens` survives the editor opening. */
81
+ let fromFiles: string[] = [];
82
+
83
+ /** The editor re-lists its files after every save and delete, so a file removed
84
+ on disk has to leave the pool too. A file that had shadowed a shipped look
85
+ hands the id back on its way out. */
86
+ export function replaceRegisteredLooks(next: RegisterSketchLookInput[]): void {
87
+ looks.update((m) => {
88
+ const out = new Map(m);
89
+ for (const id of fromFiles) {
90
+ const shipped = SHIPPED.get(id);
91
+ if (shipped) out.set(id, shipped);
92
+ else out.delete(id);
93
+ }
94
+ for (const look of next) put(out, look, look.source ?? 'file');
95
+ return out;
96
+ });
97
+ fromFiles = next.map((l) => l.id);
98
+ }