@motion-proto/live-tokens 0.95.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 (36) hide show
  1. package/CHANGELOG.md +55 -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/demo/TestingLoops.svelte +39 -16
  16. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +0 -7
  17. package/src/editor/core/store/editorPersistence.ts +9 -1
  18. package/src/editor/core/store/editorRenderer.ts +2 -0
  19. package/src/editor/core/store/editorStore.ts +25 -0
  20. package/src/editor/core/store/editorTypes.ts +9 -0
  21. package/src/editor/core/themes/buildColors.ts +16 -5
  22. package/src/editor/core/themes/slices/customColors.ts +226 -0
  23. package/src/editor/core/themes/themeTypes.ts +10 -0
  24. package/src/editor/docs/content/editing-tokens.md +28 -0
  25. package/src/editor/docs/content.generated.ts +1 -1
  26. package/src/editor/pages/EditorShell.svelte +1 -0
  27. package/src/editor/ui/CustomColorReplaceStep.svelte +118 -0
  28. package/src/editor/ui/UIPaletteSelector.svelte +338 -15
  29. package/src/editor/ui/UITokenSelector.svelte +7 -12
  30. package/src/editor/ui/VariablesTab.svelte +3 -0
  31. package/src/editor/ui/anchorToTrigger.ts +63 -0
  32. package/src/editor/ui/sections/CustomColorsSection.svelte +254 -0
  33. package/src/system/components/ImageLightbox.svelte +17 -3
  34. package/src/testing-js/page-compliance.contract.js +31 -5
  35. package/src/testing-js/page-compliance.contract.js.map +1 -1
  36. package/src/editor/ui/keepInViewport.ts +0 -84
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
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
+
40
+ ## 0.96.0 — Lightbox zoom readout in natural pixels
41
+
42
+ ### Changed
43
+
44
+ - **The `ImageLightbox` zoom readout measures the image against its natural
45
+ resolution.** 100% shows one source pixel per CSS pixel, the unit `maxZoom`
46
+ already takes. A 2880px image fitted into a 960px box opens at 33%, and
47
+ `maxZoom={1}` tops out at 100%. The readout used to call the fitted view
48
+ 100% whatever the image's size. Until the natural size is known, from
49
+ `width`/`height` or the loaded image, the readout measures against the
50
+ fitted view as before. The zoom steps and caps stay the same.
51
+
52
+ ### Fixed
53
+
54
+ - **The Testing loops page keeps its diagram text crisp in sketch mode.** The
55
+ text lies in its own svg above the boxes and arrows, so the sketch filter
56
+ roughens the shapes and leaves the labels legible.
57
+
3
58
  ## 0.95.0 — Responsive columns and production CSS
4
59
 
5
60
  ### Changed (breaking)
@@ -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.95.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": {
@@ -121,12 +121,11 @@
121
121
  <div class="figure-stage">
122
122
  <!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the diagram.) -->
123
123
  <div class="figure-scroll" role="region" aria-label="Figure 1" tabindex="0">
124
+ <div class="dg-stack">
124
125
  <svg class="dg" viewBox="20 12 1160 866" role="img" aria-label="The skill builds a page or component and runs the checks. The CLI checker checks the code, makes the routine repairs, tests the result, and reports the findings. The skill repairs each finding and runs the checks again until every check passes.">
125
126
  <rect class="lane-checker" x="20" y="12" width="1160" height="866" rx="8" />
126
127
  <path class="lane-skill" d="M28,12 H600 V878 H28 A8,8 0 0 1 20,870 V20 A8,8 0 0 1 28,12 Z" />
127
128
  <path class="lane-shade" d="M600,12 H1172 A8,8 0 0 1 1180,20 V870 A8,8 0 0 1 1172,878 H600 Z" />
128
- <text class="lane-title" x="80" y="52">Claude Skill</text>
129
- <text class="lane-title" x="660" y="52">CLI checker</text>
130
129
  <path class="flow" d="M540,148 L876,148 Q890,148 890,162 L890,225" />
131
130
  <path class="flow" d="M890,320 L890,353" />
132
131
  <path class="flow" d="M890,448 L890,481" />
@@ -135,33 +134,38 @@
135
134
  <path class="flow" d="M890,728 L890,781" />
