12 Commits
Author SHA1 Message Date
Tyler 1b16531321 Issue #4: Show recording directions after capture starts
Template tests / tests (push) Successful in 1m39s
Closes #4
2026-06-13 15:37:07 -05:00
Tyler 6c2c837a83 Remove delayed capture menu item
Template tests / tests (push) Successful in 1m43s
2026-06-13 14:59:27 -05:00
Tyler 356334dd3e Rename editor back button to library
Template tests / tests (push) Successful in 1m41s
2026-06-13 14:58:52 -05:00
Tyler 8c2229633f Simplify capture status label
Template tests / tests (push) Successful in 1m40s
2026-06-13 14:57:33 -05:00
Tyler 4311225d50 Remove auto and finish capture buttons
Template tests / tests (push) Successful in 1m40s
2026-06-13 14:55:41 -05:00
Tyler c7038a151a Remove library auto-start and shoot button
Template tests / tests (push) Successful in 1m40s
2026-06-13 14:40:57 -05:00
Tyler f0611a50b5 Remove issue 4 capture toast
Template tests / tests (push) Successful in 1m40s
2026-06-13 14:35:20 -05:00
Tyler dfd0139dc5 Sync tests workflow with main
Template tests / tests (pull_request) Successful in 1m48s
2026-06-13 14:30:54 -05:00
Tyler 1a0fda71f5 Remove new-capture follow-up toast
Template tests / tests (pull_request) Has been cancelled
2026-06-13 14:25:40 -05:00
Tyler e46f01885b Prompt to start recording for library guides
Template tests / tests (push) Failing after 12m49s
Template tests / tests (pull_request) Has been cancelled
2026-06-13 14:05:10 -05:00
Tyler 85b1f6f143 Use in-app modal for recording start confirmation
Template tests / tests (push) Successful in 2m17s
2026-06-13 13:50:13 -05:00
Iisyourdad e8f6e4cd09 Require recording acknowledgment before hide
Template tests / tests (push) Has been cancelled
2026-06-12 13:53:16 -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, - **Capture engine** — the editor's **Capture ▾** button offers full screen,
active window, and region capture (the app hides itself during the shot), 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 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 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 shows the live count and the start/pause control. Delay, global
hotkeys, click markers, clipboard paste, and PNG/JPEG/GIF import included. hotkeys, click markers, clipboard paste, and PNG/JPEG/GIF import included.
The full keyboard shortcut list lives under **More ▾ → Keyboard The full keyboard shortcut list lives under **More ▾ → Keyboard
shortcuts** in the editor. shortcuts** in the editor.
+8 -23
View File
@@ -2,7 +2,7 @@
const path = require('node:path'); const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process'); 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 { expandPlaceholders } = require('../core/placeholders');
const raster = require('../core/raster'); const raster = require('../core/raster');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
@@ -103,7 +103,13 @@ function hasBinary(name) {
} }
class CaptureService { class CaptureService {
constructor({ store, settings, getWindow, notify, screenApi = screen }) { constructor({
store,
settings,
getWindow,
notify,
screenApi = screen,
}) {
this.store = store; this.store = store;
this.settings = settings; this.settings = settings;
this.getWindow = getWindow; this.getWindow = getWindow;
@@ -184,7 +190,6 @@ class CaptureService {
// the user explicitly presses "Start recording" in the capture bar, so // the user explicitly presses "Start recording" in the capture bar, so
// New Capture never makes the window vanish out from under them. // New Capture never makes the window vanish out from under them.
this.session = { guideId, paused: true, count: 0, intervalSec: interval }; this.session = { guideId, paused: true, count: 0, intervalSec: interval };
this.sessionNotificationShown = false;
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher(); if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
this.applyInterval(); this.applyInterval();
this.notify('capture:state', this.state()); this.notify('capture:state', this.state());
@@ -352,7 +357,6 @@ class CaptureService {
const settleMs = Number(this.settings.get('capture.postHideSettleMs')); const settleMs = Number(this.settings.get('capture.postHideSettleMs'));
const run = async () => { const run = async () => {
if (!this.session || this.session.paused) { this.warmingUp = false; return; } if (!this.session || this.session.paused) { this.warmingUp = false; return; }
const startedAt = Date.now();
if (recorderWanted) { if (recorderWanted) {
// Warm the recorder, but never let a slow backend start (it waits up // Warm the recorder, but never let a slow backend start (it waits up
// to several seconds for the capture stream) keep the window visible // to several seconds for the capture stream) keep the window visible
@@ -366,14 +370,6 @@ class CaptureService {
if (capTimer) clearTimeout(capTimer); if (capTimer) clearTimeout(capTimer);
if (!this.session || this.session.paused) { this.warmingUp = false; return; } 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()) { if (wantHide && win && !win.isDestroyed() && win.isVisible()) {
win.hide(); win.hide();
// Let a couple of frames of the now-unobscured screen land before // Let a couple of frames of the now-unobscured screen land before
@@ -381,16 +377,6 @@ class CaptureService {
// app window that was just dismissed. // app window that was just dismissed.
await new Promise((r) => setTimeout(r, Number.isFinite(settleMs) ? settleMs : 150)); 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; this.warmingUp = false;
}; };
run().catch(() => { this.warmingUp = false; }); run().catch(() => { this.warmingUp = false; });
@@ -407,7 +393,6 @@ class CaptureService {
this.stopClickFrameBackend(); this.stopClickFrameBackend();
this.destroySessionTray(); this.destroySessionTray();
this.session = null; this.session = null;
this.sessionNotificationShown = false;
if (this.hiddenForSession) { if (this.hiddenForSession) {
this.hiddenForSession = false; this.hiddenForSession = false;
this.showWindow(); this.showWindow();
-1
View File
@@ -494,7 +494,6 @@ function setupIpc() {
else if (action === 'resume') capture.togglePause(false); else if (action === 'resume') capture.togglePause(false);
else if (action === 'finish') capture.finishSession(); else if (action === 'finish') capture.finishSession();
else if (action === 'interval') capture.setInterval(intervalSec); else if (action === 'interval') capture.setInterval(intervalSec);
else if (action === 'shoot') await capture.sessionCapture('manual');
const state = capture.state(); const state = capture.state();
sendToRenderer('capture:state', state); sendToRenderer('capture:state', state);
return state; return state;
+24 -39
View File
@@ -191,18 +191,7 @@ class StepForgeApp {
const guide = await api.library.create({ title: 'Untitled capture' }); const guide = await api.library.create({ title: 'Untitled capture' });
await this.refreshData(); await this.refreshData();
await this.openGuide(guide.guideId); await this.openGuide(guide.guideId);
const state = await api.capture.session({ action: 'start', guideId: guide.guideId }); await this.armCaptureSession(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.`);
} }
async openExistingWorkspace() { async openExistingWorkspace() {
@@ -230,14 +219,26 @@ class StepForgeApp {
this.renderTopbar(); 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 // 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. // up right away with a "Start recording" option to resume capturing steps.
async openGuideAndArmCapture(guideId, stepId = null) { async openGuideAndArmCapture(guideId, stepId = null) {
await this.openGuide(guideId, stepId); await this.openGuide(guideId, stepId);
// Don't restart (and reset the count of) a session already running for this guide. // Don't restart (and reset the count of) a session already running for this guide.
if (this.captureState?.active && this.captureState.guideId === guideId) return; if (this.captureState?.active && this.captureState.guideId === guideId) return;
const state = await api.capture.session({ action: 'start', guideId }); await this.armCaptureSession(guideId);
this.updateCaptureState(state);
} }
onEditorMeta(meta) { onEditorMeta(meta) {
@@ -268,37 +269,21 @@ class StepForgeApp {
: s.intervalSec > 0 ? `every ${s.intervalSec}s` : s.intervalSec > 0 ? `every ${s.intervalSec}s`
: 'hotkey only'; : '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', { const pauseBtn = el('button', {
type: 'button', type: 'button',
title: notStarted ? 'StepForge tucks away and starts capturing' : '', 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'); }, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
const finishBtn = el('button', {
type: 'button',
onClick: () => send({ action: 'finish' }),
}, 'Finish');
this.captureStatus.append( this.captureStatus.append(
el('span', { title: `Capture session — ${trigger}` }, `REC ${s.count || 0} · ${trigger}`), el('span', { title: `Capture session — ${trigger}` }, `Rec - ${trigger}`),
shootBtn,
autoBtn,
pauseBtn, pauseBtn,
finishBtn,
); );
} }
@@ -317,7 +302,7 @@ class StepForgeApp {
const guide = this.editorMeta?.guide; const guide = this.editorMeta?.guide;
this.topbarContext.append( this.topbarContext.append(
el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Back'), el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Library'),
el('button.primary', { el('button.primary', {
type: 'button', type: 'button',
title: 'Capture a screenshot step', 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 = { window.StepForgeDialogs = {
promptText, promptText,
showQuickActions, showQuickActions,
@@ -704,5 +728,6 @@ window.StepForgeDialogs = {
showPlaceholdersDialog, showPlaceholdersDialog,
showShortcutsDialog, showShortcutsDialog,
showTemplateManager, showTemplateManager,
showRecordingReminder,
}; };
})(); })();
-2
View File
@@ -1221,7 +1221,6 @@ class GuideEditor {
{ label: 'Capture full screen', action: () => this.captureStep('fullscreen') }, { label: 'Capture full screen', action: () => this.captureStep('fullscreen') },
{ label: 'Capture window', action: () => this.captureStep('window') }, { label: 'Capture window', action: () => this.captureStep('window') },
{ label: 'Capture region…', action: () => this.captureStep('region') }, { label: 'Capture region…', action: () => this.captureStep('region') },
{ label: 'Capture after 3 s delay', action: () => this.captureStep('fullscreen', 3000) },
'sep', 'sep',
{ label: 'Paste image as step', action: () => this.pasteClipboardStep() }, { label: 'Paste image as step', action: () => this.pasteClipboardStep() },
{ label: 'Import images…', action: () => this.importImageSteps() }, { label: 'Import images…', action: () => this.importImageSteps() },
@@ -1322,7 +1321,6 @@ class GuideEditor {
async startCaptureSession() { async startCaptureSession() {
await api.capture.session({ action: 'start', guideId: this.guideId }); 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(); this.emitMeta();
} }
+24
View File
@@ -619,6 +619,30 @@ fieldset legend {
background: var(--warn); background: var(--warn);
color: var(--text); 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) { #modal-root:not(:empty) {
position: fixed; position: fixed;
+5 -5
View File
@@ -96,14 +96,14 @@ Initial release.
Existing Workspace (guide library), and Settings. The brand button Existing Workspace (guide library), and Settings. The brand button
returns to the welcome screen from any view. returns to the welcome screen from any view.
- Capture menu in the editor topbar: full screen / window / region / - Capture menu in the editor topbar: full screen / window / region /
3-second delay, paste image as step, import images, and capture paste image as step, import images, and capture session start/finish
session start/finish — capture no longer requires the global hotkey. — capture no longer requires the global hotkey.
- Continuous capture sessions: steps are grabbed on every OS click where - Continuous capture sessions: steps are grabbed on every OS click where
the platform supports it (xinput on X11, PowerShell on Windows), with the platform supports it (xinput on X11, PowerShell on Windows), with
interval auto-capture (3/5/10 s) as the always-works fallback when interval auto-capture (3/5/10 s) as the always-works fallback when
click detection or global hotkeys are unavailable (e.g. WSLg/Wayland). click detection or global hotkeys are unavailable (e.g. WSLg/Wayland).
The REC bar shows the live count and trigger, with Shoot / Auto / The REC bar shows the live count and trigger, with the start/pause
Pause / Finish controls. control.
- Recording sessions tuck the window away once and control everything - Recording sessions tuck the window away once and control everything
from a red tray icon (capture now / pause / open / finish) instead of from a red tray icon (capture now / pause / open / finish) instead of
hiding the window for every shot — the app stays reachable 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 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 presses "Start recording" in the capture bar, so the app doesn't vanish
out from under you. 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 now shown only in the editor view; it no longer appears over the
library when a session is still running in the background. library when a session is still running in the background.
- Click-triggered captures now grab the cursor position at the instant of - 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 }; }, getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; },
}; };
service.getWindow = () => win; service.getWindow = () => win;
service.clickCaptureAvailable = () => true; service.clickCaptureAvailable = () => false;
// Stub the recorder so warmup resolves fast without real Electron. // Stub the recorder so warmup resolves fast without real Electron.
service.startClickFrameBackend = async () => {}; service.startClickFrameBackend = async () => {};
service.session = { guideId: 'g-arm', paused: false, count: 0, intervalSec: 0 }; 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). // User clicks "Start recording" (the resume action).
service.togglePause(false); service.togglePause(false);
assert.equal(service.session.paused, 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'); assert.equal(win.hidden, 1, 'window hides once recording actually starts');
} finally { } finally {
service.finishSession(); service.finishSession();