@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.
Files changed (49) hide show
  1. package/.claude/skills/live-tokens-create-page/SKILL.md +10 -3
  2. package/.claude/skills/live-tokens-set-type/SKILL.md +3 -3
  3. package/CHANGELOG.md +171 -0
  4. package/bin/lib/tokenVocabulary.mjs +2 -2
  5. package/bin/rules/testRuns.mjs +1 -1
  6. package/bin/rules/tokens.mjs +3 -3
  7. package/dist-plugin/chunk-7XSUHJUH.js +1652 -0
  8. package/dist-plugin/index.cjs +950 -15
  9. package/dist-plugin/index.js +1 -1
  10. package/dist-plugin/setType/index.cjs +23 -2
  11. package/dist-plugin/setType/index.js +23 -2
  12. package/dist-plugin/tokensCssMigrations/index.cjs +954 -15
  13. package/dist-plugin/tokensCssMigrations/index.d.cts +23 -3
  14. package/dist-plugin/tokensCssMigrations/index.d.ts +23 -3
  15. package/dist-plugin/tokensCssMigrations/index.js +5 -1
  16. package/package.json +1 -1
  17. package/src/app/site.css +243 -9
  18. package/src/demo/TestingLoops.svelte +9 -9
  19. package/src/editor/core/fonts/weightCoverage.ts +31 -2
  20. package/src/editor/core/palettes/tokenRegistry.ts +36 -37
  21. package/src/editor/core/store/editorKeybindings.ts +1 -1
  22. package/src/editor/core/themes/parsers/mediaBlocks.ts +46 -0
  23. package/src/editor/core/typeScale/typeScale.ts +341 -0
  24. package/src/editor/skill-atlas/SourcePane.svelte +3 -3
  25. package/src/editor/skill-atlas/skillSources.generated.ts +2 -2
  26. package/src/editor/skill-atlas/trees/create-page.ts +14 -14
  27. package/src/editor/skill-atlas/trees/set-type.ts +2 -2
  28. package/src/editor/ui/UIFontFamilySelector.svelte +5 -1
  29. package/src/editor/ui/UIFontWeightSelector.svelte +4 -0
  30. package/src/editor/ui/UILetterSpacingSelector.svelte +4 -0
  31. package/src/editor/ui/UILineHeightSelector.svelte +4 -0
  32. package/src/editor/ui/UITextTransformSelector.svelte +4 -0
  33. package/src/editor/ui/UIVariantSelector.svelte +6 -8
  34. package/src/editor/ui/VariablesTab.svelte +4 -4
  35. package/src/editor/ui/colors/ColorsTab.svelte +99 -5
  36. package/src/editor/ui/colors/paletteBaseColor.ts +17 -0
  37. package/src/editor/ui/sections/textStyles.ts +44 -92
  38. package/src/editor/ui/text-styles/ControlBody.svelte +100 -0
  39. package/src/editor/ui/text-styles/ResetIcon.svelte +60 -0
  40. package/src/editor/ui/text-styles/StepRow.svelte +330 -0
  41. package/src/editor/ui/text-styles/StyleControl.svelte +128 -0
  42. package/src/editor/ui/text-styles/TextStylesSection.svelte +551 -0
  43. package/src/editor/ui/text-styles/textStyleEdits.ts +308 -0
  44. package/src/editor/ui/variantScales.ts +16 -0
  45. package/src/system/styles/tokens.css +412 -48
  46. package/src/testing-js/page-compliance.contract.js +233 -80
  47. package/src/testing-js/page-compliance.contract.js.map +1 -1
  48. package/dist-plugin/chunk-GWMEYBZ2.js +0 -715
  49. package/src/editor/ui/TextStylesSection.svelte +0 -171
