@ambientcss/components 2.1.0 → 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 (45) hide show
  1. package/README.md +35 -0
  2. package/dist/index.cjs +1540 -391
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +749 -52
  5. package/dist/index.d.ts +749 -52
  6. package/dist/index.js +1501 -391
  7. package/dist/index.js.map +1 -1
  8. package/dist/styles.css +998 -291
  9. package/package.json +2 -2
  10. package/src/components/AmbientButton.tsx +23 -27
  11. package/src/components/AmbientFader.tsx +29 -115
  12. package/src/components/AmbientKnob.tsx +58 -220
  13. package/src/components/AmbientPanel.tsx +8 -2
  14. package/src/components/AmbientProvider.tsx +3 -0
  15. package/src/components/AmbientSelect.tsx +40 -0
  16. package/src/components/AmbientSlider.tsx +28 -113
  17. package/src/components/AmbientSwitch.tsx +58 -58
  18. package/src/controls/AmbientBank.tsx +138 -0
  19. package/src/controls/AmbientLatch.tsx +78 -0
  20. package/src/controls/AmbientPress.tsx +74 -0
  21. package/src/controls/AmbientRotary.tsx +94 -0
  22. package/src/controls/AmbientTravel.tsx +83 -0
  23. package/src/core/context.tsx +46 -0
  24. package/src/core/controllable.ts +33 -0
  25. package/src/core/dev.ts +15 -0
  26. package/src/core/frames.tsx +63 -0
  27. package/src/core/kit.tsx +107 -0
  28. package/src/core/material.ts +27 -0
  29. package/src/core/numeric.ts +88 -0
  30. package/src/core/types.ts +116 -0
  31. package/src/core/useBank.ts +163 -0
  32. package/src/core/useLatch.ts +54 -0
  33. package/src/core/usePress.ts +120 -0
  34. package/src/core/useRotary.ts +253 -0
  35. package/src/core/useTravel.ts +141 -0
  36. package/src/index.ts +122 -2
  37. package/src/kits/console.tsx +80 -0
  38. package/src/kits/grounded.tsx +113 -0
  39. package/src/parts/bank.tsx +32 -0
  40. package/src/parts/console.tsx +99 -0
  41. package/src/parts/knob.tsx +203 -0
  42. package/src/parts/latch.tsx +33 -0
  43. package/src/parts/press.tsx +56 -0
  44. package/src/parts/travel.tsx +70 -0
  45. package/src/styles.css +998 -291
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambientcss/components",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "React skeuomorphic components powered by Ambient CSS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,7 +44,7 @@
44
44
  "react-dom": "^18.3.0 || ^19.0.0"
45
45
  },
46
46
  "dependencies": {
47
- "@ambientcss/css": "2.0.0"
47
+ "@ambientcss/css": "3.0.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/react": "^19.0.8",
@@ -1,5 +1,10 @@
1
- import type { ButtonHTMLAttributes } from "react";
2
1
  import { cn } from "../lib/cn";
2
+ import { AmbientPress } from "../controls/AmbientPress";
3
+ import type { AmbientPressProps } from "../controls/AmbientPress";
4
+ import { useDress } from "../core/kit";
5
+ import type { KitLook } from "../core/kit";
6
+ import { groundedKit } from "../kits/grounded";
7
+ import type { AmbientMaterial } from "../core/material";
3
8
 
4
9
  /* Cap silhouettes from the referent lineup (ambient3d/generate.py):
5
10
  - "pill": the default wide stadium key (transport-key style)
@@ -10,40 +15,31 @@ import { cn } from "../lib/cn";
10
15
  export type AmbientButtonShape = "pill" | "round" | "square";
11
16
  export type AmbientButtonSize = "sm" | "md" | "lg";
12
17
 
13
- export interface AmbientButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
14
- material?: "matte" | "shiny" | "glass";
15
- shape?: AmbientButtonShape;
16
- size?: AmbientButtonSize;
17
- }
18
+ export type AmbientButtonProps = Omit<AmbientPressProps, "parts" | "size"> & {
19
+ material?: AmbientMaterial | undefined;
20
+ shape?: AmbientButtonShape | undefined;
21
+ size?: AmbientButtonSize | undefined;
22
+ /** Look options in the active kit's vocabulary. */
23
+ look?: KitLook | undefined;
24
+ };
18
25
 
