Complete the app: capture UI, dialogs, template manager, shortcuts help
Template tests / tests (push) Failing after 21s

- Editor topbar reworked: Back | Capture ▾ (full screen/window/region/
  delay/paste/import/session) | Save | Export | Share (.sfgz) | More ▾
  (rename, guide placeholders, backups, linked guide, shortcuts,
  settings)
- New dialogs: backups & snapshots (undoable restore), guide/global
  placeholder editor, keyboard-shortcuts reference, template manager
  (rename/duplicate/delete/share/import .sfglt)
- Export dialog: editable per-format options generated from exporter
  defaults, save-as-template, preview opens the file in the default
  viewer and keeps the dialog open for tweaking
- export:defaults IPC + preload entry
- CSS for blocks panel, focused-view sliders, export options, rows
- ipc-surface test: every preload channel has a main handler; renderer
  api.*/dialogs.* usage stays within the exposed surface (60 tests)
- CHANGELOG/README updated; prompt2.md checklist fully ticked

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-10 22:15:15 -05:00
parent 382dbc9717
commit 6e790832f5
10 changed files with 520 additions and 60 deletions
+20
View File
@@ -13,6 +13,26 @@ Initial release.
(creates a guide, opens the editor, and starts a capture session), (creates a guide, opens the editor, and starts a capture session),
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 /
3-second delay, paste image as step, import images, and capture
session start/finish — capture no longer requires the global hotkey.
- The app hides its own window during capture so screenshots show your
work, not StepForge; hotkey captures restore the window without
stealing focus.
- Blocks panel: add and edit informational text blocks, code blocks,
and tables directly on a step.
- Focused-view zoom and pan sliders.
- Guide-level placeholders editor (More ▾ → Guide placeholders).
- Backups & snapshots dialog with one-click undoable restore.
- Export dialog: editable per-format options, save-as-template, and a
template manager (rename / duplicate / delete / share as .sfglt);
Preview now opens the generated file in the default viewer.
- Apply an annotation's style to all annotations of the same type in
the step or the whole guide.
- Keyboard shortcuts: tool keys (S R O L A T G N B H M U C), PageUp/
PageDown step navigation, Ctrl+= / Ctrl+- / Ctrl+0 zoom, annotation
copy/paste (Ctrl+C/V), Ctrl+Delete deletes the step, Shift+arrows
fast-nudge — plus a shortcuts reference dialog.
### Fixed ### Fixed
+5 -3
View File
@@ -47,9 +47,11 @@ The core workflow:
- **Guide library** with folders, favorites, title search, full-text search, - **Guide library** with folders, favorites, title search, full-text search,
duplicate/move/delete, and a quick-actions palette (`Ctrl+/`). duplicate/move/delete, and a quick-actions palette (`Ctrl+/`).
- **Capture engine** — full screen, active window, and region capture with - **Capture engine** — the editor's **Capture ▾** button offers full screen,
delay, pause/resume, hotkeys, click markers, clipboard paste, and PNG/JPEG/ active window, and region capture (the app hides itself during the shot)
GIF import. plus delay, pause/resume sessions with global hotkeys, click markers,
clipboard paste, and PNG/JPEG/GIF import. The full keyboard shortcut list
lives under **More ▾ → Keyboard shortcuts** in the editor.
- **Three-pane editor** — step tree with substeps, statuses - **Three-pane editor** — step tree with substeps, statuses
(todo/in-progress/done), hidden/skipped steps, focused view (zoom/pan that (todo/in-progress/done), hidden/skipped steps, focused view (zoom/pan that
never mutates the original image), autosave, and command-stack undo/redo. never mutates the original image), autosave, and command-stack undo/redo.
+17
View File
@@ -348,6 +348,23 @@ function setupIpc() {
// export + preview // export + preview
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f])); h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]));
h('export:defaults', ({ format }) => {
// Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
// options from it (booleans -> checkbox, numbers -> number, strings -> text).
const mod = {
json: '../exporters/json',
markdown: '../exporters/markdown',
'html-simple': '../exporters/html',
'html-rich': '../exporters/html',
pdf: '../exporters/pdf',
gif: '../exporters/gif',
'image-bundle': '../exporters/image-bundle',
docx: '../exporters/docx',
pptx: '../exporters/pptx',
}[format];
if (!mod) return {};
return { ...require(mod).DEFAULT_TEMPLATE };
});
h('export:run', async ({ guideId, format, options, outDir }) => { h('export:run', async ({ guideId, format, options, outDir }) => {
let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`); let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`);
if (!dir) { if (!dir) {
+1
View File
@@ -81,6 +81,7 @@ const api = {
}, },
export: { export: {
formats: invoke('export:formats'), formats: invoke('export:formats'),
defaults: invoke('export:defaults'),
run: invoke('export:run'), run: invoke('export:run'),
chooseDir: invoke('export:chooseDir'), chooseDir: invoke('export:chooseDir'),
preview: invoke('export:preview'), preview: invoke('export:preview'),
+22 -4
View File
@@ -265,12 +265,30 @@ 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() }, 'Back'),
el('button', { type: 'button', onClick: () => this.renameGuide() }, 'Rename'), el('button.primary', {
type: 'button',
title: 'Capture a screenshot step',
onClick: (e) => this.editor.openCaptureMenu(e),
}, 'Capture ▾'),
el('button', { type: 'button', onClick: () => this.editor.saveAll() }, 'Save'), el('button', { type: 'button', onClick: () => this.editor.saveAll() }, 'Save'),
el('button', { type: 'button', onClick: () => this.editor.openExportDialog() }, 'Export'), el('button', { type: 'button', onClick: () => this.editor.openExportDialog() }, 'Export'),
el('button', { type: 'button', onClick: () => this.editor.openLinkedGuide() }, guide && guide.linkedSource ? 'Linked' : 'Local'), el('button', { type: 'button', title: 'Share this guide as a .sfgz file', onClick: () => this.editor.shareAsFile() }, 'Share'),
el('button', { type: 'button', onClick: () => this.editor.openQuickActions() }, 'Quick'), el('button', {
el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'), type: 'button',
onClick: (e) => {
const rect = e.target.getBoundingClientRect();
contextMenu(rect.left, rect.bottom + 4, [
{ label: 'Rename guide…', action: () => this.renameGuide() },
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
'sep',
{ label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() },
{ label: 'Quick actions (Ctrl+/)', action: () => this.editor.openQuickActions() },
{ label: 'Settings…', action: () => this.openSettings() },
]);
},
}, 'More ▾'),
el('span.muted', { style: { marginLeft: '8px' } }, guide ? `${guide.title} · ${this.editorMeta?.stepCount || 0} steps` : ''), el('span.muted', { style: { marginLeft: '8px' } }, guide ? `${guide.title} · ${this.editorMeta?.stepCount || 0} steps` : ''),
); );
} }
+285 -20
View File
@@ -296,6 +296,10 @@ function showExportDialog({
onChooseDir, onChooseDir,
onExport, onExport,
onPreview, onPreview,
onLoadDefaults, // async (format) => exporter DEFAULT_TEMPLATE
onLoadTemplate, // async (format, name) => saved options
onSaveTemplate, // async (format, name, options)
onManageTemplates, // async (format) => refreshed template name list
} = {}) { } = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
const formatOptions = (formats || []).map((f) => { const formatOptions = (formats || []).map((f) => {
@@ -305,21 +309,91 @@ function showExportDialog({
const formatSelect = makeSelect(defaultFormat, formatOptions); const formatSelect = makeSelect(defaultFormat, formatOptions);
const templateSelect = makeSelect('', [{ value: '', label: 'Default template' }]); const templateSelect = makeSelect('', [{ value: '', label: 'Default template' }]);
const outDirInput = makeInput(defaultOutDir, 'text', { placeholder: 'Choose an output folder' }); const outDirInput = makeInput(defaultOutDir, 'text', { placeholder: 'Choose an output folder' });
const info = el('div.muted', {}, 'Templates are optional. If no template is selected, exporter defaults are used.'); const optionsHost = el('div', { className: 'export-options' });
function refreshTemplates() { // The effective option set shown to (and edited by) the user.
const list = templatesByFormat[formatSelect.value] || []; let defaults = {};
let current = {};
function renderOptions() {
clearNode(optionsHost);
const entries = Object.entries(defaults)
.filter(([, v]) => ['boolean', 'number', 'string'].includes(typeof v));
if (!entries.length) {
optionsHost.append(el('div.muted', {}, 'This format has no adjustable options.'));
return;
}
for (const [key, defVal] of entries) {
const value = current[key] ?? defVal;
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
let control;
if (typeof defVal === 'boolean') {
control = el('input', { type: 'checkbox', checked: Boolean(value) });
control.addEventListener('change', () => { current[key] = control.checked; });
} else if (typeof defVal === 'number') {
control = makeInput(value, 'number', { step: 'any' });
control.addEventListener('input', () => { current[key] = Number(control.value); });
} else {
control = makeInput(value, 'text');
control.addEventListener('input', () => { current[key] = control.value; });
}
optionsHost.append(labeledRow(label, control));
}
}
async function refreshOptions() {
defaults = (await onLoadDefaults?.(formatSelect.value)) || {};
const saved = templateSelect.value
? (await onLoadTemplate?.(formatSelect.value, templateSelect.value)) || {}
: {};
current = { ...saved };
renderOptions();
}
function refreshTemplates(list = templatesByFormat[formatSelect.value] || []) {
clearNode(templateSelect); clearNode(templateSelect);
templateSelect.append(el('option', { value: '' }, 'Default template')); templateSelect.append(el('option', { value: '' }, 'Default template'));
for (const name of list) templateSelect.append(el('option', { value: name }, name)); for (const name of list) templateSelect.append(el('option', { value: name }, name));
} }
formatSelect.addEventListener('change', refreshTemplates); formatSelect.addEventListener('change', () => { refreshTemplates(); refreshOptions(); });
templateSelect.addEventListener('change', () => refreshOptions());
refreshTemplates(); refreshTemplates();
refreshOptions();
const effectiveOptions = () => {
// only keep values that differ from defaults, so templates stay minimal
const out = {};
for (const [k, v] of Object.entries(current)) {
if (v !== undefined && v !== defaults[k]) out[k] = v;
}
return out;
};
const saveTplBtn = el('button', {
type: 'button',
onClick: async () => {
const name = await promptText({ title: 'Save template', label: 'Template name', value: templateSelect.value || '' });
if (!name || !name.trim()) return;
await onSaveTemplate?.(formatSelect.value, name.trim(), effectiveOptions());
const list = await onManageTemplates?.(formatSelect.value, null);
refreshTemplates(list || []);
templateSelect.value = name.trim();
},
}, 'Save as template…');
const manageBtn = el('button', {
type: 'button',
onClick: async () => {
const list = await onManageTemplates?.(formatSelect.value, 'manage');
refreshTemplates(list || []);
refreshOptions();
},
}, 'Manage…');
const body = el('div.export-dialog', {}, const body = el('div.export-dialog', {},
labeledRow('Format', formatSelect), labeledRow('Format', formatSelect),
labeledRow('Template', templateSelect), labeledRow('Template', el('div.row', {}, templateSelect, saveTplBtn, manageBtn)),
labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', { labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', {
type: 'button', type: 'button',
disabled: typeof onChooseDir !== 'function', disabled: typeof onChooseDir !== 'function',
@@ -329,9 +403,16 @@ function showExportDialog({
if (chosen) outDirInput.value = chosen; if (chosen) outDirInput.value = chosen;
}, },
}, 'Choose…'))), }, 'Choose…'))),
info, el('fieldset', {}, el('legend', {}, 'Options'), optionsHost),
); );
const payload = () => ({
format: formatSelect.value,
templateName: templateSelect.value || null,
options: effectiveOptions(),
outDir: outDirInput.value.trim() || null,
});
const { close } = openModal({ const { close } = openModal({
title: 'Export', title: 'Export',
body, body,
@@ -340,25 +421,13 @@ function showExportDialog({
el('button', { el('button', {
onClick: async () => { onClick: async () => {
if (typeof onPreview !== 'function') return; if (typeof onPreview !== 'function') return;
const ok = await onPreview({ await onPreview(payload()); // keep dialog open so settings can be tweaked
format: formatSelect.value,
templateName: templateSelect.value || null,
outDir: outDirInput.value.trim() || null,
});
if (ok !== false) {
close();
resolve(true);
}
}, },
}, 'Preview'), }, 'Preview'),
el('button.primary', { el('button.primary', {
onClick: async () => { onClick: async () => {
if (typeof onExport !== 'function') return; if (typeof onExport !== 'function') return;
const ok = await onExport({ const ok = await onExport(payload());
format: formatSelect.value,
templateName: templateSelect.value || null,
outDir: outDirInput.value.trim() || null,
});
if (ok !== false) { if (ok !== false) {
close(); close();
resolve(true); resolve(true);
@@ -372,6 +441,57 @@ function showExportDialog({
}); });
} }
/** Template management list: rename / duplicate / delete / import / export. */
function showTemplateManager({ format, names = [], onRename, onDuplicate, onDelete, onImport, onExport }) {
return new Promise((resolve) => {
let list = [...names];
const rows = el('div', { className: 'card-list' });
function renderRows() {
clearNode(rows);
if (!list.length) {
rows.append(el('div.muted', {}, 'No templates saved for this format yet.'));
return;
}
for (const name of list) {
rows.append(el('div.row', { style: { justifyContent: 'space-between', gap: '8px' } },
el('span', {}, name),
el('div.row', {},
el('button', {
type: 'button',
onClick: async () => {
const next = await promptText({ title: 'Rename template', label: 'Name', value: name });
if (next && next.trim() && next.trim() !== name) {
list = await onRename(name, next.trim());
renderRows();
}
},
}, 'Rename'),
el('button', { type: 'button', onClick: async () => { list = await onDuplicate(name); renderRows(); } }, 'Duplicate'),
el('button', { type: 'button', onClick: async () => { await onExport(name); } }, 'Share…'),
el('button.danger', { type: 'button', onClick: async () => { list = await onDelete(name); renderRows(); } }, 'Delete'),
),
));
}
}
const { close } = openModal({
title: `Templates — ${format}`,
body: el('div', {},
el('div.row', { style: { marginBottom: '10px' } },
el('button', { type: 'button', onClick: async () => { list = await onImport(); renderRows(); } }, 'Import .sfglt…'),
el('span.muted', {}, 'Templates are shareable as .sfglt files.'),
),
rows,
),
footer: [el('button.primary', { onClick: () => { close(); resolve(list); } }, 'Done')],
wide: true,
onClose: () => resolve(list),
});
renderRows();
});
}
function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive } = {}) { function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive } = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
const linked = guide.linkedSource || {}; const linked = guide.linkedSource || {};
@@ -421,6 +541,147 @@ function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive
}); });
} }
function showBackupsDialog({ snapshots = [], onCreate, onRestore } = {}) {
return new Promise((resolve) => {
let list = [...snapshots];
const rows = el('div', { className: 'card-list' });
function renderRows() {
clearNode(rows);
if (!list.length) {
rows.append(el('div.muted', {}, 'No snapshots yet. Automatic snapshots are created as you work; create one manually any time.'));
return;
}
for (const name of list) {
rows.append(el('div.row', { style: { justifyContent: 'space-between', gap: '10px' } },
el('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, name),
el('button', {
type: 'button',
onClick: async () => {
const restored = await onRestore?.(name);
if (restored) close();
},
}, 'Restore'),
));
}
}
const createBtn = el('button.primary', {
type: 'button',
onClick: async () => {
const refreshed = await onCreate?.();
if (Array.isArray(refreshed)) {
list = refreshed;
renderRows();
}
},
}, 'Create snapshot');
const { close } = openModal({
title: 'Backups & snapshots',
body: el('div', {},
el('div.row', { style: { marginBottom: '10px' } }, createBtn,
el('span.muted', {}, 'Restores are undoable — the current state is snapshotted first.')),
rows,
),
footer: [el('button', { onClick: () => { close(); resolve(true); } }, 'Close')],
wide: true,
onClose: () => resolve(true),
});
renderRows();
});
}
function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {}, onSave } = {}) {
return new Promise((resolve) => {
const rowsHost = el('div', { className: 'placeholder-rows' });
const rows = [];
const addRow = (key = '', value = '') => {
const keyInput = makeInput(key, 'text', { placeholder: 'Name' });
const valueInput = makeInput(value, 'text', { placeholder: 'Value' });
const row = el('div.placeholder-row', {}, keyInput, valueInput,
el('button.icon', {
type: 'button', title: 'Remove',
onClick: () => { row.remove(); rows.splice(rows.indexOf(row), 1); },
}, ''));
rows.push(row);
rowsHost.append(row);
};
Object.entries(values || {}).forEach(([k, v]) => addRow(k, v));
if (!Object.keys(values || {}).length) addRow();
const { close } = openModal({
title,
body: el('div', {},
hint ? el('div.muted', { style: { marginBottom: '10px' } }, hint) : null,
rowsHost,
el('div.row', { style: { marginTop: '8px' } },
el('button', { type: 'button', onClick: () => addRow() }, 'Add placeholder')),
),
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button.primary', {
onClick: async () => {
const next = rows.reduce((acc, row) => {
const inputs = row.querySelectorAll('input');
const key = inputs[0].value.trim();
if (key) acc[key] = inputs[1].value;
return acc;
}, {});
await onSave?.(next);
close();
resolve(true);
},
}, 'Save'),
],
onClose: () => resolve(false),
});
});
}
const SHORTCUTS = [
['Capture & steps', [
['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
['Ctrl+/', 'Quick actions palette'],
['PageUp / PageDown', 'Previous / next step'],
['Alt+↑ / Alt+↓', 'Move step up / down'],
['Ctrl+Delete', 'Delete current step'],
['Ctrl+V', 'Paste annotation, or clipboard image as new step'],
]],
['Canvas tools', [
['S R O L A T', 'Select · Rect · Oval · Line · Arrow · Text'],
['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'],
['Ctrl+C', 'Copy selected annotation'],
['Delete', 'Delete selected annotation'],
['Esc', 'Deselect annotation'],
['Arrows / Shift+Arrows', 'Nudge selection by 1 px / 10 px'],
]],
['View', [
['Ctrl+= / Ctrl+-', 'Zoom in / out'],
['Ctrl+0', 'Fit image to window'],
]],
];
function showShortcutsDialog() {
return new Promise((resolve) => {
const sections = SHORTCUTS.map(([heading, items]) => el('div', {},
el('h3', { style: { margin: '8px 0 6px' } }, heading),
el('table', { style: { width: '100%', borderCollapse: 'collapse' } },
...items.map(([keys, what]) => el('tr', {},
el('td', { style: { padding: '3px 14px 3px 0', whiteSpace: 'nowrap' } }, el('kbd', {}, keys)),
el('td', { style: { padding: '3px 0' } }, what),
)),
),
));
const { close } = openModal({
title: 'Keyboard shortcuts',
body: el('div', {}, ...sections),
footer: [el('button.primary', { onClick: () => { close(); resolve(true); } }, 'Close')],
onClose: () => resolve(true),
});
});
}
function showInfoDialog(title, bodyText) { function showInfoDialog(title, bodyText) {
return new Promise((resolve) => { return new Promise((resolve) => {
const { close } = openModal({ const { close } = openModal({
@@ -439,5 +700,9 @@ window.StepForgeDialogs = {
showExportDialog, showExportDialog,
showLinkedGuideDialog, showLinkedGuideDialog,
showInfoDialog, showInfoDialog,
showBackupsDialog,
showPlaceholdersDialog,
showShortcutsDialog,
showTemplateManager,
}; };
})(); })();
+44 -9
View File
@@ -1195,19 +1195,54 @@ class GuideEditor {
defaultFormat: 'pdf', defaultFormat: 'pdf',
defaultOutDir: settings.exports?.lastOutputDirs?.pdf || '', defaultOutDir: settings.exports?.lastOutputDirs?.pdf || '',
onChooseDir: async (format) => api.export.chooseDir({ format }), onChooseDir: async (format) => api.export.chooseDir({ format }),
onPreview: async ({ format, templateName, outDir }) => { onLoadDefaults: async (format) => api.export.defaults({ format }),
const options = templateName ? await api.templates.load({ format, name: templateName }) : {}; onLoadTemplate: async (format, name) => api.templates.load({ format, name }),
onSaveTemplate: async (format, name, options) => {
await api.templates.save({ format, name, options });
this.onToast(`Template “${name}” saved.`);
},
onManageTemplates: async (format, mode) => {
if (mode === 'manage') {
await dialogs.showTemplateManager({
format,
names: await api.templates.list({ format }),
onRename: async (name, newName) => {
await api.templates.rename({ format, name, newName });
return api.templates.list({ format });
},
onDuplicate: async (name) => {
await api.templates.duplicate({ format, name });
return api.templates.list({ format });
},
onDelete: async (name) => {
await api.templates.delete({ format, name });
return api.templates.list({ format });
},
onImport: async () => {
const res = await api.templates.import();
if (res?.ok) this.onToast(`Imported template for ${res.format}.`);
return api.templates.list({ format });
},
onExport: async (name) => {
const res = await api.templates.export({ format, name });
if (res?.ok) this.onToast('Template shared as .sfglt.');
},
});
}
return api.templates.list({ format });
},
onPreview: async ({ format, options }) => {
const preview = await api.export.preview({ guideId: this.guideId, format, options }); const preview = await api.export.preview({ guideId: this.guideId, format, options });
if (preview && preview.file) await api.shell.showItemInFolder({ target: preview.file }); if (preview && preview.file) {
this.onToast(`Preview written to ${preview.file}`); await api.shell.openPath({ target: preview.file }); // open in default viewer
this.onToast('Preview opened (first steps only).');
}
return true; return true;
}, },
onExport: async ({ format, templateName, outDir }) => { onExport: async ({ format, options, outDir }) => {
const options = templateName ? await api.templates.load({ format, name: templateName }) : {};
const result = await api.export.run({ guideId: this.guideId, format, options, outDir }); const result = await api.export.run({ guideId: this.guideId, format, options, outDir });
if (result && result.file) { if (result && result.ok === false) return false;
this.onToast(`Exported ${format}`); if (result && result.file) this.onToast(`Exported ${format}.`);
}
return true; return true;
}, },
}); });
+25
View File
@@ -720,3 +720,28 @@ fieldset legend {
.welcome-btn.primary .welcome-btn-hint { color: var(--accent-fg); } .welcome-btn.primary .welcome-btn-hint { color: var(--accent-fg); }
.welcome-btn-label { font-size: 16px; font-weight: 650; } .welcome-btn-label { font-size: 16px; font-weight: 650; }
.welcome-btn-hint { font-size: 12px; color: var(--muted); } .welcome-btn-hint { font-size: 12px; color: var(--muted); }
/* ---------- editor additions: blocks, focused view, export options ---------- */
.spacer { flex: 1; }
.focused-controls {
margin-top: 6px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel-2);
display: flex;
flex-direction: column;
gap: 4px;
}
.focused-controls .form-row label { width: 50px; flex: none; color: var(--muted); font-size: 12px; }
.focused-controls input[type="range"] { flex: 1; }
.blocks-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
.blocks-list .block-card textarea,
.blocks-list .block-card input { width: 100%; }
.blocks-list .block-card { gap: 6px; }
.export-options { display: flex; flex-direction: column; gap: 6px; padding: 4px 2px; max-height: 260px; overflow-y: auto; }
.placeholder-row { display: flex; gap: 8px; margin-bottom: 6px; }
.placeholder-row input { flex: 1; }
.card-list { display: flex; flex-direction: column; gap: 8px; }
.card-list .row { align-items: center; }
.row { display: flex; gap: 8px; align-items: center; }
+24 -24
View File
@@ -71,73 +71,73 @@ half-done): capture-service fixes and editor additions listed in sections
OS-clipboard image -> new step), Ctrl+Delete delete step, OS-clipboard image -> new step), Ctrl+Delete delete step,
Shift+arrows = 10px nudge. Shift+arrows = 10px nudge.
### 3. Dialogs (app/renderer/dialogs.js) — add and export via ### 3. Dialogs (app/renderer/dialogs.js) — [x] DONE — add and export via
`window.StepForgeDialogs`: `window.StepForgeDialogs`:
- [ ] `showBackupsDialog({snapshots, onCreate, onRestore})` — list of - [x] `showBackupsDialog({snapshots, onCreate, onRestore})` — list of
snapshot names with a Restore button each, "Create snapshot" button snapshot names with a Restore button each, "Create snapshot" button
on top (onCreate returns the refreshed list; re-render it). on top (onCreate returns the refreshed list; re-render it).
- [ ] `showPlaceholdersDialog({title, hint, values, onSave})` — key/value - [x] `showPlaceholdersDialog({title, hint, values, onSave})` — key/value
rows with add/remove, same pattern as the placeholder rows already rows with add/remove, same pattern as the placeholder rows already
inside `showSettingsDialog` (copy that code). inside `showSettingsDialog` (copy that code).
- [ ] `showShortcutsDialog()` — static table of the shortcuts from - [x] `showShortcutsDialog()` — static table of the shortcuts from
section 2 plus Ctrl+S save, Ctrl+/ quick actions, Alt+arrows move step. section 2 plus Ctrl+S save, Ctrl+/ quick actions, Alt+arrows move step.
- [ ] Extend `showExportDialog`: a "Save as template…" button - [x] Extend `showExportDialog`: a "Save as template…" button
(prompts a name, calls new `onSaveTemplate({format, name})`), and a (prompts a name, calls new `onSaveTemplate({format, name})`), and a
"Manage…" button listing templates with rename/duplicate/delete/ "Manage…" button listing templates with rename/duplicate/delete/
import (.sfglt)/export (use `api.templates.*`, all already exist in import (.sfglt)/export (use `api.templates.*`, all already exist in
preload). preload).
### 4. Topbar rework (app/renderer/app.js, editor branch of `renderTopbar`) ### 4. Topbar rework — [x] DONE — (app/renderer/app.js, editor branch of `renderTopbar`)
- [ ] Buttons: Back | **Capture** (primary; onClick - [x] Buttons: Back | **Capture** (primary; onClick
`this.editor.openCaptureMenu(e)`) | Save | Export | Share `this.editor.openCaptureMenu(e)`) | Save | Export | Share
(`this.editor.shareAsFile()`) | More ▾ | guide title text. (`this.editor.shareAsFile()`) | More ▾ | guide title text.
- [ ] "More ▾" opens `contextMenu` with: Rename guide / Guide - [x] "More ▾" opens `contextMenu` with: Rename guide / Guide
placeholders… / Backups & snapshots… / Linked guide… / Keyboard placeholders… / Backups & snapshots… / Linked guide… / Keyboard
shortcuts… / Settings. shortcuts… / Settings.
- [ ] Remove the old Rename/Local/Quick/Settings buttons from the topbar - [x] Remove the old Rename/Local/Quick/Settings buttons from the topbar
(they move into More; Quick actions stays reachable via Ctrl+/). (they move into More; Quick actions stays reachable via Ctrl+/).
### 5. Main process additions (app/main.js + app/preload.js) ### 5. Main process additions — [x] DONE — (app/main.js + app/preload.js)
- [ ] `export:preview` flow: after writing the preview, the renderer - [x] `export:preview` flow: after writing the preview, the renderer
should call a new `shell.openPath` on the produced file so PDF/GIF should call a new `shell.openPath` on the produced file so PDF/GIF
previews actually open (change `onPreview` in previews actually open (change `onPreview` in
`editor.openExportDialog` to call `api.shell.openPath({target: preview.file})`). `editor.openExportDialog` to call `api.shell.openPath({target: preview.file})`).
- [ ] New IPC `export:defaults {format}` returning the exporter's - [x] New IPC `export:defaults {format}` returning the exporter's
DEFAULT_TEMPLATE (require the exporter module, read its export) so DEFAULT_TEMPLATE (require the exporter module, read its export) so
the export dialog can show editable options. Wire into preload as the export dialog can show editable options. Wire into preload as
`api.export.defaults`. `api.export.defaults`.
- [ ] Optional (only if simple): render checkboxes/number/text inputs in - [x] Optional (only if simple): render checkboxes/number/text inputs in
the export dialog from the defaults object (booleans -> checkbox, the export dialog from the defaults object (booleans -> checkbox,
numbers -> number input, strings -> text input), pass the edited numbers -> number input, strings -> text input), pass the edited
object as `options` to export/preview/save-as-template. object as `options` to export/preview/save-as-template.
### 6. CSS (app/renderer/style.css) ### 6. CSS (app/renderer/style.css) — [x] DONE
- [ ] Ensure `.spacer { flex: 1; }` exists (block cards use it). - [x] Ensure `.spacer { flex: 1; }` exists (block cards use it).
- [ ] Style `.focused-controls`, `.blocks-list .block-card textarea` - [x] Style `.focused-controls`, `.blocks-list .block-card textarea`
(full width), keep visual language consistent (existing vars: (full width), keep visual language consistent (existing vars:
`--panel`, `--panel-2`, `--border`, `--accent`, `--radius`). `--panel`, `--panel-2`, `--border`, `--accent`, `--radius`).
### 7. Verification tour + tests ### 7. Verification tour + tests
- [ ] Screenshot tour: welcome, library, editor (with blocks panel - [x] Screenshot tour: welcome, library, editor (with blocks panel
visible), capture menu open, export dialog, backups dialog. Check visible), capture menu open, export dialog, backups dialog. Check
each PNG looks right; fix what doesn't. each PNG looks right; fix what doesn't.
- [ ] Add a unit test `tests/unit/ipc-surface.test.js` that requires - [x] Add a unit test `tests/unit/ipc-surface.test.js` that requires
`app/preload.js` is impossible (electron); instead statically check: `app/preload.js` is impossible (electron); instead statically check:
every `ipcRenderer.invoke('X')` channel string in preload.js has a every `ipcRenderer.invoke('X')` channel string in preload.js has a
matching `h('X'` handler string in main.js (read both files with fs, matching `h('X'` handler string in main.js (read both files with fs,
regex out the channel names, assert set equality or subset). regex out the channel names, assert set equality or subset).
- [ ] `bash tests/run_test.sh` green; `bash scripts/verify.sh` green. - [x] `bash tests/run_test.sh` green; `bash scripts/verify.sh` green.
- [ ] Regenerate samples if exporter behavior changed - [x] Regenerate samples if exporter behavior changed (not needed — exporter behavior unchanged)
(`node scripts/make-sample-guide.js`), commit changes. (`node scripts/make-sample-guide.js`), commit changes.
### 8. Docs + final commit ### 8. Docs + final commit
- [ ] Update CHANGELOG.md (### Added: capture menu, block editors, - [x] Update CHANGELOG.md (### Added: capture menu, block editors,
focused-view controls, shortcuts, backups dialog, template focused-view controls, shortcuts, backups dialog, template
management, apply-style-across; ### Fixed: window-capture fallback, management, apply-style-across; ### Fixed: window-capture fallback,
app hides itself during capture). app hides itself during capture).
- [ ] README: mention the capture button and shortcut list location. - [x] README: mention the capture button and shortcut list location.
- [ ] Update THIS file: tick every box you completed. - [x] Update THIS file: tick every box you completed.
- [ ] Final commit. - [x] Final commit.
## Testing philosophy (from prompt.md — do not violate) ## Testing philosophy (from prompt.md — do not violate)
+77
View File
@@ -0,0 +1,77 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
/**
* Wiring guard: every IPC channel the renderer can invoke through the
* preload bridge must have a handler registered in the main process, and
* renderer code must only call APIs the bridge actually exposes. This
* compares extracted channel/identifier sets — it exercises the real
* contract between the three layers rather than matching arbitrary text.
*/
const ROOT = path.resolve(__dirname, '..', '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
function invokeChannels(src) {
return new Set([...src.matchAll(/invoke\('([^']+)'\)/g)].map((m) => m[1]));
}
function handledChannels(src) {
return new Set([...src.matchAll(/\bh\('([^']+)'/g)].map((m) => m[1]));
}
test('every preload invoke channel has a main-process handler', () => {
const preload = invokeChannels(read('app/preload.js'));
const handlers = handledChannels(read('app/main.js'));
assert.ok(preload.size >= 30, `expected a substantial API surface, got ${preload.size}`);
const missing = [...preload].filter((ch) => !handlers.has(ch));
assert.deepEqual(missing, [], `preload channels without handlers: ${missing.join(', ')}`);
});
test('renderer api.* usage stays within the preload surface', () => {
// Build the exposed api shape from preload.js: top-level groups and members.
const preloadSrc = read('app/preload.js');
const apiBody = preloadSrc.slice(preloadSrc.indexOf('const api = {'));
const groups = new Map();
let currentGroup = null;
for (const line of apiBody.split('\n')) {
const g = /^ (\w+): \{/.exec(line);
if (g) { currentGroup = g[1]; groups.set(currentGroup, new Set()); continue; }
const member = /^ (\w+):/.exec(line);
if (member && currentGroup) groups.get(currentGroup).add(member[1]);
if (/^ \},/.test(line)) currentGroup = null;
}
assert.ok(groups.size >= 10, 'preload should expose multiple API groups');
// Every api.<group>.<member>( call in renderer code must exist.
const offenders = [];
for (const file of ['app.js', 'editor.js', 'dialogs.js']) {
const src = read(`app/renderer/${file}`);
for (const m of src.matchAll(/\bapi\.(\w+)\.(\w+)\(/g)) {
const [, group, member] = m;
if (!groups.has(group) || !groups.get(group).has(member)) {
offenders.push(`${file}: api.${group}.${member}`);
}
}
}
assert.deepEqual(offenders, [], `renderer calls missing from preload: ${offenders.join(', ')}`);
});
test('renderer dialogs.* usage matches the StepForgeDialogs export', () => {
const dialogsSrc = read('app/renderer/dialogs.js');
const exportBlock = /window\.StepForgeDialogs = \{([\s\S]*?)\};/.exec(dialogsSrc)[1];
const exported = new Set([...exportBlock.matchAll(/(\w+),/g)].map((m) => m[1]));
const offenders = [];
for (const file of ['app.js', 'editor.js']) {
const src = read(`app/renderer/${file}`);
for (const m of src.matchAll(/\bdialogs\.(\w+)\(/g)) {
if (!exported.has(m[1])) offenders.push(`${file}: dialogs.${m[1]}`);
}
}
assert.deepEqual(offenders, [], `dialog calls missing from export: ${offenders.join(', ')}`);
});