diff --git a/services/web-ui/public/visuals.jsx b/services/web-ui/public/visuals.jsx
new file mode 100644
index 0000000..6f7c5c3
--- /dev/null
+++ b/services/web-ui/public/visuals.jsx
@@ -0,0 +1,237 @@
+// visuals.jsx - reusable visual elements: thumbnails, waveforms, sparklines, filmstrips
+
+const { thumbGrad } = window.ZAMPP_DATA;
+
+// AssetThumb — renders a placeholder thumbnail for an asset.
+// For video: gradient background + scan lines + framed window + tiny "preview" content
+// For audio: waveform
+function AssetThumb({ asset, size = "md" }) {
+ const aspect = size === "tall" ? "9 / 16" : "16 / 9";
+ const seed = asset.seed || 1;
+
+ if (asset.type === "audio") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+// FauxFrame - draws a stylized video frame using shapes/SVG
+function FauxFrame({ seed }) {
+ // pick a "shot" type by seed
+ const shots = ["stage", "track", "interview", "aerial", "crowd", "trophy"];
+ const shot = shots[seed % shots.length];
+
+ return (
+
+ );
+}
+
+// Waveform — pseudorandom but deterministic
+function Waveform({ seed = 1, color = "var(--accent)", className = "" }) {
+ const bars = 60;
+ const pts = React.useMemo(() => {
+ return Array.from({ length: bars }).map((_, i) => {
+ const x = i / bars;
+ const n = Math.sin(i * 0.7 + seed) * 0.5 + Math.sin(i * 2.1 + seed * 1.3) * 0.3 + Math.sin(i * 4.3 + seed * 0.7) * 0.2;
+ return Math.max(0.1, Math.min(1, 0.5 + n * 0.5));
+ });
+ }, [seed]);
+
+ return (
+
+ );
+}
+
+// Live scrolling thumbnail strip - shows recent frames coming in
+function LiveStrip({ seed = 1, count = 8 }) {
+ const [tick, setTick] = React.useState(0);
+ React.useEffect(() => {
+ const i = setInterval(() => setTick(t => t + 1), 2000);
+ return () => clearInterval(i);
+ }, []);
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+
+
+ ))}
+
+
+ NOW
+
+
+ );
+}
+
+// Sparkline
+function Sparkline({ data, color = "var(--accent)", height = 28, fill = true }) {
+ const max = Math.max(...data, 1);
+ const min = Math.min(...data, 0);
+ const range = max - min || 1;
+ const w = 100;
+ const pts = data.map((d, i) => {
+ const x = (i / (data.length - 1)) * w;
+ const y = height - ((d - min) / range) * height;
+ return `${x},${y}`;
+ }).join(" ");
+ const area = `0,${height} ${pts} ${w},${height}`;
+ return (
+
+ );
+}
+
+// Animated audio meter
+function AudioMeter({ level = 0.7, peak = 0.85, vertical = false }) {
+ const segs = 20;
+ return (
+
+ {Array.from({ length: segs }).map((_, i) => {
+ const v = i / segs;
+ const on = v < level;
+ const color = v < 0.6 ? "var(--success)" : v < 0.85 ? "var(--warning)" : "var(--danger)";
+ return
;
+ })}
+
+ );
+}
+
+// Status dot with pulse
+function StatusDot({ status }) {
+ const map = {
+ online: { color: "var(--success)", pulse: false },
+ recording: { color: "var(--live)", pulse: true },
+ armed: { color: "var(--accent)", pulse: false },
+ idle: { color: "var(--text-4)", pulse: false },
+ error: { color: "var(--danger)", pulse: true },
+ offline: { color: "var(--text-4)", pulse: false },
+ processing: { color: "var(--warning)", pulse: true },
+ ready: { color: "var(--success)", pulse: false },
+ live: { color: "var(--live)", pulse: true },
+ queued: { color: "var(--text-3)", pulse: false },
+ running: { color: "var(--accent)", pulse: true },
+ done: { color: "var(--success)", pulse: false },
+ failed: { color: "var(--danger)", pulse: false },
+ };
+ const s = map[status] || { color: "var(--text-3)" };
+ return (
+
+ );
+}
+
+// Live-style ticking clock for elapsed times
+function Elapsed({ seconds, live = false }) {
+ const [t, setT] = React.useState(seconds);
+ React.useEffect(() => {
+ if (!live) return;
+ const i = setInterval(() => setT(x => x + 1), 1000);
+ return () => clearInterval(i);
+ }, [live]);
+ const h = Math.floor(t / 3600);
+ const m = Math.floor((t % 3600) / 60);
+ const s = t % 60;
+ return {String(h).padStart(2, "0")}:{String(m).padStart(2, "0")}:{String(s).padStart(2, "0")};
+}
+
+Object.assign(window, { AssetThumb, FauxFrame, Waveform, LiveStrip, Sparkline, AudioMeter, StatusDot, Elapsed });