@motion-proto/live-tokens 0.96.0 → 0.97.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 (35) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/set-colors.mjs +1 -0
  3. package/bin/setup-claude.mjs +2 -2
  4. package/dist-plugin/setColors/index.cjs +7 -1
  5. package/dist-plugin/setColors/index.d.cts +9 -4
  6. package/dist-plugin/setColors/index.d.ts +9 -4
  7. package/dist-plugin/setColors/index.js +7 -1
  8. package/dist-plugin/setGeometry/index.d.cts +1 -1
  9. package/dist-plugin/setGeometry/index.d.ts +1 -1
  10. package/dist-plugin/setType/index.d.cts +1 -1
  11. package/dist-plugin/setType/index.d.ts +1 -1
  12. package/dist-plugin/{themeTypes-B8_Idrp4.d.cts → themeTypes-BaTQUyac.d.cts} +10 -1
  13. package/dist-plugin/{themeTypes-B8_Idrp4.d.ts → themeTypes-BaTQUyac.d.ts} +10 -1
  14. package/package.json +4 -2
  15. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +0 -7
  16. package/src/editor/core/store/editorPersistence.ts +9 -1
  17. package/src/editor/core/store/editorRenderer.ts +2 -0
  18. package/src/editor/core/store/editorStore.ts +25 -0
  19. package/src/editor/core/store/editorTypes.ts +9 -0
  20. package/src/editor/core/themes/buildColors.ts +16 -5
  21. package/src/editor/core/themes/slices/customColors.ts +226 -0
  22. package/src/editor/core/themes/themeTypes.ts +10 -0
  23. package/src/editor/docs/content/editing-tokens.md +28 -0
  24. package/src/editor/docs/content.generated.ts +1 -1
  25. package/src/editor/pages/EditorShell.svelte +1 -0
  26. package/src/editor/ui/CustomColorReplaceStep.svelte +118 -0
  27. package/src/editor/ui/UIPaletteSelector.svelte +338 -15
  28. package/src/editor/ui/UITokenSelector.svelte +7 -12
  29. package/src/editor/ui/VariablesTab.svelte +3 -0
  30. package/src/editor/ui/anchorToTrigger.ts +63 -0
  31. package/src/editor/ui/sections/CustomColorsSection.svelte +254 -0
  32. package/src/system/components/ImageLightbox.svelte +8 -1
  33. package/src/testing-js/page-compliance.contract.js +31 -5
  34. package/src/testing-js/page-compliance.contract.js.map +1 -1
  35. package/src/editor/ui/keepInViewport.ts +0 -84
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.97.0 — Custom colors
4
+
5
+ ### Added
6
+
7
+ - **Every color picker offers custom colors.** A **Custom** row after Danger
8
+ lists the open theme's own colors, `--color-custom-1`, `--color-custom-2`
9
+ and so on, each holding one OKLCH value for light and dark. The row's
10
+ plus swatch starts a new color from the slot's current color and opens
11
+ the OKLCH editor; confirm assigns it to the slot in one undo step, and
12
+ cancel removes it. A slot picks an existing custom color as it picks a
13
+ palette step, opacity included. The pencil on a custom swatch edits it in
14
+ place, and the edit moves every slot that names it. The Tokens view gains a
15
+ **Custom Colours** section that lists, edits and removes them. Removing a
16
+ color that a component property, gradient stop or wash names first asks
17
+ for a replacement, preselects the nearest token, and rewrites each
18
+ reference with its opacity intact, all in one undo step.
19
+ - **Themes carry an optional `customColors` field.**
20
+ `ColorsAndType.customColors` sits beside `gradients` and holds
21
+ `{ variable, color }` entries. The field is additive: a theme without it
22
+ loads with no custom colors, `THEME_SCHEMA_VERSION` stays 5, and no
23
+ migration runs. Save, export and import keep the list, and Adopt writes
24
+ each custom color into `tokens.generated.css` with the theme's other
25
+ variables. `set-colors` and `save-theme` carry the field through
26
+ unchanged. `tokens.css` and the shipped themes declare no custom color, so
27
+ `check-page` reports `unknown-token` for page CSS that names
28
+ `var(--color-custom-1)`; reach a custom color through a component property.
29
+
30
+ ## 0.96.1 — Vitest 5 peer range
31
+
32
+ ### Fixed
33
+
34
+ - **Installs alongside Vitest 5.** The optional `vitest` peer range now reads
35
+ `^4.0 || ^5.0`. With `^4.0` alone, npm stopped any install that re-resolved
36
+ the tree with `ERESOLVE` once Vitest 5 sat anywhere in it, including as an
37
+ uninstalled optional peer of Vite's devtools. The `testing` contract helpers
38
+ pass the full suite on Vitest 5.
39
+
3
40
  ## 0.96.0 — Lightbox zoom readout in natural pixels
