@motion-proto/live-tokens 0.1.0 → 0.3.1

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 (225) hide show
  1. package/README.md +160 -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 +51 -23
  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 +265 -82
  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 -31
  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 +49 -0
  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 -41
  158. package/src/{showcase → ui}/TextTab.svelte +27 -29
  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/pageSource.ts +0 -6
  195. package/src/lib/tokenInit.ts +0 -29
  196. package/src/lib/tokenService.ts +0 -144
  197. package/src/lib/tokenTypes.ts +0 -45
  198. package/src/pages/Admin.svelte +0 -100
  199. package/src/pages/ShowcasePage.svelte +0 -146
  200. package/src/showcase/BackupBrowser.svelte +0 -617
  201. package/src/showcase/ComponentsTab.svelte +0 -107
  202. package/src/showcase/PaletteEditor.svelte +0 -2579
  203. package/src/showcase/PaletteSelector.svelte +0 -627
  204. package/src/showcase/TokenMap.svelte +0 -54
  205. package/src/showcase/VariablesTab.svelte +0 -2657
  206. package/src/showcase/VisualsTab.svelte +0 -233
  207. package/src/showcase/demos/BadgeDemo.svelte +0 -58
  208. package/src/showcase/demos/CardDemo.svelte +0 -52
  209. package/src/showcase/demos/ChoiceButtonsDemo.svelte +0 -194
  210. package/src/showcase/demos/CollapsibleSectionDemo.svelte +0 -56
  211. package/src/showcase/demos/DialogDemo.svelte +0 -42
  212. package/src/showcase/demos/InlineEditActionsDemo.svelte +0 -27
  213. package/src/showcase/demos/NotificationDemo.svelte +0 -149
  214. package/src/showcase/demos/ProgressBarDemo.svelte +0 -56
  215. package/src/showcase/demos/RadioButtonDemo.svelte +0 -58
  216. package/src/showcase/demos/SectionDividerDemo.svelte +0 -79
  217. package/src/showcase/demos/StandardButtonsDemo.svelte +0 -457
  218. package/src/showcase/demos/TabBarDemo.svelte +0 -60
  219. package/src/showcase/demos/TooltipDemo.svelte +0 -54
  220. package/src/showcase/editor.css +0 -93
  221. package/src/showcase/index.ts +0 -17
  222. package/src/styles/fonts/Domine/Domine-VariableFont_wght.ttf +0 -0
  223. package/src/styles/fonts/Domine/OFL.txt +0 -97
  224. package/src/styles/fonts/Domine/README.txt +0 -66
  225. /package/src/{showcase → ui}/curveEngine.ts +0 -0
