@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.
- package/.claude/skills/live-tokens-pair-fonts/SKILL.md +2 -2
- package/CHANGELOG.md +124 -0
- package/README.md +1 -1
- package/bin/cli.mjs +2 -1
- package/dist-plugin/adjust/index.cjs +1 -0
- package/dist-plugin/adjust/index.d.cts +2 -2
- package/dist-plugin/adjust/index.d.ts +2 -2
- package/dist-plugin/adjust/index.js +1 -1
- package/dist-plugin/{chunk-TKZBVIW5.js → chunk-E5QYON4L.js} +59 -2
- package/dist-plugin/{chunk-PB2JTK2H.js → chunk-LW4SR7AZ.js} +39 -6
- package/dist-plugin/{chunk-YR6GPXW2.js → chunk-XWXIMTWZ.js} +0 -5
- package/dist-plugin/{chunk-D3ZVKOR4.js → chunk-Y5CNFSSV.js} +1 -0
- package/dist-plugin/{dataPaths-DBN0RPuT.d.cts → dataPaths-CRfD1LdA.d.cts} +3 -0
- package/dist-plugin/{dataPaths-DBN0RPuT.d.ts → dataPaths-CRfD1LdA.d.ts} +3 -0
- package/dist-plugin/fontPairing/index.cjs +4 -2
- package/dist-plugin/fontPairing/index.d.cts +3 -3
- package/dist-plugin/fontPairing/index.d.ts +3 -3
- package/dist-plugin/fontPairing/index.js +4 -3
- package/dist-plugin/generateColorsAndType/index.cjs +22 -9
- package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
- package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
- package/dist-plugin/generateColorsAndType/index.js +8 -5
- package/dist-plugin/index.cjs +158 -9
- package/dist-plugin/index.d.cts +1 -1
- package/dist-plugin/index.d.ts +1 -1
- package/dist-plugin/index.js +71 -4
- package/dist-plugin/migrateData/index.cjs +1 -0
- package/dist-plugin/migrateData/index.d.cts +1 -1
- package/dist-plugin/migrateData/index.d.ts +1 -1
- package/dist-plugin/migrateData/index.js +1 -1
- package/dist-plugin/{themeTypes-BAqtv4XO.d.cts → themeTypes-DSV3Zisf.d.cts} +12 -1
- package/dist-plugin/{themeTypes-BAqtv4XO.d.ts → themeTypes-DSV3Zisf.d.ts} +12 -1
- package/dist-plugin/tokensCssMigrations/index.cjs +58 -1
- package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
- package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
- package/dist-plugin/tokensCssMigrations/index.js +3 -3
- package/package.json +3 -1
- package/src/editor/core/fonts/applyFontPairing.ts +3 -2
- package/src/editor/core/fonts/fontLoader.ts +1 -0
- package/src/editor/core/fonts/fontMigration.ts +31 -1
- package/src/editor/core/palettes/paletteDerivation.ts +9 -2
- package/src/editor/core/sketch/maskField.ts +381 -0
- package/src/editor/core/sketch/sketchLayer.ts +1028 -0
- package/src/editor/core/sketch/sketchPresetService.ts +65 -0
- package/src/editor/core/sketch/sketchPresets.ts +328 -0
- package/src/editor/core/sketch/sketchStore.ts +190 -0
- package/src/editor/core/store/editorPersistence.ts +28 -12
- package/src/editor/core/store/editorTypes.ts +4 -1
- package/src/editor/core/store/editorViewStore.ts +2 -2
- package/src/editor/core/store/gradientSource.ts +15 -1
- package/src/editor/core/themes/parsers/gradient.ts +62 -4
- package/src/editor/core/themes/slices/gradients.ts +75 -14
- package/src/editor/core/themes/themeTypes.ts +6 -1
- package/src/editor/docs/Docs.svelte +4 -3
- package/src/editor/docs/chapters.ts +1 -0
- package/src/editor/docs/content/01-overview.md +2 -1
- package/src/editor/docs/content/editing-tokens.md +5 -1
- package/src/editor/docs/content/sketch-mode.md +89 -0
- package/src/editor/docs/content.generated.ts +3 -2
- package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
- package/src/editor/pages/EditorShell.svelte +43 -0
- package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
- package/src/editor/ui/FontStackEditor.svelte +3 -0
- package/src/editor/ui/GradientEditor.svelte +38 -1
- package/src/editor/ui/UIReveal.svelte +7 -1
- package/src/editor/ui/UISegmentedControl.svelte +4 -8
- package/src/editor/ui/sections/GradientsSection.svelte +47 -1
- package/src/editor/ui/sketch/SketchDial.svelte +124 -0
- package/src/editor/ui/sketch/SketchPreview.svelte +119 -0
- package/src/editor/ui/sketch/SketchRange.svelte +185 -0
- package/src/editor/ui/sketch/SketchTab.svelte +1263 -0
- package/src/live-tokens/data/colors-and-type/autumn.json +17 -0
- package/src/live-tokens/data/colors-and-type/default.json +17 -0
- package/src/live-tokens/data/colors-and-type/halloween.json +17 -0
- package/src/live-tokens/data/colors-and-type/midnight-study.json +17 -0
- package/src/live-tokens/data/colors-and-type/ocean.json +17 -0
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +17 -0
- package/src/live-tokens/data/colors-and-type/sketches.json +2520 -0
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +17 -0
- package/src/live-tokens/data/colors-and-type/sunset.json +17 -0
- package/src/live-tokens/data/themes/autumn.json +17 -0
- package/src/live-tokens/data/themes/halloween.json +17 -0
- package/src/live-tokens/data/themes/midnight-study.json +17 -0
- package/src/live-tokens/data/themes/ocean.json +17 -0
- package/src/live-tokens/data/themes/royal-velvet.json +17 -0
- package/src/live-tokens/data/themes/sketches.json +3984 -0
- package/src/live-tokens/data/themes/spring-meadow.json +17 -0
- package/src/live-tokens/data/themes/sunset.json +17 -0
- package/src/live-tokens/data/tokens.generated.css +1 -0
- 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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
const
|
|
66
|
-
|
|
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(
|
|
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;
|