feat(theseus/screenshot): 0.6.3 — per-tile delete, no Select button, text tool halo, right-anchor panel controls

Four issues from the user's report on 0.6.2:

- Recent captures had a global "clear all" but no way to drop a single
  screenshot. Each tile now grows a small × button (visible on hover;
  drops in behind the thumbnail preview so it never obstructs the
  content). Clicking the × invokes clearRecent({name}) and removes both
  the ring entry and the scratch PNG on disk. Bubble-guarded so the ×
  click doesn't also trigger the tile's "load into preview" handler.

- Select tool button removed — clicking it did nothing visible, so users
  read it as broken. The internal "select" mode still exists as the
  no-tool state; you get back to it now by clicking the same drawing
  tool a second time (toggle-off) or hitting Escape. The active-drawing-
  tool button flips its border when armed.

- Text tool made unmistakable: input paints with a 2 px acid border, a
  glowing acid halo, dark background, and the visible ink colour on the
  text itself. Focus attempt is three-layered (sync, rAF, timer) to
  outrun any Chromium build that drops the mid-pointer-event focus. Non-
  Enter/Escape keys get stopPropagation so a stray document listener
  can't steal the focus mid-typing.

- Panel header's sound / max / close cluster kept nudging inward when
  the status text was empty. The parent's `justify-content: space-
  between` distributed the row unevenly. Force-anchor the cluster with
  `#btn-sound { margin-left: auto }` so the three window-control icons
  hug the right edge regardless of what fills the middle.

Bundled but not shipped separately — parent session signs and pushes.
This commit is contained in:
Local Dev 2026-09-09 22:01:06 +02:00
parent 744574ba96
commit 8eda0433d7
4 changed files with 76 additions and 18 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "screenshot", "id": "screenshot",
"name": "Screenshot", "name": "Screenshot",
"version": "0.6.2", "version": "0.6.3",
"description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (crop, arrow, rect, ellipse, pen, text, blur/mosaic redaction, undo, copy, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.", "description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (crop, arrow, rect, ellipse, pen, text, blur/mosaic redaction, undo, copy, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "📸", "icon": "📸",

View file

@ -44,9 +44,6 @@
history) instead of one dot spilling to a lonely second row. --> history) instead of one dot spilling to a lonely second row. -->
<div class="toolbar"> <div class="toolbar">
<div class="tgroup" role="group" aria-label="Tools"> <div class="tgroup" role="group" aria-label="Tools">
<button class="tool active" data-tool="select" title="Select (no tool)">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 2l10 5-4 1-1 4z"/></svg>
</button>
<button class="tool" data-tool="crop" title="Crop (C)"> <button class="tool" data-tool="crop" title="Crop (C)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1v11h11M1 4h11v11"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1v11h11M1 4h11v11"/></svg>
</button> </button>

View file