26
+ /** A key cap seated in a clearance well. */
19
27
  export function AmbientButton({
20
28
  className,
21
29
  children,
30
+ look,
22
31
  material = "matte",
23
32
  shape = "pill",
24
33
  size = "md",
25
- ...props
34
+ ...rest
26
35
  }: AmbientButtonProps) {
36
+ const { dress } = useDress("press", { material, shape, children, ...look }, groundedKit.press!);
27
37
  return (
28
- <button
29
- type="button"
30
- className={cn(
31
- "amb-button amb-groove ambx-button",
32
- `ambx-button-${size}`,
33
- shape === "round" && "amb-button-round",
34
- shape === "square" && "amb-button-square",
35
- className
36
- )}
37
- {...props}
38
- >
39
- <span
40
- className={cn(
41
- "amb-button-cap ambient amb-chamfer amb-surface amb-heading-3",
42
- material === "matte" ? "amb-mat-matte" : material === "shiny" ? "amb-mat-shiny" : "amb-mat-glass"
43
- )}
44
- >
45
- {children}
46
- </span>
47
- </button>
38
+ <AmbientPress
39
+ {...rest}
40
+ size={size}
41
+ className={cn(dress.className, className)}
42
+ parts={dress.parts}
43
+ />
48
44
  );
49
45
  }
@@ -1,129 +1,43 @@
1
- import { useId, useRef } from "react";
2
- import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
3
1
  import { cn } from "../lib/cn";
2
+ import { AmbientTravel } from "../controls/AmbientTravel";
3
+ import type { AmbientTravelProps } from "../controls/AmbientTravel";
4
+ import { useDress } from "../core/kit";
5
+ import type { KitLook } from "../core/kit";
6
+ import { groundedKit } from "../kits/grounded";
7
+ import type { AmbientMaterial } from "../core/material";
4
8
 
5
9
  export type AmbientFaderSize = "sm" | "md" | "lg";
6
10
 
7
- export type AmbientFaderProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
8
- value: number;
9
- min?: number;
10
- max?: number;
11
- step?: number;
12
- label?: string;
13
- material?: "matte" | "shiny" | "glass";
14
- size?: AmbientFaderSize;
15
- onChange?: (nextValue: number) => void;
11
+ export type AmbientFaderProps = Omit<AmbientTravelProps, "parts" | "size" | "orientation"> & {
12
+ material?: AmbientMaterial | undefined;
13
+ size?: AmbientFaderSize | undefined;
14
+ /** Look options in the active kit's vocabulary. */
15
+ look?: KitLook | undefined;
16
16
  };
17
17
 
