@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.
- package/README.md +35 -0
- package/dist/index.cjs +1540 -391
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +749 -52
- package/dist/index.d.ts +749 -52
- package/dist/index.js +1501 -391
- package/dist/index.js.map +1 -1
- package/dist/styles.css +998 -291
- package/package.json +2 -2
- package/src/components/AmbientButton.tsx +23 -27
- package/src/components/AmbientFader.tsx +29 -115
- package/src/components/AmbientKnob.tsx +58 -220
- package/src/components/AmbientPanel.tsx +8 -2
- package/src/components/AmbientProvider.tsx +3 -0
- package/src/components/AmbientSelect.tsx +40 -0
- package/src/components/AmbientSlider.tsx +28 -113
- package/src/components/AmbientSwitch.tsx +58 -58
- package/src/controls/AmbientBank.tsx +138 -0
- package/src/controls/AmbientLatch.tsx +78 -0
- package/src/controls/AmbientPress.tsx +74 -0
- package/src/controls/AmbientRotary.tsx +94 -0
- package/src/controls/AmbientTravel.tsx +83 -0
- package/src/core/context.tsx +46 -0
- package/src/core/controllable.ts +33 -0
- package/src/core/dev.ts +15 -0
- package/src/core/frames.tsx +63 -0
- package/src/core/kit.tsx +107 -0
- package/src/core/material.ts +27 -0
- package/src/core/numeric.ts +88 -0
- package/src/core/types.ts +116 -0
- package/src/core/useBank.ts +163 -0
- package/src/core/useLatch.ts +54 -0
- package/src/core/usePress.ts +120 -0
- package/src/core/useRotary.ts +253 -0
- package/src/core/useTravel.ts +141 -0
- package/src/index.ts +122 -2
- package/src/kits/console.tsx +80 -0
- package/src/kits/grounded.tsx +113 -0
- package/src/parts/bank.tsx +32 -0
- package/src/parts/console.tsx +99 -0
- package/src/parts/knob.tsx +203 -0
- package/src/parts/latch.tsx +33 -0
- package/src/parts/press.tsx +56 -0
- package/src/parts/travel.tsx +70 -0
- package/src/styles.css +998 -291
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { useControlState } from "../core/context";
|
|
5
|
+
import type { AmbientMaterial } from "../core/material";
|
|
6
|
+
|
|
7
|
+
/* The knurl: a rim band of straight ribs around the knob's cap, in
|
|
8
|
+
objectBoundingBox units so the clip scales with the component.
|
|
9
|
+
|
|
10
|
+
The knob it belongs to reads like the turned-and-knurled hardware knob it
|
|
11
|
+
is named for: a smooth chamfered cap on top, and beyond that cap's edge —
|
|
12
|
+
and a step below it — a ring of knurling standing proud of the outline.
|
|
13
|
+
So the clip is an ANNULUS, not a disc: a rippled outer contour punched
|
|
14
|
+
through by a circle at the cap's radius, filled `evenodd`, which leaves
|
|
15
|
+
the cap and its chamfer bands to paint themselves underneath.
|
|
16
|
+
|
|
17
|
+
The rib section is the referent's (ambient3d/components/knob.py `wall_r`):
|
|
18
|
+
radius falls from the crest by `depth * (0.5 + 0.5cos(N.theta))^sharpness`,
|
|
19
|
+
so ridges are broad and grooves narrow. Sampling that curve beats the old
|
|
20
|
+
four-point trapezoid — at 36 teeth a square wave silhouette reads as gear
|
|
21
|
+
teeth, which is what this replaces.
|
|
22
|
+
|
|
23
|
+
One knurl for now, kept in a table because the shape is a data question and
|
|
24
|
+
the referent lineup carries broader flutes (14 ribs) we may expose later.
|
|
25
|
+
A second row is not free, though: `teeth` is shadowed by the conic pitch in
|
|
26
|
+
.amb-knob-face (360/teeth) and `band` by that rule's radial stop, so a knurl
|
|
27
|
+
with different numbers needs its shading parameterised out of the stylesheet
|
|
28
|
+
first. Only `depth` and `sharpness` live here alone. */
|
|
29
|
+
type KnurlSpec = {
|
|
30
|
+
teeth: number; // rib count
|
|
31
|
+
depth: number; // crest-to-root, bounding-box units (the crest is at 0.5)
|
|
32
|
+
sharpness: number; // >1 narrows the groove and broadens the ridge
|
|
33
|
+
band: number; // rim width the ribs occupy, bounding-box units
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const KNURLS: Record<"standard", KnurlSpec> = {
|
|
37
|
+
/* 48 ribs — the referent lineup's fine knurl (referents.py knob_cap /
|
|
38
|
+
knob_wheel) rather than the 36 of its coarse one, because a band a tenth
|
|
39
|
+
of the radius wide reads as a machined grip only if the ribs are finer
|
|
40
|
+
than it is; at 36 the same band came out a bottle cap. */
|
|
41
|
+
standard: { teeth: 48, depth: 0.009, sharpness: 1.6, band: 0.05 }
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The cap's inset: the knurl band's width, so the ribs stand proud of it.
|
|
45
|
+
* One number, two views of the same edge — the clip's inner radius and the
|
|
46
|
+
* body's inset have to agree or the ring floats off its cap. */
|
|
47
|
+
const KNURL_BAND = KNURLS.standard.band;
|
|
48
|
+
|
|
49
|
+
/* The clip's hole is a hair tighter than the cap so their antialiased edges
|
|
50
|
+
overlap instead of leaving a hairline of panel between them. */
|
|
51
|
+
const SEAM = 0.005;
|
|
52
|
+
|
|
53
|
+
/* Samples per tooth. The curve's extremes both land on samples: the groove
|
|
54
|
+
at the tooth's start, the ridge at its half-pitch. */
|
|
55
|
+
const STEPS = 6;
|
|
56
|
+
|
|
57
|
+
function knurlPath({ teeth, depth, sharpness, band }: KnurlSpec): string {
|
|
58
|
+
const outer = 0.5;
|
|
59
|
+
const inner = outer - band - SEAM;
|
|
60
|
+
const segs = teeth * STEPS;
|
|
61
|
+
const pts: string[] = [];
|
|
62
|
+
for (let i = 0; i < segs; i++) {
|
|
63
|
+
const t = (i / segs) * Math.PI * 2;
|
|
64
|
+
const r = outer - depth * (0.5 + 0.5 * Math.cos(teeth * t)) ** sharpness;
|
|
65
|
+
pts.push(
|
|
66
|
+
`${(0.5 + r * Math.cos(t)).toFixed(4)} ${(0.5 + r * Math.sin(t)).toFixed(4)}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
/* Second subpath: the cap-sized hole, two half arcs. Winding is irrelevant —
|
|
70
|
+
the clip fills evenodd. */
|
|
71
|
+
const l = (0.5 - inner).toFixed(4);
|
|
72
|
+
const r = (0.5 + inner).toFixed(4);
|
|
73
|
+
const hole = `M${l} 0.5 A${inner} ${inner} 0 0 0 ${r} 0.5 A${inner} ${inner} 0 0 0 ${l} 0.5 Z`;
|
|
74
|
+
return `M${pts.join(" L")} Z ${hole}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const KNURL_PATH = knurlPath(KNURLS.standard);
|
|
78
|
+
|
|
79
|
+
/** The knob's cap: the smooth chamfered disc that is most of what you see,
|
|
80
|
+
* and the element that carries the drop shadow.
|
|
81
|
+
*
|
|
82
|
+
* `flush` takes the full width, which is what a smooth turned knob wants.
|
|
83
|
+
* The default sits back by the knurl band so a `KnurledFace` can ring it —
|
|
84
|
+
* the same cap either way, and the chamfer the referent cuts on every knob
|
|
85
|
+
* (knob.py's `chamfer=0.35`, regardless of rib count) either way too. */
|
|
86
|
+
export function KnobBody({
|
|
87
|
+
material,
|
|
88
|
+
flush = false,
|
|
89
|
+
className
|
|
90
|
+
}: {
|
|
91
|
+
material?: AmbientMaterial | undefined;
|
|
92
|
+
flush?: boolean | undefined;
|
|
93
|
+
className?: string | undefined;
|
|
94
|
+
}) {
|
|
95
|
+
return (
|
|
96
|
+
<span
|
|
97
|
+
className={cn(
|
|
98
|
+
"amb-knob-body ambient amb-thickness-2 amb-surface",
|
|
99
|
+
material && `amb-mat-${material}`,
|
|
100
|
+
className
|
|
101
|
+
)}
|
|
102
|
+
/* Geometry, not styling: this is the clip's inner radius seen from the
|
|
103
|
+
other side, so it comes from the same constant the path does. */
|
|
104
|
+
style={flush ? undefined : { inset: `${KNURL_BAND * 100}%` }}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The rotating knurl: a rim ring of ribs around the cap, clipped to the
|
|
110
|
+
* toothed annulus so the ribs break the outline instead of being painted
|
|
111
|
+
* inside a circle, and shaded per tooth — a lit flank climbing to each
|
|
112
|
+
* ridge, a shaded one falling away — with a contact-occlusion band along
|
|
113
|
+
* its inner edge where the cap overhangs it.
|
|
114
|
+
*
|
|
115
|
+
* The clip is the part's own business — it generates the path, emits its
|
|
116
|
+
* own `<defs>` and references it by a local id. A rotary mechanism has no
|
|
117
|
+
* idea any of this is happening, which is exactly the point: if this part
|
|
118
|
+
* needed help from the control to exist, the split would not be clean. */
|
|
119
|
+
export function KnurledFace({
|
|
120
|
+
material,
|
|
121
|
+
color,
|
|
122
|
+
className
|
|
123
|
+
}: {
|
|
124
|
+
material?: AmbientMaterial | undefined;
|
|
125
|
+
/** The ribs' own colour, as an albedo. */
|
|
126
|
+
color?: string | undefined;
|
|
127
|
+
className?: string | undefined;
|
|
128
|
+
}) {
|
|
129
|
+
const id = `amb-knurl-${useId().replace(/:/g, "")}`;
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
<svg width={0} height={0} style={{ position: "absolute" }} aria-hidden focusable={false}>
|
|
133
|
+
<defs>
|
|
134
|
+
<clipPath id={id} clipPathUnits="objectBoundingBox">
|
|
135
|
+
<path d={KNURL_PATH} clipRule="evenodd" />
|
|
136
|
+
</clipPath>
|
|
137
|
+
</defs>
|
|
138
|
+
</svg>
|
|
139
|
+
<span
|
|
140
|
+
className={cn("amb-knob-face", material && `amb-mat-${material}`, className)}
|
|
141
|
+
style={{
|
|
142
|
+
clipPath: `url(#${id})`,
|
|
143
|
+
/* Not a paint colour: --amb-albedo is the ribs' REFLECTANCE, so a
|
|
144
|
+
dark knurl still takes the scene's exposure, the lamp's cast and
|
|
145
|
+
the rim's own --amb-shade step, and still goes dark when the
|
|
146
|
+
lights do. Inline, so it beats the albedo a micro-relief material
|
|
147
|
+
would otherwise set on this element — an explicit colour wins
|
|
148
|
+
over the finish's own, and the finish keeps its grain. */
|
|
149
|
+
...(color ? { "--amb-albedo": color } : null)
|
|
150
|
+
} as CSSProperties}
|
|
151
|
+
/>
|
|
152
|
+
</>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The grounded referent's offset indicator dot (knob() dot_frac 0.12,
|
|
157
|
+
* dot_offset 0.68). Put it in the `actuator` frame and it sweeps; put it
|
|
158
|
+
* in `base` and it stays put while everything else turns. */
|
|
159
|
+
export function IndicatorDot({ className }: { className?: string | undefined }) {
|
|
160
|
+
return <span className={cn("amb-knob-indicator-circle", className)} />;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A short radial bar out near the rim, running 0.50R to 0.84R. */
|
|
164
|
+
export function IndicatorBar({ className }: { className?: string | undefined }) {
|
|
165
|
+
return <span className={cn("amb-knob-indicator-rectangle", className)} />;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type ScaleRingProps = {
|
|
169
|
+
/** Dots to print. `2` is the pair the travel starts and stops at. */
|
|
170
|
+
count?: number | undefined;
|
|
171
|
+
className?: string | undefined;
|
|
172
|
+
children?: ReactNode | undefined;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Printed scale dots on the panel around a rotary, on the same arc the
|
|
176
|
+
* value sweeps.
|
|
177
|
+
*
|
|
178
|
+
* This is the part that has to read state as JS rather than CSS: the
|
|
179
|
+
* angles come from the control's own travel, and there is no way to emit
|
|
180
|
+
* N children from a stylesheet. It is also the proof that the context
|
|
181
|
+
* outlet works — the dots land on the sweep whatever `travel` is set to,
|
|
182
|
+
* without the ring being told. */
|
|
183
|
+
export function ScaleRing({ count = 13, className, children }: ScaleRingProps) {
|
|
184
|
+
const { travelStart, travelSweep } = useControlState();
|
|
185
|
+
const divisions = Math.max(1, count - 1);
|
|
186
|
+
const angles =
|
|
187
|
+
count <= 1
|
|
188
|
+
? [travelStart]
|
|
189
|
+
: Array.from({ length: count }, (_, i) => travelStart + (i / divisions) * travelSweep);
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<span className={cn("amb-knob-marker-ring", className)} aria-hidden>
|
|
193
|
+
{angles.map((angle) => (
|
|
194
|
+
<span
|
|
195
|
+
key={angle}
|
|
196
|
+
className="amb-knob-marker"
|
|
197
|
+
style={{ "--amb-marker-angle": `${angle}deg` } as CSSProperties}
|
|
198
|
+
/>
|
|
199
|
+
))}
|
|
200
|
+
{children}
|
|
201
|
+
</span>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** The recess the pill slides in (switch.py: a 1.5mm well, thickness 0.33). */
|
|
5
|
+
export function SwitchTrack({ className }: { className?: string | undefined }) {
|
|
6
|
+
return <span className={cn("amb-switch-track amb-groove", className)} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The sliding pill, standing 2.6mm above the recess floor. */
|
|
10
|
+
export function SwitchPill({ className }: { className?: string | undefined }) {
|
|
11
|
+
return (
|
|
12
|
+
<span className={cn("amb-switch-pill ambient amb-fillet amb-surface-convex", className)} />
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A pinprick indicator lamp. `color` is any CSS colour; unset it takes the
|
|
17
|
+
* scene's own lamp colour, the same `--amb-led-color` a bank reads. */
|
|
18
|
+
export function Led({
|
|
19
|
+
on = true,
|
|
20
|
+
color,
|
|
21
|
+
className
|
|
22
|
+
}: {
|
|
23
|
+
on?: boolean | undefined;
|
|
24
|
+
color?: string | undefined;
|
|
25
|
+
className?: string | undefined;
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
className={cn("amb-led", !on && "amb-led-off", className)}
|
|
30
|
+
style={color ? ({ "--amb-led-color": color } as CSSProperties) : undefined}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import type { AmbientMaterial } from "../core/material";
|
|
4
|
+
import { isRelief } from "../core/material";
|
|
5
|
+
|
|
6
|
+
/** The key cap: a chamfered, subtly dished top that sinks on `:active`.
|
|
7
|
+
*
|
|
8
|
+
* The cap is what sizes a press control — its legend sets the width above
|
|
9
|
+
* the well's `min-width` — which is why a press control's frames are
|
|
10
|
+
* `display: contents` markers rather than boxes.
|
|
11
|
+
*
|
|
12
|
+
* The cap spends its own `::after` on the dish, so the two micro-relief
|
|
13
|
+
* materials cannot ride on it: their grain wants both pseudo-elements, and
|
|
14
|
+
* the dish's `background` shorthand and the grain's tile would each silently
|
|
15
|
+
* win half of the other's declarations. They get `.ambx-cap-face` instead —
|
|
16
|
+
* an inner layer under the dish and under the legend, which is the inner
|
|
17
|
+
* layer @ambientcss/css's own note prescribes. The cap itself stays a plain
|
|
18
|
+
* `amb-surface` when relief is down there: the dish's overlay alphas are
|
|
19
|
+
* derived from `--amb-shade` the ordinary way, and since the relief
|
|
20
|
+
* materials carry no `--amb-albedo` of their own any more, that derivation
|
|
21
|
+
* is already correct for them too — no per-material tone correction to
|
|
22
|
+
* apply. */
|
|
23
|
+
export function ButtonCap({
|
|
24
|
+
material = "matte",
|
|
25
|
+
className,
|
|
26
|
+
children
|
|
27
|
+
}: {
|
|
28
|
+
material?: AmbientMaterial | undefined;
|
|
29
|
+
className?: string | undefined;
|
|
30
|
+
children?: ReactNode | undefined;
|
|
31
|
+
}) {
|
|
32
|
+
const relief = isRelief(material);
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
className={cn(
|
|
36
|
+
"amb-button-cap ambient amb-chamfer amb-surface amb-heading-3",
|
|
37
|
+
relief ? undefined : `amb-mat-${material}`,
|
|
38
|
+
className
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
{relief ? (
|
|
42
|
+
<span
|
|
43
|
+
/* `ambient amb-chamfer` on the face, not just on the cap: the
|
|
44
|
+
chamfer is painted as INSET shadows, which belong to the cap's
|
|
45
|
+
own background layer — and the face, being opaque and covering
|
|
46
|
+
it, would hide them. Wearing the cut itself puts the bevel back,
|
|
47
|
+
and puts it back in the material's own tone rather than the
|
|
48
|
+
cap's. */
|
|
49
|
+
className={cn("ambx-cap-face ambient amb-chamfer amb-surface", `amb-mat-${material}`)}
|
|
50
|
+
aria-hidden
|
|
51
|
+
/>
|
|
52
|
+
) : null}
|
|
53
|
+
{children}
|
|
54
|
+
</span>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { cn } from "../lib/cn";
|
|
2
|
+
import type { AmbientMaterial } from "../core/material";
|
|
3
|
+
|
|
4
|
+
/** The track a thumb rides in.
|
|
5
|
+
*
|
|
6
|
+
* Both grounded referents are grooves with a lume interior — dark in
|
|
7
|
+
* bright light, glowing in low light — but they are cut to different
|
|
8
|
+
* depths: a fader runs in a through-slot, a slider in a shallow concave
|
|
9
|
+
* channel (slider.py, 1mm deep = thickness 0.22). */
|
|
10
|
+
export function TravelTrack({
|
|
11
|
+
depth = "slot",
|
|
12
|
+
className
|
|
13
|
+
}: {
|
|
14
|
+
depth?: "slot" | "channel" | undefined;
|
|
15
|
+
className?: string | undefined;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<span
|
|
19
|
+
className={cn(
|
|
20
|
+
"amb-travel-track amb-groove",
|
|
21
|
+
depth === "channel" && "amb-travel-track-channel",
|
|
22
|
+
className
|
|
23
|
+
)}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Fader cap: the referent (fader.py) is a pill on a stem — 7mm tall
|
|
29
|
+
* (thickness 1.5) riding 2.2mm above the plate (elevation 0.28) — with a
|
|
30
|
+
* single grip line across the top. */
|
|
31
|
+
export function FaderCap({
|
|
32
|
+
material,
|
|
33
|
+
className
|
|
34
|
+
}: {
|
|
35
|
+
material?: AmbientMaterial | undefined;
|
|
36
|
+
className?: string | undefined;
|
|
37
|
+
}) {
|
|
38
|
+
return (
|
|
39
|
+
<span
|
|
40
|
+
className={cn(
|
|
41
|
+
"amb-fader-thumb ambient amb-fillet",
|
|
42
|
+
material !== "glass" && "amb-surface-concave",
|
|
43
|
+
material && `amb-mat-${material}`,
|
|
44
|
+
className
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
<span className="amb-fader-gripline" />
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Slider thumb: a domed disc gliding over the channel. */
|
|
53
|
+
export function SliderThumb({
|
|
54
|
+
material,
|
|
55
|
+
className
|
|
56
|
+
}: {
|
|
57
|
+
material?: AmbientMaterial | undefined;
|
|
58
|
+
className?: string | undefined;
|
|
59
|
+
}) {
|
|
60
|
+
return (
|
|
61
|
+
<span
|
|
62
|
+
className={cn(
|
|
63
|
+
"amb-slider-thumb ambient amb-fillet",
|
|
64
|
+
material !== "glass" && "amb-surface-convex",
|
|
65
|
+
material && `amb-mat-${material}`,
|
|
66
|
+
className
|
|
67
|
+
)}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|