@motion-proto/live-tokens 0.40.0 → 0.41.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 +115 -0
- package/dist-plugin/{chunk-D77VD4Z6.js → chunk-H4TRUINI.js} +102 -13
- package/dist-plugin/index.cjs +288 -67
- package/dist-plugin/index.js +171 -55
- package/dist-plugin/tokensCssMigrations/index.cjs +102 -13
- package/dist-plugin/tokensCssMigrations/index.js +1 -1
- package/package.json +3 -2
- package/src/app/site.css +97 -20
- package/src/editor/core/palettes/colorHarmony.ts +228 -0
- package/src/editor/core/palettes/contrast.ts +128 -0
- package/src/editor/core/palettes/oklch.ts +8 -0
- package/src/editor/core/palettes/paletteDerivation.ts +267 -72
- package/src/editor/core/palettes/recommendText.ts +216 -0
- package/src/editor/core/palettes/solveTextContrast.ts +251 -0
- package/src/editor/core/store/editorCore.ts +2 -1
- package/src/editor/core/store/editorPersistence.ts +18 -1
- package/src/editor/core/store/editorStore.ts +20 -1
- package/src/editor/core/store/editorTypes.ts +4 -0
- package/src/editor/core/store/editorViewStore.ts +7 -3
- package/src/editor/core/themes/migrations/2026-06-05-palette-unification.ts +11 -9
- package/src/editor/core/themes/migrations/2026-07-21-palette-oklch-basis.ts +51 -0
- package/src/editor/core/themes/migrations/2026-07-24-base-anchor-placement.ts +37 -0
- package/src/editor/core/themes/migrations/2026-07-25-background-spot-to-base.ts +40 -0
- package/src/editor/core/themes/themeTypes.ts +23 -3
- package/src/editor/docs/CodeBlock.svelte +1 -1
- package/src/editor/docs/Docs.svelte +30 -13
- package/src/editor/pages/EditorShell.svelte +31 -3
- package/src/editor/ui/ColorEditPanel.svelte +22 -16
- package/src/editor/ui/EditorViewSwitcher.svelte +43 -9
- package/src/editor/ui/FontStackEditor.svelte +0 -1
- package/src/editor/ui/PaletteEditor.svelte +117 -155
- package/src/editor/ui/ProjectFontsSection.svelte +0 -3
- package/src/editor/ui/TextStylesSection.svelte +173 -0
- package/src/editor/ui/UILineHeightSelector.svelte +6 -5
- package/src/editor/ui/UIPaletteSelector.svelte +6 -1
- package/src/editor/ui/UITextTransformSelector.svelte +106 -0
- package/src/editor/ui/VariablesTab.svelte +23 -10
- package/src/editor/ui/colors/ColorReadouts.svelte +123 -0
- package/src/editor/ui/colors/ColorStory.svelte +375 -0
- package/src/editor/ui/colors/ColorWheel.svelte +740 -0
- package/src/editor/ui/colors/ColorsTab.svelte +508 -0
- package/src/editor/ui/colors/HarmonyAxesList.svelte +320 -0
- package/src/editor/ui/colors/LightnessBar.svelte +261 -0
- package/src/editor/ui/colors/PaletteStepStrip.svelte +142 -0
- package/src/editor/ui/colors/colorWheelMath.ts +9 -0
- package/src/editor/ui/colors/harmony-icons/analogous.svg +13 -0
- package/src/editor/ui/colors/harmony-icons/complementary.svg +10 -0
- package/src/editor/ui/colors/harmony-icons/compound.svg +15 -0
- package/src/editor/ui/colors/harmony-icons/custom.svg +10 -0
- package/src/editor/ui/colors/harmony-icons/monochromatic.svg +11 -0
- package/src/editor/ui/colors/harmony-icons/shades.svg +9 -0
- package/src/editor/ui/colors/harmony-icons/split-complementary.svg +13 -0
- package/src/editor/ui/colors/harmony-icons/square.svg +13 -0
- package/src/editor/ui/colors/harmony-icons/tetradic.svg +15 -0
- package/src/editor/ui/colors/harmony-icons/triadic.svg +13 -0
- package/src/editor/ui/colors/harmonyModeIcons.ts +23 -0
- package/src/editor/ui/colors/paletteBaseColor.ts +214 -0
- package/src/editor/ui/index.ts +0 -1
- package/src/editor/ui/palette/OverridesPanel.svelte +6 -3
- package/src/editor/ui/palette/PaletteBase.svelte +16 -8
- package/src/editor/ui/palette/dockMagnify.ts +22 -0
- package/src/editor/ui/palette/paletteEditorState.ts +5 -4
- package/src/editor/ui/palette/paletteMath.ts +93 -181
- package/src/editor/ui/sections/textStyles.ts +82 -0
- package/src/editor/ui/sections/tokenScales.ts +3 -3
- package/src/live-tokens/data/themes/default.json +3 -3
- package/src/live-tokens/data/tokens.generated.css +96 -95
- package/src/system/components/Badge.svelte +11 -11
- package/src/system/components/Button.svelte +7 -7
- package/src/system/components/Callout.svelte +8 -8
- package/src/system/components/Card.svelte +2 -2
- package/src/system/components/CodeSnippet.svelte +1 -1
- package/src/system/components/CollapsibleSection.svelte +7 -7
- package/src/system/components/CornerBadge.svelte +1 -1
- package/src/system/components/Dialog.svelte +2 -2
- package/src/system/components/FloatingTokenTags.css +6 -5
- package/src/system/components/Input.svelte +6 -6
- package/src/system/components/MenuSelect.svelte +4 -4
- package/src/system/components/Notification.svelte +8 -8
- package/src/system/components/ProgressBar.svelte +2 -2
- package/src/system/components/RadioButton.svelte +4 -4
- package/src/system/components/SectionDivider.svelte +6 -6
- package/src/system/components/SegmentedControl.svelte +5 -5
- package/src/system/components/SideNavigation.svelte +12 -12
- package/src/system/components/TabBar.svelte +4 -4
- package/src/system/components/Table.svelte +2 -2
- package/src/system/components/Tooltip.svelte +1 -1
- package/src/system/styles/tokens.css +59 -6
- package/src/editor/ui/TextTab.svelte +0 -203
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
import { oklchToHex, oklchToHexClamped } from '../../core/palettes/oklch';
|
|
4
|
+
import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
|
|
5
|
+
import { editorState, beginScope, commitScope, cancelScope, type Scope } from '../../core/store/editorStore';
|
|
6
|
+
import { setBaseHueChroma, setBaseChroma, setAxisHue, setAxisHues } from './paletteBaseColor';
|
|
7
|
+
import { maxChroma } from './colorWheelMath';
|
|
8
|
+
import { applyHarmonyToAxes, modeActiveAxes, AXIS_ROLES, type HarmonyMode } from '../../core/palettes/colorHarmony';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
selected: string | null;
|
|
12
|
+
onSelect: (label: string) => void;
|
|
13
|
+
/** Lightness (0..1) the disc is painted at — the selected color's L. */
|
|
14
|
+
discLightness: number;
|
|
15
|
+
/** Called when a per-axis edit detaches the axes from their harmony geometry,
|
|
16
|
+
* so the parent can flip the active mode to 'custom'. */
|
|
17
|
+
onCustomize: () => void;
|
|
18
|
+
/** Rotation semantics: off (default) preserves relative saturation (constant
|
|
19
|
+
* render radius); on preserves absolute chroma (the dot drifts in/out). */
|
|
20
|
+
absoluteChroma: boolean;
|
|
21
|
+
/** The applied harmony mode: gates which axes exist on the wheel (only
|
|
22
|
+
* slots the geometry deals a distinct hue to). A family bound to an
|
|
23
|
+
* inactive axis renders as the free dot instead. */
|
|
24
|
+
activeMode: HarmonyMode;
|
|
25
|
+
/** Hovered harmony mode: paints non-interactive ghost markers at the hues the
|
|
26
|
+
* axes would move to, so the geometry previews before it's applied. */
|
|
27
|
+
previewMode?: HarmonyMode | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let { selected, onSelect, discLightness, onCustomize, absoluteChroma, activeMode, previewMode = null }: Props = $props();
|
|
31
|
+
|
|
32
|
+
// One handle per axis (index = role). A bound axis reads its color from the
|
|
33
|
+
// family's baseColor (hue equals axes[i].hue by invariant 1); an unbound axis
|
|
34
|
+
// carries only its stored hue, previewing where a dropped color would land.
|
|
35
|
+
let axisData = $derived(
|
|
36
|
+
$editorState.harmonyAxes.map((axis, i) => {
|
|
37
|
+
const common = { index: i, role: AXIS_ROLES[i], family: axis.family, bound: axis.family !== null };
|
|
38
|
+
if (axis.family !== null) {
|
|
39
|
+
const spec = PALETTE_SPECS.find((s) => s.label === axis.family);
|
|
40
|
+
const { l, c, h } = $editorState.palettes[axis.family]?.baseColor ?? spec!.initialColor;
|
|
41
|
+
const mc = maxChroma(l, h) || 1e-6;
|
|
42
|
+
// `hex` is the clamped sRGB projection painting the dot fill only.
|
|
43
|
+
return { ...common, hex: oklchToHexClamped(l, c, h), hue: h, chroma: c, lightness: l, rFrac: clamp(c / mc, 0, 1) };
|
|
44
|
+
}
|
|
45
|
+
return { ...common, hex: '', hue: axis.hue, chroma: 0, lightness: discLightness, rFrac: 0 };
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Axis chrome (rail, tether, handle, numeral) exists only while the axis is
|
|
50
|
+
// active in the applied geometry (its slot is a distinct position). A bound
|
|
51
|
+
// inactive axis keeps its family's color but is edited in dot mode, not axis
|
|
52
|
+
// mode: the family renders as the free dot and sits out the global spin.
|
|
53
|
+
let activeAxes = $derived(modeActiveAxes(activeMode));
|
|
54
|
+
const isVisible = (t: { index: number }) => activeAxes[t.index];
|
|
55
|
+
|
|
56
|
+
// Reserved judgment call: keyboard nudge increments.
|
|
57
|
+
const HUE_STEP = 2;
|
|
58
|
+
const CHROMA_STEP = 0.005;
|
|
59
|
+
const MARGIN = 52; // ring-to-edge gap: houses the external handles AND the axis numerals outside them
|
|
60
|
+
const EXT_OFFSET = 20; // external handle radius beyond the disc rim (room for the dotted tether)
|
|
61
|
+
const NUM_OFFSET = 24; // numeral radius beyond the external handles
|
|
62
|
+
const MIN_SIZE = 240;
|
|
63
|
+
const MAX_SIZE = 360;
|
|
64
|
+
|
|
65
|
+
const normDeg = (d: number) => ((d % 360) + 360) % 360;
|
|
66
|
+
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
|
67
|
+
const rad = (deg: number) => (deg * Math.PI) / 180;
|
|
68
|
+
// Shortest signed angular delta in (-180, 180], so a spin accumulates cleanly.
|
|
69
|
+
const angleDelta = (cur: number, prev: number) => ((cur - prev + 540) % 360) - 180;
|
|
70
|
+
|
|
71
|
+
let wrapperSize = $state(300);
|
|
72
|
+
let wrapper: HTMLDivElement | undefined = $state();
|
|
73
|
+
let canvas: HTMLCanvasElement | undefined = $state();
|
|
74
|
+
// Parked angle of the global rotate handle — starts top-left, then stays
|
|
75
|
+
// wherever it was last dragged (session-local). Cosmetic grab-point only:
|
|
76
|
+
// it never feeds the color math.
|
|
77
|
+
let globalAngle = $state(135);
|
|
78
|
+
|
|
79
|
+
let center = $derived(wrapperSize / 2);
|
|
80
|
+
let discRadius = $derived(wrapperSize / 2 - MARGIN);
|
|
81
|
+
let extRadius = $derived(discRadius + EXT_OFFSET);
|
|
82
|
+
let discDiameter = $derived(Math.max(1, wrapperSize - 2 * MARGIN));
|
|
83
|
+
|
|
84
|
+
// ── Transient drag state ──────────────────────────────────────────────────
|
|
85
|
+
// The ACTIVE family renders from PRISTINE gesture-start intent captured once
|
|
86
|
+
// here, never re-derived from the just-written 8-bit hex (that round-trip is
|
|
87
|
+
// lossy and makes the non-dragged coordinate wobble). Writes also use the
|
|
88
|
+
// pristine hue0/L0, so nothing accumulates across frames.
|
|
89
|
+
type AxisView = {
|
|
90
|
+
index: number; role: string; family: string | null; bound: boolean;
|
|
91
|
+
hex: string; hue: number; chroma: number; lightness: number; rFrac: number;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// `bound` gates the chroma policy: only a bound axis carries a color to re-hue.
|
|
95
|
+
type RotateStart = { hue0: number; chroma0: number; rFrac0: number; l0: number; bound: boolean };
|
|
96
|
+
type Drag =
|
|
97
|
+
| { kind: 'axis'; index: number; angle: number; start: RotateStart }
|
|
98
|
+
| { kind: 'global'; start: RotateStart[]; delta: number; lastAngle: number; angle: number }
|
|
99
|
+
| { kind: 'chroma'; index: number; family: string; hue0: number; l0: number; maxC0: number; rFrac: number }
|
|
100
|
+
| { kind: 'free'; family: string; l0: number; hue: number; rFrac: number };
|
|
101
|
+
let drag: Drag | null = $state(null);
|
|
102
|
+
let dragScope: Scope | null = null;
|
|
103
|
+
|
|
104
|
+
// Overlay the transient intent onto the store-derived axes so the active rail
|
|
105
|
+
// / dot / handle tracks the pointer with no store round-trip. The dragged
|
|
106
|
+
// coordinate follows the pointer; the OTHER is locked to gesture-start intent
|
|
107
|
+
// (rotation → radius; chroma → angle) — neither is re-read from the hex.
|
|
108
|
+
let axisRender = $derived(
|
|
109
|
+
axisData.map((t) => {
|
|
110
|
+
let hue = t.hue;
|
|
111
|
+
let rFrac = t.rFrac;
|
|
112
|
+
const d = drag;
|
|
113
|
+
if (d?.kind === 'axis' && d.index === t.index) {
|
|
114
|
+
hue = d.angle;
|
|
115
|
+
rFrac = t.bound ? rotateRadius(d.start, hue) : 0;
|
|
116
|
+
} else if (d?.kind === 'chroma' && d.index === t.index) {
|
|
117
|
+
hue = d.hue0;
|
|
118
|
+
rFrac = d.rFrac;
|
|
119
|
+
} else if (d?.kind === 'global' && d.start[t.index]) {
|
|
120
|
+
hue = normDeg(d.start[t.index].hue0 + d.delta);
|
|
121
|
+
rFrac = t.bound ? rotateRadius(d.start[t.index], hue) : 0;
|
|
122
|
+
}
|
|
123
|
+
const dotR = rFrac * discRadius;
|
|
124
|
+
const numR = extRadius + NUM_OFFSET;
|
|
125
|
+
return {
|
|
126
|
+
...t,
|
|
127
|
+
hue,
|
|
128
|
+
selected: t.bound && selected === t.family,
|
|
129
|
+
dot: { x: center + dotR * Math.cos(rad(hue)), y: center - dotR * Math.sin(rad(hue)) },
|
|
130
|
+
ext: { x: center + extRadius * Math.cos(rad(hue)), y: center - extRadius * Math.sin(rad(hue)) },
|
|
131
|
+
// Numeral just beyond the handle; the span itself is never rotated, so
|
|
132
|
+
// the digit stays upright at any angle.
|
|
133
|
+
num: { x: center + numR * Math.cos(rad(hue)), y: center - numR * Math.sin(rad(hue)) },
|
|
134
|
+
// Orient the glyph tangent to the ring (perpendicular to the radius).
|
|
135
|
+
// Screen y-down: the radius sits at CSS-angle -hue, so the tangent is 90 - hue.
|
|
136
|
+
iconRot: 90 - hue,
|
|
137
|
+
};
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
let visibleRender = $derived(axisRender.filter(isVisible));
|
|
142
|
+
|
|
143
|
+
// Free dot for a selected family on no ACTIVE axis (Neutral, Alternate, …,
|
|
144
|
+
// plus families whose axis the applied geometry deals no distinct slot): a 2D
|
|
145
|
+
// handle at the family's own (hue, chroma), draggable anywhere on the disc.
|
|
146
|
+
// Writes go through setBaseHueChroma only — active axes never move; a bound
|
|
147
|
+
// inactive axis follows its family via syncBoundAxisHue.
|
|
148
|
+
let freeDot = $derived.by(() => {
|
|
149
|
+
if (!selected || $editorState.harmonyAxes.some((a, i) => a.family === selected && activeAxes[i])) return null;
|
|
150
|
+
const spec = PALETTE_SPECS.find((s) => s.label === selected);
|
|
151
|
+
if (!spec) return null;
|
|
152
|
+
const { l, c, h } = $editorState.palettes[selected]?.baseColor ?? spec.initialColor;
|
|
153
|
+
let hue = h;
|
|
154
|
+
let rFrac = clamp(c / (maxChroma(l, h) || 1e-6), 0, 1);
|
|
155
|
+
const d = drag;
|
|
156
|
+
if (d?.kind === 'free') {
|
|
157
|
+
hue = d.hue;
|
|
158
|
+
rFrac = d.rFrac;
|
|
159
|
+
}
|
|
160
|
+
const r = rFrac * discRadius;
|
|
161
|
+
return {
|
|
162
|
+
family: selected, hex: oklchToHexClamped(l, c, h), hue, chroma: c, lightness: l,
|
|
163
|
+
x: center + r * Math.cos(rad(hue)), y: center - r * Math.sin(rad(hue)),
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
let globalHandle = $derived.by(() => {
|
|
168
|
+
const d = drag;
|
|
169
|
+
const angle = d?.kind === 'global' ? d.angle : globalAngle;
|
|
170
|
+
return { x: center + extRadius * Math.cos(rad(angle)), y: center - extRadius * Math.sin(rad(angle)) };
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Ghost previews for a hovered harmony mode: every visible axis re-hued to the
|
|
174
|
+
// would-be geometry (`applyHarmonyToAxes`, so a frozen inactive axis previews
|
|
175
|
+
// as stationary). A bound axis previews at its family's chroma radius; an unbound
|
|
176
|
+
// axis shows a marker on the external track. Axes that wouldn't move are
|
|
177
|
+
// dropped so a ghost never sits atop its live handle. Inert.
|
|
178
|
+
let ghosts = $derived.by(() => {
|
|
179
|
+
if (!previewMode || previewMode === 'custom' || drag) return [];
|
|
180
|
+
const target = applyHarmonyToAxes(previewMode, $editorState.harmonyAxes);
|
|
181
|
+
return axisData.filter(isVisible).flatMap((t) => {
|
|
182
|
+
const hue = target[t.index].hue;
|
|
183
|
+
if (Math.abs(angleDelta(hue, t.hue)) < 0.5) return [];
|
|
184
|
+
if (t.bound) {
|
|
185
|
+
const rFrac = clamp(t.chroma / (maxChroma(t.lightness, hue) || 1e-6), 0, 1);
|
|
186
|
+
const r = rFrac * discRadius;
|
|
187
|
+
return [{ index: t.index, bound: true, hex: t.hex, x: center + r * Math.cos(rad(hue)), y: center - r * Math.sin(rad(hue)) }];
|
|
188
|
+
}
|
|
189
|
+
return [{ index: t.index, bound: false, hex: '', x: center + extRadius * Math.cos(rad(hue)), y: center - extRadius * Math.sin(rad(hue)) }];
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
let discMaxByHue = $derived.by(() => {
|
|
194
|
+
const arr = new Float64Array(360);
|
|
195
|
+
for (let h = 0; h < 360; h++) arr[h] = maxChroma(discLightness, h);
|
|
196
|
+
return arr;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
$effect(() => {
|
|
200
|
+
const el = wrapper;
|
|
201
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
202
|
+
const ro = new ResizeObserver((entries) => {
|
|
203
|
+
const w = entries[0]?.contentRect.width ?? 0;
|
|
204
|
+
if (w > 0) wrapperSize = clamp(Math.round(w), MIN_SIZE, MAX_SIZE);
|
|
205
|
+
});
|
|
206
|
+
ro.observe(el);
|
|
207
|
+
return () => ro.disconnect();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Repaint only on size or disc-L change — never on hue/chroma drags.
|
|
211
|
+
$effect(() => {
|
|
212
|
+
const cv = canvas;
|
|
213
|
+
if (!cv) return;
|
|
214
|
+
const maxByHue = discMaxByHue;
|
|
215
|
+
const L = discLightness;
|
|
216
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
217
|
+
const px = Math.max(1, Math.round(discDiameter * dpr));
|
|
218
|
+
cv.width = px;
|
|
219
|
+
cv.height = px;
|
|
220
|
+
const ctx = cv.getContext('2d');
|
|
221
|
+
if (ctx) renderDisc(ctx, px, L, maxByHue);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function renderDisc(ctx: CanvasRenderingContext2D, px: number, L: number, maxByHue: Float64Array) {
|
|
225
|
+
const img = ctx.createImageData(px, px);
|
|
226
|
+
const data = img.data;
|
|
227
|
+
const c = px / 2;
|
|
228
|
+
const R = px / 2;
|
|
229
|
+
for (let y = 0; y < px; y++) {
|
|
230
|
+
for (let x = 0; x < px; x++) {
|
|
231
|
+
const dx = x - c;
|
|
232
|
+
const dy = y - c;
|
|
233
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
234
|
+
const i = (y * px + x) * 4;
|
|
235
|
+
if (dist > R) {
|
|
236
|
+
data[i + 3] = 0;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const hue = normDeg((Math.atan2(-dy, dx) * 180) / Math.PI);
|
|
240
|
+
const chroma = (dist / R) * maxByHue[Math.round(hue) % 360];
|
|
241
|
+
const hex = oklchToHex(L, chroma, hue);
|
|
242
|
+
data[i] = parseInt(hex.slice(1, 3), 16);
|
|
243
|
+
data[i + 1] = parseInt(hex.slice(3, 5), 16);
|
|
244
|
+
data[i + 2] = parseInt(hex.slice(5, 7), 16);
|
|
245
|
+
data[i + 3] = 255;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
ctx.putImageData(img, 0, 0);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function pointerAngle(e: PointerEvent): number {
|
|
252
|
+
const r = wrapper!.getBoundingClientRect();
|
|
253
|
+
const dx = e.clientX - (r.left + r.width / 2);
|
|
254
|
+
const dy = e.clientY - (r.top + r.height / 2);
|
|
255
|
+
return normDeg((Math.atan2(-dy, dx) * 180) / Math.PI);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Gesture lifecycle: one clipping scope = one undo; Escape cancels ──
|
|
259
|
+
function openGesture(label: string) {
|
|
260
|
+
dragScope = beginScope({ label, collapseToOne: true, clipUndoFloor: true });
|
|
261
|
+
window.addEventListener('keydown', onGestureKey, true);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function endDrag() {
|
|
265
|
+
if (!dragScope) return;
|
|
266
|
+
// Park the global handle where it was dragged (Escape/cancel doesn't reach
|
|
267
|
+
// here, so a cancelled spin leaves it at its prior spot).
|
|
268
|
+
if (drag?.kind === 'global') globalAngle = drag.angle;
|
|
269
|
+
commitScope(dragScope);
|
|
270
|
+
dragScope = null;
|
|
271
|
+
drag = null;
|
|
272
|
+
window.removeEventListener('keydown', onGestureKey, true);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function onGestureKey(e: KeyboardEvent) {
|
|
276
|
+
if (e.key !== 'Escape' || !dragScope) return;
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
cancelScope(dragScope);
|
|
279
|
+
dragScope = null;
|
|
280
|
+
drag = null;
|
|
281
|
+
window.removeEventListener('keydown', onGestureKey, true);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function capture(e: PointerEvent) {
|
|
285
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const rFracOf = (t: { chroma: number; lightness: number; hue: number }) =>
|
|
289
|
+
clamp(t.chroma / (maxChroma(t.lightness, t.hue) || 1e-6), 0, 1);
|
|
290
|
+
|
|
291
|
+
const startOf = (t: AxisView): RotateStart =>
|
|
292
|
+
({ hue0: t.hue, chroma0: t.chroma, rFrac0: rFracOf(t), l0: t.lightness, bound: t.bound });
|
|
293
|
+
|
|
294
|
+
// Render radius for a rotating family, from pristine intent (not the hex).
|
|
295
|
+
// Off: relative saturation held constant (rFrac0). On: absolute chroma0
|
|
296
|
+
// re-fractionalized against the transient hue's gamut (the intended drift,
|
|
297
|
+
// computed cleanly), clamped to the rim for display.
|
|
298
|
+
function rotateRadius(s: RotateStart, renderHue: number): number {
|
|
299
|
+
if (!absoluteChroma) return s.rFrac0;
|
|
300
|
+
return clamp(s.chroma0 / (maxChroma(s.l0, renderHue) || 1e-6), 0, 1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Chroma a rotating bound family carries, from pristine gesture-start intent.
|
|
304
|
+
// Off holds the gamut fraction (rFrac0·maxChroma(L0, newHue)); on holds absolute
|
|
305
|
+
// chroma0 (gamut-clamped for display only). Unbound axes carry no chroma.
|
|
306
|
+
function chromaForPolicy(s: RotateStart, newHue: number): number {
|
|
307
|
+
return absoluteChroma ? s.chroma0 : s.rFrac0 * maxChroma(s.l0, newHue);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Per-axis rotate — detaches harmony to custom. Bound axes select + re-hue their
|
|
311
|
+
// family (via setAxisHue's coherent write); unbound axes move only their hue.
|
|
312
|
+
function startAxisDrag(e: PointerEvent, axis: AxisView) {
|
|
313
|
+
if (e.button !== 0) return;
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
if (axis.family !== null) onSelect(axis.family);
|
|
316
|
+
onCustomize();
|
|
317
|
+
capture(e);
|
|
318
|
+
openGesture(`colors: ${axis.role} axis rotate`);
|
|
319
|
+
drag = { kind: 'axis', index: axis.index, angle: pointerAngle(e), start: startOf(axis) };
|
|
320
|
+
applyAxis(e);
|
|
321
|
+
}
|
|
322
|
+
function applyAxis(e: PointerEvent) {
|
|
323
|
+
if (drag?.kind !== 'axis') return;
|
|
324
|
+
const angle = pointerAngle(e);
|
|
325
|
+
drag.angle = angle;
|
|
326
|
+
const { start } = drag;
|
|
327
|
+
setAxisHue(drag.index, angle, start.bound ? chromaForPolicy(start, angle) : undefined);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Global rotate — every axis hue by the same accumulated delta (mode kept).
|
|
331
|
+
function startGlobalDrag(e: PointerEvent) {
|
|
332
|
+
if (e.button !== 0) return;
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
capture(e);
|
|
335
|
+
openGesture('colors: rotate all');
|
|
336
|
+
// Sparse by axis index: a hidden axis takes no part in the spin.
|
|
337
|
+
const start: RotateStart[] = [];
|
|
338
|
+
for (const t of visibleRender) start[t.index] = startOf(t);
|
|
339
|
+
const a = pointerAngle(e);
|
|
340
|
+
drag = { kind: 'global', start, delta: 0, lastAngle: a, angle: a };
|
|
341
|
+
}
|
|
342
|
+
function applyGlobal(e: PointerEvent) {
|
|
343
|
+
if (drag?.kind !== 'global') return;
|
|
344
|
+
const a = pointerAngle(e);
|
|
345
|
+
drag.delta += angleDelta(a, drag.lastAngle);
|
|
346
|
+
drag.lastAngle = a;
|
|
347
|
+
drag.angle = a;
|
|
348
|
+
const { delta } = drag;
|
|
349
|
+
drag.start.forEach((s, index) => {
|
|
350
|
+
const newHue = s.hue0 + delta;
|
|
351
|
+
setAxisHue(index, newHue, s.bound ? chromaForPolicy(s, newHue) : undefined);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Rail-constrained chroma (bound axes only) — 1D radial along the rail, hue
|
|
356
|
+
// PINNED to hue0 for the whole gesture (render + write) so it can't wobble off.
|
|
357
|
+
function startChromaDrag(e: PointerEvent, axis: AxisView) {
|
|
358
|
+
if (e.button !== 0 || axis.family === null) return;
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
onSelect(axis.family);
|
|
361
|
+
capture(e);
|
|
362
|
+
openGesture(`colors: ${axis.family} chroma`);
|
|
363
|
+
drag = {
|
|
364
|
+
kind: 'chroma', index: axis.index, family: axis.family, hue0: axis.hue, l0: axis.lightness,
|
|
365
|
+
maxC0: maxChroma(axis.lightness, axis.hue) || 1e-6, rFrac: axis.rFrac,
|
|
366
|
+
};
|
|
367
|
+
applyChroma(e);
|
|
368
|
+
}
|
|
369
|
+
function applyChroma(e: PointerEvent) {
|
|
370
|
+
if (drag?.kind !== 'chroma') return;
|
|
371
|
+
const r = wrapper!.getBoundingClientRect();
|
|
372
|
+
const dx = e.clientX - (r.left + r.width / 2);
|
|
373
|
+
const dy = e.clientY - (r.top + r.height / 2);
|
|
374
|
+
const proj = dx * Math.cos(rad(drag.hue0)) - dy * Math.sin(rad(drag.hue0)); // onto the pinned rail
|
|
375
|
+
const rFrac = clamp(proj / (r.width / 2 - MARGIN), 0, 1);
|
|
376
|
+
drag.rFrac = rFrac;
|
|
377
|
+
setBaseHueChroma(drag.family, drag.hue0, rFrac * drag.maxC0);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Unconstrained 2D drag for the off-axis selected family: angle → hue,
|
|
381
|
+
// radius → chroma fraction of the gamut at the pristine lightness.
|
|
382
|
+
function startFreeDrag(e: PointerEvent) {
|
|
383
|
+
if (e.button !== 0 || !freeDot) return;
|
|
384
|
+
e.preventDefault();
|
|
385
|
+
capture(e);
|
|
386
|
+
openGesture(`colors: ${freeDot.family} base`);
|
|
387
|
+
drag = {
|
|
388
|
+
kind: 'free', family: freeDot.family, l0: freeDot.lightness,
|
|
389
|
+
hue: freeDot.hue, rFrac: clamp(freeDot.chroma / (maxChroma(freeDot.lightness, freeDot.hue) || 1e-6), 0, 1),
|
|
390
|
+
};
|
|
391
|
+
applyFree(e);
|
|
392
|
+
}
|
|
393
|
+
function applyFree(e: PointerEvent) {
|
|
394
|
+
if (drag?.kind !== 'free') return;
|
|
395
|
+
const r = wrapper!.getBoundingClientRect();
|
|
396
|
+
const dx = e.clientX - (r.left + r.width / 2);
|
|
397
|
+
const dy = e.clientY - (r.top + r.height / 2);
|
|
398
|
+
const hue = normDeg((Math.atan2(-dy, dx) * 180) / Math.PI);
|
|
399
|
+
const rFrac = clamp(Math.sqrt(dx * dx + dy * dy) / (r.width / 2 - MARGIN), 0, 1);
|
|
400
|
+
drag.hue = hue;
|
|
401
|
+
drag.rFrac = rFrac;
|
|
402
|
+
setBaseHueChroma(drag.family, hue, rFrac * maxChroma(drag.l0, hue));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function moveDrag(e: PointerEvent) {
|
|
406
|
+
if (!drag) return;
|
|
407
|
+
if (drag.kind === 'axis') applyAxis(e);
|
|
408
|
+
else if (drag.kind === 'global') applyGlobal(e);
|
|
409
|
+
else if (drag.kind === 'free') applyFree(e);
|
|
410
|
+
else applyChroma(e);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function rotateAll(delta: number) {
|
|
414
|
+
const entries = axisData.filter(isVisible).map((t) => ({
|
|
415
|
+
index: t.index,
|
|
416
|
+
hue: t.hue + delta,
|
|
417
|
+
// Unclamped intent: off holds the gamut fraction, on holds absolute chroma.
|
|
418
|
+
familyChroma: t.bound
|
|
419
|
+
? (absoluteChroma ? t.chroma : rFracOf(t) * maxChroma(t.lightness, normDeg(t.hue + delta)))
|
|
420
|
+
: undefined,
|
|
421
|
+
}));
|
|
422
|
+
setAxisHues(entries, 'colors: rotate all');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function axisKey(e: KeyboardEvent, axis: AxisView) {
|
|
426
|
+
let dir = 0;
|
|
427
|
+
if (e.key === 'ArrowLeft') dir = -1;
|
|
428
|
+
else if (e.key === 'ArrowRight') dir = 1;
|
|
429
|
+
else return;
|
|
430
|
+
e.preventDefault();
|
|
431
|
+
onCustomize();
|
|
432
|
+
const newHue = axis.hue + dir * HUE_STEP;
|
|
433
|
+
setAxisHue(axis.index, newHue, axis.bound ? chromaForPolicy(startOf(axis), newHue) : undefined);
|
|
434
|
+
}
|
|
435
|
+
function dotKey(e: KeyboardEvent, axis: AxisView) {
|
|
436
|
+
if (axis.family === null) return;
|
|
437
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setBaseChroma(axis.family, axis.chroma + CHROMA_STEP); }
|
|
438
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); setBaseChroma(axis.family, axis.chroma - CHROMA_STEP); }
|
|
439
|
+
}
|
|
440
|
+
function freeDotKey(e: KeyboardEvent) {
|
|
441
|
+
if (!freeDot) return;
|
|
442
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setBaseChroma(freeDot.family, freeDot.chroma + CHROMA_STEP); }
|
|
443
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); setBaseChroma(freeDot.family, freeDot.chroma - CHROMA_STEP); }
|
|
444
|
+
else if (e.key === 'ArrowLeft') { e.preventDefault(); setBaseHueChroma(freeDot.family, freeDot.hue - HUE_STEP, freeDot.chroma); }
|
|
445
|
+
else if (e.key === 'ArrowRight') { e.preventDefault(); setBaseHueChroma(freeDot.family, freeDot.hue + HUE_STEP, freeDot.chroma); }
|
|
446
|
+
}
|
|
447
|
+
function globalKey(e: KeyboardEvent) {
|
|
448
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); rotateAll(-HUE_STEP); }
|
|
449
|
+
else if (e.key === 'ArrowRight') { e.preventDefault(); rotateAll(HUE_STEP); }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
onDestroy(() => {
|
|
453
|
+
if (dragScope) {
|
|
454
|
+
cancelScope(dragScope);
|
|
455
|
+
dragScope = null;
|
|
456
|
+
}
|
|
457
|
+
if (typeof window !== 'undefined') window.removeEventListener('keydown', onGestureKey, true);
|
|
458
|
+
});
|
|
459
|
+
</script>
|
|
460
|
+
|
|
461
|
+
<div class="wheel" bind:this={wrapper} style="max-width: {MAX_SIZE}px">
|
|
462
|
+
<canvas class="disc" bind:this={canvas} aria-hidden="true" style="width: {discDiameter}px; height: {discDiameter}px"></canvas>
|
|
463
|
+
|
|
464
|
+
<svg class="rails" viewBox="0 0 {wrapperSize} {wrapperSize}" width={wrapperSize} height={wrapperSize} aria-hidden="true">
|
|
465
|
+
<!-- Faint track the external axis handles ride along (their centers sit on
|
|
466
|
+
this radius). Decorative chrome, beneath the handles. -->
|
|
467
|
+
<circle class="track" cx={center} cy={center} r={extRadius} />
|
|
468
|
+
<!-- Dotted tether center→icon, drawn first so the solid rail below covers
|
|
469
|
+
its inner half — reads as one axis that extends outward as dots to the
|
|
470
|
+
external handle. Same angle as the handle, so it tracks live. -->
|
|
471
|
+
{#each visibleRender as t (t.index)}
|
|
472
|
+
<line class="tether" x1={center} y1={center} x2={t.ext.x} y2={t.ext.y} />
|
|
473
|
+
{/each}
|
|
474
|
+
{#each visibleRender as t (t.index)}
|
|
475
|
+
{#if t.bound}
|
|
476
|
+
<line class="rail" x1={center} y1={center} x2={t.dot.x} y2={t.dot.y} />
|
|
477
|
+
{/if}
|
|
478
|
+
{/each}
|
|
479
|
+
</svg>
|
|
480
|
+
|
|
481
|
+
{#each ghosts as g (g.index)}
|
|
482
|
+
<span class="ghost" class:unbound={!g.bound} style="left: {g.x}px; top: {g.y}px; --fill: {g.hex}" aria-hidden="true"></span>
|
|
483
|
+
{/each}
|
|
484
|
+
|
|
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>
|
|
487
|
+
{/each}
|
|
488
|
+
|
|
489
|
+
{#each visibleRender as t (t.index)}
|
|
490
|
+
{#if t.bound && t.family !== null}
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
class="dot"
|
|
494
|
+
class:selected={t.selected}
|
|
495
|
+
style="left: {t.dot.x}px; top: {t.dot.y}px; --fill: {t.hex}"
|
|
496
|
+
aria-label={`${t.family} — drag along rail to adjust chroma`}
|
|
497
|
+
title={`${t.family} (drag for chroma)`}
|
|
498
|
+
onpointerdown={(e) => startChromaDrag(e, t)}
|
|
499
|
+
onpointermove={moveDrag}
|
|
500
|
+
onpointerup={endDrag}
|
|
501
|
+
onpointercancel={endDrag}
|
|
502
|
+
onlostpointercapture={endDrag}
|
|
503
|
+
onkeydown={(e) => dotKey(e, t)}
|
|
504
|
+
onclick={() => t.family !== null && onSelect(t.family)}
|
|
505
|
+
></button>
|
|
506
|
+
{/if}
|
|
507
|
+
{/each}
|
|
508
|
+
|
|
509
|
+
{#each visibleRender as t (t.index)}
|
|
510
|
+
<button
|
|
511
|
+
type="button"
|
|
512
|
+
class="ext-handle"
|
|
513
|
+
class:unbound={!t.bound}
|
|
514
|
+
class:selected={t.selected}
|
|
515
|
+
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.role} axis hue (${t.family})` : `Rotate ${t.role} axis hue`}
|
|
517
|
+
title={t.bound ? `Rotate ${t.role} axis hue (${t.family})` : `Rotate ${t.role} axis hue`}
|
|
518
|
+
onpointerdown={(e) => startAxisDrag(e, t)}
|
|
519
|
+
onpointermove={moveDrag}
|
|
520
|
+
onpointerup={endDrag}
|
|
521
|
+
onpointercancel={endDrag}
|
|
522
|
+
onlostpointercapture={endDrag}
|
|
523
|
+
onkeydown={(e) => axisKey(e, t)}
|
|
524
|
+
onclick={() => t.family !== null && onSelect(t.family)}
|
|
525
|
+
><i class="fas fa-arrows-left-right" aria-hidden="true"></i></button>
|
|
526
|
+
{/each}
|
|
527
|
+
|
|
528
|
+
{#if freeDot}
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
class="dot selected"
|
|
532
|
+
style="left: {freeDot.x}px; top: {freeDot.y}px; --fill: {freeDot.hex}"
|
|
533
|
+
aria-label={`${freeDot.family} — drag to adjust hue and chroma`}
|
|
534
|
+
title={`${freeDot.family} (drag freely)`}
|
|
535
|
+
onpointerdown={startFreeDrag}
|
|
536
|
+
onpointermove={moveDrag}
|
|
537
|
+
onpointerup={endDrag}
|
|
538
|
+
onpointercancel={endDrag}
|
|
539
|
+
onlostpointercapture={endDrag}
|
|
540
|
+
onkeydown={freeDotKey}
|
|
541
|
+
></button>
|
|
542
|
+
{/if}
|
|
543
|
+
|
|
544
|
+
<button
|
|
545
|
+
type="button"
|
|
546
|
+
class="global-handle"
|
|
547
|
+
style="left: {globalHandle.x}px; top: {globalHandle.y}px"
|
|
548
|
+
aria-label="Rotate all harmony colors together"
|
|
549
|
+
title="Rotate all together"
|
|
550
|
+
onpointerdown={startGlobalDrag}
|
|
551
|
+
onpointermove={moveDrag}
|
|
552
|
+
onpointerup={endDrag}
|
|
553
|
+
onpointercancel={endDrag}
|
|
554
|
+
onlostpointercapture={endDrag}
|
|
555
|
+
onkeydown={globalKey}
|
|
556
|
+
><i class="fas fa-arrows-rotate" aria-hidden="true"></i></button>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
<style>
|
|
560
|
+
.wheel {
|
|
561
|
+
position: relative;
|
|
562
|
+
width: 100%;
|
|
563
|
+
aspect-ratio: 1;
|
|
564
|
+
margin: 0 auto;
|
|
565
|
+
touch-action: none;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.disc {
|
|
569
|
+
position: absolute;
|
|
570
|
+
top: 50%;
|
|
571
|
+
left: 50%;
|
|
572
|
+
transform: translate(-50%, -50%);
|
|
573
|
+
border-radius: var(--ui-radius-full);
|
|
574
|
+
border: 1px solid var(--ui-border-low);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.rails {
|
|
578
|
+
position: absolute;
|
|
579
|
+
inset: 0;
|
|
580
|
+
pointer-events: none;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
.rail {
|
|
584
|
+
stroke: var(--ui-text-primary);
|
|
585
|
+
stroke-width: 2;
|
|
586
|
+
opacity: 0.65;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/* Dotted continuation of the axis out to the external handle. Greyscale. */
|
|
590
|
+
.tether {
|
|
591
|
+
stroke: var(--ui-text-secondary);
|
|
592
|
+
stroke-width: 1.5;
|
|
593
|
+
stroke-dasharray: 2 4;
|
|
594
|
+
stroke-linecap: round;
|
|
595
|
+
opacity: 0.9;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/* Faint ring the external axis handles ride along. Decorative. */
|
|
599
|
+
.track {
|
|
600
|
+
fill: none;
|
|
601
|
+
stroke: var(--ui-border);
|
|
602
|
+
stroke-width: 1;
|
|
603
|
+
opacity: 0.6;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/* Preview dots for a hovered harmony mode. Dashed, translucent, no pointer
|
|
607
|
+
events — a hint at the would-be geometry, not a target. */
|
|
608
|
+
.ghost {
|
|
609
|
+
position: absolute;
|
|
610
|
+
transform: translate(-50%, -50%);
|
|
611
|
+
width: 1rem;
|
|
612
|
+
height: 1rem;
|
|
613
|
+
border-radius: var(--ui-radius-full);
|
|
614
|
+
background: var(--fill);
|
|
615
|
+
border: 2px dashed var(--ui-text-primary);
|
|
616
|
+
opacity: 0.55;
|
|
617
|
+
pointer-events: none;
|
|
618
|
+
z-index: 1;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/* Unbound-axis ghost: a hollow greyscale marker on the external track (no color
|
|
622
|
+
to preview, only where a dropped color's hue would land). */
|
|
623
|
+
.ghost.unbound {
|
|
624
|
+
width: 0.7rem;
|
|
625
|
+
height: 0.7rem;
|
|
626
|
+
background: transparent;
|
|
627
|
+
border-color: var(--ui-text-secondary);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/* Inner color dots — the only elements that carry actual palette colour. */
|
|
631
|
+
.dot {
|
|
632
|
+
position: absolute;
|
|
633
|
+
transform: translate(-50%, -50%);
|
|
634
|
+
width: 1rem;
|
|
635
|
+
height: 1rem;
|
|
636
|
+
padding: 0;
|
|
637
|
+
border-radius: var(--ui-radius-full);
|
|
638
|
+
background: var(--fill);
|
|
639
|
+
border: 2px solid var(--ui-text-primary);
|
|
640
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5);
|
|
641
|
+
cursor: grab;
|
|
642
|
+
touch-action: none;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.dot.selected {
|
|
646
|
+
width: 1.35rem;
|
|
647
|
+
height: 1.35rem;
|
|
648
|
+
border-width: 3px;
|
|
649
|
+
z-index: 2;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.dot:active {
|
|
653
|
+
cursor: grabbing;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/* External rotate handles — greyscale chrome. */
|
|
657
|
+
.ext-handle,
|
|
658
|
+
.global-handle {
|
|
659
|
+
position: absolute;
|
|
660
|
+
transform: translate(-50%, -50%);
|
|
661
|
+
display: flex;
|
|
662
|
+
align-items: center;
|
|
663
|
+
justify-content: center;
|
|
664
|
+
padding: 0;
|
|
665
|
+
border-radius: var(--ui-radius-full);
|
|
666
|
+
background: var(--ui-surface-low);
|
|
667
|
+
color: var(--ui-text-secondary);
|
|
668
|
+
border: 1px solid var(--ui-border-high);
|
|
669
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
|
670
|
+
cursor: grab;
|
|
671
|
+
touch-action: none;
|
|
672
|
+
z-index: 3;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.ext-handle {
|
|
676
|
+
width: 1.25rem;
|
|
677
|
+
height: 1.25rem;
|
|
678
|
+
font-size: var(--ui-font-size-xs);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.global-handle {
|
|
682
|
+
width: 1.6rem;
|
|
683
|
+
height: 1.6rem;
|
|
684
|
+
font-size: var(--ui-font-size-sm);
|
|
685
|
+
color: var(--ui-text-primary);
|
|
686
|
+
border-color: var(--ui-border-higher);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.ext-handle:hover,
|
|
690
|
+
.global-handle:hover {
|
|
691
|
+
color: var(--ui-text-primary);
|
|
692
|
+
border-color: var(--ui-border-higher);
|
|
693
|
+
background: var(--ui-surface-high);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.ext-handle:active,
|
|
697
|
+
.global-handle:active {
|
|
698
|
+
cursor: grabbing;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.ext-handle.selected {
|
|
702
|
+
color: var(--ui-text-primary);
|
|
703
|
+
border-color: var(--ui-text-primary);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/* Unbound axis handle: hollow + dashed so it reads as an empty preview slot,
|
|
707
|
+
not a live color handle. Shape carries the distinction — never opacity. */
|
|
708
|
+
.ext-handle.unbound {
|
|
709
|
+
background: transparent;
|
|
710
|
+
border-style: dashed;
|
|
711
|
+
border-color: var(--ui-border-high);
|
|
712
|
+
color: var(--ui-text-secondary);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.ext-handle.unbound:hover {
|
|
716
|
+
background: var(--ui-surface-low);
|
|
717
|
+
border-color: var(--ui-border-higher);
|
|
718
|
+
color: var(--ui-text-primary);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/* Axis numeral just outside the handle track. Positioned only — never rotated,
|
|
722
|
+
so it stays upright at any angle. */
|
|
723
|
+
.axis-num {
|
|
724
|
+
position: absolute;
|
|
725
|
+
transform: translate(-50%, -50%);
|
|
726
|
+
font-size: var(--ui-font-size-xs);
|
|
727
|
+
font-weight: var(--ui-font-weight-semibold);
|
|
728
|
+
color: var(--ui-text-primary);
|
|
729
|
+
pointer-events: none;
|
|
730
|
+
user-select: none;
|
|
731
|
+
z-index: 3;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.dot:focus-visible,
|
|
735
|
+
.ext-handle:focus-visible,
|
|
736
|
+
.global-handle:focus-visible {
|
|
737
|
+
outline: 2px solid var(--ui-border-higher);
|
|
738
|
+
outline-offset: 2px;
|
|
739
|
+
}
|
|
740
|
+
</style>
|