136
135
  <path class="back" d="M310,640 L310,290 Q310,276 324,276 L653,276" />
137
136
  <rect class="box-lead" x="80" y="104" width="460" height="88" rx="4" />
137
+ <rect class="box" x="660" y="232" width="460" height="88" rx="4" />
138
+ <rect class="box" x="660" y="360" width="460" height="88" rx="4" />
139
+ <rect class="box" x="660" y="488" width="460" height="112" rx="4" />
140
+ <rect class="box" x="660" y="640" width="460" height="88" rx="4" />
141
+ <rect class="box" x="80" y="640" width="460" height="88" rx="4" />
142
+ <rect class="box-lead" x="660" y="788" width="460" height="72" rx="4" />
143
+ </svg>
144
+ <svg class="dg dg-text" viewBox="20 12 1160 866" aria-hidden="true">
145
+ <text class="lane-title" x="80" y="52">Claude Skill</text>
146
+ <text class="lane-title" x="660" y="52">CLI checker</text>
138
147
  <text class="t" x="101.3" y="138">Build a page or component</text>
139
148
  <text class="s" x="101.3" y="166">from the catalogue and design tokens</text>
140
- <rect class="box" x="660" y="232" width="460" height="88" rx="4" />
141
149
  <text class="t" x="681.3" y="266">Check the code</text>
142
150
  <text class="tag" x="1098.7" y="266" text-anchor="end">Static check</text>
143
151
  <text class="s" x="681.3" y="294">design tokens, components, and props</text>
144
- <rect class="box" x="660" y="360" width="460" height="88" rx="4" />
145
152
  <text class="t" x="681.3" y="394">Make the routine repairs</text>
146
153
  <text class="s" x="681.3" y="422">fixes with one right answer</text>
147
- <rect class="box" x="660" y="488" width="460" height="112" rx="4" />
148
154
  <text class="t" x="681.3" y="522">Test the result</text>
149
155
  <text class="tag" x="1098.7" y="522" text-anchor="end">Vitest · Playwright</text>
150
156
  <text class="s" x="681.3" y="550">Test component interactions, rendering, and themes.</text>
151
157
  <text class="s" x="681.3" y="574">Check page layout, contrast, and typography.</text>
152
- <rect class="box" x="660" y="640" width="460" height="88" rx="4" />
153
158
  <text class="t" x="681.3" y="674">Report the findings</text>
154
159
  <text class="s" x="681.3" y="702">each with guidance for its repair</text>
155
- <rect class="box" x="80" y="640" width="460" height="88" rx="4" />
156
160
  <text class="t" x="101.3" y="674">Repair each finding</text>
157
161
  <text class="s" x="101.3" y="702">make the choice or write the code</text>
158
- <rect class="box-lead" x="660" y="788" width="460" height="72" rx="4" />
159
162
  <text class="t" x="890" y="830" text-anchor="middle">Every check passes</text>
160
163
  <text class="lbl" x="715" y="136" text-anchor="middle">run the checks</text>
161
164
  <text class="lbl" x="600" y="672" text-anchor="middle">findings</text>
162
165
  <text class="lbl" x="906" y="759">none remain</text>
163
166
  <text class="rule" x="479" y="262" text-anchor="middle">check again</text>
164
167
  </svg>
168
+ </div>
165
169
  </div>
166
170
  </div>
167
171
  </figure>
@@ -179,12 +183,11 @@
179
183
  <div class="figure-stage">
180
184
  <!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the diagram.) -->
181
185
  <div class="figure-scroll" role="region" aria-label="Figure 2" tabindex="0">
186
+ <div class="dg-stack">
182
187
  <svg class="dg" viewBox="20 12 1160 916" role="img" aria-label="Read the project, plan the sections, and choose components. When no component fits, create one and check it until every check passes, and it joins the catalogue. Assemble the page and check it. Repair each finding until every check passes, then review the page.">
183
188
  <rect class="lane-checker" x="20" y="12" width="1160" height="916" rx="8" />
184
189
  <path class="lane-skill" d="M28,12 H600 V928 H28 A8,8 0 0 1 20,920 V20 A8,8 0 0 1 28,12 Z" />
185
190
  <path class="lane-shade" d="M600,12 H1172 A8,8 0 0 1 1180,20 V920 A8,8 0 0 1 1172,928 H600 Z" />
