@motion-proto/live-tokens 0.47.0 → 0.48.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 (124) hide show
  1. package/.claude/skills/live-tokens-adjust-shape-space/SKILL.md +68 -0
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +2 -2
  3. package/.claude/skills/live-tokens-generate-theme/SKILL.md +152 -0
  4. package/CHANGELOG.md +292 -0
  5. package/README.md +37 -26
  6. package/bin/adjust.mjs +254 -0
  7. package/bin/cli.mjs +94 -7
  8. package/bin/generate-theme.mjs +251 -0
  9. package/bin/migrate.mjs +95 -6
  10. package/dist-plugin/adjust/index.cjs +259 -0
  11. package/dist-plugin/adjust/index.d.cts +45 -0
  12. package/dist-plugin/adjust/index.d.ts +45 -0
  13. package/dist-plugin/adjust/index.js +176 -0
  14. package/dist-plugin/{chunk-H4TRUINI.js → chunk-44RSTAII.js} +0 -48
  15. package/dist-plugin/chunk-6OZFXIQI.js +316 -0
  16. package/dist-plugin/chunk-76TFDTJO.js +556 -0
  17. package/dist-plugin/chunk-D3ZVKOR4.js +52 -0
  18. package/dist-plugin/dataPaths-DBN0RPuT.d.cts +54 -0
  19. package/dist-plugin/dataPaths-DBN0RPuT.d.ts +54 -0
  20. package/dist-plugin/generateColorsAndType/index.cjs +1781 -0
  21. package/dist-plugin/generateColorsAndType/index.d.cts +81 -0
  22. package/dist-plugin/generateColorsAndType/index.d.ts +81 -0
  23. package/dist-plugin/generateColorsAndType/index.js +1210 -0
  24. package/dist-plugin/index.cjs +901 -533
  25. package/dist-plugin/index.d.cts +3 -2
  26. package/dist-plugin/index.d.ts +3 -2
  27. package/dist-plugin/index.js +658 -1082
  28. package/dist-plugin/migrateData/index.cjs +728 -0
  29. package/dist-plugin/migrateData/index.d.cts +60 -0
  30. package/dist-plugin/migrateData/index.d.ts +60 -0
  31. package/dist-plugin/migrateData/index.js +348 -0
  32. package/dist-plugin/themeTypes-DMHZOnUn.d.cts +211 -0
  33. package/dist-plugin/themeTypes-DMHZOnUn.d.ts +211 -0
  34. package/dist-plugin/tokensCssMigrations/index.cjs +2 -1
  35. package/dist-plugin/tokensCssMigrations/index.d.cts +3 -15
  36. package/dist-plugin/tokensCssMigrations/index.d.ts +3 -15
  37. package/dist-plugin/tokensCssMigrations/index.js +4 -2
  38. package/package.json +18 -4
  39. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +183 -158
  40. package/src/editor/component-editor/scaffolding/ComponentFileMenu.svelte +0 -4
  41. package/src/editor/component-editor/scaffolding/SaveAsDialog.svelte +4 -4
  42. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +2 -59
  43. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +1 -1
  44. package/src/editor/core/components/adjustAliases.ts +180 -0
  45. package/src/editor/core/components/aliasKinds.ts +73 -0
  46. package/src/editor/core/components/componentConfigService.ts +26 -38
  47. package/src/editor/core/flashStatus.ts +1 -1
  48. package/src/editor/core/fonts/fontMigration.ts +13 -13
  49. package/src/editor/core/fonts/fontPairing.ts +35 -0
  50. package/src/editor/core/palettes/paletteDerivation.ts +128 -16
  51. package/src/editor/core/preview/lookPreview.ts +141 -0
  52. package/src/editor/core/productionPulse.ts +34 -21
  53. package/src/editor/core/storage/files/versionedFileResourceClient.ts +18 -45
  54. package/src/editor/core/store/editorConfigStore.ts +2 -2
  55. package/src/editor/core/store/editorPersistence.ts +0 -1
  56. package/src/editor/core/store/editorStore.ts +114 -51
  57. package/src/editor/core/store/editorTypes.ts +0 -1
  58. package/src/editor/core/store/gradientSource.ts +2 -2
  59. package/src/editor/core/themes/colorsAndTypeService.ts +95 -0
  60. package/src/editor/core/themes/generateColorsAndType.ts +433 -0
  61. package/src/editor/core/themes/loadRows.ts +64 -0
  62. package/src/editor/core/themes/lookSummary.ts +75 -0
  63. package/src/editor/core/themes/migrations/2026-04-24-legacy-keys-and-bg-to-canvas.ts +3 -3
  64. package/src/editor/core/themes/migrations/2026-05-13-primary-to-brand.ts +2 -2
  65. package/src/editor/core/themes/migrations/2026-05-26-drop-overlay-extra-stops.ts +3 -3
  66. package/src/editor/core/themes/migrations/2026-08-13-drop-legacy-shape-space-keys.ts +38 -0
  67. package/src/editor/core/themes/migrations/2026-08-15-line-height-scale-rename.ts +57 -0
  68. package/src/editor/core/themes/migrations/index.ts +19 -11
  69. package/src/editor/core/themes/slices/components.ts +4 -4
  70. package/src/editor/core/themes/slices/fonts.ts +1 -1
  71. package/src/editor/core/themes/slices/gradients.ts +22 -0
  72. package/src/editor/core/themes/slices/palettes.ts +2 -2
  73. package/src/editor/core/themes/themeInit.ts +21 -29
  74. package/src/editor/core/themes/themeService.ts +218 -78
  75. package/src/editor/core/themes/themeTypes.ts +94 -51
  76. package/src/editor/docs/Docs.svelte +1 -0
  77. package/src/editor/docs/chapters.ts +1 -0
  78. package/src/editor/docs/content/01-overview.md +1 -1
  79. package/src/editor/docs/content/editing-tokens.md +6 -6
  80. package/src/editor/docs/content/getting-started.md +9 -7
  81. package/src/editor/docs/content/themes-workflow.md +74 -34
  82. package/src/editor/docs/content/where-themes-live.md +61 -0
  83. package/src/editor/docs/content.generated.ts +5 -4
  84. package/src/editor/index.ts +27 -27
  85. package/src/editor/pages/ComponentEditorPage.svelte +2 -2
  86. package/src/editor/pages/EditorShell.svelte +4 -30
  87. package/src/editor/ui/BezierCurveEditor.svelte +2 -2
  88. package/src/editor/ui/ColorEditPanel.svelte +1 -1
  89. package/src/editor/ui/FileLoadList.svelte +67 -7
  90. package/src/editor/ui/GradientEditor.svelte +3 -3
  91. package/src/editor/ui/PaletteEditor.svelte +26 -12
  92. package/src/editor/ui/ThemePanel.svelte +1067 -0
  93. package/src/editor/ui/UIDialog.svelte +11 -9
  94. package/src/editor/ui/UIPillButton.svelte +7 -2
  95. package/src/editor/ui/colors/ColorWheel.svelte +34 -1
  96. package/src/editor/ui/curveEngine.ts +48 -0
  97. package/src/editor/ui/index.ts +4 -2
  98. package/src/editor/ui/palette/PaletteBase.svelte +75 -51
  99. package/src/editor/ui/palette/PaletteJumpButton.svelte +3 -8
  100. package/src/live-tokens/data/colors-and-type/autumn.json +2490 -0
  101. package/src/live-tokens/data/{themes → colors-and-type}/default.json +816 -293
  102. package/src/live-tokens/data/colors-and-type/halloween.json +2529 -0
  103. package/src/live-tokens/data/colors-and-type/midnight-study.json +2536 -0
  104. package/src/live-tokens/data/colors-and-type/ocean.json +2489 -0
  105. package/src/live-tokens/data/colors-and-type/royal-velvet.json +2520 -0
  106. package/src/live-tokens/data/colors-and-type/spring-meadow.json +2476 -0
  107. package/src/live-tokens/data/colors-and-type/sunset.json +2528 -0
  108. package/src/live-tokens/data/themes/autumn.json +3918 -0
  109. package/src/live-tokens/data/themes/halloween.json +3992 -0
  110. package/src/live-tokens/data/themes/midnight-study.json +3932 -0
  111. package/src/live-tokens/data/themes/ocean.json +3917 -0
  112. package/src/live-tokens/data/themes/royal-velvet.json +3983 -0
  113. package/src/live-tokens/data/themes/spring-meadow.json +3904 -0
  114. package/src/live-tokens/data/themes/sunset.json +3931 -0
  115. package/src/live-tokens/data/tokens.generated.css +106 -117
  116. package/src/system/styles/CONVENTIONS.md +1 -1
  117. package/src/system/styles/fonts.css +1 -1
  118. package/template/README.md +7 -5
  119. package/template/_gitignore +0 -6
  120. package/src/editor/core/components/componentPersist.ts +0 -62
  121. package/src/editor/core/manifests/manifestService.ts +0 -172
  122. package/src/editor/ui/ManifestFileManager.svelte +0 -446
  123. package/src/editor/ui/ThemeFileManager.svelte +0 -785
  124. package/src/live-tokens/data/manifests/default.json +0 -35
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { hexToOklch, oklchToHexClamped, gamutClamp, type Oklch } from './oklch';
17
- import { type CurveAnchor, sampleCurve, makeAnchor } from '../../ui/curveEngine';
17
+ import { type CurveAnchor, sampleCurve, makeAnchor, tangentAnchor } from '../../ui/curveEngine';
18
18
  import type { PaletteConfig } from '../themes/themeTypes';
