+ {months.map(({ year, month, label }) => {
+ const firstDay = new Date(year, month, 1);
+ const lastDay = new Date(year, month + 1, 0);
+ const startDow = firstDay.getDay(); // 0=Sun
+ const daysInMonth = lastDay.getDate();
+
+ const cells = [];
+ // Empty cells for offset
+ for (let i = 0; i < startDow; i++) {
+ cells.push(
);
+ }
+ for (let d = 1; d <= daysInMonth; d++) {
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
+ const info = dayMap[dateStr];
+ const isToday = dateStr === new Date().toISOString().slice(0, 10);
+ cells.push(
+
+ );
+}
+
+function RecordingRow({ recording, onClick }) {
+ const r = recording;
+ const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ const dateStr = r.startTime.toLocaleDateString([], { month: 'short', day: 'numeric' });
+ const dur = `${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m`;
+
+ return (
+
+ );
+}
+
+Object.assign(window, { Dashboard, CalendarHeatmap, RecordingRow });
diff --git a/clio-ui/components/DayView.jsx b/clio-ui/components/DayView.jsx
new file mode 100644
index 0000000..0ce3ca3
--- /dev/null
+++ b/clio-ui/components/DayView.jsx
@@ -0,0 +1,337 @@
+// DayView: calendar month grid + day timeline with audio density visualization
+const { useState: useStateDV, useEffect: useEffectDV, useRef: useRefDV } = React;
+
+function DayView({ data, selectedDay, setSelectedDay, setSelectedRecording, setView }) {
+ const now = new Date();
+ const [viewMonth, setViewMonth] = useStateDV(() => {
+ if (selectedDay) {
+ const d = new Date(selectedDay);
+ return { year: d.getFullYear(), month: d.getMonth() };
+ }
+ return { year: now.getFullYear(), month: now.getMonth() };
+ });
+
+ // Build day map
+ const dayMap = {};
+ for (const r of data.recordings) {
+ const key = r.startTime.toISOString().slice(0, 10);
+ if (!dayMap[key]) dayMap[key] = [];
+ dayMap[key].push(r);
+ }
+
+ const maxCount = Math.max(...Object.values(dayMap).map(d => d.length), 1);
+
+ // Calendar grid
+ const { year, month } = viewMonth;
+ const firstDay = new Date(year, month, 1);
+ const lastDay = new Date(year, month + 1, 0);
+ const startDow = firstDay.getDay();
+ const daysInMonth = lastDay.getDate();
+
+ const prevMonth = () => {
+ setViewMonth(m => {
+ const d = new Date(m.year, m.month - 1, 1);
+ return { year: d.getFullYear(), month: d.getMonth() };
+ });
+ };
+ const nextMonth = () => {
+ setViewMonth(m => {
+ const d = new Date(m.year, m.month + 1, 1);
+ return { year: d.getFullYear(), month: d.getMonth() };
+ });
+ };
+
+ const monthLabel = firstDay.toLocaleString('default', { month: 'long', year: 'numeric' });
+
+ const dayRecs = selectedDay ? (dayMap[selectedDay] || []).sort((a, b) => a.startTime - b.startTime) : [];
+
+ function fmtDay(dateStr) {
+ const d = new Date(dateStr + 'T12:00:00');
+ return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
+ }
+
+ return (
+
+ );
+}
+
+function DayTimeline({ recordings, onSelect }) {
+ // 24-hour timeline with recording bars
+ const HOUR_H = 52;
+ const LABEL_W = 44;
+ const BAR_W = 260;
+
+ const hours = Array.from({ length: 24 }, (_, i) => i);
+
+ function recTop(r) {
+ const h = r.startTime.getHours() + r.startTime.getMinutes() / 60;
+ return h * HOUR_H;
+ }
+ function recHeight(r) {
+ return Math.max((r.durationMinutes / 60) * HOUR_H, 8);
+ }
+
+ function qualityColor(q) {
+ const map = { excellent: 'var(--green)', good: 'var(--teal)', fair: 'var(--amber)', poor: 'var(--red)' };
+ return map[q] || 'var(--text3)';
+ }
+
+ const totalH = 24 * HOUR_H;
+
+ // Density mini-bars per hour (minutes of audio per hour)
+ const densityPerHour = Array(24).fill(0);
+ for (const r of recordings) {
+ const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
+ const endH = startH + r.durationMinutes / 60;
+ for (let h = 0; h < 24; h++) {
+ const overlapStart = Math.max(h, startH);
+ const overlapEnd = Math.min(h + 1, endH);
+ if (overlapEnd > overlapStart) {
+ densityPerHour[h] += (overlapEnd - overlapStart) * 60;
+ }
+ }
+ }
+
+ return (
+
+ {/* Timeline */}
+
+ {/* Hour lines + labels */}
+ {hours.map(h => (
+
+
+ {String(h).padStart(2, '0')}:00
+
+
+
+ ))}
+
+ {/* Recording bars */}
+ {recordings.map(r => {
+ const top = recTop(r);
+ const height = recHeight(r);
+ const srcColors = { portable: 'var(--amber)', stationary: 'var(--teal)', phone: 'var(--green)' };
+ const srcColor = srcColors[r.source] || 'var(--text3)';
+
+ return (
+
onSelect(r)}
+ title={`${r.source} · ${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m · ${r.quality}`}
+ style={{
+ position: 'absolute',
+ top: top + 1,
+ left: LABEL_W + 12,
+ width: BAR_W,
+ height: height - 2,
+ background: `${srcColor}22`,
+ border: `1px solid ${srcColor}66`,
+ borderLeft: `3px solid ${srcColor}`,
+ borderRadius: '0 4px 4px 0',
+ cursor: 'pointer',
+ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '4px 8px',
+ overflow: 'hidden',
+ transition: 'background 0.1s',
+ zIndex: 1,
+ }}
+ onMouseEnter={e => { e.currentTarget.style.background = `${srcColor}40`; }}
+ onMouseLeave={e => { e.currentTarget.style.background = `${srcColor}22`; }}
+ >
+ {r.source}
+
+ {Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m
+
+ {r.quality}
+ {r.highlights.length > 0 && (
+ ◈ {r.highlights.length}
+ )}
+
+ );
+ })}
+
+
+ {/* Density strip - clickable to go to recording at that hour */}
+
+
density
+
+ {densityPerHour.map((mins, h) => {
+ const pct = mins / 60;
+ // Find recording that covers this hour
+ const recAtHour = recordings.find(r => {
+ const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
+ const endH = startH + r.durationMinutes / 60;
+ return h >= startH && h < endH;
+ });
+ return (
+
recAtHour && onSelect(recAtHour)}
+ style={{
+ height: HOUR_H,
+ display: 'flex',
+ alignItems: 'stretch',
+ cursor: recAtHour ? 'pointer' : 'default',
+ }}
+ >
+
0 ? 2 : 0,
+ background: pct > 0.8 ? 'var(--amber)' : pct > 0.3 ? 'var(--amber-dim)' : 'var(--bg4)',
+ transition: 'width 0.2s, opacity 0.1s',
+ opacity: recAtHour ? 1 : 0.5,
+ }} />
+
+ );
+ })}
+
+
+
+ {/* Recording list for quick jump */}
+
+
Jump to
+ {recordings.map(r => {
+ const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ return (
+
onSelect(r)}
+ style={{
+ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 4,
+ padding: '8px 12px', cursor: 'pointer', transition: 'border-color 0.1s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--amber)'; }}
+ onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; }}
+ >
+
+ {timeStr}
+ {r.source}
+ {Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m
+
+
+ {r.quality}
+ {r.sampleRate / 1000}kHz · {r.bitDepth}bit · {r.fileSizeMB}MB
+ {r.hasTranscript && ✦ transcript}
+ {r.highlights.length > 0 && ◈ {r.highlights.length}}
+
+
+ );
+ })}
+
+
+ );
+}
+
+Object.assign(window, { DayView, DayTimeline });
diff --git a/clio-ui/components/HighlightsView.jsx b/clio-ui/components/HighlightsView.jsx
new file mode 100644
index 0000000..9bbf291
--- /dev/null
+++ b/clio-ui/components/HighlightsView.jsx
@@ -0,0 +1,171 @@
+// HighlightsView: all saved highlights across all recordings
+const { useState: useStateHV, useMemo: useMemoHV } = React;
+
+function HighlightsView({ data, highlights, setSelectedRecording, setView }) {
+ const [filterTag, setFilterTag] = useStateHV('');
+ const [sortBy, setSortBy] = useStateHV('recent');
+
+ const allTags = [...new Set(highlights.map(h => h.tag))].sort();
+
+ const sorted = useMemoHV(() => {
+ let hs = [...highlights];
+ if (filterTag) hs = hs.filter(h => h.tag === filterTag);
+ if (sortBy === 'recent') hs.sort((a, b) => b.createdAt - a.createdAt);
+ else if (sortBy === 'recording') hs.sort((a, b) => {
+ const ra = data.recordings.find(r => r.id === a.recordingId);
+ const rb = data.recordings.find(r => r.id === b.recordingId);
+ return (rb?.startTime || 0) - (ra?.startTime || 0);
+ });
+ else if (sortBy === 'tag') hs.sort((a, b) => a.tag.localeCompare(b.tag));
+ return hs;
+ }, [highlights, filterTag, sortBy, data]);
+
+ const TAG_COLORS = {
+ important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
+ 'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
+ };
+
+ function formatTime(secs) {
+ const h = Math.floor(secs / 3600);
+ const m = Math.floor((secs % 3600) / 60);
+ const s = Math.floor(secs % 60);
+ if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+ return `${m}:${String(s).padStart(2, '0')}`;
+ }
+
+ function openRecording(highlight) {
+ const rec = data.recordings.find(r => r.id === highlight.recordingId);
+ if (rec) {
+ // Pass the highlight's startSeconds to seek to that position
+ setSelectedRecording(rec, highlight.startSeconds);
+ setView('detail');
+ }
+ }
+
+ // Group by tag for summary
+ const tagCounts = {};
+ for (const h of highlights) tagCounts[h.tag] = (tagCounts[h.tag] || 0) + 1;
+
+ return (
+
+ {/* Left panel: tag breakdown */}
+
+
+ By Tag · {highlights.length} total
+
+
+ {Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).map(([tag, count]) => {
+ const color = TAG_COLORS[tag] || 'var(--text3)';
+ const isActive = filterTag === tag;
+ return (
+
+ );
+ })}
+
+
+ {/* Right: highlights list */}
+
+
+
+ {sorted.length} highlight{sorted.length !== 1 ? 's' : ''}
+ {filterTag ? ` · ${filterTag}` : ''}
+
+
+
Sort:
+ {['recent', 'recording', 'tag'].map(s => (
+
+ ))}
+
+
+
+ {sorted.length === 0 ? (
+
+ No highlights yet.
Open a recording and press ◈ Highlight to mark moments.
+
+ ) : (
+
+ {sorted.map(h => {
+ const rec = data.recordings.find(r => r.id === h.recordingId);
+ const color = TAG_COLORS[h.tag] || 'var(--text3)';
+ const seg = rec?.transcript?.find(s => s.id === h.segmentId);
+ return (
+
openRecording(h)}
+ style={{
+ background: 'var(--bg2)', border: `1px solid var(--border)`,
+ borderLeft: `3px solid ${color}`, borderRadius: '0 6px 6px 0',
+ padding: '12px 14px', cursor: 'pointer', transition: 'border-color 0.1s, background 0.1s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg3)'; e.currentTarget.style.borderColor = color; }}
+ onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg2)'; e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.borderLeftColor = color; }}
+ >
+
+ {h.tag}
+ {rec && (
+ <>
+
+ {rec.startTime.toLocaleDateString([], { month: 'short', day: 'numeric' })}
+
+ {rec.source}
+ >
+ )}
+
+ {formatTime(h.startSeconds)}
+
+
+ {seg && (
+
+ "{seg.text}"
+
+ )}
+ {h.note && (
+
+ {h.note}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
+
+Object.assign(window, { HighlightsView });
diff --git a/clio-ui/components/RecordingDetail.jsx b/clio-ui/components/RecordingDetail.jsx
new file mode 100644
index 0000000..74a55af
--- /dev/null
+++ b/clio-ui/components/RecordingDetail.jsx
@@ -0,0 +1,611 @@
+// RecordingDetail: audio player, transcript, highlights
+const { useState: useStateRD, useEffect: useEffectRD, useRef: useRefRD, useCallback: useCallbackRD } = React;
+
+const API_BASE = '';
+
+function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHighlights, initialSeekTime, onSeekHandled }) {
+ const [currentTime, setCurrentTime] = useStateRD(0); // seconds into recording
+ const [playing, setPlaying] = useStateRD(false);
+ const [speed, setSpeed] = useStateRD(1.0);
+ const [activeSegId, setActiveSegId] = useStateRD(null);
+ const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
+ const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
+ const [newHighlightNote, setNewHighlightNote] = useStateRD('');
+ const audioRef = useRefRD(null);
+ const transcriptRef = useRefRD(null);
+
+ const totalSeconds = r.durationMinutes * 60;
+ const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
+
+ // Audio URL from API
+ const audioUrl = `${API_BASE}/api/v1/recordings/${r.id}/audio`;
+
+ // Track recording ID to detect when recording changes
+ const lastRecordingId = useRefRD(null);
+ const hasAutoPlayed = useRefRD(false);
+
+ // Reset auto-play flag when recording changes
+ useEffectRD(() => {
+ if (lastRecordingId.current !== r.id) {
+ hasAutoPlayed.current = false;
+ lastRecordingId.current = r.id;
+ }
+ }, [r.id]);
+
+ // Auto-play when recording opens
+ useEffectRD(() => {
+ const audio = audioRef.current;
+ if (!audio || hasAutoPlayed.current) return;
+
+ const doStartPlayback = () => {
+ audio.play().catch(() => {}); // Ignore errors (e.g., browser autoplay policy)
+ hasAutoPlayed.current = true;
+ };
+
+ // If audio is ready, start immediately; otherwise wait for loadedmetadata
+ if (audio.readyState >= 1) {
+ doStartPlayback();
+ } else {
+ audio.addEventListener('loadedmetadata', doStartPlayback, { once: true });
+ return () => audio.removeEventListener('loadedmetadata', doStartPlayback);
+ }
+ }, [r.id]);
+
+ // Handle seeking to initial time (from search results, highlights, etc.)
+ useEffectRD(() => {
+ if (initialSeekTime == null) return;
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ const doSeek = () => {
+ audio.currentTime = initialSeekTime;
+ setCurrentTime(initialSeekTime);
+ if (onSeekHandled) onSeekHandled();
+ };
+
+ if (audio.readyState >= 1) {
+ doSeek();
+ } else {
+ audio.addEventListener('loadedmetadata', doSeek, { once: true });
+ return () => audio.removeEventListener('loadedmetadata', doSeek);
+ }
+ }, [initialSeekTime, onSeekHandled]);
+
+ // Sync playback state with audio element
+ useEffectRD(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
+ const handleEnded = () => setPlaying(false);
+ const handlePlay = () => setPlaying(true);
+ const handlePause = () => setPlaying(false);
+
+ audio.addEventListener('timeupdate', handleTimeUpdate);
+ audio.addEventListener('ended', handleEnded);
+ audio.addEventListener('play', handlePlay);
+ audio.addEventListener('pause', handlePause);
+
+ return () => {
+ audio.removeEventListener('timeupdate', handleTimeUpdate);
+ audio.removeEventListener('ended', handleEnded);
+ audio.removeEventListener('play', handlePlay);
+ audio.removeEventListener('pause', handlePause);
+ };
+ }, []);
+
+ // Sync playback rate with speed state
+ useEffectRD(() => {
+ if (audioRef.current) {
+ audioRef.current.playbackRate = speed;
+ }
+ }, [speed]);
+
+ // Track active transcript segment
+ useEffectRD(() => {
+ if (!r.transcript) return;
+ const active = r.transcript.find(s => currentTime >= s.startSeconds && currentTime <= s.endSeconds);
+ setActiveSegId(active ? active.id : null);
+ }, [currentTime, r.transcript]);
+
+ function formatTime(secs) {
+ const h = Math.floor(secs / 3600);
+ const m = Math.floor((secs % 3600) / 60);
+ const s = Math.floor(secs % 60);
+ if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+ return `${m}:${String(s).padStart(2, '0')}`;
+ }
+
+ function seek(e) {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const pct = (e.clientX - rect.left) / rect.width;
+ const newTime = pct * totalSeconds;
+ if (audioRef.current) {
+ audioRef.current.currentTime = newTime;
+ }
+ setCurrentTime(newTime);
+ }
+
+ function togglePlay() {
+ const audio = audioRef.current;
+ if (!audio) return;
+ if (playing) {
+ audio.pause();
+ } else {
+ audio.play();
+ }
+ }
+
+ function skipTime(delta) {
+ const audio = audioRef.current;
+ if (!audio) return;
+ const newTime = Math.max(0, Math.min(totalSeconds, audio.currentTime + delta));
+ audio.currentTime = newTime;
+ setCurrentTime(newTime);
+ }
+
+ function seekToTime(secs) {
+ const audio = audioRef.current;
+ if (audio) {
+ audio.currentTime = secs;
+ }
+ setCurrentTime(secs);
+ }
+
+ const [highlightStartTime, setHighlightStartTime] = useStateRD(null);
+ const [highlightEndTime, setHighlightEndTime] = useStateRD(null);
+
+ // When showing add highlight, set start time to current position
+ useEffectRD(() => {
+ if (showAddHighlight && highlightStartTime === null) {
+ setHighlightStartTime(Math.round(currentTime));
+ }
+ }, [showAddHighlight, currentTime, highlightStartTime]);
+
+ async function addHighlight() {
+ const startSecs = highlightStartTime ?? Math.round(currentTime);
+ const endSecs = highlightEndTime; // null for point highlight
+
+ try {
+ const res = await fetch(`${API_BASE}/api/v1/highlights`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ recording_id: r.id,
+ start_seconds: startSecs,
+ end_seconds: endSecs,
+ tag: newHighlightTag,
+ note: newHighlightNote || null,
+ }),
+ });
+
+ if (!res.ok) throw new Error('Failed to create highlight');
+
+ const data = await res.json();
+ const newH = {
+ id: `h-${data.id}`,
+ recordingId: r.id,
+ startSeconds: startSecs,
+ endSeconds: endSecs,
+ tag: newHighlightTag,
+ note: newHighlightNote,
+ createdAt: new Date(),
+ };
+ setAllHighlights(prev => [...prev, newH]);
+ } catch (err) {
+ console.error('Failed to create highlight:', err);
+ }
+
+ setShowAddHighlight(false);
+ setNewHighlightNote('');
+ setHighlightStartTime(null);
+ setHighlightEndTime(null);
+ }
+
+ async function removeHighlight(hid) {
+ // Extract numeric ID from "h-123" format
+ const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
+
+ try {
+ const res = await fetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
+ method: 'DELETE',
+ });
+ if (!res.ok && res.status !== 404) {
+ console.error('Failed to delete highlight');
+ }
+ } catch (err) {
+ console.error('Failed to delete highlight:', err);
+ }
+
+ setAllHighlights(prev => prev.filter(h => h.id !== hid));
+ }
+
+ const TAGS = ['important', 'todo', 'idea', 'follow-up', 'meeting', 'personal', 'work'];
+ const TAG_COLORS = {
+ important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
+ 'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
+ };
+
+ const progressPct = (currentTime / totalSeconds) * 100;
+
+ // Waveform: fake bars
+ const waveformBars = 120;
+ const waveHeights = useRefRD(null);
+ if (!waveHeights.current) {
+ const rand = (seed) => { let s = seed; return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; }; };
+ const rng = rand(r.id * 42);
+ waveHeights.current = Array.from({ length: waveformBars }, () => 0.1 + rng() * 0.9);
+ }
+
+ const qualityDetails = [
+ { label: 'Quality', value: r.quality, cls: `q-${r.quality}` },
+ { label: 'Sample Rate', value: `${r.sampleRate / 1000} kHz`, cls: '' },
+ { label: 'Bit Depth', value: `${r.bitDepth}-bit`, cls: '' },
+ { label: 'Peak', value: `${r.peakDB.toFixed(1)} dB`, cls: r.peakDB > -30 ? 'q-good' : r.peakDB > -45 ? 'q-fair' : 'q-poor' },
+ { label: 'Size', value: `${r.fileSizeMB} MB`, cls: '' },
+ { label: 'Source', value: r.source, cls: `source-${r.source}` },
+ ];
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {r.startTime.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
+ #{r.id}
+
+
+ {r.source}
+ {qualityDetails.map(q => (
+
+ {q.label}:
+ {q.value}
+
+ ))}
+
+
+
+
+
+ {/* Add highlight bar */}
+ {showAddHighlight && (
+
+ {/* Time range row */}
+
+ Start:
+
+ {formatTime(highlightStartTime ?? currentTime)}
+
+
+
+ End:
+
+ {highlightEndTime ? formatTime(highlightEndTime) : '—'}
+
+
+ {highlightEndTime && (
+
+ )}
+
+ {highlightEndTime && (
+
+ Duration: {formatTime(highlightEndTime - (highlightStartTime ?? currentTime))}
+
+ )}
+
+
+ {/* Tag and note row */}
+
+
+ {TAGS.map(t => (
+
+ ))}
+
+
setNewHighlightNote(e.target.value)}
+ placeholder="Note (optional)…"
+ onKeyDown={e => e.key === 'Enter' && addHighlight()}
+ style={{
+ flex: 1, background: 'var(--bg4)', border: '1px solid var(--border)',
+ borderRadius: 4, padding: '4px 10px', color: 'var(--text)',
+ fontFamily: 'var(--font-mono)', fontSize: 11,
+ outline: 'none',
+ }}
+ />
+
+
+
+
+ )}
+
+ {/* Main: player + transcript + highlights */}
+
+
+ {/* Left: Player + Transcript */}
+
+
+ {/* Player */}
+
+ {/* Waveform */}
+
+ {waveHeights.current.map((h, i) => {
+ const barPct = i / waveformBars;
+ const barTime = barPct * totalSeconds;
+ const isPast = barPct <= progressPct / 100;
+ const isActive = Math.abs(barPct - progressPct / 100) < 0.015;
+
+ // Check if bar is within any highlight range (or near a point highlight)
+ const inHighlight = recHighlights.some(hl => {
+ if (hl.endSeconds != null) {
+ // Range highlight - check if bar is within range
+ return barTime >= hl.startSeconds && barTime <= hl.endSeconds;
+ } else {
+ // Point highlight - check if near
+ return Math.abs(hl.startSeconds - barTime) < (totalSeconds / waveformBars);
+ }
+ });
+
+ // Check if bar is within the pending highlight being created
+ const inPendingHighlight = showAddHighlight && highlightStartTime != null && (
+ highlightEndTime != null
+ ? barTime >= highlightStartTime && barTime <= highlightEndTime
+ : Math.abs(highlightStartTime - barTime) < (totalSeconds / waveformBars)
+ );
+
+ return (
+
+ );
+ })}
+
+ {/* Highlight range markers when creating */}
+ {showAddHighlight && highlightStartTime != null && (
+
+ )}
+ {showAddHighlight && highlightEndTime != null && (
+
+ )}
+
+ {/* Playhead */}
+
+
+
+ {/* Hidden audio element */}
+
+
+ {/* Controls */}
+
+
+
+
+
+
+
+
+
+ {/* Speed */}
+
+ {[0.5, 1.0, 1.5, 2.0, 3.0].map(s => (
+
+ ))}
+
+
+
+ {formatTime(currentTime)} / {formatTime(totalSeconds)}
+
+
+
+
+ {/* Transcript */}
+
+ {/* Transcript header with scroll-to-current button */}
+
+
+ Transcript · {r.transcript?.length || 0} segments
+
+
+ {r.hasTranscript && r.transcript.length > 0 && (
+
+ )}
+
+
+ {!r.hasTranscript || r.transcript.length === 0 ? (
+
+ No transcript available. Run speech-to-text batch job to generate.
+
+ ) : (
+
+ {r.transcript.map(seg => {
+ const isActive = seg.id === activeSegId;
+ const hasHighlight = recHighlights.some(h => h.segmentId === seg.id);
+ return (
+
seekToTime(seg.startSeconds)}
+ style={{
+ display: 'flex', gap: 12, padding: '10px 12px', borderRadius: 5,
+ background: isActive ? 'var(--amber-glow)' : 'transparent',
+ border: `1px solid ${isActive ? 'var(--amber-dim)' : hasHighlight ? 'var(--amber-dim)' : 'transparent'}`,
+ cursor: 'pointer', transition: 'all 0.15s',
+ }}
+ onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg3)'; }}
+ onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
+ >
+
+
+ {formatTime(seg.startSeconds)}
+
+
+ {Math.round(seg.confidence * 100)}%
+
+
+
+ {seg.text}
+
+ {hasHighlight && (
+
◈
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Right: Highlights panel */}
+
+
+
+ Highlights · {recHighlights.length}
+
+
+
+ {recHighlights.length === 0 ? (
+
+ No highlights yet.
Press ◈ Highlight to mark a moment.
+
+ ) : (
+
+ {recHighlights
+ .sort((a, b) => a.startSeconds - b.startSeconds)
+ .map(h => (
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+const TAG_COLORS = {
+ important: 'var(--red)', todo: 'var(--amber)', idea: 'var(--teal)',
+ 'follow-up': 'var(--amber-dim)', meeting: 'var(--green)', personal: 'var(--text2)', work: 'var(--teal)',
+};
+
+function HighlightCard({ highlight: h, tagColors, onSeek, onRemove, formatTime }) {
+ const color = tagColors[h.tag] || 'var(--text3)';
+ const hasRange = h.endSeconds != null;
+ return (
+
+
+
+
+ {hasRange && (
+
+ ({formatTime(h.endSeconds - h.startSeconds)})
+
+ )}
+ {h.tag}
+
+
+
+ {h.note && (
+
{h.note}
+ )}
+
+ );
+}
+
+Object.assign(window, { RecordingDetail, HighlightCard, TAG_COLORS });
diff --git a/clio-ui/components/SearchView.jsx b/clio-ui/components/SearchView.jsx
new file mode 100644
index 0000000..a9dc01b
--- /dev/null
+++ b/clio-ui/components/SearchView.jsx
@@ -0,0 +1,212 @@
+// SearchView: full-text transcript search + filters
+const { useState: useStateSV, useMemo: useMemoSV } = React;
+
+function SearchView({ data, setSelectedRecording, setView }) {
+ const [query, setQuery] = useStateSV('');
+ const [filterSource, setFilterSource] = useStateSV([]);
+ const [filterQuality, setFilterQuality] = useStateSV([]);
+ const [filterHasTranscript, setFilterHasTranscript] = useStateSV(false);
+ const [filterHasHighlights, setFilterHasHighlights] = useStateSV(false);
+
+ const results = useMemoSV(() => {
+ if (!query.trim() && filterSource.length === 0 && filterQuality.length === 0 && !filterHasTranscript && !filterHasHighlights) return [];
+ const q = query.toLowerCase().trim();
+ const matches = [];
+
+ for (const r of data.recordings) {
+ if (filterSource.length > 0 && !filterSource.includes(r.source)) continue;
+ if (filterQuality.length > 0 && !filterQuality.includes(r.quality)) continue;
+ if (filterHasTranscript && !r.hasTranscript) continue;
+ if (filterHasHighlights && r.highlights.length === 0) continue;
+
+ if (!q) {
+ matches.push({ recording: r, matchingSegments: [] });
+ continue;
+ }
+
+ const matchingSegments = r.transcript.filter(s => s.text.toLowerCase().includes(q));
+ if (matchingSegments.length > 0) {
+ matches.push({ recording: r, matchingSegments });
+ }
+ }
+
+ return matches.sort((a, b) => b.matchingSegments.length - a.matchingSegments.length || b.recording.startTime - a.recording.startTime);
+ }, [query, filterSource, filterQuality, filterHasTranscript, filterHasHighlights, data]);
+
+ function toggleArr(arr, setArr, val) {
+ setArr(prev => prev.includes(val) ? prev.filter(x => x !== val) : [...prev, val]);
+ }
+
+ function formatTime(secs) {
+ const h = Math.floor(secs / 3600);
+ const m = Math.floor((secs % 3600) / 60);
+ const s = Math.floor(secs % 60);
+ if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+ return `${m}:${String(s).padStart(2, '0')}`;
+ }
+
+ function highlightText(text, query) {
+ if (!query.trim()) return text;
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
+ if (idx === -1) return text;
+ return (
+
+ {text.slice(0, idx)}
+
+ {text.slice(idx, idx + query.length)}
+
+ {text.slice(idx + query.length)}
+
+ );
+ }
+
+ const SOURCES = ['portable', 'stationary', 'phone'];
+ const QUALITIES = ['excellent', 'good', 'fair', 'poor'];
+
+ const hasFilters = query.trim() || filterSource.length > 0 || filterQuality.length > 0 || filterHasTranscript || filterHasHighlights;
+
+ return (
+
+ {/* Search bar */}
+
+
+ ⌕
+ setQuery(e.target.value)}
+ placeholder="Search transcripts…"
+ autoFocus
+ style={{
+ width: '100%', background: 'var(--bg3)', border: '1px solid var(--border)',
+ borderRadius: 6, padding: '10px 14px 10px 36px',
+ color: 'var(--text)', fontFamily: 'var(--font-sans)', fontSize: 15,
+ outline: 'none', transition: 'border-color 0.15s',
+ }}
+ onFocus={e => e.target.style.borderColor = 'var(--amber)'}
+ onBlur={e => e.target.style.borderColor = 'var(--border)'}
+ />
+ {query && (
+
+ )}
+
+
+ {/* Filters */}
+
+
Filter:
+ {SOURCES.map(s => (
+
toggleArr(filterSource, setFilterSource, s)} />
+ ))}
+
+ {QUALITIES.map(q => (
+ toggleArr(filterQuality, setFilterQuality, q)} />
+ ))}
+
+ setFilterHasTranscript(v => !v)} />
+ setFilterHasHighlights(v => !v)} />
+
+
+
+ {/* Results */}
+
+ {!hasFilters ? (
+
+
⌕
+
Search transcripts or apply filters
+
+ ) : results.length === 0 ? (
+
No results found.
+ ) : (
+ <>
+
+ {results.length} recording{results.length !== 1 ? 's' : ''} · {results.reduce((a, r) => a + r.matchingSegments.length, 0)} segment matches
+
+
+ {results.map(({ recording, matchingSegments }) => (
+ { setSelectedRecording(recording, seekTime); setView('detail'); }}
+ />
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+function FilterChip({ label, active, colorClass, onClick }) {
+ return (
+
+ );
+}
+
+function SearchResultCard({ recording: r, matchingSegments, query, highlightText, formatTime, onOpen }) {
+ const dur = `${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m`;
+ const dateStr = r.startTime.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
+ const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+
+ // First matching segment time, or null to start at beginning
+ const firstMatchTime = matchingSegments.length > 0 ? matchingSegments[0].startSeconds : null;
+
+ return (
+
+ {/* Card header - opens at first match */}
+
onOpen(firstMatchTime)}
+ style={{ padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', borderBottom: matchingSegments.length > 0 ? '1px solid var(--border)' : 'none' }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--bg3)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
+ >
+
{dateStr} {timeStr}
+
{r.source}
+
{dur}
+
{r.quality}
+ {r.highlights.length > 0 && (
+
◈ {r.highlights.length}
+ )}
+
+ {matchingSegments.length > 0 && (
+
{matchingSegments.length} match{matchingSegments.length !== 1 ? 'es' : ''}
+ )}
+
→
+
+
+ {/* Matching segments - each opens at its specific time */}
+ {matchingSegments.slice(0, 3).map(seg => (
+
onOpen(seg.startSeconds)} style={{ padding: '8px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer', display: 'flex', gap: 10 }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--bg3)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
+ >
+ {formatTime(seg.startSeconds)}
+
+ {highlightText(seg.text, query)}
+
+
+ ))}
+ {matchingSegments.length > 3 && (
+
onOpen(firstMatchTime)} style={{ padding: '6px 14px', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}
+ onMouseEnter={e => e.currentTarget.style.color = 'var(--amber)'}
+ onMouseLeave={e => e.currentTarget.style.color = 'var(--text3)'}
+ >+{matchingSegments.length - 3} more →
+ )}
+
+ );
+}
+
+Object.assign(window, { SearchView, FilterChip, SearchResultCard });
diff --git a/clio-ui/components/Sidebar.jsx b/clio-ui/components/Sidebar.jsx
new file mode 100644
index 0000000..2fd1dd2
--- /dev/null
+++ b/clio-ui/components/Sidebar.jsx
@@ -0,0 +1,99 @@
+// Sidebar navigation component
+const NAV_ITEMS = [
+ { id: 'dashboard', label: 'Dashboard', icon: '▦' },
+ { id: 'calendar', label: 'Calendar', icon: '◫' },
+ { id: 'search', label: 'Search', icon: '⌕' },
+ { id: 'highlights', label: 'Highlights', icon: '◈' },
+];
+
+function Sidebar({ view, setView, data }) {
+ const totalGB = (data.recordings.reduce((a, r) => a + r.fileSizeMB, 0) / 1024).toFixed(1);
+ const totalCount = data.recordings.length;
+
+ return (
+
+ {/* Logo */}
+
+
RecordingsViewer
+
+ {totalCount} files
+ {totalGB} GB
+
+
+
+ {/* Nav */}
+
+
+ {/* Device status */}
+
+
+ );
+}
+
+function DeviceStatus({ devices }) {
+ const now = new Date();
+
+ function hoursSince(date) {
+ return (now - date) / (1000 * 60 * 60);
+ }
+
+ function fmtSince(date) {
+ const h = hoursSince(date);
+ if (h < 1) return `${Math.round(h * 60)}m ago`;
+ if (h < 24) return `${Math.round(h)}h ago`;
+ return `${Math.round(h / 24)}d ago`;
+ }
+
+ return (
+
+
Devices
+ {Object.entries(devices).map(([key, dev]) => {
+ const hours = hoursSince(dev.lastSeen);
+ const bad = hours > 24;
+ const color = bad ? 'var(--red)' : 'var(--green)';
+ const dotColor = bad ? 'var(--red)' : 'var(--green)';
+ return (
+
+
+
+
+ {key}
+
+
{fmtSince(dev.lastSeen)}
+
+
{dev.model}
+
+ );
+ })}
+
+ );
+}
+
+Object.assign(window, { Sidebar, DeviceStatus });
diff --git a/clio-ui/components/Tweaks.jsx b/clio-ui/components/Tweaks.jsx
new file mode 100644
index 0000000..9fd5973
--- /dev/null
+++ b/clio-ui/components/Tweaks.jsx
@@ -0,0 +1,52 @@
+// Tweaks panel for RecordingsViewer
+// Loaded after tweaks-panel.jsx
+
+function TweaksApp() {
+ const { TweaksPanel, TweakSection, TweakToggle, TweakRadio, TweakColor, useTweaks } = window;
+
+ const [tweaks, setTweak] = useTweaks(/*EDITMODE-BEGIN*/{
+ "accentColor": "oklch(0.75 0.15 65)",
+ "density": "normal",
+ "monoTimestamps": true,
+ "showQualityColors": true,
+ "sidebarCollapsed": false
+ }/*EDITMODE-END*/);
+
+ // Apply tweaks to CSS vars live
+ React.useEffect(() => {
+ const root = document.documentElement;
+ // Accent color
+ root.style.setProperty('--amber', tweaks.accentColor);
+ // Density
+ const pad = tweaks.density === 'compact' ? '6px 10px' : tweaks.density === 'comfortable' ? '14px 18px' : '9px 12px';
+ root.style.setProperty('--row-padding', pad);
+ // Monospace timestamps
+ document.querySelectorAll('.mono-ts').forEach(el => {
+ el.style.fontFamily = tweaks.monoTimestamps ? 'var(--font-mono)' : 'var(--font-sans)';
+ });
+ }, [tweaks]);
+
+ return (
+
+
+ setTweak('accentColor', v)} />
+ setTweak('density', v)}
+ />
+
+
+ setTweak('monoTimestamps', v)} />
+ setTweak('showQualityColors', v)} />
+
+
+ );
+}
+
+// Mount tweaks panel in a separate div
+const tweaksRoot = document.createElement('div');
+tweaksRoot.id = 'tweaks-root';
+document.body.appendChild(tweaksRoot);
+ReactDOM.createRoot(tweaksRoot).render(
);
diff --git a/clio-ui/tweaks-panel.jsx b/clio-ui/tweaks-panel.jsx
new file mode 100644
index 0000000..5f8f95a
--- /dev/null
+++ b/clio-ui/tweaks-panel.jsx
@@ -0,0 +1,425 @@
+
+// tweaks-panel.jsx
+// Reusable Tweaks shell + form-control helpers.
+//
+// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
+// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
+// individual prototypes don't re-roll it. Ships a consistent set of controls so you
+// don't hand-draw
, segmented radios, steppers, etc.
+//
+// Usage (in an HTML file that loads React + Babel):
+//
+// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+// "primaryColor": "#D97757",
+// "fontSize": 16,
+// "density": "regular",
+// "dark": false
+// }/*EDITMODE-END*/;
+//
+// function App() {
+// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+// return (
+//
+// Hello
+//
+//
+// setTweak('fontSize', v)} />
+// setTweak('density', v)} />
+//
+// setTweak('primaryColor', v)} />
+// setTweak('dark', v)} />
+//
+//
+// );
+// }
+//
+// ─────────────────────────────────────────────────────────────────────────────
+
+const __TWEAKS_STYLE = `
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
+ background:rgba(250,249,247,.78);color:#29261b;
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
+ overflow-y:auto;overflow-x:hidden;min-height:0;
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
+ .twk-body::-webkit-scrollbar{width:8px}
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
+ border:2px solid transparent;background-clip:content-box}
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
+ border:2px solid transparent;background-clip:content-box}
+ .twk-row{display:flex;flex-direction:column;gap:5px}
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
+ color:rgba(41,38,27,.72)}
+ .twk-lbl>span:first-child{font-weight:500}
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
+
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
+ color:rgba(41,38,27,.45);padding:10px 0 0}
+ .twk-sect:first-child{padding-top:0}
+
+ .twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
+ select.twk-field{padding-right:22px;
+ background-image:url("data:image/svg+xml;utf8,
");
+ background-repeat:no-repeat;background-position:right 8px center}
+
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:14px;height:14px;border-radius:50%;background:#fff;
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
+ background:rgba(0,0,0,.06);user-select:none}
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
+ overflow-wrap:anywhere}
+
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
+ .twk-toggle[data-on="1"]{background:#34c759}
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
+
+ .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
+ user-select:none;padding-right:8px}
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
+ outline:none;color:inherit;-moz-appearance:textfield}
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
+ -webkit-appearance:none;margin:0}
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
+
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
+
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
+ background:transparent;flex-shrink:0}
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
+`;
+
+// ── useTweaks ───────────────────────────────────────────────────────────────
+// Single source of truth for tweak values. setTweak persists via the host
+// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
+function useTweaks(defaults) {
+ const [values, setValues] = React.useState(defaults);
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
+ // useState-style call doesn't write a "[object Object]" key into the persisted
+ // JSON block.
+ const setTweak = React.useCallback((keyOrEdits, val) => {
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
+ ? keyOrEdits : { [keyOrEdits]: val };
+ setValues((prev) => ({ ...prev, ...edits }));
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
+ }, []);
+ return [values, setTweak];
+}
+
+// ── TweaksPanel ─────────────────────────────────────────────────────────────
+// Floating shell. Registers the protocol listener BEFORE announcing
+// availability — if the announce ran first, the host's activate could land
+// before our handler exists and the toolbar toggle would silently no-op.
+// The close button posts __edit_mode_dismissed so the host's toolbar toggle
+// flips off in lockstep; the host echoes __deactivate_edit_mode back which
+// is what actually hides the panel.
+function TweaksPanel({ title = 'Tweaks', children }) {
+ const [open, setOpen] = React.useState(false);
+ const dragRef = React.useRef(null);
+ const offsetRef = React.useRef({ x: 16, y: 16 });
+ const PAD = 16;
+
+ const clampToViewport = React.useCallback(() => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const w = panel.offsetWidth, h = panel.offsetHeight;
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
+ offsetRef.current = {
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
+ };
+ panel.style.right = offsetRef.current.x + 'px';
+ panel.style.bottom = offsetRef.current.y + 'px';
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) return;
+ clampToViewport();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', clampToViewport);
+ return () => window.removeEventListener('resize', clampToViewport);
+ }
+ const ro = new ResizeObserver(clampToViewport);
+ ro.observe(document.documentElement);
+ return () => ro.disconnect();
+ }, [open, clampToViewport]);
+
+ React.useEffect(() => {
+ const onMsg = (e) => {
+ const t = e?.data?.type;
+ if (t === '__activate_edit_mode') setOpen(true);
+ else if (t === '__deactivate_edit_mode') setOpen(false);
+ };
+ window.addEventListener('message', onMsg);
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
+ return () => window.removeEventListener('message', onMsg);
+ }, []);
+
+ const dismiss = () => {
+ setOpen(false);
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
+ };
+
+ const onDragStart = (e) => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const r = panel.getBoundingClientRect();
+ const sx = e.clientX, sy = e.clientY;
+ const startRight = window.innerWidth - r.right;
+ const startBottom = window.innerHeight - r.bottom;
+ const move = (ev) => {
+ offsetRef.current = {
+ x: startRight - (ev.clientX - sx),
+ y: startBottom - (ev.clientY - sy),
+ };
+ clampToViewport();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ };
+
+ if (!open) return null;
+ return (
+ <>
+
+
+
+ {title}
+
+
+
{children}
+
+ >
+ );
+}
+
+// ── Layout helpers ──────────────────────────────────────────────────────────
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+
{label}
+ {children}
+ >
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+
+
+ {label}
+ {value != null && {value}}
+
+ {children}
+
+ );
+}
+
+// ── Controls ────────────────────────────────────────────────────────────────
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+
+ onChange(Number(e.target.value))} />
+
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+
+
{label}
+
+
+ );
+}
+
+function TweakRadio({ label, value, options, onChange }) {
+ const trackRef = React.useRef(null);
+ const [dragging, setDragging] = React.useState(false);
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
+ const n = opts.length;
+
+ // The active value is read by pointer-move handlers attached for the lifetime
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ const segAt = (clientX) => {
+ const r = trackRef.current.getBoundingClientRect();
+ const inner = r.width - 4;
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
+ };
+
+ const onPointerDown = (e) => {
+ setDragging(true);
+ const v0 = segAt(e.clientX);
+ if (v0 !== valueRef.current) onChange(v0);
+ const move = (ev) => {
+ if (!trackRef.current) return;
+ const v = segAt(ev.clientX);
+ if (v !== valueRef.current) onChange(v);
+ };
+ const up = () => {
+ setDragging(false);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+
+ return (
+
+
+
+ {opts.map((o) => (
+
+ ))}
+
+
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+
+
+
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+
+ onChange(e.target.value)} />
+
+ );
+}
+
+function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
+ const clamp = (n) => {
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+ };
+ const startRef = React.useRef({ x: 0, val: 0 });
+ const onScrubStart = (e) => {
+ e.preventDefault();
+ startRef.current = { x: e.clientX, val: value };
+ const decimals = (String(step).split('.')[1] || '').length;
+ const move = (ev) => {
+ const dx = ev.clientX - startRef.current.x;
+ const raw = startRef.current.val + dx * step;
+ const snapped = Math.round(raw / step) * step;
+ onChange(clamp(Number(snapped.toFixed(decimals))));
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+ return (
+
+ {label}
+ onChange(clamp(Number(e.target.value)))} />
+ {unit && {unit}}
+
+ );
+}
+
+function TweakColor({ label, value, onChange }) {
+ return (
+
+
{label}
+
onChange(e.target.value)} />
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});