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'); +});