"use client";
import * as React from "react";
import { animate, AnimatePresence, motion, MotionConfig, useMotionValue, useMotionValueEvent, useReducedMotion, type MotionValue } from "motion/react";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Braces, Check, Download, Layers, Pause, Play, Redo2, SlidersHorizontal, Undo2, Upload } from "lucide-react";
import { cn } from "@/lib/utils";
import { Artwork } from "./artwork";
import { BACKDROPS, DESIGNS, SWATCHES } from "./data";
import { downloadText, serializeArtwork, slugify } from "./export";
import { Inspector } from "./inspector";
import { LabelCanvas, type Box } from "./label-canvas";
import { LayersPanel } from "./layers-panel";
import { Mockup3D } from "./mockup-3d";
import { CONTAINER_ORDER, CONTAINERS } from "./shapes";
import { ARTBOARD, type ContainerKind, type Design, type Layer, type LayerKind } from "./types";
import { focusRing, IconButton, Kbd, Segmented, StudioMark, useIsDesktop } from "./ui";
export type { Design, Layer, ContainerKind };
export type LabelMockupStudioAppProps = {
designs?: Design[];
initialDesignId?: string;
swatches?: string[];
backdrops?: string[];
onChange?: (design: Design) => void;
onExport?: (svg: string, design: Design) => void;
className?: string;
};
const HISTORY = 20;
let seq = 0;
function textColorFor(bg: string) {
const m = /^#?([0-9a-f]{6})$/i.exec(bg.trim());
if (!m) return "#111827";
const v = parseInt(m[1], 16);
const lum = (0.299 * ((v >> 16) & 255) + 0.587 * ((v >> 8) & 255) + 0.114 * (v & 255)) / 255;
return lum > 0.6 ? "#1f2937" : "#ffffff";
}
function newLayer(kind: LayerKind, bg: string): Layer {
const id = `layer-${++seq}`;
const base = { id, kind, rotation: 0, visible: true, locked: false } as const;
if (kind === "text") return { ...base, name: "Text", x: 50, y: 50, w: 140, h: 20, props: { text: "New text", font: "sans", size: 14, weight: 700, tracking: 0.04, color: textColorFor(bg), align: "middle" } };
if (kind === "shape") return { ...base, name: "Shape", x: 90, y: 35, w: 60, h: 50, props: { shape: "rect", fill: "#be123c", stroke: "none", strokeWidth: 0, radius: 8 } };
if (kind === "pattern") return { ...base, name: "Pattern", x: 0, y: 0, w: ARTBOARD.w, h: ARTBOARD.h, props: { pattern: "dots", color: textColorFor(bg), opacity: 0.25, scale: 1 } };
return { ...base, name: "Badge", x: 100, y: 40, w: 40, h: 40, props: { text: "NEW", shape: "seal", fill: "#c9a24a", color: "#3b2a0b" } };
}
type Tab = "layers" | "edit" | "export";
export function LabelMockupStudioApp({ designs: initialDesigns = DESIGNS, initialDesignId, swatches = SWATCHES, backdrops = BACKDROPS, onChange, onExport, className }: LabelMockupStudioAppProps) {
const reduced = useReducedMotion() ?? false;
const desktop = useIsDesktop();
const pid = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const [designs, setDesigns] = React.useState<Design[]>(initialDesigns);
const [activeId, setActiveId] = React.useState(initialDesignId ?? initialDesigns[0]?.id ?? "");
const design = designs.find((d) => d.id === activeId) ?? designs[0];
const [past, setPast] = React.useState<Design[]>([]);
const [future, setFuture] = React.useState<Design[]>([]);
const lastKey = React.useRef<{ key: string; at: number } | null>(null);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [backdrop, setBackdrop] = React.useState(backdrops[0] ?? "#e9e4dc");
const [tab, setTab] = React.useState<Tab>("edit");
const [toast, setToast] = React.useState<{ id: number; text: string } | null>(null);
const [bigStep, setBigStep] = React.useState(false);
const [playing, setPlaying] = React.useState(true);
const autoTurn = playing && !reduced;
const rootRef = React.useRef<HTMLDivElement>(null);
const exportRef = React.useRef<SVGSVGElement>(null);
// Turn: motion value drives the wrap; React state mirrors it for the strip renderer.
const turnMv = useMotionValue(24);
React.useEffect(() => {
if (!autoTurn) return;
const from = turnMv.get();
const c = animate(turnMv, from + 360, { duration: 9, ease: "linear", repeat: Infinity });
return () => c.stop();
}, [autoTurn, turnMv]);
const say = (text: string) => setToast({ id: ++seq, text });
React.useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2200);
return () => window.clearTimeout(t);
}, [toast]);
const selected = design.layers.find((l) => l.id === selectedId) ?? null;
/* ------------------------------- history -------------------------------- */
const write = (next: Design) => {
setDesigns((ds) => ds.map((d) => (d.id === next.id ? next : d)));
onChange?.(next);
};
const pushPast = (cur: Design) => {
setPast((p) => [...p.slice(-(HISTORY - 1)), cur]);
setFuture([]);
};
/** Apply a change with an undo step. Changes with the same `key` within 1 s merge into one step. */
const commit = (fn: (d: Design) => Design, key?: string) => {
const now = performance.now();
const merge = key && lastKey.current?.key === key && now - lastKey.current.at < 1000;
if (!merge) pushPast(design);
lastKey.current = key ? { key, at: now } : null;
write(fn(design));
};
const live = (fn: (d: Design) => Design) => write(fn(design));
const beginGesture = () => {
pushPast(design);
lastKey.current = null;
};
const undo = () => {
if (!past.length) return;
const prev = past[past.length - 1];
setPast((p) => p.slice(0, -1));
setFuture((f) => [design, ...f].slice(0, HISTORY));
write(prev);
lastKey.current = null;
say(`Undo · ${past.length - 1} left`);
};
const redo = () => {
if (!future.length) return;
const next = future[0];
setFuture((f) => f.slice(1));
setPast((p) => [...p.slice(-(HISTORY - 1)), design]);
write(next);
lastKey.current = null;
say("Redo");
};
const mapLayer = (id: string, fn: (l: Layer) => Layer) => (d: Design) => ({ ...d, layers: d.layers.map((l) => (l.id === id ? fn(l) : l)) });
/* -------------------------------- actions ------------------------------- */
const addLayer = (kind: LayerKind) => {
const l = newLayer(kind, design.background);
commit((d) => ({ ...d, layers: kind === "pattern" ? [l, ...d.layers] : [...d.layers, l] }));
setSelectedId(l.id);
if (!desktop) setTab("edit");
say(`${kind[0].toUpperCase()}${kind.slice(1)} layer added`);
if (kind === "text") window.setTimeout(() => setEditingId(l.id), 60);
};
const removeLayer = (id: string) => {
const l = design.layers.find((x) => x.id === id);
if (!l) return;
if (l.locked) return lockedNotice(l);
commit((d) => ({ ...d, layers: d.layers.filter((x) => x.id !== id) }));
setSelectedId(null);
say(`Deleted ${l.name ?? l.kind} · Ctrl+Z to undo`);
};
const duplicate = (id: string) => {
const i = design.layers.findIndex((x) => x.id === id);
if (i < 0) return;
const src = design.layers[i];
const copy: Layer = { ...src, id: `layer-${++seq}`, name: `${src.name ?? src.kind} copy`, x: src.x + 6, y: src.y + 6, locked: false, props: { ...src.props } };
commit((d) => ({ ...d, layers: [...d.layers.slice(0, i + 1), copy, ...d.layers.slice(i + 1)] }));
setSelectedId(copy.id);
say("Duplicated");
};
const nudge = (id: string, dx: number, dy: number) => {
const l = design.layers.find((x) => x.id === id);
if (!l) return;
if (l.locked) return lockedNotice(l);
commit(mapLayer(id, (x) => ({ ...x, x: x.x + dx, y: x.y + dy })), `nudge-${id}`);
};
const arrange = (id: string, dir: 1 | -1) => {
const i = design.layers.findIndex((x) => x.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= design.layers.length) return;
commit((d) => {
const ls = [...d.layers];
[ls[i], ls[j]] = [ls[j], ls[i]];
return { ...d, layers: ls };
});
};
const lockedNotice = (l: Layer) => say(`${l.name ?? "Layer"} is locked · unlock it in Layers`);
const exportSvg = () => {
const el = exportRef.current;
if (!el) return;
const svg = serializeArtwork(el, design);
onExport?.(svg, design);
const ok = downloadText(svg, `${slugify(design.name)}-label.svg`, "image/svg+xml");
say(ok ? "SVG exported" : "Export ready");
};
const copyJson = async () => {
const json = JSON.stringify(design, null, 2);
try {
await navigator.clipboard.writeText(json);
say("Design JSON copied");
} catch {
say("Clipboard not available here");
}
};
const switchDesign = (id: string) => {
if (id === activeId) return;
setActiveId(id);
setPast([]);
setFuture([]);
setSelectedId(null);
setEditingId(null);
lastKey.current = null;
};
/* ------------------------------- shortcuts ------------------------------ */
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const root = rootRef.current;
const t = e.target as HTMLElement | null;
if (!root || (t && t !== document.body && !root.contains(t))) return;
const typing = !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
const mod = e.metaKey || e.ctrlKey;
const k = e.key.toLowerCase();
if (mod && k === "z" && !typing) {
e.preventDefault();
if (e.shiftKey) redo();
else undo();
} else if (mod && k === "y" && !typing) {
e.preventDefault();
redo();
} else if (mod && k === "d") {
e.preventDefault();
if (selectedId) duplicate(selectedId);
} else if (!typing && !mod && (e.key === "Delete" || e.key === "Backspace") && selectedId) {
e.preventDefault();
removeLayer(selectedId);
} else if (!typing && e.key === "Escape") {
setSelectedId(null);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
/* --------------------------------- pieces ------------------------------- */
const canvas = (
<LabelCanvas
design={design}
selectedId={selectedId}
editingId={editingId}
pid={`${pid}c`}
onSelect={(id) => {
setSelectedId(id);
if (editingId && id !== editingId) setEditingId(null);
}}
onGestureStart={beginGesture}
onChangeBox={(id, box: Box, props) => live(mapLayer(id, (l) => ({ ...l, ...box, props: props ?? l.props })))}
onNudge={nudge}
onDelete={removeLayer}
onStartEdit={(id) => setEditingId(id)}
onEndEdit={(id, text) => {
setEditingId(null);
const l = design.layers.find((x) => x.id === id);
if (text !== null && l && text !== l.props.text) commit(mapLayer(id, (x) => ({ ...x, props: { ...x.props, text } })));
}}
onLocked={lockedNotice}
onAddText={() => addLayer("text")}
/>
);
const layersPanel = (
<LayersPanel
layers={design.layers}
selectedId={selectedId}
compact={!desktop}
onSelect={(id) => {
setSelectedId(id);
if (!desktop) setTab("edit");
}}
onToggle={(id, key) => {
const l = design.layers.find((x) => x.id === id);
commit(mapLayer(id, (x) => ({ ...x, [key]: !x[key] })));
if (l) say(`${l.name ?? l.kind} ${key === "visible" ? (l.visible ? "hidden" : "shown") : l.locked ? "unlocked" : "locked"}`);
}}
onReorder={(ids) => commit((d) => ({ ...d, layers: ids.map((id) => d.layers.find((l) => l.id === id)!).filter(Boolean) }))}
onAdd={addLayer}
/>
);
const inspector = (
<Inspector
design={design}
layer={selected}
swatches={swatches}
onLayer={(patch, key) => selected && commit(mapLayer(selected.id, (l) => ({ ...l, ...patch })), key)}
onProps={(patch, key) => selected && commit(mapLayer(selected.id, (l) => ({ ...l, props: { ...l.props, ...patch } })), key)}
onDesign={(patch, key) => commit((d) => ({ ...d, ...patch }), key)}
onDuplicate={() => selected && duplicate(selected.id)}
onDelete={() => selected && removeLayer(selected.id)}
onArrange={(dir) => selected && arrange(selected.id, dir)}
onToggleLock={() => selected && commit(mapLayer(selected.id, (l) => ({ ...l, locked: !l.locked })))}
/>
);
const turnControls = <TurnControls turnMv={turnMv} playing={autoTurn} disabled={reduced} onToggle={() => setPlaying((p) => !p)} onScrub={() => setPlaying(false)} />;
const containerPicker = (
<Segmented
label="Container"
pillId={`${pid}-cont`}
value={design.container}
onChange={(c) => commit((d) => ({ ...d, container: c }), "container")}
options={CONTAINER_ORDER.map((c) => ({ value: c, label: CONTAINERS[c].label, title: `${CONTAINERS[c].label} · ${CONTAINERS[c].size}` }))}
/>
);
const backdropPicker = (
<div role="radiogroup" aria-label="Backdrop" className="flex items-center gap-1">
{backdrops.map((b) => (
<button
key={b}
type="button"
role="radio"
aria-checked={backdrop === b}
aria-label={`Backdrop ${b}`}
onClick={() => setBackdrop(b)}
className={cn("size-5 rounded-full border border-black/10 shadow-sm dark:border-white/20", backdrop === b && "ring-2 ring-[var(--lm-accent)] ring-offset-2 ring-offset-background", focusRing)}
style={{ background: b }}
/>
))}
</div>
);
const stage = (
<div className="relative h-full min-h-0 overflow-hidden" style={{ background: `radial-gradient(120% 90% at 50% 18%, color-mix(in oklab, ${backdrop} 88%, white), ${backdrop} 55%, color-mix(in oklab, ${backdrop} 78%, black))` }}>
<div aria-hidden className="absolute inset-x-0 bottom-0 h-[26%] bg-gradient-to-b from-transparent to-black/10" />
<div className="absolute inset-0 grid place-items-center p-3 pb-16 lg:pb-14">
<LiveMockup turnMv={turnMv} design={design} reduced={reduced} className="h-full max-h-full w-auto max-w-full drop-shadow-[0_18px_30px_rgba(0,0,0,0.18)]" />
</div>
</div>
);
const exportActions = (
<>
<button type="button" onClick={copyJson} className={cn("inline-flex h-9 items-center gap-1.5 rounded-xl border bg-card px-3 text-[12.5px] font-medium", focusRing)}>
<Braces className="size-3.5" aria-hidden /> Copy JSON
</button>
<button type="button" onClick={exportSvg} className={cn("inline-flex h-9 items-center gap-1.5 rounded-xl bg-[var(--lm-accent)] px-3 text-[12.5px] font-semibold text-white shadow-md shadow-violet-600/25", focusRing)}>
<Download className="size-3.5" aria-hidden /> Export SVG
</button>
</>
);
const designPicker = (
<label className="relative flex h-9 min-w-0 items-center rounded-xl border bg-card pl-3 pr-2 focus-within:ring-2 focus-within:ring-ring">
<span className="sr-only">Design</span>
<select value={activeId} onChange={(e) => switchDesign(e.target.value)} className="h-full min-w-0 max-w-[180px] cursor-pointer appearance-none bg-transparent pr-5 text-[12.5px] font-medium outline-none">
{designs.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<ArrowDown aria-hidden className="pointer-events-none absolute right-2 size-3 text-muted-foreground" />
</label>
);
const undoRedo = (
<div className="flex items-center">
<IconButton label="Undo (Ctrl+Z)" onClick={undo} disabled={!past.length}>
<Undo2 className="size-4" aria-hidden />
</IconButton>
<IconButton label="Redo (Ctrl+Shift+Z)" onClick={redo} disabled={!future.length}>
<Redo2 className="size-4" aria-hidden />
</IconButton>
</div>
);
const nudgePad = selected && (
<div className="flex items-center gap-3 rounded-2xl border bg-card p-2.5">
<div className="grid grid-cols-3 gap-1" role="group" aria-label="Nudge selected layer">
<span />
<NudgeBtn label="Nudge up" onClick={() => nudge(selected.id, 0, bigStep ? -10 : -1)}>
<ArrowUp className="size-4" aria-hidden />
</NudgeBtn>
<span />
<NudgeBtn label="Nudge left" onClick={() => nudge(selected.id, bigStep ? -10 : -1, 0)}>
<ArrowLeft className="size-4" aria-hidden />
</NudgeBtn>
<NudgeBtn label="Nudge down" onClick={() => nudge(selected.id, 0, bigStep ? 10 : 1)}>
<ArrowDown className="size-4" aria-hidden />
</NudgeBtn>
<NudgeBtn label="Nudge right" onClick={() => nudge(selected.id, bigStep ? 10 : 1, 0)}>
<ArrowRight className="size-4" aria-hidden />
</NudgeBtn>
</div>
<div className="min-w-0 flex-1 space-y-2">
<p className="truncate text-[12.5px] font-medium">{selected.name ?? selected.kind}</p>
<Segmented label="Nudge step" pillId={`${pid}-step`} value={bigStep ? "10" : "1"} onChange={(v) => setBigStep(v === "10")} size="md" options={[{ value: "1", label: "±1" }, { value: "10", label: "±10" }]} />
<p className="text-[11px] tabular-nums text-muted-foreground">
x {Math.round(selected.x)} · y {Math.round(selected.y)}
</p>
</div>
</div>
);
return (
<MotionConfig reducedMotion="user">
<div
ref={rootRef}
className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased [--lm-accent:#7c3aed] dark:[--lm-accent:#a78bfa]", className)}
>
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3 sm:px-4">
<StudioMark />
<div className="hidden min-w-0 leading-tight sm:block">
<h1 className="truncate text-sm font-semibold tracking-tight">Label Studio</h1>
<p className="truncate text-[11px] text-muted-foreground">
{CONTAINERS[design.container].label} · {CONTAINERS[design.container].size}
</p>
</div>
<div className="ml-1 min-w-0 sm:ml-3">{designPicker}</div>
<div className="ml-auto flex items-center gap-1.5">
{undoRedo}
{desktop && (
<>
<span className="mx-1 hidden items-center gap-1 text-[11px] text-muted-foreground xl:flex">
<Kbd>⌘Z</Kbd> <Kbd>⌘D</Kbd> <Kbd>Del</Kbd> <Kbd>↑↓←→</Kbd>
</span>
{exportActions}
</>
)}
</div>
</header>
{desktop ? (
<div className="grid min-h-0 flex-1 grid-cols-[236px_minmax(0,1fr)_296px]">
<aside aria-label="Layers" className="min-h-0 border-r bg-muted/20">
{layersPanel}
</aside>
<main className="flex min-h-0 flex-col">
<section aria-label="Flat artwork" className="shrink-0 border-b bg-[radial-gradient(circle,var(--color-border)_1px,transparent_1px)] [background-size:14px_14px] px-6 py-3">
<div className="mb-1 flex items-center justify-between text-[11.5px] text-muted-foreground">
<span className="font-medium text-foreground">Flat artwork</span>
<span className="tabular-nums">
{ARTBOARD.w} × {ARTBOARD.h} · drag, double-click text to edit
</span>
</div>
<div className="mx-auto max-w-[430px]">{canvas}</div>
</section>
<section aria-label="Mockup" className="relative min-h-0 flex-1">
{stage}
<div className="absolute inset-x-3 bottom-3 flex flex-wrap items-center gap-3 rounded-2xl border bg-background/85 p-2 shadow-lg backdrop-blur-md">
{containerPicker}
<div className="min-w-[180px] flex-1">{turnControls}</div>
{backdropPicker}
</div>
</section>
</main>
<aside aria-label="Inspector" className="min-h-0 overflow-y-auto overscroll-contain border-l p-3">
{inspector}
</aside>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<section aria-label="Mockup" className="relative h-[40%] shrink-0">
{stage}
<div className="absolute inset-x-2 bottom-2 space-y-2 rounded-2xl border bg-background/85 p-2 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">{turnControls}</div>
</div>
</div>
</section>
<div className="shrink-0 border-y px-2 py-2">
<div role="tablist" aria-label="Tools" className="flex rounded-xl bg-muted p-1">
{(
[
["layers", "Layers", Layers],
["edit", "Edit", SlidersHorizontal],
["export", "Export", Upload],
] as const
).map(([t, label, I]) => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)}
className={cn("relative inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg text-[13px] font-medium", tab === t ? "text-foreground" : "text-muted-foreground", focusRing)}
>
{tab === t && <motion.span layoutId={`${pid}-tab`} className="absolute inset-0 rounded-lg bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<I className="relative size-4" aria-hidden />
<span className="relative">{label}</span>
</button>
))}
</div>
</div>
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{tab === "layers" && <div className="h-full">{layersPanel}</div>}
{tab === "edit" && (
<div className="space-y-3 p-3">
<div className="rounded-2xl border bg-muted/30 p-2">{canvas}</div>
<div className="overflow-x-auto">{containerPicker}</div>
{nudgePad}
{inspector}
</div>
)}
{tab === "export" && (
<div className="space-y-3 p-3">
<div className="space-y-2 rounded-2xl border bg-card p-3">
<p className="text-[12px] font-semibold">Container</p>
{containerPicker}
<p className="pt-1 text-[12px] font-semibold">Backdrop</p>
{backdropPicker}
</div>
<div className="space-y-2 rounded-2xl border bg-card p-3">
<p className="text-[12px] font-semibold">Export</p>
<p className="text-[12px] text-muted-foreground">
Flat artwork as a standalone SVG ({ARTBOARD.w} × {ARTBOARD.h} units, 100 mm wide), or the design as JSON.
</p>
<div className="flex flex-wrap gap-2 [&>button]:h-11 [&>button]:flex-1">{exportActions}</div>
</div>
</div>
)}
</div>
</div>
)}
{/* Pure artwork used for export */}
<div hidden aria-hidden>
<svg ref={exportRef} viewBox={`0 0 ${ARTBOARD.w} ${ARTBOARD.h}`} width={ARTBOARD.w} height={ARTBOARD.h}>
<Artwork design={design} pid={`${pid}x`} />
</svg>
</div>
<AnimatePresence>
{toast && (
<motion.div
key={toast.id}
role="status"
initial={{ opacity: 0, y: -10, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8 }}
className="pointer-events-none absolute left-1/2 top-16 z-50 flex max-w-[92%] -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full bg-foreground px-4 py-2 text-[12.5px] font-medium text-background shadow-xl"
>
<Check className="size-4 shrink-0" aria-hidden /> <span className="truncate">{toast.text}</span>
</motion.div>
)}
</AnimatePresence>
</div>
</MotionConfig>
);
}
function NudgeBtn({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {
return (
<button type="button" aria-label={label} onClick={onClick} className={cn("grid size-11 place-items-center rounded-xl border bg-background active:scale-95", focusRing)}>
{children}
</button>
);
}
function useTurn(turnMv: MotionValue<number>) {
const [turn, setTurn] = React.useState(() => turnMv.get());
useMotionValueEvent(turnMv, "change", (v) => setTurn(v));
return turn;
}
function LiveMockup({ turnMv, ...rest }: { turnMv: MotionValue<number>; design: Design; reduced: boolean; className?: string }) {
const turn = useTurn(turnMv);
return <Mockup3D turn={turn} {...rest} />;
}
function TurnControls({ turnMv, playing, disabled, onToggle, onScrub }: { turnMv: MotionValue<number>; playing: boolean; disabled: boolean; onToggle: () => void; onScrub: () => void }) {
const turn = useTurn(turnMv);
const deg = ((Math.round(turn) % 360) + 360) % 360;
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={onToggle}
aria-pressed={playing}
aria-label={playing ? "Pause turning" : "Play turning"}
disabled={disabled}
className={cn("grid size-9 shrink-0 place-items-center rounded-full bg-foreground text-background shadow-md disabled:opacity-40", focusRing)}
>
{playing ? <Pause className="size-4" aria-hidden /> : <Play className="ml-0.5 size-4" aria-hidden />}
</button>
<label className="flex min-w-0 flex-1 items-center gap-2">
<span className="sr-only">Turn</span>
<input
type="range"
min={0}
max={359}
step={1}
value={deg}
onChange={(e) => {
onScrub();
turnMv.set(Number(e.target.value));
}}
aria-valuetext={`${deg} degrees`}
className="h-2 w-full min-w-0 cursor-pointer appearance-none rounded-full bg-foreground/15 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-[var(--lm-accent)] [&::-webkit-slider-thumb]:shadow"
/>
<span className="w-9 shrink-0 text-right text-[11.5px] tabular-nums text-muted-foreground">{deg}°</span>
</label>
</div>
);
}