@motion-proto/live-tokens 0.1.1 → 0.3.2

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 (224) hide show
  1. package/README.md +168 -21
  2. package/dist-plugin/index.cjs +823 -336
  3. package/dist-plugin/index.d.cts +9 -7
  4. package/dist-plugin/index.d.ts +9 -7
  5. package/dist-plugin/index.js +822 -335
  6. package/package.json +46 -20
  7. package/src/assets/newspaper.webp +0 -0
  8. package/src/assets/offering.webp +0 -0
  9. package/src/component-editor/BadgeEditor.svelte +170 -0
  10. package/src/component-editor/CalloutEditor.svelte +103 -0
  11. package/src/component-editor/CardEditor.svelte +184 -0
  12. package/src/component-editor/CollapsibleSectionEditor.svelte +167 -0
  13. package/src/component-editor/CornerBadgeEditor.svelte +207 -0
  14. package/src/component-editor/DialogEditor.svelte +172 -0
  15. package/src/component-editor/ImageEditor.svelte +72 -0
  16. package/src/component-editor/InlineEditActionsEditor.svelte +83 -0
  17. package/src/component-editor/NotificationEditor.svelte +160 -0
  18. package/src/component-editor/ProgressBarEditor.svelte +124 -0
  19. package/src/component-editor/RadioButtonEditor.svelte +140 -0
  20. package/src/component-editor/SectionDividerEditor.svelte +263 -0
  21. package/src/component-editor/SegmentedControlEditor.svelte +154 -0
  22. package/src/component-editor/StandardButtonsEditor.svelte +178 -0
  23. package/src/component-editor/TabBarEditor.svelte +137 -0
  24. package/src/component-editor/TableEditor.svelte +128 -0
  25. package/src/component-editor/TooltipEditor.svelte +122 -0
  26. package/src/component-editor/editorTokens.test.ts +93 -0
  27. package/src/component-editor/groupKeySlots.test.ts +67 -0
  28. package/src/component-editor/groupKeySnapshot.test.ts +52 -0
  29. package/src/component-editor/index.ts +5 -0
  30. package/src/component-editor/registry.ts +246 -0
  31. package/src/component-editor/scaffolding/AngleDial.svelte +185 -0
  32. package/src/component-editor/scaffolding/ComponentEditorBase.svelte +96 -0
  33. package/src/component-editor/scaffolding/ComponentFileManager.svelte +682 -0
  34. package/src/component-editor/scaffolding/ComponentFileMenu.svelte +312 -0
  35. package/src/component-editor/scaffolding/ComponentsTab.svelte +69 -0
  36. package/src/component-editor/scaffolding/CopyFromMenu.svelte +246 -0
  37. package/src/component-editor/scaffolding/DemoHeader.svelte +21 -0
  38. package/src/component-editor/scaffolding/DividerEditor.svelte +81 -0
  39. package/src/component-editor/scaffolding/FieldsetWrapper.svelte +46 -0
  40. package/src/component-editor/scaffolding/GradientCard.svelte +291 -0
  41. package/src/component-editor/scaffolding/LinkageChart.svelte +297 -0
  42. package/src/component-editor/scaffolding/LinkedBlock.svelte +418 -0
  43. package/src/component-editor/scaffolding/NonStylableConfig.svelte +57 -0
  44. package/src/component-editor/scaffolding/SaveAsDialog.svelte +177 -0
  45. package/src/component-editor/scaffolding/ShadowBackdrop.svelte +25 -0
  46. package/src/component-editor/scaffolding/ShadowBackdropControls.svelte +56 -0
  47. package/src/component-editor/scaffolding/StateBlock.svelte +115 -0
  48. package/src/component-editor/scaffolding/TokenLayout.svelte +511 -0
  49. package/src/component-editor/scaffolding/TypeEditor.svelte +82 -0
  50. package/src/component-editor/scaffolding/VariantGroup.svelte +277 -0
  51. package/src/component-editor/scaffolding/buildTypeGroupTokens.ts +97 -0
  52. package/src/component-editor/scaffolding/componentSectionType.ts +8 -0
  53. package/src/component-editor/scaffolding/componentSources.ts +9 -0
  54. package/src/component-editor/scaffolding/defaultSections.ts +16 -0
  55. package/src/component-editor/scaffolding/editorContext.ts +44 -0
  56. package/src/component-editor/scaffolding/linkedBlock.ts +226 -0
  57. package/src/component-editor/scaffolding/siblings.ts +33 -0
  58. package/src/component-editor/scaffolding/types.ts +39 -0
  59. package/src/components/Badge.svelte +231 -42
  60. package/src/components/Button.svelte +324 -124
  61. package/src/components/Callout.svelte +145 -0
  62. package/src/components/Card.svelte +123 -25
  63. package/src/components/CollapsibleSection.svelte +213 -35
  64. package/src/components/CornerBadge.svelte +224 -0
  65. package/src/components/Dialog.svelte +137 -114
  66. package/src/components/Image.svelte +43 -0
  67. package/src/components/InlineEditActions.svelte +74 -14
  68. package/src/components/Notification.svelte +184 -163
  69. package/src/components/ProgressBar.svelte +216 -22
  70. package/src/components/RadioButton.svelte +110 -40
  71. package/src/components/SectionDivider.svelte +428 -74
  72. package/src/components/SegmentedControl.svelte +203 -0
  73. package/src/components/TabBar.svelte +146 -21
  74. package/src/components/Table.svelte +102 -0
  75. package/src/components/Tooltip.svelte +45 -19
  76. package/src/components/types.ts +51 -0
  77. package/src/data/google-fonts.json +75 -0
  78. package/src/lib/ColumnsOverlay.svelte +20 -7
  79. package/src/lib/LiveEditorOverlay.svelte +257 -78
  80. package/src/lib/columnsOverlay.ts +21 -17
  81. package/src/lib/componentConfig.test.ts +204 -0
  82. package/src/lib/componentConfigKeys.ts +19 -0
  83. package/src/lib/componentConfigService.ts +88 -0
  84. package/src/lib/copyPopover.ts +30 -0
  85. package/src/lib/cssVarSync.ts +59 -7
  86. package/src/lib/editorConfigStore.ts +0 -10
  87. package/src/lib/editorCore.ts +402 -0
  88. package/src/lib/editorKeybindings.ts +52 -0
  89. package/src/lib/editorPersistence.ts +106 -0
  90. package/src/lib/editorRenderer.ts +74 -0
  91. package/src/lib/editorStore.test.ts +328 -0
  92. package/src/lib/editorStore.ts +412 -0
  93. package/src/lib/editorTypes.ts +100 -0
  94. package/src/lib/editorViewStore.ts +55 -0
  95. package/src/lib/files/versionedFileResource.ts +140 -0
  96. package/src/lib/fontLoader.ts +130 -0
  97. package/src/lib/fontMigration.ts +140 -0
  98. package/src/lib/fontParse.ts +168 -0
  99. package/src/lib/index.ts +48 -30
  100. package/src/lib/lazyConfig.test.ts +54 -0
  101. package/src/lib/migrations/2026-04-24-component-prefix-and-suffix-renames.ts +64 -0
  102. package/src/lib/migrations/2026-04-24-legacy-keys-and-bg-to-canvas.ts +71 -0
  103. package/src/lib/migrations/2026-04-27-segmentedcontrol-disabled-flatten.ts +43 -0
  104. package/src/lib/migrations/2026-05-08-collapsiblesection-frame-and-cleanup.ts +68 -0
  105. package/src/lib/migrations/2026-05-08-collapsiblesection-variant-namespace.ts +35 -0
  106. package/src/lib/migrations/2026-05-10-sectiondivider-gradient-stops.ts +50 -0
  107. package/src/lib/migrations/2026-05-13-primary-to-brand.ts +90 -0
  108. package/src/lib/migrations/index.ts +93 -0
  109. package/src/lib/migrations/migrations.test.ts +341 -0
  110. package/src/lib/navLinkTypes.ts +1 -0
  111. package/src/lib/overlayState.ts +3 -0
  112. package/src/lib/paletteDerivation.ts +300 -0
  113. package/src/lib/parentRouteStore.ts +42 -0
  114. package/src/lib/parsers/globalRootBlock.ts +32 -0
  115. package/src/lib/presetService.ts +94 -0
  116. package/src/lib/router.ts +42 -10
  117. package/src/lib/scrollSection.ts +45 -0
  118. package/src/lib/slices/columns.ts +59 -0
  119. package/src/lib/slices/components.ts +362 -0
  120. package/src/lib/slices/domainVars.ts +15 -0
  121. package/src/lib/slices/fonts.ts +30 -0
  122. package/src/lib/slices/gradients.ts +153 -0
  123. package/src/lib/slices/overlays.ts +132 -0
  124. package/src/lib/slices/palettes.ts +26 -0
  125. package/src/lib/slices/shadows.ts +123 -0
  126. package/src/lib/storage.ts +88 -0
  127. package/src/lib/themeInit.ts +74 -0
  128. package/src/lib/themeService.ts +101 -0
  129. package/src/lib/themeTypes.ts +146 -0
  130. package/src/lib/tokenRegistry.ts +148 -0
  131. package/src/pages/ComponentEditorPage.svelte +384 -0
  132. package/src/pages/ComponentEditorPage.svelte.d.ts +2 -0
  133. package/src/pages/Editor.svelte +98 -0
  134. package/src/pages/Editor.svelte.d.ts +2 -0
  135. package/src/pages/EditorShell.svelte +348 -0
  136. package/src/styles/_padding.scss +34 -0
  137. package/src/styles/fonts/Fraunces/Fraunces-italic-latin-ext.woff2 +0 -0
  138. package/src/styles/fonts/Fraunces/Fraunces-italic-latin.woff2 +0 -0
  139. package/src/styles/fonts/Fraunces/Fraunces-roman-latin-ext.woff2 +0 -0
  140. package/src/styles/fonts/Fraunces/Fraunces-roman-latin.woff2 +0 -0
  141. package/src/styles/fonts/Manrope/Manrope-latin-ext.woff2 +0 -0
  142. package/src/styles/fonts/Manrope/Manrope-latin.woff2 +0 -0
  143. package/src/styles/fonts.css +22 -10
  144. package/src/styles/form-controls.css +14 -16
  145. package/src/styles/tokens.css +1322 -0
  146. package/src/styles/ui-editor.css +126 -0
  147. package/src/{showcase → ui}/BezierCurveEditor.svelte +14 -14
  148. package/src/{showcase → ui}/ColorEditPanel.svelte +42 -36
  149. package/src/ui/EditorViewSwitcher.svelte +180 -0
  150. package/src/ui/FontStackEditor.svelte +360 -0
  151. package/src/ui/GradientEditor.svelte +461 -0
  152. package/src/ui/GradientStopPicker.svelte +74 -0
  153. package/src/ui/PaletteEditor.svelte +1590 -0
  154. package/src/ui/PaletteEditor.test.ts +108 -0
  155. package/src/ui/PresetFileManager.svelte +567 -0
  156. package/src/ui/ProjectFontsSection.svelte +645 -0
  157. package/src/{showcase → ui}/SurfacesTab.svelte +39 -39
  158. package/src/{showcase → ui}/TextTab.svelte +27 -27
  159. package/src/{showcase/TokenFileManager.svelte → ui/ThemeFileManager.svelte} +196 -112
  160. package/src/ui/Toggle.svelte +108 -0
  161. package/src/ui/UICopyPopover.svelte +78 -0
  162. package/src/{showcase/EditorDialog.svelte → ui/UIDialog.svelte} +66 -25
  163. package/src/ui/UIFontFamilySelector.svelte +309 -0
  164. package/src/ui/UIFontSizeSelector.svelte +165 -0
  165. package/src/ui/UIFontWeightSelector.svelte +52 -0
  166. package/src/ui/UILineHeightSelector.svelte +47 -0
  167. package/src/ui/UILinkToggle.svelte +60 -0
  168. package/src/ui/UIOptionItem.svelte +74 -0
  169. package/src/ui/UIOptionList.svelte +27 -0
  170. package/src/ui/UIPaddingSelector.svelte +661 -0
  171. package/src/ui/UIPaletteSelector.svelte +1084 -0
  172. package/src/ui/UIRadio.svelte +72 -0
  173. package/src/ui/UIRadioGroup.svelte +59 -0
  174. package/src/ui/UIRelinkConfirmPopover.svelte +235 -0
  175. package/src/ui/UITokenSelector.svelte +509 -0
  176. package/src/ui/UIVariantSelector.svelte +145 -0
  177. package/src/ui/VariablesTab.svelte +252 -0
  178. package/src/ui/index.ts +31 -0
  179. package/src/ui/keepInViewport.ts +84 -0
  180. package/src/ui/palette/GradientStopEditor.svelte +482 -0
  181. package/src/ui/palette/OverridesPanel.svelte +526 -0
  182. package/src/ui/palette/PaletteBase.svelte +165 -0
  183. package/src/ui/palette/ScaleCurveEditor.svelte +38 -0
  184. package/src/ui/palette/paletteEditorState.ts +89 -0
  185. package/src/ui/sections/ColumnsSection.svelte +273 -0
  186. package/src/ui/sections/GradientsSection.svelte +147 -0
  187. package/src/ui/sections/OverlaysSection.svelte +670 -0
  188. package/src/ui/sections/ShadowsSection.svelte +1250 -0
  189. package/src/ui/sections/TokenScaleTable.svelte +332 -0
  190. package/src/ui/sections/tokenScales.ts +81 -0
  191. package/src/ui/variantScales.ts +108 -0
  192. package/src/components/DetailNav.svelte +0 -78
  193. package/src/components/Toggle.svelte +0 -86
  194. package/src/lib/tokenInit.ts +0 -29
  195. package/src/lib/tokenService.ts +0 -144
  196. package/src/lib/tokenTypes.ts +0 -45
  197. package/src/pages/Admin.svelte +0 -100
  198. package/src/pages/ShowcasePage.svelte +0 -144
  199. package/src/showcase/BackupBrowser.svelte +0 -617
  200. package/src/showcase/ComponentsTab.svelte +0 -105
  201. package/src/showcase/PaletteEditor.svelte +0 -2579
  202. package/src/showcase/PaletteSelector.svelte +0 -627
  203. package/src/showcase/TokenMap.svelte +0 -54
  204. package/src/showcase/VariablesTab.svelte +0 -2655
  205. package/src/showcase/VisualsTab.svelte +0 -231
  206. package/src/showcase/demos/BadgeDemo.svelte +0 -56
  207. package/src/showcase/demos/CardDemo.svelte +0 -50
  208. package/src/showcase/demos/ChoiceButtonsDemo.svelte +0 -192
  209. package/src/showcase/demos/CollapsibleSectionDemo.svelte +0 -54
  210. package/src/showcase/demos/DialogDemo.svelte +0 -42
  211. package/src/showcase/demos/InlineEditActionsDemo.svelte +0 -25
  212. package/src/showcase/demos/NotificationDemo.svelte +0 -147
  213. package/src/showcase/demos/ProgressBarDemo.svelte +0 -54
  214. package/src/showcase/demos/RadioButtonDemo.svelte +0 -56
  215. package/src/showcase/demos/SectionDividerDemo.svelte +0 -77
  216. package/src/showcase/demos/StandardButtonsDemo.svelte +0 -455
  217. package/src/showcase/demos/TabBarDemo.svelte +0 -58
  218. package/src/showcase/demos/TooltipDemo.svelte +0 -52
  219. package/src/showcase/editor.css +0 -93
  220. package/src/showcase/index.ts +0 -17
  221. package/src/styles/fonts/Domine/Domine-VariableFont_wght.ttf +0 -0
  222. package/src/styles/fonts/Domine/OFL.txt +0 -97
  223. package/src/styles/fonts/Domine/README.txt +0 -66
  224. /package/src/{showcase → ui}/curveEngine.ts +0 -0
