@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambientcss/components",
3
- "version": "2.0.1",
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)
@@ -8,38 +13,33 @@ import { cn } from "../lib/cn";
8
13
  - "square": a squarer, flatter pad (EP-133-style, tighter corners,
9
14
  lower cap) */
10
15
  export type AmbientButtonShape = "pill" | "round" | "square";
16
+ export type AmbientButtonSize = "sm" | "md" | "lg";
11
17
 
12
- export interface AmbientButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
13
- material?: "matte" | "shiny" | "glass";
14
- shape?: AmbientButtonShape;
15
- }
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
+ };
16
25
 
26
+ /** A key cap seated in a clearance well. */
17
27
  export function AmbientButton({
18
28
  className,
19
29
  children,
30
+ look,
20
31
  material = "matte",
21
32
  shape = "pill",
22
- ...props
33
+ size = "md",
34
+ ...rest
23
35
  }: AmbientButtonProps) {
36
+ const { dress } = useDress("press", { material, shape, children, ...look }, groundedKit.press!);
24
37
  return (
25
- <button
26
- type="button"
27
- className={cn(
28
- "amb-button amb-groove ambx-button",
29
- shape === "round" && "amb-button-round",
30
- shape === "square" && "amb-button-square",
31
- className
32
- )}
33
- {...props}
34
- >
35
- <span
36
- className={cn(
37
- "amb-button-cap ambient amb-chamfer amb-surface amb-heading-3",
38
- material === "matte" ? "amb-mat-matte" : material === "shiny" ? "amb-mat-shiny" : "amb-mat-glass"
39
- )}
40
- >
41
- {children}
42
- </span>
43
- </button>
38
+ <AmbientPress
39
+ {...rest}
40
+ size={size}
41
+ className={cn(dress.className, className)}
42
+ parts={dress.parts}
43
+ />
44
44
  );
45
45
  }
@@ -1,125 +1,43 @@
1
- import { useId, useRef } from "react";
2
- import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
3
1
  import { cn } from "../lib/cn";
4
-
5
- export type AmbientFaderProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
6
- value: number;
7
- min?: number;
8
- max?: number;
9
- step?: number;
10
- label?: string;
11
- material?: "matte" | "shiny" | "glass";
12
- onChange?: (nextValue: number) => void;
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";
8
+
9
+ export type AmbientFaderSize = "sm" | "md" | "lg";
10
+
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;
13
16
  };
14
17
 
15
- function clamp(value: number, min: number, max: number): number {
16
- return Math.min(max, Math.max(min, value));
17
- }
18
-
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. */
19
20
  export function AmbientFader({
20
- value,
21
- min = 0,
22
- max = 100,
23
- step = 1,
24
- label,
25
21
  material,
26
- onChange,
22
+ look,
23
+ size = "md",
24
+ animate,
27
25
  className,
28
- ...props
26
+ ...rest
29
27
  }: AmbientFaderProps) {
30
- const id = useId();
31
- const trackRef = useRef<HTMLDivElement | null>(null);
32
- const safeStep = step > 0 ? step : 1;
33
-
34
- const percent = ((value - min) / (max - min || 1)) * 100;
35
-
36
- const updateFromClientY = (clientY: number) => {
37
- const track = trackRef.current;
38
- if (!track) return;
39
-
40
- const rect = track.getBoundingClientRect();
41
- const ratio = 1 - (clientY - rect.top) / rect.height;
42
- const nextValue = min + clamp(ratio, 0, 1) * (max - min);
43
- const snapped = Math.round(nextValue / safeStep) * safeStep;
44
- onChange?.(clamp(snapped, min, max));
45
- };
46
-
47
- const setValue = (nextValue: number) => {
48
- const snapped = Math.round(nextValue / safeStep) * safeStep;
49
- onChange?.(clamp(snapped, min, max));
50
- };
51
-
52
- const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
53
- const pageStep = safeStep * 10;
54
- switch (event.key) {
55
- case "ArrowUp":
56
- case "ArrowRight":
57
- event.preventDefault();
58
- setValue(value + safeStep);
59
- break;
60
- case "ArrowDown":
61
- case "ArrowLeft":
62
- event.preventDefault();
63
- setValue(value - safeStep);
64
- break;
65
- case "PageUp":
66
- event.preventDefault();
67
- setValue(value + pageStep);
68
- break;
69
- case "PageDown":
70
- event.preventDefault();
71
- setValue(value - pageStep);
72
- break;
73
- case "Home":
74
- event.preventDefault();
75
- setValue(min);
76
- break;
77
- case "End":
78
- event.preventDefault();
79
- setValue(max);
80
- break;
81
- default:
82
- break;
83
- }
84
- };
85
-
86
- const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
87
- if (event.buttons !== 1) return;
88
- updateFromClientY(event.clientY);
89
- };
90
-
28
+ const { dress, defaults } = useDress(
29
+ "travel",
30
+ { material, orientation: "vertical", ...look },
31
+ groundedKit.travel!
32
+ );
91
33
  return (
92
- <div className={cn("ambx-stack", className)} {...props}>
93
- <div
94
- className="amb-fader amb-groove ambx-fader"
95
- ref={trackRef}
96
- role="slider"
97
- aria-label={label}
98
- aria-labelledby={label ? id : undefined}
99
- aria-valuemin={min}
100
- aria-valuemax={max}
101
- aria-valuenow={value}
102
- aria-orientation="vertical"
103
- tabIndex={0}
104
- onPointerDown={(event) => {
105
- event.currentTarget.setPointerCapture(event.pointerId);
106
- updateFromClientY(event.clientY);
107
- }}
108
- onPointerMove={onPointerMove}
109
- onKeyDown={onKeyDown}
110
- >
111
- <div
112
- className={cn("amb-fader-thumb ambient amb-fillet ambx-fader-thumb", material !== "glass" && "amb-surface-concave", material && `amb-mat-${material}`)}
113
- style={{ top: `${100 - percent}%` }}
114
- >
115
- <span className="amb-fader-gripline" />
116
- </div>
117
- </div>
118
- {label ? (
119
- <span id={id} className="ambx-label">
120
- {label}
121
- </span>
122
- ) : null}
123
- </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
+ />
124
42
  );
