@motion-proto/live-tokens 0.42.0 → 0.44.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 +72 -0
- package/package.json +6 -1
- package/src/editor/core/palettes/solveTextContrast.ts +10 -7
- package/src/editor/core/routing/ownedRoutes.ts +1 -0
- package/src/editor/core/store/editorViewStore.ts +8 -3
- package/src/editor/overlay/LiveEditorOverlay.svelte +9 -4
- package/src/editor/overlay/LiveTokensRouter.svelte +29 -17
- package/src/editor/pages/ColorsPage.svelte +117 -0
- package/src/editor/pages/ColorsPage.svelte.d.ts +2 -0
- package/src/editor/ui/EditorViewSwitcher.svelte +12 -8
- package/src/editor/ui/UIPillButton.svelte +15 -0
- package/src/editor/ui/colors/ColorStory.svelte +42 -97
- package/src/editor/ui/colors/ColorWheel.svelte +25 -7
- package/src/editor/ui/colors/ColorsTab.svelte +62 -29
- package/src/editor/ui/colors/HarmonyAxesList.svelte +32 -4
- package/src/editor/ui/colors/paletteBaseColor.ts +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.44.0 — Every text step derives to body contrast
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **A full-page Colors route.** `/live-tokens/colors` renders the Colors editor
|
|
8
|
+
as its own page, alongside the existing editor and components routes, and is
|
|
9
|
+
auto-appended to the dev nav rail. New `@motion-proto/live-tokens/colors`
|
|
10
|
+
export for consumers mounting it themselves, and a new `colors` key on
|
|
11
|
+
`editorRoutes` to relocate it or pass `false` to disable it. Entering the
|
|
12
|
+
route flips the overlay off its Colors view so the two surfaces never stack.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **Selection reads across every Colors surface.** Picking a family used to
|
|
17
|
+
highlight only its swatch. Its harmony-axis row and chip now light up, and on
|
|
18
|
+
the wheel its rail thickens to full contrast while its axis numeral goes
|
|
19
|
+
white and the others step back. Axis chips are clickable to select.
|
|
20
|
+
|
|
21
|
+
- **The Color Story is a readout, not a control panel.** Its "Use suggested"
|
|
22
|
+
button and "Use black and white" checkbox moved out to three pills above the
|
|
23
|
+
story: *Raise all text to AA* (every family, contrast-first), *Even neutral
|
|
24
|
+
steps* (Neutral only, hierarchy-first), and an *Anchor at pure white/black*
|
|
25
|
+
toggle. The old labels named neither the scope nor the effect, which are what
|
|
26
|
+
separate the two actions; each pill now carries the full explanation on hover.
|
|
27
|
+
The AA-reach line is gone.
|
|
28
|
+
|
|
29
|
+
- **Color swatches hold their position.** Both swatch rows were sorted by
|
|
30
|
+
luminance, so editing a color's lightness reshuffled the row under the
|
|
31
|
+
pointer and stuttered mid-drag. They render in declaration order now.
|
|
32
|
+
|
|
33
|
+
- **`--text-muted` now derives to 4.5:1, not 3:1.** The neutral text ramp
|
|
34
|
+
guaranteed muted at the WCAG AA floor for *large* text, one step below
|
|
35
|
+
`--text-tertiary`, on the assumption it would only ever be set at 24px (or
|
|
36
|
+
18.66px bold). Nothing in the token's name or its position in a ramp of text
|
|
37
|
+
colors carried that condition, so consumers reached for it as a quiet body
|
|
38
|
+
color and shipped text below AA — in the first site audited, all eleven uses
|
|
39
|
+
were 13–16px, every one of them failing. `--text-muted` joins primary,
|
|
40
|
+
secondary and tertiary on the 4.5:1 body floor, which leaves `--text-disabled`
|
|
41
|
+
as the ramp's only sub-AA step, and WCAG 1.4.3 exempts disabled controls.
|
|
42
|
+
`AA_LARGE` is unchanged and still floors the graded chromatic steps.
|
|
43
|
+
|
|
44
|
+
Derived palettes get a lighter (dark scheme) or darker (light scheme) muted
|
|
45
|
+
step the next time text contrast is solved. **A theme already on disk keeps
|
|
46
|
+
its stored curve** — its `--text-muted` does not move until the palette is
|
|
47
|
+
re-solved, so a consuming site that has shipped muted on small text must
|
|
48
|
+
either re-derive or restate those colors itself.
|
|
49
|
+
|
|
50
|
+
Expect muted and tertiary to derive to nearly the same color: both target the
|
|
51
|
+
default surface, so both settle just past the same floor (on the reference
|
|
52
|
+
dark palette, tertiary `#94999c` 4.58:1 and muted `#96989a` 4.55:1). That is
|
|
53
|
+
arithmetic, not a rough edge — a step quieter than tertiary cannot also clear
|
|
54
|
+
tertiary's floor. Treat `--text-muted` as an alias of `--text-tertiary` for
|
|
55
|
+
now; reach for `--text-disabled` when a genuinely quieter, non-AA step is
|
|
56
|
+
wanted, and expect a decision on deprecating one of the two.
|
|
57
|
+
|
|
58
|
+
## 0.43.0 — The editor opens on Tokens
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **The editor's view no longer carries across sessions.** Opening the editor in
|
|
63
|
+
a fresh tab always lands on Tokens; previously it restored whichever of
|
|
64
|
+
Tokens / Colors / Components you last used, so a session that ended on Colors
|
|
65
|
+
reopened there indefinitely. `editorViewStore` moved the active view from
|
|
66
|
+
`localStorage` to `sessionStorage`, which keeps the two things that did depend
|
|
67
|
+
on the round-trip: the parent window and the overlay iframe stay in sync (same
|
|
68
|
+
tab, same origin, so they share one storage area and its `storage` events),
|
|
69
|
+
and an in-page deep link that calls `setEditorView()` before opening the
|
|
70
|
+
overlay still lands on the view it asked for. The sidebar's condensed/expanded
|
|
71
|
+
state is a durable preference and stays in `localStorage`. A stale
|
|
72
|
+
`lt.editorView` left in `localStorage` by an earlier version is ignored, not
|
|
73
|
+
migrated.
|
|
74
|
+
|
|
3
75
|
## 0.42.0 — Canvas, and every ramp places its base color
|
|
4
76
|
|
|
5
77
|
### Changed (breaking)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motion-proto/live-tokens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
|
|
6
6
|
"keywords": [
|
|
@@ -65,6 +65,11 @@
|
|
|
65
65
|
"svelte": "./src/editor/pages/ComponentEditorPage.svelte",
|
|
66
66
|
"default": "./src/editor/pages/ComponentEditorPage.svelte"
|
|
67
67
|
},
|
|
68
|
+
"./colors": {
|
|
69
|
+
"types": "./src/editor/pages/ColorsPage.svelte.d.ts",
|
|
70
|
+
"svelte": "./src/editor/pages/ColorsPage.svelte",
|
|
71
|
+
"default": "./src/editor/pages/ColorsPage.svelte"
|
|
72
|
+
},
|
|
68
73
|
"./docs": {
|
|
69
74
|
"types": "./src/editor/docs/Docs.svelte.d.ts",
|
|
70
75
|
"svelte": "./src/editor/docs/Docs.svelte",
|
|
@@ -65,11 +65,15 @@ interface StepPlan {
|
|
|
65
65
|
|
|
66
66
|
// Neutral family sits on neutral surfaces; primary/secondary must clear the
|
|
67
67
|
// adverse band extreme, tertiary/muted the default surface. Disabled is graded.
|
|
68
|
+
//
|
|
69
|
+
// Muted held a large-text 3.0 floor until 0.44.0. Nothing in the name marked it
|
|
70
|
+
// large-only, so consumers reached for it as a quiet body colour and shipped
|
|
71
|
+
// text below AA; disabled is now the sole sub-AA step, which WCAG exempts.
|
|
68
72
|
const NEUTRAL_PLAN: Record<string, StepPlan> = {
|
|
69
73
|
primary: { ratio: 7.0, guaranteed: true, target: 'band' },
|
|
70
74
|
secondary: { ratio: 4.5, guaranteed: true, target: 'band' },
|
|
71
75
|
tertiary: { ratio: 4.5, guaranteed: true, target: 'default' },
|
|
72
|
-
muted: { ratio:
|
|
76
|
+
muted: { ratio: 4.5, guaranteed: true, target: 'default' },
|
|
73
77
|
disabled: { ratio: 2.0, guaranteed: false, target: 'default' },
|
|
74
78
|
};
|
|
75
79
|
|
|
@@ -216,7 +220,7 @@ export function solveTextCurves(
|
|
|
216
220
|
againstVar: against.var,
|
|
217
221
|
targetRatio: stepPlan.ratio,
|
|
218
222
|
achievedRatio: achieved,
|
|
219
|
-
meets: achieved >= minGuarantee(
|
|
223
|
+
meets: achieved >= minGuarantee(stepPlan),
|
|
220
224
|
guaranteed: stepPlan.guaranteed,
|
|
221
225
|
});
|
|
222
226
|
}
|
|
@@ -225,11 +229,10 @@ export function solveTextCurves(
|
|
|
225
229
|
return { patches, report };
|
|
226
230
|
}
|
|
227
231
|
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
function minGuarantee(
|
|
231
|
-
|
|
232
|
-
return stepName === 'muted' ? 3.0 : 4.5;
|
|
232
|
+
// Every guaranteed step clears the body-text AA floor. Graded steps floor at 1
|
|
233
|
+
// (informational).
|
|
234
|
+
function minGuarantee(plan: StepPlan): number {
|
|
235
|
+
return plan.guaranteed ? 4.5 : 1;
|
|
233
236
|
}
|
|
234
237
|
|
|
235
238
|
function clamp(lo: number, hi: number, v: number): number {
|
|
@@ -8,4 +8,5 @@
|
|
|
8
8
|
// <LiveTokensRouter>; these are only the defaults.
|
|
9
9
|
export const DEFAULT_EDITOR_PATH = '/live-tokens/editor';
|
|
10
10
|
export const DEFAULT_COMPONENTS_PATH = '/live-tokens/components';
|
|
11
|
+
export const DEFAULT_COLORS_PATH = '/live-tokens/colors';
|
|
11
12
|
export const DEFAULT_DOCS_PATH = '/live-tokens/docs';
|
|
@@ -10,9 +10,14 @@ function isEditorView(v: unknown): v is EditorView {
|
|
|
10
10
|
return v === 'tokens' || v === 'components' || v === 'colors';
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// Session-scoped, not persistent: opening the editor fresh always starts on
|
|
14
|
+
// Tokens. Within a session it still round-trips, which is what the parent
|
|
15
|
+
// window / overlay iframe pair and in-page deep links (setEditorView before
|
|
16
|
+
// opening the overlay) rely on — same-origin frames in a tab share one
|
|
17
|
+
// sessionStorage area and its `storage` events.
|
|
13
18
|
function readView(): EditorView {
|
|
14
19
|
try {
|
|
15
|
-
const v =
|
|
20
|
+
const v = sessionStorage.getItem(VIEW_KEY);
|
|
16
21
|
if (isEditorView(v)) return v;
|
|
17
22
|
} catch {}
|
|
18
23
|
return 'tokens';
|
|
@@ -32,14 +37,14 @@ export const sidebarCondensed = writable<SidebarCondensed>(readCondensed());
|
|
|
32
37
|
export const selectedComponent = writable<string>('button');
|
|
33
38
|
|
|
34
39
|
editorView.subscribe((v) => {
|
|
35
|
-
try {
|
|
40
|
+
try { sessionStorage.setItem(VIEW_KEY, v); } catch {}
|
|
36
41
|
});
|
|
37
42
|
|
|
38
43
|
sidebarCondensed.subscribe((v) => {
|
|
39
44
|
try { localStorage.setItem(CONDENSED_KEY, v === 'auto' ? 'auto' : String(v)); } catch {}
|
|
40
45
|
});
|
|
41
46
|
|
|
42
|
-
// Cross-window sync: parent and overlay iframe share
|
|
47
|
+
// Cross-window sync: parent and overlay iframe share web storage but each have
|
|
43
48
|
// their own module state. `storage` events fire in the *other* window when one
|
|
44
49
|
// writes, so we mirror the new value into this window's store.
|
|
45
50
|
if (typeof window !== 'undefined') {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { fade } from 'svelte/transition';
|
|
16
16
|
import { cubicInOut } from 'svelte/easing';
|
|
17
17
|
import { route, navigate } from '../core/routing/router';
|
|
18
|
-
import { DEFAULT_EDITOR_PATH, DEFAULT_COMPONENTS_PATH } from '../core/routing/ownedRoutes';
|
|
18
|
+
import { DEFAULT_EDITOR_PATH, DEFAULT_COMPONENTS_PATH, DEFAULT_COLORS_PATH } from '../core/routing/ownedRoutes';
|
|
19
19
|
import { editorView } from '../core/store/editorViewStore';
|
|
20
20
|
import { columnsVisible, toggleColumns } from './columnsOverlay';
|
|
21
21
|
import { storageKey } from '../core/store/editorConfig';
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
open?: boolean | undefined;
|
|
30
30
|
editorPath?: string;
|
|
31
31
|
componentsPath?: string;
|
|
32
|
+
colorsPath?: string;
|
|
32
33
|
navLinks?: NavLink[];
|
|
33
34
|
pageSources?: Record<string, string>;
|
|
34
35
|
hidePageSourceOn?: string[];
|
|
@@ -39,6 +40,7 @@
|
|
|
39
40
|
open = $bindable(undefined),
|
|
40
41
|
editorPath = DEFAULT_EDITOR_PATH,
|
|
41
42
|
componentsPath = DEFAULT_COMPONENTS_PATH,
|
|
43
|
+
colorsPath = DEFAULT_COLORS_PATH,
|
|
42
44
|
navLinks = [],
|
|
43
45
|
pageSources = {},
|
|
44
46
|
hidePageSourceOn = [],
|
|
@@ -65,9 +67,9 @@
|
|
|
65
67
|
overlayOpen.set(!!open);
|
|
66
68
|
});
|
|
67
69
|
|
|
68
|
-
// The components
|
|
69
|
-
//
|
|
70
|
-
// tokens so the two surfaces don't stack. Fires only on route
|
|
70
|
+
// The components and colors routes render the same surfaces as the overlay's
|
|
71
|
+
// components and colors views. Pair them: on entering one, flip the overlay
|
|
72
|
+
// to tokens so the two surfaces don't stack. Fires only on route
|
|
71
73
|
// change, not on every editorView change — otherwise cross-window storage
|
|
72
74
|
// sync re-triggers the rule, which writes editorView, which fires another
|
|
73
75
|
// storage event, which fires the rule again. The result is heavy re-render
|
|
@@ -81,6 +83,9 @@
|
|
|
81
83
|
if (r === componentsPath) {
|
|
82
84
|
editorView.update((v) => (v === 'components' ? 'tokens' : v));
|
|
83
85
|
}
|
|
86
|
+
if (r === colorsPath) {
|
|
87
|
+
editorView.update((v) => (v === 'colors' ? 'tokens' : v));
|
|
88
|
+
}
|
|
84
89
|
});
|
|
85
90
|
|
|
86
91
|
// Editor route has its own chrome — hide overlay there.
|
|
@@ -30,20 +30,22 @@
|
|
|
30
30
|
/**
|
|
31
31
|
* Override the package's dev-only routes, which default to the reserved
|
|
32
32
|
* `/live-tokens/*` namespace (`/live-tokens/editor`, `/live-tokens/components`,
|
|
33
|
-
* `/live-tokens/docs`) so they never collide with
|
|
34
|
-
* string to relocate a route; pass `false` to disable
|
|
35
|
-
* dispatch and, for `components`/`docs`, no
|
|
33
|
+
* `/live-tokens/colors`, `/live-tokens/docs`) so they never collide with
|
|
34
|
+
* consumer pages. Pass a string to relocate a route; pass `false` to disable
|
|
35
|
+
* it entirely (no dispatch and, for `components`/`colors`/`docs`, no
|
|
36
|
+
* auto-injected nav-rail entry).
|
|
36
37
|
*/
|
|
37
38
|
export interface EditorRouteOverrides {
|
|
38
39
|
editor?: string | false;
|
|
39
40
|
components?: string | false;
|
|
41
|
+
colors?: string | false;
|
|
40
42
|
docs?: string | false;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Resolve a path to the single entry that renders it. Precedence, after the
|
|
45
|
-
* package-owned `/live-tokens/*` routes (editor, components, docs)
|
|
46
|
-
* already been matched by the component:
|
|
47
|
+
* package-owned `/live-tokens/*` routes (editor, components, colors, docs)
|
|
48
|
+
* have already been matched by the component:
|
|
47
49
|
*
|
|
48
50
|
* 1. `pages[route]` — exact static match.
|
|
49
51
|
* 2. `resolve(route)` — consumer code, where params / prefixes / gating
|
|
@@ -68,7 +70,7 @@
|
|
|
68
70
|
import LiveEditorOverlay from './LiveEditorOverlay.svelte';
|
|
69
71
|
import ColumnsOverlay from './ColumnsOverlay.svelte';
|
|
70
72
|
import { route, navigate } from '../core/routing/router';
|
|
71
|
-
import { DEFAULT_EDITOR_PATH, DEFAULT_COMPONENTS_PATH, DEFAULT_DOCS_PATH } from '../core/routing/ownedRoutes';
|
|
73
|
+
import { DEFAULT_EDITOR_PATH, DEFAULT_COMPONENTS_PATH, DEFAULT_COLORS_PATH, DEFAULT_DOCS_PATH } from '../core/routing/ownedRoutes';
|
|
72
74
|
|
|
73
75
|
interface Props {
|
|
74
76
|
pages: Record<string, RouteEntry>;
|
|
@@ -86,32 +88,38 @@
|
|
|
86
88
|
|
|
87
89
|
let editorEnabled = $derived(editorRoutes.editor !== false);
|
|
88
90
|
let componentsEnabled = $derived(editorRoutes.components !== false);
|
|
91
|
+
let colorsEnabled = $derived(editorRoutes.colors !== false);
|
|
89
92
|
let docsEnabled = $derived(editorRoutes.docs !== false);
|
|
90
93
|
let editorPath = $derived(typeof editorRoutes.editor === 'string' ? editorRoutes.editor : DEFAULT_EDITOR_PATH);
|
|
91
94
|
let componentsPath = $derived(typeof editorRoutes.components === 'string' ? editorRoutes.components : DEFAULT_COMPONENTS_PATH);
|
|
95
|
+
let colorsPath = $derived(typeof editorRoutes.colors === 'string' ? editorRoutes.colors : DEFAULT_COLORS_PATH);
|
|
92
96
|
let docsPath = $derived(typeof editorRoutes.docs === 'string' ? editorRoutes.docs : DEFAULT_DOCS_PATH);
|
|
93
97
|
|
|
94
98
|
const isDev = import.meta.env.DEV;
|
|
95
99
|
let isEditor = $derived(isDev && editorEnabled && $route === editorPath);
|
|
96
100
|
let isComponentEditor = $derived(isDev && componentsEnabled && $route === componentsPath);
|
|
101
|
+
let isColors = $derived(isDev && colorsEnabled && $route === colorsPath);
|
|
97
102
|
let isDocs = $derived(isDev && docsEnabled && $route === docsPath);
|
|
98
103
|
|
|
99
104
|
// The single entry that renders the current route. Owned routes are handled
|
|
100
|
-
// by the isEditor/isComponentEditor/isDocs branches, so they resolve
|
|
101
|
-
// here; everything else flows through resolveRoute and drives both
|
|
102
|
-
// (component + props) and page-source from this one value.
|
|
105
|
+
// by the isEditor/isComponentEditor/isColors/isDocs branches, so they resolve
|
|
106
|
+
// to null here; everything else flows through resolveRoute and drives both
|
|
107
|
+
// dispatch (component + props) and page-source from this one value.
|
|
103
108
|
let resolvedEntry = $derived(
|
|
104
|
-
isEditor || isComponentEditor || isDocs ? null : resolveRoute(pages, resolve, $route),
|
|
109
|
+
isEditor || isComponentEditor || isColors || isDocs ? null : resolveRoute(pages, resolve, $route),
|
|
105
110
|
);
|
|
106
111
|
|
|
107
112
|
// Pages with a label show up in the nav rail, in declaration order. In dev,
|
|
108
|
-
// the components-editor and docs routes are auto-appended so they're
|
|
109
|
-
// from every page. Docs ship from the package, so consumers
|
|
110
|
-
// guide in the editor while building without vendoring a copy.
|
|
113
|
+
// the colors, components-editor, and docs routes are auto-appended so they're
|
|
114
|
+
// reachable from every page. Docs ship from the package, so consumers
|
|
115
|
+
// reference the guide in the editor while building without vendoring a copy.
|
|
111
116
|
let navLinks = $derived([
|
|
112
117
|
...Object.entries(pages)
|
|
113
118
|
.filter(([, e]) => !!e.label)
|
|
114
119
|
.map(([path, e]) => ({ path, label: e.label!, icon: e.icon ?? '' })),
|
|
120
|
+
...(isDev && colorsEnabled
|
|
121
|
+
? [{ path: colorsPath, label: 'Colors', icon: 'fa-palette' }]
|
|
122
|
+
: []),
|
|
115
123
|
...(isDev && componentsEnabled
|
|
116
124
|
? [{ path: componentsPath, label: 'Components', icon: 'fa-puzzle-piece' }]
|
|
117
125
|
: []),
|
|
@@ -132,14 +140,15 @@
|
|
|
132
140
|
...(resolvedEntry?.source ? { [$route]: resolvedEntry.source } : {}),
|
|
133
141
|
});
|
|
134
142
|
|
|
135
|
-
// The package-owned components and docs routes hide the page-source
|
|
136
|
-
// (no consumer source file backs them).
|
|
143
|
+
// The package-owned components, colors, and docs routes hide the page-source
|
|
144
|
+
// button (no consumer source file backs them).
|
|
137
145
|
let hidePageSourceOn = $derived([
|
|
138
146
|
...Object.entries(pages)
|
|
139
147
|
.filter(([, e]) => e.hidePageSource)
|
|
140
148
|
.map(([path]) => path),
|
|
141
149
|
...(resolvedEntry?.hidePageSource ? [$route] : []),
|
|
142
150
|
...(componentsEnabled ? [componentsPath] : []),
|
|
151
|
+
...(colorsEnabled ? [colorsPath] : []),
|
|
143
152
|
...(docsEnabled ? [docsPath] : []),
|
|
144
153
|
]);
|
|
145
154
|
|
|
@@ -150,6 +159,7 @@
|
|
|
150
159
|
let pagePromise = $derived.by(() => {
|
|
151
160
|
if (isEditor) return import('../pages/Editor.svelte');
|
|
152
161
|
if (isComponentEditor) return import('../pages/ComponentEditorPage.svelte');
|
|
162
|
+
if (isColors) return import('../pages/ColorsPage.svelte');
|
|
153
163
|
if (isDocs) return import('../docs/Docs.svelte');
|
|
154
164
|
const entry = resolvedEntry;
|
|
155
165
|
if (!entry) return Promise.resolve({ default: null as unknown as Component<any, any, any> });
|
|
@@ -177,9 +187,10 @@
|
|
|
177
187
|
class="lt-app"
|
|
178
188
|
class:is-editor={isEditor}
|
|
179
189
|
class:is-component-editor={isComponentEditor}
|
|
190
|
+
class:is-colors={isColors}
|
|
180
191
|
onclick={handleClick}
|
|
181
192
|
>
|
|
182
|
-
<LiveEditorOverlay {navLinks} {pageSources} {hidePageSourceOn} {editorPath} {componentsPath} />
|
|
193
|
+
<LiveEditorOverlay {navLinks} {pageSources} {hidePageSourceOn} {editorPath} {componentsPath} {colorsPath} />
|
|
183
194
|
<ColumnsOverlay />
|
|
184
195
|
|
|
185
196
|
{#await pagePromise then m}
|
|
@@ -210,7 +221,8 @@
|
|
|
210
221
|
padding-right: var(--lt-overlay-scroll-pad, 0px);
|
|
211
222
|
}
|
|
212
223
|
|
|
213
|
-
.lt-app.is-editor
|
|
224
|
+
.lt-app.is-editor,
|
|
225
|
+
.lt-app.is-colors {
|
|
214
226
|
padding-bottom: 0;
|
|
215
227
|
background: black;
|
|
216
228
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import ColorsTab from '../ui/colors/ColorsTab.svelte';
|
|
4
|
+
import UICopyPopover from '../ui/UICopyPopover.svelte';
|
|
5
|
+
import { installEditorKeybindings } from '../core/store/editorKeybindings';
|
|
6
|
+
import { initializeEditorStore } from '../core/store/editorStore';
|
|
7
|
+
import { storageKey } from '../core/store/editorConfig';
|
|
8
|
+
import { DEFAULT_COLORS_PATH } from '../core/routing/ownedRoutes';
|
|
9
|
+
// Editor chrome + form controls + icon font must be JS imports (not @import
|
|
10
|
+
// inside the style block) so Vite resolves them via the module graph
|
|
11
|
+
// regardless of how the consumer compiles Svelte CSS (external ?lang.css vs
|
|
12
|
+
// injected); otherwise the @import URLs leak to the browser and 404 against
|
|
13
|
+
// the consumer's root.
|
|
14
|
+
import '../styles/ui-editor.css';
|
|
15
|
+
import '../styles/ui-form-controls.css';
|
|
16
|
+
import '@fortawesome/fontawesome-free/css/all.min.css';
|
|
17
|
+
|
|
18
|
+
// Where "Back to site" sends the user. Prefer the previous non-colors entry
|
|
19
|
+
// from session history; fall back to home.
|
|
20
|
+
const backHref = pickBackHref();
|
|
21
|
+
|
|
22
|
+
function pickBackHref(): string {
|
|
23
|
+
try {
|
|
24
|
+
const prev = sessionStorage.getItem(storageKey('prev-route'));
|
|
25
|
+
if (prev && prev !== DEFAULT_COLORS_PATH) return prev;
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore
|
|
28
|
+
}
|
|
29
|
+
return '/';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onMount(() => {
|
|
33
|
+
initializeEditorStore();
|
|
34
|
+
return installEditorKeybindings();
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<!-- .editor-page scopes the --ui-* token definitions from ui-editor.css. -->
|
|
39
|
+
<div class="editor-page colors-page">
|
|
40
|
+
<div class="page-bar">
|
|
41
|
+
<div class="bar-left">
|
|
42
|
+
<a href={backHref} class="back-link">
|
|
43
|
+
<i class="fas fa-arrow-left"></i>
|
|
44
|
+
<span>Back to site</span>
|
|
45
|
+
</a>
|
|
46
|
+
<span class="page-label">Colors</span>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<main class="content">
|
|
51
|
+
<ColorsTab />
|
|
52
|
+
</main>
|
|
53
|
+
<UICopyPopover />
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
.colors-page {
|
|
58
|
+
min-height: 100vh;
|
|
59
|
+
background: black;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.page-bar {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: var(--ui-space-16);
|
|
66
|
+
padding: var(--ui-space-10) var(--ui-space-16);
|
|
67
|
+
background: black;
|
|
68
|
+
border-bottom: 1px solid var(--ui-border-low);
|
|
69
|
+
min-height: 52px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.bar-left {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: var(--ui-space-16);
|
|
76
|
+
min-width: 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.back-link {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: var(--ui-space-6);
|
|
83
|
+
color: var(--ui-text-tertiary);
|
|
84
|
+
text-decoration: none;
|
|
85
|
+
font-size: var(--ui-font-size-md);
|
|
86
|
+
transition: color var(--ui-transition-fast);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.back-link:hover {
|
|
90
|
+
color: var(--ui-text-primary);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.page-label {
|
|
94
|
+
font-size: var(--ui-font-size-md);
|
|
95
|
+
font-weight: var(--ui-font-weight-semibold);
|
|
96
|
+
color: var(--ui-text-secondary);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.content {
|
|
100
|
+
max-width: 1680px;
|
|
101
|
+
margin: 0 auto;
|
|
102
|
+
padding: var(--ui-space-24) var(--ui-space-32);
|
|
103
|
+
isolation: isolate;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@media (max-width: 1280px) {
|
|
107
|
+
.content {
|
|
108
|
+
padding: var(--ui-space-20) var(--ui-space-20);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@media (max-width: 1024px) {
|
|
113
|
+
.content {
|
|
114
|
+
padding: var(--ui-space-16) var(--ui-space-12);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
</style>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { editorView, type EditorView } from '../core/store/editorViewStore';
|
|
3
3
|
import { parentRoute } from '../core/routing/parentRouteStore';
|
|
4
|
-
import { DEFAULT_COMPONENTS_PATH } from '../core/routing/ownedRoutes';
|
|
4
|
+
import { DEFAULT_COMPONENTS_PATH, DEFAULT_COLORS_PATH } from '../core/routing/ownedRoutes';
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
7
7
|
condensed?: boolean;
|
|
@@ -9,16 +9,17 @@
|
|
|
9
9
|
|
|
10
10
|
let { condensed = false }: Props = $props();
|
|
11
11
|
|
|
12
|
-
// On the components route the host page
|
|
13
|
-
// the overlay's
|
|
14
|
-
// switch. The switcher renders inside the editor iframe, so we read the
|
|
15
|
-
// *parent* route, not this iframe's own. Compares the default
|
|
16
|
-
// editorRoutes
|
|
17
|
-
//
|
|
12
|
+
// On the components or colors route the host page already renders that
|
|
13
|
+
// surface — the overlay's matching view would just stack on top, so disable
|
|
14
|
+
// the switch. The switcher renders inside the editor iframe, so we read the
|
|
15
|
+
// *parent* route, not this iframe's own. Compares the default paths:
|
|
16
|
+
// editorRoutes relocation isn't plumbed across the iframe, so a relocated
|
|
17
|
+
// route won't disable the switch.
|
|
18
18
|
let componentsDisabled = $derived($parentRoute === DEFAULT_COMPONENTS_PATH);
|
|
19
|
+
let colorsDisabled = $derived($parentRoute === DEFAULT_COLORS_PATH);
|
|
19
20
|
|
|
20
21
|
// Editing flow order: Tokens → Colors → Components. The condensed rail cycles
|
|
21
|
-
// through these;
|
|
22
|
+
// through these; a view is skipped while it's disabled (already on that
|
|
22
23
|
// page) so the cycle never lands on a dead view.
|
|
23
24
|
const CYCLE: readonly EditorView[] = ['tokens', 'colors', 'components'];
|
|
24
25
|
const ICONS: Record<EditorView, string> = {
|
|
@@ -43,6 +44,7 @@
|
|
|
43
44
|
i = (i + 1) % CYCLE.length;
|
|
44
45
|
const next = CYCLE[i];
|
|
45
46
|
if (next === 'components' && componentsDisabled) continue;
|
|
47
|
+
if (next === 'colors' && colorsDisabled) continue;
|
|
46
48
|
return next;
|
|
47
49
|
}
|
|
48
50
|
return current;
|
|
@@ -81,6 +83,8 @@
|
|
|
81
83
|
class="seg-btn"
|
|
82
84
|
class:active={$editorView === 'colors'}
|
|
83
85
|
aria-selected={$editorView === 'colors'}
|
|
86
|
+
disabled={colorsDisabled}
|
|
87
|
+
title={colorsDisabled ? 'Already viewing the Colors page' : undefined}
|
|
84
88
|
onclick={() => set('colors')}
|
|
85
89
|
>
|
|
86
90
|
<span class="radio" aria-hidden="true"></span>
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
disabled?: boolean;
|
|
11
11
|
title?: string;
|
|
12
12
|
type?: 'button' | 'submit' | 'reset';
|
|
13
|
+
/** Marks the pill a toggle; true also lights it as on. */
|
|
14
|
+
ariaPressed?: boolean;
|
|
13
15
|
onclick?: (e: MouseEvent) => void;
|
|
14
16
|
children?: Snippet;
|
|
15
17
|
}
|
|
@@ -23,6 +25,7 @@
|
|
|
23
25
|
disabled = false,
|
|
24
26
|
title,
|
|
25
27
|
type = 'button',
|
|
28
|
+
ariaPressed,
|
|
26
29
|
onclick,
|
|
27
30
|
children,
|
|
28
31
|
}: Props = $props();
|
|
@@ -48,6 +51,7 @@
|
|
|
48
51
|
{disabled}
|
|
49
52
|
{title}
|
|
50
53
|
{onclick}
|
|
54
|
+
aria-pressed={ariaPressed}
|
|
51
55
|
>
|
|
52
56
|
{#if icon}<i class="fas {icon}" aria-hidden="true"></i>{/if}
|
|
53
57
|
{#if children}<span class="ui-pill-label">{@render children()}</span>{/if}
|
|
@@ -89,6 +93,17 @@
|
|
|
89
93
|
border-color: rgba(255, 255, 255, 0.75);
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
/* Toggle "on": inset fill so it reads as depressed, not merely hovered. */
|
|
97
|
+
.ui-pill[aria-pressed='true'] {
|
|
98
|
+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.30) 0%, rgba(255, 255, 255, 0.16) 100%);
|
|
99
|
+
border-color: rgba(255, 255, 255, 0.9);
|
|
100
|
+
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.35);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.ui-pill[aria-pressed='true'] i {
|
|
104
|
+
color: rgba(255, 255, 255, 0.95);
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
.ui-pill:focus-visible {
|
|
93
108
|
outline: none;
|
|
94
109
|
border-color: rgba(255, 255, 255, 0.9);
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { editorState } from '../../core/store/editorStore';
|
|
3
2
|
import { oklchToHexClamped } from '../../core/palettes/oklch';
|
|
4
|
-
import { palettesToVars
|
|
5
|
-
import { defaultPaletteConfig } from '../palette/paletteMath';
|
|
3
|
+
import { palettesToVars } from '../../core/palettes/paletteDerivation';
|
|
6
4
|
import { contrastRatio } from '../../core/palettes/contrast';
|
|
7
|
-
import {
|
|
8
|
-
import { applySuggestedNeutralText } from './paletteBaseColor';
|
|
9
|
-
import UIPillButton from '../UIPillButton.svelte';
|
|
5
|
+
import type { NeutralTextRecommendation } from '../../core/palettes/recommendText';
|
|
10
6
|
import type { PaletteConfig } from '../../core/themes/themeTypes';
|
|
11
7
|
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Every family's config, spec defaults filled in (palettesWithDefaults). */
|
|
10
|
+
full: Record<string, PaletteConfig>;
|
|
11
|
+
/** Suggested neutral text hierarchy — read out here, acted on from the
|
|
12
|
+
* section's action pills. The story itself stays non-interactive. */
|
|
13
|
+
rec: NeutralTextRecommendation;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { full, rec }: Props = $props();
|
|
17
|
+
|
|
12
18
|
// 60-30-10 as an area composition: the background surface is the dominant 60%
|
|
13
19
|
// field (the page), and Brand/Accent/Special are the tones shown against it.
|
|
14
20
|
// Shares are flex-grow weights, not exact percentages.
|
|
@@ -30,31 +36,15 @@
|
|
|
30
36
|
// 1:1. Derived --surface-* tokens deliberately do not appear here: their
|
|
31
37
|
// lightness is curve-owned, which decouples the story from the wheel and
|
|
32
38
|
// reads as broken.
|
|
33
|
-
const SPEC_BY_LABEL: Record<string, PaletteSpec> = Object.fromEntries(PALETTE_SPECS.map((s) => [s.label, s]));
|
|
34
|
-
|
|
35
39
|
function seedHex(label: string): string {
|
|
36
|
-
const { l, c, h } =
|
|
40
|
+
const { l, c, h } = full[label].baseColor;
|
|
37
41
|
return oklchToHexClamped(l, c, h);
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
// Text
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// to them silently goes static.
|
|
44
|
-
let full = $derived.by(() => {
|
|
45
|
-
const map: Record<string, PaletteConfig> = {};
|
|
46
|
-
for (const spec of PALETTE_SPECS) {
|
|
47
|
-
map[spec.label] =
|
|
48
|
-
$editorState.palettes[spec.label] ??
|
|
49
|
-
defaultPaletteConfig({ baseColor: spec.initialColor, neutral: spec.neutral ?? false });
|
|
50
|
-
}
|
|
51
|
-
return map;
|
|
52
|
-
});
|
|
53
|
-
|
|
44
|
+
// Text renders from this map (text derivation tracks the seed), never from the
|
|
45
|
+
// ambient :root variables — those stay at vendored defaults for families a
|
|
46
|
+
// loaded theme doesn't configure, so a story bound to them silently goes static.
|
|
54
47
|
let vars = $derived(palettesToVars(full));
|
|
55
|
-
let useBlackWhite = $state(false);
|
|
56
|
-
let rec = $derived(recommendNeutralText(full, undefined, useBlackWhite));
|
|
57
|
-
let partialCoverage = $derived(rec.suggestions.filter((s) => !s.current.coverage.full));
|
|
58
48
|
|
|
59
49
|
// Shadow the consumed text variables locally with the store-derived values;
|
|
60
50
|
// the page field is the Canvas family's SEED, not its derived page step.
|
|
@@ -116,11 +106,9 @@
|
|
|
116
106
|
</span>
|
|
117
107
|
</p>
|
|
118
108
|
|
|
119
|
-
<!--
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
<div class="suggest-row">
|
|
123
|
-
<span class="suggest-label">Suggested</span>
|
|
109
|
+
<!-- Readout only: applied from the section's actions, not from here. -->
|
|
110
|
+
<div class="suggest-row" title="What “Even neutral steps” would set these three text steps to">
|
|
111
|
+
<span class="suggest-label">Suggested steps</span>
|
|
124
112
|
{#each rec.suggestions as s (s.step)}
|
|
125
113
|
<span class="suggest-entry" class:dim={!s.differs}>
|
|
126
114
|
<span class="fn-dot" style:--dot={s.suggested.hex}></span>
|
|
@@ -128,40 +116,15 @@
|
|
|
128
116
|
<span class="ratio">{fmt(contrastRatio(s.suggested.hex, seedHex('Canvas')))}</span>
|
|
129
117
|
</span>
|
|
130
118
|
{/each}
|
|
131
|
-
<label class="bw-toggle" title="Anchor the suggestion at pure white or black instead of the softened extreme">
|
|
132
|
-
<input type="checkbox" bind:checked={useBlackWhite} />
|
|
133
|
-
<span>Use black and white</span>
|
|
134
|
-
</label>
|
|
135
|
-
{#if rec.anyDiffers}
|
|
136
|
-
<UIPillButton
|
|
137
|
-
icon="fa-arrow-down-short-wide"
|
|
138
|
-
title="Anchor on primary and step secondary/tertiary down by even lightness drops, floored at WCAG AA (one undo)"
|
|
139
|
-
onclick={() => applySuggestedNeutralText(useBlackWhite)}
|
|
140
|
-
>
|
|
141
|
-
Use suggested
|
|
142
|
-
</UIPillButton>
|
|
143
|
-
{/if}
|
|
144
119
|
</div>
|
|
145
120
|
|
|
146
|
-
{#if partialCoverage.length > 0}
|
|
147
|
-
<div
|
|
148
|
-
class="coverage-alert"
|
|
149
|
-
title="WCAG AA (4.5:1) coverage across the neutral surface steps. Informational, nothing is blocked."
|
|
150
|
-
>
|
|
151
|
-
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
152
|
-
<span>
|
|
153
|
-
AA reach: {partialCoverage
|
|
154
|
-
.map((s) => `${s.step} ${s.current.coverage.cleared}/${s.current.coverage.total} surfaces`)
|
|
155
|
-
.join(' · ')}
|
|
156
|
-
</span>
|
|
157
|
-
</div>
|
|
158
|
-
{/if}
|
|
159
|
-
|
|
160
121
|
<div class="functional-row">
|
|
161
122
|
{#each FUNCTIONAL as fn (fn.ns)}
|
|
162
123
|
{@const r = fmt(ratioOnBg(`--text-${fn.ns}`))}
|
|
163
124
|
<span class="fn-chip">
|
|
164
|
-
|
|
125
|
+
<!-- The dot shows the TEXT color the ratio measures (shadowed via
|
|
126
|
+
storyVars), matching the label — not the family's seed fill. -->
|
|
127
|
+
<span class="fn-dot" style:--dot="var(--text-{fn.ns})"></span>
|
|
165
128
|
<span class="fn-label" style:color="var(--text-{fn.ns})">{fn.label}</span>
|
|
166
129
|
{#if r}<span class="ratio">{r}</span>{/if}
|
|
167
130
|
</span>
|
|
@@ -197,14 +160,15 @@
|
|
|
197
160
|
}
|
|
198
161
|
|
|
199
162
|
/* Transparent: the background surface reads directly, no card over it. */
|
|
163
|
+
/* No min-height: 0 — the field must keep its content min-height so tall
|
|
164
|
+
text wrapping grows the frame instead of overflowing onto the tone bars. */
|
|
200
165
|
.field {
|
|
201
166
|
flex-grow: 60;
|
|
202
167
|
flex-basis: 0;
|
|
203
168
|
display: flex;
|
|
204
169
|
flex-direction: column;
|
|
205
|
-
gap: var(--ui-space-
|
|
206
|
-
|
|
207
|
-
padding: var(--ui-space-8) var(--ui-space-12);
|
|
170
|
+
gap: var(--ui-space-8);
|
|
171
|
+
padding: var(--ui-space-12);
|
|
208
172
|
color: var(--text-primary);
|
|
209
173
|
}
|
|
210
174
|
|
|
@@ -216,30 +180,30 @@
|
|
|
216
180
|
}
|
|
217
181
|
|
|
218
182
|
.field-label {
|
|
219
|
-
font-size: var(--ui-font-size-
|
|
183
|
+
font-size: var(--ui-font-size-md);
|
|
220
184
|
font-weight: var(--ui-font-weight-semibold);
|
|
221
185
|
}
|
|
222
186
|
|
|
223
187
|
.body {
|
|
224
188
|
margin: 0;
|
|
225
|
-
font-size: var(--ui-font-size-
|
|
189
|
+
font-size: var(--ui-font-size-md);
|
|
226
190
|
color: var(--text-primary);
|
|
227
191
|
}
|
|
228
192
|
|
|
229
193
|
.secondary {
|
|
230
194
|
margin: 0;
|
|
231
|
-
font-size: var(--ui-font-size-
|
|
195
|
+
font-size: var(--ui-font-size-md);
|
|
232
196
|
color: var(--text-secondary);
|
|
233
197
|
}
|
|
234
198
|
|
|
235
199
|
.tertiary {
|
|
236
200
|
margin: 0;
|
|
237
|
-
font-size: var(--ui-font-size-
|
|
201
|
+
font-size: var(--ui-font-size-md);
|
|
238
202
|
color: var(--text-tertiary);
|
|
239
203
|
}
|
|
240
204
|
|
|
241
205
|
.inverted-row {
|
|
242
|
-
margin: 0;
|
|
206
|
+
margin: var(--ui-space-8) 0 0;
|
|
243
207
|
}
|
|
244
208
|
|
|
245
209
|
/* The pill's fill IS the primary text color: the flip is shown literally,
|
|
@@ -252,7 +216,7 @@
|
|
|
252
216
|
border-radius: var(--ui-radius-full);
|
|
253
217
|
background: var(--text-primary);
|
|
254
218
|
color: var(--text-inverted);
|
|
255
|
-
font-size: var(--ui-font-size-
|
|
219
|
+
font-size: var(--ui-font-size-md);
|
|
256
220
|
}
|
|
257
221
|
|
|
258
222
|
.suggest-row {
|
|
@@ -260,10 +224,11 @@
|
|
|
260
224
|
align-items: center;
|
|
261
225
|
flex-wrap: wrap;
|
|
262
226
|
gap: var(--ui-space-8);
|
|
227
|
+
margin-top: var(--ui-space-12);
|
|
263
228
|
}
|
|
264
229
|
|
|
265
230
|
.suggest-label {
|
|
266
|
-
font-size: var(--ui-font-size-
|
|
231
|
+
font-size: var(--ui-font-size-md);
|
|
267
232
|
font-weight: var(--ui-font-weight-semibold);
|
|
268
233
|
opacity: 0.6;
|
|
269
234
|
}
|
|
@@ -279,36 +244,15 @@
|
|
|
279
244
|
}
|
|
280
245
|
|
|
281
246
|
.suggest-step {
|
|
282
|
-
font-size: var(--ui-font-size-
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
.bw-toggle {
|
|
286
|
-
display: flex;
|
|
287
|
-
align-items: center;
|
|
288
|
-
gap: var(--ui-space-4);
|
|
289
|
-
font-size: var(--ui-font-size-xs);
|
|
290
|
-
cursor: pointer;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
.bw-toggle input {
|
|
294
|
-
margin: 0;
|
|
295
|
-
cursor: pointer;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
.coverage-alert {
|
|
299
|
-
display: flex;
|
|
300
|
-
align-items: center;
|
|
301
|
-
gap: var(--ui-space-6);
|
|
302
|
-
font-size: var(--ui-font-size-xs);
|
|
303
|
-
opacity: 0.75;
|
|
247
|
+
font-size: var(--ui-font-size-md);
|
|
304
248
|
}
|
|
305
249
|
|
|
306
250
|
.functional-row {
|
|
307
251
|
display: flex;
|
|
308
|
-
gap: var(--ui-space-8);
|
|
252
|
+
gap: var(--ui-space-8) var(--ui-space-12);
|
|
309
253
|
flex-wrap: wrap;
|
|
310
254
|
margin-top: auto;
|
|
311
|
-
padding-top: var(--ui-space-
|
|
255
|
+
padding-top: var(--ui-space-16);
|
|
312
256
|
}
|
|
313
257
|
|
|
314
258
|
.fn-chip {
|
|
@@ -326,17 +270,18 @@
|
|
|
326
270
|
}
|
|
327
271
|
|
|
328
272
|
.fn-label {
|
|
329
|
-
font-size: var(--ui-font-size-
|
|
273
|
+
font-size: var(--ui-font-size-md);
|
|
330
274
|
font-weight: var(--ui-font-weight-semibold);
|
|
331
275
|
}
|
|
332
276
|
|
|
277
|
+
/* Like .field, no min-height: 0 — the bars' min heights must grow the frame
|
|
278
|
+
rather than spill past its border when the field's text is tall. */
|
|
333
279
|
.tones {
|
|
334
280
|
display: flex;
|
|
335
281
|
flex-direction: column;
|
|
336
282
|
gap: var(--ui-space-8);
|
|
337
283
|
flex-grow: 40;
|
|
338
284
|
flex-basis: 0;
|
|
339
|
-
min-height: 0;
|
|
340
285
|
}
|
|
341
286
|
|
|
342
287
|
.tone {
|
|
@@ -363,12 +308,12 @@
|
|
|
363
308
|
}
|
|
364
309
|
|
|
365
310
|
.tone-name {
|
|
366
|
-
font-size: var(--ui-font-size-
|
|
311
|
+
font-size: var(--ui-font-size-md);
|
|
367
312
|
font-weight: var(--ui-font-weight-semibold);
|
|
368
313
|
}
|
|
369
314
|
|
|
370
315
|
.ratio {
|
|
371
|
-
font-size: var(--ui-font-size-
|
|
316
|
+
font-size: var(--ui-font-size-md);
|
|
372
317
|
font-family: var(--ui-font-mono);
|
|
373
318
|
opacity: 0.75;
|
|
374
319
|
}
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
const EXT_OFFSET = 20; // external handle radius beyond the disc rim (room for the dotted tether)
|
|
61
61
|
const NUM_OFFSET = 24; // numeral radius beyond the external handles
|
|
62
62
|
const MIN_SIZE = 240;
|
|
63
|
-
const MAX_SIZE =
|
|
63
|
+
const MAX_SIZE = 560;
|
|
64
64
|
|
|
65
65
|
const normDeg = (d: number) => ((d % 360) + 360) % 360;
|
|
66
66
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
|
@@ -469,11 +469,11 @@
|
|
|
469
469
|
its inner half — reads as one axis that extends outward as dots to the
|
|
470
470
|
external handle. Same angle as the handle, so it tracks live. -->
|
|
471
471
|
{#each visibleRender as t (t.index)}
|
|
472
|
-
<line class="tether" x1={center} y1={center} x2={t.ext.x} y2={t.ext.y} />
|
|
472
|
+
<line class="tether" class:selected={t.selected} x1={center} y1={center} x2={t.ext.x} y2={t.ext.y} />
|
|
473
473
|
{/each}
|
|
474
474
|
{#each visibleRender as t (t.index)}
|
|
475
475
|
{#if t.bound}
|
|
476
|
-
<line class="rail" x1={center} y1={center} x2={t.dot.x} y2={t.dot.y} />
|
|
476
|
+
<line class="rail" class:selected={t.selected} x1={center} y1={center} x2={t.dot.x} y2={t.dot.y} />
|
|
477
477
|
{/if}
|
|
478
478
|
{/each}
|
|
479
479
|
</svg>
|
|
@@ -483,7 +483,7 @@
|
|
|
483
483
|
{/each}
|
|
484
484
|
|
|
485
485
|
{#each visibleRender as t (t.index)}
|
|
486
|
-
<span class="axis-num" style="left: {t.num.x}px; top: {t.num.y}px" aria-hidden="true">{t.index + 1}</span>
|
|
486
|
+
<span class="axis-num" class:selected={t.selected} style="left: {t.num.x}px; top: {t.num.y}px" aria-hidden="true">{t.index + 1}</span>
|
|
487
487
|
{/each}
|
|
488
488
|
|
|
489
489
|
{#each visibleRender as t (t.index)}
|
|
@@ -583,7 +583,7 @@
|
|
|
583
583
|
.rail {
|
|
584
584
|
stroke: var(--ui-text-primary);
|
|
585
585
|
stroke-width: 2;
|
|
586
|
-
opacity: 0.
|
|
586
|
+
opacity: 0.5;
|
|
587
587
|
}
|
|
588
588
|
|
|
589
589
|
/* Dotted continuation of the axis out to the external handle. Greyscale. */
|
|
@@ -592,7 +592,20 @@
|
|
|
592
592
|
stroke-width: 1.5;
|
|
593
593
|
stroke-dasharray: 2 4;
|
|
594
594
|
stroke-linecap: round;
|
|
595
|
-
opacity: 0.
|
|
595
|
+
opacity: 0.7;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/* Selected family's axis: heavier stroke at full contrast, so the whole axis
|
|
599
|
+
reads as active alongside its enlarged dot and handle. */
|
|
600
|
+
.rail.selected {
|
|
601
|
+
stroke-width: 4;
|
|
602
|
+
opacity: 1;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
.tether.selected {
|
|
606
|
+
stroke: var(--ui-text-primary);
|
|
607
|
+
stroke-width: 2.5;
|
|
608
|
+
opacity: 1;
|
|
596
609
|
}
|
|
597
610
|
|
|
598
611
|
/* Faint ring the external axis handles ride along. Decorative. */
|
|
@@ -725,12 +738,17 @@
|
|
|
725
738
|
transform: translate(-50%, -50%);
|
|
726
739
|
font-size: var(--ui-font-size-xs);
|
|
727
740
|
font-weight: var(--ui-font-weight-semibold);
|
|
728
|
-
color: var(--ui-text-
|
|
741
|
+
color: var(--ui-text-tertiary);
|
|
729
742
|
pointer-events: none;
|
|
730
743
|
user-select: none;
|
|
731
744
|
z-index: 3;
|
|
732
745
|
}
|
|
733
746
|
|
|
747
|
+
.axis-num.selected {
|
|
748
|
+
color: var(--ui-text-primary);
|
|
749
|
+
font-size: var(--ui-font-size-sm);
|
|
750
|
+
}
|
|
751
|
+
|
|
734
752
|
.dot:focus-visible,
|
|
735
753
|
.ext-handle:focus-visible,
|
|
736
754
|
.global-handle:focus-visible {
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { flip } from 'svelte/animate';
|
|
3
|
-
import { cubicOut } from 'svelte/easing';
|
|
4
2
|
import { oklchToHexClamped } from '../../core/palettes/oklch';
|
|
5
3
|
import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
|
|
6
4
|
import { editorState, beginSliderGesture, transaction } from '../../core/store/editorStore';
|
|
@@ -13,7 +11,15 @@
|
|
|
13
11
|
import PaletteStepStrip from './PaletteStepStrip.svelte';
|
|
14
12
|
import HarmonyAxesList from './HarmonyAxesList.svelte';
|
|
15
13
|
import UIPillButton from '../UIPillButton.svelte';
|
|
16
|
-
import {
|
|
14
|
+
import { recommendNeutralText } from '../../core/palettes/recommendText';
|
|
15
|
+
import {
|
|
16
|
+
setBaseColor,
|
|
17
|
+
setBaseColors,
|
|
18
|
+
setAxisHues,
|
|
19
|
+
applySolvedTextCurves,
|
|
20
|
+
applySuggestedNeutralText,
|
|
21
|
+
palettesWithDefaults,
|
|
22
|
+
} from './paletteBaseColor';
|
|
17
23
|
import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
|
|
18
24
|
import { dockGrow } from '../palette/dockMagnify';
|
|
19
25
|
|
|
@@ -65,6 +71,14 @@
|
|
|
65
71
|
transaction('derive accessible text', applySolvedTextCurves);
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
// Color Story model: the story is a pure readout, so its palette map and text
|
|
75
|
+
// recommendation are owned here alongside the actions that write them.
|
|
76
|
+
let useBlackWhite = $state(false);
|
|
77
|
+
let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
|
|
78
|
+
let textRec = $derived(recommendNeutralText(fullPalettes, undefined, useBlackWhite));
|
|
79
|
+
|
|
80
|
+
let pureExtreme = $derived(textRec.scheme === 'light' ? 'pure black' : 'pure white');
|
|
81
|
+
|
|
68
82
|
let swatches = $derived.by(() => {
|
|
69
83
|
// A family bound to an inactive axis edits in dot mode, so it is not "on the wheel".
|
|
70
84
|
const active = modeActiveAxes(activeMode);
|
|
@@ -74,17 +88,16 @@
|
|
|
74
88
|
label: spec.label,
|
|
75
89
|
display: spec.displayLabel ?? spec.label,
|
|
76
90
|
hex: oklchToHexClamped(l, c, h),
|
|
77
|
-
l,
|
|
78
91
|
onWheel: $editorState.harmonyAxes.some((a, i) => a.family === spec.label && active[i]),
|
|
79
92
|
};
|
|
80
93
|
});
|
|
81
94
|
});
|
|
82
95
|
|
|
83
96
|
const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
let coreSwatches = $derived(
|
|
87
|
-
let functionalSwatches = $derived(
|
|
97
|
+
// Declaration order, never a computed one: sorting by luminance made the row
|
|
98
|
+
// reshuffle under the pointer while a lightness edit was in flight.
|
|
99
|
+
let coreSwatches = $derived(swatches.filter((sw) => !FUNCTIONAL_FAMILIES.has(sw.label)));
|
|
100
|
+
let functionalSwatches = $derived(swatches.filter((sw) => FUNCTIONAL_FAMILIES.has(sw.label)));
|
|
88
101
|
|
|
89
102
|
let shownModeLabel = $derived(
|
|
90
103
|
HARMONY_MODE_BUTTONS.find((b) => b.mode === (hoverMode ?? activeMode))?.label ?? '',
|
|
@@ -135,7 +148,6 @@
|
|
|
135
148
|
|
|
136
149
|
<div class="harmony-actions">
|
|
137
150
|
<UIPillButton
|
|
138
|
-
size="compact"
|
|
139
151
|
icon="fa-fill-drip"
|
|
140
152
|
title="Re-hue Neutral and Alternate to the anchor color (their own chroma and lightness kept)"
|
|
141
153
|
onclick={tintNeutrals}
|
|
@@ -183,7 +195,6 @@
|
|
|
183
195
|
aria-label={`Select ${sw.display}`}
|
|
184
196
|
aria-pressed={selected === sw.label}
|
|
185
197
|
onclick={() => select(sw.label)}
|
|
186
|
-
animate:flip={{ duration: 200, easing: cubicOut }}
|
|
187
198
|
>
|
|
188
199
|
<span class="chip">
|
|
189
200
|
{#if sw.onWheel}<span class="wheel-dot" title="On the wheel" aria-hidden="true"></span>{/if}
|
|
@@ -225,24 +236,38 @@
|
|
|
225
236
|
|
|
226
237
|
<div class="pane">
|
|
227
238
|
<section id="colors-story" class="block">
|
|
228
|
-
<
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
239
|
+
<header class="block-head">
|
|
240
|
+
<span class="eyebrow">Proportional preview</span>
|
|
241
|
+
<h2 class="title">Color Story</h2>
|
|
242
|
+
</header>
|
|
243
|
+
<div class="story-actions">
|
|
233
244
|
<UIPillButton
|
|
234
|
-
icon="fa-
|
|
235
|
-
title="
|
|
245
|
+
icon="fa-universal-access"
|
|
246
|
+
title="Every family: solve its Text lightness curve to the value that clears WCAG AA (4.5:1) against its own surfaces. Contrast-first, so secondary and tertiary can land close together. One undo."
|
|
236
247
|
onclick={deriveAccessibleText}
|
|
237
|
-
>
|
|
248
|
+
>Raise all text to AA</UIPillButton>
|
|
249
|
+
<UIPillButton
|
|
250
|
+
icon="fa-arrow-down-short-wide"
|
|
251
|
+
disabled={!textRec.anyDiffers}
|
|
252
|
+
title={textRec.anyDiffers
|
|
253
|
+
? 'Neutral text: keep primary where it is and step secondary and tertiary down in even lightness drops, floored at WCAG AA (4.5:1). Muted and disabled stay put. One undo.'
|
|
254
|
+
: 'The neutral text steps already match the suggestion shown below.'}
|
|
255
|
+
onclick={() => applySuggestedNeutralText(useBlackWhite)}
|
|
256
|
+
>Even neutral steps</UIPillButton>
|
|
257
|
+
<UIPillButton
|
|
258
|
+
icon="fa-circle-half-stroke"
|
|
259
|
+
ariaPressed={useBlackWhite}
|
|
260
|
+
title={`Start the suggested steps from ${pureExtreme} instead of the softer extreme most designers prefer. Changes the suggestion shown below.`}
|
|
261
|
+
onclick={() => (useBlackWhite = !useBlackWhite)}
|
|
262
|
+
>Anchor at {pureExtreme}</UIPillButton>
|
|
238
263
|
</div>
|
|
239
|
-
<ColorStory />
|
|
264
|
+
<ColorStory full={fullPalettes} rec={textRec} />
|
|
240
265
|
</section>
|
|
241
266
|
|
|
242
267
|
<section id="colors-axes" class="block">
|
|
243
268
|
<h2 class="title">Harmony axes</h2>
|
|
244
269
|
<p class="axes-desc">Each axis owns a hue. Drop a color on an axis to bind it. The color adopts the axis hue and follows the axis. Drag a color to Unassigned to let it float free.</p>
|
|
245
|
-
<HarmonyAxesList {activeMode} />
|
|
270
|
+
<HarmonyAxesList {activeMode} {selected} onSelect={select} />
|
|
246
271
|
</section>
|
|
247
272
|
</div>
|
|
248
273
|
</div>
|
|
@@ -255,6 +280,17 @@
|
|
|
255
280
|
align-items: start;
|
|
256
281
|
}
|
|
257
282
|
|
|
283
|
+
/* Wide standalone layouts (Colors page, full-screen editor): pin the story
|
|
284
|
+
pane to the width it was designed at in the docked panel — wider stretches
|
|
285
|
+
the seed-color bars into heavy slabs — and let the wheel column absorb the
|
|
286
|
+
rest. The docked panel's iframe viewport stays below this breakpoint. */
|
|
287
|
+
@media (min-width: 1200px) {
|
|
288
|
+
.colors-tab {
|
|
289
|
+
grid-template-columns: minmax(0, 1fr) 30rem;
|
|
290
|
+
gap: var(--ui-space-48);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
258
294
|
.pane {
|
|
259
295
|
display: flex;
|
|
260
296
|
flex-direction: column;
|
|
@@ -264,7 +300,7 @@
|
|
|
264
300
|
|
|
265
301
|
.axes-desc {
|
|
266
302
|
margin: 0;
|
|
267
|
-
font-size: var(--ui-font-size-
|
|
303
|
+
font-size: var(--ui-font-size-md);
|
|
268
304
|
line-height: 1.5;
|
|
269
305
|
color: var(--ui-text-secondary);
|
|
270
306
|
}
|
|
@@ -424,16 +460,14 @@
|
|
|
424
460
|
color: var(--ui-text-secondary);
|
|
425
461
|
}
|
|
426
462
|
|
|
427
|
-
.
|
|
463
|
+
.harmony-actions {
|
|
428
464
|
display: flex;
|
|
429
|
-
|
|
430
|
-
justify-content: space-between;
|
|
431
|
-
gap: var(--ui-space-12);
|
|
432
|
-
flex-wrap: wrap;
|
|
465
|
+
gap: var(--ui-space-8);
|
|
433
466
|
}
|
|
434
467
|
|
|
435
|
-
.
|
|
468
|
+
.story-actions {
|
|
436
469
|
display: flex;
|
|
470
|
+
flex-wrap: wrap;
|
|
437
471
|
gap: var(--ui-space-8);
|
|
438
472
|
}
|
|
439
473
|
|
|
@@ -445,8 +479,7 @@
|
|
|
445
479
|
}
|
|
446
480
|
|
|
447
481
|
/* Dock magnification, mirroring PaletteStepStrip: the flex-grow transition
|
|
448
|
-
animates the selected swatch opening
|
|
449
|
-
luminance-sorted spot as lightness is dialed. */
|
|
482
|
+
animates the selected swatch opening in place. */
|
|
450
483
|
.swatch-row {
|
|
451
484
|
display: flex;
|
|
452
485
|
align-items: flex-end;
|
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
/** The applied harmony mode: an unbound slot is disabled when the geometry
|
|
13
13
|
* deals it no distinct position (complementary enables only the first two). */
|
|
14
14
|
activeMode: HarmonyMode;
|
|
15
|
+
/** Family selected in the swatch grid / wheel — its row (or chip) reads as active here. */
|
|
16
|
+
selected: string | null;
|
|
17
|
+
onSelect: (family: string) => void;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
let { activeMode }: Props = $props();
|
|
20
|
+
let { activeMode, selected, onSelect }: Props = $props();
|
|
18
21
|
|
|
19
22
|
const DRAG_TYPE = 'application/x-harmony-family';
|
|
20
23
|
// Unbound swatch previews the hue a dropped color would take, at fixed preview
|
|
@@ -113,6 +116,7 @@
|
|
|
113
116
|
<div
|
|
114
117
|
class="axis-row"
|
|
115
118
|
class:disabled
|
|
119
|
+
class:selected={axis.family !== null && axis.family === selected}
|
|
116
120
|
class:drop-target={dragTarget === i}
|
|
117
121
|
aria-disabled={disabled || undefined}
|
|
118
122
|
ondragover={(e) => onDragOver(e, i)}
|
|
@@ -126,13 +130,16 @@
|
|
|
126
130
|
<button
|
|
127
131
|
type="button"
|
|
128
132
|
class="chip"
|
|
133
|
+
class:selected={family === selected}
|
|
129
134
|
draggable="true"
|
|
130
135
|
data-family={family}
|
|
131
136
|
aria-label={`${family}, ${AXIS_ROLES[i]} axis, position ${i + 1} of ${positionCount}. Arrow up or down to move it, Delete to unbind.`}
|
|
137
|
+
aria-pressed={family === selected}
|
|
132
138
|
title="Drag to another axis or to Unassigned. Arrow keys move it, Delete unbinds."
|
|
133
139
|
ondragstart={(e) => onDragStart(e, family)}
|
|
134
140
|
ondragend={onDragEnd}
|
|
135
141
|
onkeydown={(e) => onChipKeydown(e, family)}
|
|
142
|
+
onclick={() => onSelect(family)}
|
|
136
143
|
>
|
|
137
144
|
<span class="grip" aria-hidden="true">⋮⋮</span>
|
|
138
145
|
<span class="chip-swatch" style="--fill: {familyHex(family)}"></span>
|
|
@@ -159,13 +166,16 @@
|
|
|
159
166
|
<button
|
|
160
167
|
type="button"
|
|
161
168
|
class="chip"
|
|
169
|
+
class:selected={family === selected}
|
|
162
170
|
draggable="true"
|
|
163
171
|
data-family={family}
|
|
164
172
|
aria-label={`${family}, unassigned, position ${positionCount} of ${positionCount}. Arrow up to bind it to ${AXIS_ROLES[lastAxis]}.`}
|
|
173
|
+
aria-pressed={family === selected}
|
|
165
174
|
title={`Drag onto an axis to bind it. Arrow up binds it to ${AXIS_ROLES[lastAxis]}.`}
|
|
166
175
|
ondragstart={(e) => onDragStart(e, family)}
|
|
167
176
|
ondragend={onDragEnd}
|
|
168
177
|
onkeydown={(e) => onChipKeydown(e, family)}
|
|
178
|
+
onclick={() => onSelect(family)}
|
|
169
179
|
animate:flip={{ duration: 200, easing: cubicOut }}
|
|
170
180
|
>
|
|
171
181
|
<span class="grip" aria-hidden="true">⋮⋮</span>
|
|
@@ -195,7 +205,7 @@
|
|
|
195
205
|
background: var(--ui-surface-lowest);
|
|
196
206
|
border: 1px solid var(--ui-border-low);
|
|
197
207
|
border-radius: var(--ui-radius-md);
|
|
198
|
-
font-size: var(--ui-font-size-
|
|
208
|
+
font-size: var(--ui-font-size-md);
|
|
199
209
|
transition:
|
|
200
210
|
border-color var(--ui-transition-fast),
|
|
201
211
|
background var(--ui-transition-fast);
|
|
@@ -207,6 +217,19 @@
|
|
|
207
217
|
background: var(--ui-surface-low);
|
|
208
218
|
}
|
|
209
219
|
|
|
220
|
+
/* The family selected in the swatch grid / wheel: the row's numeral and role
|
|
221
|
+
go full-contrast so the selection reads across both surfaces. */
|
|
222
|
+
.axis-row.selected {
|
|
223
|
+
border-color: var(--ui-text-primary);
|
|
224
|
+
background: var(--ui-surface-low);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.axis-row.selected .role,
|
|
228
|
+
.axis-row.selected .role-num {
|
|
229
|
+
color: var(--ui-text-primary);
|
|
230
|
+
font-weight: var(--ui-font-weight-semibold);
|
|
231
|
+
}
|
|
232
|
+
|
|
210
233
|
.role {
|
|
211
234
|
flex: none;
|
|
212
235
|
width: 6.5rem;
|
|
@@ -241,7 +264,7 @@
|
|
|
241
264
|
|
|
242
265
|
.empty {
|
|
243
266
|
color: var(--ui-text-tertiary);
|
|
244
|
-
font-size: var(--ui-font-size-
|
|
267
|
+
font-size: var(--ui-font-size-md);
|
|
245
268
|
}
|
|
246
269
|
|
|
247
270
|
.grip {
|
|
@@ -262,7 +285,7 @@
|
|
|
262
285
|
border: 1px solid var(--ui-border-low);
|
|
263
286
|
border-radius: var(--ui-radius-md);
|
|
264
287
|
color: var(--ui-text-primary);
|
|
265
|
-
font-size: var(--ui-font-size-
|
|
288
|
+
font-size: var(--ui-font-size-md);
|
|
266
289
|
line-height: 1;
|
|
267
290
|
cursor: grab;
|
|
268
291
|
user-select: none;
|
|
@@ -272,6 +295,11 @@
|
|
|
272
295
|
border-color: var(--ui-border-high);
|
|
273
296
|
}
|
|
274
297
|
|
|
298
|
+
.chip.selected {
|
|
299
|
+
border-color: var(--ui-text-primary);
|
|
300
|
+
background: var(--ui-surface-high);
|
|
301
|
+
}
|
|
302
|
+
|
|
275
303
|
.chip:focus-visible {
|
|
276
304
|
outline: 2px solid var(--ui-border-higher);
|
|
277
305
|
outline-offset: 2px;
|
|
@@ -201,6 +201,18 @@ export function applySolvedTextCurves(s: EditorState): void {
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/** Read-only view of every family's config, seeding spec defaults for the ones a
|
|
205
|
+
* loaded theme leaves unconfigured — previews and recommendations must not go
|
|
206
|
+
* static on a family the theme never mentions. */
|
|
207
|
+
export function palettesWithDefaults(palettes: Record<string, PaletteConfig>): Record<string, PaletteConfig> {
|
|
208
|
+
const map: Record<string, PaletteConfig> = {};
|
|
209
|
+
for (const spec of PALETTE_SPECS) {
|
|
210
|
+
map[spec.label] =
|
|
211
|
+
palettes[spec.label] ?? defaultPaletteConfig({ baseColor: spec.initialColor, neutral: spec.neutral ?? false });
|
|
212
|
+
}
|
|
213
|
+
return map;
|
|
214
|
+
}
|
|
215
|
+
|
|
204
216
|
/** Apply the Color Story's suggested neutral text hierarchy (primary anchor,
|
|
205
217
|
* two even L drops, AA floors) as the Neutral family's Text curve. */
|
|
206
218
|
export function applySuggestedNeutralText(useBlackWhite = false): void {
|