@motion-proto/live-tokens 0.68.1 → 0.70.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 (95) hide show
  1. package/.claude/skills/live-tokens-build-page/SKILL.md +16 -5
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +55 -19
  3. package/.claude/skills/live-tokens-create-component/references/token-naming.md +30 -1
  4. package/.claude/skills/live-tokens-fix-findings/SKILL.md +133 -0
  5. package/.claude/skills/live-tokens-pick-component/SKILL.md +10 -1
  6. package/CHANGELOG.md +236 -0
  7. package/README.md +13 -4
  8. package/bin/check-component.mjs +367 -63
  9. package/bin/check-page.mjs +409 -0
  10. package/bin/cli.mjs +107 -9
  11. package/bin/lib/catalogue.mjs +123 -0
  12. package/bin/lib/cssValues.mjs +50 -0
  13. package/bin/lib/findings.mjs +106 -0
  14. package/bin/lib/tokenVocabulary.mjs +240 -0
  15. package/dist-plugin/adjust/index.cjs +174 -23
  16. package/dist-plugin/adjust/index.js +68 -23
  17. package/dist-plugin/{chunk-2UX6EVVA.js → chunk-2YNERPXY.js} +1 -1
  18. package/dist-plugin/{chunk-NE6N66EE.js → chunk-GPIBU44G.js} +107 -1
  19. package/dist-plugin/{chunk-ZHPX7ZYQ.js → chunk-RFVYPNRO.js} +39 -1
  20. package/dist-plugin/generateColorsAndType/index.cjs +107 -1
  21. package/dist-plugin/generateColorsAndType/index.js +1 -1
  22. package/dist-plugin/index.cjs +146 -2
  23. package/dist-plugin/index.js +3 -3
  24. package/dist-plugin/migrateData/index.cjs +107 -1
  25. package/dist-plugin/migrateData/index.js +2 -2
  26. package/dist-plugin/tokensCssMigrations/index.cjs +39 -1
  27. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  28. package/package.json +3 -2
  29. package/src/app/site.css +4 -4
  30. package/src/editor/component-editor/ButtonEditor.svelte +71 -4
  31. package/src/editor/component-editor/CardEditor.svelte +6 -6
  32. package/src/editor/component-editor/DialogEditor.svelte +3 -3
  33. package/src/editor/component-editor/IconButtonEditor.svelte +68 -3
  34. package/src/editor/component-editor/ImageEditor.svelte +8 -8
  35. package/src/editor/component-editor/ImageLightboxEditor.svelte +12 -1
  36. package/src/editor/component-editor/MenuSelectEditor.svelte +63 -3
  37. package/src/editor/component-editor/SegmentedControlEditor.svelte +63 -3
  38. package/src/editor/component-editor/SideNavigationEditor.svelte +67 -3
  39. package/src/editor/component-editor/SliderEditor.svelte +186 -0
  40. package/src/editor/component-editor/TabBarEditor.svelte +63 -3
  41. package/src/editor/component-editor/registry.ts +10 -0
  42. package/src/editor/core/components/aliasKinds.ts +51 -28
  43. package/src/editor/core/sketch/sketchLayer.ts +16 -0
  44. package/src/editor/core/store/editorPersistence.ts +44 -1
  45. package/src/editor/core/store/editorRenderer.ts +2 -2
  46. package/src/editor/core/store/editorStore.ts +18 -18
  47. package/src/editor/core/store/editorTypes.ts +7 -6
  48. package/src/editor/core/themes/migrations/2026-09-01-gate-suffix-enabled.ts +31 -0
  49. package/src/editor/core/themes/migrations/2026-09-01-scrim-rename.ts +52 -0
  50. package/src/editor/core/themes/migrations/2026-09-01-tabbar-active-tint.ts +27 -0
  51. package/src/editor/core/themes/migrations/2026-09-01-tint-rename.ts +42 -0
  52. package/src/editor/core/themes/migrations/index.ts +16 -0
  53. package/src/editor/core/themes/slices/domainVars.ts +2 -2
  54. package/src/editor/core/themes/slices/washes.ts +107 -0
  55. package/src/editor/docs/content/editing-tokens.md +5 -3
  56. package/src/editor/docs/content.generated.ts +1 -1
  57. package/src/editor/index.ts +1 -1
  58. package/src/editor/pages/EditorShell.svelte +1 -1
  59. package/src/editor/ui/SurfacesTab.svelte +3 -3
  60. package/src/editor/ui/UITokenSelector.svelte +1 -0
  61. package/src/editor/ui/VariablesTab.svelte +2 -2
  62. package/src/editor/ui/sections/{OverlaysSection.svelte → WashesSection.svelte} +44 -43
  63. package/src/live-tokens/data/colors-and-type/autumn.json +6 -6
  64. package/src/live-tokens/data/colors-and-type/default.json +6 -6
  65. package/src/live-tokens/data/colors-and-type/halloween.json +6 -6
  66. package/src/live-tokens/data/colors-and-type/midnight-study.json +6 -6
  67. package/src/live-tokens/data/colors-and-type/ocean.json +6 -6
  68. package/src/live-tokens/data/colors-and-type/royal-velvet.json +6 -6
  69. package/src/live-tokens/data/colors-and-type/sketchy.json +6 -6
  70. package/src/live-tokens/data/colors-and-type/spring-meadow.json +6 -6
  71. package/src/live-tokens/data/colors-and-type/sunset.json +6 -6
  72. package/src/live-tokens/data/themes/autumn.json +81 -13
  73. package/src/live-tokens/data/themes/halloween.json +81 -13
  74. package/src/live-tokens/data/themes/midnight-study.json +81 -13
  75. package/src/live-tokens/data/themes/ocean.json +81 -13
  76. package/src/live-tokens/data/themes/royal-velvet.json +81 -13
  77. package/src/live-tokens/data/themes/sketchy.json +81 -13
  78. package/src/live-tokens/data/themes/spring-meadow.json +81 -13
  79. package/src/live-tokens/data/themes/sunset.json +81 -13
  80. package/src/live-tokens/data/tokens.generated.css +6 -6
  81. package/src/system/components/Button.svelte +28 -10
  82. package/src/system/components/Card.svelte +6 -6
  83. package/src/system/components/Dialog.svelte +3 -3
  84. package/src/system/components/IconButton.svelte +23 -7
  85. package/src/system/components/Image.svelte +6 -6
  86. package/src/system/components/MenuSelect.svelte +17 -1
  87. package/src/system/components/SegmentedControl.svelte +20 -2
  88. package/src/system/components/SideNavigation.svelte +22 -2
  89. package/src/system/components/Slider.svelte +348 -0
  90. package/src/system/components/TabBar.svelte +20 -2
  91. package/src/system/styles/CONVENTIONS.md +2 -2
  92. package/src/system/styles/tokens.css +12 -4
  93. package/template/package.json +3 -2
  94. package/template/src/pages/Home.svelte +1 -1
  95. package/src/editor/core/themes/slices/overlays.ts +0 -101
