Files
ttvfx/components/set/ShotReport.tsx
T
twotalesanimation 4ac1817425
Deploy / deploy (push) Successful in 41s
Add /set/report: on-set VFX take logger and shareable report
Supervisors log scene/shot/take, per-camera settings, VFX tags,
references and photos on iPhone/iPad; works offline, stored on device
(localStorage + IndexedDB for photos). Exports CSV + photos and a
.json backup/restore.

/set/report/view renders a printable A4 report. "Send report link"
carries the day's log compressed in the URL fragment, so post gets
a formatted page with Save PDF and nothing is uploaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:33:26 +02:00

695 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import {
type Cam, type Rating, type Store, type Take,
CAM_FIELDS, STORE_KEY, blankTake, blobToDataUrl, clearPhotos, delPhoto, encodeReport, getPhoto,
clock, load, normalise, photoName, putPhoto, shrink, toCsv, today, uid,
} from "@/lib/report";
// On-set VFX take log. Built for one thumb between takes: everything carries over from
// the last take, "Save · next take" bumps the take number (and the clip name), so most
// takes are one tap plus whatever changed. At wrap: a report link for post (/set/report/view),
// CSV + photos, and a .json backup that carries everything (photos inline) to another device.
// Must match CACHE in public/set/sw.js
const OFFLINE_CACHE = "ttvfx-set-v1";
const REFS = ["Grey ball", "Chrome ball", "Colour chart", "Clean plate", "HDRI", "Witness cam", "Set photos", "Lidar / scan", "Measurements", "Markers"];
const TAGS = ["Green screen", "Blue screen", "Screen replace", "Clean-up", "Set extension", "CG character", "FX / sim", "Muzzle flash", "Stunt rig", "Driving", "Crowd"];
const RATINGS: [Rating, string][] = [
["circle", "◯ Circled"],
["good", "Good"],
["ng", "NG"],
];
// A003C007 -> A003C008: bump the last run of digits, keeping its width
function bumpClip(clip: string) {
return clip.replace(/(\d+)(?!.*\d)/, (d) => String(Number(d) + 1).padStart(d.length, "0"));
}
// Next take keeps the whole setup; only per-take things reset
function nextFrom(t: Take, newShot = false): Take {
return {
...t,
id: uid(),
at: 0,
shot: newShot ? "" : t.shot,
take: newShot ? 1 : t.take + 1,
cams: t.cams.map((c) => ({ ...c, clip: c.clip ? bumpClip(c.clip) : "" })),
refs: newShot ? [] : t.refs,
rating: "",
notes: "",
photos: [],
};
}
// iPhone/iPad: share sheet (AirDrop, Mail, Save to Files). Elsewhere: plain downloads.
async function shareFiles(files: File[]) {
if (navigator.canShare?.({ files })) {
try {
await navigator.share({ files, title: files[0].name });
return;
} catch (e) {
if ((e as Error).name === "AbortError") return;
}
}
for (const f of files) {
const a = document.createElement("a");
a.href = URL.createObjectURL(f);
a.download = f.name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
}
export default function ShotReport() {
const [store, setStore] = useState<Store | null>(null);
const [view, setView] = useState<"take" | "log" | "day">("take");
const [editing, setEditing] = useState(false); // draft is an existing take from the log
const [cam, setCam] = useState(0);
const [flash, setFlash] = useState("");
const [busy, setBusy] = useState("");
const [offline, setOffline] = useState(false);
const photoInput = useRef<HTMLInputElement>(null);
const restoreInput = useRef<HTMLInputElement>(null);
// Storage is read after mount so the static HTML matches on first paint
useEffect(() => {
setStore(load()); // eslint-disable-line react-hooks/set-state-in-effect
}, []);
useEffect(() => {
if (!store) return;
try {
localStorage.setItem(STORE_KEY, JSON.stringify(store));
} catch {}
}, [store]);
// Offline: same service worker as /set; save this page and its assets on first visit
useEffect(() => {
if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator) || !("caches" in window)) return;
let cancelled = false;
(async () => {
try {
await navigator.serviceWorker.register("/set/sw.js", { scope: "/set/" });
await navigator.serviceWorker.ready;
const assets = performance
.getEntriesByType("resource")
.map((e) => new URL(e.name))
.filter((u) => u.origin === location.origin && /^\/(_next\/|icon|apple-icon)/.test(u.pathname))
.map((u) => u.pathname + u.search);
const cache = await caches.open(OFFLINE_CACHE);
await cache.addAll(["/set/report/", "/set/report/view/", ...new Set(assets)]);
if (!cancelled) setOffline(true);
} catch {}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!flash) return;
const t = setTimeout(() => setFlash(""), 1600);
return () => clearTimeout(t);
}, [flash]);
const lenses = useMemo(() => {
const seen = new Set<string>();
for (const t of [...(store?.takes ?? [])].reverse()) for (const c of t.cams) if (c.lens) seen.add(c.lens);
return [...seen].slice(0, 8);
}, [store?.takes]);
if (!store) return <div className="min-h-dvh" />;
const d = store.draft;
const c = d.cams[Math.min(cam, d.cams.length - 1)];
const setDraft = (patch: Partial<Take>) => setStore({ ...store, draft: { ...d, ...patch } });
const setCamField = (k: keyof Cam, v: string) =>
setDraft({ cams: d.cams.map((x) => (x.id === c.id ? { ...x, [k]: v } : x)) });
const toggle = (k: "refs" | "tags", v: string) =>
setDraft({ [k]: d[k].includes(v) ? d[k].filter((x) => x !== v) : [...d[k], v] });
const save = (newShot = false) => {
if (!d.scene.trim() || !d.shot.trim()) {
setFlash("Scene and shot first");
return;
}
const saved = { ...d, at: d.at || Date.now() };
const exists = store.takes.some((t) => t.id === d.id);
const takes = exists ? store.takes.map((t) => (t.id === d.id ? saved : t)) : [...store.takes, saved];
// After editing an old take, carry on from the latest one rather than the edited one
const base = editing ? takes[takes.length - 1] : saved;
setStore({ ...store, takes, draft: nextFrom(base, newShot) });
setFlash(`Saved ${saved.scene}/${saved.shot} T${saved.take}`);
setEditing(false);
setCam(0);
window.scrollTo({ top: 0, behavior: "smooth" });
};
const edit = (t: Take) => {
setStore({ ...store, draft: structuredClone(t) });
setEditing(true);
setCam(0);
setView("take");
window.scrollTo({ top: 0 });
};
const cancelEdit = () => {
const last = store.takes[store.takes.length - 1];
setStore({ ...store, draft: last ? nextFrom(last) : blankTake() });
setEditing(false);
};
const remove = (t: Take) => {
if (!confirm(`Delete ${t.scene}/${t.shot} take ${t.take}?`)) return;
setStore({ ...store, takes: store.takes.filter((x) => x.id !== t.id) });
t.photos.forEach((id) => delPhoto(id).catch(() => {}));
};
const addPhotos = async (files: FileList) => {
setBusy("Saving photos…");
try {
const ids: string[] = [];
for (const f of Array.from(files)) {
const id = uid();
await putPhoto(id, await shrink(f));
ids.push(id);
}
setStore((s) => s && { ...s, draft: { ...s.draft, photos: [...s.draft.photos, ...ids] } });
} catch {
setFlash("Couldn't save that photo");
}
setBusy("");
};
const removePhoto = (id: string) => {
setDraft({ photos: d.photos.filter((x) => x !== id) });
// A logged take still uses it until the edit is saved
if (!store.takes.some((t) => t.photos.includes(id))) delPhoto(id).catch(() => {});
};
const fileBase = () => {
const m = store.meta;
return `VFX-report_${[m.production, m.day ? `day${m.day}` : "", today()].filter(Boolean).join("_").replace(/\s+/g, "-")}`;
};
// CSV plus every photo, named to match the CSV's Photos column
// The whole day rides in the link's #fragment: nothing is uploaded, the server never sees it
const shareLink = async () => {
const url = `${location.origin}/set/report/view/#${await encodeReport(store)}`;
const m = store.meta;
const title = `VFX report${m.production ? ` · ${m.production}` : ""}${m.day ? ` · Day ${m.day}` : ""}`;
if (navigator.share) {
try {
await navigator.share({ title, url });
return;
} catch (e) {
if ((e as Error).name === "AbortError") return;
}
}
try {
await navigator.clipboard.writeText(url);
setFlash("Link copied");
} catch {
window.open(url, "_blank");
}
};
const exportReport = async () => {
setBusy("Preparing…");
const files = [new File([toCsv(store)], `${fileBase()}.csv`, { type: "text/csv" })];
try {
for (const t of store.takes)
for (const [i, id] of t.photos.entries()) {
const b = await getPhoto(id);
if (b) files.push(new File([b], photoName(t, i), { type: "image/jpeg" }));
}
} catch {}
setBusy("");
await shareFiles(files);
};
// One self-contained file: the whole log with photos inline
const backup = async () => {
setBusy("Preparing…");
const photos: Record<string, string> = {};
try {
for (const t of [...store.takes, store.draft])
for (const id of t.photos) {
const b = await getPhoto(id);
if (b) photos[id] = await blobToDataUrl(b);
}
} catch {}
const json = JSON.stringify({ app: "ttvfx-report", version: 1, ...store, photos });
setBusy("");
await shareFiles([new File([json], `${fileBase()}.json`, { type: "application/json" })]);
};
const restore = async (f: File) => {
try {
const data = JSON.parse(await f.text());
if (data.app !== "ttvfx-report") throw new Error();
const n = data.takes?.length ?? 0;
if (store.takes.length && !confirm(`Replace the ${store.takes.length} takes on this device with the ${n} in this backup?`)) return;
setBusy("Restoring…");
await clearPhotos();
for (const [id, url] of Object.entries(data.photos ?? {}) as [string, string][])
await putPhoto(id, await (await fetch(url)).blob());
setStore(normalise(data));
setEditing(false);
setFlash(`Restored ${n} takes`);
} catch {
setFlash("That isn't a VFX report backup");
}
setBusy("");
};
const newDay = () => {
if (!confirm("Start a new day? This clears every logged take and photo on this device. Back up first.")) return;
setStore({ meta: { ...store.meta, day: store.meta.day ? String(Number(store.meta.day) + 1 || "") : "" }, takes: [], draft: blankTake() });
clearPhotos().catch(() => {});
setEditing(false);
setView("take");
};
return (
<div className="mx-auto max-w-2xl px-4 pb-40">
<header className="flex items-center gap-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2">
<Link href="/set/" className="flex min-h-11 items-center pr-2 text-sm text-muted">
← Markers
</Link>
<h1 className="flex-1 text-center font-display text-lg font-light tracking-wide uppercase">VFX report</h1>
<span className="w-20 text-right font-mono text-[10px] text-muted">{offline ? "✓ Offline" : ""}</span>
</header>
<nav className="sticky top-0 z-20 -mx-4 flex gap-1 bg-ink/95 px-4 pt-[env(safe-area-inset-top)] pb-3 backdrop-blur">
{([["take", editing ? "Editing" : "Take"], ["log", `Log · ${store.takes.length}`], ["day", "Day"]] as const).map(([v, l]) => (
<button
key={v}
onClick={() => setView(v)}
className={`min-h-11 flex-1 rounded-lg text-sm ${view === v ? "bg-fg text-ink" : "bg-white/5 text-muted"}`}
>
{l}
</button>
))}
</nav>
{view === "take" && (
<>
{/* Slate */}
<section className="grid grid-cols-[1fr_1fr_auto] gap-2">
<BigField label="Scene" value={d.scene} onChange={(v) => setDraft({ scene: v })} />
<BigField label="Shot" value={d.shot} onChange={(v) => setDraft({ shot: v })} />
<div className="rounded-xl bg-white/5 px-2 pt-2 pb-1">
<span className="block px-1 text-[11px] tracking-wider text-muted uppercase">Take</span>
<div className="flex items-center">
<StepBtn onClick={() => setDraft({ take: Math.max(1, d.take - 1) })}>−</StepBtn>
<input
value={d.take}
inputMode="numeric"
onChange={(e) => setDraft({ take: Math.max(1, Number(e.target.value.replace(/\D/g, "")) || 1) })}
className="w-12 bg-transparent text-center font-mono text-3xl outline-none"
aria-label="Take"
/>
<StepBtn onClick={() => setDraft({ take: d.take + 1 })}>+</StepBtn>
</div>
</div>
</section>
{/* Rating */}
<section className="mt-3 grid grid-cols-3 gap-2">
{RATINGS.map(([r, l]) => (
<button
key={r}
onClick={() => setDraft({ rating: d.rating === r ? "" : r })}
className={`min-h-12 rounded-xl text-sm font-medium ${
d.rating === r ? (r === "ng" ? "bg-white/25 text-fg line-through" : "bg-accent text-ink") : "bg-white/5 text-muted"
}`}
>
{l}
</button>
))}
</section>
{/* Cameras */}
<Heading>Camera</Heading>
<div className="flex gap-1">
{d.cams.map((x, i) => (
<button
key={x.id}
onClick={() => setCam(i)}
className={`min-h-11 min-w-12 rounded-lg font-mono ${x.id === c.id ? "bg-fg text-ink" : "bg-white/5 text-muted"}`}
>
{x.id}
</button>
))}
{d.cams.length < 6 && (
<button
onClick={() => {
const id = String.fromCharCode(65 + d.cams.length);
setDraft({ cams: [...d.cams, { ...c, id, roll: "", clip: "" }] });
setCam(d.cams.length);
}}
className="min-h-11 min-w-12 rounded-lg bg-white/5 text-muted"
aria-label="Add camera"
>
+
</button>
)}
{d.cams.length > 1 && (
<button
onClick={() => {
if (!confirm(`Remove camera ${c.id}?`)) return;
const cams = d.cams.filter((x) => x.id !== c.id).map((x, i) => ({ ...x, id: String.fromCharCode(65 + i) }));
setDraft({ cams });
setCam(0);
}}
className="ml-auto min-h-11 px-3 text-sm text-muted"
>
Remove {c.id}
</button>
)}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<Field label="Roll" value={c.roll} placeholder="A003" onChange={(v) => setCamField("roll", v.toUpperCase())} />
<Field label="Clip" value={c.clip} placeholder="A003C007" onChange={(v) => setCamField("clip", v.toUpperCase())} />
</div>
{lenses.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{lenses.map((l) => (
<Chip key={l} on={c.lens === l} onClick={() => setCamField("lens", l)}>
{l}mm
</Chip>
))}
</div>
)}
<div className="mt-2 grid grid-cols-3 gap-2 sm:grid-cols-5">
{CAM_FIELDS.map(([k, l, mode, ph]) => (
<Field key={k} label={l} value={c[k]} placeholder={ph} inputMode={mode} onChange={(v) => setCamField(k, v)} />
))}
</div>
<Heading>VFX</Heading>
<div className="flex flex-wrap gap-1.5">
{TAGS.map((t) => (
<Chip key={t} on={d.tags.includes(t)} onClick={() => toggle("tags", t)}>
{t}
</Chip>
))}
</div>
<Heading>References shot</Heading>
<div className="flex flex-wrap gap-1.5">
{REFS.map((t) => (
<Chip key={t} on={d.refs.includes(t)} onClick={() => toggle("refs", t)} check>
{t}
</Chip>
))}
</div>
<Heading>Photos</Heading>
<div className="flex flex-wrap gap-2">
{d.photos.map((id) => (
<Thumb key={id} id={id} onRemove={() => removePhoto(id)} />
))}
<button
onClick={() => photoInput.current?.click()}
className="flex h-20 w-20 flex-col items-center justify-center rounded-lg bg-white/5 text-xs text-muted"
>
<span className="text-2xl leading-none">+</span>
Photo
</button>
</div>
<input
ref={photoInput}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) addPhotos(e.target.files);
e.target.value = "";
}}
/>
<Heading>Notes</Heading>
<textarea
value={d.notes}
onChange={(e) => setDraft({ notes: e.target.value })}
rows={3}
placeholder="Boom in frame at end, actor crosses marker, wind on screen…"
className="w-full rounded-xl bg-white/5 px-3 py-3 text-base outline-none placeholder:text-white/20 focus:bg-white/10"
/>
{/* Save bar */}
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-ink/95 backdrop-blur">
<div className="mx-auto flex max-w-2xl gap-2 px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{editing ? (
<>
<button onClick={cancelEdit} className="min-h-14 flex-1 rounded-xl bg-white/5 text-muted">
Cancel
</button>
<button onClick={() => save()} className="min-h-14 flex-[2] rounded-xl bg-accent font-medium text-ink">
Update take
</button>
</>
) : (
<>
<button onClick={() => save(true)} className="min-h-14 flex-1 rounded-xl bg-white/10 text-sm leading-tight">
Save ·<br />new shot
</button>
<button onClick={() => save()} className="min-h-14 flex-[2] rounded-xl bg-accent font-medium text-ink">
Save · next take
</button>
</>
)}
</div>
</div>
</>
)}
{view === "log" && <Log takes={store.takes} onEdit={edit} onDelete={remove} onExport={exportReport} />}
{view === "day" && (
<section>
<div className="grid grid-cols-2 gap-2">
{(["production", "day", "unit", "supervisor"] as const).map((k) => (
<Field
key={k}
label={k === "day" ? "Shoot day" : k[0].toUpperCase() + k.slice(1)}
value={store.meta[k]}
inputMode={k === "day" ? "decimal" : "text"}
onChange={(v) => setStore({ ...store, meta: { ...store.meta, [k]: v } })}
/>
))}
</div>
<button
onClick={shareLink}
disabled={!store.takes.length}
className="mt-6 min-h-14 w-full rounded-xl bg-accent font-medium text-ink disabled:opacity-30"
>
Send report link to post
</button>
<Link
href="/set/report/view/"
className="mt-2 flex min-h-12 w-full items-center justify-center rounded-xl bg-white/10 text-sm"
>
Preview report · save PDF with photos
</Link>
<button
onClick={exportReport}
disabled={!store.takes.length}
className="mt-2 min-h-12 w-full rounded-xl bg-white/10 text-sm disabled:opacity-30"
>
Share CSV + photos
</button>
<div className="mt-2 grid grid-cols-2 gap-2">
<button onClick={backup} className="min-h-12 rounded-xl bg-white/10 text-sm">
Backup (.json)
</button>
<button onClick={() => restoreInput.current?.click()} className="min-h-12 rounded-xl bg-white/10 text-sm">
Restore backup
</button>
</div>
<input
ref={restoreInput}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) restore(f);
e.target.value = "";
}}
/>
<button onClick={newDay} className="mt-6 min-h-12 w-full rounded-xl bg-white/5 text-sm text-muted">
Start a new day (clears the log)
</button>
<p className="mt-6 text-xs leading-relaxed text-muted">
Everything saves on this device as you type and works without signal. Nothing is uploaded: the report
link carries the day&apos;s log inside it (photos are listed by name; send them with Share CSV + photos, or
send the PDF from Preview, which includes them). Keep a backup: the .json holds every take and photo, and
Restore loads it on another phone or iPad. Clearing Safari&apos;s website data erases the log.
</p>
</section>
)}
{busy && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 text-sm">{busy}</div>}
{flash && (
<div className="pointer-events-none fixed inset-x-0 bottom-28 z-40 flex justify-center px-4">
<span className="rounded-full bg-fg px-4 py-2 text-sm text-ink shadow-lg">{flash}</span>
</div>
)}
</div>
);
}
function Log({ takes, onEdit, onDelete, onExport }: { takes: Take[]; onEdit: (t: Take) => void; onDelete: (t: Take) => void; onExport: () => void }) {
if (!takes.length) return <p className="py-16 text-center text-sm text-muted">No takes logged yet.</p>;
// Group by scene/shot, newest setup first
const groups: { key: string; takes: Take[] }[] = [];
for (const t of takes) {
const key = `${t.scene} / ${t.shot}`;
const g = groups.find((x) => x.key === key);
if (g) g.takes.push(t);
else groups.push({ key, takes: [t] });
}
groups.reverse();
return (
<section>
<button onClick={onExport} className="mb-4 min-h-12 w-full rounded-xl bg-white/10 text-sm">
Share report · CSV + photos
</button>
{groups.map((g) => (
<div key={g.key} className="mb-5">
<h2 className="mb-1.5 font-mono text-sm text-fg">{g.key}</h2>
<ul className="divide-y divide-line overflow-hidden rounded-xl bg-white/[0.04]">
{g.takes.map((t) => (
<li key={t.id} className="flex items-stretch">
<button onClick={() => onEdit(t)} className="flex min-h-14 flex-1 items-center gap-3 px-3 py-2 text-left">
<span className={`w-10 font-mono text-lg ${t.rating === "ng" ? "text-muted line-through" : ""}`}>
{t.rating === "circle" ? <span className="rounded-full border border-accent px-1.5 text-accent">{t.take}</span> : `T${t.take}`}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">
{t.cams.map((c) => [c.id, c.lens && `${c.lens}mm`, c.stop && `T${c.stop}`, c.clip].filter(Boolean).join(" ")).join(" · ")}
</span>
<span className="block truncate text-xs text-muted">
{clock(t.at)}
{t.tags.length > 0 && ` · ${t.tags.join(", ")}`}
{t.photos.length > 0 && ` · ${t.photos.length} photo${t.photos.length > 1 ? "s" : ""}`}
{t.notes && ` · ${t.notes}`}
</span>
</span>
</button>
<button onClick={() => onDelete(t)} className="px-4 text-muted" aria-label={`Delete take ${t.take}`}>
×
</button>
</li>
))}
</ul>
</div>
))}
</section>
);
}
function Thumb({ id, onRemove }: { id: string; onRemove: () => void }) {
const [url, setUrl] = useState("");
useEffect(() => {
let u = "";
getPhoto(id)
.then((b) => {
if (b) setUrl((u = URL.createObjectURL(b)));
})
.catch(() => {});
return () => {
if (u) URL.revokeObjectURL(u);
};
}, [id]);
return (
<div className="relative h-20 w-20 overflow-hidden rounded-lg bg-white/5">
{/* eslint-disable-next-line @next/next/no-img-element */}
{url && <img src={url} alt="" className="h-full w-full object-cover" />}
<button
onClick={() => confirm("Remove this photo?") && onRemove()}
className="absolute top-0 right-0 flex h-8 w-8 items-center justify-center bg-black/60 text-sm"
aria-label="Remove photo"
>
×
</button>
</div>
);
}
function Heading({ children }: { children: React.ReactNode }) {
return <h2 className="mt-6 mb-2 text-[11px] tracking-wider text-muted uppercase">{children}</h2>;
}
function BigField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<label className="block min-w-0 rounded-xl bg-white/5 px-3 pt-2 pb-1 focus-within:bg-white/10">
<span className="block text-[11px] tracking-wider text-muted uppercase">{label}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value.toUpperCase())}
autoCapitalize="characters"
autoComplete="off"
autoCorrect="off"
className="w-full bg-transparent font-mono text-3xl outline-none"
/>
</label>
);
}
function Field({
label,
value,
onChange,
placeholder,
inputMode = "text",
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
inputMode?: "decimal" | "text";
}) {
return (
<label className="block min-w-0 rounded-lg bg-white/5 px-3 pt-1.5 pb-1 focus-within:bg-white/10">
<span className="block truncate text-[10px] tracking-wider text-muted uppercase">{label}</span>
{/* 16px text so iOS doesn't zoom in on focus */}
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
inputMode={inputMode}
autoComplete="off"
autoCorrect="off"
className="w-full bg-transparent font-mono text-base outline-none placeholder:text-white/15"
/>
</label>
);
}
function Chip({ on, onClick, check, children }: { on: boolean; onClick: () => void; check?: boolean; children: React.ReactNode }) {
return (
<button onClick={onClick} aria-pressed={on} className={`min-h-10 rounded-full px-3.5 text-sm ${on ? "bg-fg text-ink" : "bg-white/5 text-muted"}`}>
{check && on && "✓ "}
{children}
</button>
);
}
function StepBtn({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button onClick={onClick} className="flex h-11 w-9 items-center justify-center rounded-lg text-2xl text-muted active:bg-white/10">
{children}
</button>
);
}