@ambientcss/components 2.0.1 → 3.0.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 (46) hide show
  1. package/README.md +35 -0
  2. package/dist/index.cjs +1563 -384
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +763 -48
  5. package/dist/index.d.ts +763 -48
  6. package/dist/index.js +1524 -385
  7. package/dist/index.js.map +1 -1
  8. package/dist/styles.css +1102 -256
  9. package/package.json +2 -2
  10. package/src/components/AmbientButton.tsx +25 -25
  11. package/src/components/AmbientFader.tsx +33 -115
  12. package/src/components/AmbientKnob.tsx +64 -222
  13. package/src/components/AmbientPanel.tsx +8 -2
  14. package/src/components/AmbientProvider.tsx +3 -0
  15. package/src/components/AmbientRack.tsx +32 -0
  16. package/src/components/AmbientSelect.tsx +40 -0
  17. package/src/components/AmbientSlider.tsx +32 -113
  18. package/src/components/AmbientSwitch.tsx +58 -58
  19. package/src/controls/AmbientBank.tsx +138 -0
  20. package/src/controls/AmbientLatch.tsx +78 -0
  21. package/src/controls/AmbientPress.tsx +74 -0
  22. package/src/controls/AmbientRotary.tsx +94 -0
  23. package/src/controls/AmbientTravel.tsx +83 -0
  24. package/src/core/context.tsx +46 -0
  25. package/src/core/controllable.ts +33 -0
  26. package/src/core/dev.ts +15 -0
  27. package/src/core/frames.tsx +63 -0
  28. package/src/core/kit.tsx +107 -0
  29. package/src/core/material.ts +27 -0
  30. package/src/core/numeric.ts +88 -0
  31. package/src/core/types.ts +116 -0
  32. package/src/core/useBank.ts +163 -0
  33. package/src/core/useLatch.ts +54 -0
  34. package/src/core/usePress.ts +120 -0
  35. package/src/core/useRotary.ts +253 -0
  36. package/src/core/useTravel.ts +141 -0
  37. package/src/index.ts +127 -4
  38. package/src/kits/console.tsx +80 -0
  39. package/src/kits/grounded.tsx +113 -0
  40. package/src/parts/bank.tsx +32 -0
  41. package/src/parts/console.tsx +99 -0
  42. package/src/parts/knob.tsx +203 -0
  43. package/src/parts/latch.tsx +33 -0
  44. package/src/parts/press.tsx +56 -0
  45. package/src/parts/travel.tsx +70 -0
  46. package/src/styles.css +1102 -256
package/dist/styles.css CHANGED
@@ -2,6 +2,22 @@
2
2
  --ambx-grid: 4px;
3
3
  --ambx-grid-half: calc(var(--ambx-grid) / 2);
4
4
  --ambx-grid-quarter: calc(var(--ambx-grid) / 4);
5
+ /* Border-radius scale: deliberately identical in px to
6
+ @ambientcss/css's frozen .amb-rounded/-md/-lg/-xl/-full utilities
7
+ (4/8/12/16/9999) — cross-layer coherence, not coincidence. Radius
8
+ stays fixed across size variants; only footprint scales with size. */
9
+ --ambx-radius-sm: calc(var(--ambx-grid) * 1);
10
+ --ambx-radius-md: calc(var(--ambx-grid) * 2);
11
+ --ambx-radius-lg: calc(var(--ambx-grid) * 3);
12
+ --ambx-radius-xl: calc(var(--ambx-grid) * 4);
13
+ --ambx-radius-full: 9999px;
14
+ /* Spacing/clearance scale for composing controls into a device face
15
+ (hardware-panel convention: a single modular pitch, tighter within a
16
+ functional cluster, looser between clusters/at the panel edge). Each
17
+ tier ~1.6-1.7x the last so they stay visually distinct at a glance. */
18
+ --ambx-gap-tight: calc(var(--ambx-grid) * 3);
19
+ --ambx-gap-normal: calc(var(--ambx-grid) * 5);
20
+ --ambx-gap-loose: calc(var(--ambx-grid) * 8);
5
21
  --ambx-type-1-size: calc(var(--ambx-grid) * 8);
6
22
  --ambx-type-1-line: calc(var(--ambx-grid) * 10);
7
23
  --ambx-type-2-size: calc(var(--ambx-grid) * 6);
@@ -12,6 +28,10 @@
12
28
  --ambx-label-size: calc(var(--ambx-grid) * 2 + var(--ambx-grid-half));
13
29
  --ambx-label-line: calc(var(--ambx-grid) * 3);
14
30
  --ambx-control-label-gap: calc(var(--ambx-grid) * 2);
31
+ /* The panel accent a kit paints its lit states with. Defaults to the
32
+ scene's own highlight so a kit inherits the app's colour rather than
33
+ imposing one. */
34
+ --ambx-accent: var(--amb-highlight-color);
15
35
  --ambx-knob-label-clearance: calc(var(--ambx-grid) * 2);
16
36
  --ambx-slider-label-clearance: calc(var(--ambx-grid) * 2);
17
37
  --ambx-fader-label-clearance: calc(var(--ambx-grid) * 4);
@@ -25,19 +45,11 @@
25
45
  rather than shipped as a global reset: apps that already reset globally
26
46
  (apps/demo does) see no change, and apps that do not are no longer
27
47
  silently broken. */
28
- .amb-button,
29
- .amb-knob,
30
- .amb-switch,
31
- .amb-fader,
32
- .amb-slider,
48
+ .ambx-control,
49
+ .ambx-control *,
33
50
  .ambx-panel,
34
- .ambx-stack,
35
- .amb-button *,
36
- .amb-knob *,
37
- .amb-switch *,
38
- .amb-fader *,
39
- .amb-slider *,
40
51
  .ambx-panel *,
52
+ .ambx-stack,
41
53
  .ambx-stack * {
42
54
  box-sizing: border-box;
43
55
  }
@@ -60,6 +72,256 @@
60
72
  letter-spacing: var(--ambx-type-tracking);
61
73
  }
62
74
 