125
43
  }
@@ -1,233 +1,75 @@
1
- import { useId, useRef } from "react";
2
- import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
3
1
  import { cn } from "../lib/cn";
4
-
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] }
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";
8
+
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";
12
+
13
+ /** The pointer riding the rotating face: a radial bar or an offset dot. */
14
+ export type AmbientKnobIndicator = "rectangle" | "circle";
15
+
16
+ export type AmbientKnobSize = "sm" | "md" | "lg";
17
+
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;
25
37
  };
26
38
 
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
- }
48
-
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
- };
71
-
72
- export type AmbientKnobProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
73
- value: number;
74
- min?: number;
75
- max?: number;
76
- step?: number;
77
- label?: string;
78
- material?: "matte" | "shiny" | "glass";
79
- variant?: AmbientKnobVariant;
80
- onChange?: (nextValue: number) => void;
81
- };
82
-
83
- function clamp(value: number, min: number, max: number): number {
84
- return Math.min(max, Math.max(min, value));
85
- }
86
-
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
+ */
87
43
  export function AmbientKnob({
88
- value,
89
- min = 0,
90
- max = 100,
91
- step = 1,
92
- label,
93
44
  material,
94
- variant = "dot",
95
- onChange,
45
+ knurling,
46
+ knurlColor,
47
+ markers,
48
+ indicator,
49
+ look,
50
+ size = "md",
96
51
  className,
97
- ...props
52
+ travel,
53
+ input,
54
+ animate,
55
+ ...rest
98
56
  }: AmbientKnobProps) {
99
- const id = useId();
100
- const clipId = `amb-knurl-${id.replace(/:/g, "")}`;
101
- const draggingRef = useRef(false);
102
- const knobRef = useRef<HTMLDivElement>(null);
103
- const safeStep = step > 0 ? step : 1;
104
- const family = VARIANT_FAMILY[variant];
105
-
106
- const percent = (value - min) / (max - min || 1);
107
- const rotation = percent * 270 - 135;
108
-
109
- // Convert pointer position to a knob angle in [-180, 180] where 0 = up,
110
- // positive = clockwise. The knob's active range is [-135, 135] (270°),
111
- // with the dead zone at the bottom (~135° to ~225° i.e. past ±135°).
112
- const pointerToValue = (event: PointerEvent<HTMLDivElement>) => {
113
- const rect = knobRef.current!.getBoundingClientRect();
114
- const cx = rect.left + rect.width / 2;
115
- const cy = rect.top + rect.height / 2;
116
- // atan2 with screen coords: 0=right, 90=down; add 90 to rotate so 0=up
117
- const atan2Deg = Math.atan2(event.clientY - cy, event.clientX - cx) * (180 / Math.PI);
118
- let angle = atan2Deg + 90;
119
- // Normalize to [-180, 180]
120
- if (angle > 180) angle -= 360;
121
-
122
- // Clamp dead zone based on current value position
123
- if (angle < -135 || angle > 135) {
124
- angle = percent >= 0.5 ? 135 : -135;
125
- }
126
-
127
- const range = max - min;
128
- const raw = min + ((angle + 135) / 270) * range;
129
- const snapped = Math.round(raw / safeStep) * safeStep;
130
- onChange?.(clamp(snapped, min, max));
131
- };
132
-
133
- const setValue = (nextValue: number) => {
134
- const snapped = Math.round(nextValue / safeStep) * safeStep;
135
- onChange?.(clamp(snapped, min, max));
136
- };
137
-
138
- const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
139
- const pageStep = safeStep * 10;
140
- switch (event.key) {
141
- case "ArrowUp":
142
- case "ArrowRight":
143
- event.preventDefault();
144
- setValue(value + safeStep);
145
- break;
146
- case "ArrowDown":
147
- case "ArrowLeft":
148
- event.preventDefault();
149
- setValue(value - safeStep);
150
- break;
151
- case "PageUp":
152
- event.preventDefault();
153
- setValue(value + pageStep);
154
- break;
155
- case "PageDown":
156
- event.preventDefault();
157
- setValue(value - pageStep);
158
- break;
159
- case "Home":
160
- event.preventDefault();
161
- setValue(min);
162
- break;
163
- case "End":
164
- event.preventDefault();
165
- setValue(max);
166
- break;
167
- default:
168
- break;
169
- }
170
- };
57
+ const { dress, defaults } = useDress(
58
+ "rotary",
59
+ { material, knurling, knurlColor, markers, indicator, ...look },
60
+ groundedKit.rotary!
61
+ );
171
62
 
