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:
parent
125bfb02b2
commit
97b6f5406e
2 changed files with 261 additions and 0 deletions
|
|
@ -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, '"') + '"');
|
||||
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') {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue