theseus/bundled-addons/screenshot/panel.html
Local Dev 7b8539fb8d feat(theseus/screenshot): 0.6.4 — Polaroid sounds, trash icon, centred cluster, filename footer + open-in-folder
Rolling every user report from the 0.6.3 rollout into one bundle:

Sounds — the Web-Audio synth palette matches the metaphor now:
- Screenshot: Polaroid shutter — sharp metallic tick + curtain-close click
  chained to a film-advance whir (band-passed noise sweeping 900→400 Hz).
- Copy: printer "chika-chika-chika" — three descending percussive noise
  bursts pinned by short sine ticks. Reads as a print-head sweep.
- Discard: paper crumple — three overlapping band-limited noise beds
  with per-sample random-amplitude crackle, descending centre freq. No
  more descending sine "boop".
- Save: soft "photo dispensing" hiss (Polaroid ejects) + a small click.
- Both the panel and editor share the design so nothing sounds different
  depending on which surface fired it.

UI polish:
- Discard button now carries a trash-can icon so it's obviously not the
  same as the close-sidebar X (they both used to be plain X's).
- Toolbar drawing tools centre themselves via a new .tool-cluster
  wrapper (flex:1 1 auto, justify-content:center); the Copy/Save actions
  stay right-anchored via margin-left:auto on their own tgroup. Fixes
  the maximized-sidebar case where the drawing groups all crowded the
  left with a big empty gap before Copy/Save on the right.
- Filename moves out of the topbar into a dedicated footer strip under
  the canvas board, alongside a new "Open in folder" button. The topbar
  is now flex-wrap:nowrap and holds only fixed-width window controls,
  so a long filename can never push discard / sound / max / close onto
  a second row (the filename ellipsises instead).
- "Open in folder" invokes a new "openFolder" addon message that calls
  Electron's shell.showItemInFolder() to open the OS file explorer with
  the specific scratch PNG highlighted (falls back to shell.openPath()
  on the scratch dir when no capture is named).

Version bump so the OTA update endpoint picks it up on the next tick.
2026-09-09 22:34:47 +02:00

547 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Screenshot</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--btn:#1c2432; --btn-h:#242e40; --err:#ff9081; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--btn:#f0f3fa; --btn-h:#e3e8f2; --acid:#088A66; --err:#c81f0f; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink);
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; }
header { display: flex; align-items: center;
padding: 10px 14px; border-bottom: 1px solid var(--line);
background: var(--panel); gap: 8px; }
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; min-width: 0; }
header .t .em { font-size: 15px; }
header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; flex: 1; text-align: right; }
/* Force the sound / max / close cluster to hug the right edge no matter
what else fills the header. justify-content on the parent kept nudging
them mid-row when the status text was empty. */
header #btn-sound { margin-left: auto; }
header .max {
border: 1px solid var(--line); border-radius: 6px; background: var(--btn);
color: var(--ink); cursor: pointer; padding: 4px 6px; font: inherit; line-height: 0;
}
header .max:hover { background: var(--btn-h); }
header .max svg { width: 14px; height: 14px; display: block; }
header .max.close:hover { border-color: rgba(255,91,91,.55); color: var(--err); }
main { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
.modes { display: flex; gap: 6px; }
button.mode {
flex: 1; min-width: 0;
padding: 10px 6px; border: 1px solid var(--line); border-radius: 8px;
background: var(--btn); color: var(--ink); cursor: pointer;
font: inherit; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 4px;
transition: background 120ms;
}
button.mode:hover:not(:disabled) { background: var(--btn-h); }
button.mode:disabled { opacity: .5; cursor: default; }
button.mode .em { font-size: 18px; line-height: 1; }
button.mode .lbl { font-size: 11.5px; }
.prev-wrap {
border: 1px dashed var(--line); border-radius: 8px;
background:
repeating-conic-gradient(rgba(255,255,255,.02) 0% 25%, transparent 0% 50%)
50%/16px 16px;
padding: 6px; min-height: 120px;
display: flex; align-items: center; justify-content: center;
color: var(--dim); font-size: 12px; text-align: center;
}
.prev-wrap img {
max-width: 100%; max-height: 320px; border-radius: 4px; display: block;
background: #fff;
}
.meta { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
.meta .host { color: var(--mut); }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.actions .row { display: flex; gap: 6px; flex: 1 1 100%; }
button.act {
flex: 1; padding: 9px 8px; border: 1px solid var(--line); border-radius: 8px;
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
line-height: 1;
}
button.act svg { width: 14px; height: 14px; display: block; }
button.act.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
button.act:hover:not(:disabled) { background: var(--btn-h); }
button.act.primary:hover:not(:disabled) { filter: brightness(1.05); background: var(--acid); }
button.act:disabled { opacity: .45; cursor: default; }
.confirm {
display: none; gap: 6px; align-items: center;
padding: 8px 10px; border: 1px solid rgba(255,91,91,.40); border-radius: 8px;
background: rgba(255,91,91,.06); font-size: 12px;
}
.confirm.on { display: flex; }
.confirm .msg { flex: 1; color: var(--mut); }
.confirm button {
padding: 5px 10px; border: 1px solid var(--line); border-radius: 6px;
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
}
.confirm button.yes { background: #ff5b5b; color: #101418; border-color: transparent; font-weight: 600; }
.confirm button:hover { background: var(--btn-h); }
.confirm button.yes:hover { filter: brightness(1.05); background: #ff5b5b; }
.status { color: var(--mut); font-size: 12px; min-height: 16px; }
.status.err { color: var(--err); }
.status.ok { color: var(--acid); }
.recent { display: none; }
.recent.on { display: block; }
.recent .lbl { color: var(--dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: .5px; margin: 4px 0 6px; display: flex; justify-content: space-between; }
.recent .strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.recent .tile {
flex: 0 0 auto; width: 78px; height: 52px; border-radius: 4px;
border: 1px solid var(--line); background: var(--btn);
display: flex; align-items: center; justify-content: center;
cursor: pointer; overflow: hidden; position: relative;
}
.recent .tile img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
.recent .tile:hover { border-color: var(--acid); }
.recent .tile .tdel {
position: absolute; top: 2px; right: 2px;
width: 18px; height: 18px; line-height: 16px; text-align: center;
background: rgba(11,14,20,.85); color: #fff; border-radius: 50%;
font-size: 14px; font-weight: 700; cursor: pointer;
opacity: 0; transition: opacity 120ms, background 120ms, color 120ms;
user-select: none;
}
.recent .tile:hover .tdel { opacity: 1; }
.recent .tile .tdel:hover { background: #ff5b5b; color: #101418; }
.recent .clear { background: none; border: 0; color: var(--dim); font: inherit; cursor: pointer; text-transform: none; letter-spacing: 0; }
.recent .clear:hover { color: var(--err); }
</style>
</head>
<body>
<header>
<div class="t"><span class="em">📸</span> <span>Screenshot</span></div>
<div class="m" id="hdr-status"></div>
<button class="max" id="btn-sound" title="Toggle sound" aria-label="Toggle sound"></button>
<button class="max" id="btn-max" title="Expand the sidebar to full window width" aria-label="Expand sidebar">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/>
</svg>
</button>
<button class="max close" id="btn-close" title="Close the sidebar" aria-label="Close sidebar">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M4 4l8 8M12 4l-8 8"/>
</svg>
</button>
</header>
<main>
<div class="modes">
<button class="mode" data-mode="visible" title="Capture the visible part of the current tab">
<span class="em">🖼️</span><span class="lbl">Visible</span>
</button>
<button class="mode" data-mode="full" title="Capture the entire scrollable page">
<span class="em">📄</span><span class="lbl">Full page</span>
</button>
<button class="mode" data-mode="region" title="Drag a rectangle on the page to select what to capture">
<span class="em">✂️</span><span class="lbl">Region</span>
</button>
</div>
<div class="prev-wrap" id="prev-wrap">
<span id="prev-empty">No capture yet. Pick a mode above.</span>
<img id="prev-img" alt="" hidden>
</div>
<div class="meta">
<span id="meta-size"></span>
<span class="host" id="meta-host"></span>
</div>
<div class="actions">
<div class="row">
<button class="act" id="btn-copy" disabled title="Copy the raw capture to the clipboard">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="9" height="10"/><path d="M3 12V3h9"/></svg>
<span>Copy</span>
</button>
<button class="act" id="btn-save" disabled title="Save the raw capture straight to Downloads">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
<span>Save</span>
</button>
</div>
<div class="row">
<button class="act" id="btn-discard" disabled>Discard</button>
<button class="act primary" id="btn-edit" disabled title="Open the annotate editor — arrow, rect, ellipse, pen, text, undo, save, copy">Edit</button>
</div>
</div>
<div class="status" id="status"></div>
<div class="recent" id="recent">
<div class="lbl"><span>Recent</span>
<button class="clear" id="clear-recent" title="Delete every past capture">clear all</button>
</div>
<div class="strip" id="recent-strip"></div>
<div class="confirm" id="clear-confirm">
<span class="msg">Delete every past capture?</span>
<button id="clear-no">Cancel</button>
<button class="yes" id="clear-yes">Delete</button>
</div>
</div>
<a id="download-link" style="display:none"></a>
</main>
<script>
const $ = (id) => document.getElementById(id);
const previewImg = $("prev-img");
const previewEmpty = $("prev-empty");
const status = $("status");
const metaSize = $("meta-size");
const metaHost = $("meta-host");
const btnEdit = $("btn-edit");
const btnDiscard = $("btn-discard");
const btnCopy = $("btn-copy");
const btnSave = $("btn-save");
const btnMax = $("btn-max");
const btnSound = $("btn-sound");
const dl = $("download-link");
// ---- audio ----------------------------------------------------------
// Synthesized in-page so the tarball stays small — no wav shipped.
// Preference is stored under silentmode.storage so the editor sees the
// same setting when it loads (default on).
let soundOn = true;
let audioCtx = null;
function actx() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch {}
}
// A user gesture is needed to resume in some Chromium setups; the
// toggle button click satisfies that.
if (audioCtx && audioCtx.state === "suspended") { audioCtx.resume().catch(() => {}); }
return audioCtx;
}
function tone(freq, dur, type, vol, at) {
if (!soundOn) return;
const c = actx(); if (!c) return;
const t0 = c.currentTime + (at || 0);
const osc = c.createOscillator();
const g = c.createGain();
osc.type = type || "sine";
osc.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(vol || 0.15, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g); g.connect(c.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
// Filtered noise burst — camera clicks, printer thwacks, and (with
// texture=true) the crackle of crumpled paper. `bp` = band-pass centre
// freq, `q` = sharpness of the band.
function noise(dur, vol, bp, q, at, texture) {
if (!soundOn) return;
const c = actx(); if (!c) return;
const t0 = c.currentTime + (at || 0);
const n = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, n, c.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) {
let s = Math.random() * 2 - 1;
if (texture) s *= 0.4 + Math.random() * 0.6;
d[i] = s;
}
const src = c.createBufferSource(); src.buffer = buf;
const flt = c.createBiquadFilter(); flt.type = "bandpass";
flt.frequency.setValueAtTime(bp || 2000, t0);
flt.Q.setValueAtTime(q || 4, t0);
const g = c.createGain();
g.gain.setValueAtTime(vol || 0.2, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(flt); flt.connect(g); g.connect(c.destination);
src.start(t0);
src.stop(t0 + dur + 0.02);
}
// Sweeping band-pass noise — used as the film-advance whir tail of the
// Polaroid shutter.
function sweep(dur, vol, fromHz, toHz, q, at) {
if (!soundOn) return;
const c = actx(); if (!c) return;
const t0 = c.currentTime + (at || 0);
const n = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, n, c.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
const src = c.createBufferSource(); src.buffer = buf;
const flt = c.createBiquadFilter(); flt.type = "bandpass";
flt.frequency.setValueAtTime(fromHz, t0);
flt.frequency.exponentialRampToValueAtTime(toHz, t0 + dur);
flt.Q.setValueAtTime(q || 8, t0);
const g = c.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + dur * 0.2);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(flt); flt.connect(g); g.connect(c.destination);
src.start(t0);
src.stop(t0 + dur + 0.02);
}
// Polaroid: mirror + curtain click, then a film-advance whir.
function playShutter() {
noise(0.02, 0.32, 5200, 8, 0);
tone(160, 0.03, "square", 0.14, 0.003);
noise(0.03, 0.24, 3200, 5, 0.03);
tone(120, 0.04, "square", 0.10, 0.035);
sweep(0.28, 0.10, 900, 400, 12, 0.07);
}
// Printer chika-chika-chika — 3 descending percussive bursts.
function playCopy() {
noise(0.028, 0.22, 3800, 9, 0.00);
tone(1200, 0.03, "sine", 0.10, 0.00);
noise(0.028, 0.22, 3200, 9, 0.06);
tone(1000, 0.03, "sine", 0.10, 0.06);
noise(0.028, 0.22, 2600, 9, 0.12);
tone(800, 0.03, "sine", 0.10, 0.12);
}
// Paper crumple — long band-limited noise with crackle texture and a
// descending centre frequency.
function playDiscard() {
noise(0.16, 0.28, 2600, 3, 0.00, true);
noise(0.12, 0.22, 1800, 3, 0.06, true);
noise(0.08, 0.16, 1200, 3, 0.12, true);
}
function paintSound() {
btnSound.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable";
btnSound.innerHTML = soundOn
? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5.5c1 .8 1 4.2 0 5"/><path d="M12.5 4c2 1.5 2 6.5 0 8"/></svg>'
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5l4 6M15 5l-4 6"/></svg>';
}
(async () => {
try {
const v = await window.silentmode.storage.get("soundOn", true);
soundOn = v !== false; // default on
} catch {}
paintSound();
})();
btnSound.addEventListener("click", async () => {
soundOn = !soundOn;
paintSound();
try { await window.silentmode.storage.set("soundOn", soundOn); } catch {}
if (soundOn) playShutter(); // audible confirmation
});
const hdrStatus = $("hdr-status");
const recentBox = $("recent");
const recentStrip = $("recent-strip");
const clearBtn = $("clear-recent");
let last = null; // { name, dataUrl, width, height, host, bytes, mode }
let busy = false;
const fmt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " MB"
: n >= 1e3 ? (n / 1e3).toFixed(0) + " KB"
: n + " B";
function setStatus(text, cls = "") {
status.textContent = text || "";
status.className = "status" + (cls ? " " + cls : "");
}
function renderPreview() {
const on = !!(last && last.dataUrl);
previewImg.hidden = !on;
previewImg.src = on ? last.dataUrl : "";
previewEmpty.hidden = on;
metaSize.textContent = on ? `${last.width}×${last.height} · ${fmt(last.bytes || 0)}` : "—";
metaHost.textContent = on ? (last.host || "") : "";
btnEdit.disabled = !on;
btnDiscard.disabled = !on;
btnCopy.disabled = !on;
btnSave.disabled = !on;
}
async function refreshRecent() {
try {
const list = await window.silentmode.invoke("listRecent", {});
if (!Array.isArray(list) || list.length === 0) {
recentBox.classList.remove("on");
recentStrip.innerHTML = "";
return;
}
recentBox.classList.add("on");
recentStrip.innerHTML = list.map((r) => {
const nameAttr = r.name.replace(/"/g, "&quot;");
return `<div class="tile" data-name="${nameAttr}" title="${nameAttr} · ${fmt(r.bytes || 0)}">`
+ `<span class="tdel" data-del="${nameAttr}" title="Delete this capture">×</span>`
+ `</div>`;
}).join("");
for (const tile of recentStrip.querySelectorAll(".tile")) {
const name = tile.dataset.name;
try {
const b = await window.silentmode.invoke("getBytes", { name });
if (b && b.dataUrl) {
// Keep the delete button; drop it back on top of the image.
const del = tile.querySelector(".tdel");
tile.innerHTML = `<img src="${b.dataUrl}" alt="">`;
if (del) tile.appendChild(del);
}
} catch {}
tile.addEventListener("click", async (ev) => {
// The per-tile X sits inside the tile; don't count its click as
// a tile click.
if (ev.target && ev.target.classList.contains("tdel")) return;
try {
const b = await window.silentmode.invoke("getBytes", { name });
if (!b) return;
last = { name: b.name, dataUrl: b.dataUrl, width: 0, height: 0, host: "", bytes: b.bytes };
const im = new Image();
im.onload = () => { last.width = im.naturalWidth; last.height = im.naturalHeight; renderPreview(); };
im.src = b.dataUrl;
renderPreview();
setStatus(`Loaded ${b.name}.`, "ok");
} catch (e) {
setStatus("Couldn't load: " + (e && e.message || e), "err");
}
});
}
// Per-tile delete — no confirmation strip (single-item destroy is
// small enough to be safe with just the X; the sweeping "clear all"
// still gates on the inline red confirmation).
for (const del of recentStrip.querySelectorAll(".tdel")) {
del.addEventListener("click", async (ev) => {
ev.stopPropagation();
const nm = del.dataset.del;
try {
await window.silentmode.invoke("clearRecent", { name: nm });
if (last && last.name === nm) { last = null; renderPreview(); }
await refreshRecent();
} catch (e) {
setStatus("Delete failed: " + (e && e.message || e), "err");
}
});
}
} catch (e) {
console.warn("refreshRecent failed:", e);
}
}
async function doCapture(mode) {
if (busy) return;
busy = true;
hdrStatus.textContent = "capturing…";
setStatus("");
if (mode === "region") setStatus("Drag on the page. Esc to cancel.");
else if (mode === "full") setStatus("Rendering full page — this can take a moment.");
for (const b of document.querySelectorAll("button.mode")) b.disabled = true;
try {
const res = await window.silentmode.invoke("capture", { mode });
if (res && res.cancelled) {
setStatus("Region capture cancelled.");
} else if (res && res.dataUrl) {
last = res;
renderPreview();
playShutter();
setStatus("Captured. Click Edit to annotate.", "ok");
refreshRecent();
} else {
setStatus("Nothing captured.", "err");
}
} catch (e) {
console.warn("capture failed:", e);
setStatus(String(e && e.message || e), "err");
} finally {
busy = false;
hdrStatus.textContent = "";
for (const b of document.querySelectorAll("button.mode")) b.disabled = false;
}
}
async function doEdit() {
if (!last || !last.name || busy) return;
// No IPC handshake — the editor pulls bytes on load via silentmode
// .invoke("getBytes", {name}) directly. That's idempotent, so a refresh
// or back-and-forth navigation always shows the image.
location.href = "editor.html?name=" + encodeURIComponent(last.name);
}
async function toggleMax() {
try { await window.silentmode.sidebar.toggleMax(); }
catch (e) { console.warn("toggleMax failed:", e); }
}
// Convert a base64 data URL into a Blob without a fetch round-trip.
function dataUrlToBlob(dataUrl) {
const m = /^data:([^;,]+);base64,(.+)$/.exec(String(dataUrl || ""));
if (!m) throw new Error("expected base64 data URL");
const bin = atob(m[2]);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return new Blob([buf], { type: m[1] });
}
async function doCopy() {
if (!last || !last.dataUrl || busy) return;
try {
const blob = dataUrlToBlob(last.dataUrl);
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
playCopy();
setStatus("Copied to clipboard.", "ok");
} catch (e) {
console.warn("copy failed:", e);
setStatus("Copy failed: " + (e && e.message || e), "err");
}
}
async function doSave() {
if (!last || !last.dataUrl || busy) return;
try {
const blob = dataUrlToBlob(last.dataUrl);
const url = URL.createObjectURL(blob);
dl.href = url;
dl.download = last.name || "screenshot.png";
dl.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
setStatus(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`, "ok");
} catch (e) {
console.warn("save failed:", e);
setStatus("Save failed: " + (e && e.message || e), "err");
}
}
for (const b of document.querySelectorAll("button.mode")) {
b.addEventListener("click", () => doCapture(b.dataset.mode));
}
btnEdit.addEventListener("click", doEdit);
btnDiscard.addEventListener("click", () => { playDiscard(); last = null; renderPreview(); setStatus(""); });
btnCopy.addEventListener("click", doCopy);
btnSave.addEventListener("click", doSave);
btnMax.addEventListener("click", toggleMax);
document.getElementById("btn-close").addEventListener("click", async () => {
// Explicit close from the sidebar itself — same IPC the toolbar dock
// icon toggles. No need to un-maximize first; setSidebar(false) on the
// main side flips visibility and the maximize state is remembered so
// the next open lands where it left off.
try { await window.silentmode.sidebar.close(); }
catch (e) { setStatus("Close failed: " + (e && e.message || e), "err"); }
});
// Inline confirmation for "clear all" — the native confirm() looks like an
// OS-modal from the last decade AND opens over the tab area (out of the
// sidebar's visual context). Toggle a styled strip inside the sidebar so
// the destructive action stays where the user is looking.
const confirmBox = $("clear-confirm");
clearBtn.addEventListener("click", () => {
confirmBox.classList.add("on");
clearBtn.disabled = true;
});
$("clear-no").addEventListener("click", () => {
confirmBox.classList.remove("on");
clearBtn.disabled = false;
});
$("clear-yes").addEventListener("click", async () => {
confirmBox.classList.remove("on");
clearBtn.disabled = false;
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); setStatus("Cleared past captures.", "ok"); }
catch (e) { setStatus("Clear failed: " + (e && e.message || e), "err"); }
});
refreshRecent();
</script>
</body>
</html>