@motion-proto/live-tokens 0.56.1 → 0.57.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 (90) hide show
  1. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +2 -2
  2. package/CHANGELOG.md +124 -0
  3. package/README.md +1 -1
  4. package/bin/cli.mjs +2 -1
  5. package/dist-plugin/adjust/index.cjs +1 -0
  6. package/dist-plugin/adjust/index.d.cts +2 -2
  7. package/dist-plugin/adjust/index.d.ts +2 -2
  8. package/dist-plugin/adjust/index.js +1 -1
  9. package/dist-plugin/{chunk-TKZBVIW5.js → chunk-E5QYON4L.js} +59 -2
  10. package/dist-plugin/{chunk-PB2JTK2H.js → chunk-LW4SR7AZ.js} +39 -6
  11. package/dist-plugin/{chunk-YR6GPXW2.js → chunk-XWXIMTWZ.js} +0 -5
  12. package/dist-plugin/{chunk-D3ZVKOR4.js → chunk-Y5CNFSSV.js} +1 -0
  13. package/dist-plugin/{dataPaths-DBN0RPuT.d.cts → dataPaths-CRfD1LdA.d.cts} +3 -0
  14. package/dist-plugin/{dataPaths-DBN0RPuT.d.ts → dataPaths-CRfD1LdA.d.ts} +3 -0
  15. package/dist-plugin/fontPairing/index.cjs +4 -2
  16. package/dist-plugin/fontPairing/index.d.cts +3 -3
  17. package/dist-plugin/fontPairing/index.d.ts +3 -3
  18. package/dist-plugin/fontPairing/index.js +4 -3
  19. package/dist-plugin/generateColorsAndType/index.cjs +22 -9
  20. package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
  21. package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
  22. package/dist-plugin/generateColorsAndType/index.js +8 -5
  23. package/dist-plugin/index.cjs +158 -9
  24. package/dist-plugin/index.d.cts +1 -1
  25. package/dist-plugin/index.d.ts +1 -1
  26. package/dist-plugin/index.js +71 -4
  27. package/dist-plugin/migrateData/index.cjs +1 -0
  28. package/dist-plugin/migrateData/index.d.cts +1 -1
  29. package/dist-plugin/migrateData/index.d.ts +1 -1
  30. package/dist-plugin/migrateData/index.js +1 -1
  31. package/dist-plugin/{themeTypes-BAqtv4XO.d.cts → themeTypes-DSV3Zisf.d.cts} +12 -1
  32. package/dist-plugin/{themeTypes-BAqtv4XO.d.ts → themeTypes-DSV3Zisf.d.ts} +12 -1
  33. package/dist-plugin/tokensCssMigrations/index.cjs +58 -1
  34. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  35. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  36. package/dist-plugin/tokensCssMigrations/index.js +3 -3
  37. package/package.json +3 -1
  38. package/src/editor/core/fonts/applyFontPairing.ts +3 -2
  39. package/src/editor/core/fonts/fontLoader.ts +1 -0
  40. package/src/editor/core/fonts/fontMigration.ts +31 -1
  41. package/src/editor/core/palettes/paletteDerivation.ts +9 -2
  42. package/src/editor/core/sketch/maskField.ts +381 -0
  43. package/src/editor/core/sketch/sketchLayer.ts +1028 -0
  44. package/src/editor/core/sketch/sketchPresetService.ts +65 -0
  45. package/src/editor/core/sketch/sketchPresets.ts +328 -0
  46. package/src/editor/core/sketch/sketchStore.ts +190 -0
  47. package/src/editor/core/store/editorPersistence.ts +28 -12
  48. package/src/editor/core/store/editorTypes.ts +4 -1
  49. package/src/editor/core/store/editorViewStore.ts +2 -2
  50. package/src/editor/core/store/gradientSource.ts +15 -1
  51. package/src/editor/core/themes/parsers/gradient.ts +62 -4
  52. package/src/editor/core/themes/slices/gradients.ts +75 -14
  53. package/src/editor/core/themes/themeTypes.ts +6 -1
  54. package/src/editor/docs/Docs.svelte +4 -3
  55. package/src/editor/docs/chapters.ts +1 -0
  56. package/src/editor/docs/content/01-overview.md +2 -1
  57. package/src/editor/docs/content/editing-tokens.md +5 -1
  58. package/src/editor/docs/content/sketch-mode.md +89 -0
  59. package/src/editor/docs/content.generated.ts +3 -2
  60. package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
  61. package/src/editor/pages/EditorShell.svelte +43 -0
  62. package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
  63. package/src/editor/ui/FontStackEditor.svelte +3 -0
  64. package/src/editor/ui/GradientEditor.svelte +38 -1
  65. package/src/editor/ui/UIReveal.svelte +7 -1
  66. package/src/editor/ui/UISegmentedControl.svelte +4 -8
  67. package/src/editor/ui/sections/GradientsSection.svelte +47 -1
  68. package/src/editor/ui/sketch/SketchDial.svelte +124 -0
  69. package/src/editor/ui/sketch/SketchPreview.svelte +119 -0
  70. package/src/editor/ui/sketch/SketchRange.svelte +185 -0
  71. package/src/editor/ui/sketch/SketchTab.svelte +1263 -0
  72. package/src/live-tokens/data/colors-and-type/autumn.json +17 -0
  73. package/src/live-tokens/data/colors-and-type/default.json +17 -0
  74. package/src/live-tokens/data/colors-and-type/halloween.json +17 -0
  75. package/src/live-tokens/data/colors-and-type/midnight-study.json +17 -0
  76. package/src/live-tokens/data/colors-and-type/ocean.json +17 -0
  77. package/src/live-tokens/data/colors-and-type/royal-velvet.json +17 -0
  78. package/src/live-tokens/data/colors-and-type/sketches.json +2520 -0
  79. package/src/live-tokens/data/colors-and-type/spring-meadow.json +17 -0
  80. package/src/live-tokens/data/colors-and-type/sunset.json +17 -0
  81. package/src/live-tokens/data/themes/autumn.json +17 -0
  82. package/src/live-tokens/data/themes/halloween.json +17 -0
  83. package/src/live-tokens/data/themes/midnight-study.json +17 -0
  84. package/src/live-tokens/data/themes/ocean.json +17 -0
  85. package/src/live-tokens/data/themes/royal-velvet.json +17 -0
  86. package/src/live-tokens/data/themes/sketches.json +3984 -0
  87. package/src/live-tokens/data/themes/spring-meadow.json +17 -0
  88. package/src/live-tokens/data/themes/sunset.json +17 -0
  89. package/src/live-tokens/data/tokens.generated.css +1 -0
  90. package/src/system/styles/tokens.css +15 -2
