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",
"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.",
"author": "Silent Mode",
"icon": "📸",

View file

@ -44,9 +44,6 @@
history) instead of one dot spilling to a lonely second row. -->
<div class="toolbar">
<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)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1v11h11M1 4h11v11"/></svg>
</button>

View file

@ -298,8 +298,14 @@ function cancelCrop() {
clearOver();
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")) {
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")) {
b.addEventListener("click", () => {
@ -494,10 +500,19 @@ function openTextInput(pt) {
const el = document.createElement("input");
el.type = "text";
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.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
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 scale = stage._scale || 1;
const cssX = pt.x * scale + rect.left;
@ -505,19 +520,24 @@ function openTextInput(pt) {
el.style.left = cssX + "px";
el.style.top = (cssY - size) + "px";
document.body.appendChild(el);
// Focus after a paint so Chromium reliably picks it up (the input goes
// from `display:absolute` to laid-out; focus() called synchronously
// right after appendChild races that in some builds).
// Three-way focus attempt — Chromium is racy about focusing an element
// that appeared mid-pointer-event. Synchronous focus() first (works on
// most builds), then a paint tick, then a short timer as belt-and-braces.
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 };
// Don't let the input's own pointerdown / mousedown / click bubble to
// the canvas — otherwise every keystroke click-through re-fires
// openTextInput on the base and spawns duplicate boxes.
// the canvas — otherwise every click-through re-fires openTextInput on
// the base and spawns duplicate boxes.
const swallow = (ev) => ev.stopPropagation();
for (const t of ["pointerdown", "mousedown", "click"]) el.addEventListener(t, swallow);
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(); }
// Every other key stays inside the input — belt against a stray
// document-level keydown handler stealing focus.
else ev.stopPropagation();
});
el.addEventListener("blur", commitTextInput);
}

View file

@ -18,12 +18,16 @@
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; justify-content: space-between;
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;
@ -102,6 +106,16 @@
}
.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>
@ -319,16 +333,27 @@
return;
}
recentBox.classList.add("on");
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>`
).join("");
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) 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 {}
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 {
const b = await window.silentmode.invoke("getBytes", { name });
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) {
console.warn("refreshRecent failed:", e);
}