@motion-proto/live-tokens 0.61.0 → 0.63.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 (83) hide show
  1. package/.claude/skills/live-tokens-adjust-geometry/SKILL.md +4 -4
  2. package/.claude/skills/live-tokens-build-page/SKILL.md +21 -3
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +16 -48
  4. package/.claude/skills/live-tokens-create-component/references/fixed-overlays.md +10 -1
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +7 -5
  6. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +1 -1
  7. package/.claude/skills/live-tokens-create-component/references/token-naming.md +50 -0
  8. package/.claude/skills/live-tokens-generate-theme/SKILL.md +16 -9
  9. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +8 -6
  10. package/.claude/skills/live-tokens-pick-component/SKILL.md +19 -5
  11. package/CHANGELOG.md +114 -0
  12. package/bin/migrate.mjs +6 -2
  13. package/dist-plugin/adjust/index.cjs +1 -1
  14. package/dist-plugin/adjust/index.d.cts +1 -1
  15. package/dist-plugin/adjust/index.d.ts +1 -1
  16. package/dist-plugin/adjust/index.js +1 -1
  17. package/dist-plugin/{chunk-232GZGQU.js → chunk-NDJJORKJ.js} +342 -5
  18. package/dist-plugin/{chunk-Y5CNFSSV.js → chunk-RVE3MNKM.js} +1 -1
  19. package/dist-plugin/{chunk-OIOXU7FR.js → chunk-ZHPX7ZYQ.js} +83 -25
  20. package/dist-plugin/{dataPaths-CRfD1LdA.d.ts → dataPaths-DZUzVv8H.d.cts} +3 -3
  21. package/dist-plugin/{dataPaths-CRfD1LdA.d.cts → dataPaths-DZUzVv8H.d.ts} +3 -3
  22. package/dist-plugin/fontPairing/index.cjs +1 -1
  23. package/dist-plugin/fontPairing/index.d.cts +1 -1
  24. package/dist-plugin/fontPairing/index.d.ts +1 -1
  25. package/dist-plugin/fontPairing/index.js +1 -1
  26. package/dist-plugin/generateColorsAndType/index.cjs +1 -1
  27. package/dist-plugin/generateColorsAndType/index.d.cts +1 -1
  28. package/dist-plugin/generateColorsAndType/index.d.ts +1 -1
  29. package/dist-plugin/generateColorsAndType/index.js +1 -1
  30. package/dist-plugin/index.cjs +461 -58
  31. package/dist-plugin/index.d.cts +1 -1
  32. package/dist-plugin/index.d.ts +1 -1
  33. package/dist-plugin/index.js +31 -23
  34. package/dist-plugin/migrateData/index.cjs +350 -9
  35. package/dist-plugin/migrateData/index.d.cts +1 -1
  36. package/dist-plugin/migrateData/index.d.ts +1 -1
  37. package/dist-plugin/migrateData/index.js +9 -5
  38. package/dist-plugin/tokensCssMigrations/index.cjs +83 -25
  39. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  40. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  41. package/dist-plugin/tokensCssMigrations/index.js +2 -2
  42. package/package.json +4 -2
  43. package/src/app/site.css +32 -0
  44. package/src/editor/component-editor/CardEditor.svelte +7 -1
  45. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +25 -2
  46. package/src/editor/core/preview/lookPreview.ts +46 -5
  47. package/src/editor/core/productionPulse.ts +6 -2
  48. package/src/editor/core/sketch/maskField.ts +75 -39
  49. package/src/editor/core/sketch/sketchLayer.ts +32 -11
  50. package/src/editor/core/sketch/sketchStore.ts +311 -86
  51. package/src/editor/core/sketch/{sketchPresetService.ts → sketchStyleService.ts} +14 -14
  52. package/src/editor/core/sketch/{sketchPresets.ts → sketchStyles.ts} +27 -28
  53. package/src/editor/core/themes/themeDocumentSync.ts +2 -0
  54. package/src/editor/core/themes/themeInit.ts +19 -1
  55. package/src/editor/core/themes/themeService.ts +7 -2
  56. package/src/editor/core/themes/themeTypes.ts +5 -0
  57. package/src/editor/docs/content/editing-tokens.md +1 -1
  58. package/src/editor/docs/content/sketch-mode.md +34 -18
  59. package/src/editor/docs/content/themes-workflow.md +30 -21
  60. package/src/editor/docs/content/where-themes-live.md +9 -5
  61. package/src/editor/docs/content.generated.ts +4 -4
  62. package/src/editor/overlay/LiveTokensRouter.svelte +8 -0
  63. package/src/editor/ui/EditorViewSwitcher.svelte +3 -3
  64. package/src/editor/ui/ThemePanel.svelte +47 -1
  65. package/src/editor/ui/sections/textStyles.ts +29 -1
  66. package/src/editor/ui/sketch/SketchPreview.svelte +3 -3
  67. package/src/editor/ui/sketch/SketchTab.svelte +120 -134
  68. package/src/live-tokens/data/colors-and-type/midnight-study.json +40 -30
  69. package/src/live-tokens/data/themes/autumn.json +3 -3
  70. package/src/live-tokens/data/themes/halloween.json +3 -3
  71. package/src/live-tokens/data/themes/midnight-study.json +61 -49
  72. package/src/live-tokens/data/themes/ocean.json +3 -3
  73. package/src/live-tokens/data/themes/royal-velvet.json +3 -3
  74. package/src/live-tokens/data/themes/sketchy.json +3 -3
  75. package/src/live-tokens/data/themes/spring-meadow.json +3 -3
  76. package/src/live-tokens/data/themes/sunset.json +3 -3
  77. package/src/system/components/Card.svelte +27 -9
  78. package/src/system/components/FloatingTokenTags.css +10 -8
  79. package/src/system/components/ImageLightbox.svelte +5 -2
  80. package/src/system/components/SectionDivider.svelte +3 -3
  81. package/src/system/components/SegmentedControl.svelte +11 -9
  82. package/src/system/styles/tokens.css +28 -5
  83. package/template/src/pages/Home.svelte +1 -11
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Sketch effect layer.
3
3
  *