4
41
 
5
42
  ### Changed
@@ -82,6 +82,7 @@ export async function runSetColors({
82
82
  const { colors, report } = buildColors(input, {
83
83
  cssVariables: live.cssVariables,
84
84
  gradients: live.gradients,
85
+ customColors: live.customColors,
85
86
  });
86
87
 
87
88
  const next = {
@@ -19,7 +19,7 @@ import { join } from 'node:path';
19
19
 
20
20
  // The package owns this prefix in a project's skills directory. A skill without
21
21
  // it is the project's own and is never touched, whatever the flags say.
22
- const SKILL_PREFIX = 'live-tokens-';
22
+ export const SKILL_PREFIX = 'live-tokens-';
23
23
 
24
24
  const SAMPLE_PROMPTS = {
25
25
  'live-tokens-create-page': 'build a pricing page using live-tokens components',
@@ -44,7 +44,7 @@ export function runSetupClaude({ pkgRoot, cwd, force = false }) {
44
44
  throw new Error(`No bundled skills found at ${srcSkills}. Is the package installed correctly?`);
45
45
  }
46
46
 
47
- const shipped = directoriesIn(srcSkills);
47
+ const shipped = directoriesIn(srcSkills).filter((name) => name.startsWith(SKILL_PREFIX));
48
48
  if (shipped.length === 0) {
49
49
  throw new Error('No bundled skills to install.');
50
50
  }
@@ -1098,7 +1098,13 @@ function buildColors(input, carry = {}) {
1098
1098
  for (const t of gradients) cssVariables[t.variable] = formatGradientValue(t);
1099
1099
  Object.assign(cssVariables, shadowVars(carry.cssVariables ?? {}, baseColors.Canvas));
1100
1100
  return {
1101
- colors: { editorConfigs: configs, harmonyAxes, gradients, cssVariables },
1101
+ colors: {
1102
+ editorConfigs: configs,
1103
+ harmonyAxes,
1104
+ gradients,
1105
+ ...carry.customColors ? { customColors: carry.customColors } : {},
1106
+ cssVariables
1107
+ },
1102
1108
  report: {
1103
1109
  ...report,
1104
1110
  gradients: gradients === carry.gradients ? "carried" : "recipes",
@@ -1,4 +1,4 @@
1
- import { P as PaletteConfig, H as HarmonyAxis, G as GradientDiskToken, O as Oklch, d as HarmonyMode } from '../themeTypes-B8_Idrp4.cjs';
1
+ import { P as PaletteConfig, H as HarmonyAxis, G as GradientDiskToken, b as CustomColorDiskToken, O as Oklch, e as HarmonyMode } from '../themeTypes-BaTQUyac.cjs';
2
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-BpIK_Obx.cjs';
3
3
  export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-4N-Orzzi.cjs';
4
4
 
@@ -44,12 +44,14 @@ interface ColorsInput {
44
44
  canvasGradient?: boolean;
45
45
  }
46
46
  /** The live colors and type this pass reads before it replaces the color
47
- * state: the override bag, so every name no palette owns rides through, and
48
- * the swatch gradients, so user-tuned ones survive. Shadow opacity is
49
- * derived, not carried — see `shadowVars`. */
47
+ * state: the override bag, so every name no palette owns rides through, the
48
+ * swatch gradients, so user-tuned ones survive, and the custom colours, which
49
+ * ride through untouched. Shadow opacity is derived, not carried — see
50
+ * `shadowVars`. */
50
51
  interface CarryForward {
51
52
  cssVariables?: Record<string, string>;
52
53
  gradients?: GradientDiskToken[];
54
+ customColors?: CustomColorDiskToken[];
53
55
  }
54
56
  interface ContrastCheck {
55
57
  textVar: string;
@@ -80,6 +82,9 @@ interface ColorsState {
80
82
  editorConfigs: Record<string, PaletteConfig>;
81
83
  harmonyAxes: HarmonyAxis[];
82
84
  gradients: GradientDiskToken[];
85
+ /** Present only when the carried colours and type held the field, so a
86
+ * recolor adds no key to a theme that never had one. */
87
+ customColors?: CustomColorDiskToken[];
83
88
  cssVariables: Record<string, string>;
84
89
  }
85
90
  interface BuildColorsResult {
@@ -1,4 +1,4 @@
1
- import { P as PaletteConfig, H as HarmonyAxis, G as GradientDiskToken, O as Oklch, d as HarmonyMode } from '../themeTypes-B8_Idrp4.js';
1
+ import { P as PaletteConfig, H as HarmonyAxis, G as GradientDiskToken, b as CustomColorDiskToken, O as Oklch, e as HarmonyMode } from '../themeTypes-BaTQUyac.js';
2
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-BpIK_Obx.js';
3
3
  export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-4N-Orzzi.js';
4
4
 
@@ -44,12 +44,14 @@ interface ColorsInput {
44
44
  canvasGradient?: boolean;
45
45
  }
46
46
  /** The live colors and type this pass reads before it replaces the color
47
- * state: the override bag, so every name no palette owns rides through, and
48
- * the swatch gradients, so user-tuned ones survive. Shadow opacity is
49
- * derived, not carried — see `shadowVars`. */
47
+ * state: the override bag, so every name no palette owns rides through, the
48
+ * swatch gradients, so user-tuned ones survive, and the custom colours, which
49
+ * ride through untouched. Shadow opacity is derived, not carried — see
50
+ * `shadowVars`. */
50
51
  interface CarryForward {
51
52
  cssVariables?: Record<string, string>;
52
53
  gradients?: GradientDiskToken[];
54
+ customColors?: CustomColorDiskToken[];
53
55
  }
54
56
  interface ContrastCheck {
55
57
  textVar: string;
@@ -80,6 +82,9 @@ interface ColorsState {
80
82
  editorConfigs: Record<string, PaletteConfig>;
81
83
  harmonyAxes: HarmonyAxis[];
82
84
  gradients: GradientDiskToken[];
85
+ /** Present only when the carried colours and type held the field, so a
86
+ * recolor adds no key to a theme that never had one. */
87
+ customColors?: CustomColorDiskToken[];
83
88
  cssVariables: Record<string, string>;
84
89
  }
85
90
  interface BuildColorsResult {
@@ -483,7 +483,13 @@ function buildColors(input, carry = {}) {
483
483
  for (const t of gradients) cssVariables[t.variable] = formatGradientValue(t);
484
484
  Object.assign(cssVariables, shadowVars(carry.cssVariables ?? {}, baseColors.Canvas));
485
485
  return {
486
- colors: { editorConfigs: configs, harmonyAxes, gradients, cssVariables },
486
+ colors: {
487
+ editorConfigs: configs,
488
+ harmonyAxes,
489
+ gradients,
490
+ ...carry.customColors ? { customColors: carry.customColors } : {},
491
+ cssVariables
492
+ },
487
493
  report: {
488
494
  ...report,
489
495
  gradients: gradients === carry.gradients ? "carried" : "recipes",
@@ -1,4 +1,4 @@
1
- import { A as AliasDiskValue, a as ComponentConfig } from '../themeTypes-B8_Idrp4.cjs';
1
+ import { A as AliasDiskValue, a as ComponentConfig } from '../themeTypes-BaTQUyac.cjs';
2
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-BpIK_Obx.cjs';
3
3
  export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-4N-Orzzi.cjs';
4
4
 
@@ -1,4 +1,4 @@
1
- import { A as AliasDiskValue, a as ComponentConfig } from '../themeTypes-B8_Idrp4.js';
1
+ import { A as AliasDiskValue, a as ComponentConfig } from '../themeTypes-BaTQUyac.js';
2
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-BpIK_Obx.js';
3
3
  export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-4N-Orzzi.js';
4
4
 
@@ -1,4 +1,4 @@
1
- import { c as FontStackVariable, C as ColorsAndType, b as FontStack, F as FontSource } from '../themeTypes-B8_Idrp4.cjs';
1
+ import { d as FontStackVariable, C as ColorsAndType, c as FontStack, F as FontSource } from '../themeTypes-BaTQUyac.cjs';
2
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-BpIK_Obx.cjs';
3
3
 
4
4
  type PairingSlot = 'display' | 'body' | 'serif' | 'mono' | 'editorial';
@@ -1,4 +1,4 @@
1
- import { c as FontStackVariable, C as ColorsAndType, b as FontStack, F as FontSource } from '../themeTypes-B8_Idrp4.js';
1
+ import { d as FontStackVariable, C as ColorsAndType, c as FontStack, F as FontSource } from '../themeTypes-BaTQUyac.js';
2
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-BpIK_Obx.js';
3
3
 
4
4
  type PairingSlot = 'display' | 'body' | 'serif' | 'mono' | 'editorial';
@@ -181,6 +181,12 @@ interface GradientDiskToken {
181
181
  opacity?: number;
182
182
  }[];
183
183
  }
184
+ /** On-disk shape of one `--color-custom-N` token: a theme-owned colour that
185
+ * holds its own OKLCH value, one value for light and dark alike. */
186
+ interface CustomColorDiskToken {
187
+ variable: string;
188
+ color: Oklch;
189
+ }
184
190
  /**
185
191
  * Where a live component config resolved from: the unsaved `_working`
186
192
  * buffer, or the open theme's embedded copy. Every theme carries every
@@ -208,6 +214,9 @@ interface ColorsAndType {
208
214
  /** Absent on files saved before gradients round-tripped; the loader keeps
209
215
  * the stock defaults then, which match what those files rendered. */
210
216
  gradients?: GradientDiskToken[];
217
+ /** Absent when the theme holds no custom colour; the loader reads that as
218
+ * an empty list. */
219
+ customColors?: CustomColorDiskToken[];
211
220
  /** Four fixed harmony axes, each owning a hue with an optional bound family. */
212
221
  harmonyAxes?: HarmonyAxis[];
213
222
  /**
@@ -257,4 +266,4 @@ interface ComponentConfig {
257
266
  schemaVersion?: number;
258
267
  }
259
268
 
260
- export type { AliasDiskValue as A, ColorsAndType as C, FontSource as F, GradientDiskToken as G, HarmonyAxis as H, Oklch as O, PaletteConfig as P, ComponentConfig as a, FontStack as b, FontStackVariable as c, HarmonyMode as d };
269
+ export type { AliasDiskValue as A, ColorsAndType as C, FontSource as F, GradientDiskToken as G, HarmonyAxis as H, Oklch as O, PaletteConfig as P, ComponentConfig as a, CustomColorDiskToken as b, FontStack as c, FontStackVariable as d, HarmonyMode as e };
@@ -181,6 +181,12 @@ interface GradientDiskToken {
181
181
  opacity?: number;
182
182
  }[];
183
183
  }
184
+ /** On-disk shape of one `--color-custom-N` token: a theme-owned colour that
185
+ * holds its own OKLCH value, one value for light and dark alike. */
186
+ interface CustomColorDiskToken {
187
+ variable: string;
188
+ color: Oklch;
189
+ }
184
190
  /**
185
191
  * Where a live component config resolved from: the unsaved `_working`
186
192
  * buffer, or the open theme's embedded copy. Every theme carries every
@@ -208,6 +214,9 @@ interface ColorsAndType {
208
214
  /** Absent on files saved before gradients round-tripped; the loader keeps
209
215
  * the stock defaults then, which match what those files rendered. */
210
216
  gradients?: GradientDiskToken[];
217
+ /** Absent when the theme holds no custom colour; the loader reads that as
218
+ * an empty list. */
219
+ customColors?: CustomColorDiskToken[];
211
220
  /** Four fixed harmony axes, each owning a hue with an optional bound family. */
212
221
  harmonyAxes?: HarmonyAxis[];
213
222
  /**
@@ -257,4 +266,4 @@ interface ComponentConfig {
257
266
  schemaVersion?: number;
258
267
  }
259
268
 
260
- export type { AliasDiskValue as A, ColorsAndType as C, FontSource as F, GradientDiskToken as G, HarmonyAxis as H, Oklch as O, PaletteConfig as P, ComponentConfig as a, FontStack as b, FontStackVariable as c, HarmonyMode as d };
269
+ export type { AliasDiskValue as A, ColorsAndType as C, FontSource as F, GradientDiskToken as G, HarmonyAxis as H, Oklch as O, PaletteConfig as P, ComponentConfig as a, CustomColorDiskToken as b, FontStack as c, FontStackVariable as d, HarmonyMode as e };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.96.0",
3
+ "version": "0.97.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -56,6 +56,7 @@
56
56
  "dist-plugin",
57
57
  "src/testing-js",
58
58
  ".claude/skills",
59
+ "!.claude/skills/evaluate",
59
60
  "!.claude/skills/**/skill-alt.md",
60
61
  "bin",
61
62
  "template",
@@ -204,6 +205,7 @@
204
205
  "check:smoke-install": "bash scripts/smoke-install.sh",
205
206
  "check:smoke-create": "bash scripts/smoke-create.sh",
206
207
  "eval": "bash scripts/eval.sh",
208
+ "eval:billing": "bash scripts/eval-billing.sh",
207
209
  "build:eval-project": "bash scripts/build-eval-project.sh",
208
210
  "check:smoke-component-tests": "bash scripts/smoke-component-tests.sh",
209
211
  "check:smoke-page-tests": "bash scripts/smoke-page-tests.sh",
@@ -216,7 +218,7 @@
216
218
  "sass": "^1.0",
217
219
  "svelte": "^5",
218
220
  "vite": "^8",
219
- "vitest": "^4.0"
221
+ "vitest": "^4.0 || ^5.0"
220
222
  },
221
223
  "peerDependenciesMeta": {
222
224
  "@playwright/test": {
@@ -818,13 +818,6 @@
818
818
  color: var(--ui-text-tertiary);
819
819
  }
820
820
 
821
- /* Anchor dropdown to the trigger's right edge; the toolbar sits flush against
822
- the editor's right gutter, so a left-anchored dropdown would clip. */
823
- .canvas-toolbar :global(.ui-ts-dropdown) {
824
- left: auto;
825
- right: 0;
826
- }
827
-
828
821
  .variant-tabs {
829
822
  display: inline-flex;
830
823
  flex-wrap: wrap;
@@ -19,6 +19,7 @@ import { storageKey } from './editorConfig';
19
19
  import { store } from './editorCore';
20
20
  import { quietGet, quietSet } from '../storage/storage';
21
21
  import { isGradientSlot, makeDefaultGradients } from '../themes/slices/gradients';
22
+ import { isCustomColorList } from '../themes/slices/customColors';
22
23
  import { seedShadowsFromDom } from '../themes/slices/shadows';
23
24
  import { makeDefaultWashScale, type WashFamily } from '../themes/slices/washes';
24
25
  import { normalizeColumnsState } from '../themes/slices/columns';
@@ -73,6 +74,13 @@ function migrateGradients(state: EditorState): EditorState {
73
74
  return { ...state, gradients: { tokens: makeDefaultGradients() } };
74
75
  }
75
76
 
77
+ // Persisted state from before custom colours gets the empty default from the
78
+ // shallow merge; anything else that is not a well-formed list loads as empty.
79
+ function migrateCustomColors(state: EditorState): EditorState {
80
+ if (isCustomColorList(state.customColors?.tokens)) return state;
81
+ return { ...state, customColors: { tokens: [] } };
82
+ }
83
+
76
84
  // `hydrate` shallow-merges the persisted `components` bag over the default, so
77
85
  // a slice serialized by an older build can lack fields added since (e.g.
78
86
  // `config`, added with the two-field alias/config split). `componentsToVars`
@@ -205,7 +213,7 @@ export function hydrate(): void {
205
213
  store.set(
206
214
  normalizeHarmonyAxes(
207
215
  normalizeBaseAnchors(
208
- normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(normalizeColumns(normalizeWashes(migrateGradients(merged)))))),
216
+ normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(normalizeColumns(normalizeWashes(migrateCustomColors(migrateGradients(merged))))))),
209
217
  ),
210
218
  ),
211
219
  );
@@ -21,6 +21,7 @@ import {
21
21
  washesToVars,
22
22
  shadowsToVars,
23
23
  gradientsToVars,
24
+ customColorsToVars,
24
25
  componentsToVars,
25
26
  } from './editorStore';
26
27
 
@@ -34,6 +35,7 @@ export function deriveCssVars(state: EditorState): Record<string, string> {
34
35
  if (state.gradients.tokens.length > 0) {
35
36
  Object.assign(out, gradientsToVars(state.gradients));
36
37
  }
38
+ Object.assign(out, customColorsToVars(state.customColors));
37
39
  Object.assign(out, palettesToVars(state.palettes));
38
40
  Object.assign(out, componentsToVars(state.components));
39
41
  Object.assign(out, resolveFontStackValues(state.fonts.stacks, state.fonts.sources));
@@ -74,6 +74,10 @@ import {
74
74
  loadGradientsFromFile,
75
75
  makeDefaultGradients,
76
76
  } from '../themes/slices/gradients';
77
+ import {
78
+ customColorsToVars,
79
+ loadCustomColorsFromFile,
80
+ } from '../themes/slices/customColors';
77
81
  import {
78
82
  componentBaseline,
79
83
  getComponentOwnedVarNames,
@@ -102,6 +106,7 @@ function emptyState(): EditorState {
102
106
  columns: defaultColumns(),
103
107
  components: {},
104
108
  gradients: { tokens: makeDefaultGradients() },
109
+ customColors: { tokens: [] },
105
110
  harmonyAxes: defaultHarmonyAxes(),
106
111
  cssVars: {},
107
112
  };
@@ -179,6 +184,20 @@ export {
179
184
  removeGradientToken,
180
185
  } from '../themes/slices/gradients';
181
186
 
187
+ export {
188
+ customColorsToVars,
189
+ nextCustomColorSlot,
190
+ isCustomColorSlot,
191
+ addCustomColor,
192
+ setCustomColor,
193
+ removeCustomColor,
194
+ customColorUsages,
195
+ describeCustomColorUsages,
196
+ nearestColorToken,
197
+ replaceAndRemoveCustomColor,
198
+ } from '../themes/slices/customColors';
199
+ export type { CustomColorUsage } from '../themes/slices/customColors';
200
+
182
201
  export {
183
202
  componentsToVars,
184
203
  getComponentOwnedVarNames,
@@ -404,6 +423,7 @@ export function colorsAndTypeToState(colorsAndType: ColorsAndType): EditorState
404
423
  // begin with).
405
424
  for (const load of Object.values(domainLoaders)) load(next, rawVars);
406
425
  loadGradientsFromFile(next, colorsAndType.gradients, rawVars);
426
+ loadCustomColorsFromFile(next, colorsAndType.customColors, rawVars);
407
427
  next.harmonyAxes = sanitizeHarmonyAxes(renameBackgroundHarmonyFamily(colorsAndType.harmonyAxes), next.palettes);
408
428
  next.cssVars = rawVars;
409
429
  return next;
@@ -438,12 +458,17 @@ function colorsAndTypeContent(state: EditorState): Omit<ColorsAndType, 'name' |
438
458
  if (state.gradients.tokens.length > 0) {
439
459
  Object.assign(cssVariables, gradientsToVars(state.gradients));
440
460
  }
461
+ // An empty list writes no field, so a theme that never held a custom colour
462
+ // still compares equal to the buffer its load produced.
463
+ const hasCustomColors = state.customColors.tokens.length > 0;
464
+ if (hasCustomColors) Object.assign(cssVariables, customColorsToVars(state.customColors));
441
465
  return {
442
466
  editorConfigs: state.palettes,
443
467
  cssVariables,
444
468
  fontSources: state.fonts.sources,
445
469
  fontStacks: state.fonts.stacks,
446
470
  gradients: state.gradients.tokens,
471
+ ...(hasCustomColors ? { customColors: state.customColors.tokens } : {}),
447
472
  harmonyAxes: state.harmonyAxes,
448
473
  schemaVersion: CURRENT_COLORS_AND_TYPE_SCHEMA_VERSION,
449
474
  };
@@ -1,6 +1,7 @@
1
1
  import type { PaletteConfig, FontSource, FontStack } from '../themes/themeTypes';
2
2
  import type { GradientStop, GradientValue, LinearDirection } from '../themes/parsers/gradient';
3
3
  import type { HarmonyAxis } from '../palettes/colorHarmony';
4
+ import type { Oklch } from '../palettes/oklch';
4
5
  import type { ColumnViewport, ColumnViewportValues } from '../themes/slices/columns';
5
6
 
6
7
  export interface ShadowGlobals {
@@ -80,6 +81,13 @@ export interface GradientToken {
80
81
  stops: GradientTokenStop[];
81
82
  }
82
83
 
84
+ /** A theme-owned `--color-custom-N` colour. Slots name it the way they name a
85
+ * palette step, so one edit moves every slot that names it. */
86
+ export interface CustomColorToken {
87
+ variable: string;
88
+ color: Oklch;
89
+ }
90
+
83
91
  /** Structured gradient payload carried inline on a component alias.
84
92
  * `GradientToken` minus `variable` (the alias key itself is the binding).
85
93
  * Used when a component owns a per-instance gradient that doesn't share the
@@ -123,6 +131,7 @@ export interface EditorState {
123
131
  columns: ColumnsState;
124
132
  components: Record<string, ComponentSlice>;
125
133
  gradients: { tokens: GradientToken[] };
134
+ customColors: { tokens: CustomColorToken[] };
126
135
  /** Four fixed numbered axes (index 0 is the anchor); each owns a hue;
127
136
  * family is the live-bound color or null. */
128
137
  harmonyAxes: HarmonyAxis[];
@@ -26,7 +26,7 @@ import { SHADOW_VAR_NAMES, parseShadowCss, shadowTokenCss } from './parsers/shad
26
26
  import { AA_BODY, AA_LARGE, contrastRatio, findLForContrast } from '../palettes/contrast';
27
27
  import { type HarmonyAxis, type HarmonyMode } from '../palettes/colorHarmony';
28
28
  import { defaultPaletteConfig } from '../../ui/palette/paletteMath';
29
- import type { GradientDiskToken, PaletteConfig } from './themeTypes';
29
+ import type { CustomColorDiskToken, GradientDiskToken, PaletteConfig } from './themeTypes';
30
30
 
31
31
  export interface ColorsInput {
32
32
  scheme: SchemeDirection;
@@ -41,12 +41,14 @@ export interface ColorsInput {
41
41
  }
42
42
 
43
43
  /** The live colors and type this pass reads before it replaces the color
44
- * state: the override bag, so every name no palette owns rides through, and
45
- * the swatch gradients, so user-tuned ones survive. Shadow opacity is
46
- * derived, not carried — see `shadowVars`. */
44
+ * state: the override bag, so every name no palette owns rides through, the
45
+ * swatch gradients, so user-tuned ones survive, and the custom colours, which
46
+ * ride through untouched. Shadow opacity is derived, not carried — see
47
+ * `shadowVars`. */
47
48
  export interface CarryForward {
48
49
  cssVariables?: Record<string, string>;
49
50
  gradients?: GradientDiskToken[];
51
+ customColors?: CustomColorDiskToken[];
50
52
  }
51
53
 
52
54
  export interface ContrastCheck {
@@ -80,6 +82,9 @@ export interface ColorsState {
80
82
  editorConfigs: Record<string, PaletteConfig>;
81
83
  harmonyAxes: HarmonyAxis[];
82
84
  gradients: GradientDiskToken[];
85
+ /** Present only when the carried colours and type held the field, so a
86
+ * recolor adds no key to a theme that never had one. */
87
+ customColors?: CustomColorDiskToken[];
83
88
  cssVariables: Record<string, string>;
84
89
  }
85
90
 
@@ -467,7 +472,13 @@ export function buildColors(input: ColorsInput, carry: CarryForward = {}): Build
467
472
  Object.assign(cssVariables, shadowVars(carry.cssVariables ?? {}, baseColors.Canvas));
468
473
 
469
474
  return {
470
- colors: { editorConfigs: configs, harmonyAxes, gradients, cssVariables },
475
+ colors: {
476
+ editorConfigs: configs,
477
+ harmonyAxes,
478
+ gradients,
479
+ ...(carry.customColors ? { customColors: carry.customColors } : {}),
480
+ cssVariables,
481
+ },
471
482
  report: {
472
483
  ...report,
473
484
  gradients: gradients === carry.gradients ? 'carried' : 'recipes',