@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
@@ -2,9 +2,11 @@ import { derived, get, writable } from 'svelte/store';
2
2
  import {
3
3
  SKETCH_STYLES,
4
4
  DEFAULT_SKETCH_STYLE,
5
+ THEME_SKETCH_ID,
5
6
  hydrateSketchStyle,
6
7
  type SketchStyle,
7
8
  } from './sketchStyles';
9
+ import { lookById, replaceRegisteredLooks, sketchLooks } from './sketchRegistry';
8
10
  import {
9
11
  applySketchLayer,
10
12
  hostRoot,
@@ -101,15 +103,21 @@ function readBaseline(): SketchStyle | null {
101
103
  return null;
102
104
  }
103
105
 
104
- /** Marks a saved sketchstyle in `sketchStyleName`, so a user file named `pencil`
105
- and the shipped `pencil` stay distinguishable in one string. */
106
- export const USER_STYLE_PREFIX = 'user:';
106
+ /** Retired. Saved sketchstyles and shipped ones share one id namespace now, so
107
+ a file named `pencil` replaces the shipped Pencil rather than sitting beside
108
+ it. Stripped on read below; delete a release after that ships. */
109
+ const RETIRED_USER_PREFIX = 'user:';
107
110
 
111
+ /** Deliberately unvalidated. Looks are registered after this module is
112
+ imported, so an id it has never heard of is the normal case rather than a
113
+ fault: `selectSketchStyle` no-ops on one, and `sketchPick` already reports
114
+ a look nothing names as `adjusted`. Only a browser that has stored nothing
115
+ falls back. */
108
116
  function readStyleName(): string {
109
117
  try {
110
118
  const name = localStorage.getItem(STYLE_NAME_KEY);
111
- if (name === '' || (name && (name in SKETCH_STYLES || name.startsWith(USER_STYLE_PREFIX)))) {
112
- return name;
119
+ if (name !== null) {
120
+ return name.startsWith(RETIRED_USER_PREFIX) ? name.slice(RETIRED_USER_PREFIX.length) : name;
113
121
  }
114
122
  } catch {
115
123
  // fall through
@@ -122,8 +130,9 @@ function readStyleName(): string {
122
130
  export const sketchEnabled = writable<boolean>(readEnabled());
123
131
  export const sketchSettings = writable<SketchStyle>(readSettings());
124
132
  /** The sketchstyle the dials started from. It survives dial moves, so the grid
125
- keeps showing what the current look is closest to; empty only when nothing
126
- was picked, or the picked file was deleted. */
133
+ keeps showing what the current look is closest to. Any id in the pool, or
134
+ `THEME_SKETCH_ID` for the look the open theme carries; empty only when
135
+ nothing was picked, or the picked file was deleted. */
127
136
  export const sketchStyleName = writable<string>(readStyleName());
128
137
 
129
138
  /** The settings as the selected sketchstyle defined them. Kept beside the live
@@ -134,7 +143,7 @@ export const sketchBaseline = writable<SketchStyle | null>(readBaseline());
134
143
 
135
144
  /** Dial-set fields only. `label` and `blurb` name the sketchstyle rather than
136
145
  describe the look, and no dial writes them. */
137
- function sameLook(a: SketchStyle, b: SketchStyle): boolean {
146
+ export function sameLook(a: SketchStyle, b: SketchStyle): boolean {
138
147
  return (Object.keys(a) as (keyof SketchStyle)[])
139
148
  .filter((k) => k !== 'label' && k !== 'blurb')
140
149
  .every((k) => a[k] === b[k]);
@@ -170,9 +179,9 @@ export const themeSketchStyle = writable<SketchStyle | undefined>(undefined);
170
179
 
171
180
  /** Open a theme's sketchstyle: the dials, the on/off state, and the name
172
181
  recovered by comparison (RJC 3). Overwrites the live buffer, which
173
- is what opening a theme means everywhere else (RJC 6). Never writes a
174
- `user:` name here: a saved sketchstyle is a file this look may not have come
175
- from, and the only thing that can name one is picking it. */
182
+ is what opening a theme means everywhere else (RJC 6). The name is recovered
183
+ over the whole pool, so a theme carrying a look a saved file also holds is
184
+ named by that file rather than falling back to `THEME_SKETCH_ID`. */
176
185
  export function openThemeSketchStyle(sketchStyle: SketchStyle | undefined): void {
177
186
  themeSketchStyle.set(sketchStyle);
178
187
  if (!sketchStyle) {
@@ -181,13 +190,56 @@ export function openThemeSketchStyle(sketchStyle: SketchStyle | undefined): void
181
190
  sketchStyleName.set('');
182
191
  return;
183
192
  }
184
- const matched = (Object.keys(SKETCH_STYLES) as string[]).find((name) => sameLook(SKETCH_STYLES[name], sketchStyle));
193
+ const matched = get(sketchLooks).find((look) => sameLook(look.settings, sketchStyle))?.id;
185
194
  sketchSettings.set({ ...sketchStyle });
186
195
  sketchBaseline.set({ ...sketchStyle });
187
- sketchStyleName.set(matched ?? '');
196
+ sketchStyleName.set(matched ?? THEME_SKETCH_ID);
188
197
  sketchEnabled.set(true);
189
198
  }
190
199
 
200
+ /**
201
+ * Take the sketchstyle a theme carries as this browser's own, unless this
202
+ * browser has already decided for itself.
203
+ *
204
+ * The rule boot has always followed in dev, and the only one a built site has:
205
+ * a visitor who picked a look, or picked None, keeps it, and `themeSketchStyle`
206
+ * still learns what the theme holds so the panel can call the difference
207
+ * unsaved. Both branches set it, so a picker can offer the theme's look as a
208
+ * row either way.
209
+ *
210
+ * Takes the raw field rather than a `SketchStyle`, and hydrates it here: a
211
+ * built site reads its theme JSON straight off disk with no dev server to run
212
+ * `normalizeTheme` over it first, so this is the only place a look stored under
213
+ * a retired dial name gets carried forward. Anything that is not an object is
214
+ * the absent case, which is off (invariant 3).
215
+ */
216
+ export function seedSketchFromTheme(sketchStyle: unknown): void {
217
+ const style =
218
+ typeof sketchStyle === 'object' && sketchStyle !== null && !Array.isArray(sketchStyle)
219
+ ? hydrateSketchStyle(sketchStyle)
220
+ : undefined;
221
+ if (hasPersistedSketchState()) {
222
+ themeSketchStyle.set(style);
223
+ return;
224
+ }
225
+ openThemeSketchStyle(style);
226
+ }
227
+
228
+ /** Go back to the look the theme carries, after picking something else. The
229
+ theme's is the one look a picker can offer that this module did not ship, so
230
+ it needs a door of its own beside `selectSketchStyle`; `setSketch` gives the
231
+ two the same face. Silent when the theme carries none, the way
232
+ `selectSketchStyle` is for a name it does not know. */
233
+ export function selectThemeSketchStyle(): void {
234
+ const style = get(themeSketchStyle);
235
+ if (!style) return;
236
+ markSketchTouched();
237
+ if (get(sketchEnabled)) liveMovedSinceBake.set(true);
238
+ sketchStyleName.set(THEME_SKETCH_ID);
239
+ sketchBaseline.set({ ...style });
240
+ sketchSettings.set({ ...style });
241
+ }
242
+
191
243
  /** The live sketch differs from what the open theme carries. Presence is
192
244
  half the comparison: on with dials the theme does not hold, or off while
193
245
  the theme holds a layer, are both off the theme. */
@@ -201,14 +253,17 @@ export const sketchOffLook = derived(
201
253
  },
202
254
  );
203
255
 
204
- export function selectSketchStyle(name: string): void {
205
- const style = SKETCH_STYLES[name];
206
- if (!style) return;
256
+ /** Takes any id in the pool: shipped, or registered from a file or a consumer.
257
+ Silent for an id nothing knows, the way it has always been for an unknown
258
+ shipped name. */
259
+ export function selectSketchStyle(id: string): void {
260
+ const look = lookById(id);
261
+ if (!look) return;
207
262
  markSketchTouched();
208
263
  if (get(sketchEnabled)) liveMovedSinceBake.set(true);
209
- sketchStyleName.set(name);
210
- sketchBaseline.set({ ...style });
211
- sketchSettings.set({ ...style });
264
+ sketchStyleName.set(id);
265
+ sketchBaseline.set({ ...look.settings });
266
+ sketchSettings.set({ ...look.settings });
212
267
  }
213
268
 
214
269
  /** Saved sketchstyles, listed from the data tree. Empty until
@@ -216,17 +271,22 @@ export function selectSketchStyle(name: string): void {
216
271
  the network. */
217
272
  export const savedSketchStyles = writable<SketchStyleMeta[]>([]);
218
273
 
274
+ /** Lists the files and registers them in one gesture, so the editor's grid and
275
+ a built site's picker read the same pool. Loading every file to list them is
276
+ affordable: a sketchstyle is a few dozen numbers, and the alternative is a
277
+ grid that cannot paint a row until it is picked. */
219
278
  export async function refreshSavedSketchStyles(): Promise<void> {
220
- savedSketchStyles.set(await listSketchStyles());
221
- }
222
-
223
- export async function selectSavedSketchStyle(fileName: string): Promise<void> {
224
- const file = await loadSketchStyle(fileName);
225
- markSketchTouched();
226
- if (get(sketchEnabled)) liveMovedSinceBake.set(true);
227
- sketchStyleName.set(USER_STYLE_PREFIX + fileName);
228
- sketchBaseline.set({ ...file.settings });
229
- sketchSettings.set(file.settings);
279
+ const files = await listSketchStyles();
280
+ const loaded = await Promise.all(files.map((f) => loadSketchStyle(f.fileName)));
281
+ savedSketchStyles.set(files);
282
+ replaceRegisteredLooks(
283
+ files.map((file, i) => ({
284
+ id: file.fileName,
285
+ label: file.name || file.fileName,
286
+ settings: loaded[i].settings,
287
+ source: file.isPackage ? ('shipped' as const) : ('file' as const),
288
+ })),
289
+ );
230
290
  }
231
291
 
232
292
  /** Writes whatever the dials currently say to a named file and selects it, so
@@ -243,15 +303,34 @@ export async function saveCurrentSketchStyle(name: string): Promise<string> {
243
303
  await refreshSavedSketchStyles();
244
304
  sketchSettings.set(settings);
245
305
  sketchBaseline.set({ ...settings });
246
- sketchStyleName.set(USER_STYLE_PREFIX + fileName);
306
+ sketchStyleName.set(fileName);
247
307
  return fileName;
248
308
  }
249
309
 
310
+ /** Overwrite the selected saved sketchstyle. The file name comes from the
311
+ selection rather than from re-slugifying the label, because the two can
312
+ disagree: a file hand-edited to a new display name would otherwise be saved
313
+ beside itself under a fresh slug instead of over itself. The label comes
314
+ from the look for the same reason, so the name the grid shows survives.
315
+
316
+ No `markSketchTouched`: the button only lights once a dial has moved, and
317
+ every dial goes through `updateSketchSettings`, which marks it. */
318
+ export async function saveSelectedSketchStyle(): Promise<void> {
319
+ const look = lookById(get(sketchStyleName));
320
+ if (look?.source !== 'file') throw new Error('No saved sketchstyle is selected');
321
+ const settings = { ...get(sketchSettings) };
322
+ await saveSketchStyle(look.id, look.label, settings);
323
+ await refreshSavedSketchStyles();
324
+ // Re-baselining is what disables the button again and returns the readout
325
+ // from "Modified from X" to the saved blurb.
326
+ sketchBaseline.set(settings);
327
+ }
328
+
250
329
  export async function deleteSavedSketchStyle(fileName: string): Promise<void> {
251
330
  await deleteSketchStyle(fileName);
252
331
  await refreshSavedSketchStyles();
253
332
  // The dials keep their values; only the name stops naming a file that exists.
254
- if (get(sketchStyleName) === USER_STYLE_PREFIX + fileName) {
333
+ if (get(sketchStyleName) === fileName) {
255
334
  sketchStyleName.set('');
256
335
  sketchBaseline.set(null);
257
336
  }
@@ -12,6 +12,9 @@ export interface SketchStyleMeta {
12
12
  name: string;
13
13
  fileName: string;
14
14
  updatedAt: string;
15
+ /** Served from the package rather than this project, so there is no local
16
+ file to write over or delete. Saving over one creates that file. */
17
+ isPackage: boolean;
15
18
  }
16
19
 
17
20
  const BASE = `${API_BASE}/sketch-styles`;
@@ -1,3 +1,11 @@
1
+ import pencil from '../../../live-tokens/data/sketch-styles/pencil.json';
2
+ import marker from '../../../live-tokens/data/sketch-styles/marker.json';
3
+ import whiteboard from '../../../live-tokens/data/sketch-styles/whiteboard.json';
4
+ import hatched from '../../../live-tokens/data/sketch-styles/hatched.json';
5
+ import dashed from '../../../live-tokens/data/sketch-styles/dashed.json';
6
+ import napkin from '../../../live-tokens/data/sketch-styles/napkin.json';
7
+ import dry from '../../../live-tokens/data/sketch-styles/dry.json';
8
+
1
9
  export interface SketchStyle {
2
10
  label: string;
3
11
  blurb: string;
@@ -59,8 +67,22 @@ export interface SketchStyle {
59
67
  hatchInk: number;
60
68
  strokeStyle: 'solid' | 'dashed';
61
69
  maskOn: boolean;
62
- /** Wavelength of the coverage noise, in page px. Small gives speckle, large gives broad patches. */
63
- maskBlob: number;
70
+ /** Blob size of the coverage noise, in page px, across and down. Small gives
71
+ speckle, large gives broad patches. */
72
+ maskBlobX: number;
73
+ maskBlobY: number;
74
+ /** Which way the stretch runs, in degrees clockwise from level. Nothing at
75
+ all while the two blob sizes match, since a field the same in every
76
+ direction is the same field turned. The tile only meets itself at the
77
+ angles that land the page's axes back on whole cells, so the dial is
78
+ answered with the nearest of those. */
79
+ maskAngle: number;
80
+ /** Whether the two move together. Unlinked they part, and the field comes out
81
+ stretched: blobs wider than they are tall read as a wash dragged sideways,
82
+ the way ink pulled across a page does. Stored rather than inferred from
83
+ the pair matching, so a look that stretches to exactly square keeps its
84
+ dials apart. */
85
+ maskBlobLinked: boolean;
64
86
  /** Output levels on the coverage field, 0 to 1: the palest the fill gets and
65
87
  the densest. The whole field is squeezed into the gap, never cut at it, so
66
88
  0.4 to 1 is a fill that is never thinner than 40% ink and 0 to 0.8 one
@@ -119,103 +141,34 @@ export interface SketchStyle {
119
141
  iconMaskScale: number;
120
142
  }
121
143
 
122
- const base: SketchStyle = {
123
- label: '', blurb: '',
124
- fillTravel: 1.5, strokeTravel: 1.5, wobble: 45, roughness: 3,
125
- borderWavelength: 1, waveform: 1,
126
- strokeWidth: 1.5, doubleStroke: false, retraceOffset: 1.2, retracePass: 'copy',
127
- fillStyle: 'solid', hatchInk: 0.4, strokeStyle: 'solid',
128
- pressure: 0.25, pressureMod: 0.35, pooling: 1.2, strokeInk: 1,
129
- maskOn: true, maskBlob: 100, maskOutputMin: 0.45, maskOutputMax: 1,
130
- maskOctaves: 2, maskGrain: 'fractal', maskPosterize: 1, maskSoftness: 1.5,
131
- jitterX: 2.5, jitterY: 2.5, jitterRot: 0.6, jitterScale: 0.035,
132
- cornerSpread: 10, cornerTravel: 8,
133
- iconTravel: 1.25, iconWavelength: 0.625, iconMaskOn: true, iconMaskScale: 1,
134
- };
135
-
136
- export const SKETCH_STYLES: Record<string, SketchStyle> = {
137
- pencil: {
138
- ...base, label: 'Pencil',
139
- blurb: 'Two graphite passes on their own seeds, so the outline disagrees with itself the way a hand coming back round does. Tight grain, little else.',
140
- fillTravel: 0.75, strokeTravel: 1.25, wobble: 30, roughness: 3, waveform: 1,
141
- strokeWidth: 1.25, doubleStroke: true, retracePass: 'reseeded', retraceOffset: 1.5, strokeInk: 0.85,
142
- maskBlob: 40, maskOutputMin: 0.62, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 1, maskSoftness: 0,
143
- jitterX: 1.5, jitterY: 1.5, jitterRot: 0.35, jitterScale: 0.022,
144
- cornerSpread: 6, cornerTravel: 4.5,
145
- pressure: 0.15, pressureMod: 0.3, pooling: 0, iconTravel: 0.75,
146
- },
147
-
148
- marker: {
149
- ...base, label: 'Marker',
150
- blurb: 'Broad translucent nib gone round twice on the same line, so the overlap darkens and the ink pools where it slows.',
151
- fillTravel: 2, strokeTravel: 1.5, wobble: 56, waveform: 1.4, borderWavelength: 1.3,
152
- strokeWidth: 4, doubleStroke: true, retracePass: 'copy', strokeInk: 0.52, retraceOffset: 2.2,
153
- maskBlob: 115, maskOutputMin: 0.42, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 4, maskSoftness: 4,
154
- jitterX: 3, jitterY: 3, jitterRot: 0.8, jitterScale: 0.045,
155
- cornerSpread: 10, cornerTravel: 8,
156
- pressure: 0.2, pressureMod: 0.25, pooling: 2, iconTravel: 1.25, iconMaskScale: 4,
157
- },
158
-
159
- whiteboard: {
160
- ...base, label: 'Whiteboard',
161
- blurb: 'The fattest nib on glass. One long smooth undulation, and a veined mask that streaks the fill like a half-wiped board.',
162
- fillTravel: 2.5, strokeTravel: 2.5, wobble: 90, roughness: 1, waveform: 1, borderWavelength: 1.5,
163
- strokeWidth: 5.5, doubleStroke: true, retracePass: 'copy', strokeInk: 0.66, retraceOffset: 3,
164
- maskGrain: 'turbulence', maskBlob: 180, maskOutputMin: 0.42, maskOutputMax: 1,
165
- maskOctaves: 1, maskPosterize: 3, maskSoftness: 8,
166
- jitterX: 4.5, jitterY: 4.5, jitterRot: 1.2, jitterScale: 0.07,
167
- cornerSpread: 14, cornerTravel: 11,
168
- pressure: 0.15, pressureMod: 0.15, pooling: 3.5, iconTravel: 1.75, iconMaskScale: 1.5,
169
- },
170
-
171
- hatched: {
172
- ...base, label: 'Hatched',
173
- blurb: 'An etching. The fill is angled shading, the outline a single hard-edged scratch that chatters along its length. No mask: the hatch is the texture.',
174
- fillTravel: 1.25, strokeTravel: 1.5, wobble: 24, roughness: 3, waveform: 3,
175
- strokeWidth: 1.5, fillStyle: 'hatched', hatchInk: 0.5, doubleStroke: false,
176
- maskOn: false, iconMaskOn: false,
177
- jitterX: 1.5, jitterY: 1.5, jitterRot: 0.4, jitterScale: 0.03,
178
- cornerSpread: 6, cornerTravel: 6,
179
- pressure: 0.3, pressureMod: 0.45, pooling: 0.8, iconTravel: 1.25, iconWavelength: 0.5,
180
- },
181
-
182
- dashed: {
183
- ...base, label: 'Dashed',
184
- blurb: 'A drafting outline. One slow drift along the ruler, broken into strokes, with jitter, mask and pressure all off. The clean pole.',
185
- strokeStyle: 'dashed', strokeTravel: 1, fillTravel: 0.5, wobble: 120, roughness: 1, waveform: 1,
186
- strokeWidth: 1.5, doubleStroke: false,
187
- maskOn: false, iconMaskOn: false,
188
- jitterX: 0, jitterY: 0, jitterRot: 0, jitterScale: 0,
189
- cornerSpread: 4, cornerTravel: 3,
190
- pressure: 0, pressureMod: 0, pooling: 0, iconTravel: 0,
191
- },
192
-
193
- napkin: {
194
- ...base, label: 'Napkin',
195
- blurb: 'Ballpoint in a hurry. Everything loose at once: a square wave sends every edge to full travel, and the second pass lands wherever it lands.',
196
- fillTravel: 3, strokeTravel: 2.25, wobble: 50, roughness: 3, waveform: 2.5,
197
- strokeWidth: 2.25, doubleStroke: true, retracePass: 'reseeded', retraceOffset: 4, strokeInk: 1,
198
- maskBlob: 150, maskOutputMin: 0.43, maskOutputMax: 0.95, maskOctaves: 2, maskPosterize: 2, maskSoftness: 10,
199
- jitterX: 6, jitterY: 6, jitterRot: 1.8, jitterScale: 0.1,
200
- cornerSpread: 20, cornerTravel: 17,
201
- pressure: 0.45, pressureMod: 0.6, pooling: 2.5, iconTravel: 2.25, iconMaskScale: 3.6,
202
- },
144
+ /**
145
+ * The shipped sketchstyles, read from the files the package distributes. The
146
+ * files are the source: a project shadows one by saving a sketchstyle under the
147
+ * same id, the editor restores a shipped look by deleting that file, and
148
+ * `themeFileApi` serves these as the read-only fallback behind the project's own
149
+ * directory. Editing a look here means editing its JSON, which is what the
150
+ * Sketchstyle view already writes.
151
+ *
152
+ * Each file carries every dial, so there is nothing to merge a default into.
153
+ * `sketchStyles.test.ts` pins that: the seven key sets have to match, and a dial
154
+ * added to `SketchStyle` has to reach all seven before the suite goes green.
155
+ *
156
+ * Order is picker order.
157
+ */
158
+ const SHIPPED_FILES = { pencil, marker, whiteboard, hatched, dashed, napkin, dry };
203
159
 
204
- dry: {
205
- ...base, label: 'Dry marker',
206
- blurb: 'Ink that ran out. One scratchy pass that breaks up along its length, over a fill the mask has worn nearly through in patches.',
207
- fillTravel: 2.25, strokeTravel: 1.75, wobble: 50, waveform: 2, borderWavelength: 0.5,
208
- strokeWidth: 3.5, doubleStroke: false, strokeInk: 0.4,
209
- maskBlob: 150, maskOutputMin: 0.24, maskOutputMax: 0.91,
210
- maskOctaves: 2, maskPosterize: 4, maskSoftness: 1.75,
211
- jitterX: 5, jitterY: 5, jitterRot: 1.4, jitterScale: 0.08,
212
- cornerSpread: 16, cornerTravel: 13,
213
- pressure: 0.4, pressureMod: 0.8, pooling: 1.5, iconTravel: 1.75, iconMaskScale: 3.8,
214
- },
215
- };
160
+ export const SKETCH_STYLES: Record<string, SketchStyle> = Object.fromEntries(
161
+ Object.entries(SHIPPED_FILES).map(([id, file]) => [id, file.settings as unknown as SketchStyle]),
162
+ );
216
163
 
217
164
  export const DEFAULT_SKETCH_STYLE = 'marker';
218
165
 
166
+ /** The id of the look a theme carries, in the same id namespace as the shipped
167
+ sketchstyles so one picker row and one `setSketch` call cover both. Never a
168
+ key of `SKETCH_STYLES`: a shipped style claiming it would shadow the theme's
169
+ own look in every picker. `index.test.ts` pins that. */
170
+ export const THEME_SKETCH_ID = 'theme';
171
+
219
172
  /** Reconciled against a full sketchstyle in both directions: a value stored before a
220
173
  control existed picks up the default, and a value stored for a control since
221
174
  retired is dropped. Without the drop, a stale key survives every spread and
@@ -233,6 +186,7 @@ export function hydrateSketchStyle(raw: unknown): SketchStyle {
233
186
  convertCutToLevels(stored as Record<string, unknown>, out);
234
187
  carryLevelsToOutput(stored as Record<string, unknown>, out);
235
188
  halveSwingDials(stored as Record<string, unknown>, out);
189
+ splitBlobAxes(stored as Record<string, unknown>, out);
236
190
  convertCyclesToWavelength(stored as Record<string, unknown>, out);
237
191
  convertIconTileToScale(stored as Record<string, unknown>, out);
238
192
  restoreDerivedRetrace(stored as Record<string, unknown>, out);
@@ -280,6 +234,16 @@ function convertCyclesToWavelength(stored: Record<string, unknown>, out: SketchS
280
234
  if (typeof layers === 'number') out.roughness = Math.min(3, Math.max(1, layers));
281
235
  }
282
236
 
237
+ /** The blob size was one number for both axes. A look stored before the split
238
+ comes back square, with the two dials linked, which is the look it had. */
239
+ function splitBlobAxes(stored: Record<string, unknown>, out: SketchStyle): void {
240
+ const blob = stored.maskBlob;
241
+ if (typeof blob !== 'number') return;
242
+ out.maskBlobX = blob;
243
+ out.maskBlobY = blob;
244
+ out.maskBlobLinked = true;
245
+ }
246
+
283
247
  /** The icon mask tile used to be a px size, which is a unit the glyph it covers
284
248
  has no say in: 90px against a 16px icon put a whole glyph inside one patch
285
249
  of the field, so it came out either untouched or gone. It is a share of the
@@ -305,7 +269,10 @@ function convertTiledMask(stored: Record<string, unknown>, out: SketchStyle): vo
305
269
  const scale = stored.maskScale;
306
270
  if (typeof scale !== 'number') return;
307
271
  const freq = stored.maskFrequency;
308
- if (typeof freq === 'number') out.maskBlob = Math.round(scale / (freq * LEGACY_TILE));
272
+ if (typeof freq === 'number') {
273
+ out.maskBlobX = Math.round(scale / (freq * LEGACY_TILE));
274
+ out.maskBlobY = out.maskBlobX;
275
+ }
309
276
  const soft = stored.maskSoftness;
310
277
  if (typeof soft === 'number') out.maskSoftness = Number(((soft * scale) / LEGACY_TILE).toFixed(1));
311
278
  }
@@ -5,7 +5,7 @@ import { loadFromFile, seedComponentsFromApi } from '../store/editorStore';
5
5
  import { getActiveComponentConfig, type ComponentSummary } from '../components/componentConfigService';
6
6
  import { safeFetch } from '../storage/storage';
7
7
  import { API_BASE } from '../storage/apiBase';
8
- import { hasPersistedSketchState, openThemeSketchStyle, themeSketchStyle } from '../sketch/sketchStore';
8
+ import { seedSketchFromTheme } from '../sketch/sketchStore';
9
9
 
10
10
  interface ListComponentsDto {
11
11
  components: ComponentSummary[];
@@ -67,16 +67,7 @@ export async function initializeTheme(): Promise<void> {
67
67
  // A failed fetch is not "the theme carries no sketchstyle": treating null as
68
68
  // absent would tell the panel the look is off the theme, or hand a fresh
69
69
  // browser a blank buffer, over a fetch that will likely succeed next time.
70
- if (active) {
71
- if (hasPersistedSketchState()) {
72
- // The buffer already painted on the first frame; boot only learns what
73
- // the theme holds so unsaved dial work reads as unsaved rather than
74
- // getting silently overwritten (that overwrite is what Apply is for).
75
- themeSketchStyle.set(active.sketchStyle);
76
- } else {
77
- // Nothing was ever recorded in this browser: the theme's value becomes
78
- // the live value, the same reconciliation opening a theme performs.
79
- openThemeSketchStyle(active.sketchStyle);
80
- }
81
- }
70
+ // The same call a built site makes (`@motion-proto/live-tokens/sketch`), so
71
+ // one rule decides what a theme's sketchstyle means at boot in both.
72
+ if (active) seedSketchFromTheme(active.sketchStyle);
82
73
  }
@@ -6,7 +6,7 @@ they are drawn with.
6
6
 
7
7
  It is an effect layer, not a set of token values. It never touches a token
8
8
  itself, so turning it off returns every component to exactly what its tokens
9
- already say. A production build never carries the drawing.
9
+ already say.
10
10
 
11
11
  Open the **Sketchstyle** view in the editor and switch **Sketch mode** on. The effect
12
12
  applies to the page behind the editor as well as to the preview, so what you see
@@ -38,10 +38,22 @@ than just a name:
38
38
  - **Napkin.** Ballpoint in a hurry. Everything loose at once.
39
39
  - **Dry marker.** Ink that ran out. One scratchy pass over a mostly eaten fill.
40
40
 
41
- Pick one, then move whatever you like. **Save as sketchstyle…** keeps your
42
- dials under a name of your own, alongside the shipped seven, as a file under
43
- `src/live-tokens/data/sketch-styles/`. That is a different gesture from
44
- saving a theme; see "Where the settings live" below.
41
+ All seven ship as files, one per look, under
42
+ `src/live-tokens/data/sketch-styles/` in the package. There is no look that
43
+ exists only as code, so every one of them can be read, copied and edited.
44
+
45
+ Pick one, then move whatever you like. **Save As** keeps your dials under a name
46
+ of your own, alongside the shipped seven, as a file under
47
+ `src/live-tokens/data/sketch-styles/` in your project. **Save** writes them back
48
+ over the sketchstyle you have selected, and lights as soon as the dials leave
49
+ it. On one of your own it writes that file. On a shipped one it writes your
50
+ project's own copy under the same name, which takes its place in the list;
51
+ delete that copy and the shipped file behind it comes back. Both are a
52
+ different gesture from saving a theme; see "Where the settings live" below.
53
+
54
+ Your sketchstyles and the shipped ones are one list. A sketchstyle named after
55
+ a shipped one replaces it, keeping its place in the list, so a project that
56
+ wants its own Pencil saves one and every picker shows that one instead.
45
57
 
46
58
  ## The dials
47
59
 
@@ -50,8 +62,11 @@ saving a theme; see "Where the settings live" below.
50
62
  few pixels off or runs it through the pen again on its own seed.
51
63
  - **Fill.** Solid or hatched, how far the fill's edge travels, and how far each
52
64
  instance is offset, rotated and scaled from its neighbours. **Ink coverage**
53
- thins the fill with a field of blotches: set their size and how many levels of
54
- detail, then work the field as a levels control. The field always runs black
65
+ thins the fill with a field of blotches: set their size across and down and
66
+ how many levels of detail, then work the field as a levels control. The two
67
+ sizes move together under a chain; break it and the blotches stretch, which
68
+ reads as ink dragged along the axis you widened, and a **Rotation** dial joins
69
+ them to point that stretch anywhere you like. The field always runs black
55
70
  to white whatever the noise underneath. Steps flattens it into tones, and
56
71
  Output squeezes the whole of it into the range the ink covers, from how pale
57
72
  it gets at its thinnest to how dense at its fullest. Menus and tooltips are
@@ -81,14 +96,68 @@ change. The built-in **Motion Proto** theme is read-only, so Save
81
96
  is disabled there; use **Save As** to fold the dials into a theme of your
82
97
  own.
83
98
 
84
- **Save as sketchstyle…** in the **Sketchstyle** view is a different gesture. It
85
- writes a named sketchstyle to `src/live-tokens/data/sketch-styles/`, a look you
86
- can pick from any theme. It never touches the open theme, and it never marks
99
+ **Save** and **Save As** in the **Sketchstyle** view are a different gesture.
100
+ They write a named sketchstyle to `src/live-tokens/data/sketch-styles/`, a look
101
+ you can pick from any theme. Neither touches the open theme, and neither marks
87
102
  the look off the theme.
88
103
 
89
- Sketch mode is a tool for looking at the page, not a layer the page can ship.
90
- The theme records the dials, but nothing bakes them: `tokens.generated.css`
91
- never sees them, and a production build has no sketch layer in it at all.
104
+ ## Shipping the layer
105
+
106
+ The dev server reads the open theme and paints whatever it carries. A built site
107
+ has no server to ask, so it hands the field over itself:
108
+
109
+ ```ts
110
+ import { seedSketchFromTheme } from '@motion-proto/live-tokens/sketch';
111
+ import theme from './live-tokens/data/themes/sketchy.json';
112
+
113
+ seedSketchFromTheme(theme.sketchStyle);
114
+ await bootLiveTokens(App, '#app');
115
+ ```
116
+
117
+ Call it before mounting, so the look is up on the first frame. Pass the field
118
+ raw. A theme written against older dial names is carried forward on the way in,
119
+ the same reconciliation the dev server runs on every theme it reads.
120
+
121
+ Nothing is baked. `tokens.generated.css` still holds token values only, and the
122
+ layer stays JavaScript the page runs, because it builds an SVG filter bank
123
+ rather than a set of custom properties.
124
+
125
+ A visitor who has picked a look of their own keeps it, None included. The theme
126
+ seeds a browser that has decided nothing and never overwrites one that has, so
127
+ calling this on every boot is safe.
128
+
129
+ ### Your own sketchstyles
130
+
131
+ The dev server lists the files in `sketch-styles/`. A built site has no server
132
+ to ask, so it hands them over at boot, the way it hands over components:
133
+
134
+ ```ts
135
+ const files = import.meta.glob<{ name?: string; settings: unknown }>(
136
+ './live-tokens/data/sketch-styles/*.json',
137
+ { eager: true, import: 'default' },
138
+ );
139
+
140
+ await bootLiveTokens(App, '#app', {
141
+ sketchLooks: Object.entries(files).map(([path, file]) => {
142
+ const id = path.split('/').pop()!.replace('.json', '');
143
+ return { id, label: file.name || id, settings: file.settings };
144
+ }),
145
+ });
146
+ ```
147
+
148
+ Projects made with `create` ship this already. The file's slug is the look's
149
+ id, so a sketchstyle picked in the editor keeps working once the site is built.
150
+
151
+ ### Building a picker
152
+
153
+ `sketchLooks` is every look on offer, shipped and your own, as a store. Give
154
+ each row `setSketch(look.id)`, and add your own **None** row: off is a state of
155
+ the effect rather than one of the looks.
156
+
157
+ `themeSketchLook` is the theme's own look as one more row. It is null when the
158
+ theme carries none, and null when what it carries is a look already in
159
+ `sketchLooks`, since that row names it. Its id goes to `setSketch` like any
160
+ other, so a visitor who wanders off the theme's look can come back to it.
92
161
 
93
162
  ## Drawing your own elements
94
163