@@ -0,0 +1,65 @@
1
+ import { API_BASE } from '../storage/apiBase';
2
+ import { hydrateSketchSettings, type SketchSettings } from './sketchPresets';
3
+
4
+ export interface SketchPresetFile {
5
+ name: string;
6
+ createdAt?: string;
7
+ updatedAt?: string;
8
+ settings: SketchSettings;
9
+ }
10
+
11
+ export interface SketchPresetMeta {
12
+ name: string;
13
+ fileName: string;
14
+ updatedAt: string;
15
+ }
16
+
17
+ const BASE = `${API_BASE}/sketch-presets`;
18
+
19
+ /** Slug for the file name. The display name is stored inside the file, so this
20
+ only has to be stable, unique-ish and inside the route's charset. */
21
+ export function slugifySketchPreset(name: string): string {
22
+ return name
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-+|-+$/g, '')
26
+ .slice(0, 60);
27
+ }
28
+
29
+ export async function listSketchPresets(): Promise<SketchPresetMeta[]> {
30
+ const res = await fetch(BASE);
31
+ if (!res.ok) throw new Error('Failed to list sketch presets');
32
+ const body = await res.json();
33
+ return body.files ?? [];
34
+ }
35
+
36
+ export async function loadSketchPreset(fileName: string): Promise<SketchPresetFile> {
37
+ const res = await fetch(`${BASE}/${encodeURIComponent(fileName)}`);
38
+ if (!res.ok) throw new Error(`Failed to load sketch preset: ${fileName}`);
39
+ const body = await res.json();
40
+ return { ...body, settings: hydrateSketchSettings(body.settings) };
41
+ }
42
+
43
+ export async function saveSketchPreset(
44
+ fileName: string,
45
+ name: string,
46
+ settings: SketchSettings,
47
+ ): Promise<void> {
48
+ const res = await fetch(`${BASE}/${encodeURIComponent(fileName)}`, {
49
+ method: 'PUT',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ name, settings }),
52
+ });
53
+ if (!res.ok) {
54
+ const err = await res.json().catch(() => ({ error: 'Save failed' }));
55
+ throw new Error(err.error || 'Save failed');
56
+ }
57
+ }
58
+
59
+ export async function deleteSketchPreset(fileName: string): Promise<void> {
60
+ const res = await fetch(`${BASE}/${encodeURIComponent(fileName)}`, { method: 'DELETE' });
61
+ if (!res.ok) {
62
+ const err = await res.json().catch(() => ({ error: 'Delete failed' }));
63
+ throw new Error(err.error || 'Delete failed');
64
+ }
65
+ }
@@ -0,0 +1,328 @@
1
+ export interface SketchSettings {
2
+ label: string;
3
+ blurb: string;
4
+ /** How far the fill's edge travels at its furthest, in px. Every dial that
5
+ feeds a displacement map is stated this way: the map's own `scale` is the
6
+ full swing, twice what anything moves, and the field spends most of its
7
+ length well short of the peak. */
8
+ fillTravel: number;
9
+ strokeTravel: number;
10
+ /** The border's own wavelength, as a multiple of the shared one. Above 1 the
11
+ outline draws long lazy curves across a fill finer than it; below 1 it
12
+ chatters over one that stays broad. The pen and the paper are different
13
+ things. */
14
+ borderWavelength: number;
15
+ /** Shape of the displacement wave, from smooth at 1 to square at 4. The raw
16
+ field crowds its values around its own centre, which is no movement at
17
+ all, so an edge only travels the stated amplitude where the wave happens
18
+ to peak. Squaring it clips both rails, and nearly every point then sits at
19
+ full amplitude. This is what makes the effect stronger rather than
20
+ bigger: amplitude sets the distance, waveform sets how much of the edge
21
+ actually goes it. */
22
+ waveform: number;
23
+ /** Detail layers stacked on the wave. One is a single smooth undulation;
24
+ three lays two finer waves over it at half and quarter magnitude. The
25
+ format halves the magnitude every layer and offers no way to change that,
26
+ so a fourth would move an edge by a tenth of a pixel and the stack is
27
+ spent at three. */
28
+ roughness: number;
29
+ /** Wavelength of the pen wobble, in page px: how far along an edge the field
30
+ takes to turn over. Short gives tight chatter, long gives lazy curves.
31
+ Stated the way the mask states its blobs, because a number of pixels is a
32
+ thing you can picture and a number of cycles per pixel is not. */
33
+ wobble: number;
34
+ strokeWidth: number;
35
+ doubleStroke: boolean;
36
+ /** How far the second pass can land from the first, in px on each axis. Every
37
+ component draws its own distance and direction inside that range, so no
38
+ two part company by the same amount: one distance for every component
39
+ reads as a printing offset rather than as a hand. */
40
+ retraceOffset: number;
41
+ /** How the second pass is drawn. `copy` duplicates the line already
42
+ displaced, so both runs carry the same wobble a few px apart. `reseeded`
43
+ sends the line through the pen again on its own noise seed, so the two
44
+ disagree along their length the way a hand coming back round does. */
45
+ retracePass: 'copy' | 'reseeded';
46
+ /** Per-instance stroke weight variation, as a fraction. 0.3 = +/-30%. */
47
+ pressure: number;
48
+ /** Depth of the along-stroke thinning mask. 0 = even line, 1 = breaks up. */
49
+ pressureMod: number;
50
+ /** Goo radius. Blur then re-sharpen alpha, so nearby stroke runs merge and bulge. */
51
+ pooling: number;
52
+ /** Ink density of the stroke, 0 to 1. Below 1 the line is translucent, so the
53
+ retrace pass reads through the first one: where they overlap the colour
54
+ doubles and where only one landed it stays pale. That gradient across the
55
+ weight of the line is what a marker looks like and what a pen does not. */
56
+ strokeInk: number;
57
+ fillStyle: 'solid' | 'hatched';
58
+ /** Density of the hatch lines against the fill, 0 to 1. */
59
+ hatchInk: number;
60
+ strokeStyle: 'solid' | 'dashed';
61
+ maskOn: boolean;
62
+ /** Wavelength of the coverage noise, in page px. Small gives speckle, large gives broad patches. */
63
+ maskBlob: number;
64
+ /** Output levels on the coverage field, 0 to 1: the palest the fill gets and
65
+ the densest. 0 is bare and 1 is whole, so 0.4 to 1 is a fill that is never
66
+ thinner than 40% ink, and 0 to 0.8 one that never quite fills in. Close
67
+ together is a flat wash, far apart a strong blotch. The field is stretched
68
+ onto its own measured range before they apply, so both mean the same thing
69
+ at every grain and octave count. */
70
+ maskOutputMin: number;
71
+ maskOutputMax: number;
72
+ /** Detail layers. 1-2 gives broad blobs, 4+ goes cloudy and stops reading as blotches. */
73
+ maskOctaves: number;
74
+ /** Character of the noise. Fractal is cloudy; turbulence is veined, like marbled ink. */
75
+ maskGrain: 'fractal' | 'turbulence';
76
+ /** Quantise the mask into N discrete levels. 1 = smooth gradient, 2 = hard two-tone blotches. */
77
+ maskPosterize: number;
78
+ /** Feather on the blotch edges, in page px. */
79
+ maskSoftness: number;
80
+ /** Per-instance offset range in px. */
81
+ jitterX: number;
82
+ jitterY: number;
83
+ /** Per-instance rotation range in degrees. */
84
+ jitterRot: number;
85
+ /** Per-instance oversize, as a fraction. One-sided: every fill grows by
86
+ some share of it, so a rotated fill never exposes a bare corner. */
87
+ jitterScale: number;
88
+ /** Rounding added on top of the part's own radius, in px. Each corner takes
89
+ its own share, between half the dial and all of it: at 12 a box comes out
90
+ with corners near 12, 7, 10 and 6. The floor at half is what keeps it a
91
+ shape rather than a scallop, which is what a square corner sitting beside
92
+ one carrying the whole dial looks like on anything as short as a button.
93
+ A pill is unmoved. */
94
+ cornerSpread: number;
95
+ /** How far the corners of the drawn box travel at their furthest, in px. One wave of noise long
96
+ enough to span several components, so each corner is pushed a different
97
+ way and the box comes out a leaning quadrilateral with no two sides
98
+ parallel. This is the dial that stops a component reading as a div; the
99
+ corner radii only decide how the turns are cut. Every component moves the
100
+ same number of pixels whatever its size. */
101
+ cornerTravel: number;
102
+ /** Glyph travel for icons and inline SVG, in px. 0 leaves them crisp.
103
+ A glyph is all curves already, so it needs MORE travel than a card's long
104
+ straight edge before the wobble reads at all. */
105
+ iconTravel: number;
106
+ /** The same for icons. Above 1 bends the whole glyph, below 0.5 ripples its
107
+ outline. Glyphs sit well under the shared wavelength by default: a wave
108
+ long enough to lean a card leaves a 20px icon riding one flat stretch. */
109
+ iconWavelength: number;
110
+ /** Ink coverage over icons. Independent of `maskOn`, which governs fills. */
111
+ iconMaskOn: boolean;
112
+ /** Icon mask tile, in px. Near glyph size puts several blotches across one
113
+ icon; at the fill's tile size a whole icon samples a single patch and
114
+ either survives intact or vanishes. */
115
+ iconMaskTile: number;
116
+ }
117
+
118
+ const base: SketchSettings = {
119
+ label: '', blurb: '',
120
+ fillTravel: 1.5, strokeTravel: 1.5, wobble: 45, roughness: 3,
121
+ borderWavelength: 1, waveform: 1,
122
+ strokeWidth: 1.5, doubleStroke: false, retraceOffset: 1.2, retracePass: 'copy',
123
+ fillStyle: 'solid', hatchInk: 0.4, strokeStyle: 'solid',
124
+ pressure: 0.25, pressureMod: 0.35, pooling: 1.2, strokeInk: 1,
125
+ maskOn: true, maskBlob: 100, maskOutputMin: 0.35, maskOutputMax: 1,
126
+ maskOctaves: 2, maskGrain: 'fractal', maskPosterize: 1, maskSoftness: 1.5,
127
+ jitterX: 2.5, jitterY: 2.5, jitterRot: 0.6, jitterScale: 0.035,
128
+ cornerSpread: 10, cornerTravel: 8,
129
+ iconTravel: 1.25, iconWavelength: 0.625, iconMaskOn: true, iconMaskTile: 90,
130
+ };
131
+
132
+ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
133
+ pencil: {
134
+ ...base, label: 'Pencil',
135
+ 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.',
136
+ fillTravel: 0.75, strokeTravel: 1.25, wobble: 30, roughness: 3, waveform: 1,
137
+ strokeWidth: 1.25, doubleStroke: true, retracePass: 'reseeded', retraceOffset: 1.5, strokeInk: 0.85,
138
+ maskBlob: 40, maskOutputMin: 0.55, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 1, maskSoftness: 0.6,
139
+ jitterX: 1.5, jitterY: 1.5, jitterRot: 0.35, jitterScale: 0.022,
140
+ cornerSpread: 6, cornerTravel: 4.5,
141
+ pressure: 0.15, pressureMod: 0.3, pooling: 0, iconTravel: 0.75,
142
+ },
143
+
144
+ marker: {
145
+ ...base, label: 'Marker',
146
+ blurb: 'Broad translucent nib gone round twice on the same line, so the overlap darkens and the ink pools where it slows.',
147
+ fillTravel: 2, strokeTravel: 1.5, wobble: 56, waveform: 1.4, borderWavelength: 1.3,
148
+ strokeWidth: 4, doubleStroke: true, retracePass: 'copy', strokeInk: 0.52, retraceOffset: 2.2,
149
+ maskBlob: 95, maskOutputMin: 0.4, maskOutputMax: 1, maskOctaves: 2, maskPosterize: 4, maskSoftness: 2.8,
150
+ jitterX: 3, jitterY: 3, jitterRot: 0.8, jitterScale: 0.045,
151
+ cornerSpread: 10, cornerTravel: 8,
152
+ pressure: 0.2, pressureMod: 0.25, pooling: 2, iconTravel: 1.25,
153
+ },
154
+
155
+ whiteboard: {
156
+ ...base, label: 'Whiteboard',
157
+ blurb: 'The fattest nib on glass. One long smooth undulation, and a veined mask that streaks the fill like a half-wiped board.',
158
+ fillTravel: 2.5, strokeTravel: 2.5, wobble: 90, roughness: 1, waveform: 1, borderWavelength: 1.5,
159
+ strokeWidth: 5.5, doubleStroke: true, retracePass: 'copy', strokeInk: 0.66, retraceOffset: 3,
160
+ maskGrain: 'turbulence', maskBlob: 180, maskOutputMin: 0.3, maskOutputMax: 1,
161
+ maskOctaves: 1, maskPosterize: 3, maskSoftness: 5,
162
+ jitterX: 4.5, jitterY: 4.5, jitterRot: 1.2, jitterScale: 0.07,
163
+ cornerSpread: 14, cornerTravel: 11,
164
+ pressure: 0.15, pressureMod: 0.15, pooling: 3.5, iconTravel: 1.75, iconMaskTile: 140,
165
+ },
166
+
167
+ hatched: {
168
+ ...base, label: 'Hatched',
169
+ 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.',
170
+ fillTravel: 1.25, strokeTravel: 1.5, wobble: 24, roughness: 3, waveform: 3,
171
+ strokeWidth: 1.5, fillStyle: 'hatched', hatchInk: 0.5, doubleStroke: false,
172
+ maskOn: false, iconMaskOn: false,
173
+ jitterX: 1.5, jitterY: 1.5, jitterRot: 0.4, jitterScale: 0.03,
174
+ cornerSpread: 6, cornerTravel: 6,
175
+ pressure: 0.3, pressureMod: 0.45, pooling: 0.8, iconTravel: 1.25, iconWavelength: 0.5,
176
+ },
177
+
178
+ dashed: {
179
+ ...base, label: 'Dashed',
180
+ blurb: 'A drafting outline. One slow drift along the ruler, broken into strokes, with jitter, mask and pressure all off. The clean pole.',
181
+ strokeStyle: 'dashed', strokeTravel: 1, fillTravel: 0.5, wobble: 120, roughness: 1, waveform: 1,
182
+ strokeWidth: 1.5, doubleStroke: false,
183
+ maskOn: false, iconMaskOn: false,
184
+ jitterX: 0, jitterY: 0, jitterRot: 0, jitterScale: 0,
185
+ cornerSpread: 4, cornerTravel: 3,
186
+ pressure: 0, pressureMod: 0, pooling: 0, iconTravel: 0,
187
+ },
188
+
189
+ napkin: {
190
+ ...base, label: 'Napkin',
191
+ 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.',
192
+ fillTravel: 3, strokeTravel: 2.25, wobble: 50, roughness: 3, waveform: 2.5,
193
+ strokeWidth: 2.25, doubleStroke: true, retracePass: 'reseeded', retraceOffset: 4, strokeInk: 1,
194
+ maskBlob: 150, maskOutputMin: 0.25, maskOutputMax: 1, maskOctaves: 2, maskPosterize: 2, maskSoftness: 6.7,
195
+ jitterX: 6, jitterY: 6, jitterRot: 1.8, jitterScale: 0.1,
196
+ cornerSpread: 20, cornerTravel: 17,
197
+ pressure: 0.45, pressureMod: 0.6, pooling: 2.5, iconTravel: 2.25,
198
+ },
199
+
200
+ dry: {
201
+ ...base, label: 'Dry marker',
202
+ blurb: 'Ink that ran out. One scratchy pass that breaks up along its length, over a fill the veined mask has mostly eaten.',
203
+ fillTravel: 2.25, strokeTravel: 1.75, wobble: 50, waveform: 2, borderWavelength: 0.5,
204
+ strokeWidth: 3.5, doubleStroke: false, strokeInk: 0.4,
205
+ maskGrain: 'turbulence', maskBlob: 120, maskOutputMin: 0, maskOutputMax: 0.85,
206
+ maskOctaves: 2, maskPosterize: 1, maskSoftness: 3,
207
+ jitterX: 5, jitterY: 5, jitterRot: 1.4, jitterScale: 0.08,
208
+ cornerSpread: 16, cornerTravel: 13,
209
+ pressure: 0.4, pressureMod: 0.8, pooling: 1.5, iconTravel: 1.75, iconMaskTile: 60,
210
+ },
211
+ };
212
+
213
+ export const DEFAULT_SKETCH_PRESET = 'marker';
214
+
215
+ /** Reconciled against a full preset in both directions: a value stored before a
216
+ control existed picks up the default, and a value stored for a control since
217
+ retired is dropped. Without the drop, a stale key survives every spread and
218
+ makes the settings compare unequal to any baseline forever. */
219
+ export function hydrateSketchSettings(raw: unknown): SketchSettings {
220
+ const base = SKETCH_PRESETS[DEFAULT_SKETCH_PRESET];
221
+ const stored = (raw ?? {}) as Partial<SketchSettings>;
222
+ const out = { ...base };
223
+ for (const key of Object.keys(base) as (keyof SketchSettings)[]) {
224
+ if (stored[key] !== undefined) (out[key] as unknown) = stored[key];
225
+ }
226
+ // A retired option: the fill's presence belongs to the theme, not the effect.
227
+ if ((out.fillStyle as string) === 'none') out.fillStyle = base.fillStyle;
228
+ convertTiledMask(stored as Record<string, unknown>, out);
229
+ convertCutToLevels(stored as Record<string, unknown>, out);
230
+ carryLevelsToOutput(stored as Record<string, unknown>, out);
231
+ halveSwingDials(stored as Record<string, unknown>, out);
232
+ convertCyclesToWavelength(stored as Record<string, unknown>, out);
233
+ restoreDerivedRetrace(stored as Record<string, unknown>, out);
234
+ return out;
235
+ }
236
+
237
+ /** The second pass used to sit at a distance derived from the stroke width,
238
+ with no dial of its own. A look stored before the dial comes back at that
239
+ distance rather than at whatever the fallback preset happens to carry. */
240
+ function restoreDerivedRetrace(stored: Record<string, unknown>, out: SketchSettings): void {
241
+ if (stored.retraceOffset === undefined) {
242
+ out.retraceOffset = Number(Math.max(1.2, out.strokeWidth * 0.55).toFixed(2));
243
+ }
244
+ }
245
+
246
+ /** The four displacement dials used to be stated as the map's own `scale`,
247
+ which is the full swing: the number on the dial was twice the furthest
248
+ anything actually moved. They are peak travel in px now, so a look stored
249
+ under the old names comes back halved and renders identically. */
250
+ const SWING_DIALS = {
251
+ fillScale: 'fillTravel',
252
+ strokeScale: 'strokeTravel',
253
+ iconScale: 'iconTravel',
254
+ cornerShift: 'cornerTravel',
255
+ } as const;
256
+
257
+ /** The pen wobble used to be stated as `frequency`, in cycles per px, which is
258
+ the number the filter wants and not one anybody can picture. It is a
259
+ wavelength in px now, the way the mask states its blobs. */
260
+ function convertCyclesToWavelength(stored: Record<string, unknown>, out: SketchSettings): void {
261
+ const cycles = stored.frequency;
262
+ if (typeof cycles === 'number' && cycles > 0) out.wobble = Math.round(1 / cycles);
263
+ // The layer count was `octaves`, and its dial ran to 5. The top two moved
264
+ // nothing, so a look stored there comes back at the roughest that reads.
265
+ // Both were multipliers on the frequency, so they ran the other way.
266
+ for (const [legacy, key] of [
267
+ ['borderFrequency', 'borderWavelength'], ['iconFrequency', 'iconWavelength'],
268
+ ] as const) {
269
+ const multiple = stored[legacy];
270
+ if (typeof multiple === 'number' && multiple > 0) {
271
+ out[key] = Number((1 / multiple).toFixed(3));
272
+ }
273
+ }
274
+ const layers = stored.octaves;
275
+ if (typeof layers === 'number') out.roughness = Math.min(3, Math.max(1, layers));
276
+ }
277
+
278
+ function halveSwingDials(stored: Record<string, unknown>, out: SketchSettings): void {
279
+ for (const [legacy, key] of Object.entries(SWING_DIALS)) {
280
+ const value = stored[legacy];
281
+ if (typeof value === 'number') out[key as 'fillTravel'] = value / 2;
282
+ }
283
+ }
284
+
285
+ /** The mask used to be a 600-unit tile painted at `maskScale` px, with the
286
+ coverage point buried in the hardness slope. Recover page-px blobs and
287
+ softness, and the levels the old slope and floor put the edge at. */
288
+ function convertTiledMask(stored: Record<string, unknown>, out: SketchSettings): void {
289
+ const scale = stored.maskScale;
290
+ if (typeof scale !== 'number') return;
291
+ const freq = stored.maskFrequency;
292
+ if (typeof freq === 'number') out.maskBlob = Math.round(scale / (freq * LEGACY_TILE));
293
+ const soft = stored.maskSoftness;
294
+ if (typeof soft === 'number') out.maskSoftness = Number(((soft * scale) / LEGACY_TILE).toFixed(1));
295
+ }
296
+
297
+ /**
298
+ * Coverage and contrast were a cut point and a slope through `feTurbulence`'s
299
+ * own output, which runs about 0.24 to 0.77 rather than 0 to 1. Recover the two
300
+ * levels the pair put the edge between, and rescale them onto the field as it
301
+ * is now: stretched onto its full range before the levels see it.
302
+ */
303
+ function convertCutToLevels(stored: Record<string, unknown>, out: SketchSettings): void {
304
+ const contrast = stored.maskContrast;
305
+ const coverage = stored.maskCoverage;
306
+ if (typeof contrast !== 'number' || typeof coverage !== 'number') return;
307
+ const veined = out.maskGrain === 'turbulence';
308
+ const [lo, hi] = veined ? LEGACY_RANGE.turbulence : LEGACY_RANGE.fractal;
309
+ const pivot = veined ? 0.55 - 0.5 * coverage : 0.8 - 0.6 * coverage;
310
+ const level = (raw: number) => Math.min(1, Math.max(0, (raw - lo) / (hi - lo)));
311
+ out.maskOutputMin = Number(level(pivot - 0.5 / contrast).toFixed(2));
312
+ out.maskOutputMax = Number(Math.max(out.maskOutputMin + 0.02, level(pivot + 0.5 / contrast)).toFixed(2));
313
+ }
314
+
315
+ /** The pair were input levels, a cut through the field, and are output levels
316
+ now, the palest and densest the fill gets. The same two numbers carry over:
317
+ a look cut between 20% and 50% comes back as a wash between 20% and 50% ink,
318
+ which keeps its spread and loses only the hole. */
319
+ function carryLevelsToOutput(stored: Record<string, unknown>, out: SketchSettings): void {
320
+ if (typeof stored.maskLevelMin === 'number') out.maskOutputMin = stored.maskLevelMin;
321
+ if (typeof stored.maskLevelMax === 'number') out.maskOutputMax = stored.maskLevelMax;
322
+ }
323
+
324
+ /** Where `feTurbulence` actually put its output, measured across seeds, blob
325
+ sizes and octave counts. The old dials worked in these numbers. */
326
+ const LEGACY_RANGE = { fractal: [0.24, 0.77], turbulence: [0.02, 0.6] } as const;
327
+
328
+ const LEGACY_TILE = 600;
@@ -0,0 +1,190 @@
1
+ import { derived, get, writable } from 'svelte/store';
2
+ import {
3
+ SKETCH_PRESETS,
4
+ DEFAULT_SKETCH_PRESET,
5
+ hydrateSketchSettings,
6
+ type SketchSettings,
7
+ } from './sketchPresets';
8
+ import { applySketchLayer, hostRoot, removeSketchLayer, setSketchScope } from './sketchLayer';
9
+ import {
10
+ deleteSketchPreset,
11
+ listSketchPresets,
12
+ loadSketchPreset,
13
+ saveSketchPreset,
14
+ slugifySketchPreset,
15
+ type SketchPresetMeta,
16
+ } from './sketchPresetService';
17
+
18
+ const ENABLED_KEY = 'lt.sketchEnabled';
19
+ const SETTINGS_KEY = 'lt.sketchSettings';
20
+ const PRESET_KEY = 'lt.sketchPreset';
21
+ const BASELINE_KEY = 'lt.sketchBaseline';
22
+
23
+ function readEnabled(): boolean {
24
+ try {
25
+ return localStorage.getItem(ENABLED_KEY) === 'true';
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ function readSettings(): SketchSettings {
32
+ try {
33
+ const raw = localStorage.getItem(SETTINGS_KEY);
34
+ if (raw) return hydrateSketchSettings(JSON.parse(raw));
35
+ } catch {
36
+ // fall through to the default preset
37
+ }
38
+ return { ...SKETCH_PRESETS[DEFAULT_SKETCH_PRESET] };
39
+ }
40
+
41
+ function readBaseline(): SketchSettings | null {
42
+ try {
43
+ const raw = localStorage.getItem(BASELINE_KEY);
44
+ if (raw) return hydrateSketchSettings(JSON.parse(raw));
45
+ } catch {
46
+ // fall through
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /** Marks a saved preset in `sketchPreset`, so a user file named `pencil` and
52
+ the shipped `pencil` stay distinguishable in one string. */
53
+ export const USER_PRESET_PREFIX = 'user:';
54
+
55
+ function readPresetName(): string {
56
+ try {
57
+ const name = localStorage.getItem(PRESET_KEY);
58
+ if (name === '' || (name && (name in SKETCH_PRESETS || name.startsWith(USER_PRESET_PREFIX)))) {
59
+ return name;
60
+ }
61
+ } catch {
62
+ // fall through
63
+ }
64
+ return DEFAULT_SKETCH_PRESET;
65
+ }
66
+
67
+ /** Off by default: the effect is a draft look, never something a project
68
+ inherits without asking for it. */
69
+ export const sketchEnabled = writable<boolean>(readEnabled());
70
+ export const sketchSettings = writable<SketchSettings>(readSettings());
71
+ /** The preset the dials started from. It survives dial moves, so the grid keeps
72
+ showing what the current look is closest to; empty only when nothing was
73
+ picked, or the picked file was deleted. */
74
+ export const sketchPreset = writable<string>(readPresetName());
75
+
76
+ /** The settings as the selected preset defined them. Kept beside the live
77
+ settings so "modified" is derived by comparison rather than tracked as a
78
+ flag: dial a value back to where it started and the tab stops claiming a
79
+ change, with no bookkeeping to get wrong. */
80
+ export const sketchBaseline = writable<SketchSettings | null>(readBaseline());
81
+
82
+ /** Dial-set fields only. `label` and `blurb` name the preset rather than
83
+ describe the look, and no dial writes them. */
84
+ function sameLook(a: SketchSettings, b: SketchSettings): boolean {
85
+ return (Object.keys(a) as (keyof SketchSettings)[])
86
+ .filter((k) => k !== 'label' && k !== 'blurb')
87
+ .every((k) => a[k] === b[k]);
88
+ }
89
+
90
+ export const sketchDirty = derived(
91
+ [sketchSettings, sketchBaseline],
92
+ ([settings, baseline]) => baseline !== null && !sameLook(settings, baseline),
93
+ );
94
+
95
+ export const sketchBlurb = derived(sketchSettings, (s) => s.blurb);
96
+
97
+ export function selectSketchPreset(name: string): void {
98
+ const preset = SKETCH_PRESETS[name];
99
+ if (!preset) return;
100
+ sketchPreset.set(name);
101
+ sketchBaseline.set({ ...preset });
102
+ sketchSettings.set({ ...preset });
103
+ }
104
+
105
+ /** Saved presets, listed from the data tree. Empty until `refreshUserPresets`
106
+ runs, so importing this module never reaches for the network. */
107
+ export const userSketchPresets = writable<SketchPresetMeta[]>([]);
108
+
109
+ export async function refreshUserPresets(): Promise<void> {
110
+ userSketchPresets.set(await listSketchPresets());
111
+ }
112
+
113
+ export async function selectUserSketchPreset(fileName: string): Promise<void> {
114
+ const file = await loadSketchPreset(fileName);
115
+ sketchPreset.set(USER_PRESET_PREFIX + fileName);
116
+ sketchBaseline.set({ ...file.settings });
117
+ sketchSettings.set(file.settings);
118
+ }
119
+
120
+ /** Writes whatever the dials currently say to a named file and selects it, so
121
+ saving leaves the tab pointing at the thing that was just saved rather than
122
+ back on "Adjusted from a preset". Returns the file name it allocated. */
123
+ export async function saveCurrentAsSketchPreset(name: string): Promise<string> {
124
+ const trimmed = name.trim();
125
+ if (!trimmed) throw new Error('A preset needs a name');
126
+ const fileName = slugifySketchPreset(trimmed);
127
+ if (!fileName) throw new Error('That name has no letters or digits to make a file name from');
128
+ const settings = { ...get(sketchSettings), label: trimmed };
129
+ await saveSketchPreset(fileName, trimmed, settings);
130
+ await refreshUserPresets();
131
+ sketchSettings.set(settings);
132
+ sketchBaseline.set({ ...settings });
133
+ sketchPreset.set(USER_PRESET_PREFIX + fileName);
134
+ return fileName;
135
+ }
136
+
137
+ export async function deleteUserSketchPreset(fileName: string): Promise<void> {
138
+ await deleteSketchPreset(fileName);
139
+ await refreshUserPresets();
140
+ // The dials keep their values; only the label stops naming a file that exists.
141
+ if (get(sketchPreset) === USER_PRESET_PREFIX + fileName) {
142
+ sketchPreset.set('');
143
+ sketchBaseline.set(null);
144
+ }
145
+ }
146
+
147
+ /** Every dial goes through here. The selection deliberately survives: the grid
148
+ keeps naming the preset this look came from, and `sketchDirty` reports the
149
+ drift. Save writes a new preset rather than overwriting the base. */
150
+ export function updateSketchSettings(patch: Partial<SketchSettings>): void {
151
+ sketchSettings.update((s) => ({ ...s, ...patch }));
152
+ }
153
+
154
+ function persist(key: string, value: string): void {
155
+ try {
156
+ localStorage.setItem(key, value);
157
+ } catch {
158
+ // Private-mode storage denial is not worth surfacing for a draft look.
159
+ }
160
+ }
161
+
162
+ if (typeof document !== 'undefined') {
163
+ let installed = false;
164
+
165
+ const render = (enabled: boolean, settings: SketchSettings) => {
166
+ if (!enabled) {
167
+ if (installed) removeSketchLayer();
168
+ installed = false;
169
+ return;
170
+ }
171
+ applySketchLayer(settings);
172
+ installed = true;
173
+ // The editor's own chrome must never pick the effect up, so only the host
174
+ // page's root becomes a scope here. The preview container scopes itself.
175
+ setSketchScope(hostRoot(), settings);
176
+ };
177
+
178
+ sketchEnabled.subscribe((enabled) => {
179
+ persist(ENABLED_KEY, String(enabled));
180
+ render(enabled, get(sketchSettings));
181
+ });
182
+
183
+ sketchSettings.subscribe((settings) => {
184
+ persist(SETTINGS_KEY, JSON.stringify(settings));
185
+ render(get(sketchEnabled), settings);
186
+ });
187
+
188
+ sketchPreset.subscribe((name) => persist(PRESET_KEY, name));
189
+ sketchBaseline.subscribe((b) => persist(BASELINE_KEY, b ? JSON.stringify(b) : ''));
190
+ }
@@ -18,7 +18,7 @@ import type { EditorState } from './editorTypes';
18
18
  import { storageKey } from './editorConfig';
19
19
  import { store } from './editorCore';
20
20
  import { quietGet, quietSet } from '../storage/storage';
21
- import { makeDefaultGradients } from '../themes/slices/gradients';
21
+ import { isGradientSlot, makeDefaultGradients } from '../themes/slices/gradients';
22
22
  import { seedShadowsFromDom } from '../themes/slices/shadows';
23
23
  import { sanitizeHarmonyAxes } from '../palettes/colorHarmony';
24
24
  import {
@@ -26,6 +26,10 @@ import {
26
26
  renameBackgroundHarmonyFamily,
27
27
  } from '../themes/migrations/2026-07-29-background-palette-to-canvas';
28
28
  import { placeUnplacedBaseAnchors } from '../themes/migrations/2026-07-29-place-base-anchors';
29
+ import {
30
+ migratePaletteColorsToOklch,
31
+ type PreOklchPaletteConfig,
32
+ } from '../themes/migrations/2026-07-21-palette-oklch-basis';
29
33
 
30
34
  // Resolve the persist key lazily (per-call) so library consumers that invoke
31
35
  // `configureEditor({storagePrefix})` before the first store write get the
@@ -58,16 +62,12 @@ export function persistNow(): void {
58
62
  }
59
63
 
60
64
  function migrateGradients(state: EditorState): EditorState {
61
- // Gradients are a fixed-slot scale (--gradient-1 … --gradient-4). If the
62
- // persisted state predates the migration to fixed slots (e.g. it still has
63
- // a token named --gradient-progress, or the count doesn't match), replace
64
- // the gradients block with defaults rather than carrying stale entries.
65
- const expected = makeDefaultGradients().map((g) => g.variable).sort();
66
- const have = (state.gradients?.tokens ?? []).map((g) => g.variable).sort();
67
- const matches =
68
- have.length === expected.length &&
69
- expected.every((v, i) => v === have[i]);
70
- if (matches) return state;
65
+ // The library is an open `--gradient-N` set, so any number of numbered slots
66
+ // carries forward. Persisted state that predates the numbering (e.g. a token
67
+ // named --gradient-progress) is stale-shaped and falls back to defaults
68
+ // rather than carrying entries no slot name can address.
69
+ const tokens = state.gradients?.tokens ?? [];
70
+ if (tokens.length > 0 && tokens.every((g) => isGradientSlot(g.variable))) return state;
71
71
  return { ...state, gradients: { tokens: makeDefaultGradients() } };
72
72
  }
73
73
 
@@ -113,6 +113,16 @@ export function normalizeBaseAnchors(state: EditorState): EditorState {
113
113
  return { ...state, palettes: placeUnplacedBaseAnchors(state.palettes) };
114
114
  }
115
115
 
116
+ // A session persisted before the numeric OKLCH basis holds hex strings where
117
+ // every palette color is now `{ l, c, h }`. `loadFromFile` migrates those on the
118
+ // way in; hydrate did not, so such a session reached the renderer with an
119
+ // undefined hue and threw on the first serialization. Idempotent, so a
120
+ // current-shape session passes straight through.
121
+ export function normalizePaletteBasis(state: EditorState): EditorState {
122
+ const raw = state.palettes as unknown as Record<string, PreOklchPaletteConfig>;
123
+ return { ...state, palettes: migratePaletteColorsToOklch(raw) };
124
+ }
125
+
116
126
  export function hydrate(): void {
117
127
  // Corrupt state, missing key, or unavailable storage all return null;
118
128
  // the editor falls through to the empty default in that case.
@@ -121,7 +131,13 @@ export function hydrate(): void {
121
131
  // Shallow-merge onto default shape so older persisted state missing
122
132
  // newly-added domain fields still loads.
123
133
  const merged = { ...emptyStateFactory(), ...(parsed as object) } as EditorState;
124
- store.set(normalizeHarmonyAxes(normalizeBaseAnchors(normalizePaletteLabels(normalizeComponents(migrateGradients(merged))))));
134
+ store.set(
135
+ normalizeHarmonyAxes(
136
+ normalizeBaseAnchors(
137
+ normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(migrateGradients(merged)))),
138
+ ),
139
+ ),
140
+ );
125
141
  }
126
142
  // m13 fix: seed shadows from the DOM at hydrate time so the editor
127
143
  // captures the tokens.css baseline regardless of whether the user opens
@@ -1,5 +1,5 @@
1
1
  import type { PaletteConfig, FontSource, FontStack } from '../themes/themeTypes';
2
- import type { GradientStop, GradientValue } from '../themes/parsers/gradient';
2
+ import type { GradientStop, GradientValue, LinearDirection } from '../themes/parsers/gradient';
3
3
  import type { HarmonyAxis } from '../palettes/colorHarmony';
4
4
 
5
5
  export interface ShadowGlobals {
@@ -57,6 +57,9 @@ export interface GradientToken {
57
57
  type: GradientType;
58
58
  /** Degrees, applies to linear only. */
59
59
  angle: number;
60
+ /** `to <side-or-corner>`, emitted instead of `angle` on a linear gradient.
61
+ * Tracks the box's aspect the way a fixed angle cannot. */
62
+ direction?: LinearDirection;
60
63
  /** Pixel radius for radial gradients. When absent or zero, the renderer
61
64
  * emits CSS's default ellipse/farthest-corner shape. */
62
65
  radius?: number;