@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
@@ -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
+ }