4
- * Builds an SVG filter bank and a stylesheet from one SketchSettings and
4
+ * Builds an SVG filter bank and a stylesheet from one SketchStyle and
5
5
  * injects both into every document cssVarSync tracks, so the host page behind
6
6
  * the overlay iframe gets the same effect the editor's preview shows.
7
7
  *
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { getSyncedDocuments } from '../cssVarSync';
18
18
  import { buildMaskUri, MASK_TILE } from './maskField';
19
- import type { SketchSettings } from './sketchPresets';
19
+ import type { SketchStyle } from './sketchStyles';
20
20
 
21
21
  const DEFS_ATTR = 'data-sketch-defs';
22
22
  const STYLE_ATTR = 'data-sketch-style';
@@ -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,8 +323,9 @@ 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
- export function buildDefsMarkup(s: SketchSettings): string {
328
+ export function buildDefsMarkup(s: SketchStyle): string {
321
329
  /**
322
330
  * `warp` is the shape stage: one wave of noise whose wavelength spans a whole
323
331
  * component, so the four corners sample different parts of the field and the
@@ -576,7 +584,7 @@ const swing = (travel: number) => String(Number((travel * 2).toFixed(4)));
576
584
  /** Squares the displacement wave off around 0.5, its zero, so full amplitude is
577
585
  spent along the whole edge rather than only where the wave peaks. Both
578
586
  channels take it: the map reads x from R and y from G. */
579
- function squareOff(s: SketchSettings, from: string, to: string): string {
587
+ function squareOff(s: SketchStyle, from: string, to: string): string {
580
588
  if (s.waveform <= 1) return '';
581
589
  const slope = s.waveform.toFixed(2);
582
590
  const intercept = ((1 - s.waveform) / 2).toFixed(3);
@@ -586,7 +594,7 @@ function squareOff(s: SketchSettings, from: string, to: string): string {
586
594
  `</feComponentTransfer>`;
587
595
  }
588
596
 
589
- const squaredResult = (s: SketchSettings, from: string, to: string) =>
597
+ const squaredResult = (s: SketchStyle, from: string, to: string) =>
590
598
  (s.waveform > 1 ? to : from);
591
599
 
592
600
  /** Along-stroke pressure wavelength. Low, with a high floor in the transfer
@@ -642,7 +650,7 @@ const RETRACE_SEED = 53;
642
650
  const WARP_FREQUENCY = 0.08;
643
651
 
644
652
 
645
- export function buildStylesheet(s: SketchSettings): string {
653
+ export function buildStylesheet(s: SketchStyle): string {
646
654
  const on = '[data-sketch]';
647
655
  const parts = `:is(${PARTS})`;
648
656
  const el = `${on} ${parts}`;
@@ -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
  }
@@ -1071,12 +1083,21 @@ function defsNode(doc: Document): SVGSVGElement {
1071
1083
  * This only makes the effect *available*. An element opts in by carrying
1072
1084
  * data-sketch, which is what `setSketchScope` writes.
1073
1085
  */
1074
- export function applySketchLayer(settings: SketchSettings): void {
1086
+ export function applySketchLayer(settings: SketchStyle): 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
 
@@ -1094,7 +1115,7 @@ export function removeSketchLayer(): void {
1094
1115
  * The host page's root and the editor's own preview container are both scopes,
1095
1116
  * which is why this takes an element rather than assuming documentElement.
1096
1117
  */
1097
- export function setSketchScope(el: HTMLElement | null, settings: SketchSettings | null): void {
1118
+ export function setSketchScope(el: HTMLElement | null, settings: SketchStyle | null): void {
1098
1119
  if (!el) return;
1099
1120
  if (!settings) {
1100
1121
  el.removeAttribute('data-sketch');
@@ -1,24 +1,71 @@
1
1
  import { derived, get, writable } from 'svelte/store';
2
2
  import {
3
- SKETCH_PRESETS,
4
- DEFAULT_SKETCH_PRESET,
5
- hydrateSketchSettings,
6
- type SketchSettings,
7
- } from './sketchPresets';
3
+ SKETCH_STYLES,
4
+ DEFAULT_SKETCH_STYLE,
5
+ hydrateSketchStyle,
6
+ type SketchStyle,
7
+ } from './sketchStyles';
8
8
  import { applySketchLayer, hostRoot, removeSketchLayer, setSketchScope } from './sketchLayer';
9
+ import { liveMovedSinceBake } from '../productionPulse';
9
10
  import {
10
- deleteSketchPreset,
11
- listSketchPresets,
12
- loadSketchPreset,
13
- saveSketchPreset,
14
- slugifySketchPreset,
15
- type SketchPresetMeta,
16
- } from './sketchPresetService';
11
+ deleteSketchStyle,
12
+ listSketchStyles,
13
+ loadSketchStyle,
14
+ saveSketchStyle,
15
+ slugifySketchStyle,
16
+ type SketchStyleMeta,
17
+ } from './sketchStyleService';
17
18
 
18
19
  const ENABLED_KEY = 'lt.sketchEnabled';
19
20
  const SETTINGS_KEY = 'lt.sketchSettings';
20
- const PRESET_KEY = 'lt.sketchPreset';
21
+ const STYLE_NAME_KEY = 'lt.sketchStyleName';
21
22
  const BASELINE_KEY = 'lt.sketchBaseline';
23
+ /** Set only by a genuine user decision (`markSketchTouched`), never by the
24
+ `subscribe` write-back below. That write-back plants the four keys above
25
+ on every boot regardless of whether a dial was ever moved, so deriving
26
+ "touched" from their presence would answer "yes" from the second load
27
+ onward — this key is the thing that actually distinguishes the two. */
28
+ const TOUCHED_KEY = 'lt.sketchTouched';
29
+
30
+ /** True once this browser has recorded a genuine sketch decision, so boot
31
+ (`themeInit.ts`) can tell "never touched" from "explicitly off" and only
32
+ seed the live buffer from the theme in the first case. Mutated in place
33
+ by `markSketchTouched`; the first read migrates a browser that already
34
+ held real dial state before this key existed (RJC 7) so it starts
35
+ touched, not one this module is about to seed for the first time. */
36
+ let sketchTouched = (() => {
37
+ try {
38
+ const recorded = localStorage.getItem(TOUCHED_KEY);
39
+ if (recorded !== null) return recorded === 'true';
40
+ const priorUse = localStorage.getItem(ENABLED_KEY) !== null || localStorage.getItem(SETTINGS_KEY) !== null;
41
+ localStorage.setItem(TOUCHED_KEY, String(priorUse));
42
+ return priorUse;
43
+ } catch {
44
+ return false;
45
+ }
46
+ })();
47
+
48
+ /** Reads `TOUCHED_KEY` fresh rather than the cached `sketchTouched`, because a
49
+ peer document's write lands in localStorage between this module's import
50
+ and a caller's await (`themeInit.ts`'s boot reconcile is exactly that
51
+ gap): the cache would still answer as it did at import time. */
52
+ export function hasPersistedSketchState(): boolean {
53
+ try {
54
+ return localStorage.getItem(TOUCHED_KEY) === 'true';
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** Called from every control a user can act on: a dial, a sketchstyle pick, the
61
+ on/off switch. Never from `openThemeSketchStyle` or the storage-sync `adopt`
62
+ path, both of which write the same four keys as a side effect of state
63
+ this browser did not decide on its own. */
64
+ function markSketchTouched(): void {
65
+ if (sketchTouched) return;
66
+ sketchTouched = true;
67
+ persist(TOUCHED_KEY, 'true');
68
+ }
22
69
 
23
70
  function readEnabled(): boolean {
24
71
  try {
@@ -28,61 +75,61 @@ function readEnabled(): boolean {
28
75
  }
29
76
  }
30
77
 
31
- function readSettings(): SketchSettings {
78
+ function readSettings(): SketchStyle {
32
79
  try {
33
80
  const raw = localStorage.getItem(SETTINGS_KEY);
34
- if (raw) return hydrateSketchSettings(JSON.parse(raw));
81
+ if (raw) return hydrateSketchStyle(JSON.parse(raw));
35
82
  } catch {
36
- // fall through to the default preset
83
+ // fall through to the default sketchstyle
37
84
  }
38
- return { ...SKETCH_PRESETS[DEFAULT_SKETCH_PRESET] };
85
+ return { ...SKETCH_STYLES[DEFAULT_SKETCH_STYLE] };
39
86
  }
40
87
 
41
- function readBaseline(): SketchSettings | null {
88
+ function readBaseline(): SketchStyle | null {
42
89
  try {
43
90
  const raw = localStorage.getItem(BASELINE_KEY);
44
- if (raw) return hydrateSketchSettings(JSON.parse(raw));
91
+ if (raw) return hydrateSketchStyle(JSON.parse(raw));
45
92
  } catch {
46
93
  // fall through
47
94
  }
48
95
  return null;
49
96
  }
50
97
 
51
- /** Marks a saved preset in `sketchPreset`, so a user file named `pencil` and
52
- the shipped `pencil` stay distinguishable in one string. */
53
- export const USER_PRESET_PREFIX = 'user:';
98
+ /** Marks a saved sketchstyle in `sketchStyleName`, so a user file named `pencil`
99
+ and the shipped `pencil` stay distinguishable in one string. */
100
+ export const USER_STYLE_PREFIX = 'user:';
54
101
 
55
- function readPresetName(): string {
102
+ function readStyleName(): string {
56
103
  try {
57
- const name = localStorage.getItem(PRESET_KEY);
58
- if (name === '' || (name && (name in SKETCH_PRESETS || name.startsWith(USER_PRESET_PREFIX)))) {
104
+ const name = localStorage.getItem(STYLE_NAME_KEY);
105
+ if (name === '' || (name && (name in SKETCH_STYLES || name.startsWith(USER_STYLE_PREFIX)))) {
59
106
  return name;
60
107
  }
61
108
  } catch {
62
109
  // fall through
63
110
  }
64
- return DEFAULT_SKETCH_PRESET;
111
+ return DEFAULT_SKETCH_STYLE;
65
112
  }
66
113
 
67
- /** Off by default: the effect is a draft look, never something a project
68
- inherits without asking for it. */
114
+ /** Off by default: the effect paints nothing until the open theme's own
115
+ sketchstyle, or a decision this browser already recorded, says otherwise. */
69
116
  export const sketchEnabled = writable<boolean>(readEnabled());
70
- export const sketchSettings = writable<SketchSettings>(readSettings());
71
- /** The preset the dials started from. It survives dial moves, so the grid keeps
72
- showing what the current look is closest to; empty only when nothing was
73
- picked, or the picked file was deleted. */
74
- export const sketchPreset = writable<string>(readPresetName());
117
+ export const sketchSettings = writable<SketchStyle>(readSettings());
118
+ /** The sketchstyle the dials started from. It survives dial moves, so the grid
119
+ keeps showing what the current look is closest to; empty only when nothing
120
+ was picked, or the picked file was deleted. */
121
+ export const sketchStyleName = writable<string>(readStyleName());
75
122
 
76
- /** The settings as the selected preset defined them. Kept beside the live
123
+ /** The settings as the selected sketchstyle defined them. Kept beside the live
77
124
  settings so "modified" is derived by comparison rather than tracked as a
78
125
  flag: dial a value back to where it started and the tab stops claiming a
79
126
  change, with no bookkeeping to get wrong. */
80
- export const sketchBaseline = writable<SketchSettings | null>(readBaseline());
127
+ export const sketchBaseline = writable<SketchStyle | null>(readBaseline());
81
128
 
82
- /** Dial-set fields only. `label` and `blurb` name the preset rather than
129
+ /** Dial-set fields only. `label` and `blurb` name the sketchstyle rather than
83
130
  describe the look, and no dial writes them. */
84
- function sameLook(a: SketchSettings, b: SketchSettings): boolean {
85
- return (Object.keys(a) as (keyof SketchSettings)[])
131
+ function sameLook(a: SketchStyle, b: SketchStyle): boolean {
132
+ return (Object.keys(a) as (keyof SketchStyle)[])
86
133
  .filter((k) => k !== 'label' && k !== 'blurb')
87
134
  .every((k) => a[k] === b[k]);
88
135
  }
@@ -94,60 +141,122 @@ export const sketchDirty = derived(
94
141
 
95
142
  export const sketchBlurb = derived(sketchSettings, (s) => s.blurb);
96
143
 
97
- export function selectSketchPreset(name: string): void {
98
- const preset = SKETCH_PRESETS[name];
99
- if (!preset) return;
100
- sketchPreset.set(name);
101
- sketchBaseline.set({ ...preset });
102
- sketchSettings.set({ ...preset });
144
+ /** The live look as a theme would carry it: the dials when the effect is
145
+ on, nothing when it is off (RJC 1). A copy, not the store's own object,
146
+ so a caller holding onto the result can never observe a later dial move
147
+ through it. */
148
+ export function liveSketchStyle(): SketchStyle | undefined {
149
+ return get(sketchEnabled) ? { ...get(sketchSettings) } : undefined;
103
150
  }
104
151
 
105
- /** Saved presets, listed from the data tree. Empty until `refreshUserPresets`
106
- runs, so importing this module never reaches for the network. */
107
- export const userSketchPresets = writable<SketchPresetMeta[]>([]);
152
+ /** The Sketchstyle view's on/off switch goes through here rather than a bare
153
+ `sketchEnabled.set`, so flipping it by hand marks this browser as having
154
+ made a sketch decision the same as any other control does. */
155
+ export function setSketchEnabled(enabled: boolean): void {
156
+ markSketchTouched();
157
+ if (enabled !== get(sketchEnabled)) liveMovedSinceBake.set(true);
158
+ sketchEnabled.set(enabled);
159
+ }
160
+
161
+ /** What the open theme holds, so "unsaved" is a comparison rather than a
162
+ flag (RJC 5). Set by every path that opens or saves a theme. */
163
+ export const themeSketchStyle = writable<SketchStyle | undefined>(undefined);
164
+
165
+ /** Open a theme's sketchstyle: the dials, the on/off state, and the name
166
+ recovered by comparison (RJC 3). Overwrites the live buffer, which
167
+ is what opening a theme means everywhere else (RJC 6). Never writes a
168
+ `user:` name here: a saved sketchstyle is a file this look may not have come
169
+ from, and the only thing that can name one is picking it. */
170
+ export function openThemeSketchStyle(sketchStyle: SketchStyle | undefined): void {
171
+ themeSketchStyle.set(sketchStyle);
172
+ if (!sketchStyle) {
173
+ sketchEnabled.set(false);
174
+ sketchBaseline.set(null);
175
+ sketchStyleName.set('');
176
+ return;
177
+ }
178
+ const matched = (Object.keys(SKETCH_STYLES) as string[]).find((name) => sameLook(SKETCH_STYLES[name], sketchStyle));
179
+ sketchSettings.set({ ...sketchStyle });
180
+ sketchBaseline.set({ ...sketchStyle });
181
+ sketchStyleName.set(matched ?? '');
182
+ sketchEnabled.set(true);
183
+ }
108
184
 
109
- export async function refreshUserPresets(): Promise<void> {
110
- userSketchPresets.set(await listSketchPresets());
185
+ /** The live sketch differs from what the open theme carries. Presence is
186
+ half the comparison: on with dials the theme does not hold, or off while
187
+ the theme holds a layer, are both off the theme. */
188
+ export const sketchOffLook = derived(
189
+ [sketchEnabled, sketchSettings, themeSketchStyle],
190
+ ([enabled, settings, saved]) => {
191
+ const live = enabled ? settings : undefined;
192
+ if (!live && !saved) return false;
193
+ if (!live || !saved) return true;
194
+ return !sameLook(live, saved);
195
+ },
196
+ );
197
+
198
+ export function selectSketchStyle(name: string): void {
199
+ const style = SKETCH_STYLES[name];
200
+ if (!style) return;
201
+ markSketchTouched();
202
+ if (get(sketchEnabled)) liveMovedSinceBake.set(true);
203
+ sketchStyleName.set(name);
204
+ sketchBaseline.set({ ...style });
205
+ sketchSettings.set({ ...style });
206
+ }
207
+
208
+ /** Saved sketchstyles, listed from the data tree. Empty until
209
+ `refreshSavedSketchStyles` runs, so importing this module never reaches for
210
+ the network. */
211
+ export const savedSketchStyles = writable<SketchStyleMeta[]>([]);
212
+
213
+ export async function refreshSavedSketchStyles(): Promise<void> {
214
+ savedSketchStyles.set(await listSketchStyles());
111
215
  }
112
216
 
113
- export async function selectUserSketchPreset(fileName: string): Promise<void> {
114
- const file = await loadSketchPreset(fileName);
115
- sketchPreset.set(USER_PRESET_PREFIX + fileName);
217
+ export async function selectSavedSketchStyle(fileName: string): Promise<void> {
218
+ const file = await loadSketchStyle(fileName);
219
+ markSketchTouched();
220
+ if (get(sketchEnabled)) liveMovedSinceBake.set(true);
221
+ sketchStyleName.set(USER_STYLE_PREFIX + fileName);
116
222
  sketchBaseline.set({ ...file.settings });
117
223
  sketchSettings.set(file.settings);
118
224
  }
119
225
 
120
226
  /** Writes whatever the dials currently say to a named file and selects it, so
121
227
  saving leaves the tab pointing at the thing that was just saved rather than
122
- back on "Adjusted from a preset". Returns the file name it allocated. */
123
- export async function saveCurrentAsSketchPreset(name: string): Promise<string> {
228
+ back on "Adjusted from a sketchstyle". Returns the file name it allocated. */
229
+ export async function saveCurrentSketchStyle(name: string): Promise<string> {
124
230
  const trimmed = name.trim();
125
- if (!trimmed) throw new Error('A preset needs a name');
126
- const fileName = slugifySketchPreset(trimmed);
231
+ if (!trimmed) throw new Error('A sketchstyle needs a name');
232
+ const fileName = slugifySketchStyle(trimmed);
127
233
  if (!fileName) throw new Error('That name has no letters or digits to make a file name from');
234
+ markSketchTouched();
128
235
  const settings = { ...get(sketchSettings), label: trimmed };
129
- await saveSketchPreset(fileName, trimmed, settings);
130
- await refreshUserPresets();
236
+ await saveSketchStyle(fileName, trimmed, settings);
237
+ await refreshSavedSketchStyles();
131
238
  sketchSettings.set(settings);
132
239
  sketchBaseline.set({ ...settings });
133
- sketchPreset.set(USER_PRESET_PREFIX + fileName);
240
+ sketchStyleName.set(USER_STYLE_PREFIX + fileName);
134
241
  return fileName;
135
242
  }
136
243
 
137
- export async function deleteUserSketchPreset(fileName: string): Promise<void> {
138
- await deleteSketchPreset(fileName);
139
- await refreshUserPresets();
140
- // The dials keep their values; only the label stops naming a file that exists.
141
- if (get(sketchPreset) === USER_PRESET_PREFIX + fileName) {
142
- sketchPreset.set('');
244
+ export async function deleteSavedSketchStyle(fileName: string): Promise<void> {
245
+ await deleteSketchStyle(fileName);
246
+ await refreshSavedSketchStyles();
247
+ // The dials keep their values; only the name stops naming a file that exists.
248
+ if (get(sketchStyleName) === USER_STYLE_PREFIX + fileName) {
249
+ sketchStyleName.set('');
143
250
  sketchBaseline.set(null);
144
251
  }
145
252
  }
146
253
 
147
254
  /** Every dial goes through here. The selection deliberately survives: the grid
148
- keeps naming the preset this look came from, and `sketchDirty` reports the
149
- drift. Save writes a new preset rather than overwriting the base. */
150
- export function updateSketchSettings(patch: Partial<SketchSettings>): void {
255
+ keeps naming the sketchstyle this look came from, and `sketchDirty` reports
256
+ the drift. Save writes a new sketchstyle rather than overwriting the base. */
257
+ export function updateSketchSettings(patch: Partial<SketchStyle>): void {
258
+ markSketchTouched();
259
+ if (get(sketchEnabled)) liveMovedSinceBake.set(true);
151
260
  sketchSettings.update((s) => ({ ...s, ...patch }));
152
261
  }
153
262
 
@@ -155,36 +264,152 @@ function persist(key: string, value: string): void {
155
264
  try {
156
265
  localStorage.setItem(key, value);
157
266
  } catch {
158
- // Private-mode storage denial is not worth surfacing for a draft look.
267
+ // Private-mode storage denial is not worth surfacing for one browser's dials.
159
268
  }
160
269
  }
161
270
 
162
- if (typeof document !== 'undefined') {
163
- let installed = false;
271
+ /** The root the effect paints in this document, or null while this document is
272
+ showing an editor surface. Registered by LiveTokensRouter: a consumer can
273
+ relocate the editor routes, so the router is the only thing that knows
274
+ whether what is on screen is a page or the editor's own chrome. Null until
275
+ it says so, which is what keeps the editor from flashing the effect over
276
+ itself between import and first render. */
277
+ let pageRoot: HTMLElement | null = null;
164
278
 
165
- const render = (enabled: boolean, settings: SketchSettings) => {
166
- if (!enabled) {
167
- if (installed) removeSketchLayer();
168
- installed = false;
169
- return;
170
- }
171
- applySketchLayer(settings);
279
+ let installed = false;
280
+
281
+ function render(enabled: boolean, settings: SketchStyle): void {
282
+ if (typeof document === 'undefined') return;
283
+ if (!enabled) {
284
+ if (installed) removeSketchLayer();
285
+ installed = false;
286
+ return;
287
+ }
288
+ applySketchLayer(settings);
289
+ installed = true;
290
+ // The editor's own chrome must never pick the effect up. Two roots qualify:
291
+ // the host page behind the overlay iframe, and this document while it is
292
+ // showing a page. The preview container scopes itself.
293
+ setSketchScope(hostRoot(), settings);
294
+ setSketchScope(pageRoot, settings);
295
+ }
296
+
297
+ /** Paint one look on the two roots `render` owns, and on nothing else.
298
+
299
+ `render(false, ...)` reaches further: it clears every `[data-sketch]` in
300
+ the document. The scopes it takes with it belong to `$effect`s that paint
301
+ from the live stores, which a preview deliberately never ticks, so those
302
+ never come back. Previewing a theme carrying no sketchstyle left the
303
+ Sketchstyle view's own stage crisp for good, Cancel included. */
304
+ function paintPreviewRoots(style: SketchStyle | undefined): void {
305
+ if (typeof document === 'undefined') return;
306
+ if (style) {
307
+ applySketchLayer(style);
172
308
  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
- };
309
+ }
310
+ setSketchScope(hostRoot(), style ?? null);
311
+ setSketchScope(pageRoot, style ?? null);
312
+ }
313
+
314
+ /** Paint a sketchstyle for the Theme Picker preview, bypassing the live
315
+ buffer entirely: going through `sketchSettings`/`sketchEnabled` would
316
+ `share()` the previewed dials into localStorage and overwrite whatever
317
+ the user had live, which is right for Apply (RJC 6) but would destroy
318
+ unsaved work the moment the picker opened a row. The paint reaches the
319
+ host page across the iframe boundary the same way Apply's does; only the
320
+ store write and the persistence are skipped. Pass `undefined` for a theme
321
+ that carries no sketchstyle. */
322
+ export function previewSketchStyle(style: SketchStyle | undefined): void {
323
+ paintPreviewRoots(style);
324
+ }
325
+
326
+ /** Undo a sketchstyle preview by repainting the live buffer, which the
327
+ preview never touched. Re-deriving from the current stores rather than a
328
+ scraped snapshot means it can't drift from what they actually hold by the
329
+ time the picker closes. */
330
+ export function revertSketchStylePreview(): void {
331
+ if (get(sketchEnabled)) {
332
+ paintPreviewRoots(get(sketchSettings));
333
+ return;
334
+ }
335
+ // The one case with nodes to take down: a sketched preview over a crisp live
336
+ // state injected the sheet. `render`'s sweep is safe here because every
337
+ // component scope answers to `sketchEnabled`, which is false.
338
+ render(false, get(sketchSettings));
339
+ }
340
+
341
+ export function setSketchPageRoot(el: HTMLElement | null): void {
342
+ if (el === pageRoot) return;
343
+ setSketchScope(pageRoot, null);
344
+ pageRoot = el;
345
+ render(get(sketchEnabled), get(sketchSettings));
346
+ }
347
+
348
+ /**
349
+ * What this document last exchanged with the other one, per key: written by
350
+ * `share`, recorded by `adopt`.
351
+ *
352
+ * A document must never write back a value it adopted. Comparing against its
353
+ * own store is not enough, because the echo is not identical, it is LATE: a
354
+ * drag sends a value every frame, the far side adopts the first and writes it
355
+ * back, and by then this side is three frames along. It reads the echo, sees a
356
+ * value it does not hold, and adopts its own past — the handle jumps back under
357
+ * the cursor and the drag cannot move.
358
+ */
359
+ const shared = new Map<string, string>();
360
+
361
+ function share(key: string, value: string): void {
362
+ if (shared.get(key) === value) return;
363
+ shared.set(key, value);
364
+ persist(key, value);
365
+ }
177
366
 
367
+ function adopt(key: string, value: string): void {
368
+ shared.set(key, value);
369
+ }
370
+
371
+ if (typeof document !== 'undefined') {
178
372
  sketchEnabled.subscribe((enabled) => {
179
- persist(ENABLED_KEY, String(enabled));
373
+ share(ENABLED_KEY, String(enabled));
180
374
  render(enabled, get(sketchSettings));
181
375
  });
182
376
 
183
377
  sketchSettings.subscribe((settings) => {
184
- persist(SETTINGS_KEY, JSON.stringify(settings));
378
+ share(SETTINGS_KEY, JSON.stringify(settings));
185
379
  render(get(sketchEnabled), settings);
186
380
  });
187
381
 
188
- sketchPreset.subscribe((name) => persist(PRESET_KEY, name));
189
- sketchBaseline.subscribe((b) => persist(BASELINE_KEY, b ? JSON.stringify(b) : ''));
382
+ sketchStyleName.subscribe((name) => share(STYLE_NAME_KEY, name));
383
+ sketchBaseline.subscribe((b) => share(BASELINE_KEY, b ? JSON.stringify(b) : ''));
384
+
385
+ /* The overlay editor runs in an iframe, so the Sketchstyle view and a
386
+ control on the page are separate instances of this module. localStorage
387
+ is the ground they share: each adopts a value only when it differs from
388
+ what it holds, and records what it adopted so it never sends that value
389
+ back. */
390
+ window.addEventListener('storage', (event) => {
391
+ if (event.storageArea !== localStorage) return;
392
+ if (event.key === ENABLED_KEY) {
393
+ const next = event.newValue === 'true';
394
+ adopt(ENABLED_KEY, String(next));
395
+ if (next !== get(sketchEnabled)) sketchEnabled.set(next);
396
+ } else if (event.key === SETTINGS_KEY) {
397
+ if (event.newValue && event.newValue !== JSON.stringify(get(sketchSettings))) {
398
+ const next = readSettings();
399
+ adopt(SETTINGS_KEY, JSON.stringify(next));
400
+ sketchSettings.set(next);
401
+ }
402
+ } else if (event.key === STYLE_NAME_KEY) {
403
+ const next = readStyleName();
404
+ adopt(STYLE_NAME_KEY, next);
405
+ if (next !== get(sketchStyleName)) sketchStyleName.set(next);
406
+ } else if (event.key === BASELINE_KEY) {
407
+ const current = get(sketchBaseline);
408
+ if ((event.newValue || '') !== (current ? JSON.stringify(current) : '')) {
409
+ const next = readBaseline();
410
+ adopt(BASELINE_KEY, next ? JSON.stringify(next) : '');
411
+ sketchBaseline.set(next);
412
+ }
413
+ }
414
+ });
190
415
  }