@motion-proto/live-tokens 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist-plugin/{chunk-D77VD4Z6.js → chunk-H4TRUINI.js} +102 -13
  3. package/dist-plugin/index.cjs +288 -67
  4. package/dist-plugin/index.js +171 -55
  5. package/dist-plugin/tokensCssMigrations/index.cjs +102 -13
  6. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  7. package/package.json +3 -2
  8. package/src/app/site.css +97 -20
  9. package/src/editor/core/palettes/colorHarmony.ts +228 -0
  10. package/src/editor/core/palettes/contrast.ts +128 -0
  11. package/src/editor/core/palettes/oklch.ts +8 -0
  12. package/src/editor/core/palettes/paletteDerivation.ts +267 -72
  13. package/src/editor/core/palettes/recommendText.ts +216 -0
  14. package/src/editor/core/palettes/solveTextContrast.ts +251 -0
  15. package/src/editor/core/store/editorCore.ts +2 -1
  16. package/src/editor/core/store/editorPersistence.ts +18 -1
  17. package/src/editor/core/store/editorStore.ts +20 -1
  18. package/src/editor/core/store/editorTypes.ts +4 -0
  19. package/src/editor/core/store/editorViewStore.ts +7 -3
  20. package/src/editor/core/themes/migrations/2026-06-05-palette-unification.ts +11 -9
  21. package/src/editor/core/themes/migrations/2026-07-21-palette-oklch-basis.ts +51 -0
  22. package/src/editor/core/themes/migrations/2026-07-24-base-anchor-placement.ts +37 -0
  23. package/src/editor/core/themes/migrations/2026-07-25-background-spot-to-base.ts +40 -0
  24. package/src/editor/core/themes/themeTypes.ts +23 -3
  25. package/src/editor/docs/CodeBlock.svelte +1 -1
  26. package/src/editor/docs/Docs.svelte +30 -13
  27. package/src/editor/pages/EditorShell.svelte +31 -3
  28. package/src/editor/ui/ColorEditPanel.svelte +22 -16
  29. package/src/editor/ui/EditorViewSwitcher.svelte +43 -9
  30. package/src/editor/ui/FontStackEditor.svelte +0 -1
  31. package/src/editor/ui/PaletteEditor.svelte +117 -155
  32. package/src/editor/ui/ProjectFontsSection.svelte +0 -3
  33. package/src/editor/ui/TextStylesSection.svelte +173 -0
  34. package/src/editor/ui/UILineHeightSelector.svelte +6 -5
  35. package/src/editor/ui/UIPaletteSelector.svelte +6 -1
  36. package/src/editor/ui/UITextTransformSelector.svelte +106 -0
  37. package/src/editor/ui/VariablesTab.svelte +23 -10
  38. package/src/editor/ui/colors/ColorReadouts.svelte +123 -0
  39. package/src/editor/ui/colors/ColorStory.svelte +375 -0
  40. package/src/editor/ui/colors/ColorWheel.svelte +740 -0
  41. package/src/editor/ui/colors/ColorsTab.svelte +508 -0
  42. package/src/editor/ui/colors/HarmonyAxesList.svelte +320 -0
  43. package/src/editor/ui/colors/LightnessBar.svelte +261 -0
  44. package/src/editor/ui/colors/PaletteStepStrip.svelte +142 -0
  45. package/src/editor/ui/colors/colorWheelMath.ts +9 -0
  46. package/src/editor/ui/colors/harmony-icons/analogous.svg +13 -0
  47. package/src/editor/ui/colors/harmony-icons/complementary.svg +10 -0
  48. package/src/editor/ui/colors/harmony-icons/compound.svg +15 -0
  49. package/src/editor/ui/colors/harmony-icons/custom.svg +10 -0
  50. package/src/editor/ui/colors/harmony-icons/monochromatic.svg +11 -0
  51. package/src/editor/ui/colors/harmony-icons/shades.svg +9 -0
  52. package/src/editor/ui/colors/harmony-icons/split-complementary.svg +13 -0
  53. package/src/editor/ui/colors/harmony-icons/square.svg +13 -0
  54. package/src/editor/ui/colors/harmony-icons/tetradic.svg +15 -0
  55. package/src/editor/ui/colors/harmony-icons/triadic.svg +13 -0
  56. package/src/editor/ui/colors/harmonyModeIcons.ts +23 -0
  57. package/src/editor/ui/colors/paletteBaseColor.ts +214 -0
  58. package/src/editor/ui/index.ts +0 -1
  59. package/src/editor/ui/palette/OverridesPanel.svelte +6 -3
  60. package/src/editor/ui/palette/PaletteBase.svelte +16 -8
  61. package/src/editor/ui/palette/dockMagnify.ts +22 -0
  62. package/src/editor/ui/palette/paletteEditorState.ts +5 -4
  63. package/src/editor/ui/palette/paletteMath.ts +93 -181
  64. package/src/editor/ui/sections/textStyles.ts +82 -0
  65. package/src/editor/ui/sections/tokenScales.ts +3 -3
  66. package/src/live-tokens/data/themes/default.json +3 -3
  67. package/src/live-tokens/data/tokens.generated.css +96 -95
  68. package/src/system/components/Badge.svelte +11 -11
  69. package/src/system/components/Button.svelte +7 -7
  70. package/src/system/components/Callout.svelte +8 -8
  71. package/src/system/components/Card.svelte +2 -2
  72. package/src/system/components/CodeSnippet.svelte +1 -1
  73. package/src/system/components/CollapsibleSection.svelte +7 -7
  74. package/src/system/components/CornerBadge.svelte +1 -1
  75. package/src/system/components/Dialog.svelte +2 -2
  76. package/src/system/components/FloatingTokenTags.css +6 -5
  77. package/src/system/components/Input.svelte +6 -6
  78. package/src/system/components/MenuSelect.svelte +4 -4
  79. package/src/system/components/Notification.svelte +8 -8
  80. package/src/system/components/ProgressBar.svelte +2 -2
  81. package/src/system/components/RadioButton.svelte +4 -4
  82. package/src/system/components/SectionDivider.svelte +6 -6
  83. package/src/system/components/SegmentedControl.svelte +5 -5
  84. package/src/system/components/SideNavigation.svelte +12 -12
  85. package/src/system/components/TabBar.svelte +4 -4
  86. package/src/system/components/Table.svelte +2 -2
  87. package/src/system/components/Tooltip.svelte +1 -1
  88. package/src/system/styles/tokens.css +59 -6
  89. package/src/editor/ui/TextTab.svelte +0 -203