18
- function clamp(value: number, min: number, max: number): number {
19
- return Math.min(max, Math.max(min, value));
20
- }
21
-
18
+ /** A pill cap on a stem, riding an upright slot. Min is at the bottom,
19
+ * which `AmbientTravel` already does for a vertical track. */
22
20
  export function AmbientFader({
23
- value,
24
- min = 0,
25
- max = 100,
26
- step = 1,
27
- label,
28
21
  material,
22
+ look,
29
23
  size = "md",
30
- onChange,
24
+ animate,
31
25
  className,
32
- ...props
26
+ ...rest
33
27
  }: AmbientFaderProps) {
34
- const id = useId();
35
- const trackRef = useRef<HTMLDivElement | null>(null);
36
- const safeStep = step > 0 ? step : 1;
37
-
38
- const percent = ((value - min) / (max - min || 1)) * 100;
39
-
40
- const updateFromClientY = (clientY: number) => {
41
- const track = trackRef.current;
42
- if (!track) return;
43
-
44
- const rect = track.getBoundingClientRect();
45
- const ratio = 1 - (clientY - rect.top) / rect.height;
46
- const nextValue = min + clamp(ratio, 0, 1) * (max - min);
47
- const snapped = Math.round(nextValue / safeStep) * safeStep;
48
- onChange?.(clamp(snapped, min, max));
49
- };
50
-
51
- const setValue = (nextValue: number) => {
52
- const snapped = Math.round(nextValue / safeStep) * safeStep;
53
- onChange?.(clamp(snapped, min, max));
54
- };
55
-
56
- const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
57
- const pageStep = safeStep * 10;
58
- switch (event.key) {
59
- case "ArrowUp":
60
- case "ArrowRight":
61
- event.preventDefault();
62
- setValue(value + safeStep);
63
- break;
64
- case "ArrowDown":
65
- case "ArrowLeft":
66
- event.preventDefault();
67
- setValue(value - safeStep);
68
- break;
69
- case "PageUp":
70
- event.preventDefault();
71
- setValue(value + pageStep);
72
- break;
73
- case "PageDown":
74
- event.preventDefault();
75
- setValue(value - pageStep);
76
- break;
77
- case "Home":
78
- event.preventDefault();
79
- setValue(min);
80
- break;
81
- case "End":
82
- event.preventDefault();
83
- setValue(max);
84
- break;
85
- default:
86
- break;
87
- }
88
- };
89
-
90
- const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
91
- if (event.buttons !== 1) return;
92
- updateFromClientY(event.clientY);
93
- };
94
-
28
+ const { dress, defaults } = useDress(
29
+ "travel",
30
+ { material, orientation: "vertical", ...look },
31
+ groundedKit.travel!
32
+ );
95
33
  return (
96
- <div className={cn("ambx-stack", className)} {...props}>
97
- <div
98
- className={cn("amb-fader amb-groove ambx-fader", `ambx-fader-${size}`)}
99
- ref={trackRef}
100
- role="slider"
101
- aria-label={label}
102
- aria-labelledby={label ? id : undefined}
103
- aria-valuemin={min}
104
- aria-valuemax={max}
105
- aria-valuenow={value}
106
- aria-orientation="vertical"
107
- tabIndex={0}
108
- onPointerDown={(event) => {
109
- event.currentTarget.setPointerCapture(event.pointerId);
110
- updateFromClientY(event.clientY);
111
- }}
112
- onPointerMove={onPointerMove}
113
- onKeyDown={onKeyDown}
114
- >
115
- <div
116
- className={cn("amb-fader-thumb ambient amb-fillet ambx-fader-thumb", material !== "glass" && "amb-surface-concave", material && `amb-mat-${material}`)}
117
- style={{ top: `${100 - percent}%` }}
118
- >
119
- <span className="amb-fader-gripline" />
120
- </div>
121
- </div>
122
- {label ? (
123
- <span id={id} className="ambx-label">
124
- {label}
125
- </span>
126
- ) : null}
127
- </div>
34
+ <AmbientTravel
35
+ {...rest}
36
+ orientation="vertical"
37
+ size={size}
38
+ animate={animate ?? defaults?.animate}
39
+ className={cn(dress.className, className)}
40
+ parts={dress.parts}
41
+ />
128
42
  );
129
43
  }
@@ -1,237 +1,75 @@
1
- import { useId, useRef } from "react";
2
- import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
3
1
  import { cn } from "../lib/cn";
2
+ import { AmbientRotary } from "../controls/AmbientRotary";
3
+ import type { AmbientRotaryProps } from "../controls/AmbientRotary";
4
+ import { useDress } from "../core/kit";
5
+ import type { KitLook } from "../core/kit";
6
+ import type { AmbientMaterial } from "../core/material";
7
+ import { groundedKit } from "../kits/grounded";
4
8
 
