@motion-proto/live-tokens 0.61.0 → 0.62.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 (36) hide show
  1. package/.claude/skills/live-tokens-build-page/SKILL.md +1 -1
  2. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +1 -1
  3. package/CHANGELOG.md +73 -0
  4. package/dist-plugin/{chunk-OIOXU7FR.js → chunk-OPYOK2CA.js} +45 -25
  5. package/dist-plugin/index.cjs +45 -25
  6. package/dist-plugin/index.js +1 -1
  7. package/dist-plugin/tokensCssMigrations/index.cjs +45 -25
  8. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  9. package/package.json +1 -1
  10. package/src/app/site.css +16 -0
  11. package/src/editor/component-editor/CardEditor.svelte +7 -1
  12. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +25 -2
  13. package/src/editor/core/sketch/maskField.ts +70 -34
  14. package/src/editor/core/sketch/sketchLayer.ts +24 -3
  15. package/src/editor/core/sketch/sketchPresets.ts +10 -11
  16. package/src/editor/core/sketch/sketchStore.ts +89 -18
  17. package/src/editor/docs/content/sketch-mode.md +7 -2
  18. package/src/editor/docs/content.generated.ts +1 -1
  19. package/src/editor/overlay/LiveTokensRouter.svelte +8 -0
  20. package/src/editor/ui/sections/textStyles.ts +15 -1
  21. package/src/editor/ui/sketch/SketchTab.svelte +33 -81
  22. package/src/live-tokens/data/colors-and-type/midnight-study.json +40 -30
  23. package/src/live-tokens/data/themes/autumn.json +3 -3
  24. package/src/live-tokens/data/themes/halloween.json +3 -3
  25. package/src/live-tokens/data/themes/midnight-study.json +61 -49
  26. package/src/live-tokens/data/themes/ocean.json +3 -3
  27. package/src/live-tokens/data/themes/royal-velvet.json +3 -3
  28. package/src/live-tokens/data/themes/sketchy.json +3 -3
  29. package/src/live-tokens/data/themes/spring-meadow.json +3 -3
  30. package/src/live-tokens/data/themes/sunset.json +3 -3
  31. package/src/system/components/Card.svelte +27 -9
  32. package/src/system/components/ImageLightbox.svelte +5 -2
  33. package/src/system/components/SectionDivider.svelte +3 -3
  34. package/src/system/components/SegmentedControl.svelte +11 -9
  35. package/src/system/styles/tokens.css +13 -5
  36. package/template/src/pages/Home.svelte +1 -11
@@ -8,9 +8,13 @@
8
8
  * narrow band around its own midpoint (about 0.24 to 0.77 for fractal noise,
9
9
  * whatever the octaves), so a dial that walks a cut across 0 to 1 spends most
10
10
  * of its travel outside the field entirely and the little that lands inside
11
- * comes out as one flat mid-grey. Generated here, the tile is stretched onto
12
- * its own measured range before any dial sees it, so Min and Max always have
13
- * the whole field to work with and always read as levels.
11
+ * comes out as one flat mid-grey.
12
+ *
13
+ * Generated here, the tile is auto-levelled before any dial sees it, and the
14
+ * dials that follow are a levels control: Steps quantises the field, Output
15
+ * squeezes it into the density range the mask paints between. Neither ever
16
+ * clips: the field arrives running black to white and every tone survives to
17
+ * the other end in the order it started.
14
18
  *
15
19
  * Everything is integer maths on a Float32Array with no DOM, so what a test
16
20
  * asserts is what the browser paints.
@@ -29,7 +33,7 @@ const SAMPLE_PX = 2;
29
33
  const RASTER = MASK_TILE / SAMPLE_PX;
30
34
 
31
35
  /** The stage previews, in the order the field is built. */
32
- export type MaskStage = 'noise' | 'output' | 'blur';
36
+ export type MaskStage = 'noise' | 'levels' | 'blur';
33
37
 
34
38
  /* ---------------------------------------------------------------- noise --- */
35
39
 
@@ -128,41 +132,55 @@ function rawField(s: SketchSettings, seed: number, raster: number, cells: number
128
132
  out[y * raster + x] = sum / weightSum;
129
133
  }
130
134
  }
131
- return normalise(out);
135
+ return equalise(out);
132
136
  }
133
137
 
138
+ const BINS = 4096;
139
+
134
140
  /**
135
- * Stretch the field onto 0..1 by its own 0.5th and 99.5th percentiles.
141
+ * Auto levels: map the field through its own cumulative distribution, so tone
142
+ * is spread evenly from black to white whatever the noise underneath.
136
143
  *
137
- * This is the difference between levels that work and a grey wash. Noise of any
138
- * kind piles up around its middle, and how far it reaches depends on the grain
139
- * and the octave count, so a fixed range would be wrong for every setting but
140
- * one. Measured per tile, Min and Max always address a field that runs black to
141
- * white, and the two dials mean the same thing at every other setting.
144
+ * A black-and-white-point stretch is not enough on its own. Noise piles up
145
+ * around its middle and the veined fold piles it up at the bottom — half a
146
+ * one-layer veined tile sits under 0.29 — so a tile stretched by its extremes
147
+ * still reads as one dark wash, and every dial downstream cuts the range at
148
+ * points most of the field is nowhere near. Equalised, the median lands at 0.5
149
+ * and each fifth of the range holds a fifth of the tile, so a handle at 30
150
+ * addresses the darkest 30% of the field at every grain and octave count.
142
151
  *
143
- * The percentiles rather than the extremes, so one freak pixel cannot flatten
144
- * the rest of the tile.
152
+ * Piecewise-linear through a histogram rather than by rank, so the mapping
153
+ * stays smooth and cannot band a gradient it is meant to spread.
145
154
  */