@@ -0,0 +1,142 @@
1
+ <script lang="ts">
2
+ import { editorState } from '../../core/store/editorStore';
3
+ import {
4
+ PALETTE_STEPS,
5
+ derivePaletteValues,
6
+ serializeDerivedValue,
7
+ type PaletteSpec,
8
+ } from '../../core/palettes/paletteDerivation';
9
+ import { contrastRatio, AA_BODY } from '../../core/palettes/contrast';
10
+ import { defaultPaletteConfig } from '../palette/paletteMath';
11
+ import { dockGrow } from '../palette/dockMagnify';
12
+
13
+ let { spec }: { spec: PaletteSpec } = $props();
14
+
15
+ let config = $derived($editorState.palettes[spec.label]);
16
+ let isGradient = $derived(!!spec.emptySelector && (config?.emptyMode ?? 'solid') === 'gradient');
17
+
18
+ function withContrast(hex: string) {
19
+ const onWhite = contrastRatio(hex, '#ffffff');
20
+ const onBlack = contrastRatio(hex, '#000000');
21
+ const best = Math.max(onWhite, onBlack);
22
+ return { hex, textHex: onWhite >= onBlack ? '#ffffff' : '#000000', best, aa: best >= AA_BODY };
23
+ }
24
+
25
+ let effectiveConfig = $derived(config ?? defaultPaletteConfig({ baseColor: spec.initialColor, neutral: spec.neutral }));
26
+
27
+ let steps = $derived.by(() => {
28
+ const values = derivePaletteValues(spec, effectiveConfig);
29
+ return PALETTE_STEPS.map((ps) => ({
30
+ label: ps.label,
31
+ ...withContrast(serializeDerivedValue(values[`--color-${spec.cssNamespace}-${ps.label}`])),
32
+ }));
33
+ });
34
+
35
+ // Dock magnification, mirroring PaletteEditor's swatch grid: the flex-grow
36
+ // transition animates the walk as lightness is dialed.
37
+ let anchoredStep = $derived(effectiveConfig.anchorToBase === false ? null : effectiveConfig.anchorPlacement?.step ?? null);
38
+ // The page background follows the base anchor; there is no separate spot.
39
+ let backgroundStep = $derived(spec.emptySelector && !isGradient ? anchoredStep : null);
40
+
41
+ // Floor, don't round: the readout must never overstate a contrast ratio.
42
+ function verdict(s: { best: number; aa: boolean }): string {
43
+ const ratio = `${(Math.floor(s.best * 10) / 10).toFixed(1)}:1`;
44
+ return s.aa ? `best text contrast ${ratio}` : `no text reaches AA (best ${ratio})`;
45
+ }
46
+
47
+ function stepTitle(s: (typeof steps)[number], i: number): string {
48
+ const anchor = i === anchoredStep ? ' · base color' : '';
49
+ const bg = i === backgroundStep ? ' · page background' : '';
50
+ return `${spec.label} ${s.label}${anchor}${bg} · ${verdict(s)}`;
51
+ }
52
+ </script>
53
+
54
+ <div class="strip" role="group" aria-label={`${spec.label} derived scale`}>
55
+ {#each steps as s, i (s.label)}
56
+ <div
57
+ class="spot"
58
+ class:anchored={anchoredStep === i}
59
+ class:background={backgroundStep === i}
60
+ style:flex-grow={dockGrow(i, anchoredStep)}
61
+ title={stepTitle(s, i)}
62
+ >
63
+ <span class="chip" style:background={s.hex}>
64
+ <span class="aa" class:fail={!s.aa} style:color={s.textHex}>Aa</span>
65
+ </span>
66
+ <span class="step-label">{s.label}</span>
67
+ </div>
68
+ {/each}
69
+ </div>
70
+ {#if isGradient}
71
+ <p class="hint">The page background is currently a gradient; the base color takes over when gradient mode is off.</p>
72
+ {/if}
73
+
74
+ <style>
75
+ .strip {
76
+ display: flex;
77
+ align-items: flex-end;
78
+ gap: var(--ui-space-4);
79
+ }
80
+
81
+ .spot {
82
+ flex: 1 1 0;
83
+ min-width: 0;
84
+ display: flex;
85
+ flex-direction: column;
86
+ gap: var(--ui-space-4);
87
+ transition: flex-grow var(--ui-transition-fast);
88
+ }
89
+
90
+ .chip {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ height: 2.25rem;
95
+ /* Compensating margin: height + margin-top is constant every frame of the
96
+ magnification, so the strip's layout height never dips mid-transition
97
+ (the dip bounced everything below). Chip grows upward, label pinned. */
98
+ margin-top: 0.5rem;
99
+ border-radius: var(--ui-radius-sm);
100
+ border: 1px solid var(--ui-border-low);
101
+ transition: border-color var(--ui-transition-fast), height var(--ui-transition-fast), margin-top var(--ui-transition-fast);
102
+ }
103
+
104
+ .spot.anchored .chip {
105
+ height: 2.75rem;
106
+ margin-top: 0;
107
+ border-color: var(--ui-border-higher);
108
+ }
109
+
110
+ /* The ring marks the page background, which is the anchored base color. */
111
+ .spot.background .chip {
112
+ outline: 2px solid var(--ui-text-primary);
113
+ outline-offset: 1px;
114
+ }
115
+
116
+ .aa {
117
+ font-size: var(--ui-font-size-xs);
118
+ font-weight: var(--ui-font-weight-semibold);
119
+ }
120
+
121
+ .aa.fail {
122
+ opacity: 0.55;
123
+ text-decoration: line-through;
124
+ }
125
+
126
+ .step-label {
127
+ font-size: var(--ui-font-size-xs);
128
+ color: var(--ui-text-secondary);
129
+ text-align: center;
130
+ }
131
+
132
+ .spot.anchored .step-label {
133
+ color: var(--ui-text-primary);
134
+ font-weight: var(--ui-font-weight-semibold);
135
+ }
136
+
137
+ .hint {
138
+ margin: 0;
139
+ font-size: var(--ui-font-size-sm);
140
+ color: var(--ui-text-secondary);
141
+ }
142
+ </style>
@@ -0,0 +1,9 @@
1
+ import { gamutClamp } from '../../core/palettes/oklch';
2
+
3
+ // The one gamut-max-chroma helper the wheel disc and the lightness bar share:
4
+ // the in-gamut chroma boundary for a hue at a given L (invariant: gamut is
5
+ // display-only). GAMUT_PROBE is a chroma the binary search walks inward from —
6
+ // any value past the widest sRGB reach works.
7
+ const GAMUT_PROBE = 0.5;
8
+
9
+ export const maxChroma = (l: number, hue: number): number => gamutClamp(l, GAMUT_PROBE, hue).c;
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="128" cy="38" r="14"/>
7
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="128" y2="38"/>
8
+ <circle fill="currentColor" cx="64.36" cy="64.36" r="14"/>
9
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="64.36" y2="64.36"/>
10
+ <circle fill="currentColor" cx="191.64" cy="64.36" r="14"/>
11
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="191.64" y2="64.36"/>
12
+ </g>
13
+ </svg>
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="128" cy="38" r="14"/>
7
+ <circle fill="currentColor" cx="128" cy="218" r="14"/>
8
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="218" x2="128" y2="38"/>
9
+ </g>
10
+ </svg>
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="83" cy="50.058" r="14"/>
7
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="83" y2="50.058"/>
8
+ <circle fill="currentColor" cx="50.058" cy="83" r="14"/>
9
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="50.058" y2="83"/>
10
+ <circle fill="currentColor" cx="173" cy="205.942" r="14"/>
11
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="173" y2="205.942"/>
12
+ <circle fill="currentColor" cx="128" cy="218" r="14"/>
13
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="128" y2="218"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="150.686" cy="144.528" r="14"/>
7
+ <circle fill="currentColor" cx="175.564" cy="60.02" r="14"/>
8
+ <circle fill="currentColor" cx="75.597" cy="105.538" r="14"/>
9
+ </g>
10
+ </svg>
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="128" cy="38" r="14"/>
7
+ <circle fill="currentColor" cx="128" cy="70" r="14"/>
8
+ <circle fill="currentColor" cx="128" cy="102" r="14"/>
9
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="128" y2="38"/>
10
+ </g>
11
+ </svg>
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="128" cy="38" r="14"/>
7
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="128" y2="38"/>
8
+ </g>
9
+ </svg>
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="128" cy="38" r="14"/>
7
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="128" y2="38"/>
8
+ <circle fill="currentColor" cx="83" cy="205.942" r="14"/>
9
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="83" y2="205.942"/>
10
+ <circle fill="currentColor" cx="173" cy="205.942" r="14"/>
11
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="173" y2="205.942"/>
12
+ </g>
13
+ </svg>
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="64.36" cy="64.36" r="14"/>
7
+ <circle fill="currentColor" cx="64.36" cy="191.64" r="14"/>
8
+ <circle fill="currentColor" cx="191.64" cy="191.64" r="14"/>
9
+ <circle fill="currentColor" cx="191.64" cy="64.36" r="14"/>
10
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" x1="191.64" y1="64.36" x2="64.36" y2="191.64"/>
11
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" x1="64.36" y1="64.36" x2="191.64" y2="191.64"/>
12
+ </g>
13
+ </svg>
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="173" cy="50.058" r="14"/>
7
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="173" y2="50.058"/>
8
+ <circle fill="currentColor" cx="83" cy="50.058" r="14"/>
9
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="83" y2="50.058"/>
10
+ <circle fill="currentColor" cx="83" cy="205.942" r="14"/>
11
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="83" y2="205.942"/>
12
+ <circle fill="currentColor" cx="173" cy="205.942" r="14"/>
13
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="173" y2="205.942"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <g>
3
+ <circle fill="none" stroke="currentColor" stroke-width="18" opacity="0.25" cx="128" cy="128" r="90"/>
4
+ </g>
5
+ <g>
6
+ <circle fill="currentColor" cx="128" cy="38" r="14"/>
7
+ <circle fill="currentColor" cx="50.058" cy="173" r="14"/>
8
+ <circle fill="currentColor" cx="205.942" cy="173" r="14"/>
9
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="128" y2="38"/>
10
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="50.058" y2="173"/>
11
+ <line fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" x1="128" y1="128" x2="205.942" y2="173"/>
12
+ </g>
13
+ </svg>
@@ -0,0 +1,23 @@
1
+ import type { HarmonyMode } from '../../core/palettes/colorHarmony';
2
+
3
+ import monochromaticSvg from './harmony-icons/monochromatic.svg?raw';
4
+ import analogousSvg from './harmony-icons/analogous.svg?raw';
5
+ import complementarySvg from './harmony-icons/complementary.svg?raw';
6
+ import splitComplementarySvg from './harmony-icons/split-complementary.svg?raw';
7
+ import triadicSvg from './harmony-icons/triadic.svg?raw';
8
+ import tetradicSvg from './harmony-icons/tetradic.svg?raw';
9
+ import squareSvg from './harmony-icons/square.svg?raw';
10
+ import compoundSvg from './harmony-icons/compound.svg?raw';
11
+ import customSvg from './harmony-icons/custom.svg?raw';
12
+
13
+ export const HARMONY_MODE_BUTTONS: { mode: HarmonyMode; label: string; svg: string }[] = [
14
+ { mode: 'monochromatic', label: 'Monochromatic', svg: monochromaticSvg },
15
+ { mode: 'analogous', label: 'Analogous', svg: analogousSvg },
16
+ { mode: 'complementary', label: 'Complementary', svg: complementarySvg },
17
+ { mode: 'split-complementary', label: 'Split complementary', svg: splitComplementarySvg },
18
+ { mode: 'triadic', label: 'Triadic', svg: triadicSvg },
19
+ { mode: 'tetradic', label: 'Tetradic', svg: tetradicSvg },
20
+ { mode: 'square', label: 'Square', svg: squareSvg },
21
+ { mode: 'compound', label: 'Compound', svg: compoundSvg },
22
+ { mode: 'custom', label: 'Custom', svg: customSvg },
23
+ ];
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The Colors view's writes to a palette's seed color and the page-background
3
+ * spot. Both the wheel handle drag and the readout panel funnel through here so
4
+ * a Colors-view edit is byte-identical to a PaletteEditor base-color edit
5
+ * (global invariant 5: one store path, no parallel color state). `mutate` runs
6
+ * inside whatever scope the caller has opened (a wheel drag's clipping session,
7
+ * a slider gesture, or none for a discrete edit); the seed-color setters never
8
+ * open or commit scopes themselves.
9
+ *
10
+ * The store holds unclamped OKLCH intent: setters write channels directly, with
11
+ * no gamut clamp and no hex round-trip. Clamping is projection-only (derivation
12
+ * output, canvas painting, hex/CSS serialization), which is what lets a drag
13
+ * past an L extreme recover its chroma and keep its hue through grey.
14
+ *
15
+ * The seed-if-missing branch mirrors `PaletteEditor.edit` — in a loaded theme
16
+ * every palette already exists, but keeping the write self-sufficient means a
17
+ * handle can never silently drop an edit.
18
+ */
19
+
20
+ import { get } from 'svelte/store';
21
+ import type { Oklch } from '../../core/palettes/oklch';
22
+ import { AXIS_ROLES } from '../../core/palettes/colorHarmony';
23
+ import { PALETTE_SPECS, syncBaseAnchor, type PaletteSpec } from '../../core/palettes/paletteDerivation';
24
+ import { solveTextCurves } from '../../core/palettes/solveTextContrast';
25
+ import { recommendNeutralText } from '../../core/palettes/recommendText';
26
+ import { defaultPaletteConfig } from '../palette/paletteMath';
27
+ import { mutate, transaction, editorState } from '../../core/store/editorStore';
28
+ import type { EditorState } from '../../core/store/editorTypes';
29
+ import type { PaletteConfig } from '../../core/themes/themeTypes';
30
+
31
+ const SPEC_BY_LABEL: Record<string, PaletteSpec> = Object.fromEntries(PALETTE_SPECS.map((s) => [s.label, s]));
32
+
33
+ const normHue = (d: number) => ((d % 360) + 360) % 360;
34
+
35
+ function ensureConfig(s: EditorState, label: string): PaletteConfig {
36
+ let cfg = s.palettes[label];
37
+ if (!cfg) {
38
+ const spec = SPEC_BY_LABEL[label];
39
+ cfg = s.palettes[label] = defaultPaletteConfig({ baseColor: spec.initialColor, neutral: spec.neutral });
40
+ }
41
+ return cfg;
42
+ }
43
+
44
+ /** Drag the bound axis's hue along after a direct color-hue edit — the handle is
45
+ * the color's handle while bound, so it must not silently detach (invariant 1). */
46
+ function syncBoundAxisHue(s: EditorState, label: string): void {
47
+ const axis = s.harmonyAxes.find((a) => a.family === label);
48
+ if (axis) axis.hue = normHue(s.palettes[label].baseColor.h);
49
+ }
50
+
51
+ /** Set the full seed color (H, C and L). Used by the readout panel. */
52
+ export function setBaseColor(label: string, color: Oklch): void {
53
+ mutate(`colors: ${label} base`, (s) => {
54
+ const cfg = ensureConfig(s, label);
55
+ cfg.baseColor = color;
56
+ syncBaseAnchor(cfg);
57
+ syncBoundAxisHue(s, label);
58
+ });
59
+ }
60
+
61
+ /** Set hue + chroma while preserving the seed's current lightness. Used by wheel drags/nudges. */
62
+ export function setBaseHueChroma(label: string, hue: number, chroma: number): void {
63
+ mutate(`colors: ${label} base`, (s) => {
64
+ const cfg = ensureConfig(s, label);
65
+ cfg.baseColor = { l: cfg.baseColor.l, c: Math.max(0, chroma), h: normHue(hue) };
66
+ syncBaseAnchor(cfg);
67
+ syncBoundAxisHue(s, label);
68
+ });
69
+ }
70
+
71
+ /** Rotate a seed's hue only — chroma and lightness preserved (harmony rotation,
72
+ * external rotate handles). Mirrors colorHarmony's `reHue`. */
73
+ export function setBaseHue(label: string, hue: number): void {
74
+ mutate(`colors: ${label} hue`, (s) => {
75
+ const cfg = ensureConfig(s, label);
76
+ cfg.baseColor = { l: cfg.baseColor.l, c: cfg.baseColor.c, h: normHue(hue) };
77
+ syncBaseAnchor(cfg);
78
+ syncBoundAxisHue(s, label);
79
+ });
80
+ }
81
+
82
+ /** Set a seed's lightness and chroma, hue preserved from the store. Used by the
83
+ * lightness bar. No pinned-hue parameter: numeric storage keeps hue stable even
84
+ * at c≈0, so it can be read straight from the store. */
85
+ export function setBaseLightnessChroma(label: string, lightness: number, chroma: number): void {
86
+ mutate(`colors: ${label} lightness`, (s) => {
87
+ const cfg = ensureConfig(s, label);
88
+ cfg.baseColor = { l: lightness, c: Math.max(0, chroma), h: cfg.baseColor.h };
89
+ syncBaseAnchor(cfg);
90
+ });
91
+ }
92
+
93
+ /** Set a seed's chroma only — hue and lightness preserved (rail-constrained drag). */
94
+ export function setBaseChroma(label: string, chroma: number): void {
95
+ mutate(`colors: ${label} chroma`, (s) => {
96
+ const cfg = ensureConfig(s, label);
97
+ cfg.baseColor = { l: cfg.baseColor.l, c: Math.max(0, chroma), h: cfg.baseColor.h };
98
+ syncBaseAnchor(cfg);
99
+ });
100
+ }
101
+
102
+ /** Set one axis's hue; a bound family's baseColor follows in the same mutate.
103
+ * familyChroma carries the wheel drag's chroma policy; omitted = chroma kept. */
104
+ export function setAxisHue(index: number, hue: number, familyChroma?: number): void {
105
+ const h = normHue(hue);
106
+ const axis = get(editorState).harmonyAxes[index];
107
+ const c = familyChroma !== undefined ? Math.max(0, familyChroma) : undefined;
108
+ const base = axis.family !== null ? get(editorState).palettes[axis.family]?.baseColor : undefined;
109
+ const colorNoop = !base || (base.h === h && (c === undefined || base.c === c));
110
+ if (axis.hue === h && colorNoop) return;
111
+ mutate(`colors: ${AXIS_ROLES[index]} axis hue`, (s) => {
112
+ applyAxisHue(s, index, h, c);
113
+ });
114
+ }
115
+
116
+ /** Several axis hues in ONE transaction (mode apply, keyboard rotate-all). */
117
+ export function setAxisHues(
118
+ entries: { index: number; hue: number; familyChroma?: number }[],
119
+ historyLabel: string,
120
+ ): void {
121
+ const s0 = get(editorState);
122
+ const changes = entries.map((e) => ({
123
+ index: e.index,
124
+ h: normHue(e.hue),
125
+ c: e.familyChroma !== undefined ? Math.max(0, e.familyChroma) : undefined,
126
+ }));
127
+ const changed = changes.some(({ index, h, c }) => {
128
+ const axis = s0.harmonyAxes[index];
129
+ if (axis.hue !== h) return true;
130
+ const base = axis.family !== null ? s0.palettes[axis.family]?.baseColor : undefined;
131
+ if (!base) return false;
132
+ return base.h !== h || (c !== undefined && base.c !== c);
133
+ });
134
+ if (!changed) return;
135
+ transaction(historyLabel, (s) => {
136
+ for (const { index, h, c } of changes) applyAxisHue(s, index, h, c);
137
+ });
138
+ }
139
+
140
+ function applyAxisHue(s: EditorState, index: number, hue: number, chroma: number | undefined): void {
141
+ const axis = s.harmonyAxes[index];
142
+ axis.hue = hue;
143
+ if (axis.family !== null) {
144
+ const cfg = ensureConfig(s, axis.family);
145
+ cfg.baseColor = { l: cfg.baseColor.l, c: chroma ?? cfg.baseColor.c, h: hue };
146
+ syncBaseAnchor(cfg);
147
+ }
148
+ }
149
+
150
+ /** Bind a family to an axis; the family adopts the axis hue (c + L kept).
151
+ * Trade-places semantics: whatever occupied the destination takes the source's
152
+ * position (another axis, adopting its hue, or Unassigned). One mutate. */
153
+ export function bindFamilyToAxis(family: string, index: number): void {
154
+ const srcIndex = get(editorState).harmonyAxes.findIndex((a) => a.family === family);
155
+ if (srcIndex === index) return;
156
+ mutate(`colors: bind ${family}`, (s) => {
157
+ const axes = s.harmonyAxes;
158
+ const occupant = axes[index].family;
159
+ axes[index].family = family;
160
+ adoptAxisHue(s, family, axes[index].hue);
161
+ // Source was another axis: the occupant trades into it (adopting its hue).
162
+ // Source was Unassigned (-1): the occupant simply floats free, color kept.
163
+ if (srcIndex !== -1) {
164
+ axes[srcIndex].family = occupant;
165
+ if (occupant !== null) adoptAxisHue(s, occupant, axes[srcIndex].hue);
166
+ }
167
+ });
168
+ }
169
+
170
+ /** Unbind; the family keeps its current color, the axis keeps its hue. */
171
+ export function unbindFamily(family: string): void {
172
+ const idx = get(editorState).harmonyAxes.findIndex((a) => a.family === family);
173
+ if (idx === -1) return;
174
+ mutate(`colors: unbind ${family}`, (s) => {
175
+ s.harmonyAxes[idx].family = null;
176
+ });
177
+ }
178
+
179
+ function adoptAxisHue(s: EditorState, family: string, hue: number): void {
180
+ const cfg = ensureConfig(s, family);
181
+ cfg.baseColor = { l: cfg.baseColor.l, c: cfg.baseColor.c, h: normHue(hue) };
182
+ syncBaseAnchor(cfg);
183
+ }
184
+
185
+ /** Set several seed colors in ONE undo entry (harmony apply, global rotate). */
186
+ export function setBaseColors(patch: Record<string, Oklch>, historyLabel = 'colors: harmony'): void {
187
+ transaction(historyLabel, (s) => {
188
+ for (const [label, color] of Object.entries(patch)) {
189
+ const cfg = ensureConfig(s, label);
190
+ cfg.baseColor = color;
191
+ syncBaseAnchor(cfg);
192
+ }
193
+ });
194
+ }
195
+
196
+ export function applySolvedTextCurves(s: EditorState): void {
197
+ const { patches } = solveTextCurves(s.palettes);
198
+ for (const [label, patch] of Object.entries(patches)) {
199
+ const cfg = s.palettes[label];
200
+ if (cfg) cfg.scaleCurves = { ...cfg.scaleCurves, ...patch.scaleCurves };
201
+ }
202
+ }
203
+
204
+ /** Apply the Color Story's suggested neutral text hierarchy (primary anchor,
205
+ * two even L drops, AA floors) as the Neutral family's Text curve. */
206
+ export function applySuggestedNeutralText(useBlackWhite = false): void {
207
+ mutate('colors: suggested text hierarchy', (s) => {
208
+ ensureConfig(s, 'Neutral');
209
+ const rec = recommendNeutralText(s.palettes, undefined, useBlackWhite);
210
+ if (!rec.patch) return;
211
+ const cfg = s.palettes['Neutral'];
212
+ cfg.scaleCurves = { ...cfg.scaleCurves, Text: rec.patch };
213
+ });
214
+ }
@@ -15,7 +15,6 @@ export * from './variantScales';
15
15
 
16
16
  // Editor tabs & panels
17
17
  export { default as VariablesTab } from './VariablesTab.svelte';
18
- export { default as TextTab } from './TextTab.svelte';
19
18
  export { default as SurfacesTab } from './SurfacesTab.svelte';
20
19
 
21
20
  // Palette editor stack
@@ -3,6 +3,7 @@
3
3
  import UIPillButton from '../UIPillButton.svelte';
4
4
  import { type CurveAnchor, lightnessCurveConfig, saturationCurveConfig, textLightnessCurveConfig } from '../curveEngine';
5
5
  import { scaleToCssVar } from '../../core/palettes/paletteDerivation';
6
+ import type { Oklch } from '../../core/palettes/oklch';
6
7
 
7
8
  /**
8
9
  * Per-scale derived swatch section used for Text, Surfaces, and Borders
@@ -35,7 +36,9 @@
35
36
  saturation: () => CurveAnchor[];
36
37
  }
37
38
 
38
- interface PaletteStep { label: string; hex: string }
39
+ // `hex` is the sRGB projection for swatch/compare; `oklch` is the numeric
40
+ // value a snap write stores. `overrides` arrives pre-projected to hex.
41
+ interface PaletteStep { label: string; hex: string; oklch: Oklch }
39
42
 
40
43
 
41
44
 
@@ -64,7 +67,7 @@
64
67
  onResetOverride: (key: string) => void;
65
68
  onOverrideClick: (key: string, step: Step, scaleTitle: string) => void;
66
69
  onSnappedClick: (key: string) => void;
67
- onSelectSnapValue: (key: string, paletteHex: string, scaleTitle: string) => void;
70
+ onSelectSnapValue: (key: string, value: Oklch, scaleTitle: string) => void;
68
71
  onCopyHex: (key: string, hex: string, event?: MouseEvent) => void;
69
72
  onCopyVarName: (key: string, varName: string, event?: MouseEvent) => void;
70
73
  onSetScaleCurve: (scaleTitle: string, channel: Channel, anchors: CurveAnchor[]) => void;
@@ -224,7 +227,7 @@
224
227
  class="snap-picker-item"
225
228
  class:selected={overrides[k] === ps.hex}
226
229
  type="button"
227
- onclick={() => onSelectSnapValue(k, ps.hex, scale.title)}
230
+ onclick={() => onSelectSnapValue(k, ps.oklch, scale.title)}
228
231
  >
229
232
  <span class="snap-picker-swatch" style="background: {ps.hex}"></span>
230
233
  <span class="snap-picker-label">{ps.label}</span>
@@ -2,7 +2,7 @@
2
2
  import ColorEditPanel from '../ColorEditPanel.svelte';
3
3
  import Toggle from '../Toggle.svelte';
4
4
  import { beginSliderGesture } from '../../core/store/editorStore';
5
- import { hexToOklch } from '../../core/palettes/oklch';
5
+ import { oklchToHexClamped, type Oklch } from '../../core/palettes/oklch';
6
6
 
7
7
  // Full sRGB chroma range (gamutClamp trims per hue/lightness). Neutrals default
8
8
  // low but are not capped; their calm character comes from defaults, not a ceiling.
@@ -26,11 +26,13 @@
26
26
  label: string;
27
27
  displayLabel?: string | null;
28
28
  neutral?: boolean;
29
- baseColor: string;
29
+ baseColor: Oklch;
30
30
  anchorToBase: boolean;
31
+ /** Palette step label ('500', '850', …) the base color is placed at; null while off or unplaced. */
32
+ anchorStepLabel?: string | null;
31
33
  isEditingBase: boolean;
32
34
  panelOpen: boolean;
33
- editingColor: string | null;
35
+ editingColor: Oklch | null;
34
36
  editPanelTitle: string | null;
35
37
  copiedKey: string | null;
36
38
  onStartEdit: () => void;
@@ -47,6 +49,7 @@
47
49
  neutral = false,
48
50
  baseColor,
49
51
  anchorToBase,
52
+ anchorStepLabel = null,
50
53
  isEditingBase,
51
54
  panelOpen,
52
55
  editingColor,
@@ -60,7 +63,8 @@
60
63
  onCopyBaseHex
61
64
  }: Props = $props();
62
65
 
63
- let baseOklch = $derived(hexToOklch(baseColor));
66
+ let baseOklch = $derived(baseColor);
67
+ let baseHex = $derived(oklchToHexClamped(baseColor.l, baseColor.c, baseColor.h));
64
68
  let pickerChromaHint = $derived(neutral ? NEUTRAL_CALM_CHROMA : undefined);
65
69
  </script>
66
70
 
@@ -70,7 +74,7 @@
70
74
  <div
71
75
  class="header-swatch"
72
76
  class:active={isEditingBase}
73
- style="background: {baseColor}"
77
+ style="background: {baseHex}"
74
78
  onclick={onStartEdit}
75
79
  role="button"
76
80
  tabindex="0"
@@ -81,8 +85,8 @@
81
85
  <button
82
86
  class="base-hex clickable-hex"
83
87
  type="button"
84
- onclick={(e) => onCopyBaseHex('__base__', baseColor, e)}
85
- >{copiedKey === '__base__' ? 'copied!' : baseColor}</button>
88
+ onclick={(e) => onCopyBaseHex('__base__', baseHex, e)}
89
+ >{copiedKey === '__base__' ? 'copied!' : baseHex}</button>
86
90
  </div>
87
91
  </div>
88
92
  </div>
@@ -103,7 +107,11 @@
103
107
  onSliderStart={() => beginSliderGesture(`edit ${label} base`)}
104
108
  >
105
109
  {#snippet actions()}
106
- <Toggle checked={anchorToBase} onchange={(v) => onAnchorToBaseChange(v ?? !anchorToBase)} label="Lock base color to position 500" />
110
+ <Toggle
111
+ checked={anchorToBase}
112
+ onchange={(v) => onAnchorToBaseChange(v ?? !anchorToBase)}
113
+ label={anchorStepLabel ? `Base color placed at ${anchorStepLabel}` : 'Place base color in palette'}
114
+ />
107
115
  {/snippet}
108
116
  </ColorEditPanel>
109
117
  {/if}