75
+ /* ------------------------------------------------------------------ *\
76
+ The control base: state channel, frames, motion.
77
+
78
+ Every control publishes its state on its own root — as custom properties
79
+ for parts that are pure CSS, and as data attributes for the discrete
80
+ conditions a part cannot detect for itself. The properties INHERIT, which
81
+ is the whole point (any frame can read --ambx-angle without being handed
82
+ it), and is also why every one of them is reset here: a control nested
83
+ inside another control's frame would otherwise sweep along with its
84
+ ancestor.
85
+ \* ------------------------------------------------------------------ */
86
+
87
+ /* Registered so they interpolate. That is what turns "smooth or stepped"
88
+ into a CSS question rather than a JS animation loop — the same reason
89
+ @ambientcss/css registers --amb-elevation. */
90
+ @property --ambx-percent {
91
+ syntax: "<number>";
92
+ inherits: true;
93
+ initial-value: 0;
94
+ }
95
+
96
+ @property --ambx-angle {
97
+ syntax: "<angle>";
98
+ inherits: true;
99
+ initial-value: 0deg;
100
+ }
101
+
102
+ .ambx-control {
103
+ position: relative;
104
+ --ambx-value: 0;
105
+ --ambx-percent: 0;
106
+ --ambx-angle: 0deg;
107
+ --ambx-travel-start: 0deg;
108
+ --ambx-travel-sweep: 0deg;
109
+ --ambx-detents: 0;
110
+ }
111
+
112
+ .ambx-control:focus-visible {
113
+ outline-offset: var(--ambx-grid-half);
114
+ }
115
+
116
+ /* A frame is a MARKER by default, not a box: it contributes paint order and
117
+ the [data-frame] hook and nothing to layout. That default is load-bearing
118
+ rather than lazy — a button takes its height from its cap in normal flow
119
+ and its width from min-width PLUS the cap's legend, so wrapping the cap in
120
+ a positioned box would collapse the control to nothing. */
121
+ .ambx-frame {
122
+ display: contents;
123
+ }
124
+
125
+ /* Where the control has a size of its own, frames become boxes and hand
126
+ their part a correct one. A part that means to fill its frame does it at
127
+ `position: absolute; inset: 0` — which also blockifies it, where
128
+ `width/height: 100%` on a bare <span> would silently measure zero. */
129
+ .ambx-rotary > .ambx-frame,
130
+ .ambx-travel > .ambx-frame,
131
+ .ambx-latch > .ambx-frame {
132
+ display: block;
133
+ position: absolute;
134
+ inset: 0;
135
+ }
136
+
137
+ /* The panel frame is the one allowed past the control's box — a scale ring
138
+ sits outside the knob it belongs to. It takes no pointer events, so
139
+ printing a ring never enlarges the grab area. */
140
+ .ambx-rotary > .ambx-frame-panel,
141
+ .ambx-travel > .ambx-frame-panel {
142
+ pointer-events: none;
143
+ }
144
+
145
+ /* Motion. The actuator is the only frame that moves, and it moves by
146
+ transform — which is what lets the two transition rules below cover every
147
+ family whose actuator is a box. */
148
+ .ambx-rotary > .ambx-frame-actuator {
149
+ transform: rotate(var(--ambx-angle));
150
+ }
151
+
152
+ /* Follow the pointer 1:1 while dragging, ease otherwise. No JS: the root
153
+ already carries data-dragging. */
154
+ .ambx-control[data-animate="auto"] > .ambx-frame-actuator,
155
+ .ambx-control[data-animate="ease"] > .ambx-frame-actuator {
156
+ transition: transform 120ms ease-out;
157
+ }
158
+
159
+ .ambx-control[data-animate="auto"][data-dragging] > .ambx-frame-actuator,
160
+ .ambx-control[data-animate="follow"] > .ambx-frame-actuator,
161
+ .ambx-control[data-animate="snap"] > .ambx-frame-actuator {
162
+ transition: none;
163
+ }
164
+
165
+ @media (prefers-reduced-motion: reduce) {
166
+ .ambx-control > .ambx-frame-actuator {
167
+ transition: none;
168
+ }
169
+ }
170
+
171
+ .ambx-control[data-disabled] {
172
+ cursor: not-allowed;
173
+ }
174
+
175
+ /* Rotary geometry. Square, sized off one property so a user-supplied part
176
+ can scale with the control — a part cannot match .ambx-rotary-lg, it does
177
+ not know the class exists, but it can read --ambx-size. */
178
+ .ambx-rotary {
179
+ display: grid;
180
+ place-items: center;
181
+ touch-action: none;
182
+ user-select: none;
183
+ width: var(--ambx-size);
184
+ height: var(--ambx-size);
185
+ --ambx-size: calc(var(--ambx-grid) * 16); /* 64px */
186
+ }
187
+
188
+ .ambx-rotary-sm { --ambx-size: calc(var(--ambx-grid) * 12); } /* 48px */
189
+ .ambx-rotary-md { --ambx-size: calc(var(--ambx-grid) * 16); } /* 64px */
190
+ .ambx-rotary-lg { --ambx-size: calc(var(--ambx-grid) * 20); } /* 80px */
191
+
192
+ /* Travel geometry. Track length, track width and thumb all come off one
193
+ size step, and the slider's and fader's measured tables turn out to be
194
+ the same table — 6/8/12 track against a 20/24/32 thumb — so one set of
195
+ steps serves both, with the fader's pill taking the extra 1.5x height its
196
+ referent has. */
197
+ .ambx-travel {
198
+ position: relative;
199
+ touch-action: none;
200
+ user-select: none;
201
+ --ambx-size: calc(var(--ambx-grid) * 6); /* 24px thumb */
202
+ --ambx-travel-track: calc(var(--ambx-grid) * 2); /* 8px */
203
+ --ambx-travel-length: calc(var(--ambx-grid) * 30); /* 120px */
204
+ --ambx-travel-thumb-w: var(--ambx-size);
205
+ --ambx-travel-thumb-h: var(--ambx-size);
206
+ }
207
+
208
+ .ambx-travel-sm {
209
+ --ambx-size: calc(var(--ambx-grid) * 5); /* 20px */
210
+ --ambx-travel-track: calc(var(--ambx-grid) * 1.5); /* 6px */
211
+ }
212
+
213
+ .ambx-travel-md {
214
+ --ambx-size: calc(var(--ambx-grid) * 6); /* 24px */
215
+ --ambx-travel-track: calc(var(--ambx-grid) * 2); /* 8px */
216
+ }
217
+
218
+ .ambx-travel-lg {
219
+ --ambx-size: calc(var(--ambx-grid) * 8); /* 32px */
220
+ --ambx-travel-track: calc(var(--ambx-grid) * 3); /* 12px */
221
+ }
222
+
223
+ .ambx-travel-horizontal {
224
+ width: var(--ambx-travel-length);
225
+ height: var(--ambx-travel-track);
226
+ }
227
+
228
+ .ambx-travel-vertical {
229
+ width: var(--ambx-travel-track);
230
+ height: var(--ambx-travel-length);
231
+ }
232
+
233
+ /* The thumb frame is a thumb-sized box riding the track — the one case
234
+ where "inset: 0" would be the wrong box for the part. A percentage
235
+ translate resolves against the ELEMENT's size, not the track's, which is
236
+ why the length has to be a property the transform can multiply. */
237
+ .ambx-travel > .ambx-frame-actuator {
238
+ inset: auto;
239
+ width: var(--ambx-travel-thumb-w);
240
+ height: var(--ambx-travel-thumb-h);
241
+ }
242
+
243
+ .ambx-travel-horizontal > .ambx-frame-actuator {
244
+ top: 50%;
245
+ left: 0;
246
+ transform: translate(-50%, -50%)
247
+ translateX(calc(var(--ambx-percent) * var(--ambx-travel-length)));
248
+ }
249
+
250
+ .ambx-travel-vertical > .ambx-frame-actuator {
251
+ top: 100%;
252
+ left: 50%;
253
+ transform: translate(-50%, -50%)
254
+ translateY(calc(var(--ambx-percent) * var(--ambx-travel-length) * -1));
255
+ }
256
+
257
+ /* Latch: the control IS the track, so the pill can be positioned against
258
+ it directly. Its travel is the track less the pill and the two end
259
+ clearances — stated once here rather than as an on/off pair of offsets,
260
+ so --ambx-percent drives it like every other actuator. */
261
+ .ambx-latch {
262
+ position: relative;
263
+ cursor: pointer;
264
+ border: none;
265
+ padding: 0;
266
+ margin: 0;
267
+ background: none;
268
+ display: block;
269
+ width: var(--ambx-switch-w);
270
+ height: var(--ambx-switch-h);
271
+ --ambx-size: var(--ambx-switch-h);
272
+ --ambx-switch-w: calc(var(--ambx-grid) * 12);
273
+ --ambx-switch-h: calc(var(--ambx-grid) * 6);
274
+ --ambx-latch-thumb-w: calc(var(--ambx-switch-w) * 0.52);
275
+ --ambx-latch-thumb-h: calc(var(--ambx-switch-h) * 0.78);
276
+ --ambx-latch-inset: calc(var(--ambx-switch-h) * 0.11);
277
+ }
278
+
279
+ .ambx-latch-sm {
280
+ --ambx-switch-w: calc(var(--ambx-grid) * 10);
281
+ --ambx-switch-h: calc(var(--ambx-grid) * 5);
282
+ }
283
+
284
+ .ambx-latch-md {
285
+ --ambx-switch-w: calc(var(--ambx-grid) * 12);
286
+ --ambx-switch-h: calc(var(--ambx-grid) * 6);
287
+ }
288
+
289
+ .ambx-latch-lg {
290
+ --ambx-switch-w: calc(var(--ambx-grid) * 16);
291
+ --ambx-switch-h: calc(var(--ambx-grid) * 8);
292
+ }
293
+
294
+ /* Stated as tokens rather than as literals in the transform, so a kit that
295
+ wants a round thumb on a wider track restates three lengths instead of
296
+ re-writing this selector. */
297
+ .ambx-latch > .ambx-frame-actuator {
298
+ inset: auto;
299
+ top: 50%;
300
+ left: 0;
301
+ width: var(--ambx-latch-thumb-w);
302
+ height: var(--ambx-latch-thumb-h);
303
+ transform: translateY(-50%)
304
+ translateX(
305
+ calc(
306
+ var(--ambx-latch-inset) + var(--ambx-percent) *
307
+ (
308
+ var(--ambx-switch-w) - var(--ambx-latch-thumb-w) - 2 *
309
+ var(--ambx-latch-inset)
310
+ )
311
+ )
312
+ );
313
+ }
314
+
315
+ /* Press and bank size their control from their cap's legend, so their
316
+ frames stay markers and only the well needs geometry. */
317
+ .ambx-press {
318
+ --ambx-size: var(--ambx-button-size);
319
+ }
320
+
321
+ .ambx-bank {
322
+ --ambx-size: var(--ambx-select-size);
323
+ }
324
+
63
325
  /* Button: a chamfered key cap seated in a clearance well — the referent
64
326
  is components/button.py's "flush" style (cap seated into a hole with a
65
327
  shadow gap ring around it). The button element IS the well: a shallow
@@ -73,19 +335,154 @@
73
335
  padding: var(--ambx-grid-half); /* the clearance gap ring */
74
336
  --amb-thickness: 0.27; /* seat depth of the well */
75
337
  background-color: var(--amb-lume);
76
- border-radius: calc(var(--ambx-grid) * 3 + var(--ambx-grid-half));
77
- min-width: calc(var(--ambx-grid) * 16);
338
+ /* Well radius = cap radius + its own clearance padding (concentric
339
+ shapes nest by adding the gap, not by picking a second number). */
340
+ border-radius: calc(var(--ambx-radius-lg) + var(--ambx-grid-half));
341
+ min-width: var(--ambx-button-size);
78
342
  }
79
343
 
