app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
master
John Lauer
Close out wiki discussion 557 (startup hang after an ab cycle with orphaned Chrome windows): timeout-cap every hangable…
9b11632
3mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Adom Bridge Recorder</title>
<!--
Brand: Adom. Sources reconciled:
• gallia/skills/brand/SKILL.md — operational rules + canonical font URLs
• gallia/brand/BRAND-GUIDELINES.md — fonts, palette, weights
• gallia/viewer/adom-theme.js — token names/values, button + card patterns
Fonts: Familjen Grotesk + Satoshi from adom.inc (canonical Adom CDN per
SKILL.md), JetBrains Mono from Google Fonts (per SKILL.md font-sources
table). All adom.inc URLs verified to return 200.
Icons: monochrome white SVGs in icons/ directory per the SKILL.md rule
"all icons must be monochrome white". No emoji, no multi-color icons.
App icon: recorder-icon.svg — a red variant of favicon-color.svg, used
instead of the regular Adom teal favicon so the recorder window is
visually distinct from the main Adom Bridge app in the Windows taskbar.
-->
<!--
v1.8.131: crisp raster favicon. The recorder runs as a Chrome `--app`
window, whose Windows taskbar icon is derived from this favicon. An
SVG-only favicon (the previous `recorder-icon.svg` link) gets rasterized
by Chrome at a small fixed size and then upscaled by the taskbar → blurry.
A multi-size `.ico` (16→256, incl. a 256 entry) + a 256px PNG give the
taskbar a sharp bitmap at every DPI. `recorder-icon.svg` remains the
source of truth; `recorder-icon.ico` / `recorder-icon-256.png` are
generated from it (the red record-state variant) and ship in the bundle.
-->
<link rel="icon" type="image/x-icon" href="recorder-icon.ico">
<link rel="icon" type="image/png" sizes="256x256" href="recorder-icon-256.png">
<style>
@font-face {
font-family: 'Familjen Grotesk';
src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');
font-weight: 700; font-display: swap;
}
@font-face {
font-family: 'Familjen Grotesk';
src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');
font-weight: 500; font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');
font-weight: 400; font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');
font-weight: 500; font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2') format('woff2');
font-weight: 700; font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2') format('woff2');
font-weight: 400; font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwhsk.woff2') format('woff2');
font-weight: 500; font-display: swap;
}
</style>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
/* Semantic tokens follow gallia/viewer/adom-theme.js naming (--gv-*).
Same names = future-proof if recorder is later swapped to import
from adom-theme.js directly. */
:root {
--gv-bg: #0d1117;
--gv-bg-surface: #161b22;
--gv-bg-elevated: #1c2128;
--gv-bg-overlay: #21262d;
--gv-border: #30363d;
--gv-border-muted: #21262d;
--gv-border-accent: rgba(0, 184, 176, 0.3);
--gv-text: #e6edf3;
--gv-text-secondary: #8b949e;
--gv-text-muted: #484f58;
--gv-accent: #00b8b0;
--gv-accent-bright: #00e6dc;
--gv-accent-muted: rgba(0, 184, 176, 0.12);
--gv-accent-border: rgba(0, 184, 176, 0.25);
--gv-danger: #f85149; /* adom-theme.js semantic danger */
--gv-pin-power: #ef5350; /* used for the REC pulse dot per brand pin-power red */
--gv-radius-sm: 4px;
--gv-radius-md: 6px;
--gv-radius-lg: 8px;
/* Brand fonts. Per BRAND-GUIDELINES.md weights:
Familjen Grotesk: headlines (Bold 700) or sub-headlines (Medium 500)
Satoshi: body (Regular 400 or Medium 500)
JetBrains Mono: code/monospace (per SKILL.md font-sources table) */
--gv-font: 'Satoshi', system-ui, -apple-system, sans-serif;
--gv-font-display: 'Familjen Grotesk', 'Satoshi', system-ui, sans-serif;
--gv-font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', Consolas, monospace;
}
html, body {
min-height: 100%;
background: var(--gv-bg);
color: var(--gv-text);
font-family: var(--gv-font);
font-weight: 400; /* Satoshi Regular — body default per BRAND-GUIDELINES.md */
font-size: 13px;
user-select: none;
overflow: auto; /* graceful degradation on user-resize */
}
body {
padding: 14px 16px;
display: flex; flex-direction: column; gap: 12px;
min-height: 100vh;
}
/* min-height: 0 lets flex children shrink below their content size
(CSS default for flex items is min-height: auto). Without this, a
tall child like an oversized SVG forces the column to grow past
the viewport and pushes later siblings — Stop button, action row —
below the fold. */
.state.active {
flex: 1; display: flex; flex-direction: column; gap: 12px;
min-height: 0; min-width: 0;
}
/* Action controls + their wrappers must NEVER shrink — they're the
primary affordance and clipping them is the worst-case failure. */
.btn, .source-card, .rec-bar, .actions, .saved-info { flex-shrink: 0; }
.header {
display: flex; align-items: center; gap: 10px;
font-family: var(--gv-font-display); /* Familjen Grotesk Bold */
font-weight: 700; font-size: 14.5px;
letter-spacing: -0.012em; /* Familjen Grotesk reads tighter at headline weight */
}
/* `.logo` (NOT scoped to `.header`) so the constraint applies wherever
the logo appears. Earlier this was `.header .logo`, which meant the
same <img class="logo"> placed inside `.rec-bar` got no sizing rule
— SVGs with viewBox but no intrinsic px size then render at the
browser default (300×150 stretched to flex parent), dominating the
Recording state and pushing the Stop button below the fold. */
.logo {
width: 22px; height: 22px;
flex-shrink: 0;
border-radius: 5px;
box-shadow: 0 0 0 1px var(--gv-border);
}
.reason {
color: var(--gv-text-secondary); font-size: 11.5px;
line-height: 1.45; word-wrap: break-word;
}
.reason b { color: var(--gv-text); font-weight: 500; }
.divider { height: 1px; background: var(--gv-border); }
.helper { color: var(--gv-text-secondary); font-size: 11.5px; line-height: 1.45; }
.helper code {
font-family: var(--gv-font-mono); font-size: 11px;
color: var(--gv-text); background: var(--gv-bg-overlay);
padding: 1px 5px; border-radius: var(--gv-radius-sm);
}
.label-section {
font-family: var(--gv-font-display); font-size: 10px;
font-weight: 700; letter-spacing: 0.06em;
color: var(--gv-text-secondary); margin-top: 2px;
}
/* Source-cards follow the .card pattern from gallia/viewer/adom-theme.js
cardCSS(): bg-surface, border, radius-lg (8px). Hover lifts to elevated
+ accent border. */
.source-card {
background: var(--gv-bg-surface);
border: 1px solid var(--gv-border);
border-radius: var(--gv-radius-lg);
padding: 12px 14px;
display: flex; align-items: center; gap: 12px;
cursor: pointer;
transition: all 0.15s;
}
.source-card:hover {
border-color: var(--gv-accent);
background: var(--gv-bg-elevated);
}
/* Monochrome white SVG icons (per SKILL.md "all icons must be monochrome
white"). currentColor on the SVG path inherits this color. */
.source-card .icon {
width: 18px; height: 18px; flex-shrink: 0;
color: var(--gv-text-secondary);
transition: color 0.15s;
}
.source-card:hover .icon { color: var(--gv-text); }
.source-card .icon svg { width: 100%; height: 100%; display: block; }
.source-card .label { flex: 1; }
.source-card .label .name {
font-family: var(--gv-font-display); font-weight: 500; font-size: 13px;
}
.source-card .label .sub { color: var(--gv-text-secondary); font-size: 11px; margin-top: 1px; }
.badge {
font-family: var(--gv-font-display); font-size: 10px;
font-weight: 700; letter-spacing: 0.05em;
padding: 3px 7px; border-radius: var(--gv-radius-sm);
}
/* ACTIVE: brand teal accent (matches toolbarCSS button.active) */
.badge-active { background: var(--gv-accent-muted); color: var(--gv-accent-bright); }
.badge-off { background: var(--gv-bg-overlay); color: var(--gv-text-muted); }
/* Buttons follow gallia/viewer/adom-theme.js buttonCSS() ghost pattern:
transparent surface, secondary text, border. Hover bumps border + text.
transitions all 0.15s (per SKILL.md "always 0.15s ease"). */
.btn {
background: var(--gv-bg-surface); color: var(--gv-text-secondary);
border: 1px solid var(--gv-border);
border-radius: var(--gv-radius-md);
padding: 10px 12px; cursor: pointer;
font-family: var(--gv-font-display);
font-size: 13px; font-weight: 500;
width: 100%;
display: flex; align-items: center; justify-content: center; gap: 8px;
transition: all 0.15s;
}
.btn:hover {
background: var(--gv-bg-elevated);
border-color: var(--gv-text-secondary);
color: var(--gv-text);
}
/* Start/Stop = danger action (REC red). Same ghost pattern with
brand danger color tones. */
.btn-start, .btn-stop {
color: var(--gv-danger);
border-color: rgba(248, 81, 73, 0.35);
}
.btn-start:hover, .btn-stop:hover {
background: rgba(248, 81, 73, 0.10);
border-color: var(--gv-danger);
color: var(--gv-danger);
}
.btn .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.btn .square { width: 8px; height: 8px; background: currentColor; }
/* State 2 — Recording */
.rec-bar {
display: flex; align-items: center; gap: 10px;
font-family: var(--gv-font-display);
font-size: 14px; font-weight: 700;
}
.rec-bar .rec-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--gv-pin-power); /* brand pin-power red, slightly warmer */
animation: pulse 1.4s ease-in-out infinite;
box-shadow: 0 0 8px var(--gv-pin-power);
}
.rec-bar .duration {
font-family: var(--gv-font-mono); font-weight: 500; letter-spacing: 0.04em;
}
.rec-bar .size {
margin-left: auto; color: var(--gv-text-secondary);
font-family: var(--gv-font-mono); font-weight: 400; font-size: 11px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* State 3 — Saved */
.saved-header {
display: flex; align-items: center; gap: 8px;
color: var(--gv-accent-bright);
font-family: var(--gv-font-display);
font-weight: 700; font-size: 14px;
}
.saved-info { font-size: 11.5px; color: var(--gv-text-secondary); line-height: 1.5; }
.saved-info code {
font-family: var(--gv-font-mono); font-size: 11px; color: var(--gv-text);
}
.actions { display: flex; gap: 8px; }
.actions .btn { flex: 1; }
/* Hide-by-default */
.state { display: none; flex-direction: column; gap: 12px; }
.state.active { display: flex; }
</style>
</head>
<body>
<!-- ================ STATE 1 — READY ================ -->
<div id="state-ready" class="state active">
<div class="header">
<img class="logo" src="recorder-icon.svg" alt="Adom Bridge Recorder" />
<span>Adom Bridge Recorder</span>
</div>
<div class="reason"><b>Reason:</b> <span id="reason-ready">—</span></div>
<div class="divider"></div>
<div class="helper">Ready to record. Toggle sources below, or wait for Claude.</div>
<div class="label-section">SOURCES</div>
<div class="source-card">
<span class="icon">
<!-- Monitor icon (MDI 'monitor' equivalent), monochrome white per brand rule -->
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/></svg>
</span>
<div class="label">
<div class="name">Screen</div>
<div class="sub">Entire desktop</div>
</div>
<span class="badge badge-active">ACTIVE</span>
</div>
<div class="source-card">
<span class="icon">
<!-- Microphone icon (MDI 'microphone' equivalent), monochrome white per brand rule -->
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2c-1.66 0-3 1.34-3 3v6c0 1.66 1.34 3 3 3s3-1.34 3-3V5c0-1.66-1.34-3-3-3zm5.91 9c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/></svg>
</span>
<div class="label">
<div class="name">Microphone</div>
<div class="sub" id="mic-sub">Not enabled</div>
</div>
<span class="badge badge-off" id="mic-badge">OFF</span>
</div>
<div class="helper" id="clip-summary" style="display:none">Clips this session: <b id="clip-count">0</b><br>Last: <code id="last-clip">—</code></div>
<button class="btn btn-start" id="start-btn">
<span class="dot"></span>
<span>Start recording</span>
</button>
</div>
<!-- ================ STATE 2 — RECORDING ================ -->
<div id="state-recording" class="state">
<!-- No logo here: the OS title-bar favicon provides brand identity
in this compact state, and the REC pulse dot is the dominant
indicator. Adding the SVG logo here was the bug — it had no
applied size and dominated the layout. -->
<div class="rec-bar">
<span class="rec-dot"></span>
<span>REC</span>
<span class="duration" id="duration-el">0:00</span>
<span class="size" id="size-el">0 KB</span>
</div>
<div class="reason" id="reason-recording">—</div>
<div class="divider"></div>
<div class="helper">Recording entire desktop. Claude can call <code>desktop_record_stop</code>, or click:</div>
<button class="btn btn-stop" id="stop-btn">
<span class="square"></span>
<span>Stop recording</span>
</button>
</div>
<!-- ================ STATE 3 — SAVED ================ -->
<div id="state-saved" class="state">
<div class="saved-header">
<span>✓</span>
<span>Clip saved</span>
</div>
<div class="reason" id="reason-saved">—</div>
<div class="divider"></div>
<div class="saved-info">
<code id="saved-name">—</code><br>
<span id="saved-meta">—</span>
</div>
<div class="actions">
<button class="btn" id="open-folder-btn">Open folder</button>
<button class="btn" id="another-btn">Record another</button>
</div>
<div class="helper" id="auto-ready">Auto-ready in <b id="auto-ready-count">3</b>…</div>
</div>
<script>
// ─── State & DOM ──────────────────────────────────────────────────────
const states = {
ready: document.getElementById('state-ready'),
recording: document.getElementById('state-recording'),
saved: document.getElementById('state-saved'),
};
const ui = {
reasonReady: document.getElementById('reason-ready'),
reasonRecording: document.getElementById('reason-recording'),
reasonSaved: document.getElementById('reason-saved'),
micBadge: document.getElementById('mic-badge'),
micSub: document.getElementById('mic-sub'),
startBtn: document.getElementById('start-btn'),
stopBtn: document.getElementById('stop-btn'),
durationEl: document.getElementById('duration-el'),
sizeEl: document.getElementById('size-el'),
clipSummary: document.getElementById('clip-summary'),
clipCount: document.getElementById('clip-count'),
lastClip: document.getElementById('last-clip'),
savedName: document.getElementById('saved-name'),
savedMeta: document.getElementById('saved-meta'),
openFolderBtn: document.getElementById('open-folder-btn'),
anotherBtn: document.getElementById('another-btn'),
autoReady: document.getElementById('auto-ready'),
autoReadyCount: document.getElementById('auto-ready-count'),
};
let mediaRecorder = null;
let stream = null;
let sentBytes = 0;
let startedAt = 0;
let durationTimer = null;
let autoReadyTimer = null;
let session = {
reason: '—',
audioRequested: false,
clipCount: 0,
lastClip: null,
};
function showState(name) {
for (const s of Object.values(states)) s.classList.remove('active');
states[name].classList.add('active');
}
function fmtDuration(ms) {
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function fmtSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024*1024) return `${(bytes/1024).toFixed(0)} KB`;
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
}
function updateReasonText(reason) {
session.reason = reason || '—';
ui.reasonReady.textContent = session.reason;
ui.reasonRecording.textContent = session.reason;
ui.reasonSaved.textContent = session.reason;
}
function updateMicUi(enabled) {
if (enabled) {
ui.micBadge.textContent = 'ON';
ui.micBadge.className = 'badge badge-active';
ui.micSub.textContent = 'Will be captured';
} else {
ui.micBadge.textContent = 'OFF';
ui.micBadge.className = 'badge badge-off';
ui.micSub.textContent = 'Not enabled';
}
}
function updateClipSummary() {
if (session.clipCount === 0) {
ui.clipSummary.style.display = 'none';
return;
}
ui.clipSummary.style.display = 'block';
ui.clipCount.textContent = String(session.clipCount);
ui.lastClip.textContent = session.lastClip || '—';
}
// ─── Public: bridge → page (called via page.evaluate) ──────────────────
window.setSessionReason = (reason) => updateReasonText(reason);
window.startRecording = async (opts = {}) => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
throw new Error('Already recording');
}
if (opts.reason) updateReasonText(opts.reason);
session.audioRequested = !!opts.audio;
updateMicUi(session.audioRequested);
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: opts.fps || 30, displaySurface: 'monitor' },
audio: opts.audio === true,
});
} catch (e) {
throw new Error('getDisplayMedia failed: ' + e.message);
}
let mimeType = 'video/webm;codecs=vp9';
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp8') ? 'video/webm;codecs=vp8' : 'video/webm';
}
mediaRecorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: opts.videoBitsPerSecond || 4_000_000,
});
sentBytes = 0;
startedAt = Date.now();
mediaRecorder.ondataavailable = async (e) => {
if (!e.data || !e.data.size) return;
const buf = await e.data.arrayBuffer();
let binary = '';
const bytes = new Uint8Array(buf);
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));
}
const b64 = btoa(binary);
try { await window._saveChunk(b64); } catch (err) { console.error('saveChunk:', err); }
sentBytes += buf.byteLength;
ui.sizeEl.textContent = fmtSize(sentBytes);
};
mediaRecorder.onstop = async () => {
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
if (durationTimer) { clearInterval(durationTimer); durationTimer = null; }
const durationMs = Date.now() - startedAt;
try {
await window._recordingStopped({ sentBytes, durationMs });
} catch (e) {
console.error('recordingStopped:', e);
}
mediaRecorder = null;
};
mediaRecorder.start(1000);
showState('recording');
durationTimer = setInterval(() => {
ui.durationEl.textContent = fmtDuration(Date.now() - startedAt);
}, 250);
// No dynamic window resize — window stays at its launch size for the
// life of the session. Each state's content fits within the same
// window comfortably; switching states only swaps which .state.active
// div is visible. Eliminates the resize-clipping bug where the Stop
// button got cut off when the window shrank to fit Recording state.
};
window.stopRecording = async () => {
if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
return new Promise((resolve) => {
mediaRecorder.addEventListener('stop', resolve, { once: true });
mediaRecorder.stop();
});
};
// Bridge → page: switch to Saved state with clip metadata
window.showSaved = (clip) => {
session.clipCount += 1;
if (clip && clip.filename) session.lastClip = clip.filename + ' · ' + (clip.sizeKBStr || '');
ui.savedName.textContent = clip && clip.filename ? clip.filename : '—';
ui.savedMeta.textContent = clip
? `${fmtSize(clip.sizeBytes || 0)} · ${fmtDuration(clip.durationMs || 0)}`
: '—';
showState('saved');
// Auto-transition back to Ready after 3s
if (autoReadyTimer) clearInterval(autoReadyTimer);
let countdown = 3;
ui.autoReadyCount.textContent = countdown;
autoReadyTimer = setInterval(() => {
countdown -= 1;
if (countdown <= 0) {
clearInterval(autoReadyTimer); autoReadyTimer = null;
goToReady();
} else {
ui.autoReadyCount.textContent = countdown;
}
}, 1000);
};
window.goToReady = goToReady;
function goToReady() {
if (autoReadyTimer) { clearInterval(autoReadyTimer); autoReadyTimer = null; }
updateClipSummary();
showState('ready');
// No dynamic window resize — see comment in startRecording above.
}
// ─── User → bridge: in-window button clicks ────────────────────────────
ui.startBtn.addEventListener('click', async () => {
// Default audio off; user can toggle by clicking the mic card (see below)
try {
await window.startRecording({ audio: session.audioRequested });
} catch (e) {
alert('Recording failed: ' + e.message);
}
});
ui.stopBtn.addEventListener('click', async () => {
await window.stopRecording();
});
ui.openFolderBtn.addEventListener('click', async () => {
try { await window._openRecordingsFolder(); } catch {}
});
ui.anotherBtn.addEventListener('click', () => goToReady());
// Mic toggle by clicking the source card
document.querySelectorAll('.source-card')[1].addEventListener('click', () => {
session.audioRequested = !session.audioRequested;
updateMicUi(session.audioRequested);
});
// Window-close → bridge can detect via beforeunload
window.addEventListener('beforeunload', () => {
try {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
window._recorderClosed && window._recorderClosed();
} catch {}
});
// Initialize from query string if launched with ?reason=...
const params = new URLSearchParams(location.search);
if (params.get('reason')) updateReasonText(params.get('reason'));
</script>
</body>
</html>