@@ -0,0 +1,128 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import ControlBody from './ControlBody.svelte';
4
+ import ResetIcon from './ResetIcon.svelte';
5
+
6
+ interface Link {
7
+ linked: boolean;
8
+ /** What the linked value shows, such as the usage's face. */
9
+ text: string;
10
+ /** The link expression, shown as the linked value's tooltip. */
11
+ title: string;
12
+ ontoggle: () => void;
13
+ }
14
+
15
+ interface Props {
16
+ label: string;
17
+ /** Names the control in accessible labels, such as `heading-xl face`. */
18
+ name: string;
19
+ width: string;
20
+ resetVisible: boolean;
21
+ onreset: () => void;
22
+ link?: Link;
23
+ children: Snippet;
24
+ }
25
+
26
+ let { label, name, width, resetVisible, onreset, link, children }: Props = $props();
27
+ </script>
28
+
29
+ <div class="ctl" data-reset-scope>
30
+ <div class="ctl-head">
31
+ <span class="ctl-label">{label}</span>
32
+ <ResetIcon label={name} visible={resetVisible} {onreset} />
33
+ </div>
34
+ <ControlBody {width} linked={link ? link.linked : null}>
35
+ {#if link?.linked}
36
+ <span class="linked-value" title={link.title}>{link.text}</span>
37
+ {:else}
38
+ {@render children()}
39
+ {/if}
40
+ {#snippet after()}
41
+ {#if link}
42
+ <button
43
+ type="button"
44
+ class="lock"
45
+ class:is-unlinked={!link.linked}
46
+ aria-pressed={link.linked}
47
+ aria-label="{link.linked ? 'Unlink' : 'Relink'} {name}"
48
+ title={link.linked ? 'Unlink' : 'Relink'}
49
+ onclick={link.ontoggle}
50
+ >
51
+ <i class="fas {link.linked ? 'fa-lock' : 'fa-lock-open'}" aria-hidden="true"></i>
52
+ </button>
53
+ {/if}
54
+ {/snippet}
55
+ </ControlBody>
56
+ </div>
57
+
58
+ <style>
59
+ .ctl {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: var(--ui-space-4);
63
+ min-width: 0;
64
+ }
65
+
66
+ /* The label line has a fixed height and the reset icon trails the label, so
67
+ showing or hiding the icon moves nothing. */
68
+ .ctl-head {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: var(--ui-space-4);
72
+ height: 18px;
73
+ min-width: 0;
74
+ }
75
+
76
+ .ctl-label {
77
+ font-size: var(--ui-font-size-xs);
78
+ font-weight: var(--ui-font-weight-semibold);
79
+ color: var(--ui-text-secondary);
80
+ white-space: nowrap;
81
+ }
82
+
83
+ .linked-value {
84
+ box-sizing: border-box;
85
+ display: block;
86
+ height: 1.75rem;
87
+ padding: 0 var(--ui-space-8);
88
+ background: var(--ui-surface-higher);
89
+ border: 1px solid var(--ui-border-low);
90
+ border-left: none;
91
+ border-radius: 0 var(--ui-radius-md) var(--ui-radius-md) 0;
92
+ color: var(--ui-text-secondary);
93
+ font-size: var(--ui-font-size-sm);
94
+ line-height: calc(1.75rem - 2px);
95
+ white-space: nowrap;
96
+ overflow: hidden;
97
+ text-overflow: ellipsis;
98
+ }
99
+
100
+ .lock {
101
+ flex: none;
102
+ display: inline-grid;
103
+ place-items: center;
104
+ width: 24px;
105
+ height: 24px;
106
+ padding: 0;
107
+ background: none;
108
+ border: none;
109
+ border-radius: var(--ui-radius-md);
110
+ color: var(--ui-text-tertiary);
111
+ font-size: 0.8125rem;
112
+ cursor: pointer;
113
+ }
114
+
115
+ .lock:hover {
116
+ color: var(--ui-text-primary);
117
+ background: var(--ui-surface-higher);
118
+ }
119
+
120
+ .lock:focus-visible {
121
+ outline: 2px solid var(--ui-text-primary);
122
+ outline-offset: 1px;
123
+ }
124
+
125
+ .lock.is-unlinked {
126
+ color: var(--ui-link-broken);
127
+ }
128
+ </style>
@@ -0,0 +1,551 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import { beginSliderGesture, editorState } from '../../core/store/editorStore';
4
+ import { getDeclaredValue } from '../../core/palettes/tokenRegistry';
5
+ import {
6
+ COMPRESSION_RANGE,
7
+ CUSTOM_RATIO_RANGE,
8
+ EDITABLE_NAMES,
9
+ INTERVALS,
10
+ ROOT_PX,
11
+ STEPS,
12
+ USAGES,
13
+ compressionName,
14
+ formatNumber,
15
+ formatRem,
16
+ usageName,
17
+ type Usage,
18
+ type Viewport,
19
+ } from '../../core/typeScale/typeScale';
20
+ import UIFontFamilySelector from '../UIFontFamilySelector.svelte';
21
+ import UIFontWeightSelector from '../UIFontWeightSelector.svelte';
22
+ import UIPillButton from '../UIPillButton.svelte';
23
+ import UISegmentedControl from '../UISegmentedControl.svelte';
24
+ import { TEXT_STYLES, type TextStyle } from '../sections/textStyles';
25
+ import ResetIcon from './ResetIcon.svelte';
26
+ import StepRow from './StepRow.svelte';
27
+ import StyleControl from './StyleControl.svelte';
28
+ import {
29
+ BASE_RANGE_PX,
30
+ COMPRESSED,
31
+ EYEBROW,
32
+ compressionEffect,
33
+ compressionOf,
34
+ hasStored,
35
+ intervalOf,
36
+ isStored,
37
+ ratioLabel,
38
+ settingsOf,
39
+ stepTarget,
40
+ textStyleEdits,
41
+ usageKeys,
42
+ type TokenView,
43
+ } from './textStyleEdits';
44
+
45
+ const edits = textStyleEdits(getDeclaredValue);
46
+ const uid = $props.id();
47
+
48
+ const USAGE_LABEL: Record<Usage, string> = {
49
+ display: 'Display',
50
+ heading: 'Heading',
51
+ body: 'Body',
52
+ editorial: 'Editorial',
53
+ code: 'Code',
54
+ };
55
+ const VIEWPORT_OPTIONS: { value: Viewport; label: string }[] = [
56
+ { value: 'desktop', label: 'Desktop' },
57
+ { value: 'tablet', label: 'Tablet' },
58
+ { value: 'phone', label: 'Phone' },
59
+ ];
60
+ const STEPS_DOWN = [...STEPS].reverse();
61
+ const STYLE_BY_PREFIX = new Map<string, TextStyle>(TEXT_STYLES.map((s) => [s.prefix, s]));
62
+ const styleOf = (prefix: string): TextStyle => STYLE_BY_PREFIX.get(prefix)!;
63
+
64
+ let active: Usage = $state('heading');
65
+ let viewport: Viewport = $state('desktop');
66
+ // A ratio that matches an interval shows that interval until the user picks Custom.
67
+ let customRatio: Partial<Record<Usage, boolean>> = $state({});
68
+
69
+ let view: TokenView = $derived({ stored: $editorState.cssVars, declared: getDeclaredValue });
70
+ let settings = $derived(settingsOf(view, active));
71
+ let ratioMode = $derived(customRatio[active] ? 'custom' : intervalOf(settings.ratio).key);
72
+
73
+ async function selectTab(usage: Usage) {
74
+ active = usage;
75
+ await tick();
76
+ document.getElementById(`${uid}-tab-${usage}`)?.focus();
77
+ }
78
+
79
+ const TAB_MOVES: Record<string, (i: number) => number> = {
80
+ ArrowRight: (i) => (i + 1) % USAGES.length,
81
+ ArrowLeft: (i) => (i - 1 + USAGES.length) % USAGES.length,
82
+ Home: () => 0,
83
+ End: () => USAGES.length - 1,
84
+ };
85
+
86
+ function onTabKey(e: KeyboardEvent) {
87
+ const move = TAB_MOVES[e.key];
88
+ if (!move) return;
89
+ e.preventDefault();
90
+ selectTab(USAGES[move(USAGES.indexOf(active))]);
91
+ }
92
+
93
+ function chooseRatio(key: string) {
94
+ const interval = INTERVALS.find((i) => i.key === key);
95
+ customRatio[active] = interval?.ratio === null;
96
+ if (interval?.ratio != null) edits.setRatio(active, interval.ratio);
97
+ }
98
+
99
+ function resetRatio() {
100
+ customRatio[active] = false;
101
+ edits.resetKey(usageName(active, 'scale-ratio'));
102
+ }
103
+
104
+ function resetUsage() {
105
+ customRatio[active] = false;
106
+ edits.resetUsage(active);
107
+ }
108
+
109
+ function resetAll() {
110
+ customRatio = {};
111
+ edits.resetAll();
112
+ }
113
+
114
+ // A clamped or unchanged value would otherwise stay in the input as typed.
115
+ function editBase(input: HTMLInputElement) {
116
+ edits.setBase(active, input.valueAsNumber);
117
+ input.value = formatNumber(settingsOf(edits.view(), active).base * ROOT_PX);
118
+ }
119
+
120
+ function editCustomRatio(input: HTMLInputElement) {
121
+ edits.setRatio(active, input.valueAsNumber);
122
+ input.value = formatNumber(settingsOf(edits.view(), active).ratio);
123
+ }
124
+
125
+ const writeSetting = (name: string) => (value: string | null) => edits.writeToken(name, value);
126
+ </script>
127
+
128
+ <div class="group-head">
129
+ <h3 class="group-title">Text Styles</h3>
130
+ <UIPillButton icon="fa-rotate-left" disabled={!hasStored(view, EDITABLE_NAMES)} onclick={resetAll}>
131
+ Reset all
132
+ </UIPillButton>
133
+ </div>
134
+
135
+ <div class="scaling">
136
+ <h4 class="sub-title scaling-wide">Responsive scaling</h4>
137
+ {#each COMPRESSED as vp (vp)}
138
+ {@const c = compressionOf(view, vp)}
139
+ {@const id = `${uid}-${vp}-scaling`}
140
+ <div class="scaling-field" data-reset-scope>
141
+ <div class="scaling-top">
142
+ <div class="ctl-head">
143
+ <label class="field-label" for={id}>{vp === 'tablet' ? 'Tablet' : 'Phone'} scaling</label>
144
+ <ResetIcon
145
+ label="{vp} scaling"
146
+ visible={isStored(view, compressionName(vp))}
147
+ onreset={() => edits.resetKey(compressionName(vp))}
148
+ />
149
+ </div>
150
+ <span class="scaling-value">{c.toFixed(2)}</span>
151
+ </div>
152
+ <input
153
+ {id}
154
+ type="range"
155
+ min={COMPRESSION_RANGE.min}
156
+ max={COMPRESSION_RANGE.max}
157
+ step="0.01"
158
+ value={c}
159
+ onpointerdown={() => beginSliderGesture(`drag ${vp} scaling`)}
160
+ oninput={(e) => edits.setCompression(vp, e.currentTarget.valueAsNumber)}
161
+ />
162
+ <span class="scaling-effect">{compressionEffect(c)}</span>
163
+ </div>
164
+ {/each}
165
+ <p class="hint scaling-wide">
166
+ Sets the recommended tablet and phone size for every step. Sizes above 16px move toward 16px.
167
+ Lower values fit large type to small screens and narrow the gap between levels, and higher values
168
+ keep more of the desktop hierarchy. Every recommendation stays at or below its desktop size.
169
+ </p>
170
+ </div>
171
+
172
+ <div>
173
+ <div class="tabs" role="tablist" aria-label="Usages">
174
+ {#each USAGES as usage (usage)}
175
+ {@const selected = usage === active}
176
+ <button
177
+ type="button"
178
+ role="tab"
179
+ class="tab"
180
+ id="{uid}-tab-{usage}"
181
+ aria-selected={selected}
182
+ aria-controls="{uid}-panel"
183
+ tabindex={selected ? 0 : -1}
184
+ onclick={() => selectTab(usage)}
185
+ onkeydown={onTabKey}
186
+ >
187
+ {USAGE_LABEL[usage]}
188
+ <span class="tab-ratio">{ratioLabel(settingsOf(view, usage).ratio)}</span>
189
+ </button>
190
+ {/each}
191
+ </div>
192
+
193
+ <div class="tab-panel" id="{uid}-panel" role="tabpanel" aria-labelledby="{uid}-tab-{active}">
194
+ {#key active}
195
+ <div class="scale">
196
+ <StyleControl
197
+ label="Face"
198
+ name="{active} face"
199
+ width="10rem"
200
+ resetVisible={isStored(view, usageName(active, 'font-family'))}
201
+ onreset={() => edits.resetKey(usageName(active, 'font-family'))}
202
+ >
203
+ <UIFontFamilySelector
204
+ variable={usageName(active, 'font-family')}
205
+ onwrite={writeSetting(usageName(active, 'font-family'))}
206
+ />
207
+ </StyleControl>
208
+ <StyleControl
209
+ label="Weight"
210
+ name="{active} weight"
211
+ width="8rem"
212
+ resetVisible={isStored(view, usageName(active, 'font-weight'))}
213
+ onreset={() => edits.resetKey(usageName(active, 'font-weight'))}
214
+ >
215
+ <UIFontWeightSelector
216
+ variable={usageName(active, 'font-weight')}
217
+ onwrite={writeSetting(usageName(active, 'font-weight'))}
218
+ />
219
+ </StyleControl>
220
+ <div class="field" data-reset-scope>
221
+ <div class="ctl-head">
222
+ <label class="field-label" for="{uid}-base">Base</label>
223
+ <ResetIcon
224
+ label="{active} base"
225
+ visible={isStored(view, usageName(active, 'scale-base'))}
226
+ onreset={() => edits.resetKey(usageName(active, 'scale-base'))}
227
+ />
228
+ </div>
229
+ <div class="inline">
230
+ <input
231
+ id="{uid}-base"
232
+ class="num-input"
233
+ type="number"
234
+ min={BASE_RANGE_PX.min}
235
+ max={BASE_RANGE_PX.max}
236
+ step="any"
237
+ value={formatNumber(settings.base * ROOT_PX)}
238
+ onchange={(e) => editBase(e.currentTarget)}
239
+ />
240
+ <span class="unit">px</span>
241
+ <span class="rem">{formatRem(settings.base)}</span>
242
+ </div>
243
+ </div>
244
+ <div class="field" data-reset-scope>
245
+ <div class="ctl-head">
246
+ <label class="field-label" for="{uid}-ratio">Ratio</label>
247
+ <ResetIcon
248
+ label="{active} ratio"
249
+ visible={isStored(view, usageName(active, 'scale-ratio'))}
250
+ onreset={resetRatio}
251
+ />
252
+ </div>
253
+ <div class="inline">
254
+ <select
255
+ id="{uid}-ratio"
256
+ class="select-input"
257
+ value={ratioMode}
258
+ onchange={(e) => chooseRatio(e.currentTarget.value)}
259
+ >
260
+ {#each INTERVALS as interval (interval.key)}
261
+ <option value={interval.key}>
262
+ {interval.ratio === null ? interval.label : `${interval.label} · ${interval.ratio}`}
263
+ </option>
264
+ {/each}
265
+ </select>
266
+ {#if ratioMode === 'custom'}
267
+ <input
268
+ class="num-input"
269
+ type="number"
270
+ min={CUSTOM_RATIO_RANGE.min}
271
+ max={CUSTOM_RATIO_RANGE.max}
272
+ step="any"
273
+ value={formatNumber(settings.ratio)}
274
+ aria-label="{USAGE_LABEL[active]} custom ratio"
275
+ onchange={(e) => editCustomRatio(e.currentTarget)}
276
+ />
277
+ {/if}
278
+ </div>
279
+ </div>
280
+ <p class="hint">
281
+ Desktop sizes follow from the base and ratio. Every step uses this face and weight until you unlink it.
282
+ </p>
283
+ <UIPillButton icon="fa-rotate-left" disabled={!hasStored(view, usageKeys(active))} onclick={resetUsage}>
284
+ Reset {USAGE_LABEL[active]}
285
+ </UIPillButton>
286
+ </div>
287
+
288
+ <div class="ramp-head">
289
+ <h4 class="sub-title">Steps</h4>
290
+ <UISegmentedControl bind:value={viewport} options={VIEWPORT_OPTIONS} ariaLabel="Preview viewport" />
291
+ </div>
292
+ <div class="ramp">
293
+ {#each STEPS_DOWN as step (step)}
294
+ {@const target = stepTarget(active, step)}
295
+ <StepRow {target} style={styleOf(target.prefix)} {view} {viewport} {edits} />
296
+ {/each}
297
+ </div>
298
+ {#if active === EYEBROW.usage}
299
+ <h4 class="sub-title">Eyebrow</h4>
300
+ <div class="ramp">
301
+ <StepRow target={EYEBROW} style={styleOf(EYEBROW.prefix)} {view} {viewport} {edits} />
302
+ </div>
303
+ {/if}
304
+ {/key}
305
+ </div>
306
+ </div>
307
+
308
+ <style>
309
+ .group-head {
310
+ display: flex;
311
+ align-items: center;
312
+ justify-content: space-between;
313
+ flex-wrap: wrap;
314
+ gap: var(--ui-space-12);
315
+ }
316
+
317
+ .group-title {
318
+ font-size: var(--ui-font-size-xl);
319
+ font-weight: var(--ui-font-weight-bold);
320
+ color: var(--ui-text-primary);
321
+ margin: 0;
322
+ }
323
+
324
+ .sub-title {
325
+ margin: 0;
326
+ font-size: var(--ui-font-size-md);
327
+ font-weight: var(--ui-font-weight-semibold);
328
+ color: var(--ui-text-primary);
329
+ }
330
+
331
+ .scaling {
332
+ display: flex;
333
+ flex-wrap: wrap;
334
+ gap: var(--ui-space-10) var(--ui-space-24);
335
+ padding: var(--ui-space-12) var(--ui-space-16);
336
+ background: var(--ui-surface-low);
337
+ border: 1px solid var(--ui-border-low);
338
+ border-radius: var(--ui-radius-lg);
339
+ }
340
+
341
+ .scaling-wide {
342
+ flex: 1 1 100%;
343
+ }
344
+
345
+ .scaling-field {
346
+ flex: 1 1 16rem;
347
+ display: grid;
348
+ gap: var(--ui-space-4);
349
+ min-width: 0;
350
+ }
351
+
352
+ .scaling-top {
353
+ display: flex;
354
+ justify-content: space-between;
355
+ align-items: center;
356
+ gap: var(--ui-space-8);
357
+ }
358
+
359
+ .scaling-value {
360
+ color: var(--ui-text-primary);
361
+ font-variant-numeric: tabular-nums;
362
+ font-weight: var(--ui-font-weight-semibold);
363
+ }
364
+
365
+ .scaling-field input[type="range"] {
366
+ width: 100%;
367
+ margin: 0;
368
+ accent-color: var(--ui-text-primary);
369
+ }
370
+
371
+ .scaling-effect {
372
+ color: var(--ui-text-tertiary);
373
+ font-size: var(--ui-font-size-xs);
374
+ font-variant-numeric: tabular-nums;
375
+ }
376
+
377
+ /* The active tab carries the bright underline the editor gives section
378
+ titles, sitting on the bar's own hairline. */
379
+ .tabs {
380
+ display: flex;
381
+ flex-wrap: wrap;
382
+ border-bottom: 1px solid var(--ui-border-low);
383
+ }
384
+
385
+ .tab {
386
+ display: flex;
387
+ flex-direction: column;
388
+ align-items: flex-start;
389
+ gap: var(--ui-space-2);
390
+ margin-bottom: -1px;
391
+ padding: var(--ui-space-10) var(--ui-space-20) var(--ui-space-8);
392
+ background: none;
393
+ border: none;
394
+ border-bottom: 2px solid transparent;
395
+ color: var(--ui-text-tertiary);
396
+ font: inherit;
397
+ font-size: var(--ui-font-size-md);
398
+ font-weight: var(--ui-font-weight-semibold);
399
+ text-align: left;
400
+ cursor: pointer;
401
+ transition:
402
+ color var(--ui-transition-fast),
403
+ border-color var(--ui-transition-fast);
404
+ }
405
+
406
+ .tab:hover {
407
+ color: var(--ui-text-primary);
408
+ border-bottom-color: var(--ui-border-high);
409
+ }
410
+
411
+ .tab[aria-selected="true"] {
412
+ color: var(--ui-text-primary);
413
+ border-bottom-color: var(--ui-text-primary);
414
+ }
415
+
416
+ .tab:focus-visible {
417
+ outline: 2px solid var(--ui-text-primary);
418
+ outline-offset: -2px;
419
+ }
420
+
421
+ .tab-ratio {
422
+ color: var(--ui-text-muted);
423
+ font-size: var(--ui-font-size-xs);
424
+ font-weight: var(--ui-font-weight-normal);
425
+ }
426
+
427
+ .tab[aria-selected="true"] .tab-ratio {
428
+ color: var(--ui-text-secondary);
429
+ }
430
+
431
+ .tab-panel {
432
+ display: flex;
433
+ flex-direction: column;
434
+ gap: var(--ui-space-16);
435
+ padding-top: var(--ui-space-16);
436
+ }
437
+
438
+ .scale {
439
+ display: flex;
440
+ flex-wrap: wrap;
441
+ align-items: flex-end;
442
+ gap: var(--ui-space-16);
443
+ padding: var(--ui-space-16);
444
+ background: var(--ui-surface-low);
445
+ border: 1px solid var(--ui-border-low);
446
+ border-radius: var(--ui-radius-lg);
447
+ }
448
+
449
+ .field {
450
+ display: flex;
451
+ flex-direction: column;
452
+ gap: var(--ui-space-4);
453
+ min-width: 0;
454
+ }
455
+
456
+ .ctl-head {
457
+ display: flex;
458
+ align-items: center;
459
+ gap: var(--ui-space-4);
460
+ height: 18px;
461
+ min-width: 0;
462
+ }
463
+
464
+ .field-label {
465
+ font-size: var(--ui-font-size-xs);
466
+ font-weight: var(--ui-font-weight-semibold);
467
+ color: var(--ui-text-secondary);
468
+ }
469
+
470
+ .inline {
471
+ display: flex;
472
+ align-items: center;
473
+ gap: var(--ui-space-6);
474
+ min-width: 0;
475
+ }
476
+
477
+ .num-input,
478
+ .select-input {
479
+ box-sizing: border-box;
480
+ height: 1.75rem;
481
+ min-width: 0;
482
+ padding: 0 var(--ui-space-8);
483
+ background: var(--ui-surface-high);
484
+ border: 1px solid var(--ui-border-low);
485
+ border-radius: var(--ui-radius-md);
486
+ color: var(--ui-text-primary);
487
+ font: inherit;
488
+ font-size: var(--ui-font-size-sm);
489
+ }
490
+
491
+ .num-input {
492
+ width: 5.5rem;
493
+ font-variant-numeric: tabular-nums;
494
+ }
495
+
496
+ .num-input:hover,
497
+ .select-input:hover {
498
+ border-color: var(--ui-border-high);
499
+ }
500
+
501
+ .num-input:focus-visible,
502
+ .select-input:focus-visible,
503
+ .scaling-field input[type="range"]:focus-visible {
504
+ outline: 2px solid var(--ui-text-primary);
505
+ outline-offset: 2px;
506
+ }
507
+
508
+ .unit {
509
+ flex: none;
510
+ color: var(--ui-text-muted);
511
+ font-size: var(--ui-font-size-xs);
512
+ }
513
+
514
+ .rem {
515
+ flex: none;
516
+ width: 9ch;
517
+ color: var(--ui-text-muted);
518
+ font-family: var(--ui-font-mono);
519
+ font-size: var(--ui-font-size-xs);
520
+ font-variant-numeric: tabular-nums;
521
+ }
522
+
523
+ .hint {
524
+ flex: 1 1 14rem;
525
+ margin: 0;
526
+ color: var(--ui-text-muted);
527
+ font-size: var(--ui-font-size-xs);
528
+ line-height: var(--ui-line-height-relaxed);
529
+ }
530
+
531
+ .ramp-head {
532
+ display: flex;
533
+ align-items: center;
534
+ justify-content: space-between;
535
+ flex-wrap: wrap;
536
+ gap: var(--ui-space-12);
537
+ }
538
+
539
+ /* No overflow clipping: the pickers' dropdowns spill past the frame. */
540
+ .ramp {
541
+ display: flex;
542
+ flex-direction: column;
543
+ border: 1px solid var(--ui-border);
544
+ }
545
+
546
+ @media (prefers-reduced-motion: reduce) {
547
+ .tab {
548
+ transition: none;
549
+ }
550
+ }
551
+ </style>