@@ -0,0 +1,42 @@
1
+ import { writable, type Readable } from 'svelte/store';
2
+ import { route } from './router';
3
+
4
+ // Parent-window route, viewable from inside the editor iframe. Mirrors the
5
+ // host page's route via postMessage from LiveEditorOverlay. When not in an
6
+ // iframe, falls through to the local route store.
7
+
8
+ const MSG_TYPE = 'lt:parent-route';
9
+
10
+ function buildStore(): Readable<string> {
11
+ if (typeof window === 'undefined') return route;
12
+ if (window.parent === window) return route;
13
+
14
+ const inner = writable<string>('/');
15
+
16
+ // Best-effort initial read (same-origin in dev).
17
+ try {
18
+ inner.set(window.parent.location.pathname || '/');
19
+ } catch {
20
+ // cross-origin or sandboxed — wait for the first postMessage instead
21
+ }
22
+
23
+ window.addEventListener('message', (e: MessageEvent) => {
24
+ const data = e.data;
25
+ if (data && typeof data === 'object' && data.type === MSG_TYPE && typeof data.path === 'string') {
26
+ inner.set(data.path);
27
+ }
28
+ });
29
+
30
+ return inner;
31
+ }
32
+
33
+ export const parentRoute: Readable<string> = buildStore();
34
+
35
+ export function postParentRoute(target: Window | null | undefined, path: string): void {
36
+ if (!target) return;
37
+ try {
38
+ target.postMessage({ type: MSG_TYPE, path }, '*');
39
+ } catch {
40
+ // ignore — iframe may not be ready yet
41
+ }
42
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared parser for `:global(:root) { ... }` declaration blocks in Svelte
3
+ * component sources.
4
+ *
5
+ * Both the browser-side `tokenRegistry` (Vite `?raw` import-time scrape) and
6
+ * the Node-side `themeFileApi` plugin (filesystem read on dev/HMR) need to
7
+ * recover Layer-2 component-token declarations from `.svelte` files. They had
8
+ * been carrying separate copies of the same regex; this is the canonical
9
+ * implementation that both targets import.
10
+ *
11
+ * Assumes no nested braces inside the block — Layer-2 token blocks are flat
12
+ * declaration lists (`--name: value;`), not nested rulesets. If a future token
13
+ * block ever needs `@media`/`@supports` nesting, this regex changes once here.
14
+ *
15
+ * Pure (no DOM, no fs) so the tsup ESM+CJS build of the vite plugin can import
16
+ * it safely.
17
+ *
18
+ * Consumer-facing implication: components must keep their `:global(:root)`
19
+ * blocks as flat literal declarations — no `@each` / SCSS loops — otherwise
20
+ * this parser sees zero tokens and the editor's alias picker / file-manager
21
+ * UI is empty for that component. See `src/components/Notification.svelte:86–92`
22
+ * for the documented reason a four-variant block stays expanded.
23
+ */
24
+ export function extractGlobalRootBody(source: string): string {
25
+ const re = /:global\(:root\)\s*\{([^}]*)\}/g;
26
+ const bodies: string[] = [];
27
+ let m: RegExpExecArray | null;
28
+ while ((m = re.exec(source)) !== null) {
29
+ bodies.push(m[1]);
30
+ }
31
+ return bodies.join('\n');
32
+ }
@@ -0,0 +1,94 @@
1
+ import type { Preset, PresetMeta, Theme, ComponentConfig } from './themeTypes';
2
+ import { versionedFileResource } from './files/versionedFileResource';
3
+ import { listComponents } from './componentConfigService';
4
+ import { getActiveTheme } from './themeService';
5
+
6
+ /**
7
+ * REST client for preset (bundle) manifest files. Each preset file references
8
+ * a theme file basename + a per-component config file basename. Loading a
9
+ * preset flips the corresponding `_active.json` pointers via `applyPreset`,
10
+ * leaving the underlying theme + component-config files as the source of
11
+ * truth.
12
+ *
13
+ * Mirrors the lifecycle of `themeService.ts` and `componentConfigService.ts`
14
+ * but adds one custom route — `PUT /api/presets/:name/apply` — that the
15
+ * server uses to atomically validate every reference and flip every pointer
16
+ * in one shot.
17
+ */
18
+
19
+ const presetsResource = versionedFileResource<Preset, PresetMeta, never>({
20
+ baseUrl: '/api/presets',
21
+ });
22
+
23
+ export const listPresets = async (): Promise<PresetMeta[]> => {
24
+ const data = await presetsResource.list();
25
+ return data.files;
26
+ };
27
+
28
+ export const loadPreset = (fileName: string): Promise<Preset> =>
29
+ presetsResource.load(fileName);
30
+ export const savePreset = (fileName: string, data: Preset): Promise<void> =>
31
+ presetsResource.save(fileName, data);
32
+ export const deletePreset = (fileName: string): Promise<void> =>
33
+ presetsResource.remove(fileName);
34
+ export const getActivePreset = (): Promise<Preset | null> => presetsResource.getActive();
35
+ export const setActivePreset = (fileName: string): Promise<void> =>
36
+ presetsResource.setActive(fileName);
37
+
38
+ export interface ApplyPresetResult {
39
+ ok: boolean;
40
+ preset: Preset;
41
+ theme: Theme;
42
+ componentConfigs: Record<string, ComponentConfig>;
43
+ }
44
+
45
+ /**
46
+ * Server-side atomic apply: validate every referenced file exists, flip the
47
+ * theme + each component's `_active.json` pointer, and return the resolved
48
+ * theme + component configs in one payload.
49
+ *
50
+ * The client typically follows this with a full page reload — loading a
51
+ * preset is a "blow up the world" action and preserving editor session
52
+ * state across that boundary is low value.
53
+ */
54
+ export async function applyPreset(fileName: string): Promise<ApplyPresetResult> {
55
+ const res = await fetch(`/api/presets/${encodeURIComponent(fileName)}/apply`, {
56
+ method: 'PUT',
57
+ });
58
+ if (!res.ok) {
59
+ const err = await res.json().catch(() => ({ error: 'Apply failed' }));
60
+ throw new Error(err.error || 'Apply failed');
61
+ }
62
+ return res.json();
63
+ }
64
+
65
+ /**
66
+ * Build a manifest from the *currently active files on disk* (not in-memory
67
+ * editor state) and persist it. Dirty editor state isn't a file yet, so it's
68
+ * not part of any preset until the user saves it. Callers should warn the
69
+ * user via the UI if `$dirty` is true before invoking this.
70
+ */
71
+ export async function captureCurrentAsPreset(
72
+ fileName: string,
73
+ displayName: string,
74
+ ): Promise<void> {
75
+ const activeTheme = await getActiveTheme();
76
+ if (!activeTheme || !activeTheme._fileName) {
77
+ throw new Error('No active theme on disk to capture');
78
+ }
79
+ const components = await listComponents();
80
+ const componentConfigs: Record<string, string> = {};
81
+ for (const c of components) {
82
+ componentConfigs[c.name] = c.activeFile || 'default';
83
+ }
84
+ const now = new Date().toISOString();
85
+ const manifest: Preset = {
86
+ name: displayName,
87
+ createdAt: now,
88
+ updatedAt: now,
89
+ theme: activeTheme._fileName,
90
+ componentConfigs,
91
+ };
92
+ await savePreset(fileName, manifest);
93
+ await setActivePreset(fileName);
94
+ }
@@ -0,0 +1,49 @@
1
+ import { writable } from 'svelte/store';
2
+ import { storageKey } from './editorConfig';
3
+
4
+ function prevKey(): string {
5
+ return storageKey('prev-route');
6
+ }
7
+
8
+ function rememberPrev(current: string) {
9
+ if (current === '/editor') return;
10
+ try { sessionStorage.setItem(prevKey(), current); } catch { /* ignore */ }
11
+ }
12
+
13
+ export const route = writable<string>('/');
14
+
15
+ let initialised = false;
16
+
17
+ /**
18
+ * Idempotent host hook — call once during boot to seed the route store from
19
+ * the current location and wire popstate handling. Module import no longer
20
+ * touches `window`, so SSR / test harnesses can import without crashing.
21
+ */
22
+ export function init(): void {
23
+ if (initialised) return;
24
+ initialised = true;
25
+ if (typeof window === 'undefined') return;
26
+ const initial = window.location.pathname || '/';
27
+ rememberPrev(initial);
28
+ route.set(initial);
29
+ window.addEventListener('popstate', () => {
30
+ route.set(window.location.pathname || '/');
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Push a new history entry and update the route store. Produces exactly one
36
+ * `route` store update per call — the popstate listener installed in `init()`
37
+ * fires only on browser back/forward, not on synthetic dispatch.
38
+ */
39
+ export function navigate(path: string) {
40
+ const [pathname] = path.split('#');
41
+ if (typeof window !== 'undefined') {
42
+ rememberPrev(window.location.pathname || '/');
43
+ history.pushState(null, '', path);
44
+ if (!path.includes('#')) {
45
+ window.scrollTo(0, 0);
46
+ }
47
+ }
48
+ route.set(pathname);
49
+ }
@@ -0,0 +1,45 @@
1
+ const PRELUDE_PX = 128;
2
+ const DURATION_MS = 400;
3
+ const SPEED_PX_PER_MS = PRELUDE_PX / DURATION_MS;
4
+
5
+ function easeOutCubic(t: number): number {
6
+ return 1 - Math.pow(1 - t, 3);
7
+ }
8
+
9
+ function findScrollParent(el: HTMLElement): HTMLElement {
10
+ let node: HTMLElement | null = el.parentElement;
11
+ while (node) {
12
+ const style = getComputedStyle(node);
13
+ if (/(auto|scroll|overlay)/.test(style.overflowY) && node.scrollHeight > node.clientHeight) {
14
+ return node;
15
+ }
16
+ node = node.parentElement;
17
+ }
18
+ return document.scrollingElement as HTMLElement ?? document.documentElement;
19
+ }
20
+
21
+ export function scrollSectionIntoView(target: HTMLElement, scroller?: HTMLElement) {
22
+ const scrollEl = scroller ?? findScrollParent(target);
23
+ const max = scrollEl.scrollHeight - scrollEl.clientHeight;
24
+ const targetTop = Math.max(0, Math.min(max, target.offsetTop));
25
+ const start = scrollEl.scrollTop;
26
+ const delta = targetTop - start;
27
+ if (delta === 0) return;
28
+
29
+ const direction = delta > 0 ? 1 : -1;
30
+ const distance = Math.abs(delta);
31
+ const animatedDistance = Math.min(distance, PRELUDE_PX);
32
+ const duration = animatedDistance / SPEED_PX_PER_MS;
33
+
34
+ const animFrom = targetTop - animatedDistance * direction;
35
+ scrollEl.scrollTop = animFrom;
36
+
37
+ const t0 = performance.now();
38
+ function step(now: number) {
39
+ const t = Math.min(1, (now - t0) / duration);
40
+ scrollEl.scrollTop = animFrom + animatedDistance * direction * easeOutCubic(t);
41
+ if (t < 1) requestAnimationFrame(step);
42
+ else scrollEl.scrollTop = targetTop;
43
+ }
44
+ requestAnimationFrame(step);
45
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Columns slice — fixed-shape (count/maxWidth/gutter/margin) state, derived to
3
+ * four CSS vars. Values default to a 12-column 1440px grid; while state matches
4
+ * the default we leave tokens.css in charge so the `clamp()` in
5
+ * `--columns-gutter` survives until the editor overrides it.
6
+ */
7
+ import type { ColumnsState } from '../editorTypes';
8
+
9
+ export const DEFAULT_COLUMNS: ColumnsState = { count: 12, maxWidth: 1440, gutter: 16, margin: 0 };
10
+
11
+ export const COLUMN_VAR_NAMES = ['--columns-count', '--columns-max-width', '--columns-gutter', '--columns-margin'] as const;
12
+
13
+ export function columnsToVars(c: ColumnsState): Record<string, string> {
14
+ return {
15
+ '--columns-count': String(c.count),
16
+ '--columns-max-width': `${c.maxWidth}px`,
17
+ '--columns-gutter': `${c.gutter}px`,
18
+ '--columns-margin': `${c.margin}px`,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Only emit column CSS vars once the user has actually modified columns.
24
+ * While columns match the default, we leave tokens.css in charge — which
25
+ * preserves the `clamp()` in `--columns-gutter` until the editor overrides it.
26
+ */
27
+ export function columnsEqualsDefault(c: ColumnsState): boolean {
28
+ return c.count === DEFAULT_COLUMNS.count
29
+ && c.maxWidth === DEFAULT_COLUMNS.maxWidth
30
+ && c.gutter === DEFAULT_COLUMNS.gutter
31
+ && c.margin === DEFAULT_COLUMNS.margin;
32
+ }
33
+
34
+ export function parseColumnVars(vars: Record<string, string>): Partial<ColumnsState> {
35
+ const out: Partial<ColumnsState> = {};
36
+ const count = parseInt(vars['--columns-count'] ?? '', 10);
37
+ if (Number.isFinite(count) && count > 0) out.count = count;
38
+ const maxWidth = parseFloat(vars['--columns-max-width'] ?? '');
39
+ if (Number.isFinite(maxWidth)) out.maxWidth = Math.round(maxWidth);
40
+ const gutter = parseFloat(vars['--columns-gutter'] ?? '');
41
+ if (Number.isFinite(gutter)) out.gutter = Math.round(gutter);
42
+ const margin = parseFloat(vars['--columns-margin'] ?? '');
43
+ if (Number.isFinite(margin)) out.margin = Math.round(margin);
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Loader: route the relevant entries from a freshly-loaded theme's vars bag
49
+ * into `next.columns` and remove them from the bag so derivation stays
50
+ * single-source. Mutates `next` and `rawVars` in place.
51
+ */
52
+ export function loadColumnsFromVars(
53
+ next: import('../editorTypes').EditorState,
54
+ rawVars: Record<string, string>,
55
+ ): void {
56
+ const overrides = parseColumnVars(rawVars);
57
+ next.columns = { ...DEFAULT_COLUMNS, ...overrides };
58
+ for (const name of COLUMN_VAR_NAMES) delete rawVars[name];
59
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Components slice — per-component `{ activeFile, aliases, config, unlinked? }`.
3
+ *
4
+ * `aliases` are the typed component-token → semantic-token map (each entry is
5
+ * a `CssVarRef` discriminated union: `{ kind: 'token', name }` or
6
+ * `{ kind: 'literal', value }`). The renderer emits each alias entry as
7
+ * `var(<name>)` for tokens or as the raw literal for literals.
8
+ *
9
+ * `config` carries literal-valued knobs that don't follow the alias →
10
+ * `var(...)` shape (e.g. `--dialog-confirm-variant: 'primary'`). The set of
11
+ * keys routed to config lives in `componentConfigKeys`.
12
+ *
13
+ * Themes and components are orthogonal: `loadFromFile` preserves
14
+ * `state.components`.
15
+ *
16
+ * Sharing semantics: tokens with the same groupKey (registered explicitly
17
+ * via `registerComponentSchema`) are siblings. Each individual sibling can opt out of the group: `unlinked`
18
+ * lists the specific variable names that have detached, leaving the rest
19
+ * of the group intact. `setComponentAliasLinked` writes the alias to every
20
+ * sibling that is *currently linked* (i.e. not in `unlinked`) plus the
21
+ * target itself, re-joining it to the group; `unlinkComponentProperty`
22
+ * detaches just that one variable. The `unlinked` list persists across
23
+ * theme loads.
24
+ *
25
+ * Dirty tracking: `savedComponents` is the on-disk snapshot baseline (one
26
+ * stringified `{aliases, config}` bag per component). `componentDirty`
27
+ * re-evaluates on any state change or baseline bump. The baseline is set
28
+ * explicitly by the load path (`setSavedComponentBaseline`) — `loadComponentActive`
29
+ * and `seedComponentsFromApi` in editorStore call into this from their migration
30
+ * pipeline.
31
+ */
32
+ import { writable, derived, get, type Readable } from 'svelte/store';
33
+ import type { CssVarRef, EditorState } from '../editorTypes';
34
+ import { store, mutate } from '../editorCore';
35
+
36
+ const EMPTY_COMPONENT_BASELINE = JSON.stringify({ aliases: {}, config: {} });
37
+
38
+ export function componentBaseline(slice: { aliases: Record<string, CssVarRef>; config: Record<string, unknown> }): string {
39
+ return JSON.stringify({ aliases: slice.aliases, config: slice.config });
40
+ }
41
+
42
+ export function componentsToVars(components: EditorState['components']): Record<string, string> {
43
+ const out: Record<string, string> = {};
44
+ for (const slice of Object.values(components)) {
45
+ for (const [varName, ref] of Object.entries(slice.aliases)) {
46
+ out[varName] = ref.kind === 'token' ? `var(${ref.name})` : ref.value;
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ export function getComponentOwnedVarNames(state: EditorState): string[] {
53
+ const names: string[] = [];
54
+ for (const slice of Object.values(state.components)) {
55
+ for (const name of Object.keys(slice.aliases)) names.push(name);
56
+ }
57
+ return names;
58
+ }
59
+
60
+ /**
61
+ * Loader: themes and components are orthogonal — component aliases live
62
+ * in their own files, not the theme JSON. Preserve the current slice
63
+ * across theme loads and strip any component-owned vars that may have
64
+ * leaked into the theme's cssVariables bag. Mutates `next` and `rawVars`
65
+ * in place.
66
+ */
67
+ export function loadComponentsFromVars(
68
+ next: EditorState,
69
+ rawVars: Record<string, string>,
70
+ ): void {
71
+ next.components = structuredClone(get(store).components);
72
+ for (const name of getComponentOwnedVarNames(next)) delete rawVars[name];
73
+ }
74
+
75
+ // Module-private baseline for per-component dirty detection. Parallels
76
+ // `savedAtIndex` + `historyTick` for the global flag; `componentSavedTick`
77
+ // drives re-derivation when the baseline changes.
78
+ const savedComponents: Record<string, string> = {};
79
+ const componentSavedTick = writable(0);
80
+ function bumpComponentSavedTick(): void { componentSavedTick.update((n) => n + 1); }
81
+
82
+ export const componentDirty: Readable<Record<string, boolean>> = derived(
83
+ [store, componentSavedTick],
84
+ ([$state]) => {
85
+ const out: Record<string, boolean> = {};
86
+ for (const [comp, slice] of Object.entries($state.components)) {
87
+ out[comp] = componentBaseline(slice) !== (savedComponents[comp] ?? EMPTY_COMPONENT_BASELINE);
88
+ }
89
+ return out;
90
+ },
91
+ );
92
+
93
+ export function setComponentAlias(component: string, varName: string, ref: CssVarRef): void {
94
+ mutate(`set alias ${component}/${varName}`, (s) => {
95
+ const existing = s.components[component];
96
+ if (existing) {
97
+ existing.aliases[varName] = ref;
98
+ } else {
99
+ s.components[component] = { activeFile: 'default', aliases: { [varName]: ref }, config: {} };
100
+ }
101
+ });
102
+ }
103
+
104
+ export function clearComponentAlias(component: string, varName: string): void {
105
+ mutate(`clear alias ${component}/${varName}`, (s) => {
106
+ const slice = s.components[component];
107
+ if (!slice) return;
108
+ delete slice.aliases[varName];
109
+ });
110
+ }
111
+
112
+ export function setComponentConfig(component: string, key: string, value: unknown): void {
113
+ mutate(`set config ${component}/${key}`, (s) => {
114
+ const existing = s.components[component];
115
+ if (existing) {
116
+ existing.config[key] = value;
117
+ } else {
118
+ s.components[component] = { activeFile: 'default', aliases: {}, config: { [key]: value } };
119
+ }
120
+ });
121
+ }
122
+
123
+ export function clearComponentConfig(component: string, key: string): void {
124
+ mutate(`clear config ${component}/${key}`, (s) => {
125
+ const slice = s.components[component];
126
+ if (!slice) return;
127
+ delete slice.config[key];
128
+ });
129
+ }
130
+
131
+ function componentVarPrefix(component: string): string {
132
+ return `--${component}-`;
133
+ }
134
+
135
+ /**
136
+ * Per-component groupKey schema registered by editor modules. Maps each
137
+ * declared variable to its groupKey (the explicit sibling-set identifier).
138
+ * Tokens with the same groupKey are siblings; tokens not in the schema fall
139
+ * back to last-dash property inference so unmigrated editors keep working.
140
+ */
141
+ const componentSchemas: Record<string, Map<string, string>> = {};
142
+ /** Inverse of `componentSchemas`: groupKey → declared variables sharing it.
143
+ * This is the linkage topology declared by the editor, independent of which
144
+ * aliases the user happens to have saved. */
145
+ const componentSchemaSiblings: Record<string, Map<string, string[]>> = {};
146
+
147
+ const TYPOGRAPHY_PROP_SUFFIXES = ['font-family', 'font-size', 'font-weight', 'line-height'] as const;
148
+
149
+ /** Pull the slot identifier off a typography variable name, e.g.
150
+ * `--card-primary-title-font-family` → `'title'`. Returns null for
151
+ * non-typography vars, where the slot concept doesn't apply. */
152
+ function typographySlotOf(varName: string): string | null {
153
+ for (const suffix of TYPOGRAPHY_PROP_SUFFIXES) {
154
+ if (!varName.endsWith('-' + suffix)) continue;
155
+ const head = varName.slice(0, -(suffix.length + 1));
156
+ const lastDash = head.lastIndexOf('-');
157
+ if (lastDash < 0) return null;
158
+ return head.slice(lastDash + 1);
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Register a component's token → groupKey mapping. Editors call this at
165
+ * module load (top of `<script>`) so sibling lookups can prefer explicit
166
+ * groupKeys over name-derived inference. Re-registration overwrites prior
167
+ * entries for the same component.
168
+ *
169
+ * Warns when a single groupKey covers typography variables whose name-derived
170
+ * slots differ — e.g. `groupKey: 'font-family'` covering both
171
+ * `--card-primary-title-font-family` and `--card-primary-body-font-family`.
172
+ * Slot prefixes (`title-font-family` vs `body-font-family`) are required so
173
+ * each slot is independently linkable. See `src/styles/CONVENTIONS.md`.
174
+ */
175
+ export function registerComponentSchema(
176
+ component: string,
177
+ tokens: ReadonlyArray<{ variable: string; groupKey?: string }>,
178
+ ): void {
179
+ const map = new Map<string, string>();
180
+ const siblings = new Map<string, string[]>();
181
+ for (const t of tokens) {
182
+ if (!t.groupKey) continue;
183
+ map.set(t.variable, t.groupKey);
184
+ const list = siblings.get(t.groupKey) ?? [];
185
+ list.push(t.variable);
186
+ siblings.set(t.groupKey, list);
187
+ }
188
+ componentSchemas[component] = map;
189
+ componentSchemaSiblings[component] = siblings;
190
+
191
+ for (const [groupKey, vars] of siblings) {
192
+ const slots = new Set<string>();
193
+ for (const v of vars) {
194
+ const slot = typographySlotOf(v);
195
+ if (slot) slots.add(slot);
196
+ }
197
+ if (slots.size > 1) {
198
+ const slotList = [...slots];
199
+ const examples = slotList.map((s) => `"${s}-${groupKey}"`).join(', ');
200
+ console.warn(
201
+ `[registerComponentSchema] component "${component}" groupKey "${groupKey}" links typography variables with distinct slots: ${slotList.join(', ')}. ` +
202
+ `Use slot-prefixed groupKeys (e.g. ${examples}) so each slot is independently linkable.`,
203
+ );
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Resolve a variable's groupKey from the component's registered schema.
210
+ * Returns null when the variable has no declared groupKey (i.e. it isn't a
211
+ * member of any sibling group).
212
+ */
213
+ function getGroupKey(component: string, varName: string): string | null {
214
+ return componentSchemas[component]?.get(varName) ?? null;
215
+ }
216
+
217
+ /**
218
+ * Variables that share `varName`'s groupKey, i.e. are declared as one
219
+ * linkage group. The editor's registered schema is the source of truth — the
220
+ * declared topology is reported regardless of which aliases happen to be
221
+ * persisted in the slice, so link UI reflects the editor's intent rather than
222
+ * leaking through the on-disk state. Falls back to a slice scan only when the
223
+ * variable isn't declared (legacy/inferred groupKey).
224
+ */
225
+ export function getComponentPropertySiblings(component: string, varName: string): string[] {
226
+ const groupKey = getGroupKey(component, varName);
227
+ if (!groupKey) return [];
228
+ const declared = componentSchemaSiblings[component]?.get(groupKey);
229
+ if (declared && declared.length > 0) return declared.slice();
230
+ const slice = get(store).components[component];
231
+ if (!slice) return [];
232
+ const siblings: string[] = [];
233
+ for (const v of Object.keys(slice.aliases)) {
234
+ if (getGroupKey(component, v) === groupKey) siblings.push(v);
235
+ }
236
+ return siblings;
237
+ }
238
+
239
+ function cssVarRefEqual(a: CssVarRef | undefined, b: CssVarRef | undefined): boolean {
240
+ if (!a || !b) return a === b;
241
+ if (a.kind !== b.kind) return false;
242
+ return a.kind === 'token'
243
+ ? a.name === (b as { kind: 'token'; name: string }).name
244
+ : a.value === (b as { kind: 'literal'; value: string }).value;
245
+ }
246
+
247
+ /** True iff `varName` is not individually opted out, has ≥2 declared siblings,
248
+ * and the linked siblings agree — either all sharing the same explicit alias,
249
+ * or all having no override (linked at the upstream default). */
250
+ export function isComponentPropertyLinked(component: string, varName: string): boolean {
251
+ const slice = get(store).components[component];
252
+ if (slice?.unlinked?.includes(varName)) return false;
253
+ const siblings = getComponentPropertySiblings(component, varName);
254
+ if (siblings.length < 2) return false;
255
+ const unlinkedList = slice?.unlinked ?? [];
256
+ const linkedSiblings = siblings.filter((v) => !unlinkedList.includes(v));
257
+ if (linkedSiblings.length < 2) return false;
258
+ const aliases = slice?.aliases ?? {};
259
+ const first = aliases[linkedSiblings[0]];
260
+ return linkedSiblings.every((v) => cssVarRefEqual(aliases[v], first));
261
+ }
262
+
263
+ /** Write `ref` to `varName` and every sibling currently linked (not in `unlinked`),
264
+ * and remove `varName` from the unlinked list so it rejoins the group. */
265
+ export function setComponentAliasLinked(component: string, varName: string, ref: CssVarRef): void {
266
+ const groupKey = getGroupKey(component, varName);
267
+ const siblings = getComponentPropertySiblings(component, varName);
268
+ if (!groupKey || siblings.length === 0) {
269
+ setComponentAlias(component, varName, ref);
270
+ return;
271
+ }
272
+ mutate(`link ${component}/${groupKey}`, (s) => {
273
+ const slice = s.components[component] ?? (s.components[component] = { activeFile: 'default', aliases: {}, config: {} });
274
+ const unlinked = (slice.unlinked ?? []).filter((p) => p !== varName);
275
+ slice.aliases[varName] = ref;
276
+ for (const v of siblings) {
277
+ if (v === varName) continue;
278
+ if (!unlinked.includes(v)) slice.aliases[v] = ref;
279
+ }
280
+ if (unlinked.length === 0) delete slice.unlinked;
281
+ else slice.unlinked = unlinked;
282
+ });
283
+ }
284
+
285
+ /** Clear `varName` and every sibling currently linked (not in `unlinked`). */
286
+ export function clearComponentAliasLinked(component: string, varName: string): void {
287
+ const groupKey = getGroupKey(component, varName);
288
+ const siblings = getComponentPropertySiblings(component, varName);
289
+ if (!groupKey || siblings.length === 0) {
290
+ clearComponentAlias(component, varName);
291
+ return;
292
+ }
293
+ mutate(`clear link ${component}/${groupKey}`, (s) => {
294
+ const slice = s.components[component];
295
+ if (!slice) return;
296
+ const unlinked = slice.unlinked ?? [];
297
+ for (const v of siblings) {
298
+ if (v === varName || !unlinked.includes(v)) delete slice.aliases[v];
299
+ }
300
+ });
301
+ }
302
+
303
+ /** Detach a single property from its sibling group. Other siblings stay linked
304
+ * to each other; only `varName` becomes independently editable. */
305
+ export function unlinkComponentProperty(component: string, varName: string): void {
306
+ const groupKey = getGroupKey(component, varName);
307
+ if (!groupKey) return;
308
+ const siblings = getComponentPropertySiblings(component, varName);
309
+ if (siblings.length < 2) return;
310
+ mutate(`unlink ${component}/${varName}`, (s) => {
311
+ const slice = s.components[component];
312
+ if (!slice) return;
313
+ const unlinked = slice.unlinked ?? [];
314
+ if (!unlinked.includes(varName)) {
315
+ slice.unlinked = [...unlinked, varName];
316
+ }
317
+ });
318
+ }
319
+
320
+ /** Rejoin `varName` to its sibling group as pure metadata: drop it from the
321
+ * `unlinked` list without writing any alias. Use when the group has no
322
+ * overrides yet (linked at the upstream default) and the user just wants to
323
+ * re-engage membership — `setComponentAliasLinked` requires a ref to write,
324
+ * which there is none of in that state. */
325
+ export function relinkComponentProperty(component: string, varName: string): void {
326
+ const slice = get(store).components[component];
327
+ if (!slice?.unlinked?.includes(varName)) return;
328
+ mutate(`relink ${component}/${varName}`, (s) => {
329
+ const next = s.components[component];
330
+ if (!next?.unlinked) return;
331
+ const remaining = next.unlinked.filter((v) => v !== varName);
332
+ if (remaining.length === 0) delete next.unlinked;
333
+ else next.unlinked = remaining;
334
+ });
335
+ }
336
+
337
+ export function markComponentSaved(component: string): void {
338
+ const slice = get(store).components[component];
339
+ if (!slice) return;
340
+ savedComponents[component] = componentBaseline(slice);
341
+ bumpComponentSavedTick();
342
+ }
343
+
344
+ /**
345
+ * Set the on-disk baseline for a component without touching the store.
346
+ * Called by `loadComponentActive` / `seedComponentsFromApi` after their
347
+ * migration step so the post-load state reads clean.
348
+ */
349
+ export function setSavedComponentBaseline(component: string, baseline: string): void {
350
+ savedComponents[component] = baseline;
351
+ }
352
+
353
+ /** Notify subscribers that the dirty baseline changed. */
354
+ export function notifyComponentSavedChanged(): void {
355
+ bumpComponentSavedTick();
356
+ }
357
+
358
+ /** Test-only: clear the baseline and the dirty signal. */
359
+ export function __resetComponentsForTests(): void {
360
+ for (const k of Object.keys(savedComponents)) delete savedComponents[k];
361
+ bumpComponentSavedTick();
362
+ }