186
- <text class="lane-title" x="80" y="52">Page</text>
187
- <text class="lane-title" x="660" y="52">New component</text>
188
191
  <path class="flow" d="M310,192 L310,225" />
189
192
  <path class="flow" d="M310,320 L310,353" />
190
193
  <path class="flow" d="M310,448 L310,545" />
@@ -194,25 +197,29 @@
194
197
  <path class="back" d="M540,724 L556,724 Q570,724 570,710 L570,629 Q570,620 561,620 L547,620" />
195
198
  <path class="flow" d="M310,768 L310,813" />
196
199
  <rect class="box-lead" x="80" y="104" width="460" height="88" rx="4" />
200
+ <rect class="box" x="80" y="232" width="460" height="88" rx="4" />
201
+ <rect class="box" x="80" y="360" width="460" height="88" rx="4" />
202
+ <rect class="box" x="660" y="360" width="460" height="112" rx="4" />
203
+ <rect class="box" x="80" y="552" width="460" height="88" rx="4" />
204
+ <rect class="box" x="80" y="680" width="460" height="88" rx="4" />
205
+ <rect class="box-lead" x="80" y="820" width="460" height="88" rx="4" />
206
+ </svg>
207
+ <svg class="dg dg-text" viewBox="20 12 1160 916" aria-hidden="true">
208
+ <text class="lane-title" x="80" y="52">Page</text>
209
+ <text class="lane-title" x="660" y="52">New component</text>
197
210
  <text class="t" x="101.3" y="138">Read the project</text>
198
211
  <text class="s" x="101.3" y="166">routes, grid, and the component catalogue</text>
199
- <rect class="box" x="80" y="232" width="460" height="88" rx="4" />
200
212
  <text class="t" x="101.3" y="266">Plan the sections</text>
201
213
  <text class="s" x="101.3" y="294">one section per purpose</text>
202
- <rect class="box" x="80" y="360" width="460" height="88" rx="4" />
203
214
  <text class="t" x="101.3" y="394">Choose components</text>
204
215
  <text class="s" x="101.3" y="422">from the catalogue first</text>
205
- <rect class="box" x="660" y="360" width="460" height="112" rx="4" />
206
216
  <text class="t" x="681.3" y="394">Create the component</text>
207
217
  <text class="s" x="681.3" y="422">check it and repair it</text>
208
218
  <text class="s" x="681.3" y="446">until every check passes</text>
209
- <rect class="box" x="80" y="552" width="460" height="88" rx="4" />
210
219
  <text class="t" x="101.3" y="586">Assemble the page</text>
211
220
  <text class="s" x="101.3" y="614">components, tokens, and text styles</text>
212
- <rect class="box" x="80" y="680" width="460" height="88" rx="4" />
213
221
  <text class="t" x="101.3" y="714">Check the page</text>
214
222
  <text class="s" x="101.3" y="742">review the code and test it in the browser</text>
215
- <rect class="box-lead" x="80" y="820" width="460" height="88" rx="4" />
216
223
  <text class="t" x="101.3" y="854">Review the page</text>
217
224
  <text class="s" x="101.3" y="882">read it the way a visitor would</text>
218
225
  <text class="lbl" x="326" y="499">a shipped component fits</text>
@@ -221,6 +228,7 @@
221
228
  <text class="rule" x="584" y="677">repair</text>
222
229
  <text class="lbl" x="326" y="793">every check passes</text>
223
230
  </svg>
231
+ </div>
224
232
  </div>
225
233
  </div>
226
234
  </figure>
@@ -832,6 +840,21 @@
832
840
  color: var(--text-primary);
833
841
  }
834
842
 
843
+ /* Sketch mode filters every inline svg as artwork, which smears type. The
844
+ text gets its own svg over the shapes, so the boxes stay drawn and the
845
+ labels crisp. */
846
+ .dg-stack {
847
+ display: grid;
848
+ }
849
+
850
+ .dg-stack > .dg {
851
+ grid-area: 1 / 1;
852
+ }
853
+
854
+ .dg-text {
855
+ --sketch-icon-off: none;
856
+ }
857
+
835
858
  .dg :global(.lane-checker) {
836
859
  fill: none;
837
860
  stroke: var(--border-neutral-subtle);
@@ -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[];