"use client";
import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { createSpring, focusQuietly, type Spring } from "@/design-system";
import { hostTransport, play } from "@/lib/sound";
/*
* A long-throw motorized fader after a mixing console's: a ridged cap riding
* in a dark slot, an engraved scale beside it and its level on a small LCD.
*
* The value is the cap's position, min to max; the scale reads it through an
* audio taper, as a console's is printed: 0 dB three quarters of the way up,
* 5 dB an eighth above −10, and the bottom eighth falling away to −∞.
*
* Values set from outside travel on the motor, a spring; values set by hand
* follow the hand, and while a hand is on the cap the motor lets go of it, as
* a touch-sensitive fader's does. Near the default the cap drops into a
* detent with a click and holds until the hand pulls clear. Double-click or
* double-tap sends it home on the motor. Shift drags finely; the arrow keys
* step, Page Up and Down move a tenth, Home and End go to the stops. The cap
* moves by a CSS variable the spring writes, never by React state.
*
* With `name` it carries its value in a hidden input and returns to its
* default when the form resets.
*/
/* The well, in em: the cap stops STOP short of either end. */
const TRACK = 13.4;
const WIDTH = 6;
const CAP = 2.3;
const STOP = 0.4;
const TRAVEL = TRACK - 2 * STOP - CAP;
const SLOT_X = 3.75; // the slot's centre, right of the scale
/** The motor: a whisker under critically damped, so it lands without hunting. */
const MOTOR = { stiffness: 240, damping: 30 };
const FINE = 0.25; // Shift: a quarter of the travel per pixel
const CATCH = 0.012; // of the range: how near the default the hand comes before the detent takes the cap
const RELEASE = 0.035; // of the range: how far past it the hand pulls before the detent lets go
const DOUBLE = 320; // ms between two presses that make a double tap
/** The audio taper: travel (0 to 1) against gain in dB, as the scale is printed. */
const TAPER: [number, number][] = [
[0.12, -40],
[0.22, -30],
[0.35, -20],
[0.5, -10],
[0.625, -5],
[0.75, 0],
[0.875, 5],
[1, 10],
];
/** Gain in dB at a point of the travel. Below −40 it falls away to −∞ at the bottom stop. */
export function faderGain(travel: number) {
const [f0, db0] = TAPER[0];
if (travel <= f0) return travel <= 0 ? -Infinity : db0 + 20 * Math.log10(travel / f0);
for (let i = 1; i < TAPER.length; i++) {
const [fa, a] = TAPER[i - 1];
const [fb, b] = TAPER[i];
if (travel <= fb) return a + ((travel - fa) / (fb - fa)) * (b - a);
}
return TAPER[TAPER.length - 1][1];
}
/** "−3.5 dB", "0.0 dB", "+10 dB", "−∞ dB": tenths near unity, whole decibels further out. */
export function formatGain(db: number) {
if (db < -99) return "−∞ dB";
const r = Math.abs(db) < 9.95 ? Math.round(db * 10) / 10 : Math.round(db);
const figures = Math.abs(r) < 10 ? Math.abs(r).toFixed(1) : String(Math.abs(r));
return `${r > 0 ? "+" : r < 0 ? "−" : ""}${figures} dB`;
}
/** Where the cap's centre line sits at a point of the travel, in em from the top of the well. */
const lineAt = (travel: number) => Math.round((STOP + CAP / 2 + (1 - travel) * TRAVEL) * 1000) / 1000;
/** The scale: a labelled mark at each point of the taper, and −∞ at the bottom stop. */
const MARKS = [...TAPER.map(([f, db]) => ({ f, label: db > 0 ? `+${db}` : db < 0 ? `−${-db}` : "0" })).reverse(), { f: 0, label: "−∞" }].map(
(m) => ({ ...m, y: lineAt(m.f) }),
);
/** Short unlabelled marks halfway between the labelled ones. */
const MINOR = TAPER.slice(1).map(([f], i) => lineAt((f + TAPER[i][0]) / 2));
type FaderProps = {
label: string;
value: number;
onChange: (value: number) => void;
min: number;
max: number;
step: number;
/** Where the detent sits, and where a double-click or a form reset sends the cap. */
defaultValue: number;
/** The LCD and aria-valuetext. Defaults to the scale's gain in dB. */
format?: (value: number) => string;
/** Carries the value in a hidden input, for forms. */
name?: string;
/** A hand took hold of the cap (true) or let go of it (false). */
onTouch?: (touching: boolean) => void;
className?: string;
};
const cx = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" ");
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
type Range = { min: number; max: number; step: number };
/** How far up the travel a value sits, 0 to 1. */
const travelOf = (v: number, { min, max }: Range) => clamp((v - min) / (max - min), 0, 1);
/** The nearest step to a value, inside the range and clean of float dust. */
function snap(v: number, { min, max, step }: Range) {
const n = Math.round((clamp(v, min, max) - min) / step);
return clamp(Math.round((min + n * step) * 1e6) / 1e6, min, max);
}
export function Fader({ label, value, onChange, min, max, step, defaultValue, format, name, onTouch, className }: FaderProps) {
const rootRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const spring = useRef<Spring | null>(null);
const placed = useRef(false);
const at = useRef(value); // the value, ahead of the render that confirms it
const target = useRef(0); // permille of travel the motor is heading for
const pending = useRef<{ value: number; glide: boolean } | null>(null); // a value this fader just asked for
const held = useRef(false); // a hand is on the cap: the motor lets go of it
const homing = useRef(false); // the motor is taking it home: it clicks into the detent as it lands
const press = useRef<number | null>(null); // the pointer that is down
const lastPress = useRef({ at: -Infinity, y: 0 });
const drag = useRef<{ id: number; y: number; from: number; raw: number; travel: number; fine: boolean; caught: boolean } | null>(null);
const [initial] = useState(() => travelOf(value, { min, max, step }));
const show = format ?? ((v: number) => formatGain(faderGain(travelOf(v, { min, max, step }))));
// Props the spring and the form read when they fire, rather than when they were made.
const props = useRef({ value, onChange, onTouch, show, min, max, step, defaultValue });
useLayoutEffect(() => {
props.current = { value, onChange, onTouch, show, min, max, step, defaultValue };
});
// The spring runs in permille of travel and draws everything that moves: the cap and the LCD.
useLayoutEffect(() => {
const root = rootRef.current!;
let shown = "";
const s = createSpring(initial * 1000, MOTOR, (permille) => {
const { show, min, max } = props.current;
const f = permille / 1000;
root.style.setProperty("--f", f.toFixed(4));
const text = show(snap(min + f * (max - min), props.current));
if (text !== shown && readoutRef.current) {
shown = text;
readoutRef.current.textContent = text;
}
if (homing.current && permille === target.current) {
homing.current = false;
play("tick", { gain: 0.8, pitch: 1.1 });
}
});
spring.current = s;
return () => s.stop();
}, [initial]);
// A new value: the hand's lands at once, anyone else's travels on the motor, unless a hand is holding the cap.
useLayoutEffect(() => {
const s = spring.current!;
const ours = pending.current?.value === value ? pending.current : null;
pending.current = null;
at.current = value;
target.current = travelOf(value, props.current) * 1000;
if (value !== props.current.defaultValue) homing.current = false;
if (!placed.current || reducedMotion() || (ours && !ours.glide)) {
placed.current = true;
s.jump(target.current);
} else if (ours || !held.current) s.set(target.current);
}, [value]);
/** Asks for a value: `glide` carries it on the motor, otherwise it lands under the hand. */
function moveTo(v: number, glide: boolean) {
at.current = v;
pending.current = { value: v, glide };
props.current.onChange(v);
}
function home() {
const { defaultValue } = props.current;
if (at.current === defaultValue && spring.current?.value === target.current) return play("tick", { gain: 0.8, pitch: 1.1 });
homing.current = true;
moveTo(defaultValue, true);
}
// In a form: a reset sends the cap home on the motor.
const reset = useEffectEvent(() => {
if (at.current !== defaultValue) moveTo(defaultValue, true);
});
useEffect(() => {
const form = inputRef.current?.form;
if (!form) return;
const onReset = () => reset();
form.addEventListener("reset", onReset);
return () => form.removeEventListener("reset", onReset);
}, [name]);
function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const range = max - min;
const delta = { ArrowUp: step, ArrowRight: step, ArrowDown: -step, ArrowLeft: -step, PageUp: range / 10, PageDown: -range / 10 }[e.key];
const next = delta !== undefined ? snap(at.current + delta, props.current) : e.key === "Home" ? min : e.key === "End" ? max : null;
if (next === null) return;
e.preventDefault();
const from = at.current;
if (next === from) return play("bump", { gain: 0.5 });
// A light click per step; the stops bump, and the default clicks firmly, as the detent does under the hand.
if (next === min || next === max) play("bump", { gain: 0.5 });
else if (next === defaultValue || (from - defaultValue) * (next - defaultValue) < 0) play("tick", { gain: 0.8, pitch: 1.1 });
else play("tick", { gain: 0.35, pitch: 0.9 + travelOf(next, props.current) * 0.25 });
moveTo(next, true);
}
function onPointerDown(e: PointerEvent<HTMLDivElement>) {
if (e.button !== 0 || press.current !== null) return;
e.preventDefault();
const el = e.currentTarget;
focusQuietly(el); // no ring until a key is pressed
el.setPointerCapture(e.pointerId);
press.current = e.pointerId;
play("press", { gain: 0.5 });
onTouch?.(true);
// The second press of a double tap sends it home.
const last = lastPress.current;
const double = e.timeStamp - last.at < DOUBLE && Math.abs(e.clientY - last.y) < 24;
lastPress.current = { at: double ? -Infinity : e.timeStamp, y: e.clientY };
if (double) return home();
const s = spring.current!;
const box = el.getBoundingClientRect();
const cap = el.querySelector("[data-cap]")!.getBoundingClientRect();
const travel = box.height * (TRAVEL / TRACK);
held.current = true;
homing.current = false;
el.dataset.held = "";
let from: number;
if (e.clientY >= cap.top && e.clientY <= cap.bottom) {
// On the cap: the hand stops the motor where it is.
s.jump(s.value);
from = min + (s.value / 1000) * (max - min);
} else {
// Beside it: the motor brings the cap to the hand, and the hand takes it from there.
const f = clamp(1 - (e.clientY - box.top - box.height * ((STOP + CAP / 2) / TRACK)) / travel, 0, 1);
from = snap(min + f * (max - min), props.current);
if (from !== at.current) moveTo(from, true);
}
drag.current = { id: e.pointerId, y: e.clientY, from, raw: from, travel, fine: e.shiftKey, caught: from === defaultValue };
}
function onPointerMove(e: PointerEvent<HTMLDivElement>) {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
const range = max - min;
// Shift is fine control. Changing it mid-drag starts over from here, so the cap never jumps.
if (e.shiftKey !== d.fine) {
d.from = d.raw;
d.y = e.clientY;
d.fine = e.shiftKey;
}
let raw = d.from - ((e.clientY - d.y) / d.travel) * range * (d.fine ? FINE : 1);
// Past a stop, the hand slides off the cap: coming back moves it at once.
if (raw > max || raw < min) {
raw = clamp(raw, min, max);
d.from = raw;
d.y = e.clientY;
}
// The detent at the default: it takes the cap as the hand comes near and holds it until the hand pulls clear.
const off = raw - defaultValue;
let next: number;
if (d.caught && Math.abs(off) <= RELEASE * range) next = defaultValue;
else if (!d.caught && Math.abs(off) <= CATCH * range) {
d.caught = true;
next = defaultValue;
play("tick", { gain: 0.85, pitch: 1.1 });
} else {
if (d.caught) play("tick", { gain: 0.4, pitch: 0.95 });
else if ((d.raw - defaultValue) * off < 0) play("tick", { gain: 0.6, pitch: 1.1 }); // flew straight through it
d.caught = false;
next = snap(raw, props.current);
}
d.raw = raw;
if (next === at.current) return;
if (next === min || next === max) play("bump", { gain: 0.55 });
moveTo(next, false);
}
function onPointerEnd(e: PointerEvent<HTMLDivElement>) {
if (press.current !== e.pointerId) return;
press.current = null;
drag.current = null;
play("release", { gain: 0.5 });
if (held.current) {
held.current = false;
delete e.currentTarget.dataset.held;
// If the value moved on while the hand held the cap, the motor takes it there now.
const { value } = props.current;
if (value !== at.current) {
at.current = value;
target.current = travelOf(value, props.current) * 1000;
spring.current?.set(target.current);
}
}
onTouch?.(false);
}
return (
<div className={cx("flex flex-col items-center gap-[0.5em]", className)}>
{/* The LCD: the spring writes it, so it counts as the motor travels. */}
<span
ref={readoutRef}
aria-hidden
data-part="lcd"
className="min-h-[1.6em] min-w-[5.4em] rounded-[0.45em] px-[0.45em] py-[0.32em] text-center text-[0.72em] leading-none tabular-nums text-(--device-lcd-ink) [background:var(--device-lcd)] shadow-(--device-lcd-edge)"
/>
{/* The well: the scale engraved beside a dark slot, and the cap riding in it. */}
<div
ref={rootRef}
role="slider"
tabIndex={0}
aria-label={label}
aria-orientation="vertical"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={show(value)}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
data-part="well"
className="group/fader relative shrink-0 cursor-ns-resize touch-none select-none rounded-[0.85em] bg-(--device-well) shadow-(--device-recess) outline-offset-2"
style={{ width: `${WIDTH}em`, height: `${TRACK}em`, "--f": initial } as CSSProperties}
>
{/* Taken apart, this wrapper becomes the containing block of everything absolute in it (preserve-3d does that) and it has no height, so the slot's top and bottom would meet; drawn, it fills the well. */}
{/* The well's own box: taken apart, this wrapper is what the marks, the slot and the cap are placed in. */}
<div aria-hidden className="absolute inset-0">
{/* Drawn, each mark's box shrinks to its line (the hairline that outlines it), with the figure set back where it was, and 0 dB's line is the strong one. */}
{MARKS.map((m) => (
<div
key={m.label}
data-part="lettering"
className={cx(
"absolute inset-x-0 drawn:h-0 drawn:outline-offset-[-0.5px]!",
m.label === "0" ? "drawn:left-[1.85em] drawn:right-[0.3em]" : "drawn:left-[2em] drawn:right-[0.45em]",
)}
style={{ top: `${m.y}em` }}
>
<span className={cx("absolute left-0 top-0 flex w-[1.7em] -translate-y-1/2 justify-end", m.label === "0" ? "drawn:-left-[1.85em]" : "drawn:-left-[2em]")}>
<span
className={cx(
"whitespace-nowrap text-[0.5em] font-semibold leading-none tracking-[0.02em] tabular-nums [text-shadow:var(--device-engrave)]",
m.label === "0" ? "text-(--device-label)" : "text-(--device-label-quiet)",
)}
>
{m.label}
</span>
</span>
<span
className={cx(
"absolute top-0 h-px -translate-y-1/2 shadow-[0_1px_0_rgb(255_255_255/0.85)] dark:shadow-[0_-1px_0_rgb(0_0_0/0.6)] drawn:inset-x-0 drawn:bg-(--device-draw-ink)!",
m.label === "0" ? "left-[1.85em] right-[0.3em] bg-(--device-label) drawn:h-[2px]" : "left-[2em] right-[0.45em] bg-(--device-label-quiet)/70",
)}
/>
</div>
))}
{MINOR.map((y) => (
<span
key={y}
data-part="lettering"
className="absolute left-[2.2em] right-[0.65em] h-px -translate-y-1/2 bg-(--device-label-quiet)/40 shadow-[0_1px_0_rgb(255_255_255/0.85)] dark:shadow-[0_-1px_0_rgb(0_0_0/0.6)] drawn:h-0 drawn:outline-offset-[-0.5px]!"
style={{ top: `${y}em` }}
/>
))}
{/* The slot the cap's stem runs in. Drawn, a hairline capsule with the stem's line down its middle. */}
<div
data-part="slot"
className="absolute w-[0.26em] -translate-x-1/2 rounded-full bg-(--device-rim) shadow-[inset_0_1px_2px_rgb(0_0_0/0.7),0_1px_0_rgb(255_255_255/0.9)] dark:shadow-[inset_0_1px_2px_rgb(0_0_0/0.9),0_1px_0_rgb(255_255_255/0.06)] drawn:[background-image:linear-gradient(var(--device-draw-ink),var(--device-draw-ink))]! drawn:[background-position:center]! drawn:[background-repeat:no-repeat]! drawn:[background-size:1px_100%]!"
style={{ left: `${SLOT_X}em`, top: `${STOP + CAP / 2 - 0.35}em`, bottom: `${STOP + CAP / 2 - 0.35}em` }}
/>
{/* The cap: a ridged key, grip lines either side of the line that reads against the scale. Drawn, all of them are ink. */}
<div
data-cap
data-part="key"
className="absolute rounded-[0.36em] [background:var(--device-key-face)] shadow-(--device-key-shadow) transition-[box-shadow] duration-(--duration-exit) group-data-held/fader:shadow-(--device-key-shadow-pressed) [--grip-hi:rgb(255_255_255/0.95)] [--grip:rgb(0_0_0/0.14)] dark:[--grip-hi:rgb(255_255_255/0.07)] dark:[--grip:rgb(0_0_0/0.6)]"
style={{
left: `${SLOT_X - 1.25}em`,
width: "2.5em",
top: `${STOP}em`,
height: `${CAP}em`,
translate: `0 calc((1 - var(--f)) * ${TRAVEL}em)`,
}}
>
{["top-[0.24em]", "bottom-[0.24em]"].map((edge) => (
<span
key={edge}
className={cx("absolute inset-x-[0.24em] h-[0.66em] rounded-[0.12em] drawn:[background:repeating-linear-gradient(to_bottom,var(--device-draw-ink)_0_1px,transparent_1px_4px)]!", edge)}
style={{ background: "repeating-linear-gradient(to bottom, var(--grip) 0 1px, var(--grip-hi) 1px 2px, transparent 2px 3.5px)" }}
/>
))}
<span className="absolute inset-x-0 top-1/2 h-[0.13em] -translate-y-1/2 bg-(--device-key-ink) shadow-[0_1px_0_var(--grip-hi)] drawn:bg-(--device-draw-ink)!" />
</div>
</div>
</div>
<span aria-hidden data-part="lettering" className="text-[0.6em] font-semibold uppercase leading-none tracking-[0.16em] text-(--device-label) [text-shadow:var(--device-engrave)]">
{label}
</span>
{name && <input ref={inputRef} type="hidden" name={name} value={value} />}
</div>
);
}
/* --- Demo: a three-channel strip playing back its mix automation ------------- */
type Channel = "voice" | "music" | "room";
type Mode = "read" | "touch" | "off";
const CHANNELS: { key: Channel; label: string }[] = [
{ key: "voice", label: "Voice" },
{ key: "music", label: "Music" },
{ key: "room", label: "Room" },
];
const RANGE = { min: 0, max: 100, step: 0.5 };
const UNITY = 75; // 0 dB on the scale
const LOOP = 8; // s of recorded automation, played round
const READ_EVERY = 80; // ms between automation reads; the motors smooth between them
const STAGGER = 0.12; // s between one motor joining the playback and the next, on power-up
const HAND_BACK = 650; // ms a channel waits after the hand leaves it before the motor takes it back
const KEY_HAND_BACK = 1600; // ms after a key or a double-click
const smooth = (x: number) => {
const c = clamp(x, 0, 1);
return c * c * (3 - 2 * c);
};
/** Up at `a`, down at `b`, each over `r` seconds. */
const swell = (t: number, a: number, b: number, r: number) => smooth((t - a) / r) * (1 - smooth((t - b) / r));
/** Voice has two phrases a loop. */
const speaking = (t: number) => {
const u = ((t % LOOP) + LOOP) % LOOP;
return swell(u, 0.35, 2.7, 0.45) + swell(u, 3.7, 6.5, 0.45);
};
const q = (v: number) => snap(v, RANGE);
/** The recorded mix at `t` seconds: Voice rides its phrases, Music ducks under it a beat later, Room breathes. */
function mix(t: number): Record<Channel, number> {
const voice = speaking(t);
const duck = speaking(t - 0.18);
return {
voice: q(44 + 30 * voice + 2.2 * voice * Math.sin(t * Math.PI * 1.5)),
music: q(77 - 21 * duck + 1.6 * Math.sin((t * Math.PI) / 2)),
room: q(40 + 7 * Math.sin((t * Math.PI) / 4 + 0.6)),
};
}
const now = () => performance.now();
const clock = (t: number) => `${String(Math.floor(t / 60) % 60).padStart(2, "0")}:${(t % 60).toFixed(1).padStart(4, "0")}`;
const SILENT: Record<Channel, number> = { voice: 0, music: 0, room: 0 };
const OFF: Record<Channel, Mode> = { voice: "off", music: "off", room: "off" };
const READ: Record<Channel, Mode> = { voice: "read", music: "read", room: "read" };
export default function Demo() {
const [values, setValues] = useState(SILENT);
const [modes, setModes] = useState(OFF);
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [announcement, setAnnouncement] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const modesRef = useRef(OFF);
const playingRef = useRef(false);
const started = useRef(0);
const holding = useRef(new Set<Channel>());
const homed = useRef(new Set<Channel>()); // sent home under the hand: let the motor land before handing back
const timers = useRef<Partial<Record<Channel, ReturnType<typeof setTimeout>>>>({});
function setMode(ch: Channel, mode: Mode) {
if (modesRef.current[ch] === mode) return;
modesRef.current = { ...modesRef.current, [ch]: mode };
setModes(modesRef.current);
const label = CHANNELS.find((c) => c.key === ch)!.label;
if (mode === "touch") setAnnouncement(`${label}: touch`);
else if (mode === "read") setAnnouncement(`${label}: back on automation`);
}
function start() {
started.current = now();
playingRef.current = true;
modesRef.current = READ;
setModes(READ);
setPlaying(true);
}
// Playback: read the curves a few times a second; each channel in READ follows them on its motor.
// It makes no sound of its own: only a hand on the strip clicks.
const read = useEffectEvent(() => {
const t = (now() - started.current) / 1000;
const curve = mix(t);
const reading = CHANNELS.filter(({ key }, i) => modesRef.current[key] === "read" && t >= i * STAGGER);
setTime(t);
setValues((prev) => {
const next = { ...prev };
reading.forEach(({ key }) => (next[key] = curve[key]));
return next;
});
});
useEffect(() => {
if (!playing) return;
const id = setInterval(read, READ_EVERY);
return () => clearInterval(id);
}, [playing]);
// Power-up: play the automation, unless the viewer prefers less motion (then a still mix) or the host's tape is stopped.
// A stopped host that starts again starts the playback.
const powerUp = useEffectEvent(() => {
if (reducedMotion()) setValues(mix(1.6));
else if (hostTransport(rootRef.current) !== "stop") start();
});
const hostPlays = useEffectEvent(() => {
if (!playingRef.current && !reducedMotion() && hostTransport(rootRef.current) === "play") start();
});
useEffect(() => {
const id = setTimeout(powerUp, 250);
const host = rootRef.current?.closest("[data-transport]");
const observer = new MutationObserver(hostPlays);
if (host) observer.observe(host, { attributes: true, attributeFilter: ["data-transport"] });
const pending = timers.current;
return () => {
clearTimeout(id);
observer.disconnect();
Object.values(pending).forEach(clearTimeout);
};
}, []);
/** After a pause, the channel goes back to reading its automation and the motor brings it to the curve. */
function handBack(ch: Channel, after: number) {
clearTimeout(timers.current[ch]);
timers.current[ch] = setTimeout(() => setMode(ch, playingRef.current ? "read" : "off"), after);
}
function touch(ch: Channel, on: boolean) {
clearTimeout(timers.current[ch]);
if (on) {
holding.current.add(ch);
setMode(ch, "touch");
} else {
holding.current.delete(ch);
handBack(ch, homed.current.delete(ch) ? KEY_HAND_BACK : HAND_BACK);
}
}
function change(ch: Channel, v: number) {
setValues((prev) => ({ ...prev, [ch]: v }));
if (holding.current.has(ch)) {
if (v === UNITY) homed.current.add(ch);
else homed.current.delete(ch);
return;
}
// A key or a double-click takes the channel over too, until it has been left alone a moment.
setMode(ch, "touch");
handBack(ch, KEY_HAND_BACK);
}
const touched = CHANNELS.filter((c) => modes[c.key] === "touch");
return (
<div ref={rootRef} className="@container w-full max-w-[460px] select-none">
<div data-part="plate" className="relative isolate animate-enter overflow-hidden rounded-[1.25em] p-[0.9em] text-[clamp(11px,4cqw,15px)] [background:var(--device-body)] shadow-[var(--device-body-edge),0_1px_2px_rgb(0_0_0/0.06),0_16px_32px_-18px_rgb(0_0_0/0.3)]">
<div aria-hidden className="device-grain pointer-events-none absolute inset-0 -z-10 rounded-[inherit]" />
{/* The automation screen. */}
<div
aria-hidden
data-part="lcd"
className="flex h-[3.3em] flex-col justify-between overflow-hidden rounded-[0.7em] px-[0.8em] pb-[0.5em] pt-[0.55em] text-(--device-lcd-ink) [background:var(--device-lcd)] shadow-(--device-lcd-edge)"
>
<div className="flex items-center gap-[0.5em]">
<span data-part="chip" className="inline-flex shrink-0 items-center gap-[0.35em] rounded-[0.4em] bg-white px-[0.42em] py-[0.24em] text-black">
{touched.length ? (
<span className="size-[0.55em] rounded-full bg-(--device-hold) drawn:bg-(--device-draw-ink)!" />
) : playing ? (
<span className="size-[0.55em] animate-pulse rounded-full bg-(--device-rec) drawn:bg-(--device-rec)!" />
) : (
<span className="size-[0.5em] rounded-[1px] bg-current drawn:bg-(--device-draw-ink)!" />
)}
<span className="text-[0.58em] font-semibold uppercase leading-none tracking-[0.02em]">
{touched.length ? "Touch" : playing ? "Auto · Read" : "Auto · Off"}
</span>
</span>
<span className="ml-auto text-[0.66em] tabular-nums text-(--device-lcd-dim) drawn:text-(--device-draw-line)!">{clock(time)}</span>
</div>
<span className="truncate text-[1.05em] font-light leading-none tracking-[-0.01em]">
{touched.length ? touched.map((c) => c.label).join(" · ") : "Mix · take_04"}
</span>
</div>
{/* The strip. */}
<div className="mt-[0.85em] grid grid-cols-3 gap-[0.4em]">
{CHANNELS.map(({ key, label }) => (
<div key={key} className="flex flex-col items-center gap-[0.45em]">
<Fader
label={label}
value={values[key]}
min={RANGE.min}
max={RANGE.max}
step={RANGE.step}
defaultValue={UNITY}
onChange={(v) => change(key, v)}
onTouch={(on) => touch(key, on)}
/>
{/* The channel's automation light. */}
<span aria-hidden className="flex items-center gap-[0.4em]">
<span
data-part="light"
className={cx(
"size-[0.4em] rounded-full transition-[background-color,box-shadow] duration-(--duration-exit)",
// Drawn, Read and Touch are both ink (orange is the drawing's own); off stays its hairline ring.
modes[key] === "touch"
? "bg-(--device-hold) shadow-[0_0_0.45em_var(--device-hold)] drawn:bg-(--device-draw-ink)!"
: modes[key] === "read"
? "bg-(--device-meter-on) drawn:bg-(--device-draw-ink)!"
: "bg-(--device-meter-off)",
)}
/>
<span data-part="lettering" className="text-[0.52em] font-semibold uppercase leading-none tracking-[0.14em] text-(--device-label-quiet) [text-shadow:var(--device-engrave)]">
{modes[key]}
</span>
</span>
</div>
))}
</div>
</div>
<p role="status" aria-live="polite" className="sr-only">
{announcement}
</p>
</div>
);
}