@motion-proto/live-tokens 0.41.0 → 0.42.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.
- package/CHANGELOG.md +55 -0
- package/dist-plugin/index.cjs +1 -1
- package/dist-plugin/index.js +1 -1
- package/package.json +1 -1
- package/src/editor/core/palettes/colorHarmony.ts +7 -53
- package/src/editor/core/palettes/paletteDerivation.ts +1 -1
- package/src/editor/core/store/editorPersistence.ts +25 -15
- package/src/editor/core/store/editorStore.ts +15 -14
- package/src/editor/core/themes/migrations/2026-07-29-background-palette-to-canvas.ts +41 -0
- package/src/editor/core/themes/migrations/2026-07-29-place-base-anchors.ts +29 -0
- package/src/editor/core/themes/themeTypes.ts +0 -5
- package/src/editor/ui/colors/ColorStory.svelte +6 -6
- package/src/editor/ui/colors/ColorsTab.svelte +52 -30
- package/src/live-tokens/data/themes/default.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.42.0 — Canvas, and every ramp places its base color
|
|
4
|
+
|
|
5
|
+
### Changed (breaking)
|
|
6
|
+
|
|
7
|
+
- **The "Background" palette family is renamed to "Canvas".** Its CSS namespace
|
|
8
|
+
was always `canvas` (`--color-canvas-*`, `--surface-canvas-*`), and every other
|
|
9
|
+
surface that names the family already said Canvas: the token selector,
|
|
10
|
+
SectionDivider's variant families, the shadows backdrop picker. Only the
|
|
11
|
+
palette label said Background, so the label joins the namespace. **No token
|
|
12
|
+
names change**, so consumer CSS is unaffected. The label doubles as a storage
|
|
13
|
+
key, so theme migration `2026-07-29-background-palette-to-canvas` renames the
|
|
14
|
+
`editorConfigs` key and any `harmonyAxes` family binding. It applies
|
|
15
|
+
automatically both when a theme file loads and when a persisted editor session
|
|
16
|
+
hydrates, and it runs before the axes are sanitized, which would otherwise drop
|
|
17
|
+
the now-ineligible family and silently unbind the axis. The shipped
|
|
18
|
+
`default.json` is rewritten under the new key.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **The Colors view's swatch rows read as ramps.** Both rows (core families and
|
|
23
|
+
functional families) order their swatches by base-color luminance, light to
|
|
24
|
+
dark, matching the palette ramps. The selected swatch dock-magnifies through
|
|
25
|
+
the same `dockGrow` policy the palette grid and the derived-scale strip already
|
|
26
|
+
share, and it animates to its new position as its lightness is dialed, so the
|
|
27
|
+
selection is legible while it walks the row.
|
|
28
|
+
|
|
29
|
+
### Removed
|
|
30
|
+
|
|
31
|
+
- `Theme.harmonyOrder` and its migration path. The field only ever existed
|
|
32
|
+
between v0.40.1 and v0.41.0, superseded by `harmonyAxes` inside the same
|
|
33
|
+
release cycle, so nothing on disk carries it: a 0.41.0-saved theme wrote both
|
|
34
|
+
fields and `harmonyAxes` was already authoritative on load. Themes with
|
|
35
|
+
neither field still resolve to the default axes, with the unbound Quaternary
|
|
36
|
+
offset from the anchor's own hue.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **Palette ramps now show the enlarged slot for the step the base color sits
|
|
41
|
+
at.** The dock magnification keys off `anchorPlacement`, which the July 24
|
|
42
|
+
migration only wrote for configs carrying the legacy locked-500 curve anchor;
|
|
43
|
+
a palette whose `anchorToBase` was on but never re-edited had no placement, so
|
|
44
|
+
its ramp never magnified anything. Theme migration
|
|
45
|
+
`2026-07-29-place-base-anchors` places those configs up front by pinning the
|
|
46
|
+
base color at the step whose curve lightness is nearest the base L, the same
|
|
47
|
+
thing a base-color edit has always done. It applies on theme load and on
|
|
48
|
+
session hydrate, skips configs that are already placed or have `anchorToBase`
|
|
49
|
+
off, and self-sunsets once every theme has been resaved with a placement.
|
|
50
|
+
|
|
51
|
+
### Migration
|
|
52
|
+
|
|
53
|
+
- Both migrations in this release are **theme-file migrations**: they run
|
|
54
|
+
automatically when a theme loads and when a persisted editor session hydrates.
|
|
55
|
+
No token names changed, so `tokens.css` is untouched and there is nothing for
|
|
56
|
+
`npx live-tokens migrate` to do. Consumers upgrade by bumping the dependency.
|
|
57
|
+
|
|
3
58
|
## 0.41.0 — A Colors view, and semantic text styles
|
|
4
59
|
|
|
5
60
|
### Added
|
package/dist-plugin/index.cjs
CHANGED
|
@@ -198,7 +198,7 @@ function sampleCurve(anchors, xPos) {
|
|
|
198
198
|
var PALETTE_SPECS = [
|
|
199
199
|
{ label: "Brand", cssNamespace: "brand", initialColor: { l: 0.5573, c: 0.1841, h: 25.49 } },
|
|
200
200
|
{ label: "Accent", cssNamespace: "accent", initialColor: { l: 0.7674, c: 0.164, h: 70.44 } },
|
|
201
|
-
{ label: "
|
|
201
|
+
{ label: "Canvas", cssNamespace: "canvas", emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
|
|
202
202
|
{ label: "Neutral", cssNamespace: "neutral", neutral: true, initialColor: { l: 0.5679, c: 0.0134, h: 240.07 } },
|
|
203
203
|
{ label: "Alternate", cssNamespace: "alternate", neutral: true, displayLabel: "Alternate (neutral)", initialColor: { l: 0.5873, c: 87e-4, h: 48.57 } },
|
|
204
204
|
{ label: "Special", cssNamespace: "special", initialColor: { l: 0.6056, c: 0.2189, h: 292.72 } },
|
package/dist-plugin/index.js
CHANGED
|
@@ -157,7 +157,7 @@ function sampleCurve(anchors, xPos) {
|
|
|
157
157
|
var PALETTE_SPECS = [
|
|
158
158
|
{ label: "Brand", cssNamespace: "brand", initialColor: { l: 0.5573, c: 0.1841, h: 25.49 } },
|
|
159
159
|
{ label: "Accent", cssNamespace: "accent", initialColor: { l: 0.7674, c: 0.164, h: 70.44 } },
|
|
160
|
-
{ label: "
|
|
160
|
+
{ label: "Canvas", cssNamespace: "canvas", emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
|
|
161
161
|
{ label: "Neutral", cssNamespace: "neutral", neutral: true, initialColor: { l: 0.5679, c: 0.0134, h: 240.07 } },
|
|
162
162
|
{ label: "Alternate", cssNamespace: "alternate", neutral: true, displayLabel: "Alternate (neutral)", initialColor: { l: 0.5873, c: 87e-4, h: 48.57 } },
|
|
163
163
|
{ label: "Special", cssNamespace: "special", initialColor: { l: 0.6056, c: 0.2189, h: 292.72 } },
|
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@ export type HarmonyMode =
|
|
|
21
21
|
| 'custom';
|
|
22
22
|
|
|
23
23
|
/** Families the user may order/include on the harmony axes (dev-declared pool). */
|
|
24
|
-
export const HARMONY_ELIGIBLE: readonly string[] = ['Brand', 'Accent', '
|
|
24
|
+
export const HARMONY_ELIGIBLE: readonly string[] = ['Brand', 'Accent', 'Canvas', 'Special', 'Neutral', 'Alternate'];
|
|
25
25
|
|
|
26
26
|
export const AXIS_COUNT = 4;
|
|
27
27
|
export const AXIS_ROLES = ['Anchor', 'Secondary', 'Tertiary', 'Quaternary'] as const;
|
|
@@ -33,29 +33,6 @@ export interface HarmonyAxis {
|
|
|
33
33
|
family: string | null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// Slot 0 is the anchor (Brand), then Accent, Background — the pre-axes default.
|
|
37
|
-
const LEGACY_DEFAULT_ORDER: readonly string[] = ['Brand', 'Accent', 'Background'];
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Coerce an untrusted legacy `harmonyOrder` (theme JSON) into a valid order:
|
|
41
|
-
* keep only eligible entries, first occurrence of each. A non-array, empty, or
|
|
42
|
-
* fully-invalid input falls back to the default order. Private to
|
|
43
|
-
* `axesFromLegacyOrder`, the sole migration entry point.
|
|
44
|
-
*/
|
|
45
|
-
function sanitizeLegacyOrder(input: unknown): string[] {
|
|
46
|
-
if (!Array.isArray(input)) return [...LEGACY_DEFAULT_ORDER];
|
|
47
|
-
const seen = new Set<string>();
|
|
48
|
-
const out: string[] = [];
|
|
49
|
-
for (const entry of input) {
|
|
50
|
-
if (typeof entry !== 'string') continue;
|
|
51
|
-
if (!HARMONY_ELIGIBLE.includes(entry)) continue;
|
|
52
|
-
if (seen.has(entry)) continue;
|
|
53
|
-
seen.add(entry);
|
|
54
|
-
out.push(entry);
|
|
55
|
-
}
|
|
56
|
-
return out.length > 0 ? out : [...LEGACY_DEFAULT_ORDER];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
36
|
// In-range hues pass through bit-exact: the mod-360 round trip perturbs the
|
|
60
37
|
// last mantissa bits, which would make a preserved hue compare unequal.
|
|
61
38
|
const norm = (h: number): number => (h >= 0 && h < 360 ? h : ((h % 360) + 360) % 360);
|
|
@@ -116,7 +93,7 @@ export function defaultHarmonyAxes(): HarmonyAxis[] {
|
|
|
116
93
|
return [
|
|
117
94
|
{ hue: anchorHue, family: 'Brand' },
|
|
118
95
|
{ hue: specInitialHue('Accent'), family: 'Accent' },
|
|
119
|
-
{ hue: specInitialHue('
|
|
96
|
+
{ hue: specInitialHue('Canvas'), family: 'Canvas' },
|
|
120
97
|
{ hue: norm(anchorHue + 270), family: null },
|
|
121
98
|
];
|
|
122
99
|
}
|
|
@@ -196,33 +173,10 @@ export function sanitizeHarmonyAxes(
|
|
|
196
173
|
axis.hue = norm(palettes[axis.family].baseColor.h);
|
|
197
174
|
}
|
|
198
175
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
* the same eligibility/dedup rules as the old model), seeded at its palette's
|
|
205
|
-
* `baseColor.h` (or the family's spec hue when no palette loaded). Unfilled axes
|
|
206
|
-
* stay unbound at the default hues, Quaternary offset from the migrated anchor.
|
|
207
|
-
*/
|
|
208
|
-
export function axesFromLegacyOrder(
|
|
209
|
-
order: unknown,
|
|
210
|
-
palettes: Record<string, PaletteConfig>,
|
|
211
|
-
): HarmonyAxis[] {
|
|
212
|
-
const legacy = sanitizeLegacyOrder(order);
|
|
213
|
-
const defaults = defaultHarmonyAxes();
|
|
214
|
-
const hueOf = (family: string): number =>
|
|
215
|
-
norm(palettes[family] ? palettes[family].baseColor.h : specInitialHue(family));
|
|
216
|
-
const axes: HarmonyAxis[] = [];
|
|
217
|
-
for (let i = 0; i < AXIS_COUNT; i++) {
|
|
218
|
-
const family = legacy[i] ?? null;
|
|
219
|
-
if (family) {
|
|
220
|
-
axes.push({ hue: hueOf(family), family });
|
|
221
|
-
} else if (i === AXIS_COUNT - 1) {
|
|
222
|
-
axes.push({ hue: norm(axes[0].hue + 270), family: null });
|
|
223
|
-
} else {
|
|
224
|
-
axes.push({ hue: defaults[i].hue, family: null });
|
|
225
|
-
}
|
|
226
|
-
}
|
|
176
|
+
// With no stored axes (a theme predating them), the default Quaternary hue is
|
|
177
|
+
// an offset from the anchor, so it follows the anchor's palette-seeded hue
|
|
178
|
+
// rather than the spec hue the defaults were built from.
|
|
179
|
+
const last = axes[AXIS_COUNT - 1];
|
|
180
|
+
if (!Array.isArray(input) && last.family === null) last.hue = norm(axes[0].hue + 270);
|
|
227
181
|
return axes;
|
|
228
182
|
}
|
|
@@ -40,7 +40,7 @@ export interface PaletteSpec {
|
|
|
40
40
|
export const PALETTE_SPECS: readonly PaletteSpec[] = [
|
|
41
41
|
{ label: 'Brand', cssNamespace: 'brand', initialColor: { l: 0.5573, c: 0.1841, h: 25.49 } },
|
|
42
42
|
{ label: 'Accent', cssNamespace: 'accent', initialColor: { l: 0.7674, c: 0.164, h: 70.44 } },
|
|
43
|
-
{ label: '
|
|
43
|
+
{ label: 'Canvas', cssNamespace: 'canvas', emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
|
|
44
44
|
{ label: 'Neutral', cssNamespace: 'neutral', neutral: true, initialColor: { l: 0.5679, c: 0.0134, h: 240.07 } },
|
|
45
45
|
{ label: 'Alternate', cssNamespace: 'alternate', neutral: true, displayLabel: 'Alternate (neutral)', initialColor: { l: 0.5873, c: 0.0087, h: 48.57 } },
|
|
46
46
|
{ label: 'Special', cssNamespace: 'special', initialColor: { l: 0.6056, c: 0.2189, h: 292.72 } },
|
|
@@ -20,7 +20,12 @@ import { store } from './editorCore';
|
|
|
20
20
|
import { quietGet, quietSet } from '../storage/storage';
|
|
21
21
|
import { makeDefaultGradients } from '../themes/slices/gradients';
|
|
22
22
|
import { seedShadowsFromDom } from '../themes/slices/shadows';
|
|
23
|
-
import { sanitizeHarmonyAxes
|
|
23
|
+
import { sanitizeHarmonyAxes } from '../palettes/colorHarmony';
|
|
24
|
+
import {
|
|
25
|
+
renameBackgroundPaletteKey,
|
|
26
|
+
renameBackgroundHarmonyFamily,
|
|
27
|
+
} from '../themes/migrations/2026-07-29-background-palette-to-canvas';
|
|
28
|
+
import { placeUnplacedBaseAnchors } from '../themes/migrations/2026-07-29-place-base-anchors';
|
|
24
29
|
|
|
25
30
|
// Resolve the persist key lazily (per-call) so library consumers that invoke
|
|
26
31
|
// `configureEditor({storagePrefix})` before the first store write get the
|
|
@@ -88,20 +93,25 @@ export function normalizeComponents(state: EditorState): EditorState {
|
|
|
88
93
|
return { ...state, components };
|
|
89
94
|
}
|
|
90
95
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
// A session persisted before the rename keys its palette by the old label and
|
|
97
|
+
// binds an axis to it. Both have to move before the axes are sanitized, which
|
|
98
|
+
// would otherwise drop the now-ineligible family and unbind the axis.
|
|
99
|
+
export function normalizePaletteLabels(state: EditorState): EditorState {
|
|
100
|
+
return { ...state, palettes: renameBackgroundPaletteKey(state.palettes) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// `hydrate` shallow-merges persisted state over `emptyState()`, so the axes that
|
|
104
|
+
// arrive here may be the injected defaults sitting over the session's real
|
|
105
|
+
// palettes. Reconcile them against those palettes.
|
|
97
106
|
export function normalizeHarmonyAxes(state: EditorState): EditorState {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
const axes = renameBackgroundHarmonyFamily(state.harmonyAxes);
|
|
108
|
+
return { ...state, harmonyAxes: sanitizeHarmonyAxes(axes, state.palettes) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A session persisted before placements existed has anchored palettes with no
|
|
112
|
+
// `anchorPlacement`, so their ramps never show the anchored slot.
|
|
113
|
+
export function normalizeBaseAnchors(state: EditorState): EditorState {
|
|
114
|
+
return { ...state, palettes: placeUnplacedBaseAnchors(state.palettes) };
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
export function hydrate(): void {
|
|
@@ -112,7 +122,7 @@ export function hydrate(): void {
|
|
|
112
122
|
// Shallow-merge onto default shape so older persisted state missing
|
|
113
123
|
// newly-added domain fields still loads.
|
|
114
124
|
const merged = { ...emptyStateFactory(), ...(parsed as object) } as EditorState;
|
|
115
|
-
store.set(normalizeHarmonyAxes(normalizeComponents(migrateGradients(merged))));
|
|
125
|
+
store.set(normalizeHarmonyAxes(normalizeBaseAnchors(normalizePaletteLabels(normalizeComponents(migrateGradients(merged))))));
|
|
116
126
|
}
|
|
117
127
|
// m13 fix: seed shadows from the DOM at hydrate time so the editor
|
|
118
128
|
// captures the tokens.css baseline regardless of whether the user opens
|
|
@@ -28,11 +28,16 @@ import {
|
|
|
28
28
|
runMigrations,
|
|
29
29
|
} from '../themes/migrations';
|
|
30
30
|
import { renamePrimaryPaletteKey } from '../themes/migrations/2026-05-13-primary-to-brand';
|
|
31
|
+
import {
|
|
32
|
+
renameBackgroundPaletteKey,
|
|
33
|
+
renameBackgroundHarmonyFamily,
|
|
34
|
+
} from '../themes/migrations/2026-07-29-background-palette-to-canvas';
|
|
31
35
|
import { unifyGrayPalettes } from '../themes/migrations/2026-06-05-palette-unification';
|
|
32
36
|
import { migratePaletteColorsToOklch, type PreOklchPaletteConfig } from '../themes/migrations/2026-07-21-palette-oklch-basis';
|
|
33
37
|
import { adoptLegacyBaseAnchor } from '../themes/migrations/2026-07-24-base-anchor-placement';
|
|
38
|
+
import { placeUnplacedBaseAnchors } from '../themes/migrations/2026-07-29-place-base-anchors';
|
|
34
39
|
import { adoptBackgroundSpotAsBase } from '../themes/migrations/2026-07-25-background-spot-to-base';
|
|
35
|
-
import { defaultHarmonyAxes, sanitizeHarmonyAxes
|
|
40
|
+
import { defaultHarmonyAxes, sanitizeHarmonyAxes } from '../palettes/colorHarmony';
|
|
36
41
|
import { __resetRendererCacheForTests, installRenderer } from './editorRenderer';
|
|
37
42
|
import {
|
|
38
43
|
store,
|
|
@@ -517,13 +522,15 @@ const domainLoaders: Record<string, DomainLoader> = {
|
|
|
517
522
|
*/
|
|
518
523
|
export function loadFromFile(theme: Theme): void {
|
|
519
524
|
const next = emptyState();
|
|
520
|
-
// Structural palette migrations, innermost → outermost: rename Primary→Brand
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
+
// Structural palette migrations, innermost → outermost: rename Primary→Brand
|
|
526
|
+
// and Background→Canvas (both key renames, so they precede anything that looks
|
|
527
|
+
// a palette up by label), drop the gray "mode", convert every color channel to
|
|
528
|
+
// the numeric OKLCH basis, adopt any legacy locked-500 anchor as a persisted
|
|
529
|
+
// placement, fold a legacy background spot into the base color, then place any
|
|
530
|
+
// still-unplaced anchor by base luminance. The clone is still pre-basis (hex
|
|
531
|
+
// or already-numeric) on disk.
|
|
525
532
|
const raw = structuredClone(theme.editorConfigs ?? {}) as Record<string, PreOklchPaletteConfig>;
|
|
526
|
-
next.palettes = adoptBackgroundSpotAsBase(adoptLegacyBaseAnchor(migratePaletteColorsToOklch(unifyGrayPalettes(renamePrimaryPaletteKey(raw)))));
|
|
533
|
+
next.palettes = placeUnplacedBaseAnchors(adoptBackgroundSpotAsBase(adoptLegacyBaseAnchor(migratePaletteColorsToOklch(unifyGrayPalettes(renameBackgroundPaletteKey(renamePrimaryPaletteKey(raw)))))));
|
|
527
534
|
next.fonts.sources = structuredClone(theme.fontSources ?? []);
|
|
528
535
|
next.fonts.stacks = structuredClone(theme.fontStacks ?? []);
|
|
529
536
|
const rawVars = runMigrations(
|
|
@@ -536,9 +543,7 @@ export function loadFromFile(theme: Theme): void {
|
|
|
536
543
|
// (or in components' case, vars that wouldn't have been in the theme to
|
|
537
544
|
// begin with).
|
|
538
545
|
for (const load of Object.values(domainLoaders)) load(next, rawVars);
|
|
539
|
-
next.harmonyAxes = theme.harmonyAxes
|
|
540
|
-
? sanitizeHarmonyAxes(theme.harmonyAxes, next.palettes)
|
|
541
|
-
: axesFromLegacyOrder(theme.harmonyOrder, next.palettes);
|
|
546
|
+
next.harmonyAxes = sanitizeHarmonyAxes(renameBackgroundHarmonyFamily(theme.harmonyAxes), next.palettes);
|
|
542
547
|
next.cssVars = rawVars;
|
|
543
548
|
resetHistoryForLoad();
|
|
544
549
|
store.set(next);
|
|
@@ -564,9 +569,6 @@ export function toTheme(state: EditorState, meta: { name: string }): Theme {
|
|
|
564
569
|
if (state.shadows.tokens.length > 0) {
|
|
565
570
|
Object.assign(cssVariables, shadowsToVars(state.shadows));
|
|
566
571
|
}
|
|
567
|
-
// Compat: bound families in axis order for older builds. Omitted when nothing
|
|
568
|
-
// is bound — an empty legacy list would resurrect the default trio on load.
|
|
569
|
-
const boundOrder = state.harmonyAxes.filter((a) => a.family !== null).map((a) => a.family!);
|
|
570
572
|
return {
|
|
571
573
|
name: meta.name,
|
|
572
574
|
createdAt: now,
|
|
@@ -576,7 +578,6 @@ export function toTheme(state: EditorState, meta: { name: string }): Theme {
|
|
|
576
578
|
fontSources: state.fonts.sources,
|
|
577
579
|
fontStacks: state.fonts.stacks,
|
|
578
580
|
harmonyAxes: state.harmonyAxes,
|
|
579
|
-
...(boundOrder.length > 0 ? { harmonyOrder: boundOrder } : {}),
|
|
580
581
|
schemaVersion: CURRENT_THEME_SCHEMA_VERSION,
|
|
581
582
|
};
|
|
582
583
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette family "Background" → "Canvas".
|
|
3
|
+
*
|
|
4
|
+
* The family's CSS namespace was always `canvas` (`--color-canvas-*`,
|
|
5
|
+
* `--surface-canvas-*`), and every other surface that names it — the token
|
|
6
|
+
* selector, SectionDivider's variant families, the shadows backdrop picker —
|
|
7
|
+
* said Canvas too. Only the palette label said Background, so the label joins
|
|
8
|
+
* the namespace rather than the other way round. No token names change.
|
|
9
|
+
*
|
|
10
|
+
* The label doubles as a storage key in two places, so this renames both: the
|
|
11
|
+
* `editorConfigs` map key and any `harmonyAxes` family binding. It has to run
|
|
12
|
+
* before `sanitizeHarmonyAxes`, which drops families outside `HARMONY_ELIGIBLE`
|
|
13
|
+
* to `null` and would silently unbind the axis.
|
|
14
|
+
*
|
|
15
|
+
* Lives outside the `runMigrations` framework (whose contract is the flat
|
|
16
|
+
* cssVariables bag) because it transforms structured state, exactly like
|
|
17
|
+
* `renamePrimaryPaletteKey`, which it composes with in `loadFromFile`. Applied
|
|
18
|
+
* unconditionally; idempotent (a `Canvas` key already present wins, so a second
|
|
19
|
+
* run is a no-op) and self-sunsetting once every saved theme has been resaved.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { HarmonyAxis } from '../../palettes/colorHarmony';
|
|
23
|
+
|
|
24
|
+
const OLD = 'Background';
|
|
25
|
+
const NEW = 'Canvas';
|
|
26
|
+
|
|
27
|
+
export function renameBackgroundPaletteKey<T>(editorConfigs: Record<string, T>): Record<string, T> {
|
|
28
|
+
if (!(OLD in editorConfigs)) return editorConfigs;
|
|
29
|
+
const { [OLD]: legacy, ...rest } = editorConfigs;
|
|
30
|
+
// A hand-merged file could carry both; the already-renamed key is the newer intent.
|
|
31
|
+
return NEW in rest ? rest : { ...rest, [NEW]: legacy };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renameBackgroundHarmonyFamily(axes: unknown): unknown {
|
|
35
|
+
if (!Array.isArray(axes)) return axes;
|
|
36
|
+
return axes.map((axis) =>
|
|
37
|
+
axis && typeof axis === 'object' && (axis as HarmonyAxis).family === OLD
|
|
38
|
+
? { ...(axis as HarmonyAxis), family: NEW }
|
|
39
|
+
: axis,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base-anchor placement for never-edited palettes.
|
|
3
|
+
*
|
|
4
|
+
* `anchorToBase` defaults on, but configs saved before placements existed
|
|
5
|
+
* carry no `anchorPlacement` until the next base-color edit — so their ramps
|
|
6
|
+
* never show the anchored slot. Place them up front: `syncBaseAnchor` pins the
|
|
7
|
+
* base color at the step whose curve lightness is nearest the base L.
|
|
8
|
+
*
|
|
9
|
+
* Composes outermost in `loadFromFile` — it must run after
|
|
10
|
+
* `adoptBackgroundSpotAsBase`, which derives the legacy spot color from the
|
|
11
|
+
* not-yet-pinned curves — and over hydrated session state
|
|
12
|
+
* (`normalizeBaseAnchors`). Idempotent (skips placed configs) and
|
|
13
|
+
* self-sunsetting once every saved theme has been resaved with a placement.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { syncBaseAnchor } from '../../palettes/paletteDerivation';
|
|
17
|
+
import type { PaletteConfig } from '../themeTypes';
|
|
18
|
+
|
|
19
|
+
export function placeUnplacedBaseAnchors(
|
|
20
|
+
editorConfigs: Record<string, PaletteConfig>,
|
|
21
|
+
): Record<string, PaletteConfig> {
|
|
22
|
+
for (const cfg of Object.values(editorConfigs)) {
|
|
23
|
+
if (cfg.anchorPlacement !== undefined || cfg.anchorToBase === false) continue;
|
|
24
|
+
// Disk/storage data: a config missing its basis can't be placed.
|
|
25
|
+
if (!cfg.baseColor || !cfg.lightnessCurve?.length || !cfg.saturationCurve?.length) continue;
|
|
26
|
+
syncBaseAnchor(cfg);
|
|
27
|
+
}
|
|
28
|
+
return editorConfigs;
|
|
29
|
+
}
|
|
@@ -91,11 +91,6 @@ export interface Theme {
|
|
|
91
91
|
cssVariables: Record<string, string>;
|
|
92
92
|
fontSources?: FontSource[];
|
|
93
93
|
fontStacks?: FontStack[];
|
|
94
|
-
/**
|
|
95
|
-
* Deprecated legacy field. Read when `harmonyAxes` is absent; written on save
|
|
96
|
-
* for older builds.
|
|
97
|
-
*/
|
|
98
|
-
harmonyOrder?: string[];
|
|
99
94
|
/** Four fixed harmony axes, each owning a hue with an optional bound family. */
|
|
100
95
|
harmonyAxes?: HarmonyAxis[];
|
|
101
96
|
/**
|
|
@@ -57,21 +57,21 @@
|
|
|
57
57
|
let partialCoverage = $derived(rec.suggestions.filter((s) => !s.current.coverage.full));
|
|
58
58
|
|
|
59
59
|
// Shadow the consumed text variables locally with the store-derived values;
|
|
60
|
-
// the page field is the
|
|
60
|
+
// the page field is the Canvas family's SEED, not its derived page step.
|
|
61
61
|
let storyVars = $derived(
|
|
62
62
|
['--text-primary', '--text-secondary', '--text-tertiary', '--text-inverted']
|
|
63
63
|
.concat([...TONES, ...FUNCTIONAL].map((t) => `--text-${t.ns}`))
|
|
64
64
|
.filter((name) => vars[name] !== undefined)
|
|
65
65
|
.map((name) => `${name}: ${vars[name]}`)
|
|
66
|
-
.concat(`--page-bg: ${seedHex('
|
|
66
|
+
.concat(`--page-bg: ${seedHex('Canvas')}`)
|
|
67
67
|
.join('; '),
|
|
68
68
|
);
|
|
69
69
|
|
|
70
|
-
// Ratios are measured against the DISPLAYED field — the
|
|
70
|
+
// Ratios are measured against the DISPLAYED field — the Canvas seed —
|
|
71
71
|
// so the numbers always describe what the story shows.
|
|
72
72
|
function ratioOnBg(textVar: string): number | null {
|
|
73
73
|
const text = vars[textVar];
|
|
74
|
-
return typeof text === 'string' ? contrastRatio(text, seedHex('
|
|
74
|
+
return typeof text === 'string' ? contrastRatio(text, seedHex('Canvas')) : null;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
// Floor, don't round: the readout must never overstate a contrast ratio.
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
<div class="story" style={storyVars}>
|
|
98
98
|
<div class="field">
|
|
99
99
|
<div class="field-head">
|
|
100
|
-
<span class="field-label">
|
|
100
|
+
<span class="field-label">Canvas</span>
|
|
101
101
|
{#if fmt(bgRatio)}<span class="ratio">{fmt(bgRatio)}</span>{/if}
|
|
102
102
|
</div>
|
|
103
103
|
<p class="body">The dominant surface. Body text sits here.</p>
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
<span class="suggest-entry" class:dim={!s.differs}>
|
|
126
126
|
<span class="fn-dot" style:--dot={s.suggested.hex}></span>
|
|
127
127
|
<span class="suggest-step">{s.step}</span>
|
|
128
|
-
<span class="ratio">{fmt(contrastRatio(s.suggested.hex, seedHex('
|
|
128
|
+
<span class="ratio">{fmt(contrastRatio(s.suggested.hex, seedHex('Canvas')))}</span>
|
|
129
129
|
</span>
|
|
130
130
|
{/each}
|
|
131
131
|
<label class="bw-toggle" title="Anchor the suggestion at pure white or black instead of the softened extreme">
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { flip } from 'svelte/animate';
|
|
3
|
+
import { cubicOut } from 'svelte/easing';
|
|
2
4
|
import { oklchToHexClamped } from '../../core/palettes/oklch';
|
|
3
5
|
import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
|
|
4
6
|
import { editorState, beginSliderGesture, transaction } from '../../core/store/editorStore';
|
|
@@ -13,6 +15,7 @@
|
|
|
13
15
|
import UIPillButton from '../UIPillButton.svelte';
|
|
14
16
|
import { setBaseColor, setBaseColors, setAxisHues, applySolvedTextCurves } from './paletteBaseColor';
|
|
15
17
|
import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
|
|
18
|
+
import { dockGrow } from '../palette/dockMagnify';
|
|
16
19
|
|
|
17
20
|
let selected = $state('Brand');
|
|
18
21
|
let activeMode = $state<HarmonyMode>('custom');
|
|
@@ -71,14 +74,17 @@
|
|
|
71
74
|
label: spec.label,
|
|
72
75
|
display: spec.displayLabel ?? spec.label,
|
|
73
76
|
hex: oklchToHexClamped(l, c, h),
|
|
77
|
+
l,
|
|
74
78
|
onWheel: $editorState.harmonyAxes.some((a, i) => a.family === spec.label && active[i]),
|
|
75
79
|
};
|
|
76
80
|
});
|
|
77
81
|
});
|
|
78
82
|
|
|
79
83
|
const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
// Light → dark, matching the palette ramps (white bookend on the left).
|
|
85
|
+
const byLuminance = (arr: typeof swatches) => [...arr].sort((a, b) => b.l - a.l);
|
|
86
|
+
let coreSwatches = $derived(byLuminance(swatches.filter((sw) => !FUNCTIONAL_FAMILIES.has(sw.label))));
|
|
87
|
+
let functionalSwatches = $derived(byLuminance(swatches.filter((sw) => FUNCTIONAL_FAMILIES.has(sw.label))));
|
|
82
88
|
|
|
83
89
|
let shownModeLabel = $derived(
|
|
84
90
|
HARMONY_MODE_BUTTONS.find((b) => b.mode === (hoverMode ?? activeMode))?.label ?? '',
|
|
@@ -163,33 +169,33 @@
|
|
|
163
169
|
|
|
164
170
|
<div class="group">
|
|
165
171
|
<span class="eyebrow">Swatches</span>
|
|
166
|
-
{#snippet
|
|
167
|
-
|
|
168
|
-
type="button"
|
|
169
|
-
class="swatch"
|
|
170
|
-
class:active={selected === sw.label}
|
|
171
|
-
style="--swatch-fill: {sw.hex}"
|
|
172
|
-
title={sw.display}
|
|
173
|
-
aria-label={`Select ${sw.display}`}
|
|
174
|
-
aria-pressed={selected === sw.label}
|
|
175
|
-
onclick={() => select(sw.label)}
|
|
176
|
-
>
|
|
177
|
-
<span class="chip"></span>
|
|
178
|
-
<span class="swatch-label">{sw.label}</span>
|
|
179
|
-
{#if sw.onWheel}<span class="wheel-dot" title="On the wheel" aria-hidden="true"></span>{/if}
|
|
180
|
-
</button>
|
|
181
|
-
{/snippet}
|
|
182
|
-
<div class="swatch-rows">
|
|
183
|
-
<div class="swatch-row">
|
|
184
|
-
{#each coreSwatches as sw (sw.label)}
|
|
185
|
-
{@render swatchButton(sw)}
|
|
186
|
-
{/each}
|
|
187
|
-
</div>
|
|
172
|
+
{#snippet swatchRow(row: typeof swatches)}
|
|
173
|
+
{@const selIdx = row.findIndex((sw) => sw.label === selected)}
|
|
188
174
|
<div class="swatch-row">
|
|
189
|
-
{#each
|
|
190
|
-
|
|
175
|
+
{#each row as sw, i (sw.label)}
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
class="swatch"
|
|
179
|
+
class:active={selected === sw.label}
|
|
180
|
+
style="--swatch-fill: {sw.hex}"
|
|
181
|
+
style:flex-grow={dockGrow(i, selIdx === -1 ? null : selIdx)}
|
|
182
|
+
title={sw.display}
|
|
183
|
+
aria-label={`Select ${sw.display}`}
|
|
184
|
+
aria-pressed={selected === sw.label}
|
|
185
|
+
onclick={() => select(sw.label)}
|
|
186
|
+
animate:flip={{ duration: 200, easing: cubicOut }}
|
|
187
|
+
>
|
|
188
|
+
<span class="chip">
|
|
189
|
+
{#if sw.onWheel}<span class="wheel-dot" title="On the wheel" aria-hidden="true"></span>{/if}
|
|
190
|
+
</span>
|
|
191
|
+
<span class="swatch-label">{sw.label}</span>
|
|
192
|
+
</button>
|
|
191
193
|
{/each}
|
|
192
194
|
</div>
|
|
195
|
+
{/snippet}
|
|
196
|
+
<div class="swatch-rows">
|
|
197
|
+
{@render swatchRow(coreSwatches)}
|
|
198
|
+
{@render swatchRow(functionalSwatches)}
|
|
193
199
|
</div>
|
|
194
200
|
</div>
|
|
195
201
|
|
|
@@ -438,14 +444,18 @@
|
|
|
438
444
|
min-width: 0;
|
|
439
445
|
}
|
|
440
446
|
|
|
447
|
+
/* Dock magnification, mirroring PaletteStepStrip: the flex-grow transition
|
|
448
|
+
animates the selected swatch opening while flip walks it to its
|
|
449
|
+
luminance-sorted spot as lightness is dialed. */
|
|
441
450
|
.swatch-row {
|
|
442
|
-
display:
|
|
443
|
-
|
|
451
|
+
display: flex;
|
|
452
|
+
align-items: flex-end;
|
|
444
453
|
gap: var(--ui-space-8);
|
|
445
454
|
}
|
|
446
455
|
|
|
447
456
|
.swatch {
|
|
448
|
-
|
|
457
|
+
flex: 1 1 0;
|
|
458
|
+
min-width: 0;
|
|
449
459
|
display: flex;
|
|
450
460
|
flex-direction: column;
|
|
451
461
|
align-items: stretch;
|
|
@@ -454,15 +464,26 @@
|
|
|
454
464
|
background: none;
|
|
455
465
|
border: none;
|
|
456
466
|
cursor: pointer;
|
|
467
|
+
transition: flex-grow var(--ui-transition-fast);
|
|
457
468
|
}
|
|
458
469
|
|
|
459
470
|
.chip {
|
|
471
|
+
position: relative;
|
|
460
472
|
display: block;
|
|
461
473
|
height: 2.75rem;
|
|
474
|
+
/* Compensating margin: height + margin-top is constant every frame of the
|
|
475
|
+
magnification, so the row's layout height never dips mid-transition.
|
|
476
|
+
Chip grows upward, label pinned. */
|
|
477
|
+
margin-top: 0.5rem;
|
|
462
478
|
border-radius: var(--ui-radius-sm);
|
|
463
479
|
background: var(--swatch-fill);
|
|
464
480
|
border: 1px solid var(--ui-border-low);
|
|
465
|
-
transition: border-color var(--ui-transition-fast);
|
|
481
|
+
transition: border-color var(--ui-transition-fast), height var(--ui-transition-fast), margin-top var(--ui-transition-fast);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.swatch.active .chip {
|
|
485
|
+
height: 3.25rem;
|
|
486
|
+
margin-top: 0;
|
|
466
487
|
}
|
|
467
488
|
|
|
468
489
|
.swatch:hover .chip {
|
|
@@ -497,6 +518,7 @@
|
|
|
497
518
|
border-radius: var(--ui-radius-full);
|
|
498
519
|
background: var(--ui-text-primary);
|
|
499
520
|
box-shadow: 0 0 0 1.5px var(--ui-surface-lowest);
|
|
521
|
+
pointer-events: none;
|
|
500
522
|
}
|
|
501
523
|
|
|
502
524
|
@media (max-width: 1024px) {
|