app
Adom Desktop - Puppeteer Bridge
Public Made by Adomby adom
The Adom Desktop Puppeteer (pup) bridge — drive a real headful browser on the user's desktop from the cloud: open/screenshot/eval/record. Cold-start drives the browser ALREADY on the machine (installed Chrome, else Microsoft Edge — every Windows PC ships with Edge) with a fresh isolated profile, so the common case needs NO download; Chrome for Testing is fetched only as a last resort. Two artifacts: the Releases zip is the bridge runtime (also bundled in Adom Desktop); the pkg installs the container-side Claude skills.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Adom Desktop 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 Desktop 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 Desktop Recorder" />
<span>Adom Desktop 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>