@motion-proto/live-tokens 0.45.0 → 0.46.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 +67 -0
- package/dist-plugin/index.cjs +1 -1
- package/dist-plugin/index.js +1 -1
- package/package.json +1 -1
- package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +0 -1
- package/src/editor/component-editor/scaffolding/ComponentFileMenu.svelte +1 -1
- package/src/editor/core/palettes/colorHarmony.ts +35 -6
- package/src/editor/core/palettes/paletteDerivation.ts +1 -1
- package/src/editor/core/store/editorTypes.ts +2 -2
- package/src/editor/docs/CodeBlock.svelte +0 -2
- package/src/editor/docs/Docs.svelte +0 -3
- package/src/editor/pages/ComponentEditorPage.svelte +3 -5
- package/src/editor/pages/EditorShell.svelte +3 -5
- package/src/editor/ui/ColorEditPanel.svelte +19 -22
- package/src/editor/ui/EditorViewSwitcher.svelte +2 -3
- package/src/editor/ui/FileLoadList.svelte +7 -9
- package/src/editor/ui/ManifestFileManager.svelte +1 -3
- package/src/editor/ui/ProjectFontsSection.svelte +0 -4
- package/src/editor/ui/SurfacesTab.svelte +3 -3
- package/src/editor/ui/TextStylesSection.svelte +6 -8
- package/src/editor/ui/ThemeFileManager.svelte +1 -3
- package/src/editor/ui/UIEasingSelector.svelte +0 -2
- package/src/editor/ui/UIFontFamilySelector.svelte +9 -3
- package/src/editor/ui/UIMenuButton.svelte +230 -0
- package/src/editor/ui/UIPaletteSelector.svelte +0 -1
- package/src/editor/ui/VariablesTab.svelte +3 -4
- package/src/editor/ui/colors/AxisNumeral.svelte +70 -0
- package/src/editor/ui/colors/ColorReadouts.svelte +0 -2
- package/src/editor/ui/colors/ColorWheel.svelte +48 -29
- package/src/editor/ui/colors/ColorsTab.svelte +361 -172
- package/src/editor/ui/colors/HarmonyAxesList.svelte +264 -141
- package/src/editor/ui/colors/LightnessBar.svelte +0 -2
- package/src/editor/ui/colors/harmonyDrag.ts +10 -0
- package/src/editor/ui/colors/harmonyModeIcons.ts +5 -0
- package/src/editor/ui/colors/paletteBaseColor.ts +25 -17
- package/src/live-tokens/data/tokens.generated.css +28 -28
- package/src/system/components/ImageLightbox.svelte +0 -2
- package/src/system/components/SectionDivider.svelte +4 -4
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Heading line inside the menu; also its accessible name. */
|
|
7
|
+
header: string;
|
|
8
|
+
/** Accessible name for an icon-only trigger; a text trigger names itself. */
|
|
9
|
+
triggerLabel?: string;
|
|
10
|
+
/** Lets the caller style the trigger as its own furniture via `:global`. */
|
|
11
|
+
triggerClass?: string;
|
|
12
|
+
menuMinWidth?: string;
|
|
13
|
+
trigger: Snippet;
|
|
14
|
+
children: Snippet<[{ close: () => void }]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
header,
|
|
19
|
+
triggerLabel,
|
|
20
|
+
triggerClass = '',
|
|
21
|
+
menuMinWidth = '14rem',
|
|
22
|
+
trigger,
|
|
23
|
+
children,
|
|
24
|
+
}: Props = $props();
|
|
25
|
+
|
|
26
|
+
let open = $state(false);
|
|
27
|
+
let btnEl = $state<HTMLButtonElement | undefined>(undefined);
|
|
28
|
+
let menuEl = $state<HTMLDivElement | undefined>(undefined);
|
|
29
|
+
let initialItem = $state<'first' | 'last'>('first');
|
|
30
|
+
|
|
31
|
+
function items(): HTMLButtonElement[] {
|
|
32
|
+
return menuEl ? Array.from(menuEl.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')) : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function close(refocusTrigger = true) {
|
|
36
|
+
if (!open) return;
|
|
37
|
+
open = false;
|
|
38
|
+
if (refocusTrigger) btnEl?.focus();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toggle() {
|
|
42
|
+
if (open) {
|
|
43
|
+
close();
|
|
44
|
+
} else {
|
|
45
|
+
initialItem = 'first';
|
|
46
|
+
open = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function onTriggerKeydown(e: KeyboardEvent) {
|
|
51
|
+
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
initialItem = e.key === 'ArrowUp' ? 'last' : 'first';
|
|
54
|
+
open = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Enter and Space activate the focused item natively: items are real buttons.
|
|
58
|
+
function onMenuKeydown(e: KeyboardEvent) {
|
|
59
|
+
if (e.key === 'Escape' || e.key === 'Tab') {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
close();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const list = items();
|
|
65
|
+
if (list.length === 0) return;
|
|
66
|
+
const idx = list.indexOf(document.activeElement as HTMLButtonElement);
|
|
67
|
+
let next = -1;
|
|
68
|
+
if (e.key === 'ArrowDown') next = idx === -1 ? 0 : (idx + 1) % list.length;
|
|
69
|
+
else if (e.key === 'ArrowUp') next = idx === -1 ? list.length - 1 : (idx - 1 + list.length) % list.length;
|
|
70
|
+
else if (e.key === 'Home') next = 0;
|
|
71
|
+
else if (e.key === 'End') next = list.length - 1;
|
|
72
|
+
if (next === -1) return;
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
list[next].focus();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onWindowKeydown(e: KeyboardEvent) {
|
|
78
|
+
if (e.key === 'Escape' && open) close();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onDocumentMousedown(e: MouseEvent) {
|
|
82
|
+
if (!open) return;
|
|
83
|
+
const target = e.target as Node | null;
|
|
84
|
+
if (target && (btnEl?.contains(target) || menuEl?.contains(target))) return;
|
|
85
|
+
// The user is directing attention elsewhere; refocusing the trigger would
|
|
86
|
+
// steal focus from whatever they clicked.
|
|
87
|
+
close(false);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onMount(() => {
|
|
91
|
+
window.addEventListener('keydown', onWindowKeydown);
|
|
92
|
+
document.addEventListener('mousedown', onDocumentMousedown, true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
onDestroy(() => {
|
|
96
|
+
window.removeEventListener('keydown', onWindowKeydown);
|
|
97
|
+
document.removeEventListener('mousedown', onDocumentMousedown, true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/* Fixed positioning escapes any parent overflow/stacking context. Anchored
|
|
101
|
+
below the trigger, right edges aligned; flips above near the viewport
|
|
102
|
+
bottom. Inline visibility rather than a state flag so the item focus that
|
|
103
|
+
follows lands on an already-visible menu (hidden elements refuse focus). */
|
|
104
|
+
function position(): void {
|
|
105
|
+
const btn = btnEl;
|
|
106
|
+
const menu = menuEl;
|
|
107
|
+
if (!btn || !menu) return;
|
|
108
|
+
const br = btn.getBoundingClientRect();
|
|
109
|
+
const mr = menu.getBoundingClientRect();
|
|
110
|
+
const margin = 8;
|
|
111
|
+
const vw = window.innerWidth;
|
|
112
|
+
const vh = window.innerHeight;
|
|
113
|
+
let left = br.right - mr.width;
|
|
114
|
+
if (left < margin) left = margin;
|
|
115
|
+
if (left + mr.width > vw - margin) left = vw - margin - mr.width;
|
|
116
|
+
let top = br.bottom + margin / 2;
|
|
117
|
+
if (top + mr.height > vh - margin) {
|
|
118
|
+
top = br.top - margin / 2 - mr.height;
|
|
119
|
+
if (top < margin) top = margin;
|
|
120
|
+
}
|
|
121
|
+
menu.style.left = `${left}px`;
|
|
122
|
+
menu.style.top = `${top}px`;
|
|
123
|
+
menu.style.visibility = 'visible';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
$effect(() => {
|
|
127
|
+
if (!open) return;
|
|
128
|
+
let raf = requestAnimationFrame(() => {
|
|
129
|
+
raf = requestAnimationFrame(() => {
|
|
130
|
+
// The composed option primitives expose no role prop; stamping
|
|
131
|
+
// menuitem + tabindex here is what makes the popup a real menu with
|
|
132
|
+
// roving focus instead of a cluster of tab stops.
|
|
133
|
+
for (const b of items()) {
|
|
134
|
+
b.setAttribute('role', 'menuitem');
|
|
135
|
+
b.tabIndex = -1;
|
|
136
|
+
}
|
|
137
|
+
position();
|
|
138
|
+
const list = items();
|
|
139
|
+
(initialItem === 'last' ? list[list.length - 1] : list[0])?.focus();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
window.addEventListener('scroll', position, true);
|
|
143
|
+
window.addEventListener('resize', position);
|
|
144
|
+
return () => {
|
|
145
|
+
cancelAnimationFrame(raf);
|
|
146
|
+
window.removeEventListener('scroll', position, true);
|
|
147
|
+
window.removeEventListener('resize', position);
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
class="menu-trigger {triggerClass}"
|
|
155
|
+
aria-haspopup="menu"
|
|
156
|
+
aria-expanded={open}
|
|
157
|
+
aria-label={triggerLabel}
|
|
158
|
+
bind:this={btnEl}
|
|
159
|
+
onclick={toggle}
|
|
160
|
+
onkeydown={onTriggerKeydown}
|
|
161
|
+
>
|
|
162
|
+
{@render trigger()}
|
|
163
|
+
</button>
|
|
164
|
+
|
|
165
|
+
{#if open}
|
|
166
|
+
<div
|
|
167
|
+
class="menu"
|
|
168
|
+
role="menu"
|
|
169
|
+
tabindex="-1"
|
|
170
|
+
aria-label={header}
|
|
171
|
+
style="min-width: {menuMinWidth};"
|
|
172
|
+
bind:this={menuEl}
|
|
173
|
+
onkeydown={onMenuKeydown}
|
|
174
|
+
>
|
|
175
|
+
<div class="menu-header" role="presentation">{header}</div>
|
|
176
|
+
{@render children({ close })}
|
|
177
|
+
</div>
|
|
178
|
+
{/if}
|
|
179
|
+
|
|
180
|
+
<style>
|
|
181
|
+
.menu-trigger {
|
|
182
|
+
display: inline-flex;
|
|
183
|
+
align-items: center;
|
|
184
|
+
justify-content: center;
|
|
185
|
+
gap: var(--ui-space-4);
|
|
186
|
+
padding: 0;
|
|
187
|
+
background: transparent;
|
|
188
|
+
border: 0;
|
|
189
|
+
color: inherit;
|
|
190
|
+
font: inherit;
|
|
191
|
+
line-height: 1;
|
|
192
|
+
cursor: pointer;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.menu-trigger:focus-visible {
|
|
196
|
+
outline: 2px solid var(--ui-border-higher);
|
|
197
|
+
outline-offset: 2px;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.menu {
|
|
201
|
+
position: fixed;
|
|
202
|
+
top: 0;
|
|
203
|
+
left: 0;
|
|
204
|
+
max-width: calc(100vw - var(--ui-space-24));
|
|
205
|
+
background: var(--ui-surface-higher);
|
|
206
|
+
border: 1px solid var(--ui-border-high);
|
|
207
|
+
border-radius: var(--ui-radius-lg);
|
|
208
|
+
box-shadow: var(--ui-shadow-lg);
|
|
209
|
+
z-index: 1000;
|
|
210
|
+
overflow: hidden;
|
|
211
|
+
visibility: hidden;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.menu-header {
|
|
215
|
+
padding: var(--ui-space-8) var(--ui-space-12);
|
|
216
|
+
border-bottom: 1px solid var(--ui-border-low);
|
|
217
|
+
color: var(--ui-text-primary);
|
|
218
|
+
font-size: var(--ui-font-size-xs);
|
|
219
|
+
font-weight: var(--ui-font-weight-semibold);
|
|
220
|
+
line-height: 1.2;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* Roving focus is the menu's selection cursor; the inset ring keeps it
|
|
224
|
+
inside the clipped rounded corners. */
|
|
225
|
+
.menu :global(button:focus-visible) {
|
|
226
|
+
background: var(--ui-hover);
|
|
227
|
+
outline: 2px solid var(--ui-border-higher);
|
|
228
|
+
outline-offset: -2px;
|
|
229
|
+
}
|
|
230
|
+
</style>
|
|
@@ -204,10 +204,9 @@
|
|
|
204
204
|
/* Subsection title (used by Spacing & Borders) */
|
|
205
205
|
.subsection-title {
|
|
206
206
|
margin: var(--ui-space-16) 0 var(--ui-space-8);
|
|
207
|
-
font-size: var(--ui-font-size-
|
|
208
|
-
font-weight: var(--ui-font-weight-
|
|
209
|
-
color: var(--ui-text-
|
|
210
|
-
text-transform: uppercase;
|
|
207
|
+
font-size: var(--ui-font-size-xl);
|
|
208
|
+
font-weight: var(--ui-font-weight-bold);
|
|
209
|
+
color: var(--ui-text-primary);
|
|
211
210
|
}
|
|
212
211
|
|
|
213
212
|
.subsection-title:first-child {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { AxisStatus } from '../../core/palettes/colorHarmony';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
index: number;
|
|
6
|
+
status: AxisStatus;
|
|
7
|
+
/** The axis carries the family selected across the Colors surfaces. */
|
|
8
|
+
selected?: boolean;
|
|
9
|
+
/** Rendered over the swatch badge's scrim rather than an editor surface. */
|
|
10
|
+
scrim?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { index, status, selected = false, scrim = false }: Props = $props();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<!-- Carries no positioning: the wheel places it absolutely, the rows inline. -->
|
|
17
|
+
<span class="numeral" class:filled={status === 'on-wheel'} class:selected class:scrim>{index + 1}</span>
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
/* Hollow is the resting shape; filled means the axis has a rail on the wheel
|
|
21
|
+
right now. An unused axis is hollow too, but it never holds a family, so a
|
|
22
|
+
hollow numeral beside a color always reads "bound, off the wheel". */
|
|
23
|
+
.numeral {
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
flex: none;
|
|
26
|
+
display: inline-flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
/* Callers that need a bigger numeral set these two; the row default is the
|
|
30
|
+
size the wheel and the axes list share. */
|
|
31
|
+
width: var(--numeral-size, 1rem);
|
|
32
|
+
height: var(--numeral-size, 1rem);
|
|
33
|
+
border: 1px dashed var(--ui-text-muted);
|
|
34
|
+
border-radius: var(--ui-radius-full);
|
|
35
|
+
color: var(--ui-text-muted);
|
|
36
|
+
font-size: var(--numeral-font-size, var(--ui-font-size-xs));
|
|
37
|
+
font-weight: var(--ui-font-weight-semibold);
|
|
38
|
+
/* No tabular figures: the box is a fixed circle, so nothing needs aligning,
|
|
39
|
+
and a padded tabular "1" sits off-center inside its own advance. */
|
|
40
|
+
line-height: 1;
|
|
41
|
+
user-select: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* The scrim composites with the swatch fill beneath it, which lands close
|
|
45
|
+
enough to the muted token to erase the ring and the digit. */
|
|
46
|
+
.numeral.scrim:not(.filled) {
|
|
47
|
+
border-color: var(--ui-text-secondary);
|
|
48
|
+
color: var(--ui-text-secondary);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.numeral.filled {
|
|
52
|
+
border-style: solid;
|
|
53
|
+
border-color: var(--ui-border-higher);
|
|
54
|
+
background: var(--ui-surface-high);
|
|
55
|
+
color: var(--ui-text-secondary);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.numeral.filled.selected {
|
|
59
|
+
background: var(--ui-text-primary);
|
|
60
|
+
border-color: var(--ui-text-primary);
|
|
61
|
+
color: var(--ui-surface-lowest);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Selection raises contrast but never fills: filled is reserved for "has a
|
|
65
|
+
rail", so inverting here would make the list contradict the wheel. */
|
|
66
|
+
.numeral.selected:not(.filled) {
|
|
67
|
+
border-color: var(--ui-text-primary);
|
|
68
|
+
color: var(--ui-text-primary);
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import { editorState, beginScope, commitScope, cancelScope, type Scope } from '../../core/store/editorStore';
|
|
6
6
|
import { setBaseHueChroma, setBaseChroma, setAxisHue, setAxisHues } from './paletteBaseColor';
|
|
7
7
|
import { maxChroma } from './colorWheelMath';
|
|
8
|
-
import { applyHarmonyToAxes,
|
|
8
|
+
import { applyHarmonyToAxes, axisLabel, axisStatuses, type HarmonyMode } from '../../core/palettes/colorHarmony';
|
|
9
|
+
import AxisNumeral from './AxisNumeral.svelte';
|
|
10
|
+
import { modeLabel } from './harmonyModeIcons';
|
|
9
11
|
|
|
10
12
|
interface Props {
|
|
11
13
|
selected: string | null;
|
|
@@ -29,12 +31,12 @@
|
|
|
29
31
|
|
|
30
32
|
let { selected, onSelect, discLightness, onCustomize, absoluteChroma, activeMode, previewMode = null }: Props = $props();
|
|
31
33
|
|
|
32
|
-
// One handle per axis
|
|
33
|
-
//
|
|
34
|
-
//
|
|
34
|
+
// One handle per axis. A bound axis reads its color from the family's
|
|
35
|
+
// baseColor (hue equals axes[i].hue by invariant 1); an unbound axis carries
|
|
36
|
+
// only its stored hue, previewing where an assigned color would land.
|
|
35
37
|
let axisData = $derived(
|
|
36
38
|
$editorState.harmonyAxes.map((axis, i) => {
|
|
37
|
-
const common = { index: i,
|
|
39
|
+
const common = { index: i, label: axisLabel(i), family: axis.family, bound: axis.family !== null };
|
|
38
40
|
if (axis.family !== null) {
|
|
39
41
|
const spec = PALETTE_SPECS.find((s) => s.label === axis.family);
|
|
40
42
|
const { l, c, h } = $editorState.palettes[axis.family]?.baseColor ?? spec!.initialColor;
|
|
@@ -50,8 +52,8 @@
|
|
|
50
52
|
// active in the applied geometry (its slot is a distinct position). A bound
|
|
51
53
|
// inactive axis keeps its family's color but is edited in dot mode, not axis
|
|
52
54
|
// mode: the family renders as the free dot and sits out the global spin.
|
|
53
|
-
let
|
|
54
|
-
const isVisible = (t: { index: number }) =>
|
|
55
|
+
let statuses = $derived(axisStatuses(activeMode, $editorState.harmonyAxes));
|
|
56
|
+
const isVisible = (t: { index: number }) => statuses[t.index] === 'on-wheel';
|
|
55
57
|
|
|
56
58
|
// Reserved judgment call: keyboard nudge increments.
|
|
57
59
|
const HUE_STEP = 2;
|
|
@@ -59,6 +61,7 @@
|
|
|
59
61
|
const MARGIN = 52; // ring-to-edge gap: houses the external handles AND the axis numerals outside them
|
|
60
62
|
const EXT_OFFSET = 20; // external handle radius beyond the disc rim (room for the dotted tether)
|
|
61
63
|
const NUM_OFFSET = 24; // numeral radius beyond the external handles
|
|
64
|
+
const FREE_NUM_OFFSET = 15; // free-dot numeral, diagonal clearance past the enlarged dot
|
|
62
65
|
const MIN_SIZE = 240;
|
|
63
66
|
const MAX_SIZE = 560;
|
|
64
67
|
|
|
@@ -87,7 +90,7 @@
|
|
|
87
90
|
// lossy and makes the non-dragged coordinate wobble). Writes also use the
|
|
88
91
|
// pristine hue0/L0, so nothing accumulates across frames.
|
|
89
92
|
type AxisView = {
|
|
90
|
-
index: number;
|
|
93
|
+
index: number; label: string; family: string | null; bound: boolean;
|
|
91
94
|
hex: string; hue: number; chroma: number; lightness: number; rFrac: number;
|
|
92
95
|
};
|
|
93
96
|
|
|
@@ -146,7 +149,9 @@
|
|
|
146
149
|
// Writes go through setBaseHueChroma only — active axes never move; a bound
|
|
147
150
|
// inactive axis follows its family via syncBoundAxisHue.
|
|
148
151
|
let freeDot = $derived.by(() => {
|
|
149
|
-
if (!selected
|
|
152
|
+
if (!selected) return null;
|
|
153
|
+
const axisIndex = $editorState.harmonyAxes.findIndex((a) => a.family === selected);
|
|
154
|
+
if (axisIndex !== -1 && statuses[axisIndex] === 'on-wheel') return null;
|
|
150
155
|
const spec = PALETTE_SPECS.find((s) => s.label === selected);
|
|
151
156
|
if (!spec) return null;
|
|
152
157
|
const { l, c, h } = $editorState.palettes[selected]?.baseColor ?? spec.initialColor;
|
|
@@ -159,11 +164,22 @@
|
|
|
159
164
|
}
|
|
160
165
|
const r = rFrac * discRadius;
|
|
161
166
|
return {
|
|
162
|
-
family: selected,
|
|
167
|
+
family: selected, axisIndex: axisIndex === -1 ? null : axisIndex,
|
|
168
|
+
hex: oklchToHexClamped(l, c, h), hue, chroma: c, lightness: l,
|
|
163
169
|
x: center + r * Math.cos(rad(hue)), y: center - r * Math.sin(rad(hue)),
|
|
164
170
|
};
|
|
165
171
|
});
|
|
166
172
|
|
|
173
|
+
// The dot serves two states: bound to an axis the mode left off the wheel, and
|
|
174
|
+
// unassigned. Only the first has an axis to name.
|
|
175
|
+
let freeDotLabel = $derived.by(() => {
|
|
176
|
+
if (!freeDot) return '';
|
|
177
|
+
const where = freeDot.axisIndex === null
|
|
178
|
+
? 'unassigned'
|
|
179
|
+
: `${axisLabel(freeDot.axisIndex)}. Off the wheel in ${modeLabel(activeMode)}`;
|
|
180
|
+
return `${freeDot.family}, ${where}. Drag to adjust hue and chroma.`;
|
|
181
|
+
});
|
|
182
|
+
|
|
167
183
|
let globalHandle = $derived.by(() => {
|
|
168
184
|
const d = drag;
|
|
169
185
|
const angle = d?.kind === 'global' ? d.angle : globalAngle;
|
|
@@ -315,7 +331,7 @@
|
|
|
315
331
|
if (axis.family !== null) onSelect(axis.family);
|
|
316
332
|
onCustomize();
|
|
317
333
|
capture(e);
|
|
318
|
-
openGesture(`colors: ${axis.
|
|
334
|
+
openGesture(`colors: ${axis.label} rotate`);
|
|
319
335
|
drag = { kind: 'axis', index: axis.index, angle: pointerAngle(e), start: startOf(axis) };
|
|
320
336
|
applyAxis(e);
|
|
321
337
|
}
|
|
@@ -483,7 +499,9 @@
|
|
|
483
499
|
{/each}
|
|
484
500
|
|
|
485
501
|
{#each visibleRender as t (t.index)}
|
|
486
|
-
<span class="axis-num"
|
|
502
|
+
<span class="axis-num" style="left: {t.num.x}px; top: {t.num.y}px" aria-hidden="true">
|
|
503
|
+
<AxisNumeral index={t.index} status={statuses[t.index]} selected={t.selected} />
|
|
504
|
+
</span>
|
|
487
505
|
{/each}
|
|
488
506
|
|
|
489
507
|
{#each visibleRender as t (t.index)}
|
|
@@ -493,7 +511,7 @@
|
|
|
493
511
|
class="dot"
|
|
494
512
|
class:selected={t.selected}
|
|
495
513
|
style="left: {t.dot.x}px; top: {t.dot.y}px; --fill: {t.hex}"
|
|
496
|
-
aria-label={`${t.family}
|
|
514
|
+
aria-label={`${t.family}, drag along the rail to adjust chroma`}
|
|
497
515
|
title={`${t.family} (drag for chroma)`}
|
|
498
516
|
onpointerdown={(e) => startChromaDrag(e, t)}
|
|
499
517
|
onpointermove={moveDrag}
|
|
@@ -513,8 +531,8 @@
|
|
|
513
531
|
class:unbound={!t.bound}
|
|
514
532
|
class:selected={t.selected}
|
|
515
533
|
style="left: {t.ext.x}px; top: {t.ext.y}px; transform: translate(-50%, -50%) rotate({t.iconRot}deg)"
|
|
516
|
-
aria-label={t.bound ? `Rotate ${t.
|
|
517
|
-
title={t.bound ? `Rotate ${t.
|
|
534
|
+
aria-label={t.bound ? `Rotate ${t.label} hue (${t.family})` : `Rotate ${t.label} hue`}
|
|
535
|
+
title={t.bound ? `Rotate ${t.label} hue (${t.family})` : `Rotate ${t.label} hue`}
|
|
518
536
|
onpointerdown={(e) => startAxisDrag(e, t)}
|
|
519
537
|
onpointermove={moveDrag}
|
|
520
538
|
onpointerup={endDrag}
|
|
@@ -526,12 +544,21 @@
|
|
|
526
544
|
{/each}
|
|
527
545
|
|
|
528
546
|
{#if freeDot}
|
|
547
|
+
{#if freeDot.axisIndex !== null}
|
|
548
|
+
<span
|
|
549
|
+
class="axis-num"
|
|
550
|
+
style="left: {freeDot.x + FREE_NUM_OFFSET}px; top: {freeDot.y - FREE_NUM_OFFSET}px"
|
|
551
|
+
aria-hidden="true"
|
|
552
|
+
>
|
|
553
|
+
<AxisNumeral index={freeDot.axisIndex} status={statuses[freeDot.axisIndex]} selected />
|
|
554
|
+
</span>
|
|
555
|
+
{/if}
|
|
529
556
|
<button
|
|
530
557
|
type="button"
|
|
531
558
|
class="dot selected"
|
|
532
559
|
style="left: {freeDot.x}px; top: {freeDot.y}px; --fill: {freeDot.hex}"
|
|
533
|
-
aria-label={
|
|
534
|
-
title={
|
|
560
|
+
aria-label={freeDotLabel}
|
|
561
|
+
title={freeDotLabel}
|
|
535
562
|
onpointerdown={startFreeDrag}
|
|
536
563
|
onpointermove={moveDrag}
|
|
537
564
|
onpointerup={endDrag}
|
|
@@ -632,7 +659,7 @@
|
|
|
632
659
|
}
|
|
633
660
|
|
|
634
661
|
/* Unbound-axis ghost: a hollow greyscale marker on the external track (no color
|
|
635
|
-
to preview, only where
|
|
662
|
+
to preview, only where an assigned color's hue would land). */
|
|
636
663
|
.ghost.unbound {
|
|
637
664
|
width: 0.7rem;
|
|
638
665
|
height: 0.7rem;
|
|
@@ -731,24 +758,16 @@
|
|
|
731
758
|
color: var(--ui-text-primary);
|
|
732
759
|
}
|
|
733
760
|
|
|
734
|
-
/* Axis numeral just outside the handle track.
|
|
735
|
-
so
|
|
761
|
+
/* Axis numeral just outside the handle track. Positioning only — never
|
|
762
|
+
rotated, so the digit stays upright at any angle. */
|
|
736
763
|
.axis-num {
|
|
737
764
|
position: absolute;
|
|
738
765
|
transform: translate(-50%, -50%);
|
|
739
|
-
|
|
740
|
-
font-weight: var(--ui-font-weight-semibold);
|
|
741
|
-
color: var(--ui-text-tertiary);
|
|
766
|
+
display: flex;
|
|
742
767
|
pointer-events: none;
|
|
743
|
-
user-select: none;
|
|
744
768
|
z-index: 3;
|
|
745
769
|
}
|
|
746
770
|
|
|
747
|
-
.axis-num.selected {
|
|
748
|
-
color: var(--ui-text-primary);
|
|
749
|
-
font-size: var(--ui-font-size-sm);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
771
|
.dot:focus-visible,
|
|
753
772
|
.ext-handle:focus-visible,
|
|
754
773
|
.global-handle:focus-visible {
|