feat(voyage): add export flow + A11Y baseline — v4.2 Step 11 [skip-docs]

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.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-09 15:27:01 +02:00
commit 97b6f5406e
2 changed files with 261 additions and 0 deletions

View file

@ -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);
}
</style>
</head>
<body>
@ -514,9 +554,44 @@
aria-labelledby="voyage-tab-history"
hidden
></div>
<div class="voyage-export-bar">
<button
type="button"
id="voyage-export-btn"
class="voyage-export-btn"
aria-label="Eksporter ventende drafts som /trekrevise-kommando"
>Eksporter batch</button>
</div>
</div>
</aside>
<!-- Step 11 — export modal -->
<div
id="voyage-export-backdrop"
class="voyage-modal-backdrop"
hidden
>
<div
id="voyage-export-modal"
class="voyage-modal"
role="dialog"
aria-modal="true"
aria-labelledby="voyage-export-title"
>
<div id="voyage-export-title" class="voyage-modal__header">Eksporter annotations</div>
<div class="voyage-export-modal-content voyage-modal__body">
<p id="voyage-export-count">Ingen drafts å eksportere.</p>
<label for="voyage-export-cmd" class="visually-hidden">/trekrevise-kommando</label>
<code id="voyage-export-cmd" class="voyage-export-cmd"></code>
</div>
<div class="voyage-modal__footer">
<button type="button" id="voyage-export-copy">Kopier kommando</button>
<button type="button" id="voyage-export-download">Last ned annotated.md</button>
<button type="button" id="voyage-export-close">Lukk</button>
</div>
</div>
</div>
<!-- Step 9 — adder popup (Gesture 1) -->
<div
id="voyage-adder-popup"
@ -1183,6 +1258,167 @@ playground first-run shows a complete round-trip-able artifact.
});
}
// ---- Step 11 — export flow ----------------------------------------
function buildAnnotatedMarkdown(rawText, drafts) {
// Inject voyage:anchor comments above the body lines they reference,
// mirroring lib/parsers/anchor-parser.mjs addAnchors() behaviour.
// Operates on the raw paste-input text (not the rendered HTML) so
// line numbers correspond to the source artifact.
if (!Array.isArray(drafts) || drafts.length === 0) return rawText;
var lines = String(rawText).split('\n');
// Sort by line desc so earlier-line insertions don't shift later ones.
var sorted = drafts.slice().sort(function (a, b) {
return (Number(b.line) || 0) - (Number(a.line) || 0);
});
for (var i = 0; i < sorted.length; i++) {
var d = sorted[i];
var line = Number(d.line);
if (!line || line < 1 || line > 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, '&quot;') + '"');
if (d.intent) attrs.push('intent="' + d.intent + '"');
var anchorLine = '<!-- voyage:anchor ' + attrs.join(' ') + ' -->';
lines.splice(line - 1, 0, anchorLine, '');
}
return lines.join('\n');
}
function buildTrekreviseCommand(projectDir, target, draftCount) {
return '/trekrevise --project ' + (projectDir || '<project-dir>') +
' --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(/^#/, '') || '<project-dir>';
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') {

View file

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