5
- /* Straight-knurled silhouettes for the rotating face, in
6
- objectBoundingBox units so the clip scales with the component. Each
7
- variant family mirrors its referent's rib section
8
- (ambient3d/ground_components.py): trapezoid teeth between the outer
9
- radius and the tooth-root radius. The circular body underneath keeps
10
- the smooth drop shadow. */
11
- type KnurlSpec = {
12
- teeth: number; // rib count
13
- root: number; // tooth-root radius (bounding-box units; outer is 0.5)
14
- rise: number; // fraction of the pitch spent climbing to the crest
15
- fall: [number, number]; // crest-end and root-return pitch fractions
16
- };
17
-
18
- const KNURLS: Record<"standard" | "flute" | "fine", KnurlSpec> = {
19
- /* 36 ribs — the grounded referent knob (dot and line variants) */
20
- standard: { teeth: 36, root: 0.468, rise: 0.12, fall: [0.5, 0.62] },
21
- /* 14 broad flutes with deeper roots (OP-Z-style flute variant) */
22
- flute: { teeth: 14, root: 0.44, rise: 0.08, fall: [0.72, 0.8] },
23
- /* 48-rib fine knurl, shallower (cap and wheel variants) */
24
- fine: { teeth: 48, root: 0.476, rise: 0.12, fall: [0.5, 0.62] }
25
- };
26
-
27
- function knurlPath({ teeth, root, rise, fall }: KnurlSpec): string {
28
- const outer = 0.5;
29
- const pitch = (Math.PI * 2) / teeth;
30
- const pts: string[] = [];
31
- for (let i = 0; i < teeth; i++) {
32
- const a = i * pitch;
33
- const tooth: Array<[number, number]> = [
34
- [0, root],
35
- [rise, outer],
36
- [fall[0], outer],
37
- [fall[1], root]
38
- ];
39
- for (const [frac, radius] of tooth) {
40
- const t = a + frac * pitch;
41
- pts.push(
42
- `${(0.5 + radius * Math.cos(t)).toFixed(4)} ${(0.5 + radius * Math.sin(t)).toFixed(4)}`
43
- );
44
- }
45
- }
46
- return `M${pts.join(" L")} Z`;
47
- }
9
+ /** Printed scale dots on the panel around the knob: the arc's two ends, the
10
+ * full graduated ring, or nothing. */
11
+ export type AmbientKnobMarkers = "none" | "ends" | "full";
48
12
 
49
- const KNURL_PATHS = {
50
- standard: knurlPath(KNURLS.standard),
51
- flute: knurlPath(KNURLS.flute),
52
- fine: knurlPath(KNURLS.fine)
53
- };
54
-
55
- /* Knob types from the referent lineup (ambient3d/generate.py):
56
- - "dot": the grounded referent — 36-rib knurl, offset indicator dot
57
- - "line": same body with a radial indicator line (classic pot)
58
- - "flute": 14 broad flutes, centered dot (OP-Z-style encoder)
59
- - "cap": fine knurl under a smooth accent top disc (OP-1-style)
60
- - "wheel": bare fine knurl, no indicator (machined wheel — pair with
61
- material="shiny") */
62
- export type AmbientKnobVariant = "dot" | "line" | "flute" | "cap" | "wheel";
63
-
64
- const VARIANT_FAMILY: Record<AmbientKnobVariant, keyof typeof KNURL_PATHS> = {
65
- dot: "standard",
66
- line: "standard",
67
- flute: "flute",
68
- cap: "fine",
69
- wheel: "fine"
70
- };
13
+ /** The pointer riding the rotating face: a radial bar or an offset dot. */
14
+ export type AmbientKnobIndicator = "rectangle" | "circle";
71
15
 
72
16
  export type AmbientKnobSize = "sm" | "md" | "lg";
73
17
 