@ -298,8 +298,14 @@ function cancelCrop() {
clearOver(); clearOver();
updateCropUI(); updateCropUI();
} }
// Clicking an already-active tool toggles it off (back to "select" = no
// tool). The dedicated "Select" button used to live here but read as
// broken to users — clicking it did nothing visible. Toggle-off gives the
// same "put the pen down" affordance without a mystery button.
for (const b of document.querySelectorAll(".tool")) { for (const b of document.querySelectorAll(".tool")) {
b.addEventListener("click", () => setTool(b.dataset.tool)); b.addEventListener("click", () => {
setTool(state.tool === b.dataset.tool ? "select" : b.dataset.tool);
});
} }
for (const b of document.querySelectorAll(".swatch")) { for (const b of document.querySelectorAll(".swatch")) {
b.addEventListener("click", () => { b.addEventListener("click", () => {
@ -494,10 +500,19 @@ function openTextInput(pt) {
const el = document.createElement("input"); const el = document.createElement("input");
el.type = "text"; el.type = "text";
el.className = "text-input"; el.className = "text-input";
el.placeholder = "text…"; el.placeholder = "type, then Enter";
el.autocomplete = "off";
el.setAttribute("autocorrect", "off");
el.setAttribute("spellcheck", "false");
// Visual weight — a bright acid halo around the box + the actual
// ink colour on the text itself, so users see something clearly
// happened when they clicked.
el.style.color = state.color; el.style.color = state.color;
el.style.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`; el.style.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
el.style.lineHeight = "1.15"; el.style.lineHeight = "1.15";
el.style.background = "rgba(11,14,20,0.94)";
el.style.border = "2px solid var(--acid, #d6ff3d)";
el.style.boxShadow = "0 0 0 3px rgba(214,255,61,.25), 0 4px 14px rgba(0,0,0,.45)";
const rect = base.getBoundingClientRect(); const rect = base.getBoundingClientRect();
const scale = stage._scale || 1; const scale = stage._scale || 1;
const cssX = pt.x * scale + rect.left; const cssX = pt.x * scale + rect.left;
@ -505,19 +520,24 @@ function openTextInput(pt) {
el.style.left = cssX + "px"; el.style.left = cssX + "px";
el.style.top = (cssY - size) + "px"; el.style.top = (cssY - size) + "px";
document.body.appendChild(el); document.body.appendChild(el);
// Focus after a paint so Chromium reliably picks it up (the input goes // Three-way focus attempt — Chromium is racy about focusing an element
// from `display:absolute` to laid-out; focus() called synchronously // that appeared mid-pointer-event. Synchronous focus() first (works on
// right after appendChild races that in some builds). // most builds), then a paint tick, then a short timer as belt-and-braces.
el.focus();
requestAnimationFrame(() => el.focus()); requestAnimationFrame(() => el.focus());
setTimeout(() => { if (state.textInput && state.textInput.el === el) el.focus(); }, 30);
state.textInput = { x: pt.x, y: pt.y, size, el }; state.textInput = { x: pt.x, y: pt.y, size, el };
// Don't let the input's own pointerdown / mousedown / click bubble to // Don't let the input's own pointerdown / mousedown / click bubble to
// the canvas — otherwise every keystroke click-through re-fires // the canvas — otherwise every click-through re-fires openTextInput on
// openTextInput on the base and spawns duplicate boxes. // the base and spawns duplicate boxes.
const swallow = (ev) => ev.stopPropagation(); const swallow = (ev) => ev.stopPropagation();
for (const t of ["pointerdown", "mousedown", "click"]) el.addEventListener(t, swallow); for (const t of ["pointerdown", "mousedown", "click"]) el.addEventListener(t, swallow);
el.addEventListener("keydown", (ev) => { el.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") { commitTextInput(); ev.preventDefault(); ev.stopPropagation(); } if (ev.key === "Enter") { commitTextInput(); ev.preventDefault(); ev.stopPropagation(); }
else if (ev.key === "Escape") { cancelTextInput(); ev.preventDefault(); ev.stopPropagation(); } else if (ev.key === "Escape") { cancelTextInput(); ev.preventDefault(); ev.stopPropagation(); }
// Every other key stays inside the input — belt against a stray
// document-level keydown handler stealing focus.
else ev.stopPropagation();
}); });
el.addEventListener("blur", commitTextInput); el.addEventListener("blur", commitTextInput);
} }

View file

@ -18,12 +18,16 @@
body { background: var(--bg); color: var(--ink); body { background: var(--bg); color: var(--ink);
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; } display: flex; flex-direction: column; }
header { display: flex; align-items: center; justify-content: space-between; header { display: flex; align-items: center;
padding: 10px 14px; border-bottom: 1px solid var(--line); padding: 10px 14px; border-bottom: 1px solid var(--line);
background: var(--panel); gap: 8px; } background: var(--panel); gap: 8px; }
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; min-width: 0; } header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; min-width: 0; }
header .t .em { font-size: 15px; } header .t .em { font-size: 15px; }
header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; flex: 1; text-align: right; } 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 { header .max {
border: 1px solid var(--line); border-radius: 6px; background: var(--btn); border: 1px solid var(--line); border-radius: 6px; background: var(--btn);
color: var(--ink); cursor: pointer; padding: 4px 6px; font: inherit; line-height: 0; color: var(--ink); cursor: pointer; padding: 4px 6px; font: inherit; line-height: 0;
@ -102,6 +106,16 @@
} }
.recent .tile img { width: 100%; height: 100%; object-fit: cover; background: #fff; } .recent .tile img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
.recent .tile:hover { border-color: var(--acid); } .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 { background: none; border: 0; color: var(--dim); font: inherit; cursor: pointer; text-transform: none; letter-spacing: 0; }
.recent .clear:hover { color: var(--err); } .recent .clear:hover { color: var(--err); }
</style> </style>
@ -319,16 +333,27 @@
return; return;
} }
recentBox.classList.add("on"); recentBox.classList.add("on");
recentStrip.innerHTML = list.map((r) => recentStrip.innerHTML = list.map((r) => {
`<div class="tile" data-name="${r.name.replace(/"/g,"&quot;")}" title="${r.name.replace(/"/g,"&quot;")} · ${fmt(r.bytes || 0)}"></div>` const nameAttr = r.name.replace(/"/g, "&quot;");
).join(""); 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")) { for (const tile of recentStrip.querySelectorAll(".tile")) {
const name = tile.dataset.name; const name = tile.dataset.name;
try { try {
const b = await window.silentmode.invoke("getBytes", { name }); const b = await window.silentmode.invoke("getBytes", { name });
if (b && b.dataUrl) tile.innerHTML = `<img src="${b.dataUrl}" alt="">`; 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 {} } catch {}
tile.addEventListener("click", async () => { 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 { try {
const b = await window.silentmode.invoke("getBytes", { name }); const b = await window.silentmode.invoke("getBytes", { name });
if (!b) return; if (!b) return;
@ -343,6 +368,22 @@
} }
}); });
} }
// 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) { } catch (e) {
console.warn("refreshRecent failed:", e); console.warn("refreshRecent failed:", e);
} }