80
344
  .amb-button-cap {
81
345
  display: grid;
82
346
  place-items: center;
83
347
  width: 100%;
84
- padding: calc(var(--ambx-grid) * 1.5) calc(var(--ambx-grid) * 2);
85
- border-radius: calc(var(--ambx-grid) * 3);
348
+ padding: var(--ambx-button-cap-pad-y) var(--ambx-button-cap-pad-x);
349
+ border-radius: var(--ambx-radius-lg);
86
350
  text-transform: uppercase;
87
351
  font-weight: 600;
88
352
  color: var(--amb-label);
353
+ /* Containing block + stacking context for the dish overlay below, so it
354
+ can sit above the cap's own background and below the label. */
355
+ position: relative;
356
+ isolation: isolate;
357
+ }
358
+
359
+ /* Cap dish (subtle concavity).
360
+
361
+ Real key caps are not flat: the top is scooped, so the wall on the far
362
+ side of the dish tilts INTO the light and the near wall tilts away.
363
+ Photographed reference (a dished ON key lit from above): along the light
364
+ axis the face starts a little off its floor at the lit rim, bottoms out
365
+ ~20% across, then climbs — accelerating, not linearly — to its brightest
366
+ at the far rim. Sampling that face and normalizing (0 = lit rim, 1 = far
367
+ rim) gives v = u^1.37 past the minimum; subtracting the profile's own
368
+ mean so the dish redistributes light rather than darkening the cap
369
+ overall leaves the signed stop table used below, in units of the end
370
+ delta: -0.42 at the lit rim, -0.73 at the floor, 0 at 62.5%, +1 at the
371
+ far rim. The far side overshoots more than the near side dips — a
372
+ grazing wall gains more than a tilted-away one loses.
373
+
374
+ AMPLITUDE is not taken from that photo (a near-black cap; absolute
375
+ deltas there mean nothing at .amb-surface's ~90% lightness). It reads
376
+ --amb-curve-delta, the grounded end delta .amb-surface-concave and
377
+ -convex ride (ambient3d/derived/notes/curved.md), so a concave cap and
378
+ a concave plate curve by the same measured amount and cannot drift
379
+ apart if that fit is ever re-derived. Only the AXIS and the PROFILE are
380
+ this file's own: the curved classes are single-axis (-concave reads
381
+ only --amb-light-y) and symmetric, and a cap has to follow whichever
382
+ light an app has set.
383
+
384
+ The overlay is painted as white/black alpha rather than as a background
385
+ gradient because the cap's background is already spoken for: the
386
+ material classes own background-image, and .amb-surface-concave's
387
+ `background` shorthand would wipe the shiny specular. An alpha over a
388
+ base of lightness L lands exactly on L + a(100 - L) for white and
389
+ L(1 - a) for black (the hue/saturation terms cancel in HSL lightness),
390
+ so the two alphas below invert the wanted delta EXACTLY for a matte cap
391
+ and approximately for shiny/glass, whose base is no longer plain
392
+ .amb-surface — the same residual .amb-mat-shiny's own comment carries. */
393
+ .amb-button-cap::after {
394
+ content: "";
395
+ position: absolute;
396
+ inset: 0;
397
+ z-index: -1; /* over the cap face, under the label:
398
+ the alphas are calibrated for the
399
+ surface's lightness, and washing them
400
+ over the much darker label would fade
401
+ the glyph out at the bright rim. */
402
+ border-radius: inherit;
403
+ pointer-events: none;
404
+ /* Same axis convention as .amb-mat-shiny's --_shiny-angle: 0% of the
405
+ gradient lands on the lit edge, so the dish reorients itself with all
406
+ eight .amb-light-* positions instead of assuming a light from above. */
407
+ --_ambx-dish-angle: calc(atan2(var(--amb-light-y), var(--amb-light-x)) + 270deg);
408
+ /* Unitless twins of the cap's own surface lightness and dish end delta
409
+ (percentage points), so they can divide into alphas.
410
+
411
+ The lightness is rebuilt here rather than read off --amb-lit because
412
+ it is needed as a NUMBER, and relative colour syntax only exposes `l`
413
+ inside a colour function. So: the grounded exposure law (albedo x
414
+ exposure) with the sRGB transfer applied by hand.
415
+
416
+ That tracks --amb-shade EXACTLY — a dished cap on a darkened panel
417
+ gets alphas scaled for the tone it actually sits at — but it has to
418
+ assume the reference reflectance for the CHROMATIC part, because an
419
+ --amb-albedo's luminance cannot be reached as a number. On a dark
420
+ coloured albedo the two halves then miss in opposite directions: the
421
+ black half divides by too large an l and under-shades, while the
422
+ white half divides by too small a (100 - l) and over-lifts, so the
423
+ dish reads as a bright sheen rather than a curve. Set luminance with
424
+ --amb-shade and hue with --amb-albedo and both halves stay exact;
425
+ that pairing is the documented way to colour a dished component.
426
+
427
+ --_ambx-cap-tone is an escape hatch for a future material that ships
428
+ its own fixed albedo again: set it to that albedo's ratio against the
429
+ reference so the alphas derive at the lightness they actually wash
430
+ over. Nothing applies it today — the micro-relief finishes carry no
431
+ --amb-albedo of their own (@ambientcss/css), so a relief-faced cap's
432
+ .ambx-cap-face is already at the reference tone by default and takes
433
+ --amb-shade the same way any other dished surface does; it multiplies
434
+ --amb-shade rather than replacing it, so a consumer's own shade still
435
+ composes. */
436
+ --_ambx-dish-l: calc(
437
+ 100 * (
438
+ 1.055 * pow(
439
+ 0.82 * var(--_ambx-cap-tone, 1) * var(--amb-shade) * var(--amb-exposure),
440
+ 0.41667
441
+ ) - 0.055
442
+ )
443
+ );
444
+ /* One curvature for all three cap silhouettes: a dish is a property of
445
+ the tooling, not of how big or how round the key is. Tune it — for
446
+ one button, or a whole panel — with @ambientcss/css's
447
+ --amb-curve-scale, which is the same knob the curved surface classes
448
+ answer to and inherits like any other variable. */
449
+ --_ambx-dish-d: var(--amb-curve-delta);
450
+ /* Alpha per unit of the stop table. The max() floors keep the divisor
451
+ off zero: at Ik = If = 1 the grounded surface computes past 100%
452
+ lightness (already clipped white), where a white overlay is a no-op
453
+ anyway. */
454
+ --_ambx-dish-up: calc(var(--_ambx-dish-d) / max(6, 100 - var(--_ambx-dish-l)));
455
+ --_ambx-dish-dn: calc(var(--_ambx-dish-d) / max(6, var(--_ambx-dish-l)));
456
+ background: linear-gradient(
457
+ var(--_ambx-dish-angle),
458
+ hsl(var(--amb-light-hue) var(--amb-light-saturation) 0% / calc(var(--_ambx-dish-dn) * 0.42)) 0%,
459
+ hsl(var(--amb-light-hue) var(--amb-light-saturation) 0% / calc(var(--_ambx-dish-dn) * 0.73)) 20%,
460
+ hsl(var(--amb-light-hue) var(--amb-light-saturation) 0% / calc(var(--_ambx-dish-dn) * 0.47)) 40%,
461
+ /* Doubled stop at the neutral crossing: a zero-length interval, so the
462
+ gradient never interpolates between a black and a white stop while
463
+ either still carries alpha. */
464
+ hsl(var(--amb-light-hue) var(--amb-light-saturation) 0% / 0) 62.5%,
465
+ hsl(var(--amb-light-hue) var(--amb-light-saturation) 100% / 0) 62.5%,
466
+ hsl(var(--amb-light-hue) var(--amb-light-saturation) 100% / calc(var(--_ambx-dish-up) * 0.44)) 80%,
467
+ hsl(var(--amb-light-hue) var(--amb-light-saturation) 100% / var(--_ambx-dish-up)) 100%
468
+ );
469
+ }
470
+
471
+ /* The micro-relief cap face: the layer .amb-mat-brushed / .amb-mat-blasted
472
+ ride on when the cap itself cannot carry them, because its own ::after is
473
+ already the dish. Sits under the dish (same negative index, earlier in
474
+ paint order) and under the legend, so the grain textures the cap's face
475
+ without washing over either. It paints .amb-surface in its own material's
476
+ tone, covering the cap's, and wears the cap's chamfer itself — an inset
477
+ shadow belongs to the background layer of the element that declares it, so
478
+ an opaque face would otherwise hide the cut. The cap keeps its shadow and
479
+ its sink-on-press, neither of which the face touches. */
480
+ .ambx-cap-face {
481
+ position: absolute;
482
+ inset: 0;
483
+ z-index: -1;
484
+ border-radius: inherit;
485
+ pointer-events: none;
89
486
  }
90
487
 
91
488
  .amb-button:focus {
@@ -96,6 +493,13 @@
96
493
  color: var(--amb-highlight-color);
97
494
  }
98
495
 
496
+ /* Pressed: the cap keeps its tone and its dish, and only SINKS — thickness
497
+ 1 -> 0.84, the referent's 0.7mm travel. The cue is entirely geometric:
498
+ the swept drop shadow shortens and tightens as the cap's silhouette
499
+ drops toward the well floor, and the chamfer bands narrow with it. A
500
+ key that is the same key still lights the same face, so nothing about
501
+ the cap's colour moves — the button reads as travel, not as a state
502
+ repaint. */
99
503
  .amb-button:active .amb-button-cap {
100
504
  --amb-thickness: 0.84;
101
505
  }
@@ -107,27 +511,30 @@
107
511
  3.6mm cap instead of the key's 4.5mm — thickness 0.8 — with the same
108
512
  0.7mm press travel). */
109
513
  .amb-button.amb-button-round {
110
- min-width: calc(var(--ambx-grid) * 12);
514
+ min-width: var(--ambx-button-size);
111
515
  aspect-ratio: 1;
112
516
  border-radius: 50%;
113
517
  }
114
518
 
115
519
  .amb-button-round .amb-button-cap {
116
520
  height: 100%;
117
- padding: var(--ambx-grid);
521
+ padding: var(--ambx-button-cap-inset);
118
522
  border-radius: 50%;
119
523
  }
120
524
 
121
525
  .amb-button.amb-button-square {
122
- min-width: calc(var(--ambx-grid) * 14);
526
+ min-width: var(--ambx-button-size);
123
527
  aspect-ratio: 1;
124
- border-radius: calc(var(--ambx-grid) * 2);
528
+ border-radius: var(--ambx-radius-md);
125
529
  }
126
530
 
127
531
  .amb-button-square .amb-button-cap {
128
532
  height: 100%;
129
- padding: var(--ambx-grid);
130
- border-radius: calc(var(--ambx-grid) * 1.5);
533
+ padding: var(--ambx-button-cap-inset);
534
+ /* Cap radius = well radius (var(--ambx-radius-md)) minus its own inset
535
+ clearance — the same concentric-nesting rule as the pill, solved the
536
+ other way because here the well, not the cap, sits on the scale. */
537
+ border-radius: calc(var(--ambx-radius-md) - var(--ambx-grid-half));
131
538
  --amb-thickness: 0.8;
132
539
  }
133
540
 
@@ -136,207 +543,192 @@
136
543
  }
137
544
 
138
545
  /* Knob: a knob-scale body (thickness 2 = the referent knob.py's 9mm
139
- height) resting on the panel. The rotating face carries the knurl a
140
- repeating-conic rib pattern, physically the referent's ribs catching
141
- key light on one flank and shading on the other under a smooth
142
- opaque top disc (the OP-1-style cap), with an accent indicator dot.
143
- The whole face rotates with the value, so the ribs move under the
144
- fingers like the real thing. */
546
+ height) resting on the panel a smooth chamfered cap, and around it
547
+ three independent axes, each its own prop. The cap may be ringed by a
548
+ knurl a rim band of ribs standing proud of its edge and a step below
549
+ it, physically the referent's ribs catching key light on one flank and
550
+ shading on the other the panel around it may carry printed scale
551
+ markers, and the face carries one accent indicator. The knurl and the
552
+ indicator rotate with the value, so the ribs move under the fingers like
553
+ the real thing; the markers do not, because they are printed on the
554
+ panel, and neither does the cap, whose chamfer is lit from a fixed
555
+ direction and so cannot ride a turning frame. */
556
+ /* Every knob metric is a fraction of the control's own size, so the three
557
+ size steps only restate that one length. Marker radius and dot size are
558
+ fractions of the DIAMETER: the ring sits at 1.33R and the dots measure
559
+ 0.14R, both taken off the reference panels. --ambx-knob-size is a family
560
+ alias of --ambx-size, kept because these fractions read better against a
561
+ name that says which control they belong to. */
145
562
  .amb-knob {
146
563
  border-radius: 50%;
147
- height: calc(var(--ambx-grid) * 16);
148
- width: calc(var(--ambx-grid) * 16);
564
+ --ambx-knob-size: var(--ambx-size);
565
+ --ambx-knob-rect-width: calc(var(--ambx-knob-size) * 0.075);
566
+ --ambx-knob-marker-radius: calc(var(--ambx-knob-size) * 0.665);
567
+ --ambx-knob-marker-size: calc(var(--ambx-knob-size) * 0.07);
149
568
  }
