@motion-proto/live-tokens 0.43.0 → 0.45.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 +91 -0
- package/package.json +6 -1
- package/src/editor/core/palettes/paletteDerivation.ts +1 -2
- package/src/editor/core/routing/ownedRoutes.ts +1 -0
- 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 +51 -159
- package/src/editor/ui/colors/ColorWheel.svelte +25 -7
- package/src/editor/ui/colors/ColorsTab.svelte +31 -38
- package/src/editor/ui/colors/HarmonyAxesList.svelte +32 -4
- package/src/editor/ui/colors/paletteBaseColor.ts +9 -19
- package/src/editor/core/palettes/recommendText.ts +0 -216
- package/src/editor/core/palettes/solveTextContrast.ts +0 -251
|
@@ -1,14 +1,16 @@
|
|
|
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 { recommendNeutralText } from '../../core/palettes/recommendText';
|
|
8
|
-
import { applySuggestedNeutralText } from './paletteBaseColor';
|
|
9
|
-
import UIPillButton from '../UIPillButton.svelte';
|
|
10
5
|
import type { PaletteConfig } from '../../core/themes/themeTypes';
|
|
11
6
|
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Every family's config, spec defaults filled in (palettesWithDefaults). */
|
|
9
|
+
full: Record<string, PaletteConfig>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { full }: Props = $props();
|
|
13
|
+
|
|
12
14
|
// 60-30-10 as an area composition: the background surface is the dominant 60%
|
|
13
15
|
// field (the page), and Brand/Accent/Special are the tones shown against it.
|
|
14
16
|
// Shares are flex-grow weights, not exact percentages.
|
|
@@ -18,6 +20,8 @@
|
|
|
18
20
|
{ label: 'Special', ns: 'special', share: 2 },
|
|
19
21
|
] as const;
|
|
20
22
|
|
|
23
|
+
const TEXT_STEPS = ['primary', 'secondary', 'tertiary'] as const;
|
|
24
|
+
|
|
21
25
|
const FUNCTIONAL = [
|
|
22
26
|
{ label: 'Info', ns: 'info' },
|
|
23
27
|
{ label: 'Success', ns: 'success' },
|
|
@@ -30,31 +34,15 @@
|
|
|
30
34
|
// 1:1. Derived --surface-* tokens deliberately do not appear here: their
|
|
31
35
|
// lightness is curve-owned, which decouples the story from the wheel and
|
|
32
36
|
// reads as broken.
|
|
33
|
-
const SPEC_BY_LABEL: Record<string, PaletteSpec> = Object.fromEntries(PALETTE_SPECS.map((s) => [s.label, s]));
|
|
34
|
-
|
|
35
37
|
function seedHex(label: string): string {
|
|
36
|
-
const { l, c, h } =
|
|
38
|
+
const { l, c, h } = full[label].baseColor;
|
|
37
39
|
return oklchToHexClamped(l, c, h);
|
|
38
40
|
}
|
|
39
41
|
|
|
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
|
-
|
|
42
|
+
// Text renders from this map (text derivation tracks the seed), never from the
|
|
43
|
+
// ambient :root variables — those stay at vendored defaults for families a
|
|
44
|
+
// loaded theme doesn't configure, so a story bound to them silently goes static.
|
|
54
45
|
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
46
|
|
|
59
47
|
// Shadow the consumed text variables locally with the store-derived values;
|
|
60
48
|
// the page field is the Canvas family's SEED, not its derived page step.
|
|
@@ -79,8 +67,6 @@
|
|
|
79
67
|
return r === null ? null : `${(Math.floor(r * 10) / 10).toFixed(1)}:1`;
|
|
80
68
|
}
|
|
81
69
|
|
|
82
|
-
let bgRatio = $derived(ratioOnBg('--text-primary'));
|
|
83
|
-
|
|
84
70
|
// Inverted is measured against the fill it sits on (primary), not the page:
|
|
85
71
|
// the pill below IS that pairing, so the number describes what is shown. A
|
|
86
72
|
// mid-lightness primary makes this pairing inherently low-contrast — the
|
|
@@ -96,19 +82,20 @@
|
|
|
96
82
|
|
|
97
83
|
<div class="story" style={storyVars}>
|
|
98
84
|
<div class="field">
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
85
|
+
<span class="field-label">Canvas</span>
|
|
86
|
+
|
|
87
|
+
<!-- Each step renders in its own token, so the word IS the sample and the
|
|
88
|
+
ratio beside it measures exactly what you are reading. -->
|
|
89
|
+
<div class="text-steps">
|
|
90
|
+
{#each TEXT_STEPS as step (step)}
|
|
91
|
+
{@const r = fmt(ratioOnBg(`--text-${step}`))}
|
|
92
|
+
<p class="text-step" style:color="var(--text-{step})">
|
|
93
|
+
{step}
|
|
94
|
+
{#if r}<span class="ratio">{r}</span>{/if}
|
|
95
|
+
</p>
|
|
96
|
+
{/each}
|
|
102
97
|
</div>
|
|
103
|
-
|
|
104
|
-
<p class="secondary">
|
|
105
|
-
Secondary text follows the same field.
|
|
106
|
-
{#if fmt(ratioOnBg('--text-secondary'))}<span class="ratio">{fmt(ratioOnBg('--text-secondary'))}</span>{/if}
|
|
107
|
-
</p>
|
|
108
|
-
<p class="tertiary">
|
|
109
|
-
Tertiary text steps back once more.
|
|
110
|
-
{#if fmt(ratioOnBg('--text-tertiary'))}<span class="ratio">{fmt(ratioOnBg('--text-tertiary'))}</span>{/if}
|
|
111
|
-
</p>
|
|
98
|
+
|
|
112
99
|
<p class="inverted-row">
|
|
113
100
|
<span class="inverted-pill">
|
|
114
101
|
Inverted text flips onto primary.
|
|
@@ -116,52 +103,13 @@
|
|
|
116
103
|
</span>
|
|
117
104
|
</p>
|
|
118
105
|
|
|
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>
|
|
124
|
-
{#each rec.suggestions as s (s.step)}
|
|
125
|
-
<span class="suggest-entry" class:dim={!s.differs}>
|
|
126
|
-
<span class="fn-dot" style:--dot={s.suggested.hex}></span>
|
|
127
|
-
<span class="suggest-step">{s.step}</span>
|
|
128
|
-
<span class="ratio">{fmt(contrastRatio(s.suggested.hex, seedHex('Canvas')))}</span>
|
|
129
|
-
</span>
|
|
130
|
-
{/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
|
-
</div>
|
|
145
|
-
|
|
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
106
|
<div class="functional-row">
|
|
161
107
|
{#each FUNCTIONAL as fn (fn.ns)}
|
|
162
108
|
{@const r = fmt(ratioOnBg(`--text-${fn.ns}`))}
|
|
163
109
|
<span class="fn-chip">
|
|
164
|
-
|
|
110
|
+
<!-- The dot shows the TEXT color the ratio measures (shadowed via
|
|
111
|
+
storyVars), matching the label — not the family's seed fill. -->
|
|
112
|
+
<span class="fn-dot" style:--dot="var(--text-{fn.ns})"></span>
|
|
165
113
|
<span class="fn-label" style:color="var(--text-{fn.ns})">{fn.label}</span>
|
|
166
114
|
{#if r}<span class="ratio">{r}</span>{/if}
|
|
167
115
|
</span>
|
|
@@ -197,49 +145,25 @@
|
|
|
197
145
|
}
|
|
198
146
|
|
|
199
147
|
/* Transparent: the background surface reads directly, no card over it. */
|
|
148
|
+
/* No min-height: 0 — the field must keep its content min-height so tall
|
|
149
|
+
text wrapping grows the frame instead of overflowing onto the tone bars. */
|
|
200
150
|
.field {
|
|
201
151
|
flex-grow: 60;
|
|
202
152
|
flex-basis: 0;
|
|
203
153
|
display: flex;
|
|
204
154
|
flex-direction: column;
|
|
205
|
-
gap: var(--ui-space-4);
|
|
206
|
-
min-height: 0;
|
|
207
|
-
padding: var(--ui-space-8) var(--ui-space-12);
|
|
208
|
-
color: var(--text-primary);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
.field-head {
|
|
212
|
-
display: flex;
|
|
213
|
-
align-items: baseline;
|
|
214
|
-
justify-content: space-between;
|
|
215
155
|
gap: var(--ui-space-8);
|
|
156
|
+
padding: var(--ui-space-12);
|
|
157
|
+
color: var(--text-primary);
|
|
216
158
|
}
|
|
217
159
|
|
|
218
160
|
.field-label {
|
|
219
|
-
font-size: var(--ui-font-size-
|
|
161
|
+
font-size: var(--ui-font-size-md);
|
|
220
162
|
font-weight: var(--ui-font-weight-semibold);
|
|
221
163
|
}
|
|
222
164
|
|
|
223
|
-
.body {
|
|
224
|
-
margin: 0;
|
|
225
|
-
font-size: var(--ui-font-size-sm);
|
|
226
|
-
color: var(--text-primary);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
.secondary {
|
|
230
|
-
margin: 0;
|
|
231
|
-
font-size: var(--ui-font-size-xs);
|
|
232
|
-
color: var(--text-secondary);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
.tertiary {
|
|
236
|
-
margin: 0;
|
|
237
|
-
font-size: var(--ui-font-size-xs);
|
|
238
|
-
color: var(--text-tertiary);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
165
|
.inverted-row {
|
|
242
|
-
margin: 0;
|
|
166
|
+
margin: var(--ui-space-8) 0 0;
|
|
243
167
|
}
|
|
244
168
|
|
|
245
169
|
/* The pill's fill IS the primary text color: the flip is shown literally,
|
|
@@ -252,63 +176,30 @@
|
|
|
252
176
|
border-radius: var(--ui-radius-full);
|
|
253
177
|
background: var(--text-primary);
|
|
254
178
|
color: var(--text-inverted);
|
|
255
|
-
font-size: var(--ui-font-size-
|
|
179
|
+
font-size: var(--ui-font-size-md);
|
|
256
180
|
}
|
|
257
181
|
|
|
258
|
-
.
|
|
182
|
+
.text-steps {
|
|
259
183
|
display: flex;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
gap: var(--ui-space-8);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
.suggest-label {
|
|
266
|
-
font-size: var(--ui-font-size-sm);
|
|
267
|
-
font-weight: var(--ui-font-weight-semibold);
|
|
268
|
-
opacity: 0.6;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
.suggest-entry {
|
|
272
|
-
display: flex;
|
|
273
|
-
align-items: center;
|
|
184
|
+
flex-direction: column;
|
|
185
|
+
align-items: flex-start;
|
|
274
186
|
gap: var(--ui-space-6);
|
|
275
187
|
}
|
|
276
188
|
|
|
277
|
-
.
|
|
278
|
-
opacity: 0.45;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
.suggest-step {
|
|
282
|
-
font-size: var(--ui-font-size-xs);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
.bw-toggle {
|
|
189
|
+
.text-step {
|
|
286
190
|
display: flex;
|
|
287
|
-
align-items:
|
|
288
|
-
gap: var(--ui-space-
|
|
289
|
-
font-size: var(--ui-font-size-xs);
|
|
290
|
-
cursor: pointer;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
.bw-toggle input {
|
|
191
|
+
align-items: baseline;
|
|
192
|
+
gap: var(--ui-space-8);
|
|
294
193
|
margin: 0;
|
|
295
|
-
|
|
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;
|
|
194
|
+
font-size: var(--ui-font-size-md);
|
|
304
195
|
}
|
|
305
196
|
|
|
306
197
|
.functional-row {
|
|
307
198
|
display: flex;
|
|
308
|
-
gap: var(--ui-space-8);
|
|
199
|
+
gap: var(--ui-space-8) var(--ui-space-12);
|
|
309
200
|
flex-wrap: wrap;
|
|
310
201
|
margin-top: auto;
|
|
311
|
-
padding-top: var(--ui-space-
|
|
202
|
+
padding-top: var(--ui-space-16);
|
|
312
203
|
}
|
|
313
204
|
|
|
314
205
|
.fn-chip {
|
|
@@ -326,17 +217,18 @@
|
|
|
326
217
|
}
|
|
327
218
|
|
|
328
219
|
.fn-label {
|
|
329
|
-
font-size: var(--ui-font-size-
|
|
220
|
+
font-size: var(--ui-font-size-md);
|
|
330
221
|
font-weight: var(--ui-font-weight-semibold);
|
|
331
222
|
}
|
|
332
223
|
|
|
224
|
+
/* Like .field, no min-height: 0 — the bars' min heights must grow the frame
|
|
225
|
+
rather than spill past its border when the field's text is tall. */
|
|
333
226
|
.tones {
|
|
334
227
|
display: flex;
|
|
335
228
|
flex-direction: column;
|
|
336
229
|
gap: var(--ui-space-8);
|
|
337
230
|
flex-grow: 40;
|
|
338
231
|
flex-basis: 0;
|
|
339
|
-
min-height: 0;
|
|
340
232
|
}
|
|
341
233
|
|
|
342
234
|
.tone {
|
|
@@ -363,12 +255,12 @@
|
|
|
363
255
|
}
|
|
364
256
|
|
|
365
257
|
.tone-name {
|
|
366
|
-
font-size: var(--ui-font-size-
|
|
258
|
+
font-size: var(--ui-font-size-md);
|
|
367
259
|
font-weight: var(--ui-font-weight-semibold);
|
|
368
260
|
}
|
|
369
261
|
|
|
370
262
|
.ratio {
|
|
371
|
-
font-size: var(--ui-font-size-
|
|
263
|
+
font-size: var(--ui-font-size-md);
|
|
372
264
|
font-family: var(--ui-font-mono);
|
|
373
265
|
opacity: 0.75;
|
|
374
266
|
}
|
|
@@ -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,9 +1,7 @@
|
|
|
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
|
-
import { editorState, beginSliderGesture
|
|
4
|
+
import { editorState, beginSliderGesture } from '../../core/store/editorStore';
|
|
7
5
|
import { applyHarmonyToAxes, modeActiveAxes, tintNeutralsFromAnchor, type HarmonyMode } from '../../core/palettes/colorHarmony';
|
|
8
6
|
import ColorEditPanel from '../ColorEditPanel.svelte';
|
|
9
7
|
import ColorWheel from './ColorWheel.svelte';
|
|
@@ -13,7 +11,12 @@
|
|
|
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 {
|
|
15
|
+
setBaseColor,
|
|
16
|
+
setBaseColors,
|
|
17
|
+
setAxisHues,
|
|
18
|
+
palettesWithDefaults,
|
|
19
|
+
} from './paletteBaseColor';
|
|
17
20
|
import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
|
|
18
21
|
import { dockGrow } from '../palette/dockMagnify';
|
|
19
22
|
|
|
@@ -61,9 +64,7 @@
|
|
|
61
64
|
if (Object.keys(patch).length) setBaseColors(patch, 'colors: tint neutrals from anchor');
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
transaction('derive accessible text', applySolvedTextCurves);
|
|
66
|
-
}
|
|
67
|
+
let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
|
|
67
68
|
|
|
68
69
|
let swatches = $derived.by(() => {
|
|
69
70
|
// A family bound to an inactive axis edits in dot mode, so it is not "on the wheel".
|
|
@@ -74,17 +75,16 @@
|
|
|
74
75
|
label: spec.label,
|
|
75
76
|
display: spec.displayLabel ?? spec.label,
|
|
76
77
|
hex: oklchToHexClamped(l, c, h),
|
|
77
|
-
l,
|
|
78
78
|
onWheel: $editorState.harmonyAxes.some((a, i) => a.family === spec.label && active[i]),
|
|
79
79
|
};
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
let coreSwatches = $derived(
|
|
87
|
-
let functionalSwatches = $derived(
|
|
84
|
+
// Declaration order, never a computed one: sorting by luminance made the row
|
|
85
|
+
// reshuffle under the pointer while a lightness edit was in flight.
|
|
86
|
+
let coreSwatches = $derived(swatches.filter((sw) => !FUNCTIONAL_FAMILIES.has(sw.label)));
|
|
87
|
+
let functionalSwatches = $derived(swatches.filter((sw) => FUNCTIONAL_FAMILIES.has(sw.label)));
|
|
88
88
|
|
|
89
89
|
let shownModeLabel = $derived(
|
|
90
90
|
HARMONY_MODE_BUTTONS.find((b) => b.mode === (hoverMode ?? activeMode))?.label ?? '',
|
|
@@ -135,7 +135,6 @@
|
|
|
135
135
|
|
|
136
136
|
<div class="harmony-actions">
|
|
137
137
|
<UIPillButton
|
|
138
|
-
size="compact"
|
|
139
138
|
icon="fa-fill-drip"
|
|
140
139
|
title="Re-hue Neutral and Alternate to the anchor color (their own chroma and lightness kept)"
|
|
141
140
|
onclick={tintNeutrals}
|
|
@@ -183,7 +182,6 @@
|
|
|
183
182
|
aria-label={`Select ${sw.display}`}
|
|
184
183
|
aria-pressed={selected === sw.label}
|
|
185
184
|
onclick={() => select(sw.label)}
|
|
186
|
-
animate:flip={{ duration: 200, easing: cubicOut }}
|
|
187
185
|
>
|
|
188
186
|
<span class="chip">
|
|
189
187
|
{#if sw.onWheel}<span class="wheel-dot" title="On the wheel" aria-hidden="true"></span>{/if}
|
|
@@ -225,24 +223,17 @@
|
|
|
225
223
|
|
|
226
224
|
<div class="pane">
|
|
227
225
|
<section id="colors-story" class="block">
|
|
228
|
-
<
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
<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)"
|
|
236
|
-
onclick={deriveAccessibleText}
|
|
237
|
-
>Derive accessible text</UIPillButton>
|
|
238
|
-
</div>
|
|
239
|
-
<ColorStory />
|
|
226
|
+
<header class="block-head">
|
|
227
|
+
<span class="eyebrow">Proportional preview</span>
|
|
228
|
+
<h2 class="title">Color Story</h2>
|
|
229
|
+
</header>
|
|
230
|
+
<ColorStory full={fullPalettes} />
|
|
240
231
|
</section>
|
|
241
232
|
|
|
242
233
|
<section id="colors-axes" class="block">
|
|
243
234
|
<h2 class="title">Harmony axes</h2>
|
|
244
235
|
<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} />
|
|
236
|
+
<HarmonyAxesList {activeMode} {selected} onSelect={select} />
|
|
246
237
|
</section>
|
|
247
238
|
</div>
|
|
248
239
|
</div>
|
|
@@ -255,6 +246,17 @@
|
|
|
255
246
|
align-items: start;
|
|
256
247
|
}
|
|
257
248
|
|
|
249
|
+
/* Wide standalone layouts (Colors page, full-screen editor): pin the story
|
|
250
|
+
pane to the width it was designed at in the docked panel — wider stretches
|
|
251
|
+
the seed-color bars into heavy slabs — and let the wheel column absorb the
|
|
252
|
+
rest. The docked panel's iframe viewport stays below this breakpoint. */
|
|
253
|
+
@media (min-width: 1200px) {
|
|
254
|
+
.colors-tab {
|
|
255
|
+
grid-template-columns: minmax(0, 1fr) 30rem;
|
|
256
|
+
gap: var(--ui-space-48);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
258
260
|
.pane {
|
|
259
261
|
display: flex;
|
|
260
262
|
flex-direction: column;
|
|
@@ -264,7 +266,7 @@
|
|
|
264
266
|
|
|
265
267
|
.axes-desc {
|
|
266
268
|
margin: 0;
|
|
267
|
-
font-size: var(--ui-font-size-
|
|
269
|
+
font-size: var(--ui-font-size-md);
|
|
268
270
|
line-height: 1.5;
|
|
269
271
|
color: var(--ui-text-secondary);
|
|
270
272
|
}
|
|
@@ -424,14 +426,6 @@
|
|
|
424
426
|
color: var(--ui-text-secondary);
|
|
425
427
|
}
|
|
426
428
|
|
|
427
|
-
.head-row {
|
|
428
|
-
display: flex;
|
|
429
|
-
align-items: flex-end;
|
|
430
|
-
justify-content: space-between;
|
|
431
|
-
gap: var(--ui-space-12);
|
|
432
|
-
flex-wrap: wrap;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
429
|
.harmony-actions {
|
|
436
430
|
display: flex;
|
|
437
431
|
gap: var(--ui-space-8);
|
|
@@ -445,8 +439,7 @@
|
|
|
445
439
|
}
|
|
446
440
|
|
|
447
441
|
/* Dock magnification, mirroring PaletteStepStrip: the flex-grow transition
|
|
448
|
-
animates the selected swatch opening
|
|
449
|
-
luminance-sorted spot as lightness is dialed. */
|
|
442
|
+
animates the selected swatch opening in place. */
|
|
450
443
|
.swatch-row {
|
|
451
444
|
display: flex;
|
|
452
445
|
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;
|