@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,1590 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy, tick } from 'svelte';
3
+ import { hexToOklch, oklchToHex, gamutClamp } from '../lib/oklch';
4
+ import { type CurveAnchor, makeAnchor, sampleCurve, lightnessCurveConfig, saturationCurveConfig, textLightnessCurveConfig } from './curveEngine';
5
+ import ColorEditPanel from './ColorEditPanel.svelte';
6
+ import OverridesPanel from './palette/OverridesPanel.svelte';
7
+ import GradientStopEditor from './palette/GradientStopEditor.svelte';
8
+ import ScaleCurveEditor from './palette/ScaleCurveEditor.svelte';
9
+ import PaletteBase from './palette/PaletteBase.svelte';
10
+ import { type EditingState, idleState, BASE_KEY, isEditingBase as isBaseEdit } from './palette/paletteEditorState';
11
+ import type { PaletteConfig, GradientStyle, GradientStop } from '../lib/themeTypes';
12
+ import { editorState, mutate, setPaletteConfig, beginSliderGesture, beginScope, commitScope, cancelScope, type Scope } from '../lib/editorStore';
13
+ import { scaleToCssVar } from '../lib/paletteDerivation';
14
+ import { showCopyPopover } from '../lib/copyPopover';
15
+ import { get } from 'svelte/store';
16
+
17
+ /** Mid-gray fallback used when no base colour or computed gray-500 is available. */
18
+ const GRAY_FALLBACK = '#808080';
19
+
20
+ export let label: string;
21
+ export let displayLabel: string | null = null;
22
+ export let initialColor: string = GRAY_FALLBACK;
23
+ export let mode: 'chromatic' | 'gray' = 'chromatic';
24
+ export let cssNamespace: string | null = null;
25
+ export let emptySelector: boolean = false;
26
+
27
+ // --- Store-sourced config (single source of truth) ---
28
+ //
29
+ // All persistent palette state lives in `$editorState.palettes[label]`.
30
+ // Local `$:` derivations below pull named fields with defaults; every
31
+ // handler writes via `edit()` / `patchPalette()` so the store is the only
32
+ // writer. No `let` mirrors, no round-trip sync reactives.
33
+ //
34
+ // The defaults fall back only when palettes[label] is undefined (brand-new
35
+ // install, never seeded). Production seeds via themeInit → seedPalettesFromTheme.
36
+ $: paletteConfig = $editorState.palettes[label];
37
+ $: baseColor = paletteConfig?.baseColor ?? initialColor;
38
+ $: tintHue = paletteConfig?.tintHue ?? 240;
39
+ $: tintChroma = paletteConfig?.tintChroma ?? DEFAULT_TINT_CHROMA;
40
+ $: lightnessCurve = paletteConfig?.lightnessCurve ?? DEFAULT_PALETTE_LIGHTNESS();
41
+ $: saturationCurve = paletteConfig?.saturationCurve ?? DEFAULT_PALETTE_SATURATION();
42
+ $: grayLightnessCurve = paletteConfig?.grayLightnessCurve ?? DEFAULT_GRAY_LIGHTNESS();
43
+ $: graySaturationCurve = paletteConfig?.graySaturationCurve ?? DEFAULT_GRAY_SATURATION();
44
+ $: scaleCurves = paletteConfig?.scaleCurves ?? defaultScaleCurvesObject();
45
+ $: curveOffset = paletteConfig?.curveOffset ?? { lightness: 0, saturation: 0 };
46
+ $: overrides = paletteConfig?.overrides ?? {};
47
+ $: snappedScales = new Set(paletteConfig?.snappedScales ?? []);
48
+ $: anchorToBase = paletteConfig?.anchorToBase ?? true;
49
+ $: emptyMode = paletteConfig?.emptyMode ?? 'solid';
50
+ $: emptyStep = paletteConfig?.emptyStep ?? '850';
51
+ $: gradientStyle = paletteConfig?.gradientStyle ?? 'linear';
52
+ $: gradientAngle = paletteConfig?.gradientAngle ?? 180;
53
+ $: gradientReverse = paletteConfig?.gradientReverse ?? false;
54
+ $: gradientStops = paletteConfig?.gradientStops ?? [
55
+ { position: 0, paletteLabel: '800' },
56
+ { position: 100, paletteLabel: '950' },
57
+ ];
58
+ $: gradientSize = paletteConfig?.gradientSize ?? 'page';
59
+
60
+ function defaultPaletteConfig(): PaletteConfig {
61
+ return {
62
+ baseColor: initialColor,
63
+ tintHue: 240,
64
+ tintChroma: DEFAULT_TINT_CHROMA,
65
+ lightnessCurve: DEFAULT_PALETTE_LIGHTNESS(),
66
+ saturationCurve: DEFAULT_PALETTE_SATURATION(),
67
+ grayLightnessCurve: DEFAULT_GRAY_LIGHTNESS(),
68
+ graySaturationCurve: DEFAULT_GRAY_SATURATION(),
69
+ scaleCurves: defaultScaleCurvesObject(),
70
+ curveOffset: { lightness: 0, saturation: 0 },
71
+ overrides: {},
72
+ snappedScales: [],
73
+ anchorToBase: true,
74
+ };
75
+ }
76
+
77
+ function defaultScaleCurvesObject() {
78
+ return {
79
+ Surfaces: { lightness: defaultScaleCurves.Surfaces.lightness(), saturation: defaultScaleCurves.Surfaces.saturation() },
80
+ Borders: { lightness: defaultScaleCurves.Borders.lightness(), saturation: defaultScaleCurves.Borders.saturation() },
81
+ Text: { lightness: defaultScaleCurves.Text.lightness(), saturation: defaultScaleCurves.Text.saturation() },
82
+ };
83
+ }
84
+
85
+ function edit<K extends keyof PaletteConfig>(field: K, value: PaletteConfig[K]): void {
86
+ mutate(`${label}: ${String(field)}`, (s) => {
87
+ if (!s.palettes[label]) s.palettes[label] = defaultPaletteConfig();
88
+ (s.palettes[label] as any)[field] = value;
89
+ });
90
+ }
91
+
92
+ function patchPalette(patch: Partial<PaletteConfig>, historyLabel: string): void {
93
+ mutate(`${label}: ${historyLabel}`, (s) => {
94
+ if (!s.palettes[label]) s.palettes[label] = defaultPaletteConfig();
95
+ Object.assign(s.palettes[label], patch);
96
+ });
97
+ }
98
+
99
+ // --- Transient UI state (not persisted; not in PaletteConfig) ---
100
+ let lockedLightnessIdx: number | null = null;
101
+ let lockedSaturationIdx: number | null = null;
102
+
103
+ // Handle for the open palette edit scope: a clipping scope (clipUndoFloor:
104
+ // true) bracketing one panel-open → confirm/cancel cycle. Held at component
105
+ // scope so the inline header-swatch handlers and the function handlers
106
+ // share the same handle for commitScope/cancelScope.
107
+ let paletteEditScope: Scope | null = null;
108
+
109
+ function stopColor(stop: GradientStop, pc: typeof paletteComputed): string {
110
+ const ps = pc?.find(p => p.label === stop.paletteLabel);
111
+ return ps ? ps.effective : '#000000';
112
+ }
113
+
114
+ let gradientColorStops = '';
115
+ let gradientCssValue = '';
116
+ let gradientBarPreview = '';
117
+
118
+ function onEmptyModeChange(e: Event) {
119
+ edit('emptyMode', (e.currentTarget as HTMLInputElement).checked ? 'gradient' : 'solid');
120
+ }
121
+
122
+ // --- Gray mode ---
123
+
124
+ interface GrayStep {
125
+ label: string;
126
+ hue: number;
127
+ saturation: number;
128
+ lightness: number;
129
+ }
130
+
131
+ const graySteps: GrayStep[] = [
132
+ { label: '100', hue: 240, saturation: 5, lightness: 92 },
133
+ { label: '200', hue: 220, saturation: 13, lightness: 84 },
134
+ { label: '300', hue: 216, saturation: 12, lightness: 72 },
135
+ { label: '400', hue: 240, saturation: 5, lightness: 61 },
136
+ { label: '500', hue: 240, saturation: 5, lightness: 50 },
137
+ { label: '600', hue: 240, saturation: 5, lightness: 42 },
138
+ { label: '700', hue: 240, saturation: 5, lightness: 34 },
139
+ { label: '800', hue: 240, saturation: 10, lightness: 25 },
140
+ { label: '850', hue: 229, saturation: 20, lightness: 18 },
141
+ { label: '900', hue: 240, saturation: 30, lightness: 10 },
142
+ { label: '950', hue: 229, saturation: 34, lightness: 3 },
143
+ ];
144
+
145
+ let grayEditorOpen = false;
146
+ let showDerived = false;
147
+
148
+ // --- Palette curve editors (lightness + saturation) ---
149
+ let paletteEditorOpen = false;
150
+
151
+ // Default curve anchors (used for initial state and reset)
152
+ const DEFAULT_PALETTE_LIGHTNESS = () => [makeAnchor(0, 95, 5), makeAnchor(100, 8, 5)];
153
+ const DEFAULT_PALETTE_SATURATION = () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)];
154
+ const DEFAULT_GRAY_LIGHTNESS = () => [makeAnchor(0, 92, 5), makeAnchor(100, 3, 5)];
155
+ const DEFAULT_GRAY_SATURATION = () => [makeAnchor(0, 20, 30), makeAnchor(100, 20, 30)];
156
+
157
+ function setLightnessCurve(a: CurveAnchor[]) { edit('lightnessCurve', a); }
158
+ function setSaturationCurve(a: CurveAnchor[]) { edit('saturationCurve', a); }
159
+ function setGrayLightnessCurve(a: CurveAnchor[]) { edit('grayLightnessCurve', a); }
160
+ function setGraySaturationCurve(a: CurveAnchor[]) { edit('graySaturationCurve', a); }
161
+
162
+ // --- Curve offset + clipboard (shared across all curve editors) ---
163
+
164
+ function handleOffset(key: string, value: number) {
165
+ edit('curveOffset', { ...curveOffset, [key]: value });
166
+ }
167
+
168
+ // Gray step index to curve x-position
169
+ function grayStepToX(index: number): number {
170
+ return graySteps.length > 1 ? (index / (graySteps.length - 1)) * 100 : 50;
171
+ }
172
+
173
+ // Base chroma for gray tinting (editable via the color panel's chroma slider)
174
+ const DEFAULT_TINT_CHROMA = 0.04;
175
+
176
+ // --- Editing-state machine (M4 fold) ---
177
+ //
178
+ // Single discriminated union replaces five independent `let` decls
179
+ // (`editingKey`, `editingSnapshot`, `editingDraft`, `snapshotTintHue`,
180
+ // `snapshotTintChroma`). The compatibility `$:` derivations below preserve
181
+ // existing read sites while writes go through `editing = { kind: ... }`.
182
+ let editing: EditingState = idleState;
183
+
184
+ // Read-side compat: existing `editingKey === ...` etc. comparisons keep
185
+ // working. New code should narrow on `editing.kind` directly.
186
+ $: editingKey = editing.kind === 'idle' ? null : editing.kind === 'editingBase' ? BASE_KEY : editing.stepKey;
187
+ $: editingDraft = editing.kind === 'editingStep' ? editing.draft : null;
188
+ $: editingSnapshot = editing.kind === 'idle' ? null : editing.kind === 'editingBase' ? editing.snapshotHex : editing.snapshot;
189
+ $: snapshotTintHue = editing.kind === 'editingBase' ? editing.snapshotTintHue : null;
190
+ $: snapshotTintChroma = editing.kind === 'editingBase' ? editing.snapshotTintChroma : null;
191
+
192
+ function computeGrayColor(index: number, hue: number, chroma: number = tintChroma): string {
193
+ const xPos = grayStepToX(index);
194
+ const lOff = curveOffset['gray-lightness'] ?? 0;
195
+ const sOff = curveOffset['gray-saturation'] ?? 0;
196
+
197
+ const targetL = Math.max(0, Math.min(100, sampleCurve(grayLightnessCurve, xPos) + lOff)) / 100;
198
+ const satMul = Math.max(0, Math.min(2, (sampleCurve(graySaturationCurve, xPos) + sOff) / 100));
199
+ const targetC = chroma * satMul;
200
+
201
+ const clamped = gamutClamp(targetL, targetC, hue);
202
+ return oklchToHex(clamped.l, clamped.c, clamped.h);
203
+ }
204
+
205
+ function grayStepKey(label: string): string {
206
+ return `gray-${label}`;
207
+ }
208
+
209
+ // Reactive map of computed gray colors
210
+ $: grayComputed = (() => {
211
+ const _gl = grayLightnessCurve, _gs = graySaturationCurve, _co = curveOffset, _tc = tintChroma, _th = tintHue;
212
+ return graySteps.map((step, index) => ({
213
+ step,
214
+ index,
215
+ key: grayStepKey(step.label),
216
+ hex: computeGrayColor(index, _th, _tc),
217
+ }));
218
+ })();
219
+
220
+ $: grayEffective = (() => {
221
+ const _ed = editingDraft, _ek = editingKey, _ov = overrides;
222
+ return grayComputed.map(g => ({
223
+ ...g,
224
+ effective: (_ek === g.key && _ed !== null) ? _ed : (g.key in _ov) ? _ov[g.key] : g.hex,
225
+ }));
226
+ })();
227
+
228
+ // Gray-500 hex — always the computed (curve-derived) value so derived
229
+ // scales (surfaces, borders, text) update in realtime when tint changes.
230
+ $: gray500Hex = mode === 'gray'
231
+ ? (grayComputed.find(g => g.step.label === '500')?.hex ?? GRAY_FALLBACK)
232
+ : baseColor;
233
+
234
+
235
+ // --- Chromatic palette steps ---
236
+
237
+ const paletteStepLightness = [
238
+ { label: '100', lightness: 95 },
239
+ { label: '200', lightness: 88 },
240
+ { label: '300', lightness: 78 },
241
+ { label: '400', lightness: 68 },
242
+ { label: '500', lightness: 57 },
243
+ { label: '600', lightness: 49 },
244
+ { label: '700', lightness: 41 },
245
+ { label: '800', lightness: 32 },
246
+ { label: '850', lightness: 25 },
247
+ { label: '900', lightness: 17 },
248
+ { label: '950', lightness: 8 },
249
+ ];
250
+
251
+ function paletteStepKey(label: string): string {
252
+ return `Palette-${label}`;
253
+ }
254
+
255
+ function stepIndexToX(index: number): number {
256
+ return (index / (paletteStepLightness.length - 1)) * 100;
257
+ }
258
+
259
+ // --- Locked anchor management ---
260
+
261
+ let injectedLightness = false;
262
+ let injectedSaturation = false;
263
+
264
+ function injectLockedAnchor(curve: CurveAnchor[], x: number, y: number): { curve: CurveAnchor[], idx: number, injected: boolean } {
265
+ const existing = curve.findIndex(a => Math.abs(a.x - x) < 0.5);
266
+ if (existing >= 0) {
267
+ if (curve[existing].x === x && Math.abs(curve[existing].y - y) < 0.01) return { curve, idx: existing, injected: false };
268
+ return { curve: curve.map((a, i) => i === existing ? { ...a, x, y } : a), idx: existing, injected: false };
269
+ }
270
+ let insertAt = curve.findIndex(a => a.x > x);
271
+ if (insertAt < 0) insertAt = curve.length;
272
+ return { curve: [...curve.slice(0, insertAt), makeAnchor(x, y, 15), ...curve.slice(insertAt)], idx: insertAt, injected: true };
273
+ }
274
+
275
+ function removeLockedAnchor(curve: CurveAnchor[], idx: number | null): CurveAnchor[] {
276
+ if (idx === null || idx === 0 || idx === curve.length - 1) return curve;
277
+ return curve.filter((_, i) => i !== idx);
278
+ }
279
+
280
+ /**
281
+ * Toggle anchorToBase: inject (or remove) the locked 500 anchor in both
282
+ * curves atomically with the flag flip, so one undo reverses the whole
283
+ * thing. Transient `injectedLightness` / `injectedSaturation` remember
284
+ * whether we created the anchor (vs it already existed) so toggle-off
285
+ * doesn't destroy a user-authored anchor. Not persisted — acceptable drift
286
+ * on reload is that a pre-existing anchor would be preserved on toggle-off.
287
+ */
288
+ function setAnchorToBase(next: boolean) {
289
+ if (next === anchorToBase) return;
290
+ if (next) {
291
+ const x500 = stepIndexToX(4);
292
+ const lResult = injectLockedAnchor(lightnessCurve, x500, hexToOklch(baseColor).l * 100);
293
+ const sResult = injectLockedAnchor(saturationCurve, x500, 100);
294
+ injectedLightness = lResult.injected;
295
+ injectedSaturation = sResult.injected;
296
+ patchPalette({
297
+ anchorToBase: true,
298
+ lightnessCurve: lResult.curve,
299
+ saturationCurve: sResult.curve,
300
+ }, 'anchor on');
301
+ } else {
302
+ const lCurr = lightnessCurve;
303
+ const sCurr = saturationCurve;
304
+ const nextL = injectedLightness ? removeLockedAnchor(lCurr, lockedLightnessIdx) : lCurr;
305
+ const nextS = injectedSaturation ? removeLockedAnchor(sCurr, lockedSaturationIdx) : sCurr;
306
+ injectedLightness = false;
307
+ injectedSaturation = false;
308
+ patchPalette({
309
+ anchorToBase: false,
310
+ lightnessCurve: nextL,
311
+ saturationCurve: nextS,
312
+ }, 'anchor off');
313
+ }
314
+ }
315
+
316
+ // Derive locked anchor indices from curve shape — no writes to state.
317
+ $: {
318
+ if (anchorToBase) {
319
+ const x500 = stepIndexToX(4);
320
+ const lIdx = lightnessCurve.findIndex(a => Math.abs(a.x - x500) < 0.5);
321
+ lockedLightnessIdx = lIdx >= 0 ? lIdx : null;
322
+ const sIdx = saturationCurve.findIndex(a => Math.abs(a.x - x500) < 0.5);
323
+ lockedSaturationIdx = sIdx >= 0 ? sIdx : null;
324
+ } else {
325
+ lockedLightnessIdx = null;
326
+ lockedSaturationIdx = null;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Keep the locked lightness anchor y in sync with baseColor. Idempotent —
332
+ * only writes when the curve's anchor y differs from the baseColor-derived
333
+ * target. During a baseColor drag (inside a slider transaction) this
334
+ * additional curve edit merges into the same history entry. On undo/redo
335
+ * the curve already has the correct y (they're saved together), so this
336
+ * is a no-op.
337
+ */
338
+ $: if (anchorToBase && lockedLightnessIdx !== null && baseColor) {
339
+ const targetY = hexToOklch(baseColor).l * 100;
340
+ if (lightnessCurve[lockedLightnessIdx] && Math.abs(lightnessCurve[lockedLightnessIdx].y - targetY) > 0.01) {
341
+ edit('lightnessCurve', lightnessCurve.map((a, i) => i === lockedLightnessIdx ? { ...a, y: targetY } : a));
342
+ }
343
+ }
344
+
345
+ function computePaletteColor(index: number, base: string): string {
346
+ const { c: baseC, h } = hexToOklch(base);
347
+ const xPos = stepIndexToX(index);
348
+
349
+ const targetL = Math.max(0, Math.min(100, sampleCurve(lightnessCurve, xPos) + (curveOffset['lightness'] ?? 0))) / 100;
350
+ const satMul = Math.max(0, Math.min(2, (sampleCurve(saturationCurve, xPos) + (curveOffset['saturation'] ?? 0)) / 100));
351
+ const targetC = baseC * satMul;
352
+
353
+ const clamped = gamutClamp(targetL, targetC, h);
354
+ return oklchToHex(clamped.l, clamped.c, clamped.h);
355
+ }
356
+
357
+ $: paletteComputed = (() => {
358
+ const _bc = baseColor, _lc = lightnessCurve, _sc = saturationCurve, _co = curveOffset, _ed = editingDraft, _ek = editingKey, _ov = overrides, _ab = anchorToBase;
359
+ return paletteStepLightness.map((ps, index) => {
360
+ const k = paletteStepKey(ps.label);
361
+ const hex = computePaletteColor(index, baseColor);
362
+ const effective = (_ek === k && _ed !== null) ? _ed : (k in _ov) ? _ov[k] : hex;
363
+ return {
364
+ label: ps.label,
365
+ lightness: ps.lightness,
366
+ index,
367
+ key: k,
368
+ hex,
369
+ effective,
370
+ };
371
+ });
372
+ })();
373
+
374
+ // Gradient reactives — must follow paletteComputed
375
+ $: {
376
+ const pc = paletteComputed;
377
+ const sorted = [...gradientStops].sort((a, b) => gradientReverse ? b.position - a.position : a.position - b.position);
378
+ gradientColorStops = sorted.map(s => `${stopColor(s, pc)} ${s.position}%`).join(', ');
379
+ gradientBarPreview = `linear-gradient(to right, ${gradientColorStops})`;
380
+ if (emptySelector && emptyMode === 'gradient') {
381
+ switch (gradientStyle) {
382
+ case 'radial': gradientCssValue = `radial-gradient(circle, ${gradientColorStops})`; break;
383
+ case 'conic': gradientCssValue = `conic-gradient(from ${gradientAngle}deg, ${gradientColorStops})`; break;
384
+ default: gradientCssValue = `linear-gradient(${gradientAngle}deg, ${gradientColorStops})`;
385
+ }
386
+ } else {
387
+ gradientCssValue = '';
388
+ }
389
+ }
390
+
391
+ function startBaseEdit() {
392
+ if (editing.kind === 'editingBase') { confirmEdit(); return; }
393
+ editing = mode === 'gray'
394
+ ? { kind: 'editingBase', snapshotHex: gray500Hex, snapshotTintHue: tintHue, snapshotTintChroma: tintChroma }
395
+ : { kind: 'editingBase', snapshotHex: baseColor, snapshotTintHue: null, snapshotTintChroma: null };
396
+ paletteEditScope = beginScope({ label: 'palette session', collapseToOne: true, clipUndoFloor: true });
397
+ }
398
+
399
+ function handlePaletteClick(ps: { label: string; lightness: number; index: number }) {
400
+ const k = paletteStepKey(ps.label);
401
+ if (editingKey === k) {
402
+ confirmEdit();
403
+ return;
404
+ }
405
+ const current = (k in overrides) ? overrides[k] : computePaletteColor(ps.index, baseColor);
406
+ editing = { kind: 'editingStep', stepKey: k, snapshot: current, draft: current };
407
+ paletteEditScope = beginScope({ label: 'palette session', collapseToOne: true, clipUndoFloor: true });
408
+ }
409
+
410
+ // --- Scale types ---
411
+
412
+ interface Step {
413
+ name: string;
414
+ position: number;
415
+ lightness?: number;
416
+ saturation?: number;
417
+ }
418
+
419
+ interface Scale {
420
+ title: string;
421
+ isText: boolean;
422
+ steps: Step[];
423
+ }
424
+
425
+ const scales: Scale[] = [
426
+ {
427
+ title: 'Surfaces',
428
+ isText: false,
429
+ steps: [
430
+ { name: 'lowest', position: -1 },
431
+ { name: 'lower', position: -2/3 },
432
+ { name: 'low', position: -1/3 },
433
+ { name: 'default', position: 0 },
434
+ { name: 'high', position: 1/3 },
435
+ { name: 'higher', position: 2/3 },
436
+ { name: 'highest', position: 1 },
437
+ ]
438
+ },
439
+ {
440
+ title: 'Borders',
441
+ isText: false,
442
+ steps: [
443
+ { name: 'faint', position: -1 },
444
+ { name: 'subtle', position: -0.5 },
445
+ { name: 'default', position: 0 },
446
+ { name: 'medium', position: 0.5 },
447
+ { name: 'strong', position: 1 },
448
+ ]
449
+ },
450
+ {
451
+ title: 'Text',
452
+ isText: true,
453
+ steps: [
454
+ { name: 'primary', position: 0 },
455
+ { name: 'secondary', position: 0 },
456
+ { name: 'tertiary', position: 0 },
457
+ { name: 'muted', position: 0 },
458
+ { name: 'disabled', position: 0 },
459
+ ]
460
+ }
461
+ ];
462
+
463
+ // Scales to render in gray mode (varies by namespace)
464
+ $: grayScales = mode === 'gray' ? scales.filter(scale => {
465
+ if (scale.title === 'Surfaces') return true;
466
+ if (scale.title === 'Borders') return true;
467
+ if (scale.title === 'Text') return true;
468
+ return false;
469
+ }) : [];
470
+
471
+ // --- Per-scale curve state (Surfaces & Borders) ---
472
+
473
+ const defaultScaleCurves: Record<string, { lightness: () => CurveAnchor[]; saturation: () => CurveAnchor[] }> = {
474
+ Surfaces: {
475
+ lightness: () => [makeAnchor(0, 15, 5), makeAnchor(100, 47, 5)],
476
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
477
+ },
478
+ Borders: {
479
+ lightness: () => [makeAnchor(0, 25, 5), makeAnchor(100, 80, 5)],
480
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
481
+ },
482
+ Text: {
483
+ lightness: () => [makeAnchor(0, 120, 30), makeAnchor(100, 55, 30)],
484
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 15, 30)],
485
+ },
486
+ };
487
+
488
+ let scaleEditorOpen: Record<string, boolean> = { Surfaces: false, Borders: false, Text: false };
489
+
490
+ function toggleScaleEditor(title: string) {
491
+ scaleEditorOpen[title] = !scaleEditorOpen[title];
492
+ scaleEditorOpen = scaleEditorOpen;
493
+ }
494
+
495
+ function setScaleCurve(title: string, channel: 'lightness' | 'saturation', a: CurveAnchor[]) {
496
+ const cur = scaleCurves[title] ?? { lightness: defaultScaleCurves[title].lightness(), saturation: defaultScaleCurves[title].saturation() };
497
+ edit('scaleCurves', { ...scaleCurves, [title]: { ...cur, [channel]: a } });
498
+ }
499
+
500
+ function getScaleCurveKey(scaleTitle: string, channel: 'lightness' | 'saturation'): string {
501
+ return `${scaleTitle}-${channel}`;
502
+ }
503
+
504
+ interface ScaleConfig {
505
+ lightnessLow: number;
506
+ lightnessHigh: number;
507
+ saturation: number;
508
+ }
509
+
510
+ function configForScale(title: string): ScaleConfig {
511
+ return { lightnessLow: 0, lightnessHigh: 100, saturation: 100 };
512
+ }
513
+
514
+
515
+ function stepKey(scaleTitle: string, stepName: string): string {
516
+ return `${scaleTitle}-${stepName}`;
517
+ }
518
+
519
+ $: curveVersion = JSON.stringify(scaleCurves) + JSON.stringify(curveOffset) + gray500Hex;
520
+
521
+ function derivedHex(step: Step, base: string, scaleTitle: string, _version?: string): string {
522
+ return computeDerivedColor(step, base, configForScale(scaleTitle), scaleTitle);
523
+ }
524
+
525
+ /**
526
+ * Closure factories used by `<OverridesPanel>` so the panel doesn't have to
527
+ * know about `baseColor` / `gray500Hex` / `curveVersion`. These keep
528
+ * reactivity intact (the parent's `$:` blocks still drive re-render).
529
+ *
530
+ * Note: `effectiveColor` itself always uses `gray500Hex` for non-override
531
+ * derivation (pre-existing); the chromatic vs gray distinction here is
532
+ * only for the `derivedHex` (the "Ag" preview / border-color base).
533
+ */
534
+ $: derivedHexForBase = (step: Step, scaleTitle: string) => derivedHex(step, baseColor, scaleTitle, curveVersion);
535
+ $: derivedHexForGray = (step: Step, scaleTitle: string) => derivedHex(step, gray500Hex, scaleTitle, curveVersion);
536
+ $: effectiveHexAny = (k: string, step: Step, scaleTitle: string) => effectiveColor(k, step, scaleTitle, curveVersion);
537
+
538
+ // --- Reactive editing state ---
539
+
540
+ $: isEditingBase = isBaseEdit(editing);
541
+
542
+ $: editingColor = isEditingBase
543
+ ? (mode === 'gray' ? gray500Hex : baseColor)
544
+ : editingDraft;
545
+
546
+ $: editingStepInfo = (() => {
547
+ if (!editingKey || isEditingBase) return null;
548
+ if (mode === 'gray') {
549
+ const gs = graySteps.find(s => grayStepKey(s.label) === editingKey);
550
+ if (gs) return { scale: 'Gray', step: gs.label };
551
+ }
552
+ const ps = paletteStepLightness.find(p => paletteStepKey(p.label) === editingKey);
553
+ if (ps) return { scale: 'Palette', step: ps.label };
554
+ for (const scale of scales) {
555
+ for (const step of scale.steps) {
556
+ if (stepKey(scale.title, step.name) === editingKey) {
557
+ return { scale: scale.title, step: step.name };
558
+ }
559
+ }
560
+ }
561
+ return null;
562
+ })();
563
+
564
+ $: panelOpen = editingKey !== null && (isEditingBase || (editingDraft !== null && editingStepInfo !== null));
565
+
566
+ $: editPanelTitle = isEditingBase
567
+ ? 'Base Color'
568
+ : editingStepInfo
569
+ ? `${editingStepInfo.scale} \u203A ${editingStepInfo.step}`
570
+ : null;
571
+
572
+ // --- Compute derived color via OKLCH ---
573
+
574
+ function scaleStepToX(step: Step, scale: Scale): number {
575
+ const idx = scale.steps.indexOf(step);
576
+ return scale.steps.length > 1 ? (idx / (scale.steps.length - 1)) * 100 : 50;
577
+ }
578
+
579
+ function computeDerivedColor(step: Step, base: string, config: ScaleConfig, scaleTitle: string): string {
580
+ const { l: baseL, c: baseC, h: baseH } = hexToOklch(base);
581
+ const scale = scales.find(s => s.title === scaleTitle)!;
582
+ const xPos = scaleStepToX(step, scale);
583
+
584
+ const lCurve = scaleCurves[scaleTitle]?.lightness ?? [];
585
+ const sCurve = scaleCurves[scaleTitle]?.saturation ?? [];
586
+ const lKey = getScaleCurveKey(scaleTitle, 'lightness');
587
+ const sKey = getScaleCurveKey(scaleTitle, 'saturation');
588
+ const lOff = curveOffset[lKey] ?? 0;
589
+ const sOff = curveOffset[sKey] ?? 0;
590
+
591
+ let targetL: number;
592
+ if (scale.isText) {
593
+ // Text: lightness curve is a multiplier (100 = 1x base lightness)
594
+ const lMul = Math.max(0, Math.min(2, (sampleCurve(lCurve, xPos) + lOff) / 100));
595
+ targetL = Math.max(0, Math.min(1, baseL * lMul));
596
+ } else {
597
+ targetL = Math.max(0, Math.min(100, sampleCurve(lCurve, xPos) + lOff)) / 100;
598
+ }
599
+
600
+ const satMul = Math.max(0, Math.min(2, (sampleCurve(sCurve, xPos) + sOff) / 100));
601
+ const targetC = baseC * satMul;
602
+
603
+ const clamped = gamutClamp(targetL, targetC, baseH);
604
+ return oklchToHex(clamped.l, clamped.c, clamped.h);
605
+ }
606
+
607
+ // --- Interaction handlers ---
608
+
609
+ function handleColorChange(hex: string) {
610
+ if (isEditingBase) {
611
+ // Gray mode's base is derived from tintHue/tintChroma (onHueChromaChange
612
+ // writes those). The raw hex path only applies to chromatic palettes.
613
+ if (mode === 'chromatic') edit('baseColor', hex);
614
+ return;
615
+ }
616
+ if (editing.kind === 'editingStep') {
617
+ editing = { ...editing, draft: hex };
618
+ if (editing.stepKey in overrides) {
619
+ edit('overrides', { ...overrides, [editing.stepKey]: hex });
620
+ }
621
+ }
622
+ }
623
+
624
+ function resetOverride(k: string) {
625
+ if (!(k in overrides)) return;
626
+ const { [k]: _, ...rest } = overrides;
627
+ edit('overrides', rest);
628
+ }
629
+
630
+ function handleOverrideClick(k: string, step: Step, scaleTitle: string) {
631
+ if (editingKey === k) {
632
+ confirmEdit();
633
+ return;
634
+ }
635
+ const current = (k in overrides) ? overrides[k] : computeDerivedColor(step, gray500Hex, configForScale(scaleTitle), scaleTitle);
636
+ editing = { kind: 'editingStep', stepKey: k, snapshot: current, draft: current };
637
+ paletteEditScope = beginScope({ label: 'palette session', collapseToOne: true, clipUndoFloor: true });
638
+ }
639
+
640
+ function handleGrayClick(gStep: GrayStep, index: number) {
641
+ const k = grayStepKey(gStep.label);
642
+ if (editingKey === k) {
643
+ confirmEdit();
644
+ return;
645
+ }
646
+ const current = (k in overrides) ? overrides[k] : computeGrayColor(index, tintHue, tintChroma);
647
+ editing = { kind: 'editingStep', stepKey: k, snapshot: current, draft: current };
648
+ paletteEditScope = beginScope({ label: 'palette session', collapseToOne: true, clipUndoFloor: true });
649
+ }
650
+
651
+ async function confirmEdit() {
652
+ if (editingKey) {
653
+ // Accumulate all override changes into one patch so the session commit
654
+ // sees a single final state (no intermediate reactive round-trips).
655
+ let nextOverrides = { ...overrides };
656
+ if (editingDraft !== null) {
657
+ const computed = computedValueForKey(editingKey);
658
+ if (computed !== null && editingDraft !== computed) {
659
+ nextOverrides[editingKey] = editingDraft;
660
+ } else if (computed !== null && editingKey in nextOverrides && editingDraft === computed) {
661
+ delete nextOverrides[editingKey];
662
+ }
663
+ }
664
+ for (const scale of scales.filter(s => !s.isText)) {
665
+ if (snappedScales.has(scale.title) && scale.steps.some(s => stepKey(scale.title, s.name) === editingKey)) {
666
+ const assigned = snapScaleToPalette(scale);
667
+ nextOverrides = { ...nextOverrides, ...assigned };
668
+ break;
669
+ }
670
+ }
671
+ if (JSON.stringify(nextOverrides) !== JSON.stringify(overrides)) {
672
+ edit('overrides', nextOverrides);
673
+ }
674
+ }
675
+ editing = idleState;
676
+ // tick() lets the store-derived reactives flush before session commit
677
+ await tick();
678
+ if (paletteEditScope) { commitScope(paletteEditScope); paletteEditScope = null; }
679
+ }
680
+
681
+ function cancelEdit() {
682
+ editing = idleState;
683
+ // Restoring the session snapshot in the store fires the sync reactive,
684
+ // which pulls baseColor/tintHue/tintChroma/overrides/… back to pre-open.
685
+ if (paletteEditScope) { cancelScope(paletteEditScope); paletteEditScope = null; }
686
+ }
687
+
688
+ function removeOverride(k: string) {
689
+ const { [k]: _, ...rest } = overrides;
690
+ edit('overrides', rest);
691
+ if (editingKey === k) { editing = idleState; }
692
+ }
693
+
694
+ function computedValueForKey(key: string): string | null {
695
+ const ps = paletteStepLightness.find(p => paletteStepKey(p.label) === key);
696
+ if (ps) {
697
+ const idx = paletteStepLightness.indexOf(ps);
698
+ return computePaletteColor(idx, baseColor);
699
+ }
700
+ const gi = graySteps.findIndex(g => grayStepKey(g.label) === key);
701
+ if (gi >= 0) return computeGrayColor(gi, tintHue, tintChroma);
702
+ for (const scale of scales) {
703
+ for (const step of scale.steps) {
704
+ if (stepKey(scale.title, step.name) === key) {
705
+ return computeDerivedColor(step, gray500Hex, configForScale(scale.title), scale.title);
706
+ }
707
+ }
708
+ }
709
+ return null;
710
+ }
711
+
712
+ function effectiveColor(k: string, step: Step, scaleTitle: string, _version?: string): string {
713
+ if (editingKey === k && editingDraft !== null) return editingDraft;
714
+ return (k in overrides) ? overrides[k] : computeDerivedColor(step, gray500Hex, configForScale(scaleTitle), scaleTitle);
715
+ }
716
+
717
+ // --- Brightness/Saturation gradient helpers for scale editor ---
718
+
719
+ function lightnessGrad(base: string): string {
720
+ const { c, h } = hexToOklch(base);
721
+ const points: string[] = [];
722
+ for (let i = 0; i <= 8; i++) {
723
+ const l = i / 8;
724
+ const clamped = gamutClamp(l, c, h);
725
+ points.push(`${oklchToHex(clamped.l, clamped.c, clamped.h)} ${Math.round((i / 8) * 100)}%`);
726
+ }
727
+ return `linear-gradient(to right, ${points.join(', ')})`;
728
+ }
729
+
730
+ function saturationGrad(base: string): string {
731
+ const { l, c, h } = hexToOklch(base);
732
+ const midL = Math.max(0.3, Math.min(0.7, l));
733
+ const points: string[] = [];
734
+ for (let i = 0; i <= 8; i++) {
735
+ const scale = (i / 8) * 2;
736
+ const targetC = c * scale;
737
+ const clamped = gamutClamp(midL, targetC, h);
738
+ points.push(`${oklchToHex(clamped.l, clamped.c, clamped.h)} ${Math.round((i / 8) * 100)}%`);
739
+ }
740
+ return `linear-gradient(to right, ${points.join(', ')})`;
741
+ }
742
+
743
+ let copiedKey: string | null = null;
744
+ function copyHex(k: string, hex: string, event?: MouseEvent) {
745
+ navigator.clipboard.writeText(hex);
746
+ copiedKey = k;
747
+ showCopyPopover(hex, event?.currentTarget ?? null);
748
+ setTimeout(() => { copiedKey = null; }, 1500);
749
+ }
750
+
751
+ let copiedLabelKey: string | null = null;
752
+ function copyVarName(k: string, varName: string, event?: MouseEvent) {
753
+ navigator.clipboard.writeText(varName);
754
+ copiedLabelKey = k;
755
+ showCopyPopover(varName, event?.currentTarget ?? null);
756
+ setTimeout(() => { copiedLabelKey = null; }, 1500);
757
+ }
758
+
759
+ // --- Snap-all: constrain an entire scale to unique palette steps ---
760
+
761
+ function snapScaleToPalette(scale: Scale): Record<string, string> {
762
+ const cfg = configForScale(scale.title);
763
+ const n = scale.steps.length;
764
+
765
+ const stepL = scale.steps.map(step => {
766
+ const derived = computeDerivedColor(step, baseColor, cfg, scale.title);
767
+ return hexToOklch(derived).l;
768
+ });
769
+
770
+ const palL = paletteComputed.map(ps => hexToOklch(ps.hex).l);
771
+
772
+ const palDarkFirst = [...paletteComputed].reverse();
773
+ const palLDarkFirst = [...palL].reverse();
774
+
775
+ let bestStart = 0;
776
+ let bestCost = Infinity;
777
+ for (let start = 0; start <= palDarkFirst.length - n; start++) {
778
+ let cost = 0;
779
+ for (let i = 0; i < n; i++) {
780
+ const d = stepL[i] - palLDarkFirst[start + i];
781
+ cost += d * d;
782
+ }
783
+ if (cost < bestCost) {
784
+ bestCost = cost;
785
+ bestStart = start;
786
+ }
787
+ }
788
+
789
+ const assigned: Record<string, string> = {};
790
+ for (let i = 0; i < n; i++) {
791
+ const k = stepKey(scale.title, scale.steps[i].name);
792
+ assigned[k] = palDarkFirst[bestStart + i].hex;
793
+ }
794
+ return assigned;
795
+ }
796
+
797
+ function toggleSnapAll(scale: Scale) {
798
+ if (snappedScales.has(scale.title)) {
799
+ snapPickerKey = null;
800
+ const nextOverrides = { ...overrides };
801
+ for (const step of scale.steps) {
802
+ delete nextOverrides[stepKey(scale.title, step.name)];
803
+ }
804
+ const nextSnapped = [...snappedScales].filter(s => s !== scale.title);
805
+ patchPalette({ snappedScales: nextSnapped, overrides: nextOverrides }, 'unsnap scale');
806
+ if (editingKey && scale.steps.some(s => stepKey(scale.title, s.name) === editingKey)) {
807
+ editing = idleState;
808
+ }
809
+ } else {
810
+ const assigned = snapScaleToPalette(scale);
811
+ const nextSnapped = [...snappedScales, scale.title];
812
+ patchPalette({ snappedScales: nextSnapped, overrides: { ...overrides, ...assigned } }, 'snap scale');
813
+ }
814
+ }
815
+
816
+ function clearPaletteOverrides() {
817
+ const next = { ...overrides };
818
+ if (mode === 'gray') {
819
+ for (const step of graySteps) delete next[grayStepKey(step.label)];
820
+ } else {
821
+ for (const ps of paletteStepLightness) delete next[paletteStepKey(ps.label)];
822
+ }
823
+ edit('overrides', next);
824
+ }
825
+
826
+ function scaleHasOverrides(scale: Scale): boolean {
827
+ return scale.steps.some(s => stepKey(scale.title, s.name) in overrides);
828
+ }
829
+
830
+ function clearScaleOverrides(scale: Scale) {
831
+ snapPickerKey = null;
832
+ const nextOverrides = { ...overrides };
833
+ for (const step of scale.steps) {
834
+ delete nextOverrides[stepKey(scale.title, step.name)];
835
+ }
836
+ const nextSnapped = [...snappedScales].filter(s => s !== scale.title);
837
+ patchPalette({ snappedScales: nextSnapped, overrides: nextOverrides }, 'clear scale overrides');
838
+ if (editingKey && scale.steps.some(s => stepKey(scale.title, s.name) === editingKey)) {
839
+ editing = idleState;
840
+ }
841
+ }
842
+
843
+ let snapPickerKey: string | null = null;
844
+
845
+ function handleDocClick(e: MouseEvent) {
846
+ if (!snapPickerKey) return;
847
+ const target = e.target as HTMLElement;
848
+ if (target.closest('.override-slot-wrapper')) return;
849
+ snapPickerKey = null;
850
+ }
851
+ onMount(() => document.addEventListener('click', handleDocClick, true));
852
+ onDestroy(() => document.removeEventListener('click', handleDocClick, true));
853
+
854
+ function selectSnapValue(k: string, paletteHex: string, _scaleTitle: string) {
855
+ edit('overrides', { ...overrides, [k]: paletteHex });
856
+ snapPickerKey = null;
857
+ }
858
+
859
+ function handleSnappedClick(k: string) {
860
+ if (snapPickerKey === k) {
861
+ snapPickerKey = null;
862
+ } else {
863
+ snapPickerKey = k;
864
+ }
865
+ }
866
+
867
+ function resnapScales() {
868
+ if (snappedScales.size === 0) return;
869
+ let changed = false;
870
+ const next = { ...overrides };
871
+ for (const scale of scales.filter(s => !s.isText)) {
872
+ if (!snappedScales.has(scale.title)) continue;
873
+ const assigned = snapScaleToPalette(scale);
874
+ for (const [k, hex] of Object.entries(assigned)) {
875
+ if (next[k] !== hex) {
876
+ next[k] = hex;
877
+ changed = true;
878
+ }
879
+ }
880
+ }
881
+ if (changed) edit('overrides', next);
882
+ }
883
+
884
+ $: baseColor, scaleCurves, lightnessCurve, saturationCurve, curveOffset, snappedScales, resnapScales();
885
+
886
+ // CSS-var emission lives in `paletteDerivation` → `editorRenderer`; the store
887
+ // is the single source of truth for palette config and the renderer
888
+ // subscription writes the derived `--color-*` / `--surface-*` / `--border-*`
889
+ // / `--text-*` / `--page-bg` variables to :root.
890
+
891
+ // --- Load external config ---
892
+ //
893
+ // External file loads come through editorStore.loadFromFile, which
894
+ // overwrites $editorState.palettes — no component-side mirroring needed.
895
+ // This export is kept only for callers that want to push a config in
896
+ // directly.
897
+ export function loadConfig(config: PaletteConfig) {
898
+ setPaletteConfig(label, config);
899
+ }
900
+
901
+
902
+ </script>
903
+
904
+ <div class="palette-editor" style="--editor-base: {mode === 'gray' ? gray500Hex : baseColor}">
905
+ <PaletteBase
906
+ {label}
907
+ {displayLabel}
908
+ {mode}
909
+ {baseColor}
910
+ {gray500Hex}
911
+ {tintHue}
912
+ {tintChroma}
913
+ {anchorToBase}
914
+ {isEditingBase}
915
+ {panelOpen}
916
+ {editingColor}
917
+ {editPanelTitle}
918
+ {copiedKey}
919
+ onStartEdit={startBaseEdit}
920
+ onConfirm={confirmEdit}
921
+ onCancel={cancelEdit}
922
+ onColorChange={handleColorChange}
923
+ onTintChange={(h, c) => patchPalette({ tintHue: h, tintChroma: c }, 'tint hue/chroma')}
924
+ onAnchorToBaseChange={setAnchorToBase}
925
+ onCopyBaseHex={copyHex}
926
+ />
927
+
928
+ {#if mode === 'chromatic'}
929
+ <!-- Palette + Text row -->
930
+ <div class="scales-row">
931
+ <div class="scale-section">
932
+ <div class="scale-header">
933
+ <h4 class="scale-title">Palette</h4>
934
+ {#if emptySelector}
935
+ <label class="empty-mode-toggle">
936
+ <input
937
+ type="checkbox"
938
+ checked={emptyMode === 'gradient'}
939
+ on:change={onEmptyModeChange}
940
+ />
941
+ <span>Gradient</span>
942
+ </label>
943
+ {/if}
944
+ <button class="edit-toggle" type="button" on:click={clearPaletteOverrides}>Clear Overrides</button>
945
+ <button
946
+ class="edit-toggle"
947
+ type="button"
948
+ on:click={() => paletteEditorOpen = !paletteEditorOpen}
949
+ >{paletteEditorOpen ? 'Close' : 'Edit'}</button>
950
+ </div>
951
+ <div class="swatch-grid" style="--swatch-cols: {paletteStepLightness.length + 2}">
952
+ <div class="step-column">
953
+ <button class="step-label copyable-label" class:copied={copiedLabelKey === 'palette-white'} type="button" on:click={(e) => copyVarName('palette-white', `--color-${cssNamespace}-white`, e)}>
954
+ {copiedLabelKey === 'palette-white' ? 'copied!' : 'white'}
955
+ </button>
956
+ <div class="swatch gray-swatch bookend" style="background: #ffffff"></div>
957
+ </div>
958
+ {#each paletteComputed as ps}
959
+ <div class="step-column">
960
+ <button class="step-label copyable-label" class:copied={copiedLabelKey === ps.key} type="button" on:click={(e) => copyVarName(ps.key, `--color-${cssNamespace}-${ps.label}`, e)}>
961
+ {copiedLabelKey === ps.key ? 'copied!' : ps.label}
962
+ </button>
963
+ <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
964
+ <div
965
+ class="swatch gray-swatch"
966
+ class:active={editingKey === ps.key}
967
+ class:overridden={ps.key in overrides}
968
+ style="background: {ps.effective}"
969
+ on:click={() => handlePaletteClick({ label: ps.label, lightness: ps.lightness, index: ps.index })}
970
+ role="button"
971
+ tabindex="0"
972
+ on:keydown={(e) => e.key === 'Enter' && handlePaletteClick({ label: ps.label, lightness: ps.lightness, index: ps.index })}
973
+ >
974
+ {#if ps.key in overrides}
975
+ <span class="override-dot" title="Palette override"></span>
976
+ {/if}
977
+ {#if emptySelector && emptyMode === 'solid'}
978
+ <input
979
+ type="checkbox"
980
+ class="empty-check"
981
+ checked={emptyStep === ps.label}
982
+ on:click|stopPropagation={() => edit('emptyStep', ps.label)}
983
+ on:keydown|stopPropagation
984
+ title="Page background"
985
+ />
986
+ {/if}
987
+ </div>
988
+ <button
989
+ class="step-hex"
990
+ class:copied={copiedKey === ps.key}
991
+ type="button"
992
+ on:click={(e) => copyHex(ps.key, ps.effective, e)}
993
+ >{copiedKey === ps.key ? 'copied!' : ps.effective}</button>
994
+ </div>
995
+ {/each}
996
+ <div class="step-column">
997
+ <button class="step-label copyable-label" class:copied={copiedLabelKey === 'palette-black'} type="button" on:click={(e) => copyVarName('palette-black', `--color-${cssNamespace}-black`, e)}>
998
+ {copiedLabelKey === 'palette-black' ? 'copied!' : 'black'}
999
+ </button>
1000
+ <div class="swatch gray-swatch bookend" style="background: #000000"></div>
1001
+ </div>
1002
+ {#if paletteEditorOpen}
1003
+ <div class="curve-grid-span" style="grid-column: 2 / {paletteStepLightness.length + 2}">
1004
+ <ScaleCurveEditor
1005
+ curveKey="lightness"
1006
+ anchors={lightnessCurve}
1007
+ cfg={lightnessCurveConfig}
1008
+ stepCount={paletteStepLightness.length}
1009
+ defaults={DEFAULT_PALETTE_LIGHTNESS()}
1010
+ offset={curveOffset['lightness'] ?? 0}
1011
+ lockedAnchorIndex={lockedLightnessIdx}
1012
+ onAnchorsChange={setLightnessCurve}
1013
+ onOffsetChange={handleOffset}
1014
+ />
1015
+ <ScaleCurveEditor
1016
+ curveKey="saturation"
1017
+ anchors={saturationCurve}
1018
+ cfg={saturationCurveConfig}
1019
+ stepCount={paletteStepLightness.length}
1020
+ defaults={DEFAULT_PALETTE_SATURATION()}
1021
+ offset={curveOffset['saturation'] ?? 0}
1022
+ lockedAnchorIndex={lockedSaturationIdx}
1023
+ onAnchorsChange={setSaturationCurve}
1024
+ onOffsetChange={handleOffset}
1025
+ />
1026
+ </div>
1027
+ {/if}
1028
+ </div>
1029
+
1030
+ {#if emptySelector && emptyMode === 'gradient'}
1031
+ <GradientStopEditor
1032
+ {gradientStyle}
1033
+ {gradientAngle}
1034
+ {gradientSize}
1035
+ {gradientReverse}
1036
+ {gradientStops}
1037
+ {gradientBarPreview}
1038
+ {paletteComputed}
1039
+ onSetGradientStyle={(v) => edit('gradientStyle', v)}
1040
+ onSetGradientSize={(v) => edit('gradientSize', v)}
1041
+ onSetGradientAngle={(v) => edit('gradientAngle', v)}
1042
+ onSetGradientReverse={(v) => edit('gradientReverse', v)}
1043
+ onSetGradientStops={(v) => edit('gradientStops', v)}
1044
+ />
1045
+ {/if}
1046
+ </div>
1047
+
1048
+ </div>
1049
+
1050
+ <button class="derived-toggle" type="button" on:click={() => showDerived = !showDerived}>
1051
+ <i class="fas" class:fa-chevron-right={!showDerived} class:fa-chevron-down={showDerived}></i>
1052
+ <span>Text, Surfaces &amp; Borders</span>
1053
+ </button>
1054
+
1055
+ {#if showDerived}
1056
+ <div class="scales-row">
1057
+ {#each scales.filter(s => s.isText) as scale}
1058
+ <OverridesPanel
1059
+ {scale}
1060
+ editorOpen={scaleEditorOpen[scale.title] ?? false}
1061
+ snapped={snappedScales.has(scale.title)}
1062
+ supportsSnap={true}
1063
+ {cssNamespace}
1064
+ {scaleCurves}
1065
+ {curveOffset}
1066
+ {defaultScaleCurves}
1067
+ {overrides}
1068
+ {editingKey}
1069
+ {snapPickerKey}
1070
+ {copiedKey}
1071
+ {copiedLabelKey}
1072
+ {paletteComputed}
1073
+ derivedHexFor={derivedHexForBase}
1074
+ effectiveHexFor={effectiveHexAny}
1075
+ stepKeyFor={stepKey}
1076
+ scaleCurveKeyFor={getScaleCurveKey}
1077
+ onToggleSnap={toggleSnapAll}
1078
+ onClearScaleOverrides={clearScaleOverrides}
1079
+ onToggleEditor={toggleScaleEditor}
1080
+ onResetOverride={resetOverride}
1081
+ onOverrideClick={handleOverrideClick}
1082
+ onSnappedClick={handleSnappedClick}
1083
+ onSelectSnapValue={selectSnapValue}
1084
+ onCopyHex={copyHex}
1085
+ onCopyVarName={copyVarName}
1086
+ onSetScaleCurve={setScaleCurve}
1087
+ onOffsetChange={handleOffset}
1088
+ />
1089
+ {/each}
1090
+ </div>
1091
+
1092
+ <!-- Surfaces & Borders — per-scale editors -->
1093
+ <div class="scales-row">
1094
+ {#each scales.filter(s => !s.isText) as scale}
1095
+ <OverridesPanel
1096
+ {scale}
1097
+ editorOpen={scaleEditorOpen[scale.title] ?? false}
1098
+ snapped={snappedScales.has(scale.title)}
1099
+ supportsSnap={true}
1100
+ {cssNamespace}
1101
+ {scaleCurves}
1102
+ {curveOffset}
1103
+ {defaultScaleCurves}
1104
+ {overrides}
1105
+ {editingKey}
1106
+ {snapPickerKey}
1107
+ {copiedKey}
1108
+ {copiedLabelKey}
1109
+ {paletteComputed}
1110
+ derivedHexFor={derivedHexForBase}
1111
+ effectiveHexFor={effectiveHexAny}
1112
+ stepKeyFor={stepKey}
1113
+ scaleCurveKeyFor={getScaleCurveKey}
1114
+ onToggleSnap={toggleSnapAll}
1115
+ onClearScaleOverrides={clearScaleOverrides}
1116
+ onToggleEditor={toggleScaleEditor}
1117
+ onResetOverride={resetOverride}
1118
+ onOverrideClick={handleOverrideClick}
1119
+ onSnappedClick={handleSnappedClick}
1120
+ onSelectSnapValue={selectSnapValue}
1121
+ onCopyHex={copyHex}
1122
+ onCopyVarName={copyVarName}
1123
+ onSetScaleCurve={setScaleCurve}
1124
+ onOffsetChange={handleOffset}
1125
+ />
1126
+ {/each}
1127
+ </div>
1128
+ {/if}
1129
+
1130
+ {:else}
1131
+ <!-- Gray mode: palette + text row -->
1132
+ <div class="scales-row">
1133
+ <div class="scale-section">
1134
+ <div class="scale-header">
1135
+ <h4 class="scale-title">{displayLabel ?? label}</h4>
1136
+ <button class="edit-toggle" type="button" on:click={clearPaletteOverrides}>Clear Overrides</button>
1137
+ <button
1138
+ class="edit-toggle"
1139
+ type="button"
1140
+ on:click={() => grayEditorOpen = !grayEditorOpen}
1141
+ >{grayEditorOpen ? 'Close' : 'Edit'}</button>
1142
+ </div>
1143
+ <div class="swatch-grid" style="--swatch-cols: {graySteps.length + 2}">
1144
+ <div class="step-column">
1145
+ <button class="step-label copyable-label" class:copied={copiedLabelKey === 'gray-white'} type="button" on:click={(e) => copyVarName('gray-white', `--color-${cssNamespace}-white`, e)}>
1146
+ {copiedLabelKey === 'gray-white' ? 'copied!' : 'white'}
1147
+ </button>
1148
+ <div class="swatch gray-swatch bookend" style="background: #ffffff"></div>
1149
+ </div>
1150
+ {#each grayEffective as g}
1151
+ <div class="step-column">
1152
+ <button class="step-label copyable-label" class:copied={copiedLabelKey === g.key} type="button" on:click={(e) => copyVarName(g.key, `--color-${cssNamespace}-${g.step.label}`, e)}>
1153
+ {copiedLabelKey === g.key ? 'copied!' : g.step.label}
1154
+ </button>
1155
+ <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
1156
+ <div
1157
+ class="swatch gray-swatch"
1158
+ class:active={editingKey === g.key}
1159
+ class:overridden={g.key in overrides}
1160
+ style="background: {g.effective}"
1161
+ on:click={() => handleGrayClick(g.step, g.index)}
1162
+ role="button"
1163
+ tabindex="0"
1164
+ on:keydown={(e) => e.key === 'Enter' && handleGrayClick(g.step, g.index)}
1165
+ >
1166
+ {#if g.key in overrides}
1167
+ <span class="override-dot" title="Palette override"></span>
1168
+ {/if}
1169
+ </div>
1170
+ <button
1171
+ class="step-hex"
1172
+ class:copied={copiedKey === g.key}
1173
+ type="button"
1174
+ on:click={(e) => copyHex(g.key, g.effective, e)}
1175
+ >{copiedKey === g.key ? 'copied!' : g.effective}</button>
1176
+ </div>
1177
+ {/each}
1178
+ <div class="step-column">
1179
+ <button class="step-label copyable-label" class:copied={copiedLabelKey === 'gray-black'} type="button" on:click={(e) => copyVarName('gray-black', `--color-${cssNamespace}-black`, e)}>
1180
+ {copiedLabelKey === 'gray-black' ? 'copied!' : 'black'}
1181
+ </button>
1182
+ <div class="swatch gray-swatch bookend" style="background: #000000"></div>
1183
+ </div>
1184
+ {#if grayEditorOpen}
1185
+ <div class="curve-grid-span" style="grid-column: 2 / {graySteps.length + 2}">
1186
+ <ScaleCurveEditor
1187
+ curveKey="gray-lightness"
1188
+ anchors={grayLightnessCurve}
1189
+ cfg={lightnessCurveConfig}
1190
+ stepCount={graySteps.length}
1191
+ defaults={DEFAULT_GRAY_LIGHTNESS()}
1192
+ offset={curveOffset['gray-lightness'] ?? 0}
1193
+ onAnchorsChange={setGrayLightnessCurve}
1194
+ onOffsetChange={handleOffset}
1195
+ />
1196
+ <ScaleCurveEditor
1197
+ curveKey="gray-saturation"
1198
+ anchors={graySaturationCurve}
1199
+ cfg={saturationCurveConfig}
1200
+ stepCount={graySteps.length}
1201
+ defaults={DEFAULT_GRAY_SATURATION()}
1202
+ offset={curveOffset['gray-saturation'] ?? 0}
1203
+ onAnchorsChange={setGraySaturationCurve}
1204
+ onOffsetChange={handleOffset}
1205
+ />
1206
+ </div>
1207
+ {/if}
1208
+ </div>
1209
+ </div>
1210
+ </div>
1211
+
1212
+ <button class="derived-toggle" type="button" on:click={() => showDerived = !showDerived}>
1213
+ <i class="fas" class:fa-chevron-right={!showDerived} class:fa-chevron-down={showDerived}></i>
1214
+ <span>Text, Surfaces &amp; Borders</span>
1215
+ </button>
1216
+
1217
+ {#if showDerived}
1218
+ <div class="scales-row">
1219
+ {#each grayScales.filter(s => s.isText) as scale}
1220
+ <OverridesPanel
1221
+ {scale}
1222
+ editorOpen={scaleEditorOpen[scale.title] ?? false}
1223
+ snapped={snappedScales.has(scale.title)}
1224
+ supportsSnap={true}
1225
+ {cssNamespace}
1226
+ {scaleCurves}
1227
+ {curveOffset}
1228
+ {defaultScaleCurves}
1229
+ {overrides}
1230
+ {editingKey}
1231
+ {snapPickerKey}
1232
+ {copiedKey}
1233
+ {copiedLabelKey}
1234
+ {paletteComputed}
1235
+ derivedHexFor={derivedHexForGray}
1236
+ effectiveHexFor={effectiveHexAny}
1237
+ stepKeyFor={stepKey}
1238
+ scaleCurveKeyFor={getScaleCurveKey}
1239
+ onToggleSnap={toggleSnapAll}
1240
+ onClearScaleOverrides={clearScaleOverrides}
1241
+ onToggleEditor={toggleScaleEditor}
1242
+ onResetOverride={resetOverride}
1243
+ onOverrideClick={handleOverrideClick}
1244
+ onSnappedClick={handleSnappedClick}
1245
+ onSelectSnapValue={selectSnapValue}
1246
+ onCopyHex={copyHex}
1247
+ onCopyVarName={copyVarName}
1248
+ onSetScaleCurve={setScaleCurve}
1249
+ onOffsetChange={handleOffset}
1250
+ />
1251
+ {/each}
1252
+ </div>
1253
+ <!-- Surfaces & Borders for gray mode -->
1254
+ <div class="scales-row">
1255
+ {#each grayScales.filter(s => !s.isText) as scale}
1256
+ <OverridesPanel
1257
+ {scale}
1258
+ editorOpen={scaleEditorOpen[scale.title] ?? false}
1259
+ snapped={false}
1260
+ supportsSnap={false}
1261
+ {cssNamespace}
1262
+ {scaleCurves}
1263
+ {curveOffset}
1264
+ {defaultScaleCurves}
1265
+ {overrides}
1266
+ {editingKey}
1267
+ {snapPickerKey}
1268
+ {copiedKey}
1269
+ {copiedLabelKey}
1270
+ {paletteComputed}
1271
+ derivedHexFor={derivedHexForGray}
1272
+ effectiveHexFor={effectiveHexAny}
1273
+ stepKeyFor={stepKey}
1274
+ scaleCurveKeyFor={getScaleCurveKey}
1275
+ onToggleSnap={toggleSnapAll}
1276
+ onClearScaleOverrides={clearScaleOverrides}
1277
+ onToggleEditor={toggleScaleEditor}
1278
+ onResetOverride={resetOverride}
1279
+ onOverrideClick={handleOverrideClick}
1280
+ onSnappedClick={handleSnappedClick}
1281
+ onSelectSnapValue={selectSnapValue}
1282
+ onCopyHex={copyHex}
1283
+ onCopyVarName={copyVarName}
1284
+ onSetScaleCurve={setScaleCurve}
1285
+ onOffsetChange={handleOffset}
1286
+ />
1287
+ {/each}
1288
+ </div>
1289
+ {/if}
1290
+ {/if}
1291
+
1292
+ <!-- Color Edit Panel (non-base edits) -->
1293
+ {#if !isEditingBase && panelOpen && editingColor}
1294
+ <ColorEditPanel
1295
+ color={editingColor}
1296
+ title={editPanelTitle}
1297
+ showRemoveOverride={!!editingKey}
1298
+ mode={'hsl'}
1299
+ hue={tintHue}
1300
+ chroma={tintChroma}
1301
+ onHueChromaChange={(h, c) => patchPalette({ tintHue: h, tintChroma: c }, 'tint hue/chroma')}
1302
+ onColorChange={handleColorChange}
1303
+ onConfirm={confirmEdit}
1304
+ onCancel={cancelEdit}
1305
+ onRemoveOverride={() => editingKey && removeOverride(editingKey)}
1306
+ onSliderStart={() => beginSliderGesture(`edit ${label} ${editingKey ?? 'color'}`)}
1307
+ />
1308
+ {/if}
1309
+ </div>
1310
+
1311
+ <style>
1312
+ .palette-editor {
1313
+ display: flex;
1314
+ flex-direction: column;
1315
+ gap: var(--ui-space-20);
1316
+ padding: var(--ui-space-16) var(--ui-space-16) var(--ui-space-24);
1317
+ background: none;
1318
+ border: none;
1319
+ border-bottom: 1px solid var(--ui-border-faint);
1320
+ font-family: var(--ui-font-sans);
1321
+ min-width: 0;
1322
+ }
1323
+
1324
+ .palette-editor:last-child {
1325
+ border-bottom: none;
1326
+ }
1327
+
1328
+ /* Scale header with edit button */
1329
+
1330
+ .scale-header {
1331
+ display: flex;
1332
+ align-items: center;
1333
+ gap: var(--ui-space-8);
1334
+ }
1335
+
1336
+ .edit-toggle {
1337
+ font-size: var(--ui-font-size-md);
1338
+ color: var(--ui-text-tertiary);
1339
+ background: none;
1340
+ border: 1px solid var(--ui-border-subtle);
1341
+ border-radius: var(--ui-radius-sm);
1342
+ padding: var(--ui-space-2) var(--ui-space-6);
1343
+ cursor: pointer;
1344
+ }
1345
+
1346
+ .edit-toggle:hover {
1347
+ color: var(--ui-text-primary);
1348
+ border-color: var(--ui-border-medium);
1349
+ }
1350
+
1351
+ .derived-toggle {
1352
+ display: flex;
1353
+ align-items: center;
1354
+ gap: var(--ui-space-8);
1355
+ padding: var(--ui-space-6) var(--ui-space-4);
1356
+ background: none;
1357
+ border: none;
1358
+ color: var(--ui-text-tertiary);
1359
+ font-size: var(--ui-font-size-sm);
1360
+ font-weight: var(--ui-font-weight-semibold);
1361
+ cursor: pointer;
1362
+ transition: color var(--ui-transition-fast);
1363
+ text-transform: uppercase;
1364
+ letter-spacing: 0.04em;
1365
+ }
1366
+
1367
+ .derived-toggle:hover {
1368
+ color: var(--ui-text-secondary);
1369
+ }
1370
+
1371
+ .derived-toggle i {
1372
+ font-size: var(--ui-font-size-xs);
1373
+ width: 0.75rem;
1374
+ text-align: center;
1375
+ }
1376
+
1377
+ /* Scale layout */
1378
+
1379
+ .scales-row {
1380
+ display: flex;
1381
+ gap: 3rem;
1382
+ flex-wrap: wrap;
1383
+ min-width: 0;
1384
+ }
1385
+
1386
+ .scale-section {
1387
+ display: flex;
1388
+ flex-direction: column;
1389
+ gap: var(--ui-space-6);
1390
+ min-width: 0;
1391
+ max-width: 100%;
1392
+ }
1393
+
1394
+ .scale-title {
1395
+ font-size: var(--ui-font-size-md);
1396
+ font-weight: var(--ui-font-weight-semibold);
1397
+ color: var(--ui-text-tertiary);
1398
+ margin: 0;
1399
+ text-transform: uppercase;
1400
+ letter-spacing: 0.05em;
1401
+ }
1402
+
1403
+ /* Step columns */
1404
+
1405
+ .step-column {
1406
+ display: flex;
1407
+ flex-direction: column;
1408
+ align-items: stretch;
1409
+ justify-self: stretch;
1410
+ gap: var(--ui-space-2);
1411
+ width: auto;
1412
+ min-width: 0;
1413
+ overflow: visible;
1414
+ }
1415
+
1416
+ .step-label {
1417
+ font-size: var(--ui-font-size-sm);
1418
+ color: var(--ui-text-secondary);
1419
+ text-align: center;
1420
+ line-height: 1;
1421
+ height: var(--ui-font-size-xs);
1422
+ display: flex;
1423
+ align-items: flex-end;
1424
+ }
1425
+
1426
+ .step-label.copyable-label {
1427
+ background: none;
1428
+ border: none;
1429
+ padding: 0;
1430
+ cursor: pointer;
1431
+ font: inherit;
1432
+ font-size: var(--ui-font-size-sm);
1433
+ justify-content: center;
1434
+ transition: color var(--ui-transition-fast);
1435
+ }
1436
+
1437
+ .step-label.copyable-label:hover {
1438
+ color: var(--ui-text-primary);
1439
+ }
1440
+
1441
+ .step-label.copyable-label.copied {
1442
+ color: var(--ui-text-accent, var(--ui-text-primary));
1443
+ }
1444
+
1445
+ /* Swatches */
1446
+
1447
+ .swatch {
1448
+ width: 100%;
1449
+ height: 2rem;
1450
+ border-radius: var(--ui-radius-sm);
1451
+ border: 1px solid var(--ui-border-faint);
1452
+ }
1453
+
1454
+ /* Step hex values */
1455
+
1456
+ .step-hex {
1457
+ font-size: var(--ui-font-size-xs);
1458
+ color: var(--ui-text-secondary);
1459
+ font-family: var(--ui-font-mono);
1460
+ cursor: pointer;
1461
+ padding: 1px var(--ui-space-2);
1462
+ border-radius: var(--ui-radius-sm);
1463
+ white-space: nowrap;
1464
+ background: none;
1465
+ border: none;
1466
+ text-align: center;
1467
+ min-width: 0;
1468
+ overflow: hidden;
1469
+ text-overflow: ellipsis;
1470
+ }
1471
+
1472
+ .step-hex:hover {
1473
+ background: var(--ui-surface-highest);
1474
+ color: var(--ui-text-primary);
1475
+ }
1476
+
1477
+ .step-hex.copied {
1478
+ color: var(--ui-text-accent);
1479
+ }
1480
+
1481
+ /* Swatch grid */
1482
+
1483
+ .swatch-grid {
1484
+ display: grid;
1485
+ grid-template-columns: repeat(var(--swatch-cols), minmax(0, 1fr));
1486
+ gap: var(--ui-space-4) var(--swatch-gap, var(--ui-space-4));
1487
+ align-items: start;
1488
+ justify-content: start;
1489
+ min-width: 0;
1490
+ max-width: calc(var(--swatch-cols) * 4rem + (var(--swatch-cols) - 1) * var(--swatch-gap, var(--ui-space-4)));
1491
+ }
1492
+
1493
+ .curve-grid-span {
1494
+ display: flex;
1495
+ flex-direction: column;
1496
+ gap: var(--ui-space-8);
1497
+ }
1498
+
1499
+ .swatch.gray-swatch {
1500
+ width: 100%;
1501
+ height: calc(4rem + var(--ui-space-2));
1502
+ cursor: pointer;
1503
+ position: relative;
1504
+ }
1505
+
1506
+ .swatch.gray-swatch:hover {
1507
+ border-color: var(--ui-border-medium);
1508
+ }
1509
+
1510
+ .swatch.gray-swatch.active {
1511
+ border-color: var(--ui-border-strong);
1512
+ outline: 2px solid var(--ui-border-medium);
1513
+ outline-offset: 1px;
1514
+ }
1515
+
1516
+ .override-dot {
1517
+ position: absolute;
1518
+ top: 3px;
1519
+ right: 3px;
1520
+ width: 6px;
1521
+ height: 6px;
1522
+ border-radius: 50%;
1523
+ background: var(--ui-text-primary);
1524
+ border: 1px solid rgba(255, 255, 255, 0.6);
1525
+ }
1526
+
1527
+ .empty-mode-toggle {
1528
+ display: flex;
1529
+ align-items: center;
1530
+ gap: var(--ui-space-6);
1531
+ font-size: var(--ui-font-size-md);
1532
+ color: var(--ui-text-secondary);
1533
+ cursor: pointer;
1534
+ user-select: none;
1535
+ }
1536
+
1537
+ .empty-mode-toggle input {
1538
+ margin: 0;
1539
+ cursor: pointer;
1540
+ }
1541
+
1542
+ .empty-check {
1543
+ -webkit-appearance: none;
1544
+ appearance: none;
1545
+ position: absolute;
1546
+ bottom: 3px;
1547
+ right: 3px;
1548
+ margin: 0;
1549
+ cursor: pointer;
1550
+ width: 14px;
1551
+ height: 14px;
1552
+ background: white;
1553
+ border: 1px solid rgba(0, 0, 0, 0.3);
1554
+ border-radius: 2px;
1555
+ opacity: 0.5;
1556
+ }
1557
+
1558
+ .empty-check:checked {
1559
+ opacity: 1;
1560
+ }
1561
+
1562
+ .empty-check:checked::after {
1563
+ content: '\2713';
1564
+ display: flex;
1565
+ align-items: center;
1566
+ justify-content: center;
1567
+ width: 100%;
1568
+ height: 100%;
1569
+ font-size: var(--ui-font-size-md);
1570
+ font-weight: bold;
1571
+ color: black;
1572
+ line-height: 1;
1573
+ }
1574
+
1575
+ /* Narrow desktop: tighten palette editor spacing */
1576
+ @media (max-width: 1280px) {
1577
+ .palette-editor {
1578
+ padding: var(--ui-space-12) var(--ui-space-12) var(--ui-space-20);
1579
+ }
1580
+ .scales-row {
1581
+ gap: var(--ui-space-24);
1582
+ }
1583
+ }
1584
+
1585
+ @media (max-width: 1024px) {
1586
+ .scales-row {
1587
+ gap: var(--ui-space-16);
1588
+ }
1589
+ }
1590
+ </style>