146
- function normalise(f: Float32Array): Float32Array {
147
- const sorted = Float32Array.from(f).sort();
148
- const lo = sorted[Math.floor(0.005 * (sorted.length - 1))];
149
- const hi = sorted[Math.ceil(0.995 * (sorted.length - 1))];
155
+ function equalise(f: Float32Array): Float32Array {
156
+ let lo = Infinity, hi = -Infinity;
157
+ for (const v of f) { if (v < lo) lo = v; if (v > hi) hi = v; }
150
158
  const span = hi - lo;
151
159
  if (span <= 1e-6) return f.fill(0.5);
152
- for (let i = 0; i < f.length; i++) f[i] = Math.min(1, Math.max(0, (f[i] - lo) / span));
160
+
161
+ const count = new Float64Array(BINS);
162
+ for (const v of f) count[Math.min(BINS - 1, Math.floor(((v - lo) / span) * BINS))]++;
163
+ const below = new Float64Array(BINS + 1);
164
+ for (let b = 0; b < BINS; b++) below[b + 1] = below[b] + count[b];
165
+
166
+ for (let i = 0; i < f.length; i++) {
167
+ const t = ((f[i] - lo) / span) * BINS;
168
+ const b = Math.min(BINS - 1, Math.floor(t));
169
+ f[i] = (below[b] + (t - b) * count[b]) / f.length;
170
+ }
153
171
  return f;
154
172
  }
155
173
 
156
174
  /* ------------------------------------------------------------- levelling --- */
157
175
 
158
- /** Output levels. The field is stretched into the gap between the handles, so
159
- the pair states the range the mask paints: nothing is barer than Min or
160
- denser than Max, and the field keeps its own shape in between.
176
+ /** Output levels, the same squeeze Photoshop's output handles apply. The whole
177
+ field is stretched into the gap between them, so the pair states the range
178
+ the mask paints: nothing is barer than Min or denser than Max, and every
179
+ tone in between keeps its place in the order.
161
180
 
162
- Clamping instead makes Min the value most of the field sits AT rather than
163
- its rare floor, because the field arrives centred on its own middle: half of
164
- it is below 0.5, so a floor of 0.6 piles that half onto 0.6 and the fill
165
- comes out a flat wash at the floor with a thin bright tail above it. */
181
+ Never a cut. Clipping to the handles instead would make Min the value most
182
+ of the field sits AT rather than its floor, and the fill would come out a
183
+ flat wash at the floor with a thin bright tail above it. */
166
184
  function applyOutput(f: Float32Array, min: number, max: number): Float32Array {
167
185
  const span = max - min;
168
186
  for (let i = 0; i < f.length; i++) f[i] = min + f[i] * span;
@@ -178,14 +196,30 @@ function posterise(f: Float32Array, steps: number): Float32Array {
178
196
  return f;
179
197
  }
180
198
 
181
- /** Three box passes, wrapped at the tile edges so the blur cannot draw a rim
182
- where the tile repeats.
199
+ /** Wrapped box blur, so the blur cannot draw a rim where the tile repeats.
183
200
 
184
- The width is the one the filter spec derives for three boxes to land on a
185
- gaussian of a given deviation, so the dial's px are the px it gets. */
201
+ Three boxes at the width the filter spec derives land on a gaussian of a
202
+ given deviation, which is what makes the dial's px the px it gets. But a box
203
+ radius is a whole number of samples and a sample is two page px, so rounding
204
+ to one put the dial on a 2px ladder with a dead zone at the bottom: every
205
+ setting under 2.5px came out perfectly sharp, and everything from 2.5 to
206
+ 4.2px came out identical. Mixing the two radii either side of the exact one
207
+ puts the dial back on a continuous scale, and radius zero is the field
208
+ itself, so the bottom of the travel eases in instead of switching on. */
186
209
  function blur(f: Float32Array, raster: number, std: number): Float32Array {
187
- const radius = Math.round((std * 3 * Math.sqrt(2 * Math.PI) / 4 - 1) / 2);
188
- if (radius < 1) return f;
210
+ const exact = (std * 3 * Math.sqrt(2 * Math.PI) / 4 - 1) / 2;
211
+ if (exact <= 0) return f;
212
+ const lower = Math.floor(exact);
213
+ const mix = exact - lower;
214
+ const low = lower < 1 ? f : boxes(f, raster, lower);
215
+ if (mix < 1e-6) return low;
216
+ const high = boxes(f, raster, lower + 1);
217
+ const out = new Float32Array(f.length);
218
+ for (let i = 0; i < f.length; i++) out[i] = low[i] + (high[i] - low[i]) * mix;
219
+ return out;
220
+ }
221
+
222
+ function boxes(f: Float32Array, raster: number, radius: number): Float32Array {
189
223
  let cur = f;
190
224
  for (let pass = 0; pass < 3; pass++) cur = boxPass(cur, raster, radius);
191
225
  return cur;
@@ -234,8 +268,10 @@ function cachedRaw(s: SketchSettings, seed: number): Float32Array {
234
268
  /**
235
269
  * The finished field, 0 (bare) to 1 (inked), row-major.
236
270
  *
237
- * Three stages and no fourth: the last one IS the result, so the strip of
238
- * previews in the tab accounts for the whole of what the dials do.
271
+ * `through` stops the pipeline early. Nothing in the app asks for a part of it
272
+ * — the tab shows the finished field and nothing else — but the levels are
273
+ * three passes over one array, and the seam is where the tests read what each
274
+ * pass did.
239
275
  */
240
276
  export function buildMaskField(
241
277
  s: SketchSettings, seed = 9, through?: MaskStage,
@@ -246,7 +282,7 @@ export function buildMaskField(
246
282
  const levelled = applyOutput(
247
283
  posterise(Float32Array.from(raw), s.maskPosterize), s.maskOutputMin, s.maskOutputMax,
248
284
  );
249
- if (through === 'output') return { field: levelled, raster: RASTER };
285
+ if (through === 'levels') return { field: levelled, raster: RASTER };
250
286
 
251
287
  return { field: blur(levelled, RASTER, s.maskSoftness / SAMPLE_PX), raster: RASTER };
252
288
  }
@@ -84,6 +84,12 @@ interface PartSpec {
84
84
  drawn corner radii on the host instead, so the clip at least turns the
85
85
  same way the ink does. */
86
86
  clips?: boolean;
87
+ /** Keeps the ink coverage mask off the fill. Coverage wears a fill through in
88
+ patches, and a part that floats over arbitrary page content shows whatever
89
+ is behind it through the worn places: it stops reading as a surface the
90
+ pointer can land on. Set it where the part floats over the page rather
91
+ than over a scrim of its own. */
92
+ unmasked?: boolean;
87
93
  positioned?: boolean;
88
94
  strokeless?: boolean;
89
95
  }
@@ -165,7 +171,7 @@ const PART_SPECS: readonly PartSpec[] = [
165
171
  clips: true,
166
172
  },
167
173
  // The arrow is the tooltip's own ::after, so the box takes the fill only.
168
- { sel: '.tooltip', stem: 'tooltip', positioned: true, strokeless: true },
174
+ { sel: '.tooltip', stem: 'tooltip', positioned: true, strokeless: true, unmasked: true },
169
175
 
170
176
  // Status blocks
171
177
  ...STATUS_VARIANTS.map((v) => ({ sel: `.callout-${v}`, stem: `callout-${v}` })),
@@ -189,6 +195,7 @@ const PART_SPECS: readonly PartSpec[] = [
189
195
  stroke: 'var(--menuselect-menu-border)',
190
196
  radius: 'var(--menuselect-menu-radius, 0px)',
191
197
  shadow: 'var(--menuselect-menu-shadow, none)',
198
+ unmasked: true,
192
199
  },
193
200
  { sel: '.tab', stem: 'tabbar-default', radius: 'var(--tabbar-default-tab-top-radius, 0px)' },
194
201
  { sel: '.tab.active', stem: 'tabbar-active', radius: 'var(--tabbar-active-tab-top-radius, 0px)' },
@@ -316,6 +323,7 @@ const FLOW_PARTS = PART_SPECS.filter((p) => !p.positioned).map((p) => p.sel).joi
316
323
  const STROKE_PARTS = PART_SPECS.filter((p) => !p.strokeless).map((p) => p.sel).join(', ');
317
324
  const UNCLIPPED = PART_SPECS.filter((p) => !p.clips).map((p) => p.sel).join(', ');
318
325
  const CLIPPED = PART_SPECS.filter((p) => p.clips).map((p) => p.sel).join(', ');
326
+ const UNMASKED = PART_SPECS.filter((p) => p.unmasked).map((p) => p.sel).join(', ');
319
327
 
320
328
  export function buildDefsMarkup(s: SketchSettings): string {
321
329
  /**
@@ -853,6 +861,10 @@ export function buildStylesheet(s: SketchSettings): string {
853
861
  `pointer-events:none;` +
854
862
  `}`;
855
863
 
864
+ /* The parts that float over page content take the fill whole, and everything
865
+ else the layer does with it. The second `:is` outweighs the rule above. */
866
+ const solidFills = s.maskOn ? `${el}:is(${UNMASKED})::before{mask-image:none;}` : '';
867
+
856
868
  // The hatch is laid over the fill as a second background LAYER rather than
857
869
  // as `background-image` beside a `background-color`, because a part is free
858
870
  // to name a gradient as its fill: the Kit's lead block hands the layer the
@@ -1037,7 +1049,7 @@ export function buildStylesheet(s: SketchSettings): string {
1037
1049
  }).join('');
1038
1050
 
1039
1051
  return [
1040
- registrations, vars, icons, host, fill, fillStyles, retrace, stroke,
1052
+ registrations, vars, icons, host, fill, solidFills, fillStyles, retrace, stroke,
1041
1053
  seedRotation, shape, jitter, perPart, colours, states,
1042
1054
  ].join('\n');
1043
1055
  }
@@ -1075,8 +1087,17 @@ export function applySketchLayer(settings: SketchSettings): void {
1075
1087
  const defs = buildDefsMarkup(settings);
1076
1088
  const css = buildStylesheet(settings);
1077
1089
  for (const doc of getSyncedDocuments()) {
1090
+ const style = styleNode(doc);
1091
+ // Writing markup a document already has is a visible flash, not a no-op:
1092
+ // rewriting defs destroys every filter the page is mid-paint against, and
1093
+ // rewriting the sheet drops the mask image to be decoded again. Both are
1094
+ // built from the same settings, so the sheet answers for the pair. The
1095
+ // comparison is against the DOM rather than a variable because with the
1096
+ // overlay open two instances of this module render into this page, and the
1097
+ // document is the only ground they share.
1098
+ if (style.textContent === css) continue;
1078
1099
  defsNode(doc).innerHTML = defs;
1079
- styleNode(doc).textContent = css;
1100
+ style.textContent = css;
1080
1101
  }
1081
1102
  }
1082
1103
 
@@ -62,11 +62,10 @@ export interface SketchSettings {
62
62
  /** Wavelength of the coverage noise, in page px. Small gives speckle, large gives broad patches. */
63
63
  maskBlob: number;
64
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. */
65
+ the densest. The whole field is squeezed into the gap, never cut at it, so
66
+ 0.4 to 1 is a fill that is never thinner than 40% ink and 0 to 0.8 one
67
+ that never quite fills in. Close together is a flat wash, far apart a
68
+ strong blotch. */
70
69
  maskOutputMin: number;
71
70
  maskOutputMax: number;
72
71
  /** Detail layers. 1-2 gives broad blobs, 4+ goes cloudy and stops reading as blotches. */
@@ -140,7 +139,7 @@ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
140
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.',
141
140
  fillTravel: 0.75, strokeTravel: 1.25, wobble: 30, roughness: 3, waveform: 1,
142
141
  strokeWidth: 1.25, doubleStroke: true, retracePass: 'reseeded', retraceOffset: 1.5, strokeInk: 0.85,
143
- maskBlob: 40, maskOutputMin: 0.62, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 1, maskSoftness: 0.6,
142
+ maskBlob: 40, maskOutputMin: 0.62, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 1, maskSoftness: 0,
144
143
  jitterX: 1.5, jitterY: 1.5, jitterRot: 0.35, jitterScale: 0.022,
145
144
  cornerSpread: 6, cornerTravel: 4.5,
146
145
  pressure: 0.15, pressureMod: 0.3, pooling: 0, iconTravel: 0.75,
@@ -151,7 +150,7 @@ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
151
150
  blurb: 'Broad translucent nib gone round twice on the same line, so the overlap darkens and the ink pools where it slows.',
152
151
  fillTravel: 2, strokeTravel: 1.5, wobble: 56, waveform: 1.4, borderWavelength: 1.3,
153
152
  strokeWidth: 4, doubleStroke: true, retracePass: 'copy', strokeInk: 0.52, retraceOffset: 2.2,
154
- maskBlob: 115, maskOutputMin: 0.21, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 4, maskSoftness: 2.5,
153
+ maskBlob: 115, maskOutputMin: 0.42, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 4, maskSoftness: 4,
155
154
  jitterX: 3, jitterY: 3, jitterRot: 0.8, jitterScale: 0.045,
156
155
  cornerSpread: 10, cornerTravel: 8,
157
156
  pressure: 0.2, pressureMod: 0.25, pooling: 2, iconTravel: 1.25, iconMaskScale: 4,
@@ -162,8 +161,8 @@ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
162
161
  blurb: 'The fattest nib on glass. One long smooth undulation, and a veined mask that streaks the fill like a half-wiped board.',
163
162
  fillTravel: 2.5, strokeTravel: 2.5, wobble: 90, roughness: 1, waveform: 1, borderWavelength: 1.5,
164
163
  strokeWidth: 5.5, doubleStroke: true, retracePass: 'copy', strokeInk: 0.66, retraceOffset: 3,
165
- maskGrain: 'turbulence', maskBlob: 180, maskOutputMin: 0.42, maskOutputMax: 0.9,
166
- maskOctaves: 1, maskPosterize: 3, maskSoftness: 5,
164
+ maskGrain: 'turbulence', maskBlob: 180, maskOutputMin: 0.42, maskOutputMax: 1,
165
+ maskOctaves: 1, maskPosterize: 3, maskSoftness: 8,
167
166
  jitterX: 4.5, jitterY: 4.5, jitterRot: 1.2, jitterScale: 0.07,
168
167
  cornerSpread: 14, cornerTravel: 11,
169
168
  pressure: 0.15, pressureMod: 0.15, pooling: 3.5, iconTravel: 1.75, iconMaskScale: 1.5,
@@ -196,7 +195,7 @@ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
196
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.',
197
196
  fillTravel: 3, strokeTravel: 2.25, wobble: 50, roughness: 3, waveform: 2.5,
198
197
  strokeWidth: 2.25, doubleStroke: true, retracePass: 'reseeded', retraceOffset: 4, strokeInk: 1,
199
- maskBlob: 150, maskOutputMin: 0.43, maskOutputMax: 0.95, maskOctaves: 2, maskPosterize: 2, maskSoftness: 6.7,
198
+ maskBlob: 150, maskOutputMin: 0.43, maskOutputMax: 0.95, maskOctaves: 2, maskPosterize: 2, maskSoftness: 10,
200
199
  jitterX: 6, jitterY: 6, jitterRot: 1.8, jitterScale: 0.1,
201
200
  cornerSpread: 20, cornerTravel: 17,
202
201
  pressure: 0.45, pressureMod: 0.6, pooling: 2.5, iconTravel: 2.25, iconMaskScale: 3.6,
@@ -207,7 +206,7 @@ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
207
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.',
208
207
  fillTravel: 2.25, strokeTravel: 1.75, wobble: 50, waveform: 2, borderWavelength: 0.5,
209
208
  strokeWidth: 3.5, doubleStroke: false, strokeInk: 0.4,
210
- maskBlob: 150, maskOutputMin: 0.15, maskOutputMax: 0.91,
209
+ maskBlob: 150, maskOutputMin: 0.24, maskOutputMax: 0.91,
211
210
  maskOctaves: 2, maskPosterize: 4, maskSoftness: 1.75,
212
211
  jitterX: 5, jitterY: 5, jitterRot: 1.4, jitterScale: 0.08,
213
212
  cornerSpread: 16, cornerTravel: 13,
@@ -159,32 +159,103 @@ function persist(key: string, value: string): void {
159
159
  }
160
160
  }
161
161
 
162
- if (typeof document !== 'undefined') {
163
- let installed = false;
162
+ /** The root the effect paints in this document, or null while this document is
163
+ showing an editor surface. Registered by LiveTokensRouter: a consumer can
164
+ relocate the editor routes, so the router is the only thing that knows
165
+ whether what is on screen is a page or the editor's own chrome. Null until
166
+ it says so, which is what keeps the editor from flashing the effect over
167
+ itself between import and first render. */
168
+ let pageRoot: HTMLElement | null = null;
164
169
 
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
- };
170
+ let installed = false;
171
+
172
+ function render(enabled: boolean, settings: SketchSettings): void {
173
+ if (typeof document === 'undefined') return;
174
+ if (!enabled) {
175
+ if (installed) removeSketchLayer();
176
+ installed = false;
177
+ return;
178
+ }
179
+ applySketchLayer(settings);
180
+ installed = true;
181
+ // The editor's own chrome must never pick the effect up. Two roots qualify:
182
+ // the host page behind the overlay iframe, and this document while it is
183
+ // showing a page. The preview container scopes itself.
184
+ setSketchScope(hostRoot(), settings);
185
+ setSketchScope(pageRoot, settings);
186
+ }
187
+
188
+ export function setSketchPageRoot(el: HTMLElement | null): void {
189
+ if (el === pageRoot) return;
190
+ setSketchScope(pageRoot, null);
191
+ pageRoot = el;
192
+ render(get(sketchEnabled), get(sketchSettings));
193
+ }
194
+
195
+ /**
196
+ * What this document last exchanged with the other one, per key: written by
197
+ * `share`, recorded by `adopt`.
198
+ *
199
+ * A document must never write back a value it adopted. Comparing against its
200
+ * own store is not enough, because the echo is not identical, it is LATE: a
201
+ * drag sends a value every frame, the far side adopts the first and writes it
202
+ * back, and by then this side is three frames along. It reads the echo, sees a
203
+ * value it does not hold, and adopts its own past — the handle jumps back under
204
+ * the cursor and the drag cannot move.
205
+ */
206
+ const shared = new Map<string, string>();
177
207
 
208
+ function share(key: string, value: string): void {
209
+ if (shared.get(key) === value) return;
210
+ shared.set(key, value);
211
+ persist(key, value);
212
+ }
213
+
214
+ function adopt(key: string, value: string): void {
215
+ shared.set(key, value);
216
+ }
217
+
218
+ if (typeof document !== 'undefined') {
178
219
  sketchEnabled.subscribe((enabled) => {
179
- persist(ENABLED_KEY, String(enabled));
220
+ share(ENABLED_KEY, String(enabled));
180
221
  render(enabled, get(sketchSettings));
181
222
  });
182
223
 
183
224
  sketchSettings.subscribe((settings) => {
184
- persist(SETTINGS_KEY, JSON.stringify(settings));
225
+ share(SETTINGS_KEY, JSON.stringify(settings));
185
226
  render(get(sketchEnabled), settings);
186
227
  });
187
228
 
188
- sketchPreset.subscribe((name) => persist(PRESET_KEY, name));
189
- sketchBaseline.subscribe((b) => persist(BASELINE_KEY, b ? JSON.stringify(b) : ''));
229
+ sketchPreset.subscribe((name) => share(PRESET_KEY, name));
230
+ sketchBaseline.subscribe((b) => share(BASELINE_KEY, b ? JSON.stringify(b) : ''));
231
+
232
+ /* The overlay editor runs in an iframe, so the Sketch tab and a control on
233
+ the page are separate instances of this module. localStorage is the ground
234
+ they share: each adopts a value only when it differs from what it holds,
235
+ and records what it adopted so it never sends that value back. */
236
+ window.addEventListener('storage', (event) => {
237
+ if (event.storageArea !== localStorage) return;
238
+ if (event.key === ENABLED_KEY) {
239
+ const next = event.newValue === 'true';
240
+ adopt(ENABLED_KEY, String(next));
241
+ if (next !== get(sketchEnabled)) sketchEnabled.set(next);
242
+ } else if (event.key === SETTINGS_KEY) {
243
+ if (event.newValue && event.newValue !== JSON.stringify(get(sketchSettings))) {
244
+ const next = readSettings();
245
+ adopt(SETTINGS_KEY, JSON.stringify(next));
246
+ sketchSettings.set(next);
247
+ }
248
+ } else if (event.key === PRESET_KEY) {
249
+ const next = readPresetName();
250
+ adopt(PRESET_KEY, next);
251
+ if (next !== get(sketchPreset)) sketchPreset.set(next);
252
+ } else if (event.key === BASELINE_KEY) {
253
+ const current = get(sketchBaseline);
254
+ if ((event.newValue || '') !== (current ? JSON.stringify(current) : '')) {
255
+ const next = readBaseline();
256
+ adopt(BASELINE_KEY, next ? JSON.stringify(next) : '');
257
+ sketchBaseline.set(next);
258
+ }
259
+ }
260
+ });
190
261
  }
@@ -50,8 +50,13 @@ your own, alongside the shipped seven, as a file under
50
50
  few pixels off or runs it through the pen again on its own seed.
51
51
  - **Fill.** Solid or hatched, how far the fill's edge travels, and how far each
52
52
  instance is offset, rotated and scaled from its neighbours. **Ink coverage**
53
- thins the fill with a field of blotches: set their size, how many levels of
54
- detail, how pale and how dense they go, and how soft their edges are.
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
55
+ to white whatever the noise underneath. Steps flattens it into tones, and
56
+ Output squeezes the whole of it into the range the ink covers, from how pale
57
+ it gets at its thinnest to how dense at its fullest. Menus and tooltips are
58
+ drawn solid whatever the coverage dials say: they float over the page, and a
59
+ fill worn through in patches lets the page show through them.
55
60
  - **Shape.** **Corner spread** rounds each corner by its own share of the dial,
56
61
  so no two match. **Corner travel** leans the drawn box into a quadrilateral
57
62
  with no two sides parallel. This is the dial that stops a component reading as
@@ -7,7 +7,7 @@ export const docContent: Record<string, string> = {
7
7
  "editing-tokens": "# Editing tokens\n\nA tour of the editor. The page behind it repaints on every change; saving\nwrites a theme file you can reload later.\n\nThe editor has four views:\n\n- **Tokens**: the design-system primitives (colour, type, spacing, and so on).\n They apply everywhere your site uses them.\n- **Color Wheel**: the harmony wheel, the palette curves, and the story your colours\n tell across a page.\n- **Components**: per-component editors. Re-Assign what tokens a component uses\n without changing the underlying system.\n- **Sketch Style**: an effect layer that redraws the page by hand. See\n [Sketch mode](sketch-mode.md).\n\nThis page covers **Tokens**. For components, see\n[Creating components](creating-components.md).\n\n## Palettes\n\nMost colour work happens here. Each palette (Brand, Accent, Neutral, Canvas,\nSuccess, Warning, Info, Danger, and a few more) has:\n\n- **Base colour.** Pick a hex; the palette derives an 11-step ramp (100 to 950)\n from it.\n- **Curves.** Three curves shape the ramp, in stack order: Hue, Saturation,\n Lightness. Drag the handles to bias it warmer or cooler, more or less\n saturated, darker or lighter. Hue drifts the ramp's temperature without\n moving contrast, because OKLCH hue rotation is close to lightness-preserving.\n It holds ±45 degrees; a bigger shift belongs on the base colour or the\n harmony axis.\n- **Overrides.** Lock a single step to a hand-picked hex when the curve doesn't\n land where you want.\n\nEditing a palette base ripples through every colour that depends on it, in real\ntime. Colours use OKLCH, so the ramp stays perceptually even across hues\nwithout muddy mid-tones.\n\n## Type\n\n- **Fonts.** Add sources from Google Fonts, Adobe (Typekit), a CSS URL, or an\n inline `@font-face`. The font loads in the page as soon as you add it.\n- **Stacks.** Named font cascades you reference by token, such as a display\n stack and a body stack.\n- **Sizes and weights.** A t-shirt scale (xs, sm, md, lg, xl, 2xl…) for size and\n a numeric scale (100 to 900) for weight.\n\n## Spacing, radius, shadow\n\nNumeric scales with a slider per step.\n\n- **Spacing**: the padding, gap, and margin scale.\n- **Radius**: none through full.\n- **Shadow**: colour, offset, blur, spread, and opacity per step, with stacked\n shadows supported.\n\nChange a step and every element using it repaints.\n\n## Overlays and gradients\n\n- **Overlays** are translucent tints layered over surfaces, like the subtle\n tint a card gets on hover. Set a colour and opacity per state.\n- **Gradients** are reusable gradient tokens with a stop list and direction, for\n hero panels and accent backgrounds.\n\n## Columns\n\nThe page-grid overlay. Set column count, gutter, and outer margin, and toggle\nthe visual guide with `Cmd/Ctrl+G`. Pages built on the column system reflow\nlive.\n\n## Saving\n\nThe editor saves to your browser continuously, so work survives a reload\nmid-edit. Writing a file is a separate step: the **Theme** panel at the foot of\nthe sidebar has **Save**, **Save As**, and **Load**, and each theme is one JSON\nfile under `src/live-tokens/data/themes/`.\n\nThe header gives you undo/redo (`Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`). You can keep\nmany themes side by side; one is open at a time, and only **Adopt** publishes\none. See [Themes](themes-workflow.md) for the full lifecycle.\n",
8
8
  "getting-started": "# Getting started\n\nScaffold a live token site in a moments. You need Node 20 or later, a\npackage manager (npm, pnpm, or yarn), and a browser. Open claude code in your repo and start building.\n\n## Scaffold a new app\n\n```bash\nnpm create @motion-proto/live-tokens@latest my-app\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen the URL Vite prints (usually `http://localhost:5173`). You get a\none-page Svelte + Vite app that depends on the published package, with the\neditor wired up and the full component set ready to import.\n\n`npx @motion-proto/live-tokens create my-app` runs the same scaffold without\nthe initialiser package.\n\n### What the scaffold gives you\n\nEvery editable file lives under `src/` and is committed, so `npm install` and\nversion upgrades never touch your styles. The package code stays in\n`node_modules`.\n\n| Path | What it is |\n|------|------------|\n| `src/pages/Home.svelte` | The starter page. Replace it with your own content. |\n| `src/App.svelte` | Your routes. `<LiveTokensRouter>` adds dev-only routes under a reserved `/live-tokens/*` namespace: `/live-tokens/editor`, `/live-tokens/components`, and `/live-tokens/docs`. |\n| `src/system/styles/tokens.css` | Your base token vocabulary, hand-authored. |\n| `src/styles/site.css` | Themed page typography, yours to edit. |\n\n## Your first edit\n\n1. Run `npm run dev` and open the home page.\n2. Click **Open Token Editor**, or visit `/live-tokens/editor`. The editor opens beside\n the page.\n3. Open **Palettes**, pick **Brand**, and change the base hex. The page\n repaints as you type.\n4. In the **Theme** panel at the foot of the sidebar, choose **Save As**. Your\n theme appears as JSON under `src/live-tokens/data/themes/`.\n5. Reload. The editor reopens on your theme, so the page returns as you left\n it.\n\n## What you just changed\n\nEvery edit sets a CSS custom property on `:root`. Your components read those\nproperties through `var(--...)`. There is no token build step and no\npreprocessor rewriting your code: the page renders against plain CSS variables\nthe editor swaps live.\n\nTo ship, click **Adopt** in the Theme panel. That saves the open theme and bakes\nit into `src/live-tokens/data/tokens.generated.css`, which your build bundles\nalongside `tokens.css`. Adopt is the only action that changes what your site\nships, so try any look you like first. The editor itself never reaches\nproduction.\n\nAlready have a Svelte 5 + Vite app? The\n[README](https://github.com/motionproto/live-tokens#readme) covers installing\ninto an existing project.\n\n## Where to go next\n\n- **[Editing tokens](editing-tokens.md)**: a tour of the editor.\n- **[Themes](themes-workflow.md)**: save, switch, and ship.\n- **[Creating components](creating-components.md)**: make your own component\n editable.\n",
9
9
  "light-and-dark": "# Light and dark\n\nSome things on a page cannot be written as a token. A wordmark drawn in white\ndisappears on a pale theme. Ink that multiplies onto paper vanishes on a dark\none. A photograph behind a headline is dark no matter what the palette says.\n\nEach of those needs the same fact first: which way does the surface behind this\nthing lean? One attribute carries it.\n\n## The attribute\n\n`data-backdrop` is either `light` or `dark`, and it does two things at once: it\nselects, so a rule can key on it, and it sets `color-scheme`, so every\n`light-dark()` under it resolves the half that reads.\n\n```css\n.title {\n color: light-dark(var(--color-black), var(--color-white));\n}\n```\n\nThat line is right on both sides of the theme, and it is right inside a dark\nband on a pale page, because the nearest `color-scheme` wins.\n\n## Stating it\n\nPut it in the markup when the surface knows its own tone — a hero over a\nphotograph, a plate that stays pale in every theme:\n\n```svelte\n<div class=\"hero-panel\" data-backdrop=\"dark\">\n```\n\nA stated tone beats any measurement, and it inherits, so everything inside the\npanel resolves against it.\n\n## Measuring it\n\nWhere the tone is a property of the theme rather than of the markup, let it be\nmeasured:\n\n```svelte\n<script>\n import { backdrop } from '@motion-proto/live-tokens/backdrop';\n</script>\n\n<section use:backdrop>\n```\n\nThe action reads whatever actually paints behind the element — the nearest\nancestor with an opaque fill, averaged across its gradient stops, falling back\nto the theme's `--page-bg` — and stamps the answer. It re-reads when the theme\nchanges, which the editor does by rewriting custom properties with no reload,\nso the stamp follows a live edit.\n\nThe page itself is stamped for you: the build bakes the production theme's\npolarity into `tokens.generated.css`, so the first paint is already right, and\n`syncDocumentBackdrop()` keeps `<html>` current as themes switch.\n\n```ts\nimport { syncDocumentBackdrop } from '@motion-proto/live-tokens/backdrop';\n\nsyncDocumentBackdrop();\n```\n\n## Reading it from JavaScript\n\nAnything that paints outside CSS — a canvas, a WebGL uniform, an `<img>` that\ncomes in two versions — asks the same question through the same module:\n\n```ts\nimport { isLightBackdrop, watchBackdrop, cssColorToHex } from '@motion-proto/live-tokens/backdrop';\n\nconst stop = watchBackdrop(logoEl, {\n stamp: false,\n onChange: (polarity) => (src = polarity === 'light' ? darkMark : lightMark),\n});\n```\n\n`isLightBackdrop(el)` answers once. `watchBackdrop` keeps answering and returns\na stop function. `cssColorToHex` resolves any CSS colour — including the\n`oklch()` a token holds — to a hex a non-CSS consumer can take.\n\n## What it does not do\n\nPolarity is a property of a surface, not of a component, so nothing is stamped\nfor you below `<html>`: a section that needs an answer either states one or asks\nfor one. And a measurement reads the paint at the moment it runs — an element\nthat scrolls from a pale band onto a dark one keeps the answer it was given.\nState the tone on each band instead.\n",
10
- "sketch-mode": "# Sketch mode\n\nSketch mode redraws your whole page as if it had been drawn by hand. Every\ncomponent keeps its own colours, spacing and corners; what changes is the line\nthey are drawn with.\n\nIt is an effect layer, not a set of token values. It reads nothing from your\ntheme and writes nothing back, so it never touches a token, never lands in a\ntheme file, and never reaches the CSS you ship. Turn it off and every trace of\nit goes.\n\nOpen the **Sketch Style** view in the editor and switch **Sketch mode** on. The effect\napplies to the page behind the editor as well as to the preview, so what you see\nin context is what it does.\n\n## What it draws\n\nEach component's fill and outline are repainted from the tokens that component\nalready owns. The real background and border are hidden behind them, then both\nare pushed around one shared field of noise. Because every component samples the\nsame field, the whole page reads as one drawing rather than as a set of\nseparately wobbled boxes.\n\n## The presets\n\nSeven looks ship with the package, and each is a complete set of dials rather\nthan a style name:\n\n- **Pencil.** Two graphite passes on their own seeds, so the outline disagrees\n with itself the way a hand coming back round does.\n- **Marker.** A broad translucent nib gone round twice on the same line, so the\n overlap darkens and the ink pools where it slows.\n- **Whiteboard.** The fattest nib on glass, with a mask that streaks the fill\n like a half-wiped board.\n- **Hatched.** An etching. The fill is angled shading and the outline a single\n hard-edged scratch.\n- **Dashed.** A drafting outline: one slow drift along the ruler, broken into\n strokes. The clean pole.\n- **Napkin.** Ballpoint in a hurry. Everything loose at once.\n- **Dry marker.** Ink that ran out. One scratchy pass over a mostly eaten fill.\n\nPick one, then move whatever you like. **Save** keeps your dials under a name of\nyour own, alongside the shipped seven, as a file under\n`src/live-tokens/data/sketch-presets/`.\n\n## The dials\n\n- **Border.** How far the outline travels and how long its wave is, then its\n width, ink, pressure and pooling. A second pass either copies the first line a\n few pixels off or runs it through the pen again on its own seed.\n- **Fill.** Solid or hatched, how far the fill's edge travels, and how far each\n instance is offset, rotated and scaled from its neighbours. **Ink coverage**\n thins the fill with a field of blotches: set their size, how many levels of\n detail, how pale and how dense they go, and how soft their edges are.\n- **Shape.** **Corner spread** rounds each corner by its own share of the dial,\n so no two match. **Corner travel** leans the drawn box into a quadrilateral\n with no two sides parallel. This is the dial that stops a component reading as\n a rectangle.\n- **Icons and SVG.** Glyph travel and wavelength on their own scale. A glyph is\n all curves already, so it needs more travel than a card's long straight edge\n before the wobble reads at all.\n- **Noise.** The shared field itself: its wavelength, how many layers of detail\n sit on it, and the shape of its wave. A square wave sends nearly every edge to\n full travel, which is what makes the effect stronger rather than bigger.\n\n## Where the settings live\n\nThe dials you are moving live in your browser, so the effect follows you across\nreloads and stays off everyone else's screen. **Save** writes a named preset to\n`src/live-tokens/data/sketch-presets/`, which is the only thing that reaches\ndisk.\n\nSketch mode is a tool for looking at the page, not a layer the page can ship.\nNothing is written into a theme, `tokens.generated.css` never sees it, and a\nproduction build has no sketch layer in it at all.\n\n## Drawing your own elements\n\nThe layer draws a fixed set of parts: the shipped components, and four classes\nit reserves for you. Nothing else is touched, so a page element or a\nconsumer-authored component is left crisp until it carries one of them.\n\n| Class | For |\n|---------------------|-----------------------------------------------------------|\n| `sketch-surface` | A box. The default treatment. |\n| `sketch-container` | A large box. Tilts less, so the type inside stays readable. |\n| `sketch-chip` | A small box. Finer fill mask, more rotation, less travel. |\n| `sketch-rule` | A line rather than a box. No rotation, no rounded ends. |\n\nPick by size, not by kind: a card and a modal both take `sketch-container`, a\nbadge and a pill both take `sketch-chip`.\n\nThe class opts the element in; it names no colours, so the element states its\nown. `--sketch-fill`, `--sketch-stroke`, `--sketch-hatch-color`,\n`--sketch-radius` and `--sketch-shadow` name the fill, the outline, the hatching\nink, the corners and the shadow for one element and everything inside it. The\nlayer blanks the real background and border, so an element whose fill matters\nunder Sketch mode has to name it here as well as paint it.\n\nThe layer also paints on the element's `::before` and `::after`, forces its\n`overflow` visible, and gives it a stacking context of its own. Keep the class\noff anything that owns a pseudo-element, clips its content, or is positioned\nabsolutely, and put it on a wrapper instead.\n\n```css\n.my-callout {\n background: var(--surface-brand-lowest);\n border: var(--border-width-1) solid var(--border-brand);\n border-radius: var(--radius-xl);\n\n --sketch-fill: var(--surface-brand-lowest);\n --sketch-stroke: var(--border-brand);\n --sketch-radius: var(--radius-xl);\n}\n```\n\nA gradient is a valid fill: the shorthand's last layer takes a colour or an\nimage, so `--sketch-fill` accepts either. States work the same way, since\nnothing is competing with you for the value:\n\n```css\n.my-callout:hover { --sketch-stroke: var(--border-brand-strong); }\n```\n\n## Images inside a drawn part\n\nA drawn part's `overflow` is forced visible, because the fill and outline are\npainted on pseudo-elements that travel past the box and would otherwise be cut\noff at its edge. A background that bleeds is the effect working. An image that\nbleeds is not: it keeps its square corners while the card around it turns.\n\nMedia that runs to a part's edge therefore has to carry that part's corners\nitself. `--sketch-radius` is the radius the layer drew, and it inherits, so a\nchild can read it and fall back to its own value when Sketch mode is off:\n\n```css\n.cover {\n overflow: hidden;\n border-top-left-radius: var(--sketch-radius, var(--card-default-radius));\n border-top-right-radius: var(--sketch-radius, var(--card-default-radius));\n}\n```\n\nCorner spread is per-corner and per-instance, so at high spread the crop is the\nmean rather than an exact trace of the drawn edge.\n\nA rule made from a `border` is not a box and cannot be displaced. Make it an\nelement, give it `sketch-rule`, and name its ink:\n\n```html\n<div class=\"rule sketch-rule\"></div>\n```\n```css\n.rule {\n height: var(--border-width-2);\n background: var(--border-brand);\n --sketch-fill: var(--border-brand);\n}\n```\n\nIcons and inline SVG take the wobble directly, since a glyph has no box to\nredraw. Body type is left alone: an icon is a shape and survives a wobble, a\nparagraph is not.\n\n`--sketch-icon-off` names what a subtree's glyphs are drawn with instead. It\ninherits, so one declaration covers everything under it, and it takes the ink\nmask off as well as the wobble:\n\n```css\n/* Crisp. Chrome, a logo, anything that has to stay exact. */\n.app-bar { --sketch-icon-off: none; }\n\n/* Drawn back rather than off, at a third of the travel. Small artwork, and\n type set as an SVG, which the layer reads as one large glyph. */\n.wordmark { --sketch-icon-off: var(--sketch-icon-soft); }\n```\n\nThe **Blotch size** dial under Icons and SVG is a share of the glyph rather than\na px size, because no px size is right for both a 16px icon and a page-wide\ndrawing. At 100% every glyph gets one period of the field across it whatever its\nsize. Below that the field repeats inside the glyph and the blotches get finer.\nAbove it a glyph reads part of one blotch, so the mask thins the whole glyph\nunevenly instead of breaking it up. The fill's blotches stay in px, since a\ncomponent does have a size to state one against.\n",
10
+ "sketch-mode": "# Sketch mode\n\nSketch mode redraws your whole page as if it had been drawn by hand. Every\ncomponent keeps its own colours, spacing and corners; what changes is the line\nthey are drawn with.\n\nIt is an effect layer, not a set of token values. It reads nothing from your\ntheme and writes nothing back, so it never touches a token, never lands in a\ntheme file, and never reaches the CSS you ship. Turn it off and every trace of\nit goes.\n\nOpen the **Sketch Style** view in the editor and switch **Sketch mode** on. The effect\napplies to the page behind the editor as well as to the preview, so what you see\nin context is what it does.\n\n## What it draws\n\nEach component's fill and outline are repainted from the tokens that component\nalready owns. The real background and border are hidden behind them, then both\nare pushed around one shared field of noise. Because every component samples the\nsame field, the whole page reads as one drawing rather than as a set of\nseparately wobbled boxes.\n\n## The presets\n\nSeven looks ship with the package, and each is a complete set of dials rather\nthan a style name:\n\n- **Pencil.** Two graphite passes on their own seeds, so the outline disagrees\n with itself the way a hand coming back round does.\n- **Marker.** A broad translucent nib gone round twice on the same line, so the\n overlap darkens and the ink pools where it slows.\n- **Whiteboard.** The fattest nib on glass, with a mask that streaks the fill\n like a half-wiped board.\n- **Hatched.** An etching. The fill is angled shading and the outline a single\n hard-edged scratch.\n- **Dashed.** A drafting outline: one slow drift along the ruler, broken into\n strokes. The clean pole.\n- **Napkin.** Ballpoint in a hurry. Everything loose at once.\n- **Dry marker.** Ink that ran out. One scratchy pass over a mostly eaten fill.\n\nPick one, then move whatever you like. **Save** keeps your dials under a name of\nyour own, alongside the shipped seven, as a file under\n`src/live-tokens/data/sketch-presets/`.\n\n## The dials\n\n- **Border.** How far the outline travels and how long its wave is, then its\n width, ink, pressure and pooling. A second pass either copies the first line a\n few pixels off or runs it through the pen again on its own seed.\n- **Fill.** Solid or hatched, how far the fill's edge travels, and how far each\n instance is offset, rotated and scaled from its neighbours. **Ink coverage**\n thins the fill with a field of blotches: set their size and how many levels of\n detail, then work the field as a levels control. The field always runs black\n to white whatever the noise underneath. Steps flattens it into tones, and\n Output squeezes the whole of it into the range the ink covers, from how pale\n it gets at its thinnest to how dense at its fullest. Menus and tooltips are\n drawn solid whatever the coverage dials say: they float over the page, and a\n fill worn through in patches lets the page show through them.\n- **Shape.** **Corner spread** rounds each corner by its own share of the dial,\n so no two match. **Corner travel** leans the drawn box into a quadrilateral\n with no two sides parallel. This is the dial that stops a component reading as\n a rectangle.\n- **Icons and SVG.** Glyph travel and wavelength on their own scale. A glyph is\n all curves already, so it needs more travel than a card's long straight edge\n before the wobble reads at all.\n- **Noise.** The shared field itself: its wavelength, how many layers of detail\n sit on it, and the shape of its wave. A square wave sends nearly every edge to\n full travel, which is what makes the effect stronger rather than bigger.\n\n## Where the settings live\n\nThe dials you are moving live in your browser, so the effect follows you across\nreloads and stays off everyone else's screen. **Save** writes a named preset to\n`src/live-tokens/data/sketch-presets/`, which is the only thing that reaches\ndisk.\n\nSketch mode is a tool for looking at the page, not a layer the page can ship.\nNothing is written into a theme, `tokens.generated.css` never sees it, and a\nproduction build has no sketch layer in it at all.\n\n## Drawing your own elements\n\nThe layer draws a fixed set of parts: the shipped components, and four classes\nit reserves for you. Nothing else is touched, so a page element or a\nconsumer-authored component is left crisp until it carries one of them.\n\n| Class | For |\n|---------------------|-----------------------------------------------------------|\n| `sketch-surface` | A box. The default treatment. |\n| `sketch-container` | A large box. Tilts less, so the type inside stays readable. |\n| `sketch-chip` | A small box. Finer fill mask, more rotation, less travel. |\n| `sketch-rule` | A line rather than a box. No rotation, no rounded ends. |\n\nPick by size, not by kind: a card and a modal both take `sketch-container`, a\nbadge and a pill both take `sketch-chip`.\n\nThe class opts the element in; it names no colours, so the element states its\nown. `--sketch-fill`, `--sketch-stroke`, `--sketch-hatch-color`,\n`--sketch-radius` and `--sketch-shadow` name the fill, the outline, the hatching\nink, the corners and the shadow for one element and everything inside it. The\nlayer blanks the real background and border, so an element whose fill matters\nunder Sketch mode has to name it here as well as paint it.\n\nThe layer also paints on the element's `::before` and `::after`, forces its\n`overflow` visible, and gives it a stacking context of its own. Keep the class\noff anything that owns a pseudo-element, clips its content, or is positioned\nabsolutely, and put it on a wrapper instead.\n\n```css\n.my-callout {\n background: var(--surface-brand-lowest);\n border: var(--border-width-1) solid var(--border-brand);\n border-radius: var(--radius-xl);\n\n --sketch-fill: var(--surface-brand-lowest);\n --sketch-stroke: var(--border-brand);\n --sketch-radius: var(--radius-xl);\n}\n```\n\nA gradient is a valid fill: the shorthand's last layer takes a colour or an\nimage, so `--sketch-fill` accepts either. States work the same way, since\nnothing is competing with you for the value:\n\n```css\n.my-callout:hover { --sketch-stroke: var(--border-brand-strong); }\n```\n\n## Images inside a drawn part\n\nA drawn part's `overflow` is forced visible, because the fill and outline are\npainted on pseudo-elements that travel past the box and would otherwise be cut\noff at its edge. A background that bleeds is the effect working. An image that\nbleeds is not: it keeps its square corners while the card around it turns.\n\nMedia that runs to a part's edge therefore has to carry that part's corners\nitself. `--sketch-radius` is the radius the layer drew, and it inherits, so a\nchild can read it and fall back to its own value when Sketch mode is off:\n\n```css\n.cover {\n overflow: hidden;\n border-top-left-radius: var(--sketch-radius, var(--card-default-radius));\n border-top-right-radius: var(--sketch-radius, var(--card-default-radius));\n}\n```\n\nCorner spread is per-corner and per-instance, so at high spread the crop is the\nmean rather than an exact trace of the drawn edge.\n\nA rule made from a `border` is not a box and cannot be displaced. Make it an\nelement, give it `sketch-rule`, and name its ink:\n\n```html\n<div class=\"rule sketch-rule\"></div>\n```\n```css\n.rule {\n height: var(--border-width-2);\n background: var(--border-brand);\n --sketch-fill: var(--border-brand);\n}\n```\n\nIcons and inline SVG take the wobble directly, since a glyph has no box to\nredraw. Body type is left alone: an icon is a shape and survives a wobble, a\nparagraph is not.\n\n`--sketch-icon-off` names what a subtree's glyphs are drawn with instead. It\ninherits, so one declaration covers everything under it, and it takes the ink\nmask off as well as the wobble:\n\n```css\n/* Crisp. Chrome, a logo, anything that has to stay exact. */\n.app-bar { --sketch-icon-off: none; }\n\n/* Drawn back rather than off, at a third of the travel. Small artwork, and\n type set as an SVG, which the layer reads as one large glyph. */\n.wordmark { --sketch-icon-off: var(--sketch-icon-soft); }\n```\n\nThe **Blotch size** dial under Icons and SVG is a share of the glyph rather than\na px size, because no px size is right for both a 16px icon and a page-wide\ndrawing. At 100% every glyph gets one period of the field across it whatever its\nsize. Below that the field repeats inside the glyph and the blotches get finer.\nAbove it a glyph reads part of one blotch, so the mask thins the whole glyph\nunevenly instead of breaking it up. The fill's blotches stay in px, since a\ncomponent does have a size to state one against.\n",
11
11
  "themes-workflow": "# Themes\n\nSave your work, switch between looks, and ship one to production.\n\n## The Theme panel\n\nThe **Theme** panel at the foot of the editor sidebar holds the whole look:\ncolors, type, and a setting for every component, in one file. It carries the\nname the look ships under, whether production is running it, and **Adopt**.\nTwo parts sit under it, each a read-out rather than a file to manage.\n\n- **Colors & Type** holds the design tokens. Components read those tokens to\n define their appearance. It names the two faces the page is showing.\n- **Components** counts how many components have an unsaved edit that has not\n been saved into the theme, and opens the component editors.\n\nA theme holds its own copy of every part, so one theme can never break another.\n\n## How themes work\n\nA theme is a document, and the editor works the way any editor does.\n\n- **A theme** is a named JSON file in `src/live-tokens/data/themes/`. It carries\n the whole look: the colors and type plus a setting for every component.\n- **The open theme** is the one the editor is working on, named in\n `themes/_active.json`. One at a time.\n- **Your unsaved edits** are what the page shows right now. The editor keeps\n them in your browser as you work and writes them to a buffer, `_working.json`,\n one slot per part of the look. **Save** captures that buffer into the open\n theme.\n- **The production theme** is the one your site ships, named in\n `themes/_production.json`. **Adopt** changes it; saving a preset in the Theme\n Picker performs that Adopt for you.\n\nAbsence is the answer for anything untouched: a buffer exists only where the\nlive look diverges from the active theme, so a newly opened theme has none.\n\n## Fonts\n\nType is part of the look, so it saves, loads and ships with the theme rather\nthan on its own. Four named stacks carry it:\n\n| Stack | Used by |\n|---|---|\n| `--font-display` | headings |\n| `--font-sans` | body text and most UI |\n| `--font-serif` | anywhere you ask for it |\n| `--font-mono` | code |\n\nEach stack is a family followed by its fallbacks, so a page still reads while a\nweb font loads, and still reads if it never does. **Project fonts**, in the\nColors and type editor, is where families come from: type a Google Fonts family\nname and the editor checks it, or paste a fonts URL, an embed tag, or your own\n`@font-face` rules. Removing a family puts the stack back on its fallbacks.\n\nYou can also set both faces at once from the command line:\n\n```bash\nnpx live-tokens set-fonts fonts.json\n```\n\nwith a brief naming the families:\n\n```json\n{ \"display\": \"Fraunces\", \"body\": \"Nunito Sans\" }\n```\n\nIt checks each family against Google Fonts, works out the weights that family\nactually has, and binds it to its stack. Like every other edit, the result lands\nin the buffer, so **Save** keeps it. In Claude Code, asking for a font pairing in\nplain English runs the same command.\n\nA font is only requested by the browser once something on the page uses it, so\ncarrying a family you no longer reference costs nothing at load. Adopting is\nwhat writes the font imports your site ships, into `fonts.css`.\n\n## Saving\n\nIn the Theme panel:\n\n- **Save** captures the look on screen into the open theme. Your colors and type\n go in as part of it, so there is nothing to save first.\n- **Save As** names a new theme. Use it for your first save and for forking.\n\nComponent editors keep their own unsaved state. If one or more components are\nwaiting when you use **Save**, **Save As**, or **Adopt**, the Theme panel offers\nto save all of them before continuing. You can accept once instead of visiting\neach component, or cancel to review them individually. A component editor's\n**Save As** creates a reusable component preset.\n\nNames are tidied to lowercase with hyphens, so \"My Brand!\" becomes `my-brand`,\nand a leading underscore is dropped: those names are reserved for the buffer.\n**Motion Proto** is the built-in theme and is read-only. You can always return\nto it, and the editor never overwrites it, so start your own with **Save As**.\n\n## Switching\n\n**Load**—or clicking the active theme's name—opens the Theme Picker. Picking a\ntheme shows it on the page as a preview with nothing written to disk, so you can\ntry each look and compare. **Save** in that window opens and adopts the previewed\ntheme in one step: the active pointer changes, the buffers clear, the editor\nworks on it, and production ships it. **Cancel** returns you to where you were.\nPreviewing alone never changes what your site ships.\n\n**Colors and type only. Keep my shapes.** narrows the load to the palette and\nthe fonts: your component settings stay as they are and the theme you have open\nstays open. Saved colors and type files are listed there too, marked *colors &\ntype*, and picking one is always that narrower load.\n\n## Shipping\n\n**Adopt**, in the Theme panel, is the \"ship it\" step, and it ships the whole\nlook. It saves the open theme, then bakes that theme into\n`src/live-tokens/data/tokens.generated.css`, which your build bundles alongside\n`tokens.css`: the colors and type plus every component the theme carries. Fonts\nregenerate to match. The line under the theme name says whether production is\nrunning this theme.\n\nProduction is one saved theme, so nothing else publishes. Trying a look, moving\na token, saving a theme: all of it leaves the generated CSS alone until you\nAdopt. A component editor's Adopt runs the same whole-look step, because a\ncomponent never ships alone. Adopting while Motion Proto is open saves your look\nas a theme of your own first, since the built-in one is read-only.\n\nProduction builds (`npm run build`) ship only that plain CSS and your\ncomponents. No editor, no JSON loading, no runtime indirection.\n\n## Keeping your work safe\n\nEverything under `src/live-tokens/data/` is plain JSON, so commit it. Themes show\nup as readable diffs you can review per branch, and the buffer shows up as the\nwork you have not saved into a theme yet. Nothing is backed up anywhere else:\ngit is your safety net. To experiment freely, **Save As** a new name first, then\nedit.\n\n## Where to go next\n\n- **[Where themes live](where-themes-live.md)**: the files behind all of this,\n and what writes each one.\n- **[Creating components](creating-components.md)**: make your own components\n editable in the same editor.\n",
12
12
  "where-themes-live": "# Where themes live\n\nEverything the editor writes is plain JSON and CSS inside your project. There\nis no database and no hidden state: the files are the storage, and git is the\nhistory.\n\n## The data tree\n\n```\nsrc/live-tokens/data/\n themes/\n _active.json names the theme the editor has open\n _production.json names the theme your site ships\n default.json Motion Proto, the built-in look, rewritten at boot\n my-brand.json a saved theme: the whole look in one file\n colors-and-type/\n _working.json unsaved colors and type edits\n component-configs/\n button/\n default.json Button's shipped settings, derived at boot\n _working.json unsaved Button edits\n my-button.json a preset you saved from the Button editor\n tokens.generated.css the baked CSS your production build ships\nsrc/system/styles/\n tokens.css your token vocabulary, hand-authored, never written\n fonts.css font imports, rewritten when you Adopt\n```\n\nA saved theme carries the whole look by value: the colors and type plus a\nsetting for every component. It depends on no other file, so deleting\nanything else never breaks it.\n\n## What writes when\n\n- **Editing** changes the page through CSS variables. The editor keeps your\n edits in the browser as you work and writes them to the `_working.json`\n buffers when you save a component. When the Theme panel finds several dirty\n components, **Save all** writes those buffers together.\n- **Save** captures the buffers into the open theme's file. That file is the\n durable copy of your look; matching buffers are then removed.\n- **Load** clears the buffers and points `themes/_active.json` at the theme you\n picked. Live reads fall through to that file. Nothing else changes, so trying\n looks is free and ordinary switching changes only the pointer.\n- **Adopt** points `themes/_production.json` at the open theme, bakes it into\n `tokens.generated.css`, and rewrites `fonts.css` to match. It is the only\n action that changes what your site ships.\n\nThe `default.json` files are the shipped baseline. The editor derives them at\nboot and refreshes them when the package updates; it never saves your work\nover them.\n\nProjects upgraded from 0.48 may initially contain working files copied from the\nactive theme. On the first dev-server boot, exact copies are removed\nautomatically. Any file that differs is kept as unsaved work, so no migration\ncommand is required.\n\n## What to commit\n\nAll of it. The data tree is designed to live in git: themes diff readably, the\ntwo pointers say what is open and what ships, and a `_working.json` in a diff\nis exactly the work you have not yet saved into a theme. Nothing is backed up\nanywhere else.\n\n## Where to go next\n\n- **[Themes](themes-workflow.md)**: the workflow built on these files: saving,\n loading, and shipping.\n",
13
13
  };
@@ -71,6 +71,7 @@
71
71
  import ColumnsOverlay from './ColumnsOverlay.svelte';
72
72
  import { route, navigate } from '../core/routing/router';
73
73
  import { DEFAULT_EDITOR_PATH, DEFAULT_COMPONENTS_PATH, DEFAULT_COLORS_PATH, DEFAULT_DOCS_PATH } from '../core/routing/ownedRoutes';
74
+ import { setSketchPageRoot } from '../core/sketch/sketchStore';
74
75
 
75
76
  interface Props {
76
77
  pages: Record<string, RouteEntry>;
@@ -101,6 +102,13 @@
101
102
  let isColors = $derived(isDev && colorsEnabled && $route === colorsPath);
102
103
  let isDocs = $derived(isDev && docsEnabled && $route === docsPath);
103
104
 
105
+ // A sketch style paints the page and never the editor's own chrome. Which
106
+ // routes are chrome is this component's knowledge alone, since a consumer can
107
+ // relocate them, so it hands the sketch layer the root to paint.
108
+ $effect(() => {
109
+ setSketchPageRoot(isEditor || isComponentEditor || isColors || isDocs ? null : document.documentElement);
110
+ });
111
+
104
112
  // The single entry that renders the current route. Owned routes are handled
105
113
  // by the isEditor/isComponentEditor/isColors/isDocs branches, so they resolve
106
114
  // to null here; everything else flows through resolveRoute and drives both
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Registry of the eight v1 semantic text styles rendered by TextStylesSection.
2
+ * Registry of the semantic text styles rendered by TextStylesSection.
3
3
  * Each style is a bundle of alias tokens declared in tokens.css; the prefix
4
4
  * joins with a kind suffix (`-font-family`, `-font-size`, …) to form each
5
5
  * axis variable the editor's pickers target.
@@ -64,6 +64,20 @@ export const TEXT_STYLES: TextStyle[] = [
64
64
  defaultElement: 'small',
65
65
  preview: 'Captions and secondary text.',
66
66
  },
67
+ {
68
+ name: 'editorial-md',
69
+ label: 'Editorial MD',
70
+ prefix: '--editorial-md',
71
+ defaultElement: 'editorial-md',
72
+ preview: 'Long-form reading, set in the editorial face.',
73
+ },
74
+ {
75
+ name: 'editorial-sm',
76
+ label: 'Editorial SM',
77
+ prefix: '--editorial-sm',
78
+ defaultElement: 'editorial-sm',
79
+ preview: 'Standfirsts, pull quotes, and asides.',
80
+ },
67
81
  {
68
82
  name: 'code',
69
83
  label: 'Code',