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
+17
View File
@@ -348,6 +348,23 @@ function setupIpc() {
// export + preview
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 }) => {
let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`);
if (!dir) {
+1
View File
@@ -81,6 +81,7 @@ const api = {
},
export: {
formats: invoke('export:formats'),
defaults: invoke('export:defaults'),
run: invoke('export:run'),
chooseDir: invoke('export:chooseDir'),
preview: invoke('export:preview'),
+22 -4
View File
@@ -265,12 +265,30 @@ class StepForgeApp {
const guide = this.editorMeta?.guide;
this.topbarContext.append(
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.openExportDialog() }, 'Export'),
el('button', { type: 'button', onClick: () => this.editor.openLinkedGuide() }, guide && guide.linkedSource ? 'Linked' : 'Local'),
el('button', { type: 'button', onClick: () => this.editor.openQuickActions() }, 'Quick'),
el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'),
el('button', { type: 'button', title: 'Share this guide as a .sfgz file', onClick: () => this.editor.shareAsFile() }, 'Share'),
el('button', {
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` : ''),
);
}
+285 -20
View File
@@ -296,6 +296,10 @@ function showExportDialog({
onChooseDir,
onExport,
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) => {
const formatOptions = (formats || []).map((f) => {
@@ -305,21 +309,91 @@ function showExportDialog({
const formatSelect = makeSelect(defaultFormat, formatOptions);
const templateSelect = makeSelect('', [{ value: '', label: 'Default template' }]);
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() {
const list = templatesByFormat[formatSelect.value] || [];
// The effective option set shown to (and edited by) the user.
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);
templateSelect.append(el('option', { value: '' }, 'Default template'));
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();
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', {},
labeledRow('Format', formatSelect),
labeledRow('Template', templateSelect),
labeledRow('Template', el('div.row', {}, templateSelect, saveTplBtn, manageBtn)),
labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', {
type: 'button',
disabled: typeof onChooseDir !== 'function',
@@ -329,9 +403,16 @@ function showExportDialog({
if (chosen) outDirInput.value = chosen;
},
}, '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({
title: 'Export',
body,
@@ -340,25 +421,13 @@ function showExportDialog({
el('button', {
onClick: async () => {
if (typeof onPreview !== 'function') return;
const ok = await onPreview({
format: formatSelect.value,
templateName: templateSelect.value || null,
outDir: outDirInput.value.trim() || null,
});
if (ok !== false) {
close();
resolve(true);
}
await onPreview(payload()); // keep dialog open so settings can be tweaked
},
}, 'Preview'),
el('button.primary', {
onClick: async () => {
if (typeof onExport !== 'function') return;
const ok = await onExport({
format: formatSelect.value,
templateName: templateSelect.value || null,
outDir: outDirInput.value.trim() || null,
});
const ok = await onExport(payload());
if (ok !== false) {
close();
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 } = {}) {
return new Promise((resolve) => {
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) {
return new Promise((resolve) => {
const { close } = openModal({
@@ -439,5 +700,9 @@ window.StepForgeDialogs = {
showExportDialog,
showLinkedGuideDialog,
showInfoDialog,
showBackupsDialog,
showPlaceholdersDialog,
showShortcutsDialog,
showTemplateManager,
};
})();
+44 -9
View File
@@ -1195,19 +1195,54 @@ class GuideEditor {
defaultFormat: 'pdf',
defaultOutDir: settings.exports?.lastOutputDirs?.pdf || '',
onChooseDir: async (format) => api.export.chooseDir({ format }),
onPreview: async ({ format, templateName, outDir }) => {
const options = templateName ? await api.templates.load({ format, name: templateName }) : {};
onLoadDefaults: async (format) => api.export.defaults({ format }),
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 });
if (preview && preview.file) await api.shell.showItemInFolder({ target: preview.file });
this.onToast(`Preview written to ${preview.file}`);
if (preview && preview.file) {
await api.shell.openPath({ target: preview.file }); // open in default viewer
this.onToast('Preview opened (first steps only).');
}
return true;
},
onExport: async ({ format, templateName, outDir }) => {
const options = templateName ? await api.templates.load({ format, name: templateName }) : {};
onExport: async ({ format, options, outDir }) => {
const result = await api.export.run({ guideId: this.guideId, format, options, outDir });
if (result && result.file) {
this.onToast(`Exported ${format}`);
}
if (result && result.ok === false) return false;
if (result && result.file) this.onToast(`Exported ${format}.`);
return true;
},
});
+25
View File
@@ -720,3 +720,28 @@ fieldset legend {
.welcome-btn.primary .welcome-btn-hint { color: var(--accent-fg); }
.welcome-btn-label { font-size: 16px; font-weight: 650; }
.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; }