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,
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();