@@ -21,36 +21,59 @@ export type TokenKind =
21
21
  | 'easing'
22
22
  | 'text-color';
23
23
 
24
- /** Suffix/prefix patterns mapped to kinds — the one source of truth for the
25
- editor's selector layout and the `adjust` CLI, so the two cannot drift.
26
- Order matters: `-text` must run before `-border`/`-surface` because `--text-*`
27
- would otherwise match `surface`/`border` if any pattern overlapped. Variables
28
- that don't match any pattern fall through to `text-color` (renders as a palette
29
- picker). Tokens with unconventional suffixes should be renamed. */
30
- export const KIND_PATTERNS: ReadonlyArray<{ kind: TokenKind; matches: (v: string) => boolean }> = [
31
- { kind: 'font-family', matches: (v) => v.endsWith('-font-family') },
32
- { kind: 'font-weight', matches: (v) => v.endsWith('-font-weight') },
33
- { kind: 'font-size', matches: (v) => v.endsWith('-font-size') || v.endsWith('-icon-size') || v.endsWith('-thumb-size') },
34
- { kind: 'line-height', matches: (v) => v.endsWith('-line-height') },
35
- { kind: 'letter-spacing', matches: (v) => v.endsWith('-letter-spacing') },
36
- { kind: 'text-color', matches: (v) => v.endsWith('-text') || v.startsWith('--text-') },
37
- { kind: 'radius', matches: (v) => v.endsWith('-radius') || v.startsWith('--radius-') },
38
- { kind: 'divider-width', matches: (v) => v.endsWith('-divider-width') || v.endsWith('-divider-thickness') },
39
- { kind: 'divider-height', matches: (v) => v.endsWith('-divider-height') || v.endsWith('-track-height') },
40
- { kind: 'divider-inset', matches: (v) => v.endsWith('-divider-inset') },
41
- { kind: 'dot-size', matches: (v) => v.endsWith('-dot-size') },
42
- { kind: 'blur', matches: (v) => v.endsWith('-blur') || v.startsWith('--blur-') },
43
- { kind: 'scale', matches: (v) => v.endsWith('-scale') || v.startsWith('--scale-') },
44
- { kind: 'shadow', matches: (v) => v.endsWith('-shadow') || v.startsWith('--shadow-') },
45
- { kind: 'padding', matches: (v) => v.endsWith('-padding') || v.endsWith('-margin') },
46
- { kind: 'gap', matches: (v) => v.endsWith('-gap') },
47
- { kind: 'duration', matches: (v) => v.endsWith('-duration') || v.startsWith('--duration-') },
48
- { kind: 'easing', matches: (v) => v.endsWith('-easing') || v.startsWith('--ease-') },
49
- { kind: 'border-width', matches: (v) => v.endsWith('-border-width') || v.endsWith('-accent-width') || v.endsWith('-hairline-thickness') || v.startsWith('--border-width-') },
50
- { kind: 'border', matches: (v) => v.endsWith('-border') || v.startsWith('--border-') },
51
- { kind: 'surface', matches: (v) => v.endsWith('-surface') || v.startsWith('--surface-') },
24
+ /** Suffix and prefix data mapped to kinds — the one source of truth for the
25
+ editor's selector layout, the `adjust` CLI, and `check-component`'s naming
26
+ rule, so the three cannot drift. `bin/check-component.mjs` reads the
27
+ `suffix:` arrays out of this file, which is why they are plain literals.
28
+
29
+ Order matters: `-text` must run before `-border`/`-surface`, and
30
+ `-accent-width` before `-accent`, because the first match wins. A variable
31
+ matching nothing falls through to `text-color` (a palette picker), but that
32
+ fall-through is a smell — `check-component` rejects an unrecognised suffix,
33
+ so add the name here rather than letting it drift. */
34
+ export const KIND_RULES: ReadonlyArray<{
35
+ kind: TokenKind;
36
+ suffix?: readonly string[];
37
+ prefix?: readonly string[];
38
+ }> = [
39
+ { kind: 'font-family', suffix: ['-font-family'] },
40
+ { kind: 'font-weight', suffix: ['-font-weight'] },
41
+ { kind: 'font-size', suffix: ['-font-size', '-icon-size', '-thumb-size'] },
42
+ { kind: 'line-height', suffix: ['-line-height'] },
43
+ { kind: 'letter-spacing', suffix: ['-letter-spacing'] },
44
+ // Element-named colour roles: the property *is* the element it paints.
45
+ { kind: 'text-color', suffix: ['-text', '-label', '-icon', '-title', '-body', '-eyebrow',
46
+ '-description', '-hint', '-error', '-placeholder', '-value'],
47
+ prefix: ['--text-'] },
48
+ { kind: 'radius', suffix: ['-radius'], prefix: ['--radius-'] },
49
+ { kind: 'divider-width', suffix: ['-divider-width', '-divider-thickness'] },
50
+ { kind: 'divider-height', suffix: ['-divider-height', '-track-height'] },
51
+ { kind: 'divider-inset', suffix: ['-divider-inset', '-inset'] },
52
+ { kind: 'dot-size', suffix: ['-dot-size'] },
53
+ { kind: 'blur', suffix: ['-blur'], prefix: ['--blur-'] },
54
+ { kind: 'scale', suffix: ['-scale'], prefix: ['--scale-'] },
55
+ { kind: 'shadow', suffix: ['-shadow'], prefix: ['--shadow-'] },
56
+ { kind: 'padding', suffix: ['-padding', '-margin'] },
57
+ { kind: 'gap', suffix: ['-gap'] },
58
+ { kind: 'duration', suffix: ['-duration'], prefix: ['--duration-'] },
59
+ { kind: 'easing', suffix: ['-easing'], prefix: ['--ease-'] },
60
+ { kind: 'border-width', suffix: ['-border-width', '-accent-width', '-hairline-thickness', '-thickness'],
61
+ prefix: ['--border-width-'] },
62
+ { kind: 'border', suffix: ['-border'], prefix: ['--border-'] },
63
+ // Fills. A tint is a wash over a surface, so it takes the surface picker: the
64
+ // full palette with an alpha, not just the tint stops it defaults to.
65
+ { kind: 'surface', suffix: ['-surface', '-fill', '-divider', '-background', '-indicator',
66
+ '-thumb', '-accent', '-color', '-tint', '-opacity',
67
+ '-width', '-height', '-size'],
68
+ prefix: ['--surface-', '--tint', '--color-'] },
52
69
  ];
53
70
 
71
+ export const KIND_PATTERNS: ReadonlyArray<{ kind: TokenKind; matches: (v: string) => boolean }> =
72
+ KIND_RULES.map(({ kind, suffix = [], prefix = [] }) => ({
73
+ kind,
74
+ matches: (v: string) => suffix.some((s) => v.endsWith(s)) || prefix.some((p) => v.startsWith(p)),
75
+ }));
76
+
54
77
  export function rawKind(variable: string): TokenKind {
55
78
  for (const { kind, matches } of KIND_PATTERNS) {
56
79
  if (matches(variable)) return kind;
@@ -227,6 +227,22 @@ const PART_SPECS: readonly PartSpec[] = [
227
227
  // Holds the picture inside the frame; without it a zoom spills out square.
228
228
  clips: true,
229
229
  },
230
+ ...['single', 'range'].map((v) => ({
231
+ sel: `.slider.${v} .slider-track`,
232
+ fill: `var(--slider-${v}-track-surface)`,
233
+ stroke: `var(--slider-${v}-track-border)`,
234
+ radius: `var(--slider-${v}-track-radius, 0px)`,
235
+ })),
236
+ // The fill rides inside the track, so it is stroked by nothing and hatched
237
+ // in the track's ink.
238
+ ...['single', 'range'].map((v) => ({
239
+ sel: `.slider.${v} .slider-fill`,
240
+ fill: `var(--slider-${v}-fill)`,
241
+ stroke: 'transparent',
242
+ hatch: `var(--slider-${v}-track-border)`,
243
+ radius: `var(--slider-${v}-track-radius, 0px)`,
244
+ positioned: true,
245
+ })),
230
246
  { sel: '.toggle .track', stem: 'toggle-track' },
231
247
  { sel: '.toggle.on .track', stem: 'toggle-on-track', radius: 'var(--toggle-track-radius, 0px)' },
232
248
 
@@ -20,6 +20,7 @@ import { store } from './editorCore';
20
20
  import { quietGet, quietSet } from '../storage/storage';
21
21
  import { isGradientSlot, makeDefaultGradients } from '../themes/slices/gradients';
22
22
  import { seedShadowsFromDom } from '../themes/slices/shadows';
23
+ import { makeDefaultScrimTokens, makeDefaultTintTokens } from '../themes/slices/washes';
23
24
  import { sanitizeHarmonyAxes } from '../palettes/colorHarmony';
24
25
  import {
25
26
  renameBackgroundPaletteKey,
@@ -77,6 +78,48 @@ function migrateGradients(state: EditorState): EditorState {
77
78
  // calls `Object.entries(slice.config)` unconditionally, so backfill the
78
79
  // required fields and drop any non-object slice before the state reaches the
79
80
  // renderer. Spread preserves optional fields like `unlinked`.
81
+ // The washes slice was `overlays: { tokens, hoverTokens }` before the scrim and
82
+ // tint renames, and `hydrate` shallow-merges, so a persisted state from an
83
+ // older build replaces `washes` wholesale and arrives without `tints`.
84
+ // `washesToVars` iterates both lists unconditionally, so reshape here: carry the
85
+ // stops across under their new names, and rewrite the variable each one carries.
86
+ const WASH_VAR_RENAMES: Record<string, string> = {
87
+ '--overlay-low': '--scrim-low',
88
+ '--overlay': '--scrim',
89
+ '--overlay-high': '--scrim-high',
90
+ '--hover-low': '--tint-low',
91
+ '--hover': '--tint',
92
+ '--hover-high': '--tint-high',
93
+ };
94
+
95
+ type LegacyWashes = {
96
+ scrims?: unknown;
97
+ tints?: unknown;
98
+ hoverTokens?: unknown;
99
+ tokens?: unknown;
100
+ };
101
+
102
+ /** Carry a persisted stop list forward, or fall back to the shipped defaults. */
103
+ function washList(raw: unknown, fallback: () => EditorState['washes']['scrims']) {
104
+ if (!Array.isArray(raw) || raw.length === 0) return fallback();
105
+ const carried = raw
106
+ .filter((t): t is EditorState['washes']['scrims'][number] => !!t && typeof t === 'object' && 'variable' in t)
107
+ .map((t) => ({ ...t, variable: WASH_VAR_RENAMES[t.variable] ?? t.variable }));
108
+ return carried.length > 0 ? carried : fallback();
109
+ }
110
+
111
+ export function normalizeWashes(state: EditorState): EditorState {
112
+ const washes = (state.washes ?? {}) as LegacyWashes;
113
+ const legacy = ((state as unknown as { overlays?: LegacyWashes }).overlays ?? {}) as LegacyWashes;
114
+ const next = { ...state } as EditorState & { overlays?: unknown };
115
+ next.washes = {
116
+ scrims: washList(washes.scrims ?? legacy.tokens, makeDefaultScrimTokens),
117
+ tints: washList(washes.tints ?? washes.hoverTokens ?? legacy.hoverTokens, makeDefaultTintTokens),
118
+ };
119
+ delete next.overlays;
120
+ return next;
121
+ }
122
+
80
123
  export function normalizeComponents(state: EditorState): EditorState {
81
124
  const raw = state.components;
82
125
  if (!raw || typeof raw !== 'object') return { ...state, components: {} };
@@ -134,7 +177,7 @@ export function hydrate(): void {
134
177
  store.set(
135
178
  normalizeHarmonyAxes(
136
179
  normalizeBaseAnchors(
137
- normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(migrateGradients(merged)))),
180
+ normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(normalizeWashes(migrateGradients(merged))))),
138
181
  ),
139
182
  ),
140
183
  );
@@ -19,7 +19,7 @@ import {
19
19
  editorState,
20
20
  columnsToVars,
21
21
  columnsEqualsDefault,
22
- overlaysToVars,
22
+ washesToVars,
23
23
  shadowsToVars,
24
24
  gradientsToVars,
25
25
  componentsToVars,
@@ -30,7 +30,7 @@ export function deriveCssVars(state: EditorState): Record<string, string> {
30
30
  if (!columnsEqualsDefault(state.columns)) {
31
31
  Object.assign(out, columnsToVars(state.columns));
32
32
  }
33
- Object.assign(out, overlaysToVars(state.overlays));
33
+ Object.assign(out, washesToVars(state.washes));
34
34
  if (state.shadows.tokens.length > 0) {
35
35
  Object.assign(out, shadowsToVars(state.shadows));
36
36
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Central editor state store — the single mutation funnel.
3
3
  *
4
- * All editor state (palettes, fonts, shadows, overlays, columns, ad-hoc CSS
4
+ * All editor state (palettes, fonts, shadows, washes, columns, ad-hoc CSS
5
5
  * vars) lives in one `EditorState` tree. Every change must go through
6
6
  * `mutate` (or a transaction). History is captured automatically; the
7
7
  * renderer subscribes and writes derived CSS vars to :root via `cssVarSync`
@@ -62,10 +62,10 @@ import {
62
62
  loadColumnsFromVars,
63
63
  } from '../themes/slices/columns';
64
64
  import {
65
- loadOverlaysFromVars,
66
- makeDefaultOverlaysState,
67
- overlaysToVars,
68
- } from '../themes/slices/overlays';
65
+ loadWashesFromVars,
66
+ makeDefaultWashesState,
67
+ washesToVars,
68
+ } from '../themes/slices/washes';
69
69
  import {
70
70
  loadShadowsFromVars,
71
71
  shadowsToVars,
@@ -99,7 +99,7 @@ function emptyState(): EditorState {
99
99
  tokens: [],
100
100
  overrides: {},
101
101
  },
102
- overlays: makeDefaultOverlaysState(),
102
+ washes: makeDefaultWashesState(),
103
103
  columns: { ...DEFAULT_COLUMNS },
104
104
  components: {},
105
105
  gradients: { tokens: makeDefaultGradients() },
@@ -150,15 +150,15 @@ export {
150
150
  } from '../themes/slices/columns';
151
151
 
152
152
  export {
153
- overlaysToVars,
154
- OVERLAY_VAR_NAMES,
155
- applyOverlayVarsToState,
156
- makeDefaultOverlaysState,
157
- makeDefaultOverlayTokens,
158
- makeDefaultHoverTokens,
159
- overlayTokenToCss,
160
- parseOverlayCss,
161
- } from '../themes/slices/overlays';
153
+ washesToVars,
154
+ WASH_VAR_NAMES,
155
+ applyWashVarsToState,
156
+ makeDefaultWashesState,
157
+ makeDefaultScrimTokens,
158
+ makeDefaultTintTokens,
159
+ washTokenToCss,
160
+ parseWashCss,
161
+ } from '../themes/slices/washes';
162
162
 
163
163
  export {
164
164
  shadowsToVars,
@@ -364,7 +364,7 @@ type DomainLoader = (next: EditorState, rawVars: Record<string, string>) => void
364
364
 
365
365
  const domainLoaders: Record<string, DomainLoader> = {
366
366
  columns: loadColumnsFromVars,
367
- overlays: loadOverlaysFromVars,
367
+ washes: loadWashesFromVars,
368
368
  shadows: loadShadowsFromVars,
369
369
  components: loadComponentsFromVars,
370
370
  };
@@ -428,7 +428,7 @@ function colorsAndTypeContent(state: EditorState): Omit<ColorsAndType, 'name' |
428
428
  if (!columnsEqualsDefault(state.columns)) {
429
429
  Object.assign(cssVariables, columnsToVars(state.columns));
430
430
  }
431
- Object.assign(cssVariables, overlaysToVars(state.overlays));
431
+ Object.assign(cssVariables, washesToVars(state.washes));
432
432
  if (state.shadows.tokens.length > 0) {
433
433
  Object.assign(cssVariables, shadowsToVars(state.shadows));
434
434
  }
@@ -450,7 +450,7 @@ function colorsAndTypeContent(state: EditorState): Omit<ColorsAndType, 'name' |
450
450
 
451
451
  /**
452
452
  * Serialize current state for saving. Domains with their own typed state
453
- * (columns, overlays, shadows) fold derived vars into `cssVariables` only
453
+ * (columns, washes, shadows) fold derived vars into `cssVariables` only
454
454
  * when they diverge from defaults; the catch-all `cssVars` bag carries
455
455
  * everything not yet migrated to a typed domain.
456
456
  *
@@ -23,9 +23,10 @@ export interface ShadowOverrideFlags {
23
23
  distance: boolean; blur: boolean; size: boolean;
24
24
  }
25
25
 
26
- /** Overlay stop: an aliased color token + an opacity. Emits as
27
- * `color-mix(in srgb, var(<alias>) <opacity%>, transparent)`. */
28
- export interface OverlayToken {
26
+ /** A wash: an aliased color token + an opacity. Emits as
27
+ * `color-mix(in srgb, var(<alias>) <opacity%>, transparent)`. Scrims dim what
28
+ * is behind them; tints shade the surface they sit on. */
29
+ export interface WashToken {
29
30
  variable: string;
30
31
  label: string;
31
32
  alias: string;
@@ -111,9 +112,9 @@ export interface EditorState {
111
112
  tokens: ShadowToken[];
112
113
  overrides: Record<string, ShadowOverrideFlags>;
113
114
  };
114
- overlays: {
115
- tokens: OverlayToken[];
116
- hoverTokens: OverlayToken[];
115
+ washes: {
116
+ scrims: WashToken[];
117
+ tints: WashToken[];
117
118
  };
118
119
  columns: ColumnsState;
119
120
  components: Record<string, ComponentSlice>;
@@ -0,0 +1,31 @@
1
+ import type { Migration } from './index';
2
+
3
+ /**
4
+ * Gate suffix, state word to `-enabled` (2026-09-01).
5
+ *
6
+ * An optional interaction is switched by a gate variable. Those gates were named
7
+ * with a state word at the end (`--card-hover-border-active`,
8
+ * `--image-zoom-hover`), which reads as state-after-property and fails
9
+ * `check-component`. A gate is not a state: it says whether the interaction is
10
+ * on at all, so it takes `-enabled`.
11
+ *
12
+ * Values are unchanged; a gate still holds either its off value or its on value.
13
+ */
14
+ const RENAMED: Record<string, string> = {
15
+ '--card-hover-border-active': '--card-hover-border-enabled',
16
+ '--card-hover-shadow-active': '--card-hover-shadow-enabled',
17
+ '--image-zoom-hover': '--image-zoom-enabled',
18
+ '--image-grow-hover': '--image-grow-enabled',
19
+ };
20
+
21
+ export const componentMigration_2026_09_01_gateSuffixEnabled: Migration = {
22
+ id: '2026-09-01-gate-suffix-enabled',
23
+ fromVersion: 24,
24
+ toVersion: 25,
25
+ appliesTo: 'component-config',
26
+ apply(rawVars) {
27
+ const out: Record<string, string> = {};
28
+ for (const [key, value] of Object.entries(rawVars)) out[RENAMED[key] ?? key] = value;
29
+ return out;
30
+ },
31
+ };
@@ -0,0 +1,52 @@
1
+ import type { Migration } from './index';
2
+
3
+ /**
4
+ * Scrim rename (2026-09-01): `--overlay-*` became `--scrim-*`.
5
+ *
6
+ * An overlay is a scrim, a translucent layer that dims what is behind it. The
7
+ * name had spread to cover surface tints too, which are the opposite operation,
8
+ * and it collided with `backdrop`, an exported concept about what paints behind
9
+ * an element. Each name now means one thing.
10
+ *
11
+ * Values are unchanged, so this is a pure key rename in colors-and-type files.
12
+ * Component configs need both halves: Dialog's `--dialog-overlay-surface` is a
13
+ * key, and any alias *value* pointing at a scrim stop is a bare token name that
14
+ * has to be rebound.
15
+ */
16
+ const RENAMED: Record<string, string> = {
17
+ '--overlay-low': '--scrim-low',
18
+ '--overlay': '--scrim',
19
+ '--overlay-high': '--scrim-high',
20
+ };
21
+
22
+ const RENAMED_COMPONENT_KEYS: Record<string, string> = {
23
+ '--dialog-overlay-surface': '--dialog-scrim-surface',
24
+ };
25
+
26
+ export const colorsAndTypeMigration_2026_09_01_scrimRename: Migration = {
27
+ id: '2026-09-01-scrim-rename-theme',
28
+ fromVersion: 5,
29
+ toVersion: 6,
30
+ appliesTo: 'colors-and-type',
31
+ apply(rawVars) {
32
+ const out: Record<string, string> = {};
33
+ for (const [key, value] of Object.entries(rawVars)) {
34
+ out[RENAMED[key] ?? key] = value;
35
+ }
36
+ return out;
37
+ },
38
+ };
39
+
40
+ export const componentMigration_2026_09_01_scrimRename: Migration = {
41
+ id: '2026-09-01-scrim-rename-component',
42
+ fromVersion: 21,
43
+ toVersion: 22,
44
+ appliesTo: 'component-config',
45
+ apply(rawVars) {
46
+ const out: Record<string, string> = {};
47
+ for (const [key, value] of Object.entries(rawVars)) {
48
+ out[RENAMED_COMPONENT_KEYS[key] ?? key] = RENAMED[value] ?? value;
49
+ }
50
+ return out;
51
+ },
52
+ };
@@ -0,0 +1,27 @@
1
+ import type { Migration } from './index';
2
+
3
+ /**
4
+ * TabBar active surface, scrim to tint (2026-09-01, tabbar only).
5
+ *
6
+ * The active tab is a wash *on* the bar, not a layer dimming what is behind it,
7
+ * so it belongs to the tint family. It only ever read a scrim because no tint
8
+ * family existed. Rebinding is a visible change: the 38% near-black scrim
9
+ * becomes a 10% white tint, so the active tab reads lighter rather than darker.
10
+ *
11
+ * Scoped to the shipped default value. A theme that pointed this alias
12
+ * somewhere else keeps its choice.
13
+ */
14
+ export const componentMigration_2026_09_01_tabbarActiveTint: Migration = {
15
+ id: '2026-09-01-tabbar-active-tint',
16
+ fromVersion: 23,
17
+ toVersion: 24,
18
+ appliesTo: 'component-config',
19
+ apply(rawVars, meta) {
20
+ if (meta.component !== 'tabbar') return { ...rawVars };
21
+ const out = { ...rawVars };
22
+ if (out['--tabbar-active-surface'] === '--scrim-low') {
23
+ out['--tabbar-active-surface'] = '--tint-low';
24
+ }
25
+ return out;
26
+ },
27
+ };
@@ -0,0 +1,42 @@
1
+ import type { Migration } from './index';
2
+
3
+ /**
4
+ * Tint rename (2026-09-01): `--hover-*` became `--tint-*`.
5
+ *
6
+ * A state is a segment of a property name (`--button-outline-hover-surface`),
7
+ * not a token of its own, so the three stops are named for what they are: a
8
+ * tint shades the surface it sits on. Values are unchanged.
9
+ *
10
+ * Nothing shipped read the old names, so this only touches saved files that
11
+ * captured them from the editor's own stops.
12
+ */
13
+ const RENAMED: Record<string, string> = {
14
+ '--hover-low': '--tint-low',
15
+ '--hover': '--tint',
16
+ '--hover-high': '--tint-high',
17
+ };
18
+
19
+ export const colorsAndTypeMigration_2026_09_01_tintRename: Migration = {
20
+ id: '2026-09-01-tint-rename-theme',
21
+ fromVersion: 6,
22
+ toVersion: 7,
23
+ appliesTo: 'colors-and-type',
24
+ apply(rawVars) {
25
+ const out: Record<string, string> = {};
26
+ for (const [key, value] of Object.entries(rawVars)) out[RENAMED[key] ?? key] = value;
27
+ return out;
28
+ },
29
+ };
30
+
31
+ export const componentMigration_2026_09_01_tintRename: Migration = {
32
+ id: '2026-09-01-tint-rename-component',
33
+ fromVersion: 22,
34
+ toVersion: 23,
35
+ appliesTo: 'component-config',
36
+ apply(rawVars) {
37
+ const out: Record<string, string> = {};
38
+ // Alias values are bare token names; rebind any that pointed at a hover stop.
39
+ for (const [key, value] of Object.entries(rawVars)) out[key] = RENAMED[value] ?? value;
40
+ return out;
41
+ },
42
+ };
@@ -62,6 +62,16 @@ import {
62
62
  colorsAndTypeMigration_2026_08_15_lineHeightScaleRename,
63
63
  componentMigration_2026_08_15_lineHeightScaleRename,
64
64
  } from './2026-08-15-line-height-scale-rename';
65
+ import {
66
+ colorsAndTypeMigration_2026_09_01_scrimRename,
67
+ componentMigration_2026_09_01_scrimRename,
68
+ } from './2026-09-01-scrim-rename';
69
+ import {
70
+ colorsAndTypeMigration_2026_09_01_tintRename,
71
+ componentMigration_2026_09_01_tintRename,
72
+ } from './2026-09-01-tint-rename';
73
+ import { componentMigration_2026_09_01_tabbarActiveTint } from './2026-09-01-tabbar-active-tint';
74
+ import { componentMigration_2026_09_01_gateSuffixEnabled } from './2026-09-01-gate-suffix-enabled';
65
75
 
66
76
  /**
67
77
  * Registered migrations. Order in this array does not matter — the runner
@@ -94,6 +104,12 @@ export const MIGRATIONS: Migration[] = [
94
104
  colorsAndTypeMigration_2026_08_13_dropLegacyShapeSpaceKeys,
95
105
  colorsAndTypeMigration_2026_08_15_lineHeightScaleRename,
96
106
  componentMigration_2026_08_15_lineHeightScaleRename,
107
+ colorsAndTypeMigration_2026_09_01_scrimRename,
108
+ componentMigration_2026_09_01_scrimRename,
109
+ colorsAndTypeMigration_2026_09_01_tintRename,
110
+ componentMigration_2026_09_01_tintRename,
111
+ componentMigration_2026_09_01_tabbarActiveTint,
112
+ componentMigration_2026_09_01_gateSuffixEnabled,
97
113
  ];
98
114
 
99
115
  function countFor(kind: 'colors-and-type' | 'component-config'): number {
@@ -5,11 +5,11 @@
5
5
  * so the store stays the single source of truth.
6
6
  */
7
7
  import { COLUMN_VAR_NAMES } from './columns';
8
- import { OVERLAY_VAR_NAMES } from './overlays';
8
+ import { WASH_VAR_NAMES } from './washes';
9
9
  import { SHADOW_VAR_NAMES } from './shadows';
10
10
 
11
11
  export const DOMAIN_VAR_NAMES: readonly string[] = [
12
12
  ...COLUMN_VAR_NAMES,
13
- ...OVERLAY_VAR_NAMES,
13
+ ...WASH_VAR_NAMES,
14
14
  ...SHADOW_VAR_NAMES,
15
15
  ];
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Washes slice — a wash is a translucent layer of color, stored as
3
+ * `{alias, opacity}` and emitted as
4
+ * `color-mix(in srgb, var(<alias>) <pct>%, transparent)`, so it follows its
5
+ * aliased source token (a brand-color shift propagates without re-editing
6
+ * every stop).
7
+ *
8
+ * Two families, opposite jobs. A **scrim** dims what sits behind it, which is
9
+ * what a modal needs. A **tint** shades the surface it sits on, which is what
10
+ * a hover needs. They are not interchangeable: scrims alias a near-black
11
+ * surface, tints alias the text color, so a tint lightens a dark theme and
12
+ * darkens a light one without picking a direction.
13
+ *
14
+ * Defaults diverge from tokens.css by design: the editor starts from a
15
+ * neutral alias and tokens.css continues to win until first edit.
16
+ */
17
+ import type { EditorState, WashToken } from '../../store/editorTypes';
18
+
19
+ export function makeDefaultScrimTokens(): WashToken[] {
20
+ return [
21
+ { variable: '--scrim-low', label: 'Low', alias: '--surface-neutral-lowest', opacity: 0.38 },
22
+ { variable: '--scrim', label: 'Base', alias: '--surface-neutral-lowest', opacity: 0.51 },
23
+ { variable: '--scrim-high', label: 'High', alias: '--surface-neutral-lowest', opacity: 0.64 },
24
+ ];
25
+ }
26
+
27
+ export function makeDefaultTintTokens(): WashToken[] {
28
+ return [
29
+ { variable: '--tint-low', label: 'Low', alias: '--text-primary', opacity: 0.05 },
30
+ { variable: '--tint', label: 'Base', alias: '--text-primary', opacity: 0.1 },
31
+ { variable: '--tint-high', label: 'High', alias: '--text-primary', opacity: 0.15 },
32
+ ];
33
+ }
34
+
35
+ export function makeDefaultWashesState(): EditorState['washes'] {
36
+ return {
37
+ scrims: makeDefaultScrimTokens(),
38
+ tints: makeDefaultTintTokens(),
39
+ };
40
+ }
41
+
42
+ export const WASH_VAR_NAMES = [
43
+ '--scrim-low', '--scrim', '--scrim-high',
44
+ '--tint-low', '--tint', '--tint-high',
45
+ ] as const;
46
+
47
+ export function washTokenToCss(t: WashToken): string {
48
+ const pct = Math.round(t.opacity * 100);
49
+ if (pct >= 100) return `var(${t.alias})`;
50
+ return `color-mix(in srgb, var(${t.alias}) ${pct}%, transparent)`;
51
+ }
52
+
53
+ const COLOR_MIX_RE = /^color-mix\(in srgb,\s*var\((--[a-z0-9-]+)\)\s+(\d+)%,\s*transparent\)$/i;
54
+ const PLAIN_VAR_RE = /^var\((--[a-z0-9-]+)\)$/i;
55
+
56
+ export function parseWashCss(raw: string): { alias: string; opacity: number } | null {
57
+ const s = raw.trim();
58
+ const mix = s.match(COLOR_MIX_RE);
59
+ if (mix) {
60
+ const pct = parseInt(mix[2], 10);
61
+ if (!Number.isFinite(pct)) return null;
62
+ return { alias: mix[1], opacity: Math.max(0, Math.min(100, pct)) / 100 };
63
+ }
64
+ const plain = s.match(PLAIN_VAR_RE);
65
+ if (plain) return { alias: plain[1], opacity: 1 };
66
+ return null;
67
+ }
68
+
69
+ export function washesToVars(w: EditorState['washes']): Record<string, string> {
70
+ const out: Record<string, string> = {};
71
+ for (const t of w.scrims) out[t.variable] = washTokenToCss(t);
72
+ for (const t of w.tints) out[t.variable] = washTokenToCss(t);
73
+ return out;
74
+ }
75
+
76
+ export function applyWashVarsToState(washes: EditorState['washes'], vars: Record<string, string>): void {
77
+ const applyTo = (list: WashToken[]) => {
78
+ for (const t of list) {
79
+ const raw = vars[t.variable];
80
+ if (!raw) continue;
81
+ const parsed = parseWashCss(raw);
82
+ if (!parsed) continue;
83
+ t.alias = parsed.alias;
84
+ t.opacity = parsed.opacity;
85
+ }
86
+ };
87
+ applyTo(washes.scrims);
88
+ applyTo(washes.tints);
89
+ }
90
+
91
+ /**
92
+ * Loader: route wash entries from a freshly-loaded theme's vars bag into
93
+ * `next.washes` and remove them from the bag — but only when the value parses
94
+ * as the new format (color-mix / plain var). Legacy rgba values pass through to
95
+ * the cssVars bag so the DOM still paints them; the user's next edit in the
96
+ * picker promotes them to the typed slice.
97
+ */
98
+ export function loadWashesFromVars(
99
+ next: EditorState,
100
+ rawVars: Record<string, string>,
101
+ ): void {
102
+ applyWashVarsToState(next.washes, rawVars);
103
+ for (const name of WASH_VAR_NAMES) {
104
+ const raw = rawVars[name];
105
+ if (raw && parseWashCss(raw) !== null) delete rawVars[name];
106
+ }
107
+ }
@@ -57,10 +57,12 @@ Numeric scales with a slider per step.
57
57
 
58
58
  Change a step and every element using it repaints.
59
59
 
60
- ## Overlays and gradients
60
+ ## Washes and gradients
61
61
 
62
- - **Overlays** are translucent tints layered over surfaces, like the subtle
63
- tint a card gets on hover. Set a colour and opacity per state.
62
+ - **Scrims** are translucent layers that dim what sits behind them, like the
63
+ one a dialog draws over the page. Set a colour and opacity per stop.
64
+ - **Tints** are the opposite operation: they shade the surface they sit on
65
+ rather than dimming what is behind it, which is what a hover needs.
64
66
  - **Gradients** are reusable gradient tokens with a stop list and direction, for
65
67
  hero panels and accent backgrounds.
66
68