@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,341 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CURRENT_THEME_SCHEMA_VERSION,
4
+ CURRENT_COMPONENT_SCHEMA_VERSION,
5
+ runMigrations,
6
+ } from './index';
7
+
8
+ describe('migration runner — schemaVersion gating', () => {
9
+ it('CURRENT_*_SCHEMA_VERSION are positive (at least one migration registered each)', () => {
10
+ expect(CURRENT_THEME_SCHEMA_VERSION).toBeGreaterThan(0);
11
+ expect(CURRENT_COMPONENT_SCHEMA_VERSION).toBeGreaterThan(0);
12
+ });
13
+
14
+ it('legacy theme (schemaVersion: 0) → bg/canvas + legacy-key renames applied; resaved file matches modern shape', () => {
15
+ const legacy = {
16
+ // bg → canvas pattern (exact match + boundary suffix)
17
+ '--surface-bg': '#fff',
18
+ '--text-bg': '#000',
19
+ '--border-bg-strong': '#888',
20
+ // explicit legacy renames
21
+ '--empty': '#111',
22
+ '--empty-attachment': 'fixed',
23
+ // orphan token (silent drop)
24
+ '--border-neutral': '#ccc',
25
+ // unrelated key passes through
26
+ '--text-primary': '#333',
27
+ };
28
+ const migrated = runMigrations('theme', 0, legacy);
29
+ expect(migrated['--surface-canvas']).toBe('#fff');
30
+ expect(migrated['--text-canvas']).toBe('#000');
31
+ expect(migrated['--border-canvas-strong']).toBe('#888');
32
+ expect(migrated['--page-bg']).toBe('#111');
33
+ expect(migrated['--page-bg-attachment']).toBe('fixed');
34
+ expect(migrated['--border-neutral']).toBeUndefined();
35
+ expect(migrated['--text-primary']).toBe('#333');
36
+ // The "drop" old keys are gone
37
+ expect(migrated['--surface-bg']).toBeUndefined();
38
+ expect(migrated['--empty']).toBeUndefined();
39
+ });
40
+
41
+ it('file already at current theme version → no migrations run (passthrough)', () => {
42
+ const modern = { '--surface-canvas': '#fff', '--text-primary': '#333' };
43
+ const out = runMigrations('theme', CURRENT_THEME_SCHEMA_VERSION, modern);
44
+ expect(out).toEqual(modern);
45
+ // identity-preserved values
46
+ expect(out['--surface-canvas']).toBe('#fff');
47
+ });
48
+
49
+ it('legacy component-config (schemaVersion: 0) → prefix + suffix renames; segmentedcontrol option-disabled flatten', () => {
50
+ const legacy = {
51
+ // abbreviated prefix
52
+ '--segment-option-bg': '#eee',
53
+ // segmentedcontrol option-disabled → disabled flatten
54
+ '--segmentedcontrol-option-disabled-text': '#999',
55
+ // selected-disabled is dropped (impossible state)
56
+ '--segmentedcontrol-selected-disabled-bg': 'red',
57
+ // selected-hover is dropped
58
+ '--segmentedcontrol-selected-hover-bg': 'blue',
59
+ // unrelated key
60
+ '--segment-track-radius': '4px',
61
+ };
62
+ const migrated = runMigrations('component-config', 0, legacy, {
63
+ component: 'segmentedcontrol',
64
+ });
65
+ // Prefix renamed, then option-disabled flattened... actually option-disabled
66
+ // was already on the long-form name, so it goes through the flatten step.
67
+ expect(migrated['--segmentedcontrol-option-bg']).toBe('#eee');
68
+ expect(migrated['--segmentedcontrol-disabled-text']).toBe('#999');
69
+ expect(migrated['--segmentedcontrol-selected-disabled-bg']).toBeUndefined();
70
+ expect(migrated['--segmentedcontrol-selected-hover-bg']).toBeUndefined();
71
+ expect(migrated['--segmentedcontrol-track-radius']).toBe('4px');
72
+ });
73
+
74
+ it('component-config file at version 1 → only the >=1 migrations run (segmentedcontrol flatten only)', () => {
75
+ const v1 = {
76
+ // Already on long-form prefix; v1 → v2 step still applies for sc
77
+ '--segmentedcontrol-option-disabled-text': '#999',
78
+ // suffix-rename rules from v0→v1 should NOT re-apply; abbreviated keys
79
+ // present at v1 are user-authored and untouched
80
+ '--segment-something': 'should-pass-through',
81
+ };
82
+ const migrated = runMigrations('component-config', 1, v1, {
83
+ component: 'segmentedcontrol',
84
+ });
85
+ expect(migrated['--segmentedcontrol-disabled-text']).toBe('#999');
86
+ // v0→v1 prefix rename did NOT re-run
87
+ expect(migrated['--segment-something']).toBe('should-pass-through');
88
+ expect(migrated['--segmentedcontrol-something']).toBeUndefined();
89
+ });
90
+
91
+ it('component-config at version 2 → collapsiblesection state tokens namespace into container; v3→v4 cleanup applies on top', () => {
92
+ const v2 = {
93
+ '--collapsiblesection-default-surface': '--surface-canvas-high',
94
+ '--collapsiblesection-hover-icon': '--text-primary',
95
+ '--collapsiblesection-active-border': '--color-primary-400',
96
+ '--collapsiblesection-expanded-padding': '--space-4',
97
+ '--collapsiblesection-default-label-font-family': '--font-sans',
98
+ };
99
+ const migrated = runMigrations('component-config', 2, v2, {
100
+ component: 'collapsiblesection',
101
+ });
102
+ // Old, unscoped keys are gone after v2→v3
103
+ expect(migrated['--collapsiblesection-default-surface']).toBeUndefined();
104
+ expect(migrated['--collapsiblesection-hover-icon']).toBeUndefined();
105
+ // Surviving header tokens land in the container namespace
106
+ expect(migrated['--collapsiblesection-container-default-surface']).toBe('--surface-canvas-high');
107
+ expect(migrated['--collapsiblesection-container-hover-icon']).toBe('--text-primary');
108
+ expect(migrated['--collapsiblesection-container-default-label-font-family']).toBe('--font-sans');
109
+ expect(migrated['--collapsiblesection-container-expanded-padding']).toBe('--space-4');
110
+ // v3→v4 drops container active-border (frame owns chrome now); the
111
+ // pre-existing default-surface is also seeded into the new frame namespace.
112
+ expect(migrated['--collapsiblesection-container-active-border']).toBeUndefined();
113
+ expect(migrated['--collapsiblesection-container-frame-surface']).toBe('--surface-canvas-high');
114
+ });
115
+
116
+ it('component-config v2 namespace migration only fires for collapsiblesection', () => {
117
+ const v2 = { '--button-primary-surface': '--surface-success' };
118
+ const out = runMigrations('component-config', 2, v2, { component: 'button' });
119
+ expect(out).toEqual(v2);
120
+ });
121
+
122
+ it('component-config at version 3 → collapsiblesection container chrome moves into frame, dead per-state tokens drop', () => {
123
+ const v3 = {
124
+ // Container default-state chrome → frame (values preserved).
125
+ '--collapsiblesection-container-default-surface': '--surface-canvas-high',
126
+ '--collapsiblesection-container-default-border': '--color-primary-400',
127
+ '--collapsiblesection-container-default-border-width': '--border-width-3',
128
+ '--collapsiblesection-container-default-radius': '--radius-md',
129
+ '--collapsiblesection-container-default-padding': '--space-4',
130
+ // Container hover/active border tokens drop (frame owns chrome).
131
+ '--collapsiblesection-container-hover-border': '--color-primary-500',
132
+ '--collapsiblesection-container-hover-border-width': '--border-width-3',
133
+ '--collapsiblesection-container-active-radius': '--radius-md',
134
+ '--collapsiblesection-container-active-surface': '--surface-canvas-low',
135
+ // Chromeless per-state border / radius drop.
136
+ '--collapsiblesection-chromeless-default-border': '--color-primary-400',
137
+ '--collapsiblesection-chromeless-hover-border-width': '--border-width-1',
138
+ '--collapsiblesection-chromeless-active-radius': '--radius-none',
139
+ '--collapsiblesection-chromeless-default-padding': '--space-4',
140
+ // Divider radius drops; divider border-* survives (paints bottom rule).
141
+ '--collapsiblesection-divider-default-border': '--border-neutral-faint',
142
+ '--collapsiblesection-divider-default-border-width': '--border-width-1',
143
+ '--collapsiblesection-divider-default-radius': '--radius-none',
144
+ // Expanded panel: only padding for chromeless/divider; surface + padding for container.
145
+ '--collapsiblesection-chromeless-expanded-border': '--color-primary-400',
146
+ '--collapsiblesection-chromeless-expanded-surface': '--surface-canvas',
147
+ '--collapsiblesection-chromeless-expanded-padding': '--space-4',
148
+ '--collapsiblesection-container-expanded-radius': '--radius-md',
149
+ '--collapsiblesection-container-expanded-surface': '--surface-canvas-low',
150
+ '--collapsiblesection-container-expanded-padding': '--space-4',
151
+ };
152
+ const migrated = runMigrations('component-config', 3, v3, {
153
+ component: 'collapsiblesection',
154
+ });
155
+ // Container frame-* seeded from old default-state tokens. The
156
+ // v5→v6 primary→brand migration rewrites `--color-primary-*` values
157
+ // to `--color-brand-*` at the tail of the chain.
158
+ expect(migrated['--collapsiblesection-container-frame-surface']).toBe('--surface-canvas-high');
159
+ expect(migrated['--collapsiblesection-container-frame-border']).toBe('--color-brand-400');
160
+ expect(migrated['--collapsiblesection-container-frame-border-width']).toBe('--border-width-3');
161
+ expect(migrated['--collapsiblesection-container-frame-radius']).toBe('--radius-md');
162
+ // Container default-state surface + padding survive (still drive header strip)
163
+ expect(migrated['--collapsiblesection-container-default-surface']).toBe('--surface-canvas-high');
164
+ expect(migrated['--collapsiblesection-container-default-padding']).toBe('--space-4');
165
+ // Container default-state border / radius dropped (frame owns them now)
166
+ expect(migrated['--collapsiblesection-container-default-border']).toBeUndefined();
167
+ expect(migrated['--collapsiblesection-container-default-border-width']).toBeUndefined();
168
+ expect(migrated['--collapsiblesection-container-default-radius']).toBeUndefined();
169
+ // Container hover/active border tokens dropped
170
+ expect(migrated['--collapsiblesection-container-hover-border']).toBeUndefined();
171
+ expect(migrated['--collapsiblesection-container-hover-border-width']).toBeUndefined();
172
+ expect(migrated['--collapsiblesection-container-active-radius']).toBeUndefined();
173
+ // Container hover/active surface survives (header strip)
174
+ expect(migrated['--collapsiblesection-container-active-surface']).toBe('--surface-canvas-low');
175
+ // Chromeless per-state border / radius dropped; padding stays
176
+ expect(migrated['--collapsiblesection-chromeless-default-border']).toBeUndefined();
177
+ expect(migrated['--collapsiblesection-chromeless-hover-border-width']).toBeUndefined();
178
+ expect(migrated['--collapsiblesection-chromeless-active-radius']).toBeUndefined();
179
+ expect(migrated['--collapsiblesection-chromeless-default-padding']).toBe('--space-4');
180
+ // Divider border / border-width survive; radius drops
181
+ expect(migrated['--collapsiblesection-divider-default-border']).toBe('--border-neutral-faint');
182
+ expect(migrated['--collapsiblesection-divider-default-border-width']).toBe('--border-width-1');
183
+ expect(migrated['--collapsiblesection-divider-default-radius']).toBeUndefined();
184
+ // Expanded panel cleanup
185
+ expect(migrated['--collapsiblesection-chromeless-expanded-border']).toBeUndefined();
186
+ expect(migrated['--collapsiblesection-chromeless-expanded-surface']).toBeUndefined();
187
+ expect(migrated['--collapsiblesection-chromeless-expanded-padding']).toBe('--space-4');
188
+ expect(migrated['--collapsiblesection-container-expanded-radius']).toBeUndefined();
189
+ expect(migrated['--collapsiblesection-container-expanded-surface']).toBe('--surface-canvas-low');
190
+ expect(migrated['--collapsiblesection-container-expanded-padding']).toBe('--space-4');
191
+ });
192
+
193
+ it('component-config at version 4 → sectiondivider gradient stops migrate to angle + stop-{n}-{color,position}', () => {
194
+ const v4 = {
195
+ '--sectiondivider-canvas-padding': '--space-16',
196
+ '--sectiondivider-canvas-gradient-stop-1': '--surface-canvas-highest',
197
+ '--sectiondivider-canvas-gradient-stop-2': '--surface-canvas-higher',
198
+ '--sectiondivider-canvas-gradient-stop-3': '--surface-canvas-high',
199
+ '--sectiondivider-canvas-gradient-stop-4': '--surface-canvas',
200
+ '--sectiondivider-primary-gradient-stop-1': '--color-primary-300',
201
+ '--sectiondivider-primary-gradient-stop-2': '--color-primary-500',
202
+ '--sectiondivider-primary-gradient-stop-4': '--color-primary-800',
203
+ };
204
+ const migrated = runMigrations('component-config', 4, v4, { component: 'sectiondivider' });
205
+ // Old keys gone
206
+ expect(migrated['--sectiondivider-canvas-gradient-stop-1']).toBeUndefined();
207
+ expect(migrated['--sectiondivider-canvas-gradient-stop-3']).toBeUndefined();
208
+ expect(migrated['--sectiondivider-canvas-gradient-stop-4']).toBeUndefined();
209
+ // Unrelated tokens preserved
210
+ expect(migrated['--sectiondivider-canvas-padding']).toBe('--space-16');
211
+ // Canvas: colors mapped from old 1, 2, 4
212
+ expect(migrated['--sectiondivider-canvas-gradient-angle']).toBe('--gradient-angle-diagonal');
213
+ expect(migrated['--sectiondivider-canvas-gradient-stop-1-color']).toBe('--surface-canvas-highest');
214
+ expect(migrated['--sectiondivider-canvas-gradient-stop-1-position']).toBe('--gradient-stop-start');
215
+ expect(migrated['--sectiondivider-canvas-gradient-stop-2-color']).toBe('--surface-canvas-higher');
216
+ expect(migrated['--sectiondivider-canvas-gradient-stop-2-position']).toBe('--gradient-stop-mid');
217
+ expect(migrated['--sectiondivider-canvas-gradient-stop-3-color']).toBe('--surface-canvas');
218
+ expect(migrated['--sectiondivider-canvas-gradient-stop-3-position']).toBe('--gradient-stop-end');
219
+ // Primary variant: user-tuned colors carry across; v5→v6 also rewrites
220
+ // the brand-family value names.
221
+ expect(migrated['--sectiondivider-primary-gradient-stop-1-color']).toBe('--color-brand-300');
222
+ expect(migrated['--sectiondivider-primary-gradient-stop-2-color']).toBe('--color-brand-500');
223
+ expect(migrated['--sectiondivider-primary-gradient-stop-3-color']).toBe('--color-brand-800');
224
+ // Variants the file didn't set still gain default colors and angle/positions
225
+ expect(migrated['--sectiondivider-special-gradient-angle']).toBe('--gradient-angle-diagonal');
226
+ expect(migrated['--sectiondivider-special-gradient-stop-1-color']).toBe('--surface-special-highest');
227
+ expect(migrated['--sectiondivider-special-gradient-stop-3-position']).toBe('--gradient-stop-end');
228
+ });
229
+
230
+ it('component-config v4 sectiondivider migration only fires for sectiondivider', () => {
231
+ // Use a value not in the brand-rename map so the v5→v6 step is also a no-op.
232
+ const v4 = { '--button-primary-gradient-stop-1': '--surface-accent' };
233
+ const out = runMigrations('component-config', 4, v4, { component: 'button' });
234
+ expect(out).toEqual(v4);
235
+ });
236
+
237
+ it('component-config v4 sectiondivider migration is idempotent on the new shape', () => {
238
+ const newShape = {
239
+ '--sectiondivider-canvas-gradient-angle': '--gradient-angle-horizontal',
240
+ '--sectiondivider-canvas-gradient-stop-1-color': '--color-primary-200',
241
+ '--sectiondivider-canvas-gradient-stop-1-position': '10%',
242
+ '--sectiondivider-canvas-gradient-stop-2-color': '--color-primary-500',
243
+ '--sectiondivider-canvas-gradient-stop-2-position': '40%',
244
+ '--sectiondivider-canvas-gradient-stop-3-color': '--color-primary-900',
245
+ '--sectiondivider-canvas-gradient-stop-3-position': '85%',
246
+ };
247
+ const out = runMigrations('component-config', 4, newShape, { component: 'sectiondivider' });
248
+ // User-tuned values for canvas survive structurally; v5→v6 rewrites
249
+ // brand-family value names from primary → brand at the tail of the chain.
250
+ expect(out['--sectiondivider-canvas-gradient-angle']).toBe('--gradient-angle-horizontal');
251
+ expect(out['--sectiondivider-canvas-gradient-stop-1-color']).toBe('--color-brand-200');
252
+ expect(out['--sectiondivider-canvas-gradient-stop-1-position']).toBe('10%');
253
+ expect(out['--sectiondivider-canvas-gradient-stop-2-position']).toBe('40%');
254
+ expect(out['--sectiondivider-canvas-gradient-stop-3-position']).toBe('85%');
255
+ });
256
+
257
+ it('component-config at current version → no migrations run', () => {
258
+ const current = { '--button-primary-surface': '--surface-success' };
259
+ const out = runMigrations(
260
+ 'component-config',
261
+ CURRENT_COMPONENT_SCHEMA_VERSION,
262
+ current,
263
+ { component: 'button' },
264
+ );
265
+ expect(out).toEqual(current);
266
+ });
267
+
268
+ it('theme v1 → v2 primary→brand: brand family keys renamed, neutral --text-primary untouched', () => {
269
+ const v1 = {
270
+ '--color-primary-100': '#ffe6f9',
271
+ '--color-primary-500': '#eb0ad4',
272
+ '--surface-primary': '#55004c',
273
+ '--surface-primary-high': '#6c0061',
274
+ '--border-primary': '#b200a0',
275
+ '--border-primary-strong': '#ff90eb',
276
+ '--text-primary-color': '#ff8eeb',
277
+ '--text-primary-secondary': '#fe5be7',
278
+ // Neutral text ramp — must NOT be touched.
279
+ '--text-primary': '#fff5f0',
280
+ '--text-secondary': '#b0a9a4',
281
+ // Component variant — also must NOT be touched.
282
+ '--button-primary-surface': '#abc123',
283
+ };
284
+ const out = runMigrations('theme', 1, v1);
285
+ // Brand family renamed.
286
+ expect(out['--color-brand-100']).toBe('#ffe6f9');
287
+ expect(out['--color-brand-500']).toBe('#eb0ad4');
288
+ expect(out['--surface-brand']).toBe('#55004c');
289
+ expect(out['--surface-brand-high']).toBe('#6c0061');
290
+ expect(out['--border-brand']).toBe('#b200a0');
291
+ expect(out['--border-brand-strong']).toBe('#ff90eb');
292
+ expect(out['--text-brand']).toBe('#ff8eeb');
293
+ expect(out['--text-brand-secondary']).toBe('#fe5be7');
294
+ // Old keys gone.
295
+ expect(out['--color-primary-100']).toBeUndefined();
296
+ expect(out['--surface-primary']).toBeUndefined();
297
+ expect(out['--border-primary']).toBeUndefined();
298
+ expect(out['--text-primary-color']).toBeUndefined();
299
+ expect(out['--text-primary-secondary']).toBeUndefined();
300
+ // Neutral text + component variants survive verbatim.
301
+ expect(out['--text-primary']).toBe('#fff5f0');
302
+ expect(out['--text-secondary']).toBe('#b0a9a4');
303
+ expect(out['--button-primary-surface']).toBe('#abc123');
304
+ });
305
+
306
+ it('component-config v5 → v6 primary→brand: rewrites alias keys AND values; component variants untouched', () => {
307
+ const v5 = {
308
+ // Brand family on the alias key side (rare but possible).
309
+ '--text-primary-color': '#fff',
310
+ // Brand family on the value side (common: component aliases to family token).
311
+ '--badge-trait-surface': '--surface-primary',
312
+ '--badge-trait-text': '--text-primary-color',
313
+ '--badge-trait-border': '--border-primary-medium',
314
+ // Component variant token — name should NOT change.
315
+ '--button-primary-surface': '--surface-brand-high',
316
+ // Neutral text — value should NOT change.
317
+ '--card-default-title': '--text-primary',
318
+ // Unrelated key/value pair.
319
+ '--card-hover-title': '--text-secondary',
320
+ };
321
+ const out = runMigrations('component-config', 5, v5, { component: 'badge' });
322
+ expect(out['--text-brand']).toBe('#fff');
323
+ expect(out['--text-primary-color']).toBeUndefined();
324
+ expect(out['--badge-trait-surface']).toBe('--surface-brand');
325
+ expect(out['--badge-trait-text']).toBe('--text-brand');
326
+ expect(out['--badge-trait-border']).toBe('--border-brand-medium');
327
+ // Component variant identifier (LHS) is preserved; its value is not in the
328
+ // rename map so it passes through.
329
+ expect(out['--button-primary-surface']).toBe('--surface-brand-high');
330
+ // Neutral text value preserved.
331
+ expect(out['--card-default-title']).toBe('--text-primary');
332
+ expect(out['--card-hover-title']).toBe('--text-secondary');
333
+ });
334
+
335
+ it('runMigrations is pure — does not mutate the input map', () => {
336
+ const input = { '--surface-bg': '#fff' };
337
+ const before = { ...input };
338
+ runMigrations('theme', 0, input);
339
+ expect(input).toEqual(before);
340
+ });
341
+ });
@@ -0,0 +1 @@
1
+ export type NavLink = { path: string; label: string; icon?: string; disabled?: boolean };
@@ -0,0 +1,3 @@
1
+ import { writable } from 'svelte/store';
2
+
3
+ export const overlayOpen = writable(false);
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Pure palette → CSS-variable derivation.
3
+ *
4
+ * Mirrors the logic that previously lived only inside `PaletteEditor.svelte`'s
5
+ * reactive emission, but as standalone functions so the editor store can emit
6
+ * palette-derived vars at boot without requiring any editor component to be
7
+ * mounted. Without this module the disabled-state preview (and any other
8
+ * `var(--surface-neutral)` consumer) reads a stale value on reload until the
9
+ * user opens the LiveEditorOverlay and mounts the PaletteEditors.
10
+ *
11
+ * The PaletteEditor still owns *editing* preview (draft hex picked but not
12
+ * yet committed to overrides). Once committed via the store, this module
13
+ * derives the same CSS vars from the persisted config.
14
+ */
15
+
16
+ import { hexToOklch, oklchToHex, gamutClamp } from './oklch';
17
+ import { type CurveAnchor, sampleCurve, makeAnchor } from '../ui/curveEngine';
18
+ import type { PaletteConfig } from './themeTypes';
19
+
20
+ export type PaletteMode = 'chromatic' | 'gray';
21
+
22
+ export interface PaletteSpec {
23
+ label: string;
24
+ cssNamespace: string;
25
+ mode: PaletteMode;
26
+ emptySelector?: boolean;
27
+ initialColor: string;
28
+ }
29
+
30
+ /**
31
+ * Single source of truth for which palettes exist and how their CSS namespaces
32
+ * map. Was previously hardcoded inside `VariablesTab.svelte`; centralising it
33
+ * here lets the store seed boot-time vars without depending on the UI tree.
34
+ */
35
+ export const PALETTE_SPECS: readonly PaletteSpec[] = [
36
+ { label: 'Neutral', cssNamespace: 'neutral', mode: 'gray', initialColor: '#808080' },
37
+ { label: 'Alternate', cssNamespace: 'alternate', mode: 'gray', initialColor: '#808080' },
38
+ { label: 'Background', cssNamespace: 'canvas', mode: 'chromatic', emptySelector: true, initialColor: '#1a1a2e' },
39
+ { label: 'Brand', cssNamespace: 'brand', mode: 'chromatic', initialColor: '#c93636' },
40
+ { label: 'Accent', cssNamespace: 'accent', mode: 'chromatic', initialColor: '#f49e0b' },
41
+ { label: 'Special', cssNamespace: 'special', mode: 'chromatic', initialColor: '#8b5cf6' },
42
+ { label: 'Info', cssNamespace: 'info', mode: 'chromatic', initialColor: '#3077e8' },
43
+ { label: 'Success', cssNamespace: 'success', mode: 'chromatic', initialColor: '#21c45d' },
44
+ { label: 'Warning', cssNamespace: 'warning', mode: 'chromatic', initialColor: '#e66e1a' },
45
+ { label: 'Danger', cssNamespace: 'danger', mode: 'chromatic', initialColor: '#e8304f' },
46
+ ] as const;
47
+
48
+ const PALETTE_STEPS = [
49
+ { label: '100' }, { label: '200' }, { label: '300' }, { label: '400' },
50
+ { label: '500' }, { label: '600' }, { label: '700' }, { label: '800' },
51
+ { label: '850' }, { label: '900' }, { label: '950' },
52
+ ];
53
+
54
+ const GRAY_STEPS = [
55
+ { label: '100' }, { label: '200' }, { label: '300' }, { label: '400' },
56
+ { label: '500' }, { label: '600' }, { label: '700' }, { label: '800' },
57
+ { label: '850' }, { label: '900' }, { label: '950' },
58
+ ];
59
+
60
+ interface ScaleStep { name: string; position: number; }
61
+ interface Scale { title: string; isText: boolean; steps: ScaleStep[]; }
62
+
63
+ const SCALES: readonly Scale[] = [
64
+ {
65
+ title: 'Surfaces', isText: false,
66
+ steps: [
67
+ { name: 'lowest', position: -1 },
68
+ { name: 'lower', position: -2 / 3 },
69
+ { name: 'low', position: -1 / 3 },
70
+ { name: 'default', position: 0 },
71
+ { name: 'high', position: 1 / 3 },
72
+ { name: 'higher', position: 2 / 3 },
73
+ { name: 'highest', position: 1 },
74
+ ],
75
+ },
76
+ {
77
+ title: 'Borders', isText: false,
78
+ steps: [
79
+ { name: 'faint', position: -1 },
80
+ { name: 'subtle', position: -0.5 },
81
+ { name: 'default', position: 0 },
82
+ { name: 'medium', position: 0.5 },
83
+ { name: 'strong', position: 1 },
84
+ ],
85
+ },
86
+ {
87
+ title: 'Text', isText: true,
88
+ steps: [
89
+ { name: 'primary', position: 0 },
90
+ { name: 'secondary', position: 0 },
91
+ { name: 'tertiary', position: 0 },
92
+ { name: 'muted', position: 0 },
93
+ { name: 'disabled', position: 0 },
94
+ ],
95
+ },
96
+ ];
97
+
98
+ export const DEFAULT_PALETTE_LIGHTNESS = (): CurveAnchor[] => [makeAnchor(0, 95, 5), makeAnchor(100, 8, 5)];
99
+ export const DEFAULT_PALETTE_SATURATION = (): CurveAnchor[] => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)];
100
+ export const DEFAULT_GRAY_LIGHTNESS = (): CurveAnchor[] => [makeAnchor(0, 92, 5), makeAnchor(100, 3, 5)];
101
+ export const DEFAULT_GRAY_SATURATION = (): CurveAnchor[] => [makeAnchor(0, 20, 30), makeAnchor(100, 20, 30)];
102
+ export const DEFAULT_TINT_CHROMA = 0.04;
103
+
104
+ export const defaultScaleCurves = {
105
+ Surfaces: {
106
+ lightness: () => [makeAnchor(0, 15, 5), makeAnchor(100, 47, 5)],
107
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
108
+ },
109
+ Borders: {
110
+ lightness: () => [makeAnchor(0, 25, 5), makeAnchor(100, 80, 5)],
111
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
112
+ },
113
+ Text: {
114
+ lightness: () => [makeAnchor(0, 120, 30), makeAnchor(100, 55, 30)],
115
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 15, 30)],
116
+ },
117
+ } as const;
118
+
119
+ function paletteStepKey(label: string): string { return `Palette-${label}`; }
120
+ function grayStepKey(label: string): string { return `gray-${label}`; }
121
+ function stepKey(scaleTitle: string, stepName: string): string { return `${scaleTitle}-${stepName}`; }
122
+
123
+ function stepIndexToX(index: number, total: number): number {
124
+ return total > 1 ? (index / (total - 1)) * 100 : 50;
125
+ }
126
+
127
+ function computePaletteColor(
128
+ index: number,
129
+ base: string,
130
+ lightnessCurve: CurveAnchor[],
131
+ saturationCurve: CurveAnchor[],
132
+ curveOffset: Record<string, number>,
133
+ ): string {
134
+ const { c: baseC, h } = hexToOklch(base);
135
+ const xPos = stepIndexToX(index, PALETTE_STEPS.length);
136
+ const targetL = Math.max(0, Math.min(100, sampleCurve(lightnessCurve, xPos) + (curveOffset.lightness ?? 0))) / 100;
137
+ const satMul = Math.max(0, Math.min(2, (sampleCurve(saturationCurve, xPos) + (curveOffset.saturation ?? 0)) / 100));
138
+ const targetC = baseC * satMul;
139
+ const clamped = gamutClamp(targetL, targetC, h);
140
+ return oklchToHex(clamped.l, clamped.c, clamped.h);
141
+ }
142
+
143
+ function computeGrayColor(
144
+ index: number,
145
+ hue: number,
146
+ chroma: number,
147
+ grayLightnessCurve: CurveAnchor[],
148
+ graySaturationCurve: CurveAnchor[],
149
+ curveOffset: Record<string, number>,
150
+ ): string {
151
+ const xPos = stepIndexToX(index, GRAY_STEPS.length);
152
+ const lOff = curveOffset['gray-lightness'] ?? 0;
153
+ const sOff = curveOffset['gray-saturation'] ?? 0;
154
+ const targetL = Math.max(0, Math.min(100, sampleCurve(grayLightnessCurve, xPos) + lOff)) / 100;
155
+ const satMul = Math.max(0, Math.min(2, (sampleCurve(graySaturationCurve, xPos) + sOff) / 100));
156
+ const targetC = chroma * satMul;
157
+ const clamped = gamutClamp(targetL, targetC, hue);
158
+ return oklchToHex(clamped.l, clamped.c, clamped.h);
159
+ }
160
+
161
+ function computeDerivedColor(
162
+ step: ScaleStep,
163
+ base: string,
164
+ scaleTitle: string,
165
+ scaleCurves: Record<string, { lightness: CurveAnchor[]; saturation: CurveAnchor[] }>,
166
+ curveOffset: Record<string, number>,
167
+ ): string {
168
+ const scale = SCALES.find((s) => s.title === scaleTitle)!;
169
+ const idx = scale.steps.indexOf(step);
170
+ const xPos = stepIndexToX(idx, scale.steps.length);
171
+ const defs = defaultScaleCurves[scaleTitle as keyof typeof defaultScaleCurves];
172
+ const lCurve = scaleCurves[scaleTitle]?.lightness ?? defs.lightness();
173
+ const sCurve = scaleCurves[scaleTitle]?.saturation ?? defs.saturation();
174
+ const lOff = curveOffset[`${scaleTitle}-lightness`] ?? 0;
175
+ const sOff = curveOffset[`${scaleTitle}-saturation`] ?? 0;
176
+ const { l: baseL, c: baseC, h: baseH } = hexToOklch(base);
177
+ let targetL: number;
178
+ if (scale.isText) {
179
+ const lMul = Math.max(0, Math.min(2, (sampleCurve(lCurve, xPos) + lOff) / 100));
180
+ targetL = Math.max(0, Math.min(1, baseL * lMul));
181
+ } else {
182
+ targetL = Math.max(0, Math.min(100, sampleCurve(lCurve, xPos) + lOff)) / 100;
183
+ }
184
+ const satMul = Math.max(0, Math.min(2, (sampleCurve(sCurve, xPos) + sOff) / 100));
185
+ const targetC = baseC * satMul;
186
+ const clamped = gamutClamp(targetL, targetC, baseH);
187
+ return oklchToHex(clamped.l, clamped.c, clamped.h);
188
+ }
189
+
190
+ export function scaleToCssVar(scaleTitle: string, stepName: string, cssNamespace: string | null): string | null {
191
+ if (cssNamespace === null) return null;
192
+ if (scaleTitle === 'Surfaces') {
193
+ const suffix = stepName === 'default' ? '' : `-${stepName}`;
194
+ return cssNamespace === 'neutral' ? `--surface-neutral${suffix}` : `--surface-${cssNamespace}${suffix}`;
195
+ }
196
+ if (scaleTitle === 'Borders') {
197
+ const suffix = stepName === 'default' ? '' : `-${stepName}`;
198
+ return cssNamespace === 'neutral' ? `--border-neutral${suffix}` : `--border-${cssNamespace}${suffix}`;
199
+ }
200
+ if (scaleTitle === 'Text') {
201
+ if (cssNamespace === 'neutral') return `--text-${stepName}`;
202
+ return stepName === 'primary' ? `--text-${cssNamespace}` : `--text-${cssNamespace}-${stepName}`;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | undefined): Record<string, string> {
208
+ const out: Record<string, string> = {};
209
+ if (!config) return out;
210
+
211
+ const baseColor = config.baseColor ?? spec.initialColor;
212
+ const overrides = config.overrides ?? {};
213
+ const curveOffset = config.curveOffset ?? {};
214
+ const scaleCurves = config.scaleCurves ?? {};
215
+
216
+ let baseForScales: string;
217
+
218
+ if (spec.mode === 'gray') {
219
+ const grayLightnessCurve = config.grayLightnessCurve ?? DEFAULT_GRAY_LIGHTNESS();
220
+ const graySaturationCurve = config.graySaturationCurve ?? DEFAULT_GRAY_SATURATION();
221
+ const tintHue = config.tintHue ?? 240;
222
+ const tintChroma = config.tintChroma ?? DEFAULT_TINT_CHROMA;
223
+
224
+ let gray500 = '#808080';
225
+ GRAY_STEPS.forEach((step, index) => {
226
+ const k = grayStepKey(step.label);
227
+ const hex = computeGrayColor(index, tintHue, tintChroma, grayLightnessCurve, graySaturationCurve, curveOffset);
228
+ const effective = (k in overrides) ? overrides[k] : hex;
229
+ out[`--color-${spec.cssNamespace}-${step.label}`] = effective;
230
+ if (step.label === '500') gray500 = hex;
231
+ });
232
+ baseForScales = gray500;
233
+ } else {
234
+ const lightnessCurve = config.lightnessCurve ?? DEFAULT_PALETTE_LIGHTNESS();
235
+ const saturationCurve = config.saturationCurve ?? DEFAULT_PALETTE_SATURATION();
236
+
237
+ PALETTE_STEPS.forEach((ps, index) => {
238
+ const k = paletteStepKey(ps.label);
239
+ const hex = computePaletteColor(index, baseColor, lightnessCurve, saturationCurve, curveOffset);
240
+ const effective = (k in overrides) ? overrides[k] : hex;
241
+ out[`--color-${spec.cssNamespace}-${ps.label}`] = effective;
242
+ });
243
+ baseForScales = baseColor;
244
+ }
245
+
246
+ for (const scale of SCALES) {
247
+ for (const step of scale.steps) {
248
+ const k = stepKey(scale.title, step.name);
249
+ const hex = (k in overrides)
250
+ ? overrides[k]
251
+ : computeDerivedColor(step, baseForScales, scale.title, scaleCurves, curveOffset);
252
+ const varName = scaleToCssVar(scale.title, step.name, spec.cssNamespace);
253
+ if (varName) out[varName] = hex;
254
+ }
255
+ }
256
+
257
+ if (spec.emptySelector) {
258
+ const emptyMode = config.emptyMode ?? 'solid';
259
+ const emptyStep = config.emptyStep ?? '850';
260
+ const gradientStyle = config.gradientStyle ?? 'linear';
261
+ const gradientAngle = config.gradientAngle ?? 180;
262
+ const gradientReverse = config.gradientReverse ?? false;
263
+ const gradientSize = config.gradientSize ?? 'page';
264
+ const gradientStops = config.gradientStops ?? [
265
+ { position: 0, paletteLabel: '800' },
266
+ { position: 100, paletteLabel: '950' },
267
+ ];
268
+
269
+ if (emptyMode === 'solid') {
270
+ const stepHex = out[`--color-${spec.cssNamespace}-${emptyStep}`];
271
+ if (stepHex) out['--page-bg'] = stepHex;
272
+ out['--page-bg-attachment'] = 'scroll';
273
+ } else {
274
+ const sortedStops = [...gradientStops].sort((a, b) =>
275
+ gradientReverse ? b.position - a.position : a.position - b.position,
276
+ );
277
+ const stopsCss = sortedStops
278
+ .map((s) => `${out[`--color-${spec.cssNamespace}-${s.paletteLabel}`] ?? '#000000'} ${s.position}%`)
279
+ .join(', ');
280
+ let gradient: string;
281
+ switch (gradientStyle) {
282
+ case 'radial': gradient = `radial-gradient(circle, ${stopsCss})`; break;
283
+ case 'conic': gradient = `conic-gradient(from ${gradientAngle}deg, ${stopsCss})`; break;
284
+ default: gradient = `linear-gradient(${gradientAngle}deg, ${stopsCss})`;
285
+ }
286
+ out['--page-bg'] = gradient;
287
+ out['--page-bg-attachment'] = gradientSize === 'window' ? 'fixed' : 'scroll';
288
+ }
289
+ }
290
+
291
+ return out;
292
+ }
293
+
294
+ export function palettesToVars(palettes: Record<string, PaletteConfig>): Record<string, string> {
295
+ const out: Record<string, string> = {};
296
+ for (const spec of PALETTE_SPECS) {
297
+ Object.assign(out, derivePaletteVars(spec, palettes[spec.label]));
298
+ }
299
+ return out;
300
+ }