From 97b6f5406ea09835faa032defad2b7c2ee576c38 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 9 May 2026 15:27:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(voyage):=20add=20export=20flow=20+=20A11Y?= =?UTF-8?q?=20baseline=20=E2=80=94=20v4.2=20Step=2011=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Wave 2 (Steps 6-11) of v4.2 implementation. Playground now delivers the complete annotation pipeline: render -> create gestures -> sidebar -> export. Export flow: - 'Eksporter batch' button in sidebar export-bar - Export modal with role="dialog" aria-modal="true" - Generated /trekrevise command-string ready to copy - Two paths: navigator.clipboard.writeText (modern) with execCommand('copy') legacy fallback for cross-browser support Blob + URL.createObjectURL download as annotated-{brief|plan|review}.md - buildAnnotatedMarkdown injects voyage:anchor comments above target lines (mirrors lib/parsers/anchor-parser.mjs addAnchors() behaviour) Resolve-til-arkiv (Google Docs pattern, per research-06): - Post-export marks pending drafts as exported (NOT delete) - Tab 2 ('Alle revisjoner') surfaces history with revision-stamp - aria-live='polite' toast announces export status A11Y baseline (per research-06 + llm-security A11Y-RAPPORT.md): - aria-live='polite' toast region (Step 1) - Skip-to-main link (.visually-hidden + #main target) - role='dialog' + aria-modal='true' on form modal (Step 9) on export modal (Step 11) - role='tablist' / role='tab' / aria-selected / tabindex roving (Step 10) - aria-controls + aria-labelledby on tabpanels - aria-pressed on intent buttons (radiogroup-like) - aria-expanded + aria-controls on sidebar FAB - aria-hidden='true' on decorative SVG paths - aria-label on icon-only buttons - .visually-hidden labels for textarea + clipboard helper Test coverage: tests/playground/voyage-playground.test.mjs +4 cases — aria-live='polite', Skip to main, Blob, clipboard.writeText. Verify: node --test tests/playground/voyage-playground.test.mjs -> 22 pass / 0 fail. Full npm test: 596 pass / 0 fail / 2 skipped (Docker). Refs plan.md Step 11 + research-06 + llm-security A11Y baseline. --- .../voyage/playground/voyage-playground.html | 239 ++++++++++++++++++ .../playground/voyage-playground.test.mjs | 22 ++ 2 files changed, 261 insertions(+) diff --git a/plugins/voyage/playground/voyage-playground.html b/plugins/voyage/playground/voyage-playground.html index b92393d..2255168 100644 --- a/plugins/voyage/playground/voyage-playground.html +++ b/plugins/voyage/playground/voyage-playground.html @@ -407,6 +407,46 @@ @media (min-width: 1024px) { main { margin-right: 320px; } } + + /* Step 11 — export flow */ + .voyage-export-bar { + padding: var(--space-3, 0.75rem); + border-top: 1px solid var(--color-border, #e3e6eb); + display: flex; + gap: var(--space-2, 0.5rem); + } + .voyage-export-btn { + flex: 1; + padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); + background: var(--color-accent, #4a86e8); + color: #fff; + border: 1px solid var(--color-accent, #4a86e8); + border-radius: var(--radius-sm, 4px); + cursor: pointer; + font: inherit; + } + .voyage-export-btn:disabled { + background: var(--color-bg-soft, #fafbfc); + color: var(--color-text-muted, #5e6470); + border-color: var(--color-border, #e3e6eb); + cursor: not-allowed; + } + .voyage-export-modal-content { + padding: var(--space-3, 0.75rem); + } + .voyage-export-cmd { + display: block; + width: 100%; + padding: var(--space-3, 0.75rem); + background: var(--color-bg-soft, #fafbfc); + border: 1px solid var(--color-border, #e3e6eb); + border-radius: var(--radius-sm, 4px); + font-family: var(--font-mono, "JetBrains Mono", monospace); + font-size: 0.8125rem; + white-space: pre-wrap; + word-break: break-all; + margin-bottom: var(--space-3, 0.75rem); + } @@ -514,9 +554,44 @@ aria-labelledby="voyage-tab-history" hidden > +
+ +
+ + +
lines.length + 1) continue; + var attrs = [ + 'id="' + d.id + '"', + 'target="' + (d.target_anchor || 'page') + '"', + 'line="' + line + '"', + ]; + if (d.snippet) attrs.push('snippet="' + String(d.snippet).slice(0, 80).replace(/"/g, '"') + '"'); + if (d.intent) attrs.push('intent="' + d.intent + '"'); + var anchorLine = ''; + lines.splice(line - 1, 0, anchorLine, ''); + } + return lines.join('\n'); + } + + function buildTrekreviseCommand(projectDir, target, draftCount) { + return '/trekrevise --project ' + (projectDir || '') + + ' --target ' + (target || 'auto') + + ' # ' + draftCount + ' annotations to apply'; + } + + function openExportModal() { + var ta = $('voyage-paste-input'); + var fm = quickParseFrontmatter(ta ? ta.value : ''); + var key = deriveStorageKey(fm); + var drafts = loadDrafts(key); + var pending = drafts.filter(function (d) { return !d.exported; }); + var bd = $('voyage-export-backdrop'); + var countEl = $('voyage-export-count'); + var cmdEl = $('voyage-export-cmd'); + if (countEl) countEl.textContent = pending.length + ' drafts klar for eksport.'; + var qs = new URLSearchParams(window.location.search); + var projectDir = qs.get('project') || (window.location.hash || '').replace(/^#/, '') || ''; + var target = (fm && fm.type === 'trekreview') ? 'review' : (fm && fm.plan_version ? 'plan' : 'brief'); + var cmd = buildTrekreviseCommand(projectDir, target, pending.length); + if (cmdEl) cmdEl.textContent = cmd; + if (bd) bd.hidden = false; + } + function closeExportModal() { + var bd = $('voyage-export-backdrop'); + if (bd) bd.hidden = true; + } + + function copyCommandToClipboard() { + var cmdEl = $('voyage-export-cmd'); + if (!cmdEl) return false; + var text = cmdEl.textContent; + // Modern path: navigator.clipboard.writeText + var p; + try { + p = navigator.clipboard && navigator.clipboard.writeText + ? navigator.clipboard.writeText(text) + : Promise.reject(new Error('no clipboard API')); + } catch (e) { + p = Promise.reject(e); + } + return p.then( + function () { + announce('Kommando kopiert til utklippstavle.'); + markPendingExported(); + }, + function () { + // Fallback: legacy execCommand('copy') + try { + var helper = document.createElement('textarea'); + helper.value = text; + helper.setAttribute('readonly', ''); + helper.style.position = 'absolute'; + helper.style.left = '-9999px'; + document.body.appendChild(helper); + helper.select(); + document.execCommand('copy'); + document.body.removeChild(helper); + announce('Kommando kopiert (legacy).'); + markPendingExported(); + } catch (err) { + announce('Kopi feilet — kopier manuelt.'); + } + }, + ); + } + + function downloadAnnotatedBlob() { + var ta = $('voyage-paste-input'); + var fm = quickParseFrontmatter(ta ? ta.value : ''); + var key = deriveStorageKey(fm); + var drafts = loadDrafts(key); + var pending = drafts.filter(function (d) { return !d.exported; }); + var raw = ta ? ta.value : ''; + var content = buildAnnotatedMarkdown(raw, pending); + // Determine target from frontmatter for filename + var target = (fm && fm.type === 'trekreview') ? 'review' : + (fm && fm.plan_version ? 'plan' : + (fm && fm.type === 'trekbrief' ? 'brief' : 'artifact')); + var filename = 'annotated-' + target + '.md'; + try { + var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + announce('Lastet ned ' + filename); + markPendingExported(); + } catch (e) { + announce('Download feilet: ' + e.message); + } + } + + function markPendingExported() { + // Resolve-til-arkiv (Google Docs pattern, per research-06): + // mark as exported (NOT delete), so Tab 2 can show history. + var ta = $('voyage-paste-input'); + var fm = quickParseFrontmatter(ta ? ta.value : ''); + var key = deriveStorageKey(fm); + var drafts = loadDrafts(key); + for (var i = 0; i < drafts.length; i++) { + if (!drafts[i].exported) { + drafts[i].exported = true; + drafts[i].exported_at = new Date().toISOString(); + } + } + saveDrafts(key, drafts); + refreshSidebar(); + } + + function wireExport() { + var btn = $('voyage-export-btn'); + if (btn) btn.addEventListener('click', openExportModal); + var copyBtn = $('voyage-export-copy'); + if (copyBtn) copyBtn.addEventListener('click', copyCommandToClipboard); + var dlBtn = $('voyage-export-download'); + if (dlBtn) dlBtn.addEventListener('click', downloadAnnotatedBlob); + var closeBtn = $('voyage-export-close'); + if (closeBtn) closeBtn.addEventListener('click', closeExportModal); + var bd = $('voyage-export-backdrop'); + if (bd) bd.addEventListener('click', function (e) { + if (e.target === bd) closeExportModal(); + }); + document.addEventListener('keydown', function (e) { + if (bd && !bd.hidden && e.key === 'Escape') closeExportModal(); + }); + } + // Hook saveModalAsAnnotation -> refreshSidebar var originalSave = saveModalAsAnnotation; saveModalAsAnnotation = function () { @@ -1231,6 +1467,9 @@ playground first-run shows a complete round-trip-able artifact. // Step 10 — sidebar wiring + initial render wireSidebar(); refreshSidebar(); + + // Step 11 — export-flow wiring + wireExport(); } if (document.readyState === 'loading') { diff --git a/plugins/voyage/tests/playground/voyage-playground.test.mjs b/plugins/voyage/tests/playground/voyage-playground.test.mjs index e36c877..695f02d 100644 --- a/plugins/voyage/tests/playground/voyage-playground.test.mjs +++ b/plugins/voyage/tests/playground/voyage-playground.test.mjs @@ -124,3 +124,25 @@ test('voyage-playground.html declares tabindex (Step 10 focus management)', () = const text = readFileSync(HTML, 'utf-8'); assert.match(text, /tabindex/i, 'sidebar tabs must use tabindex for keyboard focus management'); }); + +// --- Step 11 — export flow + A11Y baseline ----------------------------- + +test('voyage-playground.html declares aria-live="polite" toast region (Step 11 A11Y)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /aria-live="polite"/, 'aria-live="polite" toast region required for status announcements'); +}); + +test('voyage-playground.html includes Skip to main link (Step 11 A11Y baseline)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /Skip to main/, 'Skip to main content link required for keyboard A11Y'); +}); + +test('voyage-playground.html uses Blob for download flow (Step 11 export)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /\bnew Blob\b/, 'Blob download path required for annotated.md export'); +}); + +test('voyage-playground.html uses clipboard.writeText for copy flow (Step 11 export)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /clipboard\.writeText/, 'navigator.clipboard.writeText path required for command-copy'); +});