Fix new-capture auto-hide, library capture bar, step delete, and click-capture timing
Template tests / tests (push) Successful in 1m49s

- New Capture sessions now start paused; the window only tucks away once
  the user clicks "Start recording" in the capture bar instead of hiding
  ~1.2s after starting.
- The capture status bar is shown only in the editor view, not over the
  library.
- Fix openModal/confirmDialog resolving as cancelled when an action button
  is clicked, which made the step "Delete" button (and other modal actions)
  silently no-op.
- Click-triggered captures now use the click-time cursor position for the
  marker and arm the capture cache as soon as recording starts, so the
  first click is captured instantly and accurately placed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-11 09:21:19 -05:00
parent 5aefbdacaa
commit 99376fdeb2
7 changed files with 213 additions and 50 deletions
+37 -35
View File
@@ -81,39 +81,26 @@ class CaptureService {
if (interval == null) {
interval = this.clickCaptureAvailable() ? 0 : (this.settings.get('capture.autoIntervalSec') || 5);
}
this.session = { guideId, paused: false, count: 0, intervalSec: interval };
// Sessions start paused: nothing hides and no capturing happens until
// the user explicitly presses "Start recording" in the capture bar, so
// New Capture never makes the window vanish out from under them.
this.session = { guideId, paused: true, count: 0, intervalSec: interval };
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
this.applyInterval();
this.notify('capture:state', this.state());
// Tuck the app away once instead of hiding it for every shot — the
// hide/show flicker made the window impossible to click mid-session.
// A tray icon controls the session while the window is hidden.
// (Skipped for the dev screenshot hook, which needs a visible page.)
if (!process.env.STEPFORGE_SCREENSHOT) {
this.createSessionTray();
const win = this.getWindow();
const startClickCache = () => {
if (this.settings.get('capture.captureOutsideClicks') !== false && this.clickCaptureAvailable()) {
this.startClickCaptureCache();
}
};
if (win && !win.isDestroyed() && win.isVisible()) {
this.hiddenForSession = true;
setTimeout(() => {
// Re-check: the session may have been finished within the delay.
if (this.session && this.hiddenForSession && !win.isDestroyed()) {
win.hide();
startClickCache();
}
}, 1200); // let the user read the "session started" toast first
} else {
startClickCache();
}
// Remember whether the window was visible when the session was set
// up — that's what `togglePause` uses to decide whether to tuck the
// app away once the user actually starts recording.
this.hiddenForSession = Boolean(win && !win.isDestroyed() && win.isVisible());
try {
new Notification({
title: 'StepForge is capturing',
body: 'The window tucks away while recording. Use the red tray icon to pause, capture, or finish.',
title: 'StepForge is ready to capture',
body: 'Click "Start recording" in the red capture bar when youre ready. The window tucks away and the red tray icon takes over.',
}).show();
} catch { /* notifications unavailable on this desktop */ }
}
@@ -200,12 +187,20 @@ class CaptureService {
if (!this.session) return;
const wasPaused = this.session.paused;
this.session.paused = typeof force === 'boolean' ? force : !this.session.paused;
// Resuming from the app tucks the window away again for clean shots.
if (wasPaused && !this.session.paused && this.hiddenForSession) {
// Starting/resuming tucks the window away again for clean shots (after
// a brief delay so the user sees it happen) and arms the click-capture
// cache so the very next click is captured instantly.
if (wasPaused && !this.session.paused) {
const win = this.getWindow();
if (win && !win.isDestroyed()) setTimeout(() => {
if (this.session && !this.session.paused && !win.isDestroyed()) win.hide();
}, 400);
const arm = () => {
if (!this.session || this.session.paused) return;
if (this.hiddenForSession && win && !win.isDestroyed() && win.isVisible()) win.hide();
if (this.settings.get('capture.captureOutsideClicks') !== false && this.clickCaptureAvailable()) {
this.startClickCaptureCache();
}
};
if (this.hiddenForSession && win && !win.isDestroyed()) setTimeout(arm, 400);
else arm();
}
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
this.notify('capture:state', this.state());
@@ -242,7 +237,7 @@ class CaptureService {
}
/** One capture inside the active session (hotkey/click/interval/manual). */
async sessionCapture(trigger = 'hotkey') {
async sessionCapture(trigger = 'hotkey', clickPos = null) {
if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' };
if (this.shooting) return { ok: false, reason: 'capture already in progress' };
// Automatic triggers stand down while the user is in StepForge, so the
@@ -258,13 +253,14 @@ class CaptureService {
? this.captureCache
: null;
const finalResult = cached
? this.storeFrameAsStep(this.session.guideId, grabMode, cached)
? this.storeFrameAsStep(this.session.guideId, grabMode, cached, clickPos)
: await this.shoot({
guideId: this.session.guideId,
mode: grabMode,
delayMs: 0,
hideWindowDelayMs: trigger === 'click' ? CLICK_CAPTURE_HIDE_DELAY_MS : null,
refocus: false, // don't steal focus from the app the user is documenting
clickPos,
});
if (finalResult.ok) {
this.session.count += 1;
@@ -372,7 +368,11 @@ while ($true) {
const now = Date.now();
if (now - this.lastClickCapture < CLICK_DEBOUNCE_MS) return;
this.lastClickCapture = now;
this.sessionCapture('click').catch(() => {});
// Grab the cursor position synchronously, right when the click is
// detected, so the marker lands exactly where the user clicked even if
// the cached frame is a beat stale or a fresh shot takes a moment.
const clickPos = screen.getCursorScreenPoint();
this.sessionCapture('click', clickPos).catch(() => {});
}
async captureCurrentFrame(mode) {
@@ -387,12 +387,13 @@ while ($true) {
};
}
storeFrameAsStep(guideId, mode, frame) {
storeFrameAsStep(guideId, mode, frame, clickPos = null) {
if (!frame) return { ok: false, reason: 'no capture frame available' };
const annotations = [];
const cursor = clickPos || frame.cursor;
if (mode !== 'window' && this.settings.get('capture.clickMarker')) {
const fx = (frame.cursor.x - frame.display.bounds.x) / frame.display.bounds.width;
const fy = (frame.cursor.y - frame.display.bounds.y) / frame.display.bounds.height;
const fx = (cursor.x - frame.display.bounds.x) / frame.display.bounds.width;
const fy = (cursor.y - frame.display.bounds.y) / frame.display.bounds.height;
if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) {
const d = 0.035;
annotations.push({
@@ -499,6 +500,7 @@ while ($true) {
hideWindow = true,
refocus = true,
hideWindowDelayMs = null,
clickPos = null,
}) {
const delay = delayMs == null ? this.settings.get('capture.delayMs') || 0 : delayMs;
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
@@ -513,7 +515,7 @@ while ($true) {
} catch (err) {
return { ok: false, reason: err.message };
}
return this.storeFrameAsStep(guideId, mode, frame);
return this.storeFrameAsStep(guideId, mode, frame, clickPos);
}
/**
+5 -2
View File
@@ -105,8 +105,10 @@ function createWindow() {
try {
const guide = store.createGuide({ title: 'hotkey selftest' });
capture.startSession(guide.guideId, { intervalSec: 0 });
// Sessions hide the window while recording; do it immediately here
// instead of waiting out the toast-grace delay.
// Sessions start paused until "Start recording" is pressed; do
// that here instead of waiting out the toast-grace delay, and
// hide the window immediately rather than after the 400ms pause.
capture.togglePause(false);
mainWindow.hide();
await new Promise((res) => setTimeout(res, 400));
const results = [];
@@ -121,6 +123,7 @@ function createWindow() {
// Interval auto-capture: 1s timer should add ~3 steps in 3.6s.
const guide2 = store.createGuide({ title: 'interval selftest' });
capture.startSession(guide2.guideId, { intervalSec: 1 });
capture.togglePause(false);
await new Promise((res) => setTimeout(res, 3600));
capture.finishSession();
console.log('INTERVAL-SELFTEST steps:', store.getGuide(guide2.guideId).stepsOrder.length);
+20 -9
View File
@@ -137,6 +137,9 @@ class StepForgeApp {
this.editorHost.classList.toggle('hidden', view !== 'editor');
this.searchInput.classList.toggle('hidden', view !== 'library');
this.renderTopbar();
// The capture bar is editor-only; re-evaluate its visibility now that
// the view changed.
this.updateCaptureState(this.captureState);
}
showWelcome() {
@@ -188,13 +191,15 @@ class StepForgeApp {
const state = await api.capture.session({ action: 'start', guideId: guide.guideId });
this.updateCaptureState(state);
const hotkey = this.state.settings?.capture?.hotkeyCapture;
let how;
if (state.clickCapture) {
toast('Recording — every click grabs a step. StepForge tucks away; use the red tray icon to pause or finish.');
how = 'every click will grab a step';
} else if (state.intervalSec > 0) {
toast(`Recording — a step every ${state.intervalSec}s. StepForge tucks away; use the red tray icon to pause or finish.`);
how = `a step will be grabbed every ${state.intervalSec}s`;
} else {
toast(hotkey ? `Recording — press ${hotkey} to grab steps. Use the red tray icon to pause or finish.` : 'Capture session started.');
how = hotkey ? `press ${hotkey} to grab steps` : 'use Shoot to grab steps';
}
toast(`Click "Start recording" in the red bar when you're ready — ${how}. StepForge tucks away; use the red tray icon to pause or finish.`);
}
async openExistingWorkspace() {
@@ -231,7 +236,10 @@ class StepForgeApp {
updateCaptureState(state) {
this.captureState = state || { active: false };
clearNode(this.captureStatus);
if (!this.captureState.active) {
// The capture bar only makes sense alongside the editor it's recording
// into — hide it everywhere else (e.g. the library) even if a session
// is still active in the background.
if (!this.captureState.active || this.state.view !== 'editor') {
this.captureStatus.classList.add('hidden');
return;
}
@@ -240,10 +248,12 @@ class StepForgeApp {
const send = (payload) => api.capture.session(payload).then((next) => this.updateCaptureState(next));
// What is currently triggering captures, so the user knows what to do.
const trigger = s.paused ? 'paused'
: s.clickCapture ? 'on click'
: s.intervalSec > 0 ? `every ${s.intervalSec}s`
: 'hotkey only';
const notStarted = s.paused && !s.count;
const trigger = notStarted ? 'ready'
: s.paused ? 'paused'
: s.clickCapture ? 'on click'
: s.intervalSec > 0 ? `every ${s.intervalSec}s`
: 'hotkey only';
const shootBtn = el('button', {
type: 'button',
@@ -261,8 +271,9 @@ class StepForgeApp {
const pauseBtn = el('button', {
type: 'button',
title: notStarted ? 'StepForge tucks away and starts capturing' : '',
onClick: () => send({ action: s.paused ? 'resume' : 'pause' }),
}, s.paused ? 'Resume' : 'Pause');
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
const finishBtn = el('button', {
type: 'button',
+1 -1
View File
@@ -1146,7 +1146,7 @@ class GuideEditor {
async startCaptureSession() {
await api.capture.session({ action: 'start', guideId: this.guideId });
this.onToast('Capture session started.');
this.onToast('Capture session ready — click "Start recording" in the red bar when you\'re set.');
this.emitMeta();
}
+10 -3
View File
@@ -56,23 +56,30 @@ function toast(message, { error = false, ms = 2600 } = {}) {
function openModal({ title, body, footer, wide = false, onClose }) {
const root = document.getElementById('modal-root');
clearNode(root);
// `close` just tears down the modal. Buttons that already resolve the
// dialog's promise themselves call this. `dismiss` additionally fires
// `onClose`, for ways of leaving the dialog that didn't pick an option
// (Esc, the ✕, or clicking the backdrop) and need a default resolution.
const close = () => {
clearNode(root);
document.removeEventListener('keydown', escHandler, true);
};
const dismiss = () => {
close();
if (onClose) onClose();
};
const escHandler = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); close(); }
if (e.key === 'Escape') { e.stopPropagation(); dismiss(); }
};
document.addEventListener('keydown', escHandler, true);
const modal = el('div.modal', { className: `modal${wide ? ' wide' : ''}` },
el('header', {}, title, el('span.close', { onClick: close, title: 'Close (Esc)' }, '✕')),
el('header', {}, title, el('span.close', { onClick: dismiss, title: 'Close (Esc)' }, '✕')),
el('div.body', {}, body),
footer ? el('footer', {}, footer) : null,
);
modal.addEventListener('click', (e) => e.stopPropagation());
root.append(modal);
root.onclick = close;
root.onclick = dismiss;
return { close, node: modal };
}