172
63
  return (
173
- <div className={cn("ambx-stack", className)} {...props}>
174
- <div
175
- ref={knobRef}
176
- className="amb-knob ambx-knob"
177
- role="slider"
178
- aria-label={label}
179
- aria-valuemin={min}
180
- aria-valuemax={max}
181
- aria-valuenow={value}
182
- aria-labelledby={label ? id : undefined}
183
- aria-orientation="vertical"
184
- tabIndex={0}
185
- onPointerDown={(event) => {
186
- event.currentTarget.setPointerCapture(event.pointerId);
187
- draggingRef.current = true;
188
- pointerToValue(event);
189
- }}
190
- onPointerMove={(event) => {
191
- if (draggingRef.current) pointerToValue(event);
192
- }}
193
- onPointerUp={() => {
194
- draggingRef.current = false;
195
- }}
196
- onPointerCancel={() => {
197
- draggingRef.current = false;
198
- }}
199
- onKeyDown={onKeyDown}
200
- >
201
- <svg width={0} height={0} style={{ position: "absolute" }} aria-hidden focusable={false}>
202
- <defs>
203
- <clipPath id={clipId} clipPathUnits="objectBoundingBox">
204
- <path d={KNURL_PATHS[family]} />
205
- </clipPath>
206
- </defs>
207
- </svg>
208
- <span className="amb-knob-body ambient amb-thickness-2 amb-surface" />
209
- <div
210
- className={cn(
211
- "ambx-knob-rotation amb-knob-face",
212
- family === "flute" && "amb-knob-face-flute",
213
- family === "fine" && "amb-knob-face-fine",
214
- variant === "cap" && "amb-knob-face-cap",
215
- material && `amb-mat-${material}`
216
- )}
217
- style={{ transform: `rotate(${rotation}deg)`, clipPath: `url(#${clipId})` }}
218
- >
219
- {variant === "dot" ? <span className="amb-knob-indicator-dot" /> : null}
220
- {variant === "line" ? <span className="amb-knob-indicator-line" /> : null}
221
- {variant === "flute" ? (
222
- <span className="amb-knob-indicator-dot amb-knob-indicator-dot-center" />
223
- ) : null}
224
- </div>
225
- </div>
226
- {label ? (
227
- <span id={id} className="ambx-label">
228
- {label}
229
- </span>
230
- ) : null}
231
- </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
+ />
232
74
  );
233
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,32 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export type AmbientRackGap = "tight" | "normal" | "loose";
5
+
6
+ export interface AmbientRackProps extends HTMLAttributes<HTMLDivElement> {
7
+ /** Clearance between controls: tight for one functional unit/repeated
8
+ * row, normal (default) for a few related controls, loose for
9
+ * separating distinct zones. */
10
+ gap?: AmbientRackGap;
11
+ direction?: "row" | "column";
12
+ }
13
+
14
+ export function AmbientRack({
15
+ className,
16
+ gap = "normal",
17
+ direction = "row",
18
+ ...props
19
+ }: AmbientRackProps) {
20
+ return (
21
+ <div
22
+ className={cn(
23
+ "ambx-rack",
24
+ gap === "tight" && "ambx-rack-tight",
25
+ gap === "loose" && "ambx-rack-loose",
26
+ direction === "column" && "ambx-rack-column",
27
+ className
28
+ )}
29
+ {...props}
30
+ />
31
+ );
32
+ }
@@ -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
+ }