Issue #4: Show recording directions after capture starts
Template tests / tests (push) Successful in 1m39s

Closes #4
This commit is contained in:
2026-06-13 15:37:07 -05:00
9 changed files with 91 additions and 75 deletions
+2 -2
View File
@@ -50,8 +50,8 @@ The core workflow:
- **Capture engine** — the editor's **Capture ▾** button offers full screen,
active window, and region capture (the app hides itself during the shot),
plus continuous capture sessions that grab a step on every click where the
OS allows it, or on a 3/5/10 s auto-interval everywhere else (the REC bar
shows the live count with Shoot/Auto/Pause/Finish controls). Delay, global
OS allows it, or on a 3/5/10 s auto-interval everywhere else. The REC bar
shows the live count and the start/pause control. Delay, global
hotkeys, click markers, clipboard paste, and PNG/JPEG/GIF import included.
The full keyboard shortcut list lives under **More ▾ → Keyboard
shortcuts** in the editor.
+8 -23
View File
@@ -2,7 +2,7 @@
const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process');
const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu, Notification } = require('electron');
const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu } = require('electron');
const { expandPlaceholders } = require('../core/placeholders');
const raster = require('../core/raster');
const { encodePng } = require('../core/png');
@@ -103,7 +103,13 @@ function hasBinary(name) {
}
class CaptureService {
constructor({ store, settings, getWindow, notify, screenApi = screen }) {
constructor({
store,
settings,
getWindow,
notify,
screenApi = screen,
}) {
this.store = store;
this.settings = settings;
this.getWindow = getWindow;
@@ -184,7 +190,6 @@ class CaptureService {
// 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 };
this.sessionNotificationShown = false;
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
this.applyInterval();
this.notify('capture:state', this.state());
@@ -352,7 +357,6 @@ class CaptureService {
const settleMs = Number(this.settings.get('capture.postHideSettleMs'));
const run = async () => {
if (!this.session || this.session.paused) { this.warmingUp = false; return; }
const startedAt = Date.now();
if (recorderWanted) {
// Warm the recorder, but never let a slow backend start (it waits up
// to several seconds for the capture stream) keep the window visible
@@ -366,14 +370,6 @@ class CaptureService {
if (capTimer) clearTimeout(capTimer);
if (!this.session || this.session.paused) { this.warmingUp = false; return; }
}
// Keep the window visible briefly so the user sees the transition even
// when warmup was instant; warmup time counts toward this.
const minVisibleMs = wantHide ? 400 : 0;
const elapsed = Date.now() - startedAt;
if (elapsed < minVisibleMs) {
await new Promise((r) => setTimeout(r, minVisibleMs - elapsed));
if (!this.session || this.session.paused) { this.warmingUp = false; return; }
}
if (wantHide && win && !win.isDestroyed() && win.isVisible()) {
win.hide();
// Let a couple of frames of the now-unobscured screen land before
@@ -381,16 +377,6 @@ class CaptureService {
// app window that was just dismissed.
await new Promise((r) => setTimeout(r, Number.isFinite(settleMs) ? settleMs : 150));
}
// Window hidden and buffer primed — clicks now count.
if (!process.env.STEPFORGE_SCREENSHOT && !this.sessionNotificationShown) {
try {
new Notification({
title: 'StepForge is recording',
body: 'Use the red tray icon to pause or finish capture.',
}).show();
this.sessionNotificationShown = true;
} catch { /* notifications unavailable on this desktop */ }
}
this.warmingUp = false;
};
run().catch(() => { this.warmingUp = false; });
@@ -407,7 +393,6 @@ class CaptureService {
this.stopClickFrameBackend();
this.destroySessionTray();
this.session = null;
this.sessionNotificationShown = false;
if (this.hiddenForSession) {
this.hiddenForSession = false;
this.showWindow();
-1
View File
@@ -494,7 +494,6 @@ function setupIpc() {
else if (action === 'resume') capture.togglePause(false);
else if (action === 'finish') capture.finishSession();
else if (action === 'interval') capture.setInterval(intervalSec);
else if (action === 'shoot') await capture.sessionCapture('manual');
const state = capture.state();
sendToRenderer('capture:state', state);
return state;
+24 -39
View File
@@ -191,18 +191,7 @@ class StepForgeApp {
const guide = await api.library.create({ title: 'Untitled capture' });
await this.refreshData();
await this.openGuide(guide.guideId);
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) {
how = 'every click will grab a step';
} else if (state.intervalSec > 0) {
how = `a step will be grabbed every ${state.intervalSec}s`;
} else {
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.`);
await this.armCaptureSession(guide.guideId);
}
async openExistingWorkspace() {
@@ -230,14 +219,26 @@ class StepForgeApp {
this.renderTopbar();
}
// Start a paused session, optionally show a reminder, and continue once
// the user acknowledges it.
async armCaptureSession(guideId, reminder = null) {
const state = await api.capture.session({ action: 'start', guideId });
this.updateCaptureState(state);
if (!reminder) return state;
const acknowledged = await dialogs.showRecordingReminder(reminder);
if (!acknowledged) return state;
const next = await api.capture.session({ action: 'resume', guideId });
this.updateCaptureState(next);
return next;
}
// Opens a guide and arms (paused) capture for it, so the red REC bar pops
// up right away with a "Start recording" option to resume capturing steps.
async openGuideAndArmCapture(guideId, stepId = null) {
await this.openGuide(guideId, stepId);
// Don't restart (and reset the count of) a session already running for this guide.
if (this.captureState?.active && this.captureState.guideId === guideId) return;
const state = await api.capture.session({ action: 'start', guideId });
this.updateCaptureState(state);
await this.armCaptureSession(guideId);
}
onEditorMeta(meta) {
@@ -268,37 +269,21 @@ class StepForgeApp {
: s.intervalSec > 0 ? `every ${s.intervalSec}s`
: 'hotkey only';
const shootBtn = el('button', {
type: 'button',
title: 'Capture a step now (the app hides itself for the shot)',
onClick: () => send({ action: 'shoot' }),
}, 'Shoot');
// Cycle interval auto-capture: off -> 3s -> 5s -> 10s -> off.
const nextInterval = { 0: 3, 3: 5, 5: 10, 10: 0 }[s.intervalSec ?? 0] ?? 3;
const autoBtn = el('button', {
type: 'button',
title: 'Automatically capture a step on a timer',
onClick: () => send({ action: 'interval', intervalSec: nextInterval }),
}, s.intervalSec > 0 ? `Auto ${s.intervalSec}s` : 'Auto off');
const pauseBtn = el('button', {
type: 'button',
title: notStarted ? 'StepForge tucks away and starts capturing' : '',
onClick: () => send({ action: s.paused ? 'resume' : 'pause' }),
onClick: async () => {
if (notStarted) {
const acknowledged = await dialogs.showRecordingReminder();
if (!acknowledged) return;
}
send({ action: s.paused ? 'resume' : 'pause' });
},
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
const finishBtn = el('button', {
type: 'button',
onClick: () => send({ action: 'finish' }),
}, 'Finish');
this.captureStatus.append(
el('span', { title: `Capture session — ${trigger}` }, `REC ${s.count || 0} · ${trigger}`),
shootBtn,
autoBtn,
el('span', { title: `Capture session — ${trigger}` }, `Rec - ${trigger}`),
pauseBtn,
finishBtn,
);
}
@@ -317,7 +302,7 @@ class StepForgeApp {
const guide = this.editorMeta?.guide;
this.topbarContext.append(
el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Back'),
el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Library'),
el('button.primary', {
type: 'button',
title: 'Capture a screenshot step',
+25
View File
@@ -693,6 +693,30 @@ function showInfoDialog(title, bodyText) {
});
}
function showRecordingReminder({
actionLabel = 'Continue',
headline = 'StepForge will hide after you continue.',
} = {}) {
return new Promise((resolve) => {
const { close } = openModal({
title: 'Before recording starts',
body: el('div.recording-notice', {},
el('div.recording-notice__badge', {}, 'Recording tip'),
el('div.recording-notice__title', {}, headline),
el('div.recording-notice__text', {},
'When you want to pause or stop, use the red tray icon in the system tray.',
),
),
footer: [
el('button.primary', {
onClick: () => { close(); resolve(true); },
}, actionLabel),
],
onClose: () => resolve(false),
});
});
}
window.StepForgeDialogs = {
promptText,
showQuickActions,
@@ -704,5 +728,6 @@ window.StepForgeDialogs = {
showPlaceholdersDialog,
showShortcutsDialog,
showTemplateManager,
showRecordingReminder,
};
})();
-2
View File
@@ -1221,7 +1221,6 @@ class GuideEditor {
{ label: 'Capture full screen', action: () => this.captureStep('fullscreen') },
{ label: 'Capture window', action: () => this.captureStep('window') },
{ label: 'Capture region…', action: () => this.captureStep('region') },
{ label: 'Capture after 3 s delay', action: () => this.captureStep('fullscreen', 3000) },
'sep',
{ label: 'Paste image as step', action: () => this.pasteClipboardStep() },
{ label: 'Import images…', action: () => this.importImageSteps() },
@@ -1322,7 +1321,6 @@ class GuideEditor {
async startCaptureSession() {
await api.capture.session({ action: 'start', guideId: this.guideId });
this.onToast('Capture session ready — click "Start recording" in the red bar when you\'re set.');
this.emitMeta();
}
+24
View File
@@ -619,6 +619,30 @@ fieldset legend {
background: var(--warn);
color: var(--text);
}
.recording-notice {
display: grid;
gap: 10px;
}
.recording-notice__badge {
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--danger);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.recording-notice__title {
font-size: 18px;
font-weight: 700;
}
.recording-notice__text {
color: var(--muted);
line-height: 1.5;
}
#modal-root:not(:empty) {
position: fixed;
+5 -5
View File
@@ -96,14 +96,14 @@ Initial release.
Existing Workspace (guide library), and Settings. The brand button
returns to the welcome screen from any view.
- Capture menu in the editor topbar: full screen / window / region /
3-second delay, paste image as step, import images, and capture
session start/finish — capture no longer requires the global hotkey.
paste image as step, import images, and capture session start/finish
— capture no longer requires the global hotkey.
- Continuous capture sessions: steps are grabbed on every OS click where
the platform supports it (xinput on X11, PowerShell on Windows), with
interval auto-capture (3/5/10 s) as the always-works fallback when
click detection or global hotkeys are unavailable (e.g. WSLg/Wayland).
The REC bar shows the live count and trigger, with Shoot / Auto /
Pause / Finish controls.
The REC bar shows the live count and trigger, with the start/pause
control.
- Recording sessions tuck the window away once and control everything
from a red tray icon (capture now / pause / open / finish) instead of
hiding the window for every shot — the app stays reachable
@@ -161,7 +161,7 @@ Initial release.
session now starts paused and the window only tucks away once the user
presses "Start recording" in the capture bar, so the app doesn't vanish
out from under you.
- The capture status bar (REC count / Shoot / Auto / Pause / Finish) is
- The capture status bar (REC count / start/pause control) is
now shown only in the editor view; it no longer appears over the
library when a session is still running in the background.
- Click-triggered captures now grab the cursor position at the instant of
+3 -3
View File
@@ -601,7 +601,7 @@ test('armRecording warms while visible, then hides and arms the session', async
getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; },
};
service.getWindow = () => win;
service.clickCaptureAvailable = () => true;
service.clickCaptureAvailable = () => false;
// Stub the recorder so warmup resolves fast without real Electron.
service.startClickFrameBackend = async () => {};
service.session = { guideId: 'g-arm', paused: false, count: 0, intervalSec: 0 };
@@ -1176,9 +1176,9 @@ test('a new session starts paused and does not hide the window until "Start reco
// User clicks "Start recording" (the resume action).
service.togglePause(false);
assert.equal(service.session.paused, false);
assert.equal(win.hidden, 0, 'hide is deferred briefly so the user sees it happen');
assert.equal(win.hidden, 0, 'hide is deferred until the resume path runs');
await new Promise((r) => setTimeout(r, 450));
await new Promise((r) => setTimeout(r, 25));
assert.equal(win.hidden, 1, 'window hides once recording actually starts');
} finally {
service.finishSession();