150
569
 
151
- /* The body is the smooth circle at the tooth-root radius: it carries
152
- the ambient drop shadow (kept circular — the teeth are too fine to
153
- read in a penumbra) while the clipped face above it supplies the
154
- knurled silhouette. */
570
+ /* The cap: the smooth chamfered disc that is most of the knob, and the
571
+ element that carries the ambient drop shadow (kept circular — the knurl's
572
+ teeth are too fine to read in a penumbra).
573
+
574
+ Both variants take the small base chamfer the referent cuts on every knob
575
+ (knob.py's `chamfer=0.35`, regardless of rib count) — width 2,
576
+ level-for-level with the body's knob-scale thickness (.amb-chamfer-2's
577
+ pairing). A rim of ribs is a grip, not an edge treatment: it tells you
578
+ the knob turns, not where its top face stops, so the knurled knob needs
579
+ the chamfer just as much as the smooth one does.
580
+
581
+ knurling={false} leaves the cap flush at the full width and it becomes the
582
+ whole visible knob — a turned cylinder. Otherwise KnobBody insets it by the
583
+ knurl band (inline, from the same constant the clip path uses) so the ribs
584
+ ring it. */
155
585
  .amb-knob-body {
156
586
  position: absolute;
157
- inset: calc(var(--ambx-grid) / 2);
587
+ inset: 0;
158
588
  border-radius: 50%;
589
+ --amb-chamfer: 1;
590
+ --amb-chamfer-width: 2;
159
591
  }
160
592
 
161
- /* The rotating face: clipped to the straight-knurl silhouette (the
162
- inline SVG clipPath in AmbientKnob), opaque at the knob's surface
163
- color, with per-tooth flank shading a lit leading flank and a
164
- shaded trailing flank every 10deg, phase-aligned with the clip's
165
- teeth (conic 0 is rotated to the +x axis where the tooth path
166
- starts) under the smooth top disc. */
593
+ /* The rotating knurl: clipped to the toothed ANNULUS (the inline SVG
594
+ clipPath in KnurledFace), so it paints only the rim band outside the cap
595
+ and the cap's own chamfer bands survive underneath.
596
+
597
+ Two things paint it. Per-tooth flank shading a lit flank climbing to
598
+ each ridge and a shaded one falling away, one pair every 7.5deg (360/48,
599
+ the clip's rib count), phase-aligned with the clip: conic 0 is rotated to
600
+ the +x axis, where the tooth curve starts in a groove, so the ridge lands
601
+ at 3.75deg and the lit and shaded stops at 1.875 and 5.625. And a
602
+ contact-occlusion band along the inner edge, where the cap overhangs the
603
+ band it steps down to: radial, so it survives the frame's rotation
604
+ unchanged, which a directional cue in a turning element could not.
605
+
606
+ The step down itself is --amb-shade, not a black wash: the rim is the same
607
+ material as the cap sitting lower and facing less of the sky, and shading
608
+ the albedo darkens it the way less light would, where an overlay would
609
+ drain a coloured knob toward grey. Uniform round the circumference, so it
610
+ too is rotation-safe. */
167
611
  .amb-knob-face {
168
612
  position: absolute;
169
613
  inset: 0;
170
- background-color: hsl(
171
- var(--amb-light-hue)
172
- var(--amb-light-saturation)
173
- calc(
174
- var(--amb-key-light-intensity) * 31.5% +
175
- var(--amb-fill-light-intensity) * 26.4% + 43.6%
176
- )
177
- );
614
+ --amb-shade: 0.88;
615
+ background-color: var(--amb-lit);
178
616
  background-image:
179
617
  radial-gradient(
180
- circle,
181
- hsl(
182
- var(--amb-light-hue)
183
- var(--amb-light-saturation)
184
- calc(
185
- var(--amb-key-light-intensity) * 31.5% +
186
- var(--amb-fill-light-intensity) * 26.4% + 43.6%
187
- )
188
- ) 0 60%,
189
- transparent 61%
190
- ),
191
- repeating-conic-gradient(
192
- from 90deg,
193
- hsl(var(--amb-light-hue) var(--amb-light-saturation) 100% /
194
- calc(var(--amb-key-light-intensity) * 0.35)) 0deg 1.4deg,
195
- transparent 2.2deg 4.6deg,
618
+ circle closest-side,
196
619
  hsl(var(--amb-light-hue) var(--amb-light-saturation) 0% /
197
620
  calc((var(--amb-key-light-intensity) - var(--amb-fill-light-intensity))
198
- * 0.3 + 0.16)) 5deg 6.4deg,
199
- transparent 7.2deg 10deg
200
- );
201
- }
202
-
203
- /* Fluted face (flute variant — the OP-Z-style referent: 14 broad
204
- ridges split by narrow grooves, pitch 25.714deg). Flank bands sit on
205
- the clip's rising (0–2.1deg) and falling (18.5–20.6deg) flanks;
206
- deeper flutes catch more key light, so both bands run stronger than
207
- the 36-rib knurl's. */
208
- .amb-knob-face-flute {
209
- background-image:
210
- radial-gradient(
211
- circle,
212
- hsl(
213
- var(--amb-light-hue)
214
- var(--amb-light-saturation)
215
- calc(
216
- var(--amb-key-light-intensity) * 31.5% +
217
- var(--amb-fill-light-intensity) * 26.4% + 43.6%
218
- )
219
- ) 0 60%,
220
- transparent 61%
221
- ),
222
- repeating-conic-gradient(
223
- from 90deg,
224
- hsl(var(--amb-light-hue) var(--amb-light-saturation) 100% /
225
- calc(var(--amb-key-light-intensity) * 0.45)) 0deg 2.1deg,
226
- transparent 3.2deg 17.4deg,
621
+ * 0.07 + 0.09)) 89%,
227
622
  hsl(var(--amb-light-hue) var(--amb-light-saturation) 0% /
228
623
  calc((var(--amb-key-light-intensity) - var(--amb-fill-light-intensity))
229
- * 0.35 + 0.2)) 18.5deg 20.6deg,
230
- transparent 21.6deg 25.7143deg
231
- );
232
- }
233
-
234
- /* Fine knurl (cap and wheel variants: 48 ribs, pitch 7.5deg) — the
235
- 36-rib pattern's angles scaled by 3/4, same flank alphas. */
236
- .amb-knob-face-fine {
237
- background-image:
238
- radial-gradient(
239
- circle,
240
- hsl(
241
- var(--amb-light-hue)
242
- var(--amb-light-saturation)
243
- calc(
244
- var(--amb-key-light-intensity) * 31.5% +
245
- var(--amb-fill-light-intensity) * 26.4% + 43.6%
246
- )
247
- ) 0 60%,
248
- transparent 61%
624
+ * 0.02 + 0.03)) 95%,
625
+ transparent 100%
249
626
  ),
250
627
  repeating-conic-gradient(
251
628
  from 90deg,
629
+ transparent 0deg,
252
630
  hsl(var(--amb-light-hue) var(--amb-light-saturation) 100% /
253
- calc(var(--amb-key-light-intensity) * 0.35)) 0deg 1.05deg,
254
- transparent 1.65deg 3.45deg,
631
+ calc(var(--amb-key-light-intensity) * 0.26)) 1.875deg,
632
+ transparent 3.75deg,
255
633
  hsl(var(--amb-light-hue) var(--amb-light-saturation) 0% /
256
634
  calc((var(--amb-key-light-intensity) - var(--amb-fill-light-intensity))
257
- * 0.3 + 0.16)) 3.75deg 4.8deg,
258
- transparent 5.4deg 7.5deg
635
+ * 0.24 + 0.13)) 5.625deg,
636
+ transparent 7.5deg
259
637
  );
260
638
  }
261
639
 
262
- /* Encoder cap (cap variant the OP-1-style referent's smooth
263
- contrasting top disc, 0.35mm proud of the knurl): the accent disc
264
- replaces the surface-colored center disc over the fine knurl. */
265
- .amb-knob-face-cap {
266
- background-image:
267
- radial-gradient(circle, var(--amb-highlight-color) 0 65%, transparent 66%),
268
- repeating-conic-gradient(
269
- from 90deg,
270
- hsl(var(--amb-light-hue) var(--amb-light-saturation) 100% /
271
- calc(var(--amb-key-light-intensity) * 0.35)) 0deg 1.05deg,
272
- transparent 1.65deg 3.45deg,
273
- hsl(var(--amb-light-hue) var(--amb-light-saturation) 0% /
274
- calc((var(--amb-key-light-intensity) - var(--amb-fill-light-intensity))
275
- * 0.3 + 0.16)) 3.75deg 4.8deg,
276
- transparent 5.4deg 7.5deg
277
- );
278
- }
640
+ /* Indicators. Both ride in the actuator frame, so they point at 12 o'clock
641
+ here and the frame's rotation carries them round to the value.
279
642
 
280
- .amb-knob-indicator-dot {
643
+ The circle is the grounded referent's offset dot (knob() dot_frac 0.12,
644
+ dot_offset 0.68). */
645
+ .amb-knob-indicator-circle {
281
646
  position: absolute;
282
647
  top: 16%;
283
648
  left: 50%;
284
649
  transform: translateX(-50%);
285
- width: calc(var(--ambx-grid) * 2);
286
- height: calc(var(--ambx-grid) * 2);
650
+ width: calc(var(--ambx-knob-size) * 0.125);
651
+ height: calc(var(--ambx-knob-size) * 0.125);
287
652
  border-radius: 50%;
288
653
  background: var(--amb-highlight-color);
289
654
  }