74
- export type AmbientKnobProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
75
- value: number;
76
- min?: number;
77
- max?: number;
78
- step?: number;
79
- label?: string;
80
- material?: "matte" | "shiny" | "glass";
81
- variant?: AmbientKnobVariant;
82
- size?: AmbientKnobSize;
83
- onChange?: (nextValue: number) => void;
18
+ export type AmbientKnobProps = Omit<AmbientRotaryProps, "parts" | "size"> & {
19
+ material?: AmbientMaterial | undefined;
20
+ /** Ribbed grip around the rotating body. Off gives a smooth turned body. */
21
+ knurling?: boolean | undefined;
22
+ /** The grip ring's own colour, for a two-tone knob — any CSS colour, read
23
+ * as an albedo, so it still takes the scene's light rather than being
24
+ * painted flat. Unset, the ring is the same material as the cap. */
25
+ knurlColor?: string | undefined;
26
+ markers?: AmbientKnobMarkers | undefined;
27
+ indicator?: AmbientKnobIndicator | undefined;
28
+ size?: AmbientKnobSize | undefined;
29
+ /** Look options in another kit's vocabulary.
30
+ *
31
+ * The five props above are the *grounded* kit's words. A kit that dresses
32
+ * knobs differently has its own, and they arrive here rather than as
33
+ * loose props so the common path stays typed and a misspelt `knurling`
34
+ * is still a compile error. Kits usually ship a preset of their own that
35
+ * types this properly — see `ConsoleKnob`. */
36
+ look?: KitLook | undefined;
84
37
  };
85
38
 
86
- function clamp(value: number, min: number, max: number): number {
87
- return Math.min(max, Math.max(min, value));
88
- }
89
-
39
+ /**
40
+ * The rotary preset: `AmbientRotary` wearing whatever the active kit says a
41
+ * knob looks like, and the grounded hardware knob when no kit is set.
42
+ */
90
43
  export function AmbientKnob({
91
- value,
92
- min = 0,
93
- max = 100,
94
- step = 1,
95
- label,
96
44
  material,
97
- variant = "dot",
45
+ knurling,
46
+ knurlColor,
47
+ markers,
48
+ indicator,
49
+ look,
98
50
  size = "md",
99
- onChange,
100
51
  className,
101
- ...props
52
+ travel,
53
+ input,
54
+ animate,
55
+ ...rest
102
56
  }: AmbientKnobProps) {
103
- const id = useId();
104
- const clipId = `amb-knurl-${id.replace(/:/g, "")}`;
105
- const draggingRef = useRef(false);
106
- const knobRef = useRef<HTMLDivElement>(null);
107
- const safeStep = step > 0 ? step : 1;
108
- const family = VARIANT_FAMILY[variant];
109
-
110
- const percent = (value - min) / (max - min || 1);
111
- const rotation = percent * 270 - 135;
112
-
113
- // Convert pointer position to a knob angle in [-180, 180] where 0 = up,
114
- // positive = clockwise. The knob's active range is [-135, 135] (270°),
115
- // with the dead zone at the bottom (~135° to ~225° i.e. past ±135°).
116
- const pointerToValue = (event: PointerEvent<HTMLDivElement>) => {
117
- const rect = knobRef.current!.getBoundingClientRect();
118
- const cx = rect.left + rect.width / 2;
119
- const cy = rect.top + rect.height / 2;
120
- // atan2 with screen coords: 0=right, 90=down; add 90 to rotate so 0=up
121
- const atan2Deg = Math.atan2(event.clientY - cy, event.clientX - cx) * (180 / Math.PI);
122
- let angle = atan2Deg + 90;
123
- // Normalize to [-180, 180]
124
- if (angle > 180) angle -= 360;
125
-
126
- // Clamp dead zone based on current value position
127
- if (angle < -135 || angle > 135) {
128
- angle = percent >= 0.5 ? 135 : -135;
129
- }
130
-
131
- const range = max - min;
132
- const raw = min + ((angle + 135) / 270) * range;
133
- const snapped = Math.round(raw / safeStep) * safeStep;
134
- onChange?.(clamp(snapped, min, max));
135
- };
136
-
137
- const setValue = (nextValue: number) => {
138
- const snapped = Math.round(nextValue / safeStep) * safeStep;
139
- onChange?.(clamp(snapped, min, max));
140
- };
141
-
142
- const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
143
- const pageStep = safeStep * 10;
144
- switch (event.key) {
145
- case "ArrowUp":
146
- case "ArrowRight":
147
- event.preventDefault();
148
- setValue(value + safeStep);
149
- break;
150
- case "ArrowDown":
151
- case "ArrowLeft":
152
- event.preventDefault();
153
- setValue(value - safeStep);
154
- break;
155
- case "PageUp":
156
- event.preventDefault();
157
- setValue(value + pageStep);
158
- break;
159
- case "PageDown":
160
- event.preventDefault();
161
- setValue(value - pageStep);
162
- break;
163
- case "Home":
164
- event.preventDefault();
165
- setValue(min);
166
- break;
167
- case "End":
168
- event.preventDefault();
169
- setValue(max);
170
- break;
171
- default:
172
- break;
173
- }
174
- };
57
+ const { dress, defaults } = useDress(
58
+ "rotary",
59
+ { material, knurling, knurlColor, markers, indicator, ...look },
60
+ groundedKit.rotary!
61
+ );
175
62
 