@@ -0,0 +1,402 @@
1
+ /**
2
+ * State-core for the editor store.
3
+ *
4
+ * Owns the writable, history stacks (undo/redo), and the unified `Scope`
5
+ * primitive that brackets a series of mutations. Exposes `editorState`
6
+ * (Readable), `mutate` for unscoped one-shots, `transaction(label, fn)` for
7
+ * sync closure form, `beginSliderGesture(label)` for window-pointerup wiring,
8
+ * and the `beginScope/commitScope/cancelScope` trio for explicit handle-based
9
+ * scopes (the only form palette panels need). Domain slices import `mutate`
10
+ * and friends from here.
11
+ *
12
+ * The Scope primitive (M6 fold) — two orthogonal axes:
13
+ * - `collapseToOne` — on commit, all intra-scope mutations collapse to a
14
+ * single history entry (the pre-scope snapshot).
15
+ * - `clipUndoFloor` — while the scope is active, undo() is clipped to the
16
+ * scope's start; on commit, intra-scope past entries are dropped from
17
+ * history so the snapshot becomes the single undo target.
18
+ *
19
+ * Mapping to use cases:
20
+ * - `mutate` — unscoped one-shot (no scope at all).
21
+ * - drag gesture / atomic — scope { collapseToOne: true, clipUndoFloor: false }.
22
+ * - palette panel session — scope { collapseToOne: true, clipUndoFloor: true }.
23
+ *
24
+ * Two side-effect hooks are wired by `editorStore.ts` at boot:
25
+ * - `setEmptyStateFactory` — produces a fresh state tree.
26
+ * - `setPersistHook` — debounced localStorage write.
27
+ *
28
+ * Hooks are nullable on first import so the module evaluates cleanly even
29
+ * when consumed from contexts that don't need persistence (SSR, isolated
30
+ * unit tests).
31
+ */
32
+
33
+ import { writable, derived, get, type Readable } from 'svelte/store';
34
+ import type { EditorState } from './editorTypes';
35
+
36
+ const HISTORY_MAX = 100;
37
+
38
+ // ── Hooks (wired by editorStore at boot) ──────────────────────────────────
39
+
40
+ let emptyStateFactory: () => EditorState = () => ({} as EditorState);
41
+ let persistHook: () => void = () => {};
42
+
43
+ export function setEmptyStateFactory(fn: () => EditorState): void {
44
+ emptyStateFactory = fn;
45
+ }
46
+ export function setPersistHook(fn: () => void): void {
47
+ persistHook = fn;
48
+ }
49
+
50
+ /** Trigger the persistence hook. Used by seed paths that bypass `mutate`. */
51
+ export function persist(): void {
52
+ persistHook();
53
+ }
54
+
55
+ // ── Store + history machine ───────────────────────────────────────────────
56
+
57
+ export const store = writable<EditorState>(emptyStateFactory());
58
+
59
+ // History stacks hold snapshots that are independent of the current state
60
+ // reference (always created via structuredClone at push time). This lets
61
+ // mutate / undo / redo use in-place mutation on the live state without
62
+ // corrupting history entries.
63
+ const past: EditorState[] = [];
64
+ const future: EditorState[] = [];
65
+ let savedAtIndex = 0;
66
+
67
+ // A counter that bumps on every history-affecting change, so `derived`
68
+ // stores (canUndo, canRedo, dirty) re-evaluate. The history arrays
69
+ // themselves live outside Svelte reactivity.
70
+ const historyTick = writable(0);
71
+ function bumpTick() { historyTick.update((n) => n + 1); }
72
+
73
+ /** Push `entry` onto past[], handling overflow + clearing future. */
74
+ function pushPast(entry: EditorState): void {
75
+ past.push(entry);
76
+ if (past.length > HISTORY_MAX) {
77
+ past.shift();
78
+ if (savedAtIndex > 0) savedAtIndex--;
79
+ }
80
+ future.length = 0;
81
+ }
82
+
83
+ // ── Scope primitive ───────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * A handle for a bracketed series of mutations. Returned by `beginScope` and
87
+ * passed back to `commitScope`/`cancelScope`. The shape is public but its
88
+ * fields are read-only from outside this module — callers treat it as an
89
+ * opaque ticket.
90
+ *
91
+ * Two orthogonal axes:
92
+ * - collapseToOne: on commit, everything in the scope collapses to one
93
+ * history entry (the pre-scope snapshot).
94
+ * - clipUndoFloor: while the scope is active, undo() cannot pop past
95
+ * entries pushed before the scope began; on commit, intra-scope past
96
+ * entries are dropped (past.length = historyIdx).
97
+ */
98
+ export interface Scope {
99
+ readonly snapshot: EditorState;
100
+ readonly label: string;
101
+ readonly collapseToOne: boolean;
102
+ readonly clipUndoFloor: boolean;
103
+ /** past.length captured at scope start — only consulted when clipUndoFloor. */
104
+ readonly historyIdx: number;
105
+ /** Set by `mutate()` when at least one mutation is applied within the scope. */
106
+ changed: boolean;
107
+ }
108
+
109
+ // Two scope slots: one for in-flight non-clipping scopes (drag gestures,
110
+ // atomic edits), one for clipping scopes (palette edit sessions). They can
111
+ // coexist — a slider drag inside an open palette session is the canonical
112
+ // case — so they're tracked independently. `mutate` consults `transactionScope`
113
+ // to suppress per-mutate pushes; `undo` consults `clippingScope` to enforce
114
+ // the floor.
115
+ let transactionScope: Scope | null = null;
116
+ let clippingScope: Scope | null = null;
117
+
118
+ /**
119
+ * Open a new scope. Returns a handle the caller passes back to
120
+ * `commitScope`/`cancelScope`. The handle's `clipUndoFloor` flag determines
121
+ * which internal slot the scope occupies; if a slot is already filled, the
122
+ * prior scope is auto-committed (transaction slot) or auto-committed
123
+ * (clipping slot) — matching the prior wrapper behavior.
124
+ */
125
+ export function beginScope(opts: {
126
+ label: string;
127
+ collapseToOne: boolean;
128
+ clipUndoFloor: boolean;
129
+ }): Scope {
130
+ const scope: Scope = {
131
+ snapshot: structuredClone(get(store)),
132
+ label: opts.label,
133
+ collapseToOne: opts.collapseToOne,
134
+ clipUndoFloor: opts.clipUndoFloor,
135
+ historyIdx: past.length,
136
+ changed: false,
137
+ };
138
+ if (opts.clipUndoFloor) {
139
+ if (clippingScope) commitScope(clippingScope);
140
+ clippingScope = scope;
141
+ } else {
142
+ if (transactionScope) {
143
+ if (import.meta.env.DEV) {
144
+ console.warn(
145
+ `[editorStore] beginScope("${opts.label}") while "${transactionScope.label}" is still open; aborting previous`,
146
+ );
147
+ }
148
+ cancelScope(transactionScope, { silent: true });
149
+ }
150
+ transactionScope = scope;
151
+ }
152
+ return scope;
153
+ }
154
+
155
+ /**
156
+ * Commit the given scope. If collapseToOne, all intra-scope past entries
157
+ * (those pushed since `historyIdx`) are dropped and the pre-scope snapshot
158
+ * is pushed in their place — but only if anything actually changed.
159
+ *
160
+ * "Did anything change" is computed by state-equality (JSON) when
161
+ * `clipUndoFloor` is set, because the scope's intra-scope mutations were
162
+ * applied to history AND then dropped — the only signal left is whether
163
+ * the live state still matches the snapshot. For non-clipping scopes,
164
+ * the `changed` flag is the source of truth: empty scopes skip the commit
165
+ * entirely.
166
+ */
167
+ export function commitScope(scope: Scope): void {
168
+ // Clear the slot first so internal aborts triggered during commit don't
169
+ // recurse against the same scope.
170
+ if (scope.clipUndoFloor) {
171
+ if (clippingScope !== scope) return;
172
+ if (transactionScope) cancelScope(transactionScope, { silent: true });
173
+ clippingScope = null;
174
+ } else {
175
+ if (transactionScope !== scope) return;
176
+ transactionScope = null;
177
+ }
178
+
179
+ if (scope.collapseToOne) {
180
+ if (scope.clipUndoFloor) {
181
+ // Clipping path: intra-scope entries were each pushed by mutate()
182
+ // (they had to be, so undo within the scope could walk them back).
183
+ // On commit, drop them and push the pre-scope snapshot iff state is
184
+ // not back at the snapshot.
185
+ past.length = scope.historyIdx;
186
+ const snapshotJson = JSON.stringify(scope.snapshot);
187
+ const currentJson = JSON.stringify(get(store));
188
+ const changedByEquality = snapshotJson !== currentJson;
189
+ if (changedByEquality) {
190
+ pushPast(scope.snapshot);
191
+ persistHook();
192
+ }
193
+ bumpTick();
194
+ if (import.meta.env.DEV) {
195
+ console.debug('[editorStore] commitScope (clipping) →', {
196
+ label: scope.label,
197
+ pastLen: past.length,
198
+ snapshotPalettes: paletteSnapshot(scope.snapshot),
199
+ currentPalettes: paletteSnapshot(get(store)),
200
+ });
201
+ }
202
+ return;
203
+ }
204
+ // Non-clipping path: intra-scope mutations were applied to the store
205
+ // but NOT pushed individually. Push the pre-scope snapshot iff anything
206
+ // changed.
207
+ if (!scope.changed) return;
208
+ pushPast(scope.snapshot);
209
+ bumpTick();
210
+ persistHook();
211
+ return;
212
+ }
213
+ // Future: a non-collapsing scope would be a no-op on commit (entries
214
+ // already live in past[]). Not used today.
215
+ bumpTick();
216
+ }
217
+
218
+ /**
219
+ * Cancel the given scope. Always reverts state to the snapshot and drops
220
+ * intra-scope past entries. The `silent` flag controls whether the cancel
221
+ * surfaces as a tick + persist:
222
+ * - silent: false (default) — used when the user aborts a scope
223
+ * explicitly (e.g., palette panel X). Refreshes dirty + persist so the
224
+ * UI reflects the discard.
225
+ * - silent: true — used by internal auto-aborts (a stray transaction
226
+ * being killed by undo() / by a competing scope). UI observes the state
227
+ * revert, but `dirty` reflects the pre-scope position because no tick
228
+ * fires and no persist runs.
229
+ */
230
+ export function cancelScope(scope: Scope, opts?: { silent?: boolean }): void {
231
+ // Clear the slot first so any code observing the state revert doesn't see
232
+ // a stale "scope still open" view.
233
+ if (scope.clipUndoFloor) {
234
+ if (clippingScope !== scope) return;
235
+ if (transactionScope) cancelScope(transactionScope, { silent: true });
236
+ clippingScope = null;
237
+ } else {
238
+ if (transactionScope !== scope) return;
239
+ transactionScope = null;
240
+ }
241
+
242
+ past.length = scope.historyIdx;
243
+ if (scope.clipUndoFloor) future.length = 0;
244
+ store.set(scope.snapshot);
245
+ if (opts?.silent) return;
246
+ bumpTick();
247
+ persistHook();
248
+ if (import.meta.env.DEV && scope.clipUndoFloor) {
249
+ console.debug('[editorStore] cancelScope (clipping) →', {
250
+ label: scope.label,
251
+ pastLen: past.length,
252
+ restoredPalettes: paletteSnapshot(scope.snapshot),
253
+ });
254
+ }
255
+ }
256
+
257
+ export const editorState: Readable<EditorState> = { subscribe: store.subscribe };
258
+
259
+ export function mutate(label: string, fn: (draft: EditorState) => void): void {
260
+ if (import.meta.env.DEV && !label) {
261
+ console.warn('[editorStore] mutate() called without a label');
262
+ }
263
+ if (transactionScope) {
264
+ // Inside a non-clipping scope: don't push individually; just mark the
265
+ // scope dirty and apply. The scope's commit pushes one collapsed entry.
266
+ transactionScope.changed = true;
267
+ if (clippingScope) clippingScope.changed = true;
268
+ store.update((s) => { fn(s); return s; });
269
+ return;
270
+ }
271
+ // No transaction scope: each mutate is its own history entry. Inside a
272
+ // clipping scope this still pushes per-mutate entries (so undo within the
273
+ // scope walks them back), and the scope's commit will collapse them.
274
+ if (clippingScope) clippingScope.changed = true;
275
+ const current = get(store);
276
+ pushPast(structuredClone(current));
277
+ store.update((s) => { fn(s); return s; });
278
+ bumpTick();
279
+ persistHook();
280
+ }
281
+
282
+ /**
283
+ * Slider-drag helper: opens a non-clipping scope on pointerdown and commits
284
+ * it on the next window-level pointerup / pointercancel. Groups a drag
285
+ * gesture under one history entry so undo rolls the whole drag back as a
286
+ * single step.
287
+ */
288
+ export function beginSliderGesture(label: string): void {
289
+ if (typeof window === 'undefined') return;
290
+ const scope = beginScope({ label, collapseToOne: true, clipUndoFloor: false });
291
+ const end = () => {
292
+ commitScope(scope);
293
+ window.removeEventListener('pointerup', end);
294
+ window.removeEventListener('pointercancel', end);
295
+ };
296
+ window.addEventListener('pointerup', end);
297
+ window.addEventListener('pointercancel', end);
298
+ }
299
+
300
+ /**
301
+ * Sync closure form: opens a non-clipping scope, runs `fn`, and commits.
302
+ * If `fn` throws, the scope is silently cancelled (state reverted, no
303
+ * persist) and the error is re-thrown.
304
+ */
305
+ export function transaction<T>(label: string, fn: (draft: EditorState) => T): T {
306
+ const scope = beginScope({ label, collapseToOne: true, clipUndoFloor: false });
307
+ try {
308
+ let result!: T;
309
+ mutate(label, (draft) => { result = fn(draft); });
310
+ commitScope(scope);
311
+ return result;
312
+ } catch (e) {
313
+ cancelScope(scope, { silent: true });
314
+ throw e;
315
+ }
316
+ }
317
+
318
+ // ── Undo / redo ───────────────────────────────────────────────────────────
319
+
320
+ export function undo(): boolean {
321
+ if (transactionScope) cancelScope(transactionScope, { silent: true });
322
+ const floor = clippingScope ? clippingScope.historyIdx : 0;
323
+ if (past.length <= floor) return false;
324
+ future.push(structuredClone(get(store)));
325
+ const previous = past.pop()!;
326
+ store.set(previous);
327
+ bumpTick();
328
+ persistHook();
329
+ if (import.meta.env.DEV) {
330
+ console.debug('[editorStore] undo →', {
331
+ pastLen: past.length, floor,
332
+ palettes: paletteSnapshot(previous),
333
+ inClippingScope: !!clippingScope,
334
+ });
335
+ }
336
+ return true;
337
+ }
338
+
339
+ export function redo(): boolean {
340
+ if (transactionScope) cancelScope(transactionScope, { silent: true });
341
+ if (future.length === 0) return false;
342
+ past.push(structuredClone(get(store)));
343
+ const next = future.pop()!;
344
+ store.set(next);
345
+ bumpTick();
346
+ persistHook();
347
+ return true;
348
+ }
349
+
350
+ export const canUndo: Readable<boolean> = derived(historyTick, () => past.length > 0);
351
+ export const canRedo: Readable<boolean> = derived(historyTick, () => future.length > 0);
352
+ export const dirty: Readable<boolean> = derived(historyTick, () => past.length !== savedAtIndex);
353
+
354
+ export function markSaved(): void {
355
+ savedAtIndex = past.length;
356
+ bumpTick();
357
+ }
358
+
359
+ /**
360
+ * Reset history + transient scope state without touching the store. Used by
361
+ * `loadFromFile` ("open a different document" — undo cannot cross a theme
362
+ * load). The caller is responsible for `store.set(next)` and `persistHook()`.
363
+ */
364
+ export function resetHistoryForLoad(): void {
365
+ past.length = 0;
366
+ future.length = 0;
367
+ savedAtIndex = 0;
368
+ transactionScope = null;
369
+ bumpTick();
370
+ }
371
+
372
+ /**
373
+ * Test-only: clear all core history + transient scope state and reset the
374
+ * store to `emptyStateFactory()`. The full `__resetForTests` in editorStore
375
+ * also resets renderer + per-slice baselines.
376
+ */
377
+ export function __resetCoreForTests(): void {
378
+ past.length = 0;
379
+ future.length = 0;
380
+ savedAtIndex = 0;
381
+ transactionScope = null;
382
+ clippingScope = null;
383
+ store.set(emptyStateFactory());
384
+ bumpTick();
385
+ }
386
+
387
+ /** Test-only accessors — internal history state is module-private otherwise. */
388
+ export function __getHistoryLengths(): { past: number; future: number } {
389
+ return { past: past.length, future: future.length };
390
+ }
391
+ export function __getPastAt(idx: number): EditorState | undefined {
392
+ return past[idx];
393
+ }
394
+
395
+ /** Dev-only: compact digest of each palette's baseColor + overrides count. */
396
+ function paletteSnapshot(s: EditorState): Record<string, { base: string; overrides: number }> {
397
+ const out: Record<string, { base: string; overrides: number }> = {};
398
+ for (const [k, v] of Object.entries(s.palettes ?? {})) {
399
+ out[k] = { base: v.baseColor, overrides: Object.keys(v.overrides).length };
400
+ }
401
+ return out;
402
+ }
@@ -0,0 +1,52 @@
1
+ import { undo, redo } from './editorStore';
2
+
3
+ /**
4
+ * Install Cmd/Ctrl+Z → undo and Cmd/Ctrl+Shift+Z → redo.
5
+ *
6
+ * Native browser text-undo inside inputs / textareas / contenteditable is
7
+ * left intact: those targets are passed through untouched. Editor undo fires
8
+ * only when focus is outside editable elements.
9
+ *
10
+ * Scope this installer to the pages where the editor is actually mounted
11
+ * (Editor.svelte) so Cmd+Z on the landing / component-editor pages isn't hijacked.
12
+ *
13
+ * Returns a cleanup function (use in Svelte's onMount return).
14
+ */
15
+ export function installEditorKeybindings(): () => void {
16
+ if (typeof window === 'undefined') return () => {};
17
+
18
+ window.addEventListener('keydown', handleKeydown);
19
+ return () => window.removeEventListener('keydown', handleKeydown);
20
+ }
21
+
22
+ function handleKeydown(e: KeyboardEvent): void {
23
+ const mod = e.metaKey || e.ctrlKey;
24
+ if (!mod) return;
25
+ if (e.key !== 'z' && e.key !== 'Z') return;
26
+
27
+ const target = e.target as HTMLElement | null;
28
+ if (target && isEditable(target)) return;
29
+
30
+ e.preventDefault();
31
+ if (e.shiftKey) redo();
32
+ else undo();
33
+ }
34
+
35
+ // Input types whose native Cmd+Z editing we want to preserve. Everything
36
+ // else (range, checkbox, radio, button, color, etc.) has no meaningful
37
+ // native undo — passing the event through would mean editor undo silently
38
+ // does nothing whenever a slider or checkbox has focus.
39
+ const TEXT_INPUT_TYPES = new Set([
40
+ 'text', 'search', 'email', 'url', 'tel', 'password', 'number',
41
+ 'date', 'datetime-local', 'month', 'time', 'week',
42
+ ]);
43
+
44
+ function isEditable(el: HTMLElement): boolean {
45
+ const tag = el.tagName;
46
+ if (tag === 'TEXTAREA') return true;
47
+ if (tag === 'INPUT') {
48
+ const type = ((el as HTMLInputElement).type || 'text').toLowerCase();
49
+ return TEXT_INPUT_TYPES.has(type);
50
+ }
51
+ return el.isContentEditable;
52
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Persistence layer for the editor store — debounced localStorage write +
3
+ * eager hydrate at module load.
4
+ *
5
+ * `editorStore.ts` wires the persist hook to `schedulePersist` exported here
6
+ * so editorCore's mutate/undo/redo debounce-write through the same path.
7
+ * Seed paths that bypass `mutate` call `editorCore.persist()` (which routes
8
+ * to `schedulePersist`).
9
+ *
10
+ * Hydrate runs eagerly at first import so child components reading
11
+ * `$editorState` in their onMount see persisted state, not the transient
12
+ * empty default — Svelte mounts children before parents, so waiting for
13
+ * Editor's onMount is too late.
14
+ */
15
+
16
+ import { get } from 'svelte/store';
17
+ import type { EditorState } from './editorTypes';
18
+ import { storageKey } from './editorConfig';
19
+ import { store } from './editorCore';
20
+ import { quietGet, quietSet } from './storage';
21
+ import { makeDefaultGradients } from './slices/gradients';
22
+ import { seedShadowsFromDom } from './slices/shadows';
23
+
24
+ // Resolve the persist key lazily (per-call) so library consumers that invoke
25
+ // `configureEditor({storagePrefix})` before the first store write get the
26
+ // configured prefix even if `editorPersistence` was imported at module-load
27
+ // time (M8). The function call is cheap; do not memoise — that defeats the point.
28
+ function getPersistKey(): string {
29
+ return storageKey('editor-state');
30
+ }
31
+ export const PERSIST_DEBOUNCE_MS = 300;
32
+
33
+ let emptyStateFactory: () => EditorState = () => ({} as EditorState);
34
+
35
+ /** Wired by editorStore at boot — the factory composes per-slice defaults. */
36
+ export function setEmptyStateFactory(fn: () => EditorState): void {
37
+ emptyStateFactory = fn;
38
+ }
39
+
40
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
41
+
42
+ export function schedulePersist(): void {
43
+ if (typeof localStorage === 'undefined') return;
44
+ if (persistTimer) clearTimeout(persistTimer);
45
+ persistTimer = setTimeout(persistNow, PERSIST_DEBOUNCE_MS);
46
+ }
47
+
48
+ export function persistNow(): void {
49
+ persistTimer = null;
50
+ // quota / serialization errors are not fatal; quietSet swallows them.
51
+ quietSet(getPersistKey(), JSON.stringify(get(store)));
52
+ }
53
+
54
+ function migrateGradients(state: EditorState): EditorState {
55
+ // Gradients are a fixed-slot scale (--gradient-1 … --gradient-4). If the
56
+ // persisted state predates the migration to fixed slots (e.g. it still has
57
+ // a token named --gradient-progress, or the count doesn't match), replace
58
+ // the gradients block with defaults rather than carrying stale entries.
59
+ const expected = makeDefaultGradients().map((g) => g.variable).sort();
60
+ const have = (state.gradients?.tokens ?? []).map((g) => g.variable).sort();
61
+ const matches =
62
+ have.length === expected.length &&
63
+ expected.every((v, i) => v === have[i]);
64
+ if (matches) return state;
65
+ return { ...state, gradients: { tokens: makeDefaultGradients() } };
66
+ }
67
+
68
+ export function hydrate(): void {
69
+ // Corrupt state, missing key, or unavailable storage all return null;
70
+ // the editor falls through to the empty default in that case.
71
+ const parsed = quietGet<unknown>(getPersistKey(), { parse: true });
72
+ if (parsed && typeof parsed === 'object') {
73
+ // Shallow-merge onto default shape so older persisted state missing
74
+ // newly-added domain fields still loads.
75
+ const merged = { ...emptyStateFactory(), ...(parsed as object) } as EditorState;
76
+ store.set(migrateGradients(merged));
77
+ }
78
+ // m13 fix: seed shadows from the DOM at hydrate time so the editor
79
+ // captures the tokens.css baseline regardless of whether the user opens
80
+ // the shadows tab. `seedShadowsFromDom` is a no-op when state already
81
+ // has tokens (e.g. we just loaded persisted state with shadows saved),
82
+ // so the seed only fires on a truly fresh boot. Deferred to next frame
83
+ // because tokens.css may not be applied yet at module-load time —
84
+ // `getComputedStyle` would return empty strings and `parseShadowCss`
85
+ // would reject every entry.
86
+ if (typeof requestAnimationFrame !== 'undefined') {
87
+ requestAnimationFrame(() => seedShadowsFromDom());
88
+ } else {
89
+ seedShadowsFromDom();
90
+ }
91
+ }
92
+
93
+ let hydrated = false;
94
+ export function ensureHydrated(): void {
95
+ if (hydrated) return;
96
+ hydrated = true;
97
+ hydrate();
98
+ }
99
+
100
+ /**
101
+ * Kept for API parity with callers that opt-in from onMount. A no-op after
102
+ * the eager load below, but cheap to call multiple times.
103
+ */
104
+ export async function initializeEditorStore(): Promise<void> {
105
+ ensureHydrated();
106
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * DOM renderer for the editor store.
3
+ *
4
+ * Subscribes to the store and writes derived CSS variables to :root via
5
+ * `cssVarSync` (which fans out to the parent document for the overlay
6
+ * iframe). This module is where all DOM side effects live; everything
7
+ * upstream of `deriveCssVars` is pure-data.
8
+ *
9
+ * Importing this module on the client triggers `store.subscribe(...)`. The
10
+ * editorStore barrel re-exports from here so legacy `import '.../editorStore'`
11
+ * paths still wire up the renderer at module load.
12
+ */
13
+
14
+ import type { EditorState } from './editorTypes';
15
+ import { setCssVar, removeCssVar } from './cssVarSync';
16
+ import { palettesToVars } from './paletteDerivation';
17
+ import {
18
+ editorState,
19
+ columnsToVars,
20
+ columnsEqualsDefault,
21
+ overlaysToVars,
22
+ overlaysEqualsDefault,
23
+ shadowsToVars,
24
+ gradientsToVars,
25
+ componentsToVars,
26
+ } from './editorStore';
27
+
28
+ export function deriveCssVars(state: EditorState): Record<string, string> {
29
+ const out: Record<string, string> = { ...state.cssVars };
30
+ if (!columnsEqualsDefault(state.columns)) {
31
+ Object.assign(out, columnsToVars(state.columns));
32
+ }
33
+ if (!overlaysEqualsDefault(state.overlays)) {
34
+ Object.assign(out, overlaysToVars(state.overlays));
35
+ }
36
+ if (state.shadows.tokens.length > 0) {
37
+ Object.assign(out, shadowsToVars(state.shadows));
38
+ }
39
+ if (state.gradients.tokens.length > 0) {
40
+ Object.assign(out, gradientsToVars(state.gradients));
41
+ }
42
+ Object.assign(out, palettesToVars(state.palettes));
43
+ Object.assign(out, componentsToVars(state.components));
44
+ return out;
45
+ }
46
+
47
+ let lastApplied: Record<string, string> = {};
48
+ let installed = false;
49
+
50
+ /**
51
+ * Install the DOM subscriber. Called once from `editorStore.ts` after its
52
+ * exports are initialized — calling it earlier would TDZ-trip on `editorState`
53
+ * because of the editorStore ↔ editorRenderer circular import.
54
+ */
55
+ export function installRenderer(): void {
56
+ if (installed) return;
57
+ installed = true;
58
+ if (typeof window === 'undefined') return;
59
+ editorState.subscribe((state) => {
60
+ const next = deriveCssVars(state);
61
+ for (const name in next) {
62
+ if (next[name] !== lastApplied[name]) setCssVar(name, next[name]);
63
+ }
64
+ for (const name in lastApplied) {
65
+ if (!(name in next)) removeCssVar(name);
66
+ }
67
+ lastApplied = next;
68
+ });
69
+ }
70
+
71
+ /** Test-only: clear the diff cache so independent test runs don't leak applied vars. */
72
+ export function __resetRendererCacheForTests(): void {
73
+ lastApplied = {};
74
+ }