19
19
 
20
20
  export interface PaletteSpec {
@@ -225,9 +225,53 @@ export function nearestPaletteStep(lightnessCurve: CurveAnchor[], baseL: number)
225
225
  return best;
226
226
  }
227
227
 
228
- /** Insert an anchor at x, or overwrite one already there (endpoints included).
229
- * Returns the y it displaced so the caller can restore it later. */
230
- export function setCurveAnchor(curve: CurveAnchor[], x: number, y: number): { curve: CurveAnchor[]; displacedY?: number } {
228
+ /**
229
+ * Insert an anchor at x, or overwrite one already there (endpoints included).
230
+ * Returns the y it displaced so the caller can restore it later.
231
+ *
232
+ * `smooth` is for a curve's first-ever placement only (nothing has had a
233
+ * chance to edit it yet): every anchor, endpoints included, gets a tangent
234
+ * computed jointly with its real neighbour, so the placement reads as one
235
+ * smooth curve instead of two segments meeting at a corner where the
236
+ * endpoints still aim along their pre-insertion secant. It also returns the
237
+ * untouched original endpoints as `priorEndpoints` — this reshaping isn't
238
+ * algebraically invertible the way the scale-and-preserve path below is, so
239
+ * the caller must hold onto them to restore the curve exactly later. Every
240
+ * other placement (`smooth` false, the default) preserves whatever shape is
241
+ * already there, edited or not.
242
+ */
243
+ export function setCurveAnchor(
244
+ curve: CurveAnchor[],
245
+ x: number,
246
+ y: number,
247
+ smooth = false,
248
+ ): { curve: CurveAnchor[]; displacedY?: number; priorEndpoints?: [CurveAnchor, CurveAnchor] } {
249
+ if (smooth && curve.length === 2) {
250
+ const [p0, p2] = curve;
251
+ const mid: CurveAnchor = { x, y, inDx: 0, inDy: 0, outDx: 0, outDy: 0 };
252
+ if (Math.abs(p0.x - x) < 0.5) {
253
+ return {
254
+ curve: [tangentAnchor(x, y, null, p2), tangentAnchor(p2.x, p2.y, mid, null)],
255
+ displacedY: p0.y,
256
+ priorEndpoints: [p0, p2],
257
+ };
258
+ }
259
+ if (Math.abs(p2.x - x) < 0.5) {
260
+ return {
261
+ curve: [tangentAnchor(p0.x, p0.y, null, mid), tangentAnchor(x, y, p0, null)],
262
+ displacedY: p2.y,
263
+ priorEndpoints: [p0, p2],
264
+ };
265
+ }
266
+ return {
267
+ curve: [
268
+ tangentAnchor(p0.x, p0.y, null, mid),
269
+ tangentAnchor(x, y, p0, p2),
270
+ tangentAnchor(p2.x, p2.y, mid, null),
271
+ ],
272
+ priorEndpoints: [p0, p2],
273
+ };
274
+ }
231
275
  const existing = curve.findIndex((a) => Math.abs(a.x - x) < 0.5);
232
276
  if (existing >= 0) {
233
277
  const displacedY = curve[existing].y;
@@ -235,18 +279,72 @@ export function setCurveAnchor(curve: CurveAnchor[], x: number, y: number): { cu
235
279
  }
236
280
  let insertAt = curve.findIndex((a) => a.x > x);
237
281
  if (insertAt < 0) insertAt = curve.length;
238
- return { curve: [...curve.slice(0, insertAt), makeAnchor(x, y, 15), ...curve.slice(insertAt)] };
282
+ const prev = curve[insertAt - 1] ?? null;
283
+ const next = curve[insertAt] ?? null;
284
+ const out = [...curve];
285
+ // The neighbours aimed their handles across a gap this insert has just split,
286
+ // so a handle that was in bounds can now reach clean past the new anchor and
287
+ // leave the segment non-monotone in x, which is the one thing sampleCurve
288
+ // cannot survive. Scale them by the share of the gap each one keeps: the same
289
+ // rule de Casteljau uses, so the curve holds its shape, and exactly invertible
290
+ // by liftCurveAnchor when the placement moves off again.
291
+ if (prev && next) {
292
+ const span = next.x - prev.x;
293
+ out[insertAt - 1] = scaleOutHandle(prev, (x - prev.x) / span);
294
+ out[insertAt] = scaleInHandle(next, (next.x - x) / span);
295
+ }
296
+ out.splice(insertAt, 0, fitBetween(tangentAnchor(x, y, prev, next), out[insertAt - 1] ?? null, out[insertAt] ?? null));
297
+ return { curve: out };
298
+ }
299
+
300
+ const scaleOutHandle = (a: CurveAnchor, k: number): CurveAnchor => ({ ...a, outDx: a.outDx * k, outDy: a.outDy * k });
301
+ const scaleInHandle = (a: CurveAnchor, k: number): CurveAnchor => ({ ...a, inDx: a.inDx * k, inDy: a.inDy * k });
302
+
303
+ /** Shorten a freshly derived anchor's handles to whatever room its neighbours
304
+ * leave. Only the new anchor gives ground: its handles carry no intent yet,
305
+ * and a stored handle is someone's edit. */
306
+ function fitBetween(a: CurveAnchor, prev: CurveAnchor | null, next: CurveAnchor | null): CurveAnchor {
307
+ let out = a;
308
+ if (prev) {
309
+ const room = a.x - (prev.x + prev.outDx);
310
+ if (room < -out.inDx) out = scaleInHandle(out, room <= 0 ? 0 : room / -out.inDx);
311
+ }
312
+ if (next) {
313
+ const room = next.x + next.inDx - a.x;
314
+ if (room < out.outDx) out = scaleOutHandle(out, room <= 0 ? 0 : room / out.outDx);
315
+ }
316
+ return out;
239
317
  }
240
318
 
241
- /** Undo a placement at x: restore the displaced y when one was recorded,
242
- * remove the anchor when the placement created it. Endpoints are never
243
- * removed — a curve needs its boundaries. */
244
- export function liftCurveAnchor(curve: CurveAnchor[], x: number, displacedY?: number): CurveAnchor[] {
319
+ /**
320
+ * Undo a placement at x: restore the displaced y when one was recorded,
321
+ * remove the anchor when the placement created it. Endpoints are never
322
+ * removed — a curve needs its boundaries.
323
+ *
324
+ * `priorEndpoints`, when given, wins outright: it's the untouched pair from
325
+ * before a `smooth` placement, which the scale/displace math below can't
326
+ * reconstruct on its own.
327
+ */
328
+ export function liftCurveAnchor(
329
+ curve: CurveAnchor[],
330
+ x: number,
331
+ displacedY?: number,
332
+ priorEndpoints?: [CurveAnchor, CurveAnchor],
333
+ ): CurveAnchor[] {
334
+ if (priorEndpoints) return priorEndpoints;
245
335
  const idx = curve.findIndex((a) => Math.abs(a.x - x) < 0.5);
246
336
  if (idx < 0) return curve;
247
337
  if (displacedY !== undefined) return curve.map((a, i) => (i === idx ? { ...a, y: displacedY } : a));
248
338
  if (idx === 0 || idx === curve.length - 1) return curve;
249
- return curve.filter((_, i) => i !== idx);
339
+ const out = curve.filter((_, i) => i !== idx);
340
+ const prev = curve[idx - 1];
341
+ const next = curve[idx + 1];
342
+ if (prev && next) {
343
+ const span = next.x - prev.x;
344
+ out[idx - 1] = scaleOutHandle(prev, span / (curve[idx].x - prev.x));
345
+ out[idx] = scaleInHandle(next, span / (next.x - curve[idx].x));
346
+ }
347
+ return out;
250
348
  }
251
349
 
252
350
  /**
@@ -261,20 +359,34 @@ export function liftCurveAnchor(curve: CurveAnchor[], x: number, displacedY?: nu
261
359
  export function syncBaseAnchor(cfg: PaletteConfig): void {
262
360
  if (cfg.anchorToBase === false) return;
263
361
  const prev = cfg.anchorPlacement;
362
+ const fresh = prev === undefined;
264
363
  let lCurve = cfg.lightnessCurve;
265
364
  let sCurve = cfg.saturationCurve;
266
365
  if (prev) {
366
+ // Re-placing an existing anchor (a hue move, typically) must preserve
367
+ // whatever is currently on the curve, edited or not — never restore the
368
+ // pristine `priorEndpoints` here, or every hand-tuned handle would be
369
+ // discarded the next time the base color moves.
267
370
  const prevX = stepIndexToX(prev.step);
268
371
  lCurve = liftCurveAnchor(lCurve, prevX, prev.displacedL);
269
372
  sCurve = liftCurveAnchor(sCurve, prevX, prev.displacedS);
270
373
  }
271
374
  const step = nearestPaletteStep(lCurve, cfg.baseColor.l);
272
375
  const x = stepIndexToX(step);
273
- const l = setCurveAnchor(lCurve, x, cfg.baseColor.l * 100);
274
- const s = setCurveAnchor(sCurve, x, 100);
376
+ const l = setCurveAnchor(lCurve, x, cfg.baseColor.l * 100, fresh);
377
+ const s = setCurveAnchor(sCurve, x, 100, fresh);
275
378
  cfg.lightnessCurve = l.curve;
276
379
  cfg.saturationCurve = s.curve;
277
- cfg.anchorPlacement = { step, displacedL: l.displacedY, displacedS: s.displacedY };
380
+ // priorEndpoints is captured once, on the curve's first-ever placement,
381
+ // and carried forward untouched thereafter so a later clear can still
382
+ // restore the true original regardless of how many times the anchor moved.
383
+ cfg.anchorPlacement = {
384
+ step,
385
+ displacedL: l.displacedY,
386
+ displacedS: s.displacedY,
387
+ priorLightnessEndpoints: prev?.priorLightnessEndpoints ?? l.priorEndpoints,
388
+ priorSaturationEndpoints: prev?.priorSaturationEndpoints ?? s.priorEndpoints,
389
+ };
278
390
  }
279
391
 
280
392
  /** Toggle-off: lift the placement out of both curves and drop the flag. */
@@ -282,8 +394,8 @@ export function clearBaseAnchor(cfg: PaletteConfig): void {
282
394
  const prev = cfg.anchorPlacement;
283
395
  if (prev) {
284
396
  const x = stepIndexToX(prev.step);
285
- cfg.lightnessCurve = liftCurveAnchor(cfg.lightnessCurve, x, prev.displacedL);
286
- cfg.saturationCurve = liftCurveAnchor(cfg.saturationCurve, x, prev.displacedS);
397
+ cfg.lightnessCurve = liftCurveAnchor(cfg.lightnessCurve, x, prev.displacedL, prev.priorLightnessEndpoints);
398
+ cfg.saturationCurve = liftCurveAnchor(cfg.saturationCurve, x, prev.displacedS, prev.priorSaturationEndpoints);
287
399
  cfg.anchorPlacement = undefined;
288
400
  }
289
401
  cfg.anchorToBase = false;
@@ -453,7 +565,7 @@ const HEX_RE = /^#[0-9a-f]{6}$/i;
453
565
  * authoritative anchor and `baseColor` is snapped to it. The flag is then
454
566
  * cleared. Editor-authored palettes (no `_imported`) are left untouched —
455
567
  * see `temp/manifest-robustness-plan.md` §9 for why "snap on any
456
- * divergence" was wrong: it would have flipped `themes/default.json`'s
568
+ * divergence" was wrong: it would have flipped `colors-and-type/default.json`'s
457
569
  * accent from teal to olive on first read.
458
570
  *
459
571
  * - **Consume** (always): every variable the palette's derivation produces
@@ -0,0 +1,141 @@
1
+ import { get } from 'svelte/store';
2
+ import type { FontSource, Theme, ColorsAndType } from '../themes/themeTypes';
3
+ import { editorState, colorsAndTypeToState, toComponentSlice } from '../store/editorStore';
4
+ import { deriveCssVars } from '../store/editorRenderer';
5
+ import { setCssVar, removeCssVar } from '../cssVarSync';
6
+ import { applyFontSources, resolveFontStackValues } from '../fonts/fontLoader';
7
+ import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
8
+ import { loadTheme } from '../themes/themeService';
9
+
10
+ /**
11
+ * Client-side rendering of a saved file, for browsing looks without committing
12
+ * one. A theme previews as a whole look; a colors-and-type file previews as
13
+ * colors and type over the components the user has right now. Nothing here writes to the server,
14
+ * mutates the editor store, or marks anything dirty: a preview is paint only.
15
+ * Save-As stays honest because `captureLook` reads the server's active files,
16
+ * which a preview never touches.
17
+ *
18
+ * Both painting and reverting run the same derivation the renderer runs on
19
+ * store state (`deriveCssVars`), so a preview cannot drift from what an Apply
20
+ * of the same theme produces, and a revert re-derives from the live store
21
+ * rather than replaying DOM values scraped before the preview.
22
+ *
23
+ * One preview is live at a time, whichever kind it is: painting reverts first,
24
+ * so every look is a diff against the user's real state.
25
+ */
26
+
27
+ /** Everything a look puts on the page: the full var set (colors and type,
28
+ * components and the composed `--font-*` stacks) plus the sources its faces
29
+ * come from. */
30
+ export interface RenderedLook {
31
+ vars: Record<string, string>;
32
+ fontSources: FontSource[];
33
+ }
34
+
35
+ /**
36
+ * The full var set a theme paints. Components the theme carries no config
37
+ * for render `defaults`' config, matching Apply's complete-look semantics;
38
+ * configs for components this install lacks are skipped, as Apply skips them.
39
+ */
40
+ export function themeLook(theme: Theme, defaults: Theme): RenderedLook {
41
+ const colorsAndType = structuredClone(theme.colorsAndType);
42
+ migrateColorsAndTypeFonts(colorsAndType);
43
+ const state = colorsAndTypeToState(colorsAndType);
44
+ state.components = {};
45
+ for (const [comp, config] of Object.entries(defaults.componentConfigs)) {
46
+ state.components[comp] = toComponentSlice(comp, config.aliases, config.config, config.schemaVersion);
47
+ }
48
+ for (const [comp, config] of Object.entries(theme.componentConfigs)) {
49
+ if (!(comp in state.components)) continue;
50
+ state.components[comp] = toComponentSlice(comp, config.aliases, config.config, config.schemaVersion);
51
+ }
52
+ const vars = deriveCssVars(state);
53
+ Object.assign(vars, resolveFontStackValues(colorsAndType.fontStacks ?? [], colorsAndType.fontSources ?? []));
54
+ return { vars, fontSources: colorsAndType.fontSources ?? [] };
55
+ }
56
+
57
+ /**
58
+ * The full var set colors and type paint on their own. They are not a whole look,
59
+ * so the components stay as the user has them: `colorsAndTypeToState` carries the live
60
+ * component slice forward (`loadComponentsFromVars`) and strips component-owned
61
+ * vars out of their own bag, which is exactly the composition a preview
62
+ * wants. No defaults fetch, no component reset.
63
+ */
64
+ export function colorsAndTypeLook(colorsAndType: ColorsAndType): RenderedLook {
65
+ const next = structuredClone(colorsAndType);
66
+ migrateColorsAndTypeFonts(next);
67
+ const vars = deriveCssVars(colorsAndTypeToState(next));
68
+ Object.assign(vars, resolveFontStackValues(next.fontStacks ?? [], next.fontSources ?? []));
69
+ return { vars, fontSources: next.fontSources ?? [] };
70
+ }
71
+
72
+ /** The look the editor store currently describes — the state a revert returns to. */
73
+ export function liveLook(): RenderedLook {
74
+ const state = get(editorState);
75
+ const vars = deriveCssVars(state);
76
+ Object.assign(vars, resolveFontStackValues(state.fonts.stacks, state.fonts.sources));
77
+ return { vars, fontSources: state.fonts.sources };
78
+ }
79
+
80
+ let livePreview: RenderedLook | null = null;
81
+ let defaultsPromise: Promise<Theme> | null = null;
82
+
83
+ function loadDefaults(): Promise<Theme> {
84
+ // A rejected promise must not be memoized, or one failed fetch (a dev-server
85
+ // restart mid-dialog) poisons every later preview until a page reload.
86
+ defaultsPromise ??= loadTheme('default').catch((err) => {
87
+ defaultsPromise = null;
88
+ throw err;
89
+ });
90
+ return defaultsPromise;
91
+ }
92
+
93
+ function paint(next: RenderedLook, from: RenderedLook): void {
94
+ for (const [name, value] of Object.entries(next.vars)) {
95
+ if (from.vars[name] !== value) setCssVar(name, value);
96
+ }
97
+ for (const name of Object.keys(from.vars)) {
98
+ if (!(name in next.vars)) removeCssVar(name);
99
+ }
100
+ applyFontSources(next.fontSources);
101
+ }
102
+
103
+ /**
104
+ * Show `look` on the page. Re-entrant: a live preview is reverted first, so
105
+ * every look is painted as a diff against the user's real state and a var one
106
+ * look sets but the next does not returns to its live value instead of vanishing.
107
+ * The two passes run in one task, so the browser never paints the intermediate.
108
+ */
109
+ function applyPreview(look: RenderedLook): void {
110
+ revertPreview();
111
+ paint(look, liveLook());
112
+ livePreview = look;
113
+ }
114
+
115
+ /** Paint a whole look: the theme's colors and type and every component config
116
+ * it carries. */
117
+ export async function previewTheme(theme: Theme): Promise<void> {
118
+ applyPreview(themeLook(theme, await loadDefaults()));
119
+ }
120
+
121
+ /** Paint colors and type over the components as they stand. */
122
+ export function previewColorsAndType(colorsAndType: ColorsAndType): void {
123
+ applyPreview(colorsAndTypeLook(colorsAndType));
124
+ }
125
+
126
+ /** Restore the live editor state. No-op when no preview is running. */
127
+ export function revertPreview(): void {
128
+ if (!livePreview) return;
129
+ paint(liveLook(), livePreview);
130
+ livePreview = null;
131
+ }
132
+
133
+ export function isPreviewing(): boolean {
134
+ return livePreview !== null;
135
+ }
136
+
137
+ /** Test-only: drop the live preview and the cached defaults theme. */
138
+ export function __resetPreviewForTests(): void {
139
+ livePreview = null;
140
+ defaultsPromise = null;
141
+ }
@@ -1,16 +1,12 @@
1
1
  import { writable } from 'svelte/store';
2
- import type { ProductionInfo } from './themes/themeService';
3
- import type { ManifestMeta } from './themes/themeTypes';
2
+ import type { Theme } from './themes/themeTypes';
4
3
 
5
4
  /**
6
- * Monotonic counter that ticks every time a production pointer flips —
7
- * theme production or a component's production. UI surfaces that need to
8
- * react to a sibling Adopt subscribe to this so they refresh without
9
- * per-pair wiring.
5
+ * Monotonic counter that ticks every time an Adopt publishes a theme. UI
6
+ * surfaces that need to react to a sibling Adopt subscribe to this so they
7
+ * refresh without per-pair wiring.
10
8
  *
11
- * Bumpers: `ThemeFileManager.handleApplyToProduction`,
12
- * `ComponentFileManager.handleUpdateProduction`. Anyone setting
13
- * `_production.json` should bump.
9
+ * Bumpers: `ThemePanel.runAdopt`, `ComponentFileManager.handleAdopt`.
14
10
  */
15
11
  export const productionRevision = writable(0);
16
12
 
@@ -19,19 +15,36 @@ export function bumpProductionRevision(): void {
19
15
  }
20
16
 
21
17
  /**
22
- * Cached production-state stores. The Theme and Manifest file managers live in
23
- * the sidebar footer, swapping in/out of the DOM as the user toggles between
24
- * the tokens and components views. Keeping the last-known production state in
25
- * module-level Svelte stores means a remount renders the correct Adopt-button
26
- * state on the first frame instead of flashing through "not in sync" while a
27
- * fresh fetch resolves.
18
+ * Ticks when a component's live config changes on the server — a save, a
19
+ * preset load, a delete. Saving one is visible through `componentDirty`, but
20
+ * loading a preset with no edit in play changes nothing the Theme panel can
21
+ * observe, and its Components count is derived from exactly that. Bumped by
22
+ * `ComponentFileManager`.
28
23
  */
29
- export const themeProductionInfo = writable<ProductionInfo | null>(null);
24
+ export const componentActiveRevision = writable(0);
25
+
26
+ export function bumpComponentActiveRevision(): void {
27
+ componentActiveRevision.update((n) => n + 1);
28
+ }
29
+
30
+ /**
31
+ * The live look has moved past the last bake. Only Adopt bakes the CSS, so a
32
+ * buffer write or a theme save after one leaves production a version behind,
33
+ * and no pointer file records when the bake happened. Every surface reads the
34
+ * one signal: a component save has to reach the theme panel's Adopt, and a
35
+ * component editor's Adopt has to clear it for the panel.
36
+ *
37
+ * Set by the client writes that move the live look (`writeWorkingColorsAndType`,
38
+ * `writeWorkingComponentConfig`, `saveActiveTheme`, `saveAsTheme`); cleared by
39
+ * `adoptLook`. Module-level, so it survives the remounts a view switch causes.
40
+ */
41
+ export const liveMovedSinceBake = writable(false);
30
42
 
31
43
  /**
32
- * Last-known active manifest meta. Bumped by ManifestFileManager whenever the
33
- * active manifest changes (load, save, save-as) and whenever a theme or
34
- * component Adopt completes (the server patches the active manifest as a
35
- * side-effect, so consumers re-read it on `productionRevision` ticks).
44
+ * Last-read production theme — the document `tokens.generated.css` was baked
45
+ * from. The Theme panel and the component file managers live in surfaces that
46
+ * swap in and out of the DOM, so keeping the last answer in a module-level
47
+ * store means a remount renders the correct Adopt state on the first frame
48
+ * instead of flashing through "not in sync" while a fresh fetch resolves.
36
49
  */
37
- export const activeManifest = writable<ManifestMeta | null>(null);
50
+ export const productionTheme = writable<Theme | null>(null);
@@ -1,30 +1,28 @@
1
1
  /**
2
2
  * Client-side REST helpers for any "versioned file resource" — a class of
3
3
  * editable artifact whose lifecycle mirrors the theme files: list / load /
4
- * save / delete + an active pointer + a production pointer.
4
+ * save / delete, plus a live read door and, for themes, the pointer naming the
5
+ * open one.
5
6
  *
6
- * `themeService.ts` and `componentConfigService.ts` previously reimplemented
7
- * the same fetch shape. They now both consume `versionedFileResource(...)`
8
- * with their resource-specific URL.
7
+ * `colorsAndTypeService.ts`, `componentConfigService.ts` and `themeService.ts`
8
+ * all consume `versionedFileResource(...)` with their own URL root.
9
9
  *
10
10
  * Pure URL construction + fetch — no DOM, no filesystem, safe for any browser
11
11
  * or test harness that has a `fetch` global.
12
12
  */
13
13
 
14
14
  export interface VersionedFileResourceClientOptions {
15
- /** REST endpoint root, e.g. `${API_BASE}/themes` or `${API_BASE}/component-configs/button`. */
15
+ /** REST endpoint root, e.g. `${API_BASE}/colors-and-type` or `${API_BASE}/component-configs/button`. */
16
16
  baseUrl: string;
17
17
  }
18
18
 
19
- export interface VersionedFileResourceClient<TItem, TMeta, TProductionInfo> {
20
- list(): Promise<{ files: TMeta[]; activeFile?: string; productionFile?: string }>;
19
+ export interface VersionedFileResourceClient<TItem, TMeta> {
20
+ list(): Promise<{ files: TMeta[] }>;
21
21
  load(fileName: string): Promise<TItem>;
22
22
  save(fileName: string, data: TItem): Promise<void>;
23
23
  remove(fileName: string): Promise<void>;
24
24
  getActive(): Promise<TItem | null>;
25
25
  setActive(fileName: string): Promise<void>;
26
- getProductionInfo(): Promise<TProductionInfo>;
27
- setProduction(fileName: string): Promise<TProductionInfo & { ok: boolean }>;
28
26
  }
29
27
 
30
28
  /**
@@ -34,17 +32,15 @@ export interface VersionedFileResourceClient<TItem, TMeta, TProductionInfo> {
34
32
  * GET {base}/:name → load
35
33
  * PUT {base}/:name → save
36
34
  * DELETE {base}/:name → delete
37
- * GET {base}/active → load active
38
- * PUT {base}/active {name} → set active
39
- * GET {base}/production → production info
40
- * PUT {base}/production {name} → set production
35
+ * GET {base}/active → the live item
36
+ * PUT {base}/active {name} → open that item (themes only)
41
37
  *
42
- * Generic over the payload types so theme & component-config can share the
43
- * same plumbing while keeping their own response/info shapes.
38
+ * Generic over the payload types so themes, colors and type and component
39
+ * configs share the plumbing while keeping their own response shapes.
44
40
  */
45
- export function versionedFileResource<TItem, TMeta, TProductionInfo>(
41
+ export function versionedFileResource<TItem, TMeta>(
46
42
  opts: VersionedFileResourceClientOptions,
47
- ): VersionedFileResourceClient<TItem, TMeta, TProductionInfo> {
43
+ ): VersionedFileResourceClient<TItem, TMeta> {
48
44
  const { baseUrl } = opts;
49
45
 
50
46
  async function readJsonError(res: Response, fallback: string): Promise<string> {
@@ -52,7 +48,7 @@ export function versionedFileResource<TItem, TMeta, TProductionInfo>(
52
48
  return err.error || fallback;
53
49
  }
54
50
 
55
- async function list(): Promise<{ files: TMeta[]; activeFile?: string; productionFile?: string }> {
51
+ async function list(): Promise<{ files: TMeta[] }> {
56
52
  const res = await fetch(baseUrl);
57
53
  if (!res.ok) throw new Error(`Failed to list ${baseUrl}`);
58
54
  return res.json();
@@ -105,32 +101,7 @@ export function versionedFileResource<TItem, TMeta, TProductionInfo>(
105
101
  }
106
102
  }
107
103
 
108
- async function getProductionInfo(): Promise<TProductionInfo> {
109
- const res = await fetch(`${baseUrl}/production`);
110
- if (!res.ok) throw new Error('Failed to get production info');
111
- return res.json();
112
- }
113
-
114
- async function setProduction(fileName: string): Promise<TProductionInfo & { ok: boolean }> {
115
- const res = await fetch(`${baseUrl}/production`, {
116
- method: 'PUT',
117
- headers: { 'Content-Type': 'application/json' },
118
- body: JSON.stringify({ name: fileName }),
119
- });
120
- if (!res.ok) {
121
- const body = await res.json().catch(() => ({}));
122
- const err = new Error(body.error || 'Set production failed') as Error & {
123
- status?: number;
124
- code?: string;
125
- };
126
- err.status = res.status;
127
- if (body.code) err.code = body.code;
128
- throw err;
129
- }
130
- return res.json();
131
- }
132
-
133
- return { list, load, save, remove, getActive, setActive, getProductionInfo, setProduction };
104
+ return { list, load, save, remove, getActive, setActive };
134
105
  }
135
106
 
136
107
  /** Sanitize a display name to a safe file name. Pure — no DOM, no fs. */
@@ -142,6 +113,8 @@ export function sanitizeFileName(name: string): string {
142
113
  .replace(/\s+/g, '-')
143
114
  .replace(/[^a-z0-9\-_]/g, '')
144
115
  .replace(/-+/g, '-')
145
- .replace(/^-|-$/g, '') || 'unnamed'
116
+ // A leading `_` marks the fixed slots (`_working`, the pointer files), so
117
+ // no name a user types or a bundle carries may keep one.
118
+ .replace(/^[-_]+|-+$/g, '') || 'unnamed'
146
119
  );
147
120
  }
@@ -1,4 +1,4 @@
1
1
  import { writable } from 'svelte/store';
2
2
 
3
- /** The file name of the currently active token file. */
4
- export const activeFileName = writable<string>('default');
3
+ /** Slug of the theme the editor has open — the document every edit belongs to. */
4
+ export const openThemeSlug = writable<string>('default');
@@ -85,7 +85,6 @@ export function normalizeComponents(state: EditorState): EditorState {
85
85
  if (!slice || typeof slice !== 'object') continue;
86
86
  components[name] = {
87
87
  ...slice,
88
- activeFile: typeof slice.activeFile === 'string' ? slice.activeFile : 'default',
89
88
  aliases: slice.aliases && typeof slice.aliases === 'object' ? slice.aliases : {},
90
89
  config: slice.config && typeof slice.config === 'object' ? slice.config : {},
91
90
  };