From 6e790832f558ff43738ded509c71ad9119a025b3 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Wed, 10 Jun 2026 22:15:15 -0500 Subject: [PATCH] Complete the app: capture UI, dialogs, template manager, shortcuts help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 20 +++ README.md | 8 +- app/main.js | 17 ++ app/preload.js | 1 + app/renderer/app.js | 26 ++- app/renderer/dialogs.js | 305 ++++++++++++++++++++++++++++++--- app/renderer/editor.js | 53 +++++- app/renderer/style.css | 25 +++ prompt2.md | 48 +++--- tests/unit/ipc-surface.test.js | 77 +++++++++ 10 files changed, 520 insertions(+), 60 deletions(-) create mode 100644 tests/unit/ipc-surface.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f2aec59..b2b538f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,26 @@ Initial release. (creates a guide, opens the editor, and starts a capture session), 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. +- 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 diff --git a/README.md b/README.md index 51d713a..0896e7c 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,11 @@ The core workflow: - **Guide library** with folders, favorites, title search, full-text search, duplicate/move/delete, and a quick-actions palette (`Ctrl+/`). -- **Capture engine** — full screen, active window, and region capture with - delay, pause/resume, hotkeys, click markers, clipboard paste, and PNG/JPEG/ - GIF import. +- **Capture engine** — the editor's **Capture ▾** button offers full screen, + active window, and region capture (the app hides itself during the shot) + 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 (todo/in-progress/done), hidden/skipped steps, focused view (zoom/pan that never mutates the original image), autosave, and command-stack undo/redo. diff --git a/app/main.js b/app/main.js index c2bdc7f..7aa547d 100644 --- a/app/main.js +++ b/app/main.js @@ -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) { diff --git a/app/preload.js b/app/preload.js index 47d179c..022f089 100644 --- a/app/preload.js +++ b/app/preload.js @@ -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'), diff --git a/app/renderer/app.js b/app/renderer/app.js index 9b112b6..31f7302 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -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` : ''), ); } diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 19399cc..f037f5d 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -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, }; })(); diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 4bf72c5..3f4e48d 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -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; }, }); diff --git a/app/renderer/style.css b/app/renderer/style.css index 4db7a8e..961ea64 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -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; } diff --git a/prompt2.md b/prompt2.md index 23abcfa..4b00b10 100644 --- a/prompt2.md +++ b/prompt2.md @@ -71,73 +71,73 @@ half-done): capture-service fixes and editor additions listed in sections OS-clipboard image -> new step), Ctrl+Delete delete step, 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`: -- [ ] `showBackupsDialog({snapshots, onCreate, onRestore})` — list of +- [x] `showBackupsDialog({snapshots, onCreate, onRestore})` — list of snapshot names with a Restore button each, "Create snapshot" button 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 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. -- [ ] 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 "Manage…" button listing templates with rename/duplicate/delete/ import (.sfglt)/export (use `api.templates.*`, all already exist in preload). -### 4. Topbar rework (app/renderer/app.js, editor branch of `renderTopbar`) -- [ ] Buttons: Back | **Capture** (primary; onClick +### 4. Topbar rework — [x] DONE — (app/renderer/app.js, editor branch of `renderTopbar`) +- [x] Buttons: Back | **Capture** (primary; onClick `this.editor.openCaptureMenu(e)`) | Save | Export | Share (`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 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+/). -### 5. Main process additions (app/main.js + app/preload.js) -- [ ] `export:preview` flow: after writing the preview, the renderer +### 5. Main process additions — [x] DONE — (app/main.js + app/preload.js) +- [x] `export:preview` flow: after writing the preview, the renderer should call a new `shell.openPath` on the produced file so PDF/GIF previews actually open (change `onPreview` in `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 the export dialog can show editable options. Wire into preload as `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, numbers -> number input, strings -> text input), pass the edited object as `options` to export/preview/save-as-template. -### 6. CSS (app/renderer/style.css) -- [ ] Ensure `.spacer { flex: 1; }` exists (block cards use it). -- [ ] Style `.focused-controls`, `.blocks-list .block-card textarea` +### 6. CSS (app/renderer/style.css) — [x] DONE +- [x] Ensure `.spacer { flex: 1; }` exists (block cards use it). +- [x] Style `.focused-controls`, `.blocks-list .block-card textarea` (full width), keep visual language consistent (existing vars: `--panel`, `--panel-2`, `--border`, `--accent`, `--radius`). ### 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 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: every `ipcRenderer.invoke('X')` channel string in preload.js has a matching `h('X'` handler string in main.js (read both files with fs, regex out the channel names, assert set equality or subset). -- [ ] `bash tests/run_test.sh` green; `bash scripts/verify.sh` green. -- [ ] Regenerate samples if exporter behavior changed +- [x] `bash tests/run_test.sh` green; `bash scripts/verify.sh` green. +- [x] Regenerate samples if exporter behavior changed (not needed — exporter behavior unchanged) (`node scripts/make-sample-guide.js`), commit changes. ### 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 management, apply-style-across; ### Fixed: window-capture fallback, app hides itself during capture). -- [ ] README: mention the capture button and shortcut list location. -- [ ] Update THIS file: tick every box you completed. -- [ ] Final commit. +- [x] README: mention the capture button and shortcut list location. +- [x] Update THIS file: tick every box you completed. +- [x] Final commit. ## Testing philosophy (from prompt.md — do not violate) diff --git a/tests/unit/ipc-surface.test.js b/tests/unit/ipc-surface.test.js new file mode 100644 index 0000000..1a67b4e --- /dev/null +++ b/tests/unit/ipc-surface.test.js @@ -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..( 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(', ')}`); +});