@motion-proto/live-tokens 0.91.2 → 0.93.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/.claude/skills/live-tokens-create-page/SKILL.md +10 -3
- package/.claude/skills/live-tokens-set-type/SKILL.md +3 -3
- package/CHANGELOG.md +171 -0
- package/bin/lib/tokenVocabulary.mjs +2 -2
- package/bin/rules/testRuns.mjs +1 -1
- package/bin/rules/tokens.mjs +3 -3
- package/dist-plugin/chunk-7XSUHJUH.js +1652 -0
- package/dist-plugin/index.cjs +950 -15
- package/dist-plugin/index.js +1 -1
- package/dist-plugin/setType/index.cjs +23 -2
- package/dist-plugin/setType/index.js +23 -2
- package/dist-plugin/tokensCssMigrations/index.cjs +954 -15
- package/dist-plugin/tokensCssMigrations/index.d.cts +23 -3
- package/dist-plugin/tokensCssMigrations/index.d.ts +23 -3
- package/dist-plugin/tokensCssMigrations/index.js +5 -1
- package/package.json +1 -1
- package/src/app/site.css +243 -9
- package/src/demo/TestingLoops.svelte +9 -9
- package/src/editor/core/fonts/weightCoverage.ts +31 -2
- package/src/editor/core/palettes/tokenRegistry.ts +36 -37
- package/src/editor/core/store/editorKeybindings.ts +1 -1
- package/src/editor/core/themes/parsers/mediaBlocks.ts +46 -0
- package/src/editor/core/typeScale/typeScale.ts +341 -0
- package/src/editor/skill-atlas/SourcePane.svelte +3 -3
- package/src/editor/skill-atlas/skillSources.generated.ts +2 -2
- package/src/editor/skill-atlas/trees/create-page.ts +14 -14
- package/src/editor/skill-atlas/trees/set-type.ts +2 -2
- package/src/editor/ui/UIFontFamilySelector.svelte +5 -1
- package/src/editor/ui/UIFontWeightSelector.svelte +4 -0
- package/src/editor/ui/UILetterSpacingSelector.svelte +4 -0
- package/src/editor/ui/UILineHeightSelector.svelte +4 -0
- package/src/editor/ui/UITextTransformSelector.svelte +4 -0
- package/src/editor/ui/UIVariantSelector.svelte +6 -8
- package/src/editor/ui/VariablesTab.svelte +4 -4
- package/src/editor/ui/colors/ColorsTab.svelte +99 -5
- package/src/editor/ui/colors/paletteBaseColor.ts +17 -0
- package/src/editor/ui/sections/textStyles.ts +44 -92
- package/src/editor/ui/text-styles/ControlBody.svelte +100 -0
- package/src/editor/ui/text-styles/ResetIcon.svelte +60 -0
- package/src/editor/ui/text-styles/StepRow.svelte +330 -0
- package/src/editor/ui/text-styles/StyleControl.svelte +128 -0
- package/src/editor/ui/text-styles/TextStylesSection.svelte +551 -0
- package/src/editor/ui/text-styles/textStyleEdits.ts +308 -0
- package/src/editor/ui/variantScales.ts +16 -0
- package/src/system/styles/tokens.css +412 -48
- package/src/testing-js/page-compliance.contract.js +233 -80
- package/src/testing-js/page-compliance.contract.js.map +1 -1
- package/dist-plugin/chunk-GWMEYBZ2.js +0 -715
- package/src/editor/ui/TextStylesSection.svelte +0 -171
|
@@ -2,7 +2,7 @@ import type { SkillTree } from '../types';
|
|
|
2
2
|
|
|
3
3
|
export const createPage: SkillTree = {
|
|
4
4
|
"id": "live-tokens-create-page",
|
|
5
|
-
"digest": "sha256:
|
|
5
|
+
"digest": "sha256:dac3be326461ce44",
|
|
6
6
|
"title": "create-page",
|
|
7
7
|
"tagline": "Create a Page Using Live Tokens",
|
|
8
8
|
"nodes": [
|
|
@@ -164,19 +164,19 @@ export const createPage: SkillTree = {
|
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
166
|
"label": "Type",
|
|
167
|
-
"lines": [105,
|
|
167
|
+
"lines": [105, 127],
|
|
168
168
|
"anchor": "### Type",
|
|
169
169
|
"anchorEnd": "Use the semantic element for each place: one `h1`, an `h2` f"
|
|
170
170
|
},
|
|
171
171
|
{
|
|
172
172
|
"label": "Size and emphasis",
|
|
173
|
-
"lines": [
|
|
173
|
+
"lines": [129, 137],
|
|
174
174
|
"anchor": "### Size",
|
|
175
175
|
"anchorEnd": "In a row of actions the primary sits last, on the right. Up "
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
"label": "Spacing",
|
|
179
|
-
"lines": [
|
|
179
|
+
"lines": [139, 153],
|
|
180
180
|
"anchor": "### Spacing",
|
|
181
181
|
"anchorEnd": "Every section after the first opens with a hairline: `paddin"
|
|
182
182
|
}
|
|
@@ -193,12 +193,12 @@ export const createPage: SkillTree = {
|
|
|
193
193
|
"chips": [
|
|
194
194
|
{
|
|
195
195
|
"label": "LiveTokensRouter",
|
|
196
|
-
"lines": [
|
|
196
|
+
"lines": [159, 159],
|
|
197
197
|
"anchor": "`<LiveTokensRouter pages={...}>`: add a `pages` entry with `"
|
|
198
198
|
},
|
|
199
199
|
{
|
|
200
200
|
"label": "LiveEditorOverlay",
|
|
201
|
-
"lines": [
|
|
201
|
+
"lines": [160, 160],
|
|
202
202
|
"anchor": "Manual `<LiveEditorOverlay>`: dispatch with `$derived.by(() "
|
|
203
203
|
}
|
|
204
204
|
]
|
|
@@ -209,7 +209,7 @@ export const createPage: SkillTree = {
|
|
|
209
209
|
"kind": "cli",
|
|
210
210
|
"title": "Run check-page",
|
|
211
211
|
"desc": "check-page --tests --strict --json applies every auto repair, proves the rendered page at its own route, and returns the fixes beside the remaining findings.",
|
|
212
|
-
"lines": [
|
|
212
|
+
"lines": [177, 177],
|
|
213
213
|
"anchor": "Run `npx live-tokens check-page <file> --tests --strict --js"
|
|
214
214
|
},
|
|
215
215
|
{
|
|
@@ -218,7 +218,7 @@ export const createPage: SkillTree = {
|
|
|
218
218
|
"kind": "gate",
|
|
219
219
|
"title": "Repair from guidance",
|
|
220
220
|
"desc": "Each remaining finding carries its guidance. Make each repair, then run check-page again.",
|
|
221
|
-
"lines": [
|
|
221
|
+
"lines": [179, 179],
|
|
222
222
|
"anchor": "Each remaining finding carries a rule id, a line, and its `g"
|
|
223
223
|
},
|
|
224
224
|
{
|
|
@@ -227,31 +227,31 @@ export const createPage: SkillTree = {
|
|
|
227
227
|
"kind": "chipset",
|
|
228
228
|
"title": "Check the page in the browser",
|
|
229
229
|
"desc": "The checkers cannot see a layout. Open the page at its width and check each line.",
|
|
230
|
-
"lines": [
|
|
230
|
+
"lines": [181, 197],
|
|
231
231
|
"anchor": "The checkers cannot see a layout. Open the page at the width",
|
|
232
232
|
"anchorEnd": "`references/interaction-sources.md` names the sources for th",
|
|
233
233
|
"chips": [
|
|
234
234
|
{
|
|
235
235
|
"label": "Structure",
|
|
236
|
-
"lines": [
|
|
236
|
+
"lines": [183, 187],
|
|
237
237
|
"anchor": "The first section holds what the user came for.",
|
|
238
238
|
"anchorEnd": "The containers in a section align at the bottom."
|
|
239
239
|
},
|
|
240
240
|
{
|
|
241
241
|
"label": "Actions",
|
|
242
|
-
"lines": [
|
|
242
|
+
"lines": [188, 191],
|
|
243
243
|
"anchor": "The actions sit where the eye goes last, with the one primar",
|
|
244
244
|
"anchorEnd": "An action that runs longer than a moment shows progress in a"
|
|
245
245
|
},
|
|
246
246
|
{
|
|
247
247
|
"label": "Fields",
|
|
248
|
-
"lines": [
|
|
248
|
+
"lines": [192, 193],
|
|
249
249
|
"anchor": "Every field has a default, and Reset restores it.",
|
|
250
250
|
"anchorEnd": "Secondary settings sit in a `CollapsibleSection`. Every cont"
|
|
251
251
|
},
|
|
252
252
|
{
|
|
253
253
|
"label": "Words and access",
|
|
254
|
-
"lines": [
|
|
254
|
+
"lines": [194, 195],
|
|
255
255
|
"anchor": "Labels use the user's words, such as \"Export slices\".",
|
|
256
256
|
"anchorEnd": "Every `img` has `alt` text. Focus order follows the reading "
|
|
257
257
|
}
|
|
@@ -263,7 +263,7 @@ export const createPage: SkillTree = {
|
|
|
263
263
|
"kind": "step",
|
|
264
264
|
"title": "Read the page twice",
|
|
265
265
|
"desc": "From a distance only the sections show. Up close, every border and bar earns its place or goes.",
|
|
266
|
-
"lines": [
|
|
266
|
+
"lines": [199, 199],
|
|
267
267
|
"anchor": "Then read the page from a distance: the sections and their e"
|
|
268
268
|
},
|
|
269
269
|
{
|
|
@@ -2,7 +2,7 @@ import type { SkillTree } from '../types';
|
|
|
2
2
|
|
|
3
3
|
export const setType: SkillTree = {
|
|
4
4
|
"id": "live-tokens-set-type",
|
|
5
|
-
"digest": "sha256:
|
|
5
|
+
"digest": "sha256:51d5983b67b548cd",
|
|
6
6
|
"title": "set-type",
|
|
7
7
|
"tagline": "Set Typefaces from Google Fonts",
|
|
8
8
|
"nodes": [
|
|
@@ -38,7 +38,7 @@ export const setType: SkillTree = {
|
|
|
38
38
|
"label": "Choose the body face first",
|
|
39
39
|
"lines": [30, 34],
|
|
40
40
|
"anchor": "## Choose the body face first",
|
|
41
|
-
"anchorEnd": "The
|
|
41
|
+
"anchorEnd": "The display and heading usages ask the display face for 600 "
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
"label": "The font matrix",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
disabled?: boolean;
|
|
16
16
|
selectionsLocked?: boolean;
|
|
17
17
|
onchange?: () => void;
|
|
18
|
+
/** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
|
|
19
|
+
onwrite?: (value: string | null) => void;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
let {
|
|
@@ -24,6 +26,7 @@
|
|
|
24
26
|
disabled = false,
|
|
25
27
|
selectionsLocked = false,
|
|
26
28
|
onchange,
|
|
29
|
+
onwrite,
|
|
27
30
|
}: Props = $props();
|
|
28
31
|
|
|
29
32
|
const SOURCE_KIND_LABELS: Record<FontSourceKind, string> = {
|
|
@@ -191,6 +194,7 @@
|
|
|
191
194
|
dropdownMinWidth="14rem"
|
|
192
195
|
onreset={handleReset}
|
|
193
196
|
onvarChange={initFromCurrent}
|
|
197
|
+
{onwrite}
|
|
194
198
|
>
|
|
195
199
|
{#snippet triggerTitle()}{activeLabel}{/snippet}
|
|
196
200
|
{#snippet triggerMeta()}{displayFamily || '—'}{/snippet}
|
|
@@ -198,7 +202,7 @@
|
|
|
198
202
|
{#snippet children({ close })}
|
|
199
203
|
|
|
200
204
|
<UIOptionList>
|
|
201
|
-
{#each options as opt}
|
|
205
|
+
{#each options as opt (opt.key)}
|
|
202
206
|
<UIOptionItem
|
|
203
207
|
active={chosenKey === opt.key}
|
|
204
208
|
onclick={() => selectOption(opt.key, close)}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
disabled?: boolean;
|
|
17
17
|
selectionsLocked?: boolean;
|
|
18
18
|
onchange?: () => void;
|
|
19
|
+
/** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
|
|
20
|
+
onwrite?: (value: string | null) => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
let {
|
|
@@ -25,6 +27,7 @@
|
|
|
25
27
|
disabled = false,
|
|
26
28
|
selectionsLocked = false,
|
|
27
29
|
onchange,
|
|
30
|
+
onwrite,
|
|
28
31
|
}: Props = $props();
|
|
29
32
|
|
|
30
33
|
const options = [
|
|
@@ -87,6 +90,7 @@
|
|
|
87
90
|
varPrefix="--font-weight-"
|
|
88
91
|
{options}
|
|
89
92
|
{onchange}
|
|
93
|
+
{onwrite}
|
|
90
94
|
>
|
|
91
95
|
{#snippet option({ opt, active, select })}
|
|
92
96
|
{@const unsupported = availability !== null && !availability.weights.has(Number(opt.value))}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
disabled?: boolean;
|
|
10
10
|
selectionsLocked?: boolean;
|
|
11
11
|
onchange?: () => void;
|
|
12
|
+
/** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
|
|
13
|
+
onwrite?: (value: string | null) => void;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
let {
|
|
@@ -18,6 +20,7 @@
|
|
|
18
20
|
disabled = false,
|
|
19
21
|
selectionsLocked = false,
|
|
20
22
|
onchange,
|
|
23
|
+
onwrite,
|
|
21
24
|
}: Props = $props();
|
|
22
25
|
|
|
23
26
|
const options = [
|
|
@@ -38,6 +41,7 @@
|
|
|
38
41
|
varPrefix="--letter-spacing-"
|
|
39
42
|
{options}
|
|
40
43
|
{onchange}
|
|
44
|
+
{onwrite}
|
|
41
45
|
>
|
|
42
46
|
{#snippet option({ opt, active, select })}
|
|
43
47
|
<UIOptionItem {active} onclick={select}>
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
disabled?: boolean;
|
|
10
10
|
selectionsLocked?: boolean;
|
|
11
11
|
onchange?: () => void;
|
|
12
|
+
/** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
|
|
13
|
+
onwrite?: (value: string | null) => void;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
let {
|
|
@@ -18,6 +20,7 @@
|
|
|
18
20
|
disabled = false,
|
|
19
21
|
selectionsLocked = false,
|
|
20
22
|
onchange,
|
|
23
|
+
onwrite,
|
|
21
24
|
}: Props = $props();
|
|
22
25
|
|
|
23
26
|
const options = [
|
|
@@ -39,6 +42,7 @@
|
|
|
39
42
|
varPrefix="--line-height-"
|
|
40
43
|
{options}
|
|
41
44
|
{onchange}
|
|
45
|
+
{onwrite}
|
|
42
46
|
>
|
|
43
47
|
{#snippet option({ opt, active, select })}
|
|
44
48
|
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
disabled?: boolean;
|
|
13
13
|
selectionsLocked?: boolean;
|
|
14
14
|
onchange?: () => void;
|
|
15
|
+
/** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
|
|
16
|
+
onwrite?: (value: string | null) => void;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
let {
|
|
@@ -21,6 +23,7 @@
|
|
|
21
23
|
disabled = false,
|
|
22
24
|
selectionsLocked = false,
|
|
23
25
|
onchange,
|
|
26
|
+
onwrite,
|
|
24
27
|
}: Props = $props();
|
|
25
28
|
|
|
26
29
|
// text-transform is a literal CSS keyword, not a scale alias — the picker
|
|
@@ -78,6 +81,7 @@
|
|
|
78
81
|
dropdownMinWidth="12rem"
|
|
79
82
|
onreset={handleReset}
|
|
80
83
|
onvarChange={readResolved}
|
|
84
|
+
{onwrite}
|
|
81
85
|
>
|
|
82
86
|
{#snippet triggerTitle()}{activeLabel}{/snippet}
|
|
83
87
|
{#snippet triggerMeta()}{current || '—'}{/snippet}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends { key: string; label?: string; value?: string }">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { resolveAliasChain } from '../core/palettes/tokenRegistry';
|
|
4
|
+
import { parseVariantRef } from './variantScales';
|
|
4
5
|
import UITokenSelector from './UITokenSelector.svelte';
|
|
5
6
|
import UIOptionList from './UIOptionList.svelte';
|
|
6
7
|
import UIOptionItem from './UIOptionItem.svelte';
|
|
@@ -28,6 +29,8 @@
|
|
|
28
29
|
/** Trailing dropdown content (e.g. action buttons). */
|
|
29
30
|
extras?: Snippet<[{ close: () => void }]>;
|
|
30
31
|
onchange?: () => void;
|
|
32
|
+
/** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
|
|
33
|
+
onwrite?: (value: string | null) => void;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
let {
|
|
@@ -46,6 +49,7 @@
|
|
|
46
49
|
option: callerOption,
|
|
47
50
|
extras: callerExtras,
|
|
48
51
|
onchange,
|
|
52
|
+
onwrite,
|
|
49
53
|
}: Props<T> = $props();
|
|
50
54
|
|
|
51
55
|
let selector: UITokenSelector;
|
|
@@ -53,16 +57,9 @@
|
|
|
53
57
|
let currentValue: string = $state('');
|
|
54
58
|
|
|
55
59
|
let validKeys = $derived(new Set(options.map((o) => o.key)));
|
|
56
|
-
let refMatcher = $derived.by(() => {
|
|
57
|
-
const escaped = varPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
58
|
-
return new RegExp(`var\\((${escaped}[^)\\s]+)\\)`);
|
|
59
|
-
});
|
|
60
60
|
|
|
61
61
|
function parseRef(value: string): string | null {
|
|
62
|
-
|
|
63
|
-
if (!m) return null;
|
|
64
|
-
const key = m[1].slice(varPrefix.length);
|
|
65
|
-
return validKeys.has(key) ? key : null;
|
|
62
|
+
return parseVariantRef(value, varPrefix, validKeys);
|
|
66
63
|
}
|
|
67
64
|
|
|
68
65
|
function readResolved() {
|
|
@@ -137,6 +134,7 @@
|
|
|
137
134
|
{dropdownMaxWidth}
|
|
138
135
|
onreset={handleReset}
|
|
139
136
|
onvarChange={initFromCurrent}
|
|
137
|
+
{onwrite}
|
|
140
138
|
>
|
|
141
139
|
{#snippet triggerTitle()}
|
|
142
140
|
{#if callerTriggerTitle}{@render callerTriggerTitle({ activeOption })}{:else}{activeOption?.label ?? ''}{/if}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { PALETTE_SPECS } from '../core/palettes/paletteDerivation';
|
|
5
5
|
import FontStackEditor from './FontStackEditor.svelte';
|
|
6
6
|
import ProjectFontsSection from './ProjectFontsSection.svelte';
|
|
7
|
-
import TextStylesSection from './TextStylesSection.svelte';
|
|
7
|
+
import TextStylesSection from './text-styles/TextStylesSection.svelte';
|
|
8
8
|
import ColumnsSection from './sections/ColumnsSection.svelte';
|
|
9
9
|
import TokenScaleTable from './sections/TokenScaleTable.svelte';
|
|
10
10
|
import WashesSection from './sections/WashesSection.svelte';
|
|
@@ -100,7 +100,6 @@
|
|
|
100
100
|
</div>
|
|
101
101
|
|
|
102
102
|
<div class="typography-group text-styles-group">
|
|
103
|
-
<h3 class="group-title">Text Styles</h3>
|
|
104
103
|
<TextStylesSection />
|
|
105
104
|
</div>
|
|
106
105
|
|
|
@@ -254,10 +253,11 @@
|
|
|
254
253
|
gap: var(--ui-space-32);
|
|
255
254
|
}
|
|
256
255
|
|
|
257
|
-
/*
|
|
258
|
-
|
|
256
|
+
/* Each step row holds three size inputs, a sample and four pickers, so the
|
|
257
|
+
group takes the full row. */
|
|
259
258
|
.text-styles-group {
|
|
260
259
|
grid-column: 1 / -1;
|
|
260
|
+
gap: var(--ui-space-16);
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
/* Utility Tokens */
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cubicOut } from 'svelte/easing';
|
|
3
|
-
import { oklchToHexClamped } from '../../core/palettes/oklch';
|
|
3
|
+
import { cssColorToOklch, oklchToCss, oklchToHexClamped } from '../../core/palettes/oklch';
|
|
4
4
|
import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
|
|
5
5
|
import { editorState, beginSliderGesture } from '../../core/store/editorStore';
|
|
6
|
+
import { isEditable } from '../../core/store/editorKeybindings';
|
|
6
7
|
import { selectedPalette } from '../../core/store/paletteFocus';
|
|
7
8
|
import { applyHarmonyToAxes, axisStatuses, HARMONY_ELIGIBLE, type HarmonyMode } from '../../core/palettes/colorHarmony';
|
|
8
9
|
import AxisNumeral from './AxisNumeral.svelte';
|
|
@@ -13,10 +14,12 @@
|
|
|
13
14
|
import PaletteStepStrip from './PaletteStepStrip.svelte';
|
|
14
15
|
import HarmonyAxesList from './HarmonyAxesList.svelte';
|
|
15
16
|
import PaletteJumpButton from '../palette/PaletteJumpButton.svelte';
|
|
17
|
+
import { showCopyPopover } from '../copyPopover';
|
|
16
18
|
import {
|
|
17
19
|
setBaseColor,
|
|
18
20
|
setAxisHues,
|
|
19
21
|
unbindFamily,
|
|
22
|
+
swapBaseColors,
|
|
20
23
|
palettesWithDefaults,
|
|
21
24
|
} from './paletteBaseColor';
|
|
22
25
|
import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
|
|
@@ -108,23 +111,99 @@
|
|
|
108
111
|
if (family) unbindFamily(family);
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
// A swatch dropped on another swatch swaps their colors; a chip dropped there
|
|
115
|
+
// still unassigns. The swap source is held here because dataTransfer data is
|
|
116
|
+
// unreadable during dragover, and both ends of a swap live in this row.
|
|
117
|
+
let swapSource = $state<string | null>(null);
|
|
118
|
+
let swapTarget = $state<string | null>(null);
|
|
119
|
+
|
|
120
|
+
function onSwatchDragStart(e: DragEvent, family: string) {
|
|
121
|
+
startFamilyDrag(e, family);
|
|
122
|
+
swapSource = family;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function onSwatchDragEnd() {
|
|
126
|
+
swapSource = null;
|
|
127
|
+
swapTarget = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function acceptsSwap(sw: Swatch): boolean {
|
|
131
|
+
return sw.eligible && swapSource !== null && swapSource !== sw.label;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onSwatchDragOver(e: DragEvent, sw: Swatch) {
|
|
135
|
+
if (!acceptsSwap(sw)) return;
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
|
139
|
+
swapTarget = sw.label;
|
|
140
|
+
unassignHover = false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function onSwatchDragLeave(sw: Swatch) {
|
|
144
|
+
if (swapTarget === sw.label) swapTarget = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function onSwatchDrop(e: DragEvent, sw: Swatch) {
|
|
148
|
+
if (!acceptsSwap(sw)) return;
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
e.stopPropagation();
|
|
151
|
+
swapBaseColors(swapSource!, sw.label);
|
|
152
|
+
onSwatchDragEnd();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const swatchEls: Record<string, HTMLButtonElement> = {};
|
|
156
|
+
|
|
157
|
+
// Copy and paste act on the selected swatch, never the focused one: Safari
|
|
158
|
+
// and macOS Firefox do not focus a button on click.
|
|
159
|
+
function onWindowKeydown(e: KeyboardEvent) {
|
|
160
|
+
if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return;
|
|
161
|
+
if (e.target instanceof HTMLElement && isEditable(e.target)) return;
|
|
162
|
+
const sw = swatches.find((s) => s.label === selected);
|
|
163
|
+
if (!sw) return;
|
|
164
|
+
const key = e.key.toLowerCase();
|
|
165
|
+
if (key === 'c') {
|
|
166
|
+
// Selected page text keeps the browser's own copy.
|
|
167
|
+
if (window.getSelection()?.isCollapsed === false) return;
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
const css = oklchToCss(sw.color.l, sw.color.c, sw.color.h);
|
|
170
|
+
navigator.clipboard?.writeText(css);
|
|
171
|
+
showCopyPopover(css, swatchEls[sw.label]);
|
|
172
|
+
} else if (key === 'v') {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
pasteInto(sw, swatchEls[sw.label]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function pasteInto(sw: Swatch, anchor: EventTarget | null) {
|
|
179
|
+
const color = cssColorToOklch(await navigator.clipboard.readText());
|
|
180
|
+
if (!color) return;
|
|
181
|
+
// A bound color drags its axis along, so an applied harmony no longer holds.
|
|
182
|
+
if (sw.axis && color.h !== sw.color.h) activeMode = 'custom';
|
|
183
|
+
setBaseColor(sw.label, color);
|
|
184
|
+
showCopyPopover(oklchToCss(color.l, color.c, color.h), anchor);
|
|
185
|
+
}
|
|
186
|
+
|
|
111
187
|
let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
|
|
112
188
|
|
|
113
189
|
let swatches = $derived.by(() => {
|
|
114
190
|
const statuses = axisStatuses(activeMode, $editorState.harmonyAxes);
|
|
115
191
|
return PALETTE_SPECS.map((spec) => {
|
|
116
|
-
const
|
|
192
|
+
const color = $editorState.palettes[spec.label]?.baseColor ?? spec.initialColor;
|
|
117
193
|
const index = $editorState.harmonyAxes.findIndex((a) => a.family === spec.label);
|
|
118
194
|
return {
|
|
119
195
|
label: spec.label,
|
|
120
196
|
display: spec.displayLabel ?? spec.label,
|
|
121
|
-
|
|
197
|
+
color,
|
|
198
|
+
hex: oklchToHexClamped(color.l, color.c, color.h),
|
|
122
199
|
axis: index === -1 ? null : { index, status: statuses[index] },
|
|
123
200
|
eligible: ELIGIBLE.has(spec.label),
|
|
124
201
|
};
|
|
125
202
|
});
|
|
126
203
|
});
|
|
127
204
|
|
|
205
|
+
type Swatch = (typeof swatches)[number];
|
|
206
|
+
|
|
128
207
|
const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
|
|
129
208
|
// Declaration order, never a computed one: sorting by luminance made the row
|
|
130
209
|
// reshuffle under the pointer while a lightness edit was in flight.
|
|
@@ -150,6 +229,8 @@
|
|
|
150
229
|
}
|
|
151
230
|
</script>
|
|
152
231
|
|
|
232
|
+
<svelte:window onkeydown={onWindowKeydown} />
|
|
233
|
+
|
|
153
234
|
<div class="colors-tab">
|
|
154
235
|
<div class="pane">
|
|
155
236
|
<section id="colors-wheel" class="block">
|
|
@@ -227,7 +308,7 @@
|
|
|
227
308
|
<div class="harmony-cols">
|
|
228
309
|
<div class="group">
|
|
229
310
|
<h3 class="title">Selected color</h3>
|
|
230
|
-
{#snippet swatchRow(row:
|
|
311
|
+
{#snippet swatchRow(row: Swatch[], acceptsDrop = false)}
|
|
231
312
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
232
313
|
<div
|
|
233
314
|
class="swatch-row"
|
|
@@ -242,12 +323,18 @@
|
|
|
242
323
|
class="swatch"
|
|
243
324
|
class:active={selected === sw.label}
|
|
244
325
|
class:draggable={sw.eligible}
|
|
326
|
+
class:swap-target={swapTarget === sw.label}
|
|
245
327
|
style="--swatch-fill: {sw.hex}"
|
|
246
328
|
draggable={sw.eligible}
|
|
247
329
|
title={sw.eligible ? `${sw.display} · drag onto an axis to assign it` : sw.display}
|
|
248
330
|
aria-label={`Select ${sw.display}`}
|
|
249
331
|
aria-pressed={selected === sw.label}
|
|
250
|
-
|
|
332
|
+
bind:this={swatchEls[sw.label]}
|
|
333
|
+
ondragstart={(e) => onSwatchDragStart(e, sw.label)}
|
|
334
|
+
ondragend={onSwatchDragEnd}
|
|
335
|
+
ondragover={(e) => onSwatchDragOver(e, sw)}
|
|
336
|
+
ondragleave={() => onSwatchDragLeave(sw)}
|
|
337
|
+
ondrop={(e) => onSwatchDrop(e, sw)}
|
|
251
338
|
onclick={() => select(sw.label)}
|
|
252
339
|
>
|
|
253
340
|
<span class="frame">
|
|
@@ -685,6 +772,13 @@
|
|
|
685
772
|
border-color: var(--ui-border-high);
|
|
686
773
|
}
|
|
687
774
|
|
|
775
|
+
/* The axis row's drop ring, doubled so it never reads as selection. */
|
|
776
|
+
.swatch.swap-target .frame {
|
|
777
|
+
border-color: var(--ui-text-primary);
|
|
778
|
+
outline: 1px solid var(--ui-text-primary);
|
|
779
|
+
outline-offset: 1px;
|
|
780
|
+
}
|
|
781
|
+
|
|
688
782
|
.swatch-label {
|
|
689
783
|
font-size: var(--ui-font-size-xs);
|
|
690
784
|
color: var(--ui-text-secondary);
|
|
@@ -208,6 +208,23 @@ function giveAxisHue(s: EditorState, index: number, family: string): boolean {
|
|
|
208
208
|
return false;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/** Two families trade seed colors. Each axis binding travels with the color it
|
|
212
|
+
* held, so every axis keeps its hue and an applied harmony stays true. */
|
|
213
|
+
export function swapBaseColors(a: string, b: string): void {
|
|
214
|
+
if (a === b) return;
|
|
215
|
+
mutate(`colors: swap ${a} and ${b}`, (s) => {
|
|
216
|
+
const ca = ensureConfig(s, a);
|
|
217
|
+
const cb = ensureConfig(s, b);
|
|
218
|
+
[ca.baseColor, cb.baseColor] = [cb.baseColor, ca.baseColor];
|
|
219
|
+
syncBaseAnchor(ca);
|
|
220
|
+
syncBaseAnchor(cb);
|
|
221
|
+
for (const axis of s.harmonyAxes) {
|
|
222
|
+
if (axis.family === a) axis.family = b;
|
|
223
|
+
else if (axis.family === b) axis.family = a;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
/** Set several seed colors in ONE undo entry (harmony apply, global rotate). */
|
|
212
229
|
export function setBaseColors(patch: Record<string, Oklch>, historyLabel = 'colors: harmony'): void {
|
|
213
230
|
transaction(historyLabel, (s) => {
|