176
63
  return (
177
- <div className={cn("ambx-stack", className)} {...props}>
178
- <div
179
- ref={knobRef}
180
- className={cn("amb-knob ambx-knob", `ambx-knob-${size}`)}
181
- role="slider"
182
- aria-label={label}
183
- aria-valuemin={min}
184
- aria-valuemax={max}
185
- aria-valuenow={value}
186
- aria-labelledby={label ? id : undefined}
187
- aria-orientation="vertical"
188
- tabIndex={0}
189
- onPointerDown={(event) => {
190
- event.currentTarget.setPointerCapture(event.pointerId);
191
- draggingRef.current = true;
192
- pointerToValue(event);
193
- }}
194
- onPointerMove={(event) => {
195
- if (draggingRef.current) pointerToValue(event);
196
- }}
197
- onPointerUp={() => {
198
- draggingRef.current = false;
199
- }}
200
- onPointerCancel={() => {
201
- draggingRef.current = false;
202
- }}
203
- onKeyDown={onKeyDown}
204
- >
205
- <svg width={0} height={0} style={{ position: "absolute" }} aria-hidden focusable={false}>
206
- <defs>
207
- <clipPath id={clipId} clipPathUnits="objectBoundingBox">
208
- <path d={KNURL_PATHS[family]} />
209
- </clipPath>
210
- </defs>
211
- </svg>
212
- <span className="amb-knob-body ambient amb-thickness-2 amb-surface" />
213
- <div
214
- className={cn(
215
- "ambx-knob-rotation amb-knob-face",
216
- family === "flute" && "amb-knob-face-flute",
217
- family === "fine" && "amb-knob-face-fine",
218
- variant === "cap" && "amb-knob-face-cap",
219
- material && `amb-mat-${material}`
220
- )}
221
- style={{ transform: `rotate(${rotation}deg)`, clipPath: `url(#${clipId})` }}
222
- >
223
- {variant === "dot" ? <span className="amb-knob-indicator-dot" /> : null}
224
- {variant === "line" ? <span className="amb-knob-indicator-line" /> : null}
225
- {variant === "flute" ? (
226
- <span className="amb-knob-indicator-dot amb-knob-indicator-dot-center" />
227
- ) : null}
228
- </div>
229
- </div>
230
- {label ? (
231
- <span id={id} className="ambx-label">
232
- {label}
233
- </span>
234
- ) : null}
235
- </div>
64
+ <AmbientRotary
65
+ {...rest}
66
+ /* Caller wins over kit, kit wins over the mechanism's own default. */
67
+ travel={travel ?? defaults?.travel}
68
+ input={input ?? defaults?.input}
69
+ animate={animate ?? defaults?.animate}
70
+ size={size}
71
+ className={cn(dress.className, className)}
72
+ parts={dress.parts}
73
+ />
236
74
  );
237
75
  }