290
655
 
291
- /* Centered dot (flute variant): the OP-Z referent's dot sits in the
292
- middle of the cap (dot_frac 0.32), not at the rim. */
293
- .amb-knob-indicator-dot-center {
294
- top: 50%;
295
- transform: translate(-50%, -50%);
296
- width: calc(var(--ambx-grid) * 4.5);
297
- height: calc(var(--ambx-grid) * 4.5);
298
- }
299
-
300
- /* Radial indicator line (line variant): the referent's accent bar,
301
- center to ~85% of the cap radius, pointing at 12 o'clock. */
302
- .amb-knob-indicator-line {
656
+ /* The rectangle is a short radial bar out near the rim, not a spoke from
657
+ the centre: it runs 0.50R to 0.84R, the proportions measured off the
658
+ reference panels, which is why it is stated as a top offset plus a
659
+ height rather than the full-radius line it replaces. Corners take a
660
+ quarter-grid round-over — enough to read as machined rather than
661
+ aliased, and far short of the pill a 50% radius would give. */
662
+ .amb-knob-indicator-rectangle {
303
663
  position: absolute;
304
- top: 12%;
664
+ top: 8%;
305
665
  left: 50%;
306
666
  transform: translateX(-50%);
307
- width: calc(var(--ambx-grid) * 0.75);
308
- height: 34%;
309
- border-radius: calc(var(--ambx-grid) * 0.375);
667
+ width: var(--ambx-knob-rect-width);
668
+ height: 17%;
669
+ border-radius: var(--ambx-grid-quarter);
310
670
  background: var(--amb-highlight-color);
311
671
  }
312
672
 
313
- .amb-knob:hover .amb-knob-indicator-dot,
314
- .amb-knob:hover .amb-knob-indicator-line {
673
+ .amb-knob:hover .amb-knob-indicator-circle,
674
+ .amb-knob:hover .amb-knob-indicator-rectangle {
315
675
  background: color-mix(in oklab, var(--amb-highlight-color), white 25%);
316
676
  }
317
677
 
678
+ /* Printed scale markers. The ring is a zero-size point at the knob's
679
+ centre and each dot is placed by rotating about that centre and pushing
680
+ out along the rotated axis, so one angle per dot is the whole geometry —
681
+ and the angles come from the same sweep the value uses (ScaleRing reads
682
+ --ambx-travel-start/-sweep off the control), so a dot always lands where
683
+ its value would, whatever `travel` is set to.
684
+
685
+ Ink is --amb-label, not --amb-highlight-color: these are printed panel
686
+ graphics like the control's legend, not part of the control. */
687
+ .amb-knob-marker-ring {
688
+ position: absolute;
689
+ top: 50%;
690
+ left: 50%;
691
+ width: 0;
692
+ height: 0;
693
+ pointer-events: none;
694
+ }
695
+
696
+ .amb-knob-marker {
697
+ position: absolute;
698
+ top: 0;
699
+ left: 0;
700
+ width: var(--ambx-knob-marker-size);
701
+ height: var(--ambx-knob-marker-size);
702
+ margin: calc(var(--ambx-knob-marker-size) / -2);
703
+ border-radius: 50%;
704
+ background: var(--amb-label);
705
+ transform: rotate(var(--amb-marker-angle))
706
+ translateY(calc(-1 * var(--ambx-knob-marker-radius)));
707
+ }
708
+
318
709
  /* Tracks are grounded grooves (.amb-groove supplies the recess: lit-wall
319
710
  shadow band + far-wall bounce) with a lume interior — dark in bright
320
711
  light, glowing in low light — overriding the groove's neutral floor. */
321
- .amb-fader {
322
- position: relative;
323
- width: var(--ambx-grid);
324
- height: calc(var(--ambx-grid) * 30);
325
- border-radius: var(--ambx-grid);
712
+ .amb-travel-track {
713
+ position: absolute;
714
+ inset: 0;
715
+ border-radius: var(--ambx-radius-full);
326
716
  background-color: var(--amb-lume);
327
717
  }
328
718
 
719
+ /* The slider's referent (slider.py) is a shallow concave channel 1mm deep
720
+ rather than the fader's through-slot. */
721
+ .amb-travel-track-channel {
722
+ --amb-thickness: 0.22;
723
+ }
724
+
329
725
  /* Fader thumb: the referent (fader.py) is a pill cap on a stem — 7mm
330
726
  tall (thickness 1.5) riding 2.2mm above the plate (elevation 0.28) —
331
727
  with a single grip line across the top. */
332
728
  .amb-fader-thumb {
333
729
  position: absolute;
334
- width: calc(var(--ambx-grid) * 6);
335
- height: calc(var(--ambx-grid) * 9);
336
- left: 50%;
337
- top: 50%;
338
- transform: translate(-50%, -50%);
339
- border-radius: calc(var(--ambx-grid) * 2);
730
+ inset: 0;
731
+ border-radius: var(--ambx-radius-md);
340
732
  display: grid;
341
733
  place-items: center;
342
734
  --amb-thickness: 1.5;
@@ -350,29 +742,20 @@
350
742
  background: var(--amb-lume);
351
743
  }
352
744
 
353
- /* Slider: the referent (slider.py) is a shallow concave channel (1mm
354
- deep = thickness 0.22), not a through-slot, with a domed disc thumb
355
- gliding over it. */
356
- .amb-slider {
357
- position: relative;
358
- height: calc(var(--ambx-grid) * 1.5);
359
- width: calc(var(--ambx-grid) * 30);
360
- border-radius: var(--ambx-grid);
361
- background-color: var(--amb-lume);
362
- --amb-thickness: 0.22;
363
- }
364
-
745
+ /* A domed disc gliding over the channel. */
365
746
  .amb-slider-thumb {
366
747
  position: absolute;
367
- width: calc(var(--ambx-grid) * 6);
368
- height: calc(var(--ambx-grid) * 6);
369
- top: 50%;
370
- left: 50%;
371
- transform: translate(-50%, -50%);
748
+ inset: 0;
372
749
  border-radius: 50%;
373
750
  --amb-thickness: 0.7;
374
751
  }
375
752
 
753
+ /* The fader's pill is 1.5x its width; the slider's disc is round. Both take
754
+ their width from the shared travel size step. */
755
+ .amb-fader {
756
+ --ambx-travel-thumb-h: calc(var(--ambx-size) * 1.5);
757
+ }
758
+
376
759
  .amb-led {
377
760
  width: calc(var(--ambx-grid) * 2);
378
761
  height: calc(var(--ambx-grid) * 2);
@@ -395,22 +778,9 @@
395
778
  in a dark stadium recess (well 1.5mm deep = thickness 0.33; the pill
396
779
  stands 2.6mm above the recess floor = thickness 0.58), with an
397
780
  optional LED above. The pill slides, nothing presses. */
398
- .amb-switch {
399
- cursor: pointer;
400
- border: none;
401
- padding: 0;
402
- background: none;
403
- display: inline-grid;
404
- justify-items: center;
405
- gap: var(--ambx-grid);
406
- margin: 0;
407
- }
408
-
409
781
  .amb-switch-track {
410
- position: relative;
411
- display: block;
412
- width: var(--ambx-switch-w);
413
- height: var(--ambx-switch-h);
782
+ position: absolute;
783
+ inset: 0;
414
784
  border-radius: calc(var(--ambx-switch-h) / 2);
415
785
  background-color: var(--amb-lume);
416
786
  --amb-thickness: 0.33;
@@ -418,66 +788,321 @@
418
788
 
419
789
  .amb-switch-pill {
420
790
  position: absolute;
421
- top: 50%;
422
- left: 0;
423
- width: calc(var(--ambx-switch-w) * 0.52);
424
- height: calc(var(--ambx-switch-h) * 0.78);
791
+ inset: 0;
425
792
  border-radius: calc(var(--ambx-switch-h) * 0.39);
426
- transform: translate(calc(var(--ambx-switch-h) * 0.11), -50%);
427
- transition: transform 140ms ease;
428
793
  --amb-thickness: 0.58;
429
794
  }
430
795
 
431
- .amb-switch-on .amb-switch-pill {
432
- transform: translate(
433
- calc(var(--ambx-switch-w) - 100% - var(--ambx-switch-h) * 0.11),
434
- -50%
435
- );
796
+ /* The lamp sits above the track on the tight grid pitch, one tier in from
797
+ the label's own clearance. */
798
+ .ambx-switch-mount {
799
+ display: inline-grid;
800
+ justify-items: center;
801
+ gap: var(--ambx-grid);
436
802
  }
437
803
 
438
804
  .amb-switch:hover .amb-switch-pill {
439
805
  background-color: color-mix(
440
806
  in oklab,
441
- hsl(
442
- var(--amb-light-hue)
443
- var(--amb-light-saturation)
444
- calc(
445
- var(--amb-key-light-intensity) * 31.5% +
446
- var(--amb-fill-light-intensity) * 26.4% + 43.6%
447
- )
448
- ),
807
+ var(--amb-lit),
449
808
  var(--amb-highlight-color) 12%
450
809
  );
451
810
  }
452
811
 
812
+ /* Select: a bank of lamp-lit keys in a shared rail — the hardware idiom
813
+ for a mode/bank selector, where the state is a lamp rather than a mark.
814
+ Three physical layers per option, and the ORDER is the whole trick:
815
+
816
+ 1. the option itself is the pocket floor, in the grounded surface tone
817
+ 2. .amb-select-lens is the LED's lens, a big disc lying ON that floor
818
+ 3. .amb-select-cap is a translucent diffuser sitting OVER both
819
+
820
+ The cap is .amb-mat-glass, so its backdrop-filter blurs whatever is
821
+ behind it — which is the lens. That single fact produces both states
822
+ without a second gradient anywhere: unlit, the lens body reads as a soft
823
+ dark circle through the frost ("the LED you can see is there"); lit, the
824
+ same disc blooms into a broad radial glow with a hot centre, exactly as
825
+ an LED does behind a diffuser. Confirmed in the browser: backdrop-filter
826
+ samples a positioned SIBLING, not just an ancestor's background, so the
827
+ lens must paint before the cap — hence the lens is absolute and the cap
828
+ is position: relative rather than the other way round. */
829
+ .amb-select {
830
+ display: inline-flex;
831
+ flex-direction: column;
832
+ padding: var(--ambx-grid-half); /* the clearance gap ring */
833
+ gap: var(--ambx-grid-half); /* the dark rail between keys */
834
+ --amb-thickness: 0.27; /* seat depth, as .amb-button */
835
+ background-color: var(--amb-lume);
836
+ border-radius: calc(var(--ambx-radius-md) + var(--ambx-grid-half));
837
+ }
838
+
839
+ .ambx-bank-horizontal.amb-select {
840
+ flex-direction: row;
841
+ }
842
+
843
+ .amb-select .ambx-key {
844
+ /* The lamp colour. Same variable .amb-led and AmbientSwitch's led prop
845
+ use, so one declaration lights every indicator on a panel; unset, it
846
+ falls back to the scene's own accent rather than to a hardcoded hue.
847
+
848
+ Resolved HERE, on the key, not on the bank: a custom property has its
849
+ var()s substituted on the element that declares it, so reading
850
+ --amb-led-color one level up would bake in the bank's colour (or the
851
+ fallback) and inherit that down, and a per-key colour would never be
852
+ seen. Same trap --amb-curve-delta is declared per element to avoid. */
853
+ --_ambx-sel-color: var(--amb-led-color, var(--amb-highlight-color));
854
+ position: relative;
855
+ cursor: pointer;
856
+ border: none;
857
+ padding: 0;
858
+ display: grid;
859
+ /* Square by default, but a WORD is allowed to widen it: min-width floors
860
+ the key at the hardware square, and the cap (a stretched grid item, so
861
+ it still covers the whole face) contributes its legend's width above
862
+ that. A bank of numerals stays a bank of numerals; a bank of mode names
863
+ grows to fit and, in a column, all keys stretch to the widest. */
864
+ min-width: var(--ambx-select-size);
865
+ height: var(--ambx-select-size);
866
+ border-radius: var(--ambx-radius-md);
867
+ /* The pocket floor the lens lies on: the grounded .amb-surface tone, so
868
+ an unlit key reads as a lit white diffuser rather than as a hole. */
869
+ background-color: var(--amb-lit);
870
+ transition: box-shadow 140ms ease;
871
+ }
872
+
873
+ /* Disabled dims the LAMP and the legend, not the key. Fading the whole
874
+ option would sink it into the dark rail, and opacity on the option makes
875
+ it a backdrop root — the one ancestor property that can cut the cap off
876
+ from the lens it is supposed to be diffusing. Fading the lens instead
877
+ leaves the diffuser doing its job on a lamp that simply cannot light. */
878
+ .amb-select .ambx-key:disabled {
879
+ cursor: not-allowed;
880
+ box-shadow: none; /* a dead lamp spills nothing onto the rail */
881
+ }
882
+
883
+ .amb-select .ambx-key:disabled .amb-select-lens {
884
+ opacity: 0.4;
885
+ }
886
+
887
+ .amb-select .ambx-key:disabled .amb-select-cap {
888
+ color: color-mix(in oklab, var(--amb-label), transparent 55%);
889
+ }
890
+
891
+ .amb-select .ambx-key:focus-visible {
892
+ outline-offset: var(--ambx-grid-half);
893
+ }
894
+
895
+ /* The LED's lens: a big disc, not the pinprick .amb-led is — this is the
896
+ lamp UNDER the key, sized so the diffused circle is legible through the
897
+ cap (the referent's lens reads at roughly three fifths of the key). */
898
+ .amb-select-lens {
899
+ position: absolute;
900
+ left: 50%;
901
+ top: 50%;
902
+ transform: translate(-50%, -50%);
903
+ height: 62%;
904
+ aspect-ratio: 1; /* off the HEIGHT, so a widened key keeps a
905
+ round lamp rather than an overflowing one */
906
+ border-radius: 50%;
907
+ /* Unlit, the lens is a tinted lens body, not an off pixel: it sits
908
+ between the floor's lit tone and the same material at 0.38 of its
909
+ reflectance (the mid-tone plate the rig calls DARKER_ALBEDO, taken
910
+ off --amb-lit in LINEAR light so it darkens the panel's own colour
911
+ rather than replacing it with a grey), and carries a trace of its
912
+ own colour so an amber lamp reads
913
+ faintly warm while dark — 6%, enough to tell an amber lamp from a
914
+ cyan one up close and not enough to tint the key. Measured against
915
+ the referent photo, whose unlit disc dips about 5% below the
916
+ surrounding cap. */
917
+ background-color: color-mix(
918
+ in oklab,
919
+ color-mix(
920
+ in oklab,
921
+ var(--amb-lit) 45%,
922
+ color-mix(in srgb-linear, var(--amb-lit) 37.8%, black)
923
+ ),
924
+ var(--_ambx-sel-color) 6%
925
+ );
926
+ transition: background-color 140ms ease;
927
+ }
928
+
929
+ /* The diffuser cap. --_glass-blur is dialled down from the material's own
930
+ 8 + 8*Ik (15.2px at the defaults): at that radius the lens washes out to
931
+ a flat tint and both states lose their circle. It is also tied to the
932
+ key rather than fixed — diffusion is a ratio of the lens it spreads, so
933
+ a flat radius would leave a small key soft and a large one hard-edged.
934
+ 0.18 of the key puts md back at the 8px that read correctly against the
935
+ referent. */
936
+ .amb-select-cap {
937
+ position: relative; /* paints above the absolutely-placed lens */
938
+ display: grid;
939
+ place-items: center;
940
+ padding-inline: var(--ambx-select-pad);
941
+ height: 100%;
942
+ /* Flush with the pocket: the cap IS the key's whole visible face, so
943
+ the floor beneath it never shows as a rim — the dark gap between keys
944
+ is the rail, not a ring around each one. */
945
+ border-radius: var(--ambx-radius-md);
946
+ font-weight: 600;
947
+ font-size: var(--ambx-select-label-size);
948
+ color: var(--amb-label);
949
+ --_glass-blur: calc(var(--ambx-select-size) * 0.18);
950
+ }
951
+
952
+ .amb-select .ambx-key:hover .amb-select-cap {
953
+ color: var(--amb-highlight-color);
954
+ }
955
+
956
+ /* Pressing sinks the key, and only sinks it — same rule the button follows:
957
+ the drop shadow shortens and the chamfer bands narrow, nothing repaints. */
958
+ .amb-select .ambx-key:active .amb-select-cap {
959
+ --amb-thickness: 0.84;
960
+ }
961
+
962
+ /* Lit. The lens goes to full lamp colour and the diffuser saturates with
963
+ it: the cap's own tint is mixed to 35% of the lamp over the glass wash,
964
+ which is what the referent measures — a flooded cap that still shows the
965
+ lens's hot centre through it. Past ~55% the radial disappears into a
966
+ flat swatch and the key stops reading as lit-from-behind. */
967
+ .amb-select .ambx-key[data-on] .amb-select-lens {
968
+ background-color: var(--_ambx-sel-color);
969
+ }
970
+
971
+ .amb-select .ambx-key[data-on] .amb-select-cap {
972
+ background-color: color-mix(
973
+ in srgb,
974
+ var(--_ambx-sel-color) 35%,
975
+ hsl(
976
+ var(--amb-light-hue) var(--amb-light-saturation)
977
+ calc(var(--amb-key-light-intensity) * 100%) /
978
+ calc(0.05 + var(--amb-key-light-intensity) * 0.1)
979
+ )
980
+ );
981
+ /* The legend is printed ink: it does not light up, it silhouettes
982
+ against the lamp — so it darkens toward the lamp's own hue rather
983
+ than staying --amb-label, which inverts to pale in a dark scene and
984
+ would vanish against a bright cap. */
985
+ color: color-mix(in oklab, var(--_ambx-sel-color), black 68%);
986
+ }
987
+
988
+ /* Light escaping around the key onto the rail. Radius is the grounded
989
+ emissive bloom (ambient3d/derived/notes/glow.md: 6.2px = 2 sigma of the
990
+ measured falloff), the same figure .amb-glow uses — only the colour is
991
+ the lamp's rather than --amb-lume's. */
992
+ .amb-select .ambx-key[data-on] {
993
+ box-shadow: 0 0 6.2px var(--_ambx-sel-color);
994
+ }
995
+
996
+ .ambx-bank {
997
+ --ambx-select-size: calc(var(--ambx-grid) * 11); /* 44px key */
998
+ --ambx-select-pad: calc(var(--ambx-grid) * 2);
999
+ --ambx-select-label-size: calc(var(--ambx-grid) * 3.5);
1000
+ }
1001
+
1002
+ .ambx-bank-sm {
1003
+ --ambx-select-size: calc(var(--ambx-grid) * 8); /* 32px */
1004
+ --ambx-select-pad: calc(var(--ambx-grid) * 1.5);
1005
+ --ambx-select-label-size: calc(var(--ambx-grid) * 2.5);
1006
+ }
1007
+
1008
+ .ambx-bank-md {
1009
+ --ambx-select-size: calc(var(--ambx-grid) * 11); /* 44px */
1010
+ --ambx-select-pad: calc(var(--ambx-grid) * 2);
1011
+ --ambx-select-label-size: calc(var(--ambx-grid) * 3.5);
1012
+ }
1013
+
1014
+ .ambx-bank-lg {
1015
+ --ambx-select-size: calc(var(--ambx-grid) * 14); /* 56px */
1016
+ --ambx-select-pad: calc(var(--ambx-grid) * 2.5);
1017
+ --ambx-select-label-size: calc(var(--ambx-grid) * 4.5);
1018
+ }
1019
+
453
1020
  .ambx-panel {
454
- border-radius: calc(var(--ambx-grid) * 4);
1021
+ border-radius: var(--ambx-radius-xl);
455
1022
  padding: calc(var(--ambx-grid) * 4);
456
1023
  }
457
1024
 
458
- .ambx-button {
459
- min-width: calc(var(--ambx-grid) * 18);
1025
+ /* Opt-in "device face" mode: edge margin = the loose gap tier, so a
1026
+ panel composing a .ambx-rack of controls reads its own border as one
1027
+ more slot in the same lattice (Rams/Braun, Teenage Engineering
1028
+ convention). Not the default padding above, which serves general
1029
+ containers (cards, settings panels) that aren't device faces. */
1030
+ .ambx-panel-device {
1031
+ padding: var(--ambx-gap-loose);
1032
+ }
1033
+
1034
+ /* Layout primitive for composing controls into a device face: pick a
1035
+ gap tier by how tightly the controls belong together (tight = one
1036
+ functional unit/repeated row, normal = a few related controls, loose
1037
+ = separating distinct zones or the panel edge, see .ambx-panel-device
1038
+ above) rather than picking an arbitrary gap per layout. */
1039
+ .ambx-rack {
1040
+ display: flex;
1041
+ align-items: center;
1042
+ gap: var(--ambx-gap-normal);
1043
+ }
1044
+
1045
+ .ambx-rack-tight {
1046
+ gap: var(--ambx-gap-tight);
1047
+ }
1048
+
1049
+ .ambx-rack-loose {
1050
+ gap: var(--ambx-gap-loose);
1051
+ }
1052
+
1053
+ .ambx-rack-column {
1054
+ flex-direction: column;
1055
+ }
1056
+
1057
+ .ambx-press {
460
1058
  line-height: var(--ambx-type-3-line);
1059
+ --ambx-button-size: calc(var(--ambx-grid) * 18); /* 72px pill */
1060
+ --ambx-button-cap-pad-x: calc(var(--ambx-grid) * 2); /* 8px */
1061
+ --ambx-button-cap-pad-y: calc(var(--ambx-grid) * 1.5); /* 6px */
1062
+ --ambx-button-cap-inset: var(--ambx-grid); /* 4px */
461
1063
  }
462
1064
 
463
- .ambx-switch {
464
- --ambx-switch-w: calc(var(--ambx-grid) * 12);
465
- --ambx-switch-h: calc(var(--ambx-grid) * 6);
1065
+ .ambx-press-sm {
1066
+ --ambx-button-size: calc(var(--ambx-grid) * 16); /* 64px pill */
1067
+ --ambx-button-cap-pad-x: calc(var(--ambx-grid) * 1.5); /* 6px */
1068
+ --ambx-button-cap-pad-y: var(--ambx-grid); /* 4px */
1069
+ --ambx-button-cap-inset: calc(var(--ambx-grid) * 0.75); /* 3px */
466
1070
  }
467
1071
 
468
- .ambx-switch-sm {
469
- --ambx-switch-w: calc(var(--ambx-grid) * 10);
470
- --ambx-switch-h: calc(var(--ambx-grid) * 5);
1072
+ .ambx-press-md {
1073
+ --ambx-button-size: calc(var(--ambx-grid) * 18); /* 72px pill */
1074
+ --ambx-button-cap-pad-x: calc(var(--ambx-grid) * 2); /* 8px */
1075
+ --ambx-button-cap-pad-y: calc(var(--ambx-grid) * 1.5); /* 6px */
1076
+ --ambx-button-cap-inset: var(--ambx-grid); /* 4px */
471
1077
  }
472
1078
 
473
- .ambx-switch-md {
474
- --ambx-switch-w: calc(var(--ambx-grid) * 12);
475
- --ambx-switch-h: calc(var(--ambx-grid) * 6);
1079
+ .ambx-press-lg {
1080
+ --ambx-button-size: calc(var(--ambx-grid) * 20); /* 80px pill */
1081
+ --ambx-button-cap-pad-x: calc(var(--ambx-grid) * 2.5); /* 10px */
1082
+ --ambx-button-cap-pad-y: calc(var(--ambx-grid) * 2); /* 8px */
1083
+ --ambx-button-cap-inset: calc(var(--ambx-grid) * 1.25); /* 5px */
476
1084
  }
477
1085
 
478
- .ambx-switch-lg {
479
- --ambx-switch-w: calc(var(--ambx-grid) * 16);
480
- --ambx-switch-h: calc(var(--ambx-grid) * 8);
1086
+ /* Round/square shapes have their own min-width table per size (not a
1087
+ ratio of the pill's — the three shapes' true md values, 72/48/56,
1088
+ share no common factor), overridden via compound selectors so they
1089
+ win regardless of source order relative to .ambx-press-sm/-md/-lg. */
1090
+ .amb-button-round.ambx-press-sm { --ambx-button-size: calc(var(--ambx-grid) * 10); } /* 40px */
1091
+ .amb-button-round.ambx-press-md { --ambx-button-size: calc(var(--ambx-grid) * 12); } /* 48px */
1092
+ .amb-button-round.ambx-press-lg { --ambx-button-size: calc(var(--ambx-grid) * 14); } /* 56px */
1093
+
1094
+ .amb-button-square.ambx-press-sm { --ambx-button-size: calc(var(--ambx-grid) * 12); } /* 48px */
1095
+ .amb-button-square.ambx-press-md { --ambx-button-size: calc(var(--ambx-grid) * 14); } /* 56px */
1096
+ .amb-button-square.ambx-press-lg { --ambx-button-size: calc(var(--ambx-grid) * 16); } /* 64px */
1097
+
1098
+ .ambx-press-sm .amb-button-cap {
1099
+ font-size: var(--ambx-label-size);
1100
+ line-height: var(--ambx-label-line);
1101
+ }
1102
+
1103
+ .ambx-press-lg .amb-button-cap {
1104
+ font-size: calc(var(--ambx-grid) * 3.5);
1105
+ line-height: calc(var(--ambx-grid) * 5);
481
1106
  }
482
1107
 
483
1108
  .ambx-stack {
@@ -495,49 +1120,270 @@
495
1120
  color: var(--amb-label);
496
1121
  }
497
1122
 
498
- .ambx-slider + .ambx-label {
1123
+ .ambx-travel-horizontal + .ambx-label {
499
1124
  margin-top: var(--ambx-slider-label-clearance);
500
1125
  }
501
1126
 
502
- .ambx-fader + .ambx-label {
1127
+ .ambx-travel-vertical + .ambx-label {
503
1128
  margin-top: var(--ambx-fader-label-clearance);
504
1129
  }
505
1130
 
506
- .ambx-knob + .ambx-label {
1131
+ .ambx-rotary + .ambx-label {
507
1132
  margin-top: var(--ambx-knob-label-clearance);
508
1133
  }
509
1134
 
510
- .ambx-knob {
511
- position: relative;
512
- display: grid;
513
- place-items: center;
514
- touch-action: none;
515
- user-select: none;
1135
+ /* The full ring reaches past the knob's own box, so it has to be paid for in
1136
+ layout or it collides with whatever sits beside the knob. Geometrically only
1137
+ three sides need it — the widest dots are the pair at ±90deg (1.33R out,
1138
+ left and right) and the one at 12 o'clock, while the arc's bottom ends land
1139
+ at 0.94R, inside the box — but the clearance is applied on all four sides
1140
+ anyway, and that is deliberate: margin on three sides moves the knob off the
1141
+ centre of its own box, so turning markers on would shift the knob down from
1142
+ wherever a caller positioned it. Measured against the referent, the
1143
+ three-sided version put the knob 6.4px low at the md size. The price is a
1144
+ band of unused space under the arc; keeping the knob concentric with the box
1145
+ it occupies is worth it.
1146
+
1147
+ markers="ends" gets no clearance: its two dots sit at 0.94R, so only the
1148
+ 1.3px their edges reach past the box (at md) falls outside, well inside the
1149
+ drop shadow the knob already casts there. */
1150
+ .amb-knob-markers-full {
1151
+ margin: var(--ambx-knob-marker-clearance);
1152
+ --ambx-knob-marker-clearance: calc(
1153
+ var(--ambx-knob-marker-radius) + var(--ambx-knob-marker-size) / 2 -
1154
+ var(--ambx-knob-size) / 2
1155
+ );
1156
+ }
1157
+
1158
+
1159
+ /* ==========================================================================
1160
+ KIT: console
1161
+
1162
+ A mixer-desk visual language, measured off photographs of the real
1163
+ controls. It dresses knobs and switches and leaves the rest to `grounded`,
1164
+ which is what a partial third-party kit looks like.
1165
+
1166
+ Everything here is paint. The kit adds no behaviour and touches no
1167
+ mechanism: the toggle's thumb travels because the latch already moves its
1168
+ actuator frame by --ambx-percent, and the track lights because a stylesheet
1169
+ can read that same number. If a genuinely different visual language had
1170
+ needed new mechanism code, the v3 split would not have been clean enough.
1171
+ ========================================================================== */
1172
+
1173
+ /* -- console knob ---------------------------------------------------------
1174
+ A cuboid bar standing on a flat face, housed in a circular groove cut into
1175
+ the panel — the same construction the button uses, a groove for the
1176
+ housing and the flat part seated in it. The bar IS the pointer, and a
1177
+ short mark printed near one end of it says which end reads. Measured off
1178
+ the reference: the housing ring is the button's own clearance gap and the
1179
+ bar is 0.24 of the face it stands on. */
1180
+ .amb-console-knob {
1181
+ border-radius: 50%;
1182
+ /* The same gap the button leaves around its cap, and a fixed length for
1183
+ the same reason: it is a clearance between two parts, not a feature
1184
+ that scales with the control. */
1185
+ --ambx-console-housing: var(--ambx-grid-half);
1186
+ --ambx-console-face: calc(var(--ambx-size) - 2 * var(--ambx-console-housing));
1187
+ --ambx-console-bar-width: calc(var(--ambx-console-face) * 0.24);
1188
+ /* Just short of the face. The bar swings through every angle inside a
1189
+ circle, so anything longer would cross the housing at the diagonals —
1190
+ and unlike the panel graphics, which pay for their overhang in the two
1191
+ clearance classes at the end of this block, the actuator has no margin
1192
+ of its own to spend. */
1193
+ --ambx-console-bar-length: calc(var(--ambx-console-face) * 0.94);
516
1194
  }
517
1195
 
518
- .ambx-knob-rotation {
1196
+ /* The housing: a circular groove, showing as a thin dark ring around the
1197
+ face — the button's well, cut round. It takes the button's seat depth and
1198
+ the button's floor colour: --amb-lume is near-black under a bright key,
1199
+ which is what makes a clearance gap read as a gap rather than as a paler
1200
+ ring of the same panel. */
1201
+ .amb-console-housing {
519
1202
  position: absolute;
520
1203
  inset: 0;
1204
+ border-radius: 50%;
1205
+ --amb-thickness: 0.27;
1206
+ background-color: var(--amb-lume);
521
1207
  }
522
1208
 
523
- .ambx-fader {
524
- width: calc(var(--ambx-grid) * 2);
525
- border-radius: 999px;
526
- touch-action: none;
527
- user-select: none;
1209
+ /* The face: flat, and the panel's own surface colour. No thickness, so it
1210
+ has no edge to catch light and casts nothing — what draws the knob's
1211
+ outline is the dark ring of housing around it. */
1212
+ .amb-console-face {
1213
+ position: absolute;
1214
+ inset: var(--ambx-console-housing);
1215
+ border-radius: 50%;
528
1216
  }
529
1217
 
530
- .ambx-fader-thumb {
531
- transform: translate(-50%, -50%);
1218
+ /* The bar rides the rotating actuator frame, so its lit edge would turn with
1219
+ it: at 180deg the chamfer highlight would sit on the shadow side of the
1220
+ screen and the drop shadow would fall towards the light. Rotating the
1221
+ LIGHT the other way fixes both at once, and it is the same trick the
1222
+ scale ring plays in reverse — the geometry is already published on the
1223
+ root, so a stylesheet can do the trigonometry.
1224
+
1225
+ This outer span exists only to capture the scene's light vector: a custom
1226
+ property cannot read itself, and `--amb-light-x: calc(var(--amb-light-x)
1227
+ ...)` on one element is a cycle, which resolves to invalid at
1228
+ computed-value time and takes the whole .ambient box-shadow composite
1229
+ down with it — silently, and looking exactly like a flat rectangle. */
1230
+ .amb-console-bar {
1231
+ position: absolute;
1232
+ inset: 0;
1233
+ --_ambx-console-light-x: var(--amb-light-x);
1234
+ --_ambx-console-light-y: var(--amb-light-y);
532
1235
  }
533
1236
 
534
- .ambx-slider {
535
- height: calc(var(--ambx-grid) * 2);
536
- border-radius: 999px;
537
- touch-action: none;
538
- user-select: none;
1237
+ /* The world light, restated in the frame's own turned coordinates: the
1238
+ inverse rotation, [cos, sin; -sin, cos] applied to the captured vector.
1239
+ --ambx-angle is an <angle>, which is what cos()/sin() want, so the whole
1240
+ correction is these two lines. */
1241
+ .amb-console-bar-body {
1242
+ position: absolute;
1243
+ top: 50%;
1244
+ left: 50%;
1245
+ width: var(--ambx-console-bar-width);
1246
+ height: var(--ambx-console-bar-length);
1247
+ margin-top: calc(var(--ambx-console-bar-length) / -2);
1248
+ margin-left: calc(var(--ambx-console-bar-width) / -2);
1249
+ /* A machined round-over, not a pill: the reference bar's corners are
1250
+ barely broken. */
1251
+ border-radius: calc(var(--ambx-size) * 0.028);
1252
+ /* The bar is the panel's own surface colour, the same material as the
1253
+ face it stands on, so it is not told apart by tone at all: the chamfer
1254
+ down its long edges and the shadow it drops on the face are what read
1255
+ as a block standing proud. */
1256
+ --amb-light-x: calc(
1257
+ var(--_ambx-console-light-x) * cos(var(--ambx-angle)) +
1258
+ var(--_ambx-console-light-y) * sin(var(--ambx-angle))
1259
+ );
1260
+ --amb-light-y: calc(
1261
+ var(--_ambx-console-light-y) * cos(var(--ambx-angle)) -
1262
+ var(--_ambx-console-light-x) * sin(var(--ambx-angle))
1263
+ );
1264
+ /* Standing on the face, not floating above it: the block's own body casts
1265
+ the shadow (the part carries the thickness), and elevation stays at zero
1266
+ so the shadow hugs its shadow-side edges the way a body at rest does. */
1267
+ --amb-elevation: 0;
539
1268
  }
540
1269
 
541
- .ambx-slider-thumb {
542
- transform: translate(-50%, -50%);
1270
+ /* The indicator: a short mark printed across the bar near one end. It is
1271
+ ink, not geometry — no edges, no shadow — and it is inside the bar rather
1272
+ than beside it because the bar already occupies the whole diameter it
1273
+ would otherwise sit on. */
1274
+ .amb-console-indicator {
1275
+ position: absolute;
1276
+ left: 50%;
1277
+ top: 7%;
1278
+ width: 62%;
1279
+ height: 7.5%;
1280
+ transform: translateX(-50%);
1281
+ border-radius: calc(var(--ambx-size) * 0.012);
1282
+ background-color: color-mix(in oklab, var(--amb-lit), black 82%);
1283
+ }
1284
+
1285
+ /* Panel graphics: the accent centre mark above the knob and the -/+ legends
1286
+ beside it. All three sit outside the knob's own box, which is what the
1287
+ panel frame allows — and what the two clearance classes below pay for. */
1288
+ .amb-console-marks {
1289
+ position: absolute;
1290
+ inset: 0;
1291
+ }
1292
+
1293
+ .amb-console-mark {
1294
+ position: absolute;
1295
+ left: 50%;
1296
+ bottom: 100%;
1297
+ width: calc(var(--ambx-size) * 0.085);
1298
+ height: calc(var(--ambx-size) * 0.2);
1299
+ margin-bottom: calc(var(--ambx-size) * 0.07);
1300
+ border-radius: var(--ambx-radius-sm);
1301
+ background-color: var(--ambx-accent);
1302
+ transform: translateX(-50%);
1303
+ }
1304
+
1305
+ .amb-console-legend {
1306
+ position: absolute;
1307
+ top: 58%;
1308
+ font-size: calc(var(--ambx-size) * 0.26);
1309
+ line-height: 1;
1310
+ color: var(--amb-label);
1311
+ }
1312
+
1313
+ .amb-console-legend-min {
1314
+ right: 100%;
1315
+ margin-right: calc(var(--ambx-size) * 0.1);
1316
+ }
1317
+
1318
+ .amb-console-legend-max {
1319
+ left: 100%;
1320
+ margin-left: calc(var(--ambx-size) * 0.1);
1321
+ }
1322
+
1323
+ /* Clearance for graphics that overflow the control's box. The centre mark
1324
+ only reaches upward, so unlike the grounded marker ring — which is paid
1325
+ for on all four sides to keep the knob concentric — this one is a top
1326
+ margin, and the knob deliberately sits low in its box because that is
1327
+ where the mark puts it. */
1328
+ .amb-console-knob-marked {
1329
+ margin-top: calc(var(--ambx-size) * 0.3);
1330
+ }
1331
+
1332
+ .amb-console-knob-legended {
1333
+ margin-inline: calc(var(--ambx-size) * 0.34);
1334
+ }
1335
+
1336
+ /* -- console toggle -------------------------------------------------------
1337
+ A pill track that fills with the accent as the switch travels, carrying a
1338
+ round accent thumb inside a white ring. Track 2.06:1; the thumb takes 0.9
1339
+ of the track height, which is a thumb that nearly fills its groove — the
1340
+ clearance has to come down with it or a thumb that size runs off the ends
1341
+ of its own travel. */
1342
+ .amb-console-toggle {
1343
+ --ambx-switch-w: calc(var(--ambx-grid) * 13);
1344
+ --ambx-switch-h: calc(var(--ambx-grid) * 6.3);
1345
+ --ambx-latch-thumb-w: calc(var(--ambx-switch-h) * 0.9);
1346
+ --ambx-latch-thumb-h: calc(var(--ambx-switch-h) * 0.9);
1347
+ --ambx-latch-inset: calc(var(--ambx-switch-h) * 0.05);
1348
+ }
1349
+
1350
+ /* The track is a pure-CSS part: it reads --ambx-percent off the control root
1351
+ and mixes its own colour from it, so it lights and dims with the travel
1352
+ without the mechanism knowing this element exists. */
1353
+ .amb-console-track {
1354
+ position: absolute;
1355
+ inset: 0;
1356
+ border-radius: var(--ambx-radius-full);
1357
+ background-color: color-mix(
1358
+ in oklab,
1359
+ color-mix(in oklab, var(--amb-lit), black 55%),
1360
+ var(--ambx-accent) calc(var(--ambx-percent) * 100%)
1361
+ );
1362
+ --amb-thickness: 0.4;
1363
+ transition: background-color 160ms ease;
1364
+ }
1365
+
1366
+ /* Flat-topped, but a body: `.ambient` with a thickness and NO edge
1367
+ treatment, which is the combination that casts a drop shadow while
1368
+ leaving the top face unbroken — the chamfer and fillet layers of the
1369
+ composite are gated on their own properties, and neither is set here. */
1370
+ .amb-console-thumb {
1371
+ position: absolute;
1372
+ inset: 0;
1373
+ border-radius: 50%;
1374
+ background-color: color-mix(in oklab, var(--ambx-accent), white 12%);
1375
+ /* The white ring is a BORDER, not a spread shadow. Two reasons, and the
1376
+ second is the load-bearing one: with the package's border-box reset the
1377
+ ring grows inward, so the thumb keeps the outer diameter measured off
1378
+ the reference — and `box-shadow` is the property `.ambient` composites
1379
+ the whole lighting model into, so setting it here would silently wipe
1380
+ the drop shadow the thickness above is there to cast. */
1381
+ border: calc(var(--ambx-latch-thumb-h) * 0.09) solid
1382
+ hsl(var(--amb-light-hue) var(--amb-light-saturation) 99%);
1383
+ }
1384
+
1385
+ @media (prefers-reduced-motion: reduce) {
1386
+ .amb-console-track {
1387
+ transition: none;
1388
+ }
543
1389
  }