@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 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.42.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: 3.0, guaranteed: true, target: 'default' },
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(step.name, stepPlan),
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
- // The AA floor a guaranteed step must clear (primary/secondary/tertiary = body
229
- // 4.5; neutral muted = large 3.0). Graded steps floor at 1 (informational).
230
- function minGuarantee(stepName: string, plan: StepPlan): number {
231
- if (!plan.guaranteed) return 1;
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 = localStorage.getItem(VIEW_KEY);
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 { localStorage.setItem(VIEW_KEY, v); } catch {}
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 localStorage but each have
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 route renders the same component-editor surface as the
69
- // overlay's components view. Pair them: on entering it, flip the overlay to
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 consumer pages. Pass a
34
- * string to relocate a route; pass `false` to disable it entirely (no
35
- * dispatch and, for `components`/`docs`, no auto-injected nav-rail entry).
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) have
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 to null
101
- // here; everything else flows through resolveRoute and drives both dispatch
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 reachable
109
- // from every page. Docs ship from the package, so consumers reference the
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 button
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>
@@ -0,0 +1,2 @@
1
+ import { SvelteComponent } from 'svelte';
2
+ export default class ColorsPage extends SvelteComponent<Record<string, never>> {}
@@ -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 is already the components editor —
13
- // the overlay's components view would just stack on top, so disable the
14
- // switch. The switcher renders inside the editor iframe, so we read the
15
- // *parent* route, not this iframe's own. Compares the default path:
16
- // editorRoutes.components relocation isn't plumbed across the iframe, so a
17
- // relocated route won't disable the switch.
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; Components is skipped while it's disabled (already on that
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, PALETTE_SPECS, type PaletteSpec } from '../../core/palettes/paletteDerivation';
5
- import { defaultPaletteConfig } from '../palette/paletteMath';
3
+ import { palettesToVars } from '../../core/palettes/paletteDerivation';
6
4
  import { contrastRatio } from '../../core/palettes/contrast';
7
- import { recommendNeutralText } from '../../core/palettes/recommendText';
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 } = $editorState.palettes[label]?.baseColor ?? SPEC_BY_LABEL[label].initialColor;
40
+ const { l, c, h } = full[label].baseColor;
37
41
  return oklchToHexClamped(l, c, h);
38
42
  }
39
43
 
40
- // Text still renders from this store-derived map (text derivation tracks the
41
- // seed), never from the ambient :root variables — those stay at vendored
42
- // defaults for families a loaded theme doesn't configure, so a story bound
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
- <!-- Always rendered: toggling "Use black and white" changes the
120
- suggestion, so the toggle must stay reachable even when the current
121
- values already match. -->
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
- <span class="fn-dot" style:--dot={seedHex(fn.label)}></span>
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-4);
206
- min-height: 0;
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-sm);
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-sm);
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-xs);
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-xs);
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-xs);
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-sm);
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-xs);
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-12);
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-xs);
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-xs);
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-xs);
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 = 360;
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.65;
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.9;
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-primary);
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 { setBaseColor, setBaseColors, setAxisHues, applySolvedTextCurves } from './paletteBaseColor';
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
- // 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))));
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
- <div class="head-row">
229
- <header class="block-head">
230
- <span class="eyebrow">Proportional preview</span>
231
- <h2 class="title">Color Story</h2>
232
- </header>
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-wand-magic-sparkles"
235
- title="Solve each family's Text lightness curve so derived text meets WCAG AA against its surfaces (one undo)"
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
- >Derive accessible text</UIPillButton>
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-sm);
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
- .head-row {
463
+ .harmony-actions {
428
464
  display: flex;
429
- align-items: flex-end;
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
- .harmony-actions {
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 while flip walks it to its
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-sm);
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-sm);
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-sm);
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 {