@@ -1,16 +1,22 @@
1
1
  import type { HTMLAttributes } from "react";
2
2
  import { cn } from "../lib/cn";
3
+ import type { AmbientMaterial } from "../core/material";
3
4
 
4
5
  export interface AmbientPanelProps extends HTMLAttributes<HTMLDivElement> {
5
- material?: "matte" | "shiny" | "glass";
6
+ material?: AmbientMaterial;
6
7
  }
7
8
 
9
+ /** A panel is a plain surface, so it can wear any finish directly: it spends
10
+ * neither pseudo-element of its own, which is what the two micro-relief
11
+ * materials need. Their grain paints below the panel's children, and their
12
+ * `overflow: hidden` clips whatever hangs outside it — a control's drop
13
+ * shadow at the very edge included. */
8
14
  export function AmbientPanel({ className, material = "matte", ...props }: AmbientPanelProps) {
9
15
  return (
10
16
  <div
11
17
  className={cn(
12
18
  "ambient amb-surface amb-chamfer amb-elevation-2 ambx-panel",
13
- material === "matte" ? "amb-mat-matte" : material === "shiny" ? "amb-mat-shiny" : "amb-mat-glass",
19
+ `amb-mat-${material}`,
14
20
  className
15
21
  )}
16
22
  {...props}
@@ -47,6 +47,9 @@ export function AmbientProvider({ children, className, style, theme }: AmbientPr
47
47
  ].join(" ");
48
48
  themeVars["--amb-label"] =
49
49
  "hsl(var(--amb-light-hue) var(--amb-light-saturation) calc((1 - var(--amb-key-light-intensity)) * 60% + 20%))";
50
+ // --amb-curve-delta deliberately absent: unlike these two it is declared
51
+ // per element in ambient.css, so it already re-resolves against the light
52
+ // overridden here. Re-declaring it would only re-copy the fit.
50
53
  const mergedStyle = { ...themeVars, ...style } as CSSProperties;
51
54
 
52
55
  return (
@@ -0,0 +1,40 @@
1
+ import { cn } from "../lib/cn";
2
+ import { AmbientBank } from "../controls/AmbientBank";
3
+ import type { AmbientBankProps } from "../controls/AmbientBank";
4
+ import type { BankOption, BankOrientation } from "../core/useBank";
5
+ import { useDress } from "../core/kit";
6
+ import type { KitLook } from "../core/kit";
7
+ import { groundedKit } from "../kits/grounded";
8
+
9
+ export type AmbientSelectOption = BankOption;
10
+ export type AmbientSelectOrientation = BankOrientation;
11
+ export type AmbientSelectSize = "sm" | "md" | "lg";
12
+
13
+ export type AmbientSelectProps = Omit<AmbientBankProps, "keyParts" | "parts" | "size"> & {
14
+ size?: AmbientSelectSize | undefined;
15
+ /** Look options in the active kit's vocabulary. */
16
+ look?: KitLook | undefined;
17
+ };
18
+
19
+ /**
20
+ * A bank of lamp-lit keys in a shared rail — the hardware idiom for a
21
+ * mode selector, where the state is a lamp rather than a mark.
22
+ *
23
+ * Three physical layers per key, and the ORDER is the whole trick: the key
24
+ * itself is the pocket floor, the lens is a disc lying on it, and the cap
25
+ * is a translucent diffuser over both. The cap is `.amb-mat-glass`, so its
26
+ * backdrop-filter blurs the lens behind it — which is why the lens goes in
27
+ * the `base` frame and the cap in `actuator`, and why swapping them would
28
+ * put out every lamp.
29
+ */
30
+ export function AmbientSelect({ size = "md", look, className, ...rest }: AmbientSelectProps) {
31
+ const { dress } = useDress("bank", { ...look }, groundedKit.bank!);
32
+ return (
33
+ <AmbientBank
34
+ {...rest}
35
+ size={size}
36
+ className={cn(dress.className, className)}
37
+ keyParts={dress.parts}
38
+ />
39
+ );
40
+ }