@ambientcss/components 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambientcss/components",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "React skeuomorphic components powered by Ambient CSS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,7 +36,8 @@
36
36
  }
37
37
  },
38
38
  "files": [
39
- "dist"
39
+ "dist",
40
+ "src"
40
41
  ],
41
42
  "scripts": {
42
43
  "build": "tsup && cp src/styles.css dist/styles.css",
@@ -0,0 +1,16 @@
1
+ import type { ButtonHTMLAttributes } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export type AmbientButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
5
+
6
+ export function AmbientButton({ className, children, ...props }: AmbientButtonProps) {
7
+ return (
8
+ <button
9
+ type="button"
10
+ className={cn("ambient amb-button amb-chamfer amb-elevation-1 ambx-button amb-heading-3", className)}
11
+ {...props}
12
+ >
13
+ {children}
14
+ </button>
15
+ );
16
+ }
@@ -0,0 +1,127 @@
1
+ import { useId, useRef } from "react";
2
+ import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
3
+ 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
+ onChange?: (nextValue: number) => void;
12
+ };
13
+
14
+ function clamp(value: number, min: number, max: number): number {
15
+ return Math.min(max, Math.max(min, value));
16
+ }
17
+
18
+ export function AmbientFader({
19
+ value,
20
+ min = 0,
21
+ max = 100,
22
+ step = 1,
23
+ label,
24
+ onChange,
25
+ className,
26
+ ...props
27
+ }: AmbientFaderProps) {
28
+ const id = useId();
29
+ const trackRef = useRef<HTMLDivElement | null>(null);
30
+ const safeStep = step > 0 ? step : 1;
31
+
32
+ const percent = ((value - min) / (max - min || 1)) * 100;
33
+
34
+ const updateFromClientY = (clientY: number) => {
35
+ const track = trackRef.current;
36
+ if (!track) return;
37
+
38
+ const rect = track.getBoundingClientRect();
39
+ const ratio = 1 - (clientY - rect.top) / rect.height;
40
+ const nextValue = min + clamp(ratio, 0, 1) * (max - min);
41
+ const snapped = Math.round(nextValue / safeStep) * safeStep;
42
+ onChange?.(clamp(snapped, min, max));
43
+ };
44
+
45
+ const setValue = (nextValue: number) => {
46
+ const snapped = Math.round(nextValue / safeStep) * safeStep;
47
+ onChange?.(clamp(snapped, min, max));
48
+ };
49
+
50
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
51
+ const pageStep = safeStep * 10;
52
+ switch (event.key) {
53
+ case "ArrowUp":
54
+ case "ArrowRight":
55
+ event.preventDefault();
56
+ setValue(value + safeStep);
57
+ break;
58
+ case "ArrowDown":
59
+ case "ArrowLeft":
60
+ event.preventDefault();
61
+ setValue(value - safeStep);
62
+ break;
63
+ case "PageUp":
64
+ event.preventDefault();
65
+ setValue(value + pageStep);
66
+ break;
67
+ case "PageDown":
68
+ event.preventDefault();
69
+ setValue(value - pageStep);
70
+ break;
71
+ case "Home":
72
+ event.preventDefault();
73
+ setValue(min);
74
+ break;
75
+ case "End":
76
+ event.preventDefault();
77
+ setValue(max);
78
+ break;
79
+ default:
80
+ break;
81
+ }
82
+ };
83
+
84
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
85
+ if (event.buttons !== 1) return;
86
+ updateFromClientY(event.clientY);
87
+ };
88
+
89
+ return (
90
+ <div className={cn("ambx-stack", className)} {...props}>
91
+ <div
92
+ className="amb-fader ambx-fader"
93
+ ref={trackRef}
94
+ role="slider"
95
+ aria-label={label}
96
+ aria-labelledby={label ? id : undefined}
97
+ aria-valuemin={min}
98
+ aria-valuemax={max}
99
+ aria-valuenow={value}
100
+ aria-orientation="vertical"
101
+ tabIndex={0}
102
+ onPointerDown={(event) => {
103
+ event.currentTarget.setPointerCapture(event.pointerId);
104
+ updateFromClientY(event.clientY);
105
+ }}
106
+ onPointerMove={onPointerMove}
107
+ onKeyDown={onKeyDown}
108
+ >
109
+ <div
110
+ className="amb-fader-thumb ambient amb-fillet amb-elevation-1 amb-surface-concave ambx-fader-thumb"
111
+ style={{ top: `${100 - percent}%` }}
112
+ >
113
+ <div className="amb-fader-grip">
114
+ <span className="amb-fader-dot" />
115
+ <span className="amb-fader-dot" />
116
+ <span className="amb-fader-dot" />
117
+ </div>
118
+ </div>
119
+ </div>
120
+ {label ? (
121
+ <span id={id} className="ambx-label">
122
+ {label}
123
+ </span>
124
+ ) : null}
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,145 @@
1
+ import { useId, useRef } from "react";
2
+ import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
3
+ import { cn } from "../lib/cn";
4
+
5
+ export type AmbientKnobProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
6
+ value: number;
7
+ min?: number;
8
+ max?: number;
9
+ step?: number;
10
+ label?: string;
11
+ onChange?: (nextValue: number) => void;
12
+ };
13
+
14
+ function clamp(value: number, min: number, max: number): number {
15
+ return Math.min(max, Math.max(min, value));
16
+ }
17
+
18
+ export function AmbientKnob({
19
+ value,
20
+ min = 0,
21
+ max = 100,
22
+ step = 1,
23
+ label,
24
+ onChange,
25
+ className,
26
+ ...props
27
+ }: AmbientKnobProps) {
28
+ const id = useId();
29
+ const draggingRef = useRef(false);
30
+ const knobRef = useRef<HTMLDivElement>(null);
31
+ const safeStep = step > 0 ? step : 1;
32
+
33
+ const percent = (value - min) / (max - min || 1);
34
+ const rotation = percent * 270 - 135;
35
+
36
+ // Convert pointer position to a knob angle in [-180, 180] where 0 = up,
37
+ // positive = clockwise. The knob's active range is [-135, 135] (270°),
38
+ // with the dead zone at the bottom (~135° to ~225° i.e. past ±135°).
39
+ const pointerToValue = (event: PointerEvent<HTMLDivElement>) => {
40
+ const rect = knobRef.current!.getBoundingClientRect();
41
+ const cx = rect.left + rect.width / 2;
42
+ const cy = rect.top + rect.height / 2;
43
+ // atan2 with screen coords: 0=right, 90=down; add 90 to rotate so 0=up
44
+ const atan2Deg = Math.atan2(event.clientY - cy, event.clientX - cx) * (180 / Math.PI);
45
+ let angle = atan2Deg + 90;
46
+ // Normalize to [-180, 180]
47
+ if (angle > 180) angle -= 360;
48
+
49
+ // Clamp dead zone based on current value position
50
+ if (angle < -135 || angle > 135) {
51
+ angle = percent >= 0.5 ? 135 : -135;
52
+ }
53
+
54
+ const range = max - min;
55
+ const raw = min + ((angle + 135) / 270) * range;
56
+ const snapped = Math.round(raw / safeStep) * safeStep;
57
+ onChange?.(clamp(snapped, min, max));
58
+ };
59
+
60
+ const setValue = (nextValue: number) => {
61
+ const snapped = Math.round(nextValue / safeStep) * safeStep;
62
+ onChange?.(clamp(snapped, min, max));
63
+ };
64
+
65
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
66
+ const pageStep = safeStep * 10;
67
+ switch (event.key) {
68
+ case "ArrowUp":
69
+ case "ArrowRight":
70
+ event.preventDefault();
71
+ setValue(value + safeStep);
72
+ break;
73
+ case "ArrowDown":
74
+ case "ArrowLeft":
75
+ event.preventDefault();
76
+ setValue(value - safeStep);
77
+ break;
78
+ case "PageUp":
79
+ event.preventDefault();
80
+ setValue(value + pageStep);
81
+ break;
82
+ case "PageDown":
83
+ event.preventDefault();
84
+ setValue(value - pageStep);
85
+ break;
86
+ case "Home":
87
+ event.preventDefault();
88
+ setValue(min);
89
+ break;
90
+ case "End":
91
+ event.preventDefault();
92
+ setValue(max);
93
+ break;
94
+ default:
95
+ break;
96
+ }
97
+ };
98
+
99
+ return (
100
+ <div className={cn("ambx-stack", className)} {...props}>
101
+ <div
102
+ ref={knobRef}
103
+ className="ambient amb-knob amb-chamfer amb-elevation-2 amb-surface ambx-knob"
104
+ role="slider"
105
+ aria-label={label}
106
+ aria-valuemin={min}
107
+ aria-valuemax={max}
108
+ aria-valuenow={value}
109
+ aria-labelledby={label ? id : undefined}
110
+ aria-orientation="vertical"
111
+ tabIndex={0}
112
+ onPointerDown={(event) => {
113
+ event.currentTarget.setPointerCapture(event.pointerId);
114
+ draggingRef.current = true;
115
+ pointerToValue(event);
116
+ }}
117
+ onPointerMove={(event) => {
118
+ if (draggingRef.current) pointerToValue(event);
119
+ }}
120
+ onPointerUp={() => {
121
+ draggingRef.current = false;
122
+ }}
123
+ onPointerCancel={() => {
124
+ draggingRef.current = false;
125
+ }}
126
+ onKeyDown={onKeyDown}
127
+ >
128
+ <div className="ambx-knob-rotation" style={{ transform: `rotate(${rotation}deg)` }}>
129
+ <div className="amb-knob-indicator">
130
+ <div className="amb-knob-grip">
131
+ <span className="amb-fader-dot" />
132
+ <span className="amb-fader-dot" />
133
+ <span className="amb-fader-dot" />
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ {label ? (
139
+ <span id={id} className="ambx-label">
140
+ {label}
141
+ </span>
142
+ ) : null}
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,8 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export type AmbientPanelProps = HTMLAttributes<HTMLDivElement>;
5
+
6
+ export function AmbientPanel({ className, ...props }: AmbientPanelProps) {
7
+ return <div className={cn("ambient amb-surface amb-chamfer amb-elevation-2 ambx-panel", className)} {...props} />;
8
+ }
@@ -0,0 +1,57 @@
1
+ import type { CSSProperties, PropsWithChildren } from "react";
2
+
3
+ export type AmbientTheme = {
4
+ lightX?: number;
5
+ lightY?: number;
6
+ keyLight?: number;
7
+ fillLight?: number;
8
+ lightHue?: number;
9
+ lightSaturation?: number;
10
+ highlightColor?: string;
11
+ lumeHue?: number;
12
+ };
13
+
14
+ export type AmbientProviderProps = PropsWithChildren<{
15
+ className?: string;
16
+ style?: CSSProperties;
17
+ theme?: AmbientTheme;
18
+ }>;
19
+
20
+ const VAR_MAP: Record<string, keyof AmbientTheme> = {
21
+ "--amb-light-x": "lightX",
22
+ "--amb-light-y": "lightY",
23
+ "--amb-key-light-intensity": "keyLight",
24
+ "--amb-fill-light-intensity": "fillLight",
25
+ "--amb-light-hue": "lightHue",
26
+ "--amb-light-saturation": "lightSaturation",
27
+ "--amb-highlight-color": "highlightColor",
28
+ "--amb-lume-hue": "lumeHue",
29
+ };
30
+
31
+ export function AmbientProvider({ children, className, style, theme }: AmbientProviderProps) {
32
+ const themeVars: Record<string, string> = {};
33
+ for (const [varName, key] of Object.entries(VAR_MAP)) {
34
+ const value = theme?.[key];
35
+ if (value !== undefined) {
36
+ themeVars[varName] = key === "lightSaturation" ? `${value}%` : String(value);
37
+ }
38
+ }
39
+ // Derived variables must be re-declared here so they recompute using the
40
+ // overridden input variables on this element rather than inheriting the
41
+ // already-resolved values from :root.
42
+ themeVars["--amb-lume"] = [
43
+ "color-mix(in oklab,",
44
+ "hsl(var(--amb-lume-hue) 100% 74%) calc(clamp(0, (0.5 - var(--amb-key-light-intensity)) / 0.2, 1) * 100%),",
45
+ "hsl(var(--amb-light-hue) var(--amb-light-saturation)",
46
+ "calc((1 - sign(var(--amb-key-light-intensity) - 0.5)) * 50% + 25%)))",
47
+ ].join(" ");
48
+ themeVars["--amb-label"] =
49
+ "hsl(var(--amb-light-hue) var(--amb-light-saturation) calc((1 - var(--amb-key-light-intensity)) * 60% + 20%))";
50
+ const mergedStyle = { ...themeVars, ...style } as CSSProperties;
51
+
52
+ return (
53
+ <div className={className} style={mergedStyle}>
54
+ {children}
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,127 @@
1
+ import { useId, useRef } from "react";
2
+ import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
3
+ import { cn } from "../lib/cn";
4
+
5
+ export type AmbientSliderProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
6
+ value: number;
7
+ min?: number;
8
+ max?: number;
9
+ step?: number;
10
+ label?: string;
11
+ onChange?: (nextValue: number) => void;
12
+ };
13
+
14
+ function clamp(value: number, min: number, max: number): number {
15
+ return Math.min(max, Math.max(min, value));
16
+ }
17
+
18
+ export function AmbientSlider({
19
+ value,
20
+ min = 0,
21
+ max = 100,
22
+ step = 1,
23
+ label,
24
+ onChange,
25
+ className,
26
+ ...props
27
+ }: AmbientSliderProps) {
28
+ const id = useId();
29
+ const trackRef = useRef<HTMLDivElement | null>(null);
30
+ const safeStep = step > 0 ? step : 1;
31
+
32
+ const percent = ((value - min) / (max - min || 1)) * 100;
33
+
34
+ const updateFromClientX = (clientX: number) => {
35
+ const track = trackRef.current;
36
+ if (!track) return;
37
+
38
+ const rect = track.getBoundingClientRect();
39
+ const ratio = (clientX - rect.left) / rect.width;
40
+ const nextValue = min + clamp(ratio, 0, 1) * (max - min);
41
+ const snapped = Math.round(nextValue / safeStep) * safeStep;
42
+ onChange?.(clamp(snapped, min, max));
43
+ };
44
+
45
+ const setValue = (nextValue: number) => {
46
+ const snapped = Math.round(nextValue / safeStep) * safeStep;
47
+ onChange?.(clamp(snapped, min, max));
48
+ };
49
+
50
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
51
+ const pageStep = safeStep * 10;
52
+ switch (event.key) {
53
+ case "ArrowRight":
54
+ case "ArrowUp":
55
+ event.preventDefault();
56
+ setValue(value + safeStep);
57
+ break;
58
+ case "ArrowLeft":
59
+ case "ArrowDown":
60
+ event.preventDefault();
61
+ setValue(value - safeStep);
62
+ break;
63
+ case "PageUp":
64
+ event.preventDefault();
65
+ setValue(value + pageStep);
66
+ break;
67
+ case "PageDown":
68
+ event.preventDefault();
69
+ setValue(value - pageStep);
70
+ break;
71
+ case "Home":
72
+ event.preventDefault();
73
+ setValue(min);
74
+ break;
75
+ case "End":
76
+ event.preventDefault();
77
+ setValue(max);
78
+ break;
79
+ default:
80
+ break;
81
+ }
82
+ };
83
+
84
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
85
+ if (event.buttons !== 1) return;
86
+ updateFromClientX(event.clientX);
87
+ };
88
+
89
+ return (
90
+ <div className={cn("ambx-stack", className)} {...props}>
91
+ <div
92
+ className="amb-slider ambx-slider"
93
+ ref={trackRef}
94
+ role="slider"
95
+ aria-label={label}
96
+ aria-labelledby={label ? id : undefined}
97
+ aria-valuemin={min}
98
+ aria-valuemax={max}
99
+ aria-valuenow={value}
100
+ aria-orientation="horizontal"
101
+ tabIndex={0}
102
+ onPointerDown={(event) => {
103
+ event.currentTarget.setPointerCapture(event.pointerId);
104
+ updateFromClientX(event.clientX);
105
+ }}
106
+ onPointerMove={onPointerMove}
107
+ onKeyDown={onKeyDown}
108
+ >
109
+ <div
110
+ className="amb-slider-thumb ambient amb-fillet amb-elevation-1 amb-surface-concave-h ambx-slider-thumb"
111
+ style={{ left: `${percent}%` }}
112
+ >
113
+ <div className="amb-slider-grip">
114
+ <span className="amb-fader-dot" />
115
+ <span className="amb-fader-dot" />
116
+ <span className="amb-fader-dot" />
117
+ </div>
118
+ </div>
119
+ </div>
120
+ {label ? (
121
+ <span id={id} className="ambx-label">
122
+ {label}
123
+ </span>
124
+ ) : null}
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,75 @@
1
+ import { useId, useState } from "react";
2
+ import type { ButtonHTMLAttributes } from "react";
3
+ import { cn } from "../lib/cn";
4
+
5
+ export type AmbientSwitchSize = "sm" | "md" | "lg";
6
+
7
+ export type AmbientSwitchProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> & {
8
+ checked?: boolean;
9
+ defaultChecked?: boolean;
10
+ onCheckedChange?: (checked: boolean) => void;
11
+ size?: AmbientSwitchSize;
12
+ label?: string;
13
+ /** Show a small LED indicator that lights up when the switch is on. Pass `true` for default green or a CSS color string. */
14
+ led?: boolean | string;
15
+ };
16
+
17
+ export function AmbientSwitch({
18
+ className,
19
+ checked,
20
+ defaultChecked,
21
+ onCheckedChange,
22
+ onClick,
23
+ size = "md",
24
+ label,
25
+ led,
26
+ children,
27
+ ...props
28
+ }: AmbientSwitchProps) {
29
+ const labelId = useId();
30
+ const isControlled = checked !== undefined;
31
+ const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false);
32
+ const active = isControlled ? (checked ?? false) : internalChecked;
33
+
34
+ const button = (
35
+ <button
36
+ type="button"
37
+ role="switch"
38
+ aria-checked={active}
39
+ aria-labelledby={label ? labelId : props["aria-labelledby"]}
40
+ className={cn(
41
+ "ambient amb-switch amb-chamfer amb-elevation-1 amb-heading-3",
42
+ active ? "amb-surface-convex amb-switch-on" : "amb-surface-concave",
43
+ "ambx-switch",
44
+ `ambx-switch-${size}`,
45
+ className
46
+ )}
47
+ onClick={(event) => {
48
+ const next = !active;
49
+ if (!isControlled) setInternalChecked(next);
50
+ onCheckedChange?.(next);
51
+ onClick?.(event);
52
+ }}
53
+ {...props}
54
+ >
55
+ {led ? (
56
+ <span
57
+ className={cn("amb-led", !active && "amb-led-off")}
58
+ style={typeof led === "string" ? { "--amb-led-color": led } as React.CSSProperties : undefined}
59
+ />
60
+ ) : null}
61
+ {children}
62
+ </button>
63
+ );
64
+
65
+ if (!label) return button;
66
+
67
+ return (
68
+ <div className="ambx-stack">
69
+ {button}
70
+ <span id={labelId} className="ambx-label">
71
+ {label}
72
+ </span>
73
+ </div>
74
+ );
75
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { AmbientProvider } from "./components/AmbientProvider";
2
+ export type { AmbientProviderProps, AmbientTheme } from "./components/AmbientProvider";
3
+
4
+ export { AmbientPanel } from "./components/AmbientPanel";
5
+ export type { AmbientPanelProps } from "./components/AmbientPanel";
6
+
7
+ export { AmbientButton } from "./components/AmbientButton";
8
+ export type { AmbientButtonProps } from "./components/AmbientButton";
9
+
10
+ export { AmbientSwitch } from "./components/AmbientSwitch";
11
+ export type { AmbientSwitchProps, AmbientSwitchSize } from "./components/AmbientSwitch";
12
+
13
+ export { AmbientKnob } from "./components/AmbientKnob";
14
+ export type { AmbientKnobProps } from "./components/AmbientKnob";
15
+
16
+ export { AmbientFader } from "./components/AmbientFader";
17
+ export type { AmbientFaderProps } from "./components/AmbientFader";
18
+
19
+ export { AmbientSlider } from "./components/AmbientSlider";
20
+ export type { AmbientSliderProps } from "./components/AmbientSlider";
package/src/lib/cn.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function cn(...values: Array<string | false | null | undefined>): string {
2
+ return values.filter(Boolean).join(" ");
3
+ }
package/src/styles.css ADDED
@@ -0,0 +1,273 @@
1
+ :root {
2
+ --ambx-grid: 4px;
3
+ --ambx-grid-half: calc(var(--ambx-grid) / 2);
4
+ --ambx-grid-quarter: calc(var(--ambx-grid) / 4);
5
+ --ambx-type-1-size: calc(var(--ambx-grid) * 8);
6
+ --ambx-type-1-line: calc(var(--ambx-grid) * 10);
7
+ --ambx-type-2-size: calc(var(--ambx-grid) * 6);
8
+ --ambx-type-2-line: calc(var(--ambx-grid) * 8);
9
+ --ambx-type-3-size: calc(var(--ambx-grid) * 3);
10
+ --ambx-type-3-line: calc(var(--ambx-grid) * 4);
11
+ --ambx-type-tracking: var(--ambx-grid-quarter);
12
+ --ambx-label-size: calc(var(--ambx-grid) * 2 + var(--ambx-grid-half));
13
+ --ambx-label-line: calc(var(--ambx-grid) * 3);
14
+ --ambx-control-label-gap: calc(var(--ambx-grid) * 2);
15
+ --ambx-knob-label-clearance: calc(var(--ambx-grid) * 2);
16
+ --ambx-slider-label-clearance: calc(var(--ambx-grid) * 2);
17
+ --ambx-fader-label-clearance: calc(var(--ambx-grid) * 4);
18
+ }
19
+
20
+ .amb-heading-1 {
21
+ font-size: var(--ambx-type-1-size);
22
+ line-height: var(--ambx-type-1-line);
23
+ letter-spacing: var(--ambx-type-tracking);
24
+ }
25
+
26
+ .amb-heading-2 {
27
+ font-size: var(--ambx-type-2-size);
28
+ line-height: var(--ambx-type-2-line);
29
+ letter-spacing: var(--ambx-type-tracking);
30
+ }
31
+
32
+ .amb-heading-3 {
33
+ font-size: var(--ambx-type-3-size);
34
+ line-height: var(--ambx-type-3-line);
35
+ letter-spacing: var(--ambx-type-tracking);
36
+ }
37
+
38
+ .amb-button {
39
+ cursor: pointer;
40
+ border: medium solid var(--amb-lume);
41
+ background-color: inherit;
42
+ padding: calc(var(--ambx-grid) * 2);
43
+ border-radius: calc(var(--ambx-grid) * 3);
44
+ text-transform: uppercase;
45
+ min-width: calc(var(--ambx-grid) * 16);
46
+ font-weight: 600;
47
+ color: var(--amb-lume);
48
+ }
49
+
50
+ .amb-button:focus {
51
+ outline-offset: var(--ambx-grid);
52
+ }
53
+
54
+ .amb-button:hover,
55
+ .amb-knob:hover {
56
+ border-color: var(--amb-highlight-color);
57
+ }
58
+
59
+ .amb-button:active {
60
+ --amb-elevation: 0;
61
+ }
62
+
63
+ .amb-knob {
64
+ border-radius: 50%;
65
+ height: calc(var(--ambx-grid) * 16);
66
+ width: calc(var(--ambx-grid) * 16);
67
+ border: medium solid var(--amb-lume);
68
+ }
69
+
70
+ .amb-knob-indicator {
71
+ position: absolute;
72
+ top: 6px;
73
+ left: 50%;
74
+ transform: translateX(-50%);
75
+ }
76
+
77
+ .amb-knob-grip {
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: 2px;
81
+ }
82
+
83
+ .amb-fader {
84
+ position: relative;
85
+ width: var(--ambx-grid);
86
+ height: calc(var(--ambx-grid) * 30);
87
+ border-radius: var(--ambx-grid);
88
+ background-color: var(--amb-lume);
89
+ }
90
+
91
+ .amb-fader-thumb {
92
+ position: absolute;
93
+ width: calc(var(--ambx-grid) * 6);
94
+ height: calc(var(--ambx-grid) * 9);
95
+ left: 50%;
96
+ top: 50%;
97
+ transform: translate(-50%, -50%);
98
+ border-radius: calc(var(--ambx-grid) * 3);
99
+ display: grid;
100
+ place-items: center;
101
+ }
102
+
103
+ .amb-fader-grip {
104
+ display: flex;
105
+ gap: 2px;
106
+ }
107
+
108
+ .amb-fader-dot {
109
+ width: 3px;
110
+ height: 3px;
111
+ border-radius: 50%;
112
+ background: radial-gradient(
113
+ circle at 30% 30%,
114
+ color-mix(in oklab, var(--amb-lume), white 30%) 0%,
115
+ var(--amb-lume) 55%,
116
+ color-mix(in oklab, var(--amb-lume), black 35%) 100%
117
+ );
118
+ }
119
+
120
+ .amb-slider {
121
+ position: relative;
122
+ height: var(--ambx-grid);
123
+ width: calc(var(--ambx-grid) * 30);
124
+ border-radius: var(--ambx-grid);
125
+ background-color: var(--amb-lume);
126
+ }
127
+
128
+ .amb-slider-thumb {
129
+ position: absolute;
130
+ width: calc(var(--ambx-grid) * 9);
131
+ height: calc(var(--ambx-grid) * 6);
132
+ top: 50%;
133
+ left: 50%;
134
+ transform: translate(-50%, -50%);
135
+ border-radius: calc(var(--ambx-grid) * 3);
136
+ display: grid;
137
+ place-items: center;
138
+ }
139
+
140
+ .amb-slider-grip {
141
+ display: flex;
142
+ flex-direction: column;
143
+ gap: 2px;
144
+ box-shadow:
145
+ inset 0 var(--ambx-grid-quarter) var(--ambx-grid-quarter) rgb(255 255 255 / 0.28),
146
+ inset 0 calc(var(--ambx-grid-quarter) * -1) var(--ambx-grid-quarter) rgb(0 0 0 / 0.22);
147
+ }
148
+
149
+ .amb-led {
150
+ width: calc(var(--ambx-grid) * 2);
151
+ height: calc(var(--ambx-grid) * 2);
152
+ border-radius: 50%;
153
+ background-color: var(--amb-led-color, #0f0);
154
+ box-shadow: 0 0 var(--ambx-grid-quarter) var(--amb-led-color, #0f0);
155
+ transition: background-color 150ms ease, box-shadow 150ms ease;
156
+ }
157
+
158
+ .amb-led-off {
159
+ background-color: hsl(
160
+ var(--amb-light-hue)
161
+ var(--amb-light-saturation)
162
+ calc(var(--amb-key-light-intensity) * 30%)
163
+ );
164
+ box-shadow: none;
165
+ }
166
+
167
+ .amb-switch {
168
+ position: relative;
169
+ width: calc(var(--ambx-grid) * 6);
170
+ height: calc(var(--ambx-grid) * 6);
171
+ border-radius: 50%;
172
+ cursor: pointer;
173
+ border: medium solid var(--amb-lume);
174
+ margin: 0;
175
+ }
176
+
177
+ .ambx-panel {
178
+ border-radius: calc(var(--ambx-grid) * 4);
179
+ padding: calc(var(--ambx-grid) * 4);
180
+ }
181
+
182
+ .ambx-button {
183
+ min-width: calc(var(--ambx-grid) * 18);
184
+ line-height: var(--ambx-type-3-line);
185
+ }
186
+
187
+ .ambx-switch {
188
+ --ambx-switch-size: calc(var(--ambx-grid) * 6);
189
+ width: var(--ambx-switch-size);
190
+ height: var(--ambx-switch-size);
191
+ display: inline-grid;
192
+ place-items: center;
193
+ padding: 0;
194
+ }
195
+
196
+ .ambx-switch-sm {
197
+ --ambx-switch-size: calc(var(--ambx-grid) * 5);
198
+ }
199
+
200
+ .ambx-switch-md {
201
+ --ambx-switch-size: calc(var(--ambx-grid) * 6);
202
+ }
203
+
204
+ .ambx-switch-lg {
205
+ --ambx-switch-size: calc(var(--ambx-grid) * 8);
206
+ }
207
+
208
+ .amb-switch-on {
209
+ box-shadow:
210
+ inset 0 0 0 var(--ambx-grid-quarter) rgb(255 255 255 / 0.25);
211
+ }
212
+
213
+ .ambx-stack {
214
+ display: inline-flex;
215
+ flex-direction: column;
216
+ align-items: center;
217
+ gap: var(--ambx-control-label-gap);
218
+ }
219
+
220
+ .ambx-label {
221
+ font-size: var(--ambx-label-size);
222
+ line-height: var(--ambx-label-line);
223
+ letter-spacing: var(--ambx-grid-quarter);
224
+ text-transform: uppercase;
225
+ color: var(--amb-label);
226
+ }
227
+
228
+ .ambx-slider + .ambx-label {
229
+ margin-top: var(--ambx-slider-label-clearance);
230
+ }
231
+
232
+ .ambx-fader + .ambx-label {
233
+ margin-top: var(--ambx-fader-label-clearance);
234
+ }
235
+
236
+ .ambx-knob + .ambx-label {
237
+ margin-top: var(--ambx-knob-label-clearance);
238
+ }
239
+
240
+ .ambx-knob {
241
+ position: relative;
242
+ display: grid;
243
+ place-items: center;
244
+ touch-action: none;
245
+ user-select: none;
246
+ }
247
+
248
+ .ambx-knob-rotation {
249
+ position: absolute;
250
+ inset: 0;
251
+ }
252
+
253
+ .ambx-fader {
254
+ width: calc(var(--ambx-grid) * 2);
255
+ border-radius: 999px;
256
+ touch-action: none;
257
+ user-select: none;
258
+ }
259
+
260
+ .ambx-fader-thumb {
261
+ transform: translate(-50%, -50%);
262
+ }
263
+
264
+ .ambx-slider {
265
+ height: calc(var(--ambx-grid) * 2);
266
+ border-radius: 999px;
267
+ touch-action: none;
268
+ user-select: none;
269
+ }
270
+
271
+ .ambx-slider-thumb {
272
+ transform: translate(-50%, -50%);
273
+ }