ktg-plugin-marketplace/plugins/voyage/playground/voyage-playground.html

3103 lines
125 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="nb" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Voyage Annotation Playground v4.2</title>
<!-- Theme bootstrap. Må kjøre før stylesheets parses for å unngå
flash-of-wrong-theme (FOUC). Prioritet:
1) lagret valg (localStorage 'voyage-theme')
2) OS-preferanse via matchMedia('(prefers-color-scheme: dark)')
3) HTML-attributtets default ('dark')
Setter både data-theme + colorScheme for native form-controls/scrollbars.
Wrappes i try/catch — file:// + privatmodus kan blokkere localStorage. -->
<script>
(function () {
var theme = null;
try {
var saved = localStorage.getItem('voyage-theme');
if (saved === 'light' || saved === 'dark') theme = saved;
} catch (e) { /* localStorage utilgjengelig */ }
if (!theme && window.matchMedia) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (!theme) theme = document.documentElement.getAttribute('data-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
})();
</script>
<script>
// Voyage anchor parser — mirrored verbatim from lib-parsers anchor-parser.mjs.
// Pure I/O-free parser for v4.2 voyage:anchor markdown comments.
// Format reference: voyage anchor id ANN-NNNN target heading-slug line N snippet ≤80c intent fix change question block.
// Constants mirror Node-side ANCHOR_LINE_RE + ATTR_RE + ID_RE (anchor-parser.mjs:20-25). Inline keeps file COLON COLON scheme compat (no ES-module).
var VOYAGE_ANCHOR_RE = /^(\s*)<!--\s*voyage:anchor\s+([^>]+?)\s*-->\s*$/;
var VOYAGE_ANCHOR_ATTR_RE = /(\w+)="([^"]*)"/g;
var VOYAGE_ANCHOR_ID_RE = /^ANN-\d{4}$/;
var VOYAGE_ANCHOR_INTENTS = ['fix', 'change', 'question', 'block'];
function parseAnchor(line) {
if (typeof line !== 'string') return null;
var m = line.match(VOYAGE_ANCHOR_RE);
if (!m) return null;
var attrs = {};
VOYAGE_ANCHOR_ATTR_RE.lastIndex = 0;
var a;
while ((a = VOYAGE_ANCHOR_ATTR_RE.exec(m[2])) !== null) attrs[a[1]] = a[2];
if (!attrs.id || !VOYAGE_ANCHOR_ID_RE.test(attrs.id)) return null;
if (typeof attrs.target !== 'string' || attrs.target.length === 0) return null;
var lineNum = null;
if (attrs.line !== undefined) {
var n = parseInt(attrs.line, 10);
if (!Number.isInteger(n) || n <= 0) return null;
lineNum = n;
}
var snippet = attrs.snippet || null;
if (snippet !== null && snippet.length > 80) return null;
var intent = attrs.intent || null;
if (intent !== null && VOYAGE_ANCHOR_INTENTS.indexOf(intent) === -1) return null;
return { id: attrs.id, target: attrs.target, line: lineNum, snippet: snippet, intent: intent };
}
// Block-boundary fallback (Step 17). Pure markdown-text -> markdown-text transform.
// For each anchor whose line falls inside an atomic block (fenced code-block,
// table-row, or deeply-nested list), inject the anchor-comment at the line
// BEFORE block-opening rather than inside. Anchors outside atomic blocks
// inject at their original line. Mirrors addAnchors semantics from
// lib/parsers/anchor-parser.mjs but with block-boundary awareness.
function relocateAnchorsToBlockBoundaries(text, anchors) {
if (typeof text !== 'string') return text;
if (!Array.isArray(anchors) || anchors.length === 0) return text;
var lines = text.split(/\r?\n/);
var FENCED_RE = /^\s*```/;
var TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
var TABLE_SEP_RE = /^\s*\|[\s\-:|]+\|\s*$/;
var LIST_RE = /^(\s*)(?:[-*+]|\d+[.)])\s+/;
var atomicRanges = [];
var inFence = false;
var fenceStart = -1;
var inTable = false;
var tableStart = -1;
for (var i = 0; i < lines.length; i++) {
var ln = lines[i];
if (FENCED_RE.test(ln)) {
if (!inFence) { inFence = true; fenceStart = i + 1; }
else { atomicRanges.push({ start: fenceStart, end: i + 1 }); inFence = false; fenceStart = -1; }
continue;
}
if (inFence) continue;
if (!inTable) {
var nextLine = i + 1 < lines.length ? lines[i + 1] : '';
if (TABLE_ROW_RE.test(ln) && TABLE_SEP_RE.test(nextLine)) {
inTable = true;
tableStart = i + 1;
}
} else if (!TABLE_ROW_RE.test(ln) || ln.trim() === '') {
atomicRanges.push({ start: tableStart, end: i });
inTable = false;
tableStart = -1;
}
}
if (inTable) atomicRanges.push({ start: tableStart, end: lines.length });
if (inFence && fenceStart > 0) atomicRanges.push({ start: fenceStart, end: lines.length });
// Deeply-nested list-items (indent >= 4 spaces = depth >= 2 in CommonMark)
for (var j = 0; j < lines.length; j++) {
var lm = lines[j].match(LIST_RE);
if (lm && lm[1].length >= 4) {
var nestStart = j + 1;
var k = j;
while (k + 1 < lines.length) {
var nm = lines[k + 1].match(LIST_RE);
if (nm && nm[1].length >= 2) k++;
else break;
}
atomicRanges.push({ start: nestStart, end: k + 1 });
j = k;
}
}
function insertionLine(line) {
var n = Number(line);
if (!Number.isInteger(n) || n < 1) return n;
for (var r = 0; r < atomicRanges.length; r++) {
var range = atomicRanges[r];
if (n >= range.start && n <= range.end) {
return Math.max(1, range.start - 1);
}
}
return n;
}
var adjusted = anchors.map(function (a) {
var newLine = insertionLine(a.line);
return Object.assign({}, a, { line: newLine });
});
var sorted = adjusted.slice().sort(function (a, b) {
return (Number(b.line) || 0) - (Number(a.line) || 0);
});
for (var s = 0; s < sorted.length; s++) {
var d = sorted[s];
var dl = Number(d.line);
if (!dl || dl < 1 || dl > lines.length + 1) continue;
var attrParts = ['id="' + d.id + '"', 'target="' + (d.target || 'page') + '"', 'line="' + dl + '"'];
if (d.snippet) attrParts.push('snippet="' + String(d.snippet).slice(0, 80).replace(/"/g, '&quot;') + '"');
if (d.intent) attrParts.push('intent="' + d.intent + '"');
var anchorLine = '<!-- voyage:anchor ' + attrParts.join(' ') + ' -->';
lines.splice(dl - 1, 0, anchorLine, '');
}
return lines.join('\n');
}
</script>
<link rel="stylesheet" href="vendor/playground-design-system/fonts.css">
<link rel="stylesheet" href="vendor/playground-design-system/tokens.css">
<link rel="stylesheet" href="vendor/playground-design-system/base.css">
<link rel="stylesheet" href="vendor/playground-design-system/components.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier2.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3-supplement.css">
<link rel="stylesheet" href="vendor/playground-design-system/print.css" media="print">
<style>
/* Voyage-specific overrides — Step 8 (render pipeline) */
body { margin: 0; }
main {
max-width: 1280px;
margin: 0 auto;
padding: var(--space-6);
}
.voyage-skeleton-msg { padding: var(--space-6); }
/* v4.3 Step 10 — A11Y foundations
.visually-hidden: standard SR-only utility (clip-path technique).
.skip-link: hidden until keyboard focus, then surfaces in viewport. */
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
top: -40px;
left: var(--space-3);
background: var(--color-primary-500);
color: #fff;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
text-decoration: none;
z-index: 100;
transition: top 0.15s ease;
}
.skip-link:focus {
top: var(--space-3);
outline: 2px solid var(--color-text-primary);
outline-offset: 2px;
}
/* v4.3 Step 12 — drag-drop overlay (hidden until dragenter). */
.voyage-dropzone {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.voyage-dropzone[hidden] { display: none; }
.voyage-dropzone--active {
pointer-events: auto;
}
.voyage-dropzone__inner {
background: var(--color-surface);
color: var(--color-text-primary);
border: 2px dashed var(--color-primary-500);
border-radius: var(--radius-md);
padding: var(--space-6) var(--space-8, 32px);
text-align: center;
max-width: 520px;
}
.voyage-dropzone__title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--space-2);
}
.voyage-dropzone__hint {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* Render-pipeline layout (left 70% viewport / right 30% sidebar reserved
for Step 10). The viewport panel mounts the rendered markdown. */
.voyage-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
align-items: start;
}
.voyage-viewport {
min-height: 60vh;
padding: var(--space-4);
background: var(--color-bg-soft);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
overflow: auto;
}
.voyage-viewport pre {
overflow: auto;
padding: var(--space-3);
background: var(--color-bg-soft);
border-radius: var(--radius-sm);
}
.voyage-viewport details {
margin: var(--space-3) 0;
padding: var(--space-2) var(--space-3);
background: var(--color-bg-soft);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
}
.voyage-viewport details > summary {
cursor: pointer;
font-weight: 600;
}
/* Paste-import row (mirrors llm-security playground:81-82 pattern) */
.paste-import-row {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.paste-import-row textarea {
width: 100%;
min-height: 8rem;
padding: var(--space-3);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
resize: vertical;
}
.paste-import-row__actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.paste-import-row__actions button {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border-subtle);
background: var(--color-surface);
border-radius: var(--radius-sm);
cursor: pointer;
font: inherit;
}
.paste-import-row__actions button:hover {
background: var(--color-bg-soft);
}
/* Annotation visual markers — extended in Steps 9-11 */
.lint-annotation {
border-left: 3px solid var(--color-scope-voyage);
padding-left: var(--space-2);
margin-left: calc(var(--space-2) * -1);
}
/* Step 9 — annotation creation gestures + form modal */
/* Gesture 1 — text-anchored adder-popup (mouseup-debounce 200ms; 300ms grace) */
.voyage-adder-popup {
position: fixed;
z-index: 1000;
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
cursor: pointer;
font-size: var(--font-size-sm);
transition: opacity 200ms ease;
}
.voyage-adder-popup[hidden] { display: none; }
/* v4.3 Step 18 — numbered-badge gutter + yellow-tint highlight.
Replaces v4.2's pencil-icon hover-reveal (Gesture 2). Badge appears
only on already-anchored paragraphs; ordering numbers match sidebar
jumplist. Body-text never reflowed or recolored — only the gutter
element + tinting. Gesture 1 (text-select adder popup) and Gesture 3
(page-level note button) remain in place for new annotation creation. */
.voyage-viewport p,
.voyage-viewport li,
.voyage-viewport h1,
.voyage-viewport h2,
.voyage-viewport h3,
.voyage-viewport h4,
.voyage-viewport h5,
.voyage-viewport h6 {
position: relative;
}
.voyage-anchor-badge {
position: absolute;
left: -2.5rem;
top: 0.15rem;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
border-radius: 50%;
background: var(--color-scope-voyage);
color: #fff;
font-size: var(--font-size-xs, 0.75rem);
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
/* 2-3px accent stripe extending right from the badge into the gutter */
box-shadow: 0.25rem 0 0 0 var(--color-scope-voyage);
transition: opacity 150ms ease, transform 150ms ease, outline-color 150ms ease;
/* v4.3 Step 21 — two-opacity pattern: default = inactive (40%) */
opacity: 0.4;
}
.voyage-anchor-badge:hover {
transform: scale(1.1);
opacity: 0.85;
}
.voyage-anchor-badge:focus-visible {
outline: 2px solid var(--color-focus-ring, #4d90fe);
outline-offset: 2px;
}
/* v4.3 Step 21 — active badge (selected via J/K nav or click): full opacity
+ 2px outline-ring for the "border-width: 2px" intent without layout shift. */
.voyage-anchor-badge[data-active="true"] {
opacity: 1;
outline: 2px solid var(--color-focus-ring, #4d90fe);
outline-offset: 2px;
}
/* v4.3 Step 21 — resolved badge: 30% opacity + strikethrough on the
numeric label. Status-vocabulary is annotation-specific (data-resolved
on the draft), distinct from dashboard fleet-tile data-status. */
.voyage-anchor-badge[data-resolved="true"] {
opacity: 0.3;
text-decoration: line-through;
}
/* Yellow-tint highlight on the anchored span when annotation is active.
Google Docs pattern; rgba value taken from research/04 Dim 4. */
.voyage-anchor-active {
background: rgba(255, 235, 59, 0.25);
transition: background 150ms ease;
}
/* Gesture 3 — page-level note button (placeholder; sidebar is Step 10) */
.voyage-page-note-btn {
padding: var(--space-2) var(--space-4);
border: 1px dashed var(--color-border-subtle);
background: var(--color-surface);
border-radius: var(--radius-sm);
cursor: pointer;
font: inherit;
}
/* Form modal — role="dialog" aria-modal="true" */
.voyage-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1100;
display: flex;
align-items: center;
justify-content: flex-end;
}
.voyage-modal-backdrop[hidden] { display: none; }
.voyage-modal {
width: 400px;
max-width: 90vw;
max-height: 90vh;
margin: var(--space-6);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
}
.voyage-modal__header {
padding: var(--space-4);
border-bottom: 1px solid var(--color-border-subtle);
font-weight: 600;
}
.voyage-modal__body {
padding: var(--space-4);
overflow: auto;
flex: 1;
}
.voyage-modal__snippet {
padding: var(--space-2) var(--space-3);
background: var(--color-bg-soft);
border-left: 3px solid var(--color-scope-voyage);
font-style: italic;
font-size: var(--font-size-sm);
margin-bottom: var(--space-3);
}
.voyage-modal__intents {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.voyage-modal__intent-btn {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-subtle);
background: var(--color-surface);
border-radius: var(--radius-sm);
cursor: pointer;
font: inherit;
}
.voyage-modal__intent-btn[aria-pressed="true"] {
background: var(--color-scope-voyage);
color: #fff;
border-color: var(--color-scope-voyage);
}
.voyage-modal textarea {
width: 100%;
min-height: 6rem;
padding: var(--space-3);
font: inherit;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
resize: vertical;
}
.voyage-modal__footer {
display: flex;
gap: var(--space-2);
padding: var(--space-4);
border-top: 1px solid var(--color-border-subtle);
justify-content: flex-end;
}
.voyage-modal__footer button {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border-subtle);
background: var(--color-surface);
border-radius: var(--radius-sm);
cursor: pointer;
font: inherit;
}
.voyage-modal__footer button[type="submit"] {
background: var(--color-scope-voyage);
color: #fff;
border-color: var(--color-scope-voyage);
}
/* Step 10 — sidebar + critique-card-list + tabs */
.voyage-sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 320px;
background: var(--color-surface);
border-left: 1px solid var(--color-border-subtle);
box-shadow: -2px 0 8px rgba(0,0,0,0.05);
transform: translateX(0);
transition: transform 200ms ease;
z-index: 900;
display: flex;
flex-direction: column;
}
.voyage-sidebar[aria-hidden="true"] {
transform: translateX(calc(320px - 40px)); /* leave 40px icon-rail */
}
.voyage-sidebar__rail {
position: absolute;
left: 0;
top: 0;
width: 40px;
bottom: 0;
background: var(--color-bg-soft);
border-right: 1px solid var(--color-border-subtle);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: var(--space-3);
}
.voyage-sidebar__panel {
flex: 1;
margin-left: 40px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.voyage-sidebar[aria-hidden="true"] .voyage-sidebar__panel { visibility: hidden; }
/* 2-state FAB toggle (per critical decision #4).
v4.3 Step 3 — position: fixed so toggle stays reachable when the
sidebar carries aria-hidden="true" (finding 09132940 a11y).
The toggle is now a sibling of the <aside>, not a descendant,
so the aria-hidden subtree no longer hides the toggle from AT. */
.voyage-fab {
position: fixed;
top: var(--space-3);
right: 4px;
z-index: 901;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--color-border-subtle);
background: var(--color-surface);
border-radius: 50%;
cursor: pointer;
font-size: var(--font-size-md);
line-height: 30px;
text-align: center;
}
.voyage-fab[aria-expanded="true"]::before { content: ""; }
.voyage-fab[aria-expanded="false"]::before { content: ""; }
.voyage-fab__badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--color-scope-voyage);
color: #fff;
border-radius: 9px;
font-size: var(--font-size-xs);
line-height: 18px;
text-align: center;
}
.voyage-fab__badge[hidden] { display: none; }
/* v4.3 Step 19 — annotation-list (sidebar): filter buttons + ordered list + jumplist count */
.voyage-annotation-list {
display: flex;
flex-direction: column;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.voyage-annotation-list__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.voyage-annotation-list__filter {
display: inline-flex;
gap: var(--space-1);
}
.voyage-filter-btn {
padding: 0.15rem 0.5rem;
border: 1px solid var(--color-border-subtle);
background: var(--color-surface);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-xs);
}
.voyage-filter-btn[aria-pressed="true"] {
background: var(--color-scope-voyage);
color: #fff;
border-color: var(--color-scope-voyage);
}
.voyage-annotation-list__count {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary, var(--color-text-secondary));
white-space: nowrap;
}
.voyage-annotation-list__items {
list-style: none;
padding: 0;
margin: 0;
max-height: 30vh;
overflow-y: auto;
}
.voyage-annotation-list__items li {
padding: var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-sm);
display: flex;
align-items: center;
gap: var(--space-2);
/* v4.3 Step 21 — two-opacity pattern: default = inactive (40%).
Active sets full opacity + yellow tint (rule below); resolved
renders at 30% with strikethrough. */
opacity: 0.4;
transition: opacity 150ms ease, background 150ms ease;
}
.voyage-annotation-list__items li:hover {
background: var(--color-bg-soft);
opacity: 0.85;
}
.voyage-annotation-list__items li[data-active="true"] {
background: rgba(255, 235, 59, 0.18);
opacity: 1;
}
/* v4.3 Step 21 — resolved list-item mirrors badge: 30% opacity +
strikethrough on the label. data-resolved is set by renderAnnotationList
from draft.resolved. */
.voyage-annotation-list__items li[data-resolved="true"] {
opacity: 0.3;
}
.voyage-annotation-list__items li[data-resolved="true"] .voyage-jumplist-label {
text-decoration: line-through;
}
.voyage-annotation-list__items .voyage-jumplist-num {
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--color-scope-voyage);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Tabs */
[role="tablist"].voyage-tabs {
display: flex;
border-bottom: 1px solid var(--color-border-subtle);
}
[role="tab"].voyage-tab {
flex: 1;
padding: var(--space-3);
border: none;
background: transparent;
cursor: pointer;
font: inherit;
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
border-bottom: 2px solid transparent;
}
[role="tab"].voyage-tab[aria-selected="true"] {
color: var(--color-text-primary);
border-bottom-color: var(--color-scope-voyage);
font-weight: 600;
}
/* findings + critique-card list */
.voyage-findings {
flex: 1;
overflow: auto;
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.critique-card {
padding: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
}
.critique-card:hover { background: var(--color-bg-soft); }
.critique-card__header {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-xs);
}
.intent-badge {
display: inline-block;
padding: 2px 6px;
border-radius: var(--radius-sm);
color: #fff;
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
}
.intent-badge--fix { background: #2c8a3e; }
.intent-badge--change { background: #4a86e8; }
.intent-badge--question { background: #d8a23a; }
.intent-badge--block { background: #c54545; }
.critique-card__id {
font-family: var(--font-family-mono);
color: var(--color-text-tertiary);
}
.critique-card__snippet {
margin: var(--space-2) 0;
padding: var(--space-2);
background: var(--color-bg-soft);
border-left: 2px solid var(--color-border-subtle);
font-style: italic;
font-size: var(--font-size-xs);
}
.critique-card__comment {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
}
.critique-card__status {
margin-top: var(--space-2);
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
.critique-card__status--exported { color: var(--color-severity-low); }
.lint-annotation-glow {
animation: voyageGlow 1s ease;
}
@keyframes voyageGlow {
0% { background-color: transparent; }
30% { background-color: rgba(74, 134, 232, 0.15); }
100% { background-color: transparent; }
}
/* Make room for sidebar in main layout */
@media (min-width: 1024px) {
main { margin-right: 320px; }
}
/* Step 11 — export flow */
.voyage-export-bar {
padding: var(--space-3);
border-top: 1px solid var(--color-border-subtle);
display: flex;
gap: var(--space-2);
}
.voyage-export-btn {
flex: 1;
padding: var(--space-2) var(--space-3);
background: var(--color-scope-voyage);
color: #fff;
border: 1px solid var(--color-scope-voyage);
border-radius: var(--radius-sm);
cursor: pointer;
font: inherit;
}
.voyage-export-btn:disabled {
background: var(--color-bg-soft);
color: var(--color-text-tertiary);
border-color: var(--color-border-subtle);
cursor: not-allowed;
}
.voyage-export-modal-content {
padding: var(--space-3);
}
.voyage-export-cmd {
display: block;
width: 100%;
padding: var(--space-3);
background: var(--color-bg-soft);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
white-space: pre-wrap;
word-break: break-all;
margin-bottom: var(--space-3);
}
/* v4.3 Step 14 — dashboard fleet-grid stage. Tiles inherit DS
fleet-tile typography from components-tier3-supplement.css; severity
badge inherits DS color-severity-* tokens. data-severity drives
border + badge color via attribute selectors below. */
#voyage-dashboard[hidden],
#voyage-detail[hidden] { display: none; }
.voyage-dashboard__page,
.voyage-detail__page { padding: 0; }
.fleet-tile[data-severity="critical"] { border-left: 3px solid var(--color-severity-critical); }
.fleet-tile[data-severity="high"] { border-left: 3px solid var(--color-severity-high); }
.fleet-tile[data-severity="medium"] { border-left: 3px solid var(--color-severity-medium); }
.fleet-tile[data-severity="low"] { border-left: 3px solid var(--color-state-success); }
.fleet-tile__status-badge {
display: inline-block;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.02em;
text-transform: uppercase;
color: #fff;
}
.fleet-tile__status-badge[data-severity="critical"] { background: var(--color-severity-critical); }
.fleet-tile__status-badge[data-severity="high"] { background: var(--color-severity-high); }
.fleet-tile__status-badge[data-severity="medium"] { background: var(--color-severity-medium); color: #1a1a1a; }
.fleet-tile__status-badge[data-severity="low"] { background: var(--color-state-success); }
.fleet-tile__stat {
margin-top: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.voyage-back-btn {
margin-bottom: var(--space-4);
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
font: inherit;
}
.voyage-back-btn:hover { background: var(--color-bg-soft); }
/* v4.3 Step 8 — inline screenshot gallery (finding 31d28f65).
Renders below the fleet-grid in the dashboard. <figure> grid with
responsive auto-fit columns at 240px min; <figcaption> shows the
relative path so operators can correlate to docs/screenshots/. */
.voyage-screenshot-gallery { margin-top: var(--space-4); }
.voyage-screenshot-gallery h3 {
margin: 0 0 var(--space-3) 0;
font-size: var(--font-size-lg);
color: var(--color-text-primary);
}
.voyage-screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-3);
}
.voyage-screenshot {
margin: 0;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
background: var(--color-surface);
overflow: hidden;
}
.voyage-screenshot img {
display: block;
width: 100%;
height: auto;
}
.voyage-screenshot figcaption {
padding: var(--space-2) var(--space-3);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
border-top: 1px solid var(--color-border-subtle);
word-break: break-all;
}
/* v4.3 remediation: color-contrast fix for finding 09132940.
The vendored DS sets `.key-stat__label` to var(--color-text-tertiary)
which is #6E7781 in light theme — borderline 4.5:1 WCAG-AA contrast
and flagged by axe-core on the small (11px) label text. Override
scoped to playground (no DS file changes) using --color-text-secondary
(#4D5663, 7.4:1) which clears WCAG-AA comfortably for labels. */
.key-stat__label { color: var(--color-text-secondary); }
</style>
</head>
<body>
<a class="skip-link" href="#main-content">Hopp til hovedinnhold</a>
<header class="app-header" id="app-header" data-renderable></header>
<!-- v4.3 Step 11 — hidden directory-picker input. Click-handler triggers
this via [data-action="open-project-picker"] in renderTopbar. -->
<input
type="file"
webkitdirectory
multiple
data-load-input
class="visually-hidden"
aria-hidden="true"
tabindex="-1"
/>
<!-- v4.3 Step 12 — drag-drop overlay. Hidden by default; shown via JS on
document-level dragenter. Origin-disclosure tooltip warns operators
that Chromium taints drags from non-OS sources. -->
<div
class="voyage-dropzone"
data-drop-target
role="region"
aria-label="Slipp prosjektmappe her"
aria-hidden="true"
hidden
>
<div class="voyage-dropzone__inner">
<div class="voyage-dropzone__title">Slipp prosjektmappe her</div>
<div class="voyage-dropzone__hint">Dra direkte fra OS-filutforsker for korrekt path-info.</div>
</div>
</div>
<main id="main-content">
<section
class="guide-panel guide-panel--info"
role="status"
aria-label="Voyage playground status"
id="empty-state"
>
<div class="guide-panel__title">Voyage playground v4.2 — annotation pipeline</div>
<div class="guide-panel__body">
<p>Lim inn <code>brief.md</code>, <code>plan.md</code> eller <code>review.md</code> nedenfor, eller bruk knappen «Last sample plan.md» for å starte med eksempelinnhold. Render-pipeline (markdown-it@14.1.0 + highlight.js@11.11.1) parser markdown og preserverer <code>&lt;!-- voyage:anchor --&gt;</code>-kommentarer for senere annotation creation gestures (Step 9-11).</p>
</div>
</section>
<!-- v4.3 Step 22 — A11Y-panel built from DS primitives (greenfield).
Empty placeholder until Wave 7 axe-core spec populates it via
window.__voyage hooks (Step 23). Severity counters use key-stats
severity modifiers (critical/high/medium/low) mapping axe-core's
critical/serious/moderate/minor enum. -->
<aside
id="voyage-a11y-panel"
class="guide-panel guide-panel--info"
role="complementary"
aria-label="A11Y-rapport (axe-core)"
hidden
>
<div class="guide-panel__title">A11Y-rapport</div>
<div class="guide-panel__body">
<div class="key-stats" role="group" aria-label="Axe-core severity-summary">
<div class="key-stat key-stat--critical">
<div class="key-stat__value" data-a11y-stat="critical">0</div>
<div class="key-stat__label">Critical</div>
</div>
<div class="key-stat key-stat--high">
<div class="key-stat__value" data-a11y-stat="serious">0</div>
<div class="key-stat__label">Serious</div>
</div>
<div class="key-stat key-stat--medium">
<div class="key-stat__value" data-a11y-stat="moderate">0</div>
<div class="key-stat__label">Moderate</div>
</div>
<div class="key-stat key-stat--low">
<div class="key-stat__value" data-a11y-stat="minor">0</div>
<div class="key-stat__label">Minor</div>
</div>
</div>
<ol class="findings__items" id="voyage-a11y-findings" aria-label="Axe-violations">
<li class="findings__item" aria-disabled="true">
<span class="findings__item-title">Kjør axe-spec for å fylle.</span>
<span class="findings__item-meta">tests/e2e/voyage-playground-a11y.spec.mjs (Wave 7)</span>
</li>
</ol>
</div>
</aside>
<!-- v4.3 Step 14 — Project dashboard mount slot. Hidden until
loadProjectDirectory completes; renderDashboard fills with a
fleet-grid of fleet-tiles (one per artifact: brief / plan /
review / progress) plus status vocabulary badges. -->
<section id="voyage-dashboard" class="voyage-dashboard__page" aria-label="Project dashboard" hidden></section>
<!-- v4.3 Step 15 — Artifact-detail mount slot. Hidden until a
fleet-tile is clicked; back-to-dashboard returns to dashboard
without state-loss. -->
<section id="voyage-detail" class="voyage-detail__page" aria-label="Artifact detail" hidden></section>
<section class="paste-import-row" aria-label="Import artifact for annotation">
<label for="voyage-paste-input" class="visually-hidden">Lim inn artifact-innhold (brief.md / plan.md / review.md)</label>
<textarea
id="voyage-paste-input"
placeholder="--- type: trekplan-fixture &#10;# Lim inn artifact-innhold her ..."
spellcheck="false"
></textarea>
<div class="paste-import-row__actions">
<button type="button" id="voyage-render-btn">Render</button>
<button type="button" id="voyage-load-sample-btn">Last sample plan.md</button>
<button type="button" id="voyage-clear-btn">Tøm</button>
</div>
</section>
<section class="voyage-layout" aria-label="Render output">
<div
id="voyage-viewport"
class="voyage-viewport"
role="region"
aria-label="Rendered artifact"
aria-live="polite"
>
<p class="voyage-skeleton-msg"><em>Ingen artifact lastet enda. Lim inn innhold over og trykk «Render».</em></p>
</div>
</section>
</main>
<!-- v4.3 Step 3 (finding 09132940) — toggle button is a sibling of the
<aside aria-hidden="true"> so it remains exposed to AT regardless
of sidebar-hidden state. CSS .voyage-fab uses position: fixed
(z-index: 901) so it floats over the sidebar at all times. -->
<button
type="button"
id="voyage-sidebar-toggle"
class="voyage-fab"
data-action="toggle-sidebar"
aria-controls="voyage-sidebar"
aria-expanded="false"
aria-label="Skjul/vis annotation-panel"
>
<span
id="voyage-fab-badge"
class="voyage-fab__badge"
aria-live="polite"
aria-label="Antall drafts ventende"
hidden
>0</span>
</button>
<!-- v4.3 Step 19 — sidebar-rail (hidden-by-default) with ordered annotation-list,
filter (Alle/Åpne/Resolved), and jumplist count "X av N". Toggle via
FAB data-action="toggle-sidebar" or ] keyboard shortcut (Step 20). -->
<aside
id="voyage-sidebar"
class="voyage-sidebar"
aria-label="Annotation drafts sidebar"
aria-hidden="true"
>
<div class="voyage-sidebar__rail" aria-hidden="true"></div>
<div class="voyage-sidebar__panel">
<!-- v4.3 Step 19 — ordered annotation list with filter + jumplist count -->
<div class="voyage-annotation-list" aria-label="Ordered annotation list">
<div class="voyage-annotation-list__header">
<div
role="radiogroup"
class="voyage-annotation-list__filter"
aria-label="Filtrer annotations"
>
<button type="button" class="voyage-filter-btn" data-filter="all" aria-pressed="true">Alle</button>
<button type="button" class="voyage-filter-btn" data-filter="open" aria-pressed="false">Åpne</button>
<button type="button" class="voyage-filter-btn" data-filter="resolved" aria-pressed="false">Resolved</button>
</div>
<div
id="voyage-jumplist-count"
class="voyage-annotation-list__count"
aria-live="polite"
>0 av 0</div>
</div>
<ol id="voyage-jumplist" class="voyage-annotation-list__items"></ol>
</div>
<div role="tablist" class="voyage-tabs" aria-label="Draft og revisjons-faner">
<button
type="button"
id="voyage-tab-drafts"
class="voyage-tab"
role="tab"
aria-selected="true"
aria-controls="voyage-tab-drafts-panel"
tabindex="0"
>Denne planen <span id="voyage-tab-drafts-count">(0 drafts)</span></button>
<button
type="button"
id="voyage-tab-history"
class="voyage-tab"
role="tab"
aria-selected="false"
aria-controls="voyage-tab-history-panel"
tabindex="-1"
>Alle revisjoner <span id="voyage-tab-history-count">(0 historiske)</span></button>
</div>
<div
id="voyage-tab-drafts-panel"
class="voyage-findings findings"
role="tabpanel"
aria-labelledby="voyage-tab-drafts"
></div>
<div
id="voyage-tab-history-panel"
class="voyage-findings findings"
role="tabpanel"
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"
class="voyage-adder-popup"
role="button"
tabindex="0"
hidden
>Annotér</div>
<!-- Step 9 — form modal (all 3 gestures share this modal) -->
<div
id="voyage-modal-backdrop"
class="voyage-modal-backdrop"
hidden
>
<form
id="voyage-modal"
class="voyage-modal"
role="dialog"
aria-modal="true"
aria-labelledby="voyage-modal-title"
>
<div id="voyage-modal-title" class="voyage-modal__header">Ny annotation</div>
<div class="voyage-modal__body">
<div id="voyage-modal-snippet" class="voyage-modal__snippet" hidden></div>
<div id="voyage-modal-anchor-info" style="font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-bottom: var(--space-3);"></div>
<div role="group" aria-label="Velg intent" class="voyage-modal__intents">
<button type="button" class="voyage-modal__intent-btn" data-intent="fix" aria-pressed="false">Fiks</button>
<button type="button" class="voyage-modal__intent-btn" data-intent="change" aria-pressed="true">Endre</button>
<button type="button" class="voyage-modal__intent-btn" data-intent="question" aria-pressed="false">Spørsmål</button>
<button type="button" class="voyage-modal__intent-btn" data-intent="block" aria-pressed="false">Block</button>
</div>
<label for="voyage-modal-comment" class="visually-hidden">Kommentar</label>
<textarea
id="voyage-modal-comment"
name="comment"
placeholder="Beskriv endringen ..."
required
></textarea>
</div>
<div class="voyage-modal__footer">
<button type="button" id="voyage-modal-cancel">Avbryt</button>
<button type="submit" id="voyage-modal-save">Lagre</button>
</div>
</form>
</div>
<div id="aria-live-region" aria-live="polite" aria-atomic="true" class="visually-hidden"></div>
<!-- Vendored libs from playground/lib/ (zero-network) -->
<script src="lib/markdown-it.min.js"></script>
<script src="lib/markdown-it-front-matter.min.js"></script>
<script src="lib/highlight.min.js"></script>
<!-- v4.3 Step 24 — DOMPurify ≥ 3.1.1 (UMD bundle exposes window.DOMPurify).
Used by sanitizeAnnotation() to scrub annotation rich-text before DOM
insertion. Pinned via scripts/vendor-playground-libs.mjs. -->
<script src="lib/dompurify.min.js"></script>
<!-- Sample plan inlined for Step 8 first-run experience.
Same content as tests/fixtures/annotation/annotation-plan.md (truncated). -->
<script id="voyage-sample-plan" type="text/markdown">
---
plan_version: 1.7
profile: balanced
---
# Demo plan for annotation round-trip
This sample mirrors `tests/fixtures/annotation/annotation-plan.md` so the
playground first-run shows a complete round-trip-able artifact.
## Implementation Plan
### Step 1: Touch a sentinel file
- **Files:** `tmp/sentinel-1.txt` (new)
- **Verify:** `test -f tmp/sentinel-1.txt`
- **Manifest:**
```yaml
manifest:
expected_paths:
- tmp/sentinel-1.txt
min_file_count: 1
```
## Verification
- `npm test` passes.
</script>
<script>
/* Voyage Playground v4.2 — Step 8 render pipeline.
Steps 9-11 will extend this script with creation gestures, sidebar,
export flow, and A11Y baseline.
localStorage key contract (per risk-assessor H7):
voyage_ann_<project-dir-basename>__<filename>
Project-dir-basename derives from URL `?project=...` or fragment;
filename derives from frontmatter `slug:` if present, else from URL. */
(function () {
'use strict';
// ---- localStorage key derivation -----------------------------------
function deriveStorageKey(frontmatter) {
var qs = new URLSearchParams(window.location.search);
var project = qs.get('project') || (window.location.hash || '').replace(/^#/, '') || 'unknown-project';
var filename = (frontmatter && frontmatter.slug) ? frontmatter.slug : 'unknown';
return 'voyage_ann_' + project + '__' + filename;
}
// ---- markdown-it initialization ------------------------------------
// html: true preserves <!-- voyage:anchor --> comments verbatim so
// anchor-parser can find them after rendering. linkify off to keep
// raw markdown stable; typographer off for byte-exact round-trip.
var md = window.markdownit({
html: true,
linkify: false,
typographer: false,
highlight: function (code, lang) {
if (window.hljs && lang && window.hljs.getLanguage(lang)) {
try {
return window.hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
} catch (e) {
// fall through to plain
}
}
return '';
},
});
// Front-matter plugin: capture YAML and wrap in <details> via
// pre-render-then-wrap pattern (research/03 — GFM Type 6 quirk).
var capturedFrontmatter = '';
try {
md.use(window.markdownitFrontMatter, function (fm) {
capturedFrontmatter = fm || '';
});
} catch (e) {
// Plugin failed to load — surface but continue without frontmatter folding.
console.warn('markdown-it-front-matter plugin not loaded:', e && e.message);
}
// ---- v4.3 Step 25 — Sec T4 HTML-comment indirect prompt-injection
// mitigation. -----------------------------------
// Strip every <!-- ... --> comment from the source text BEFORE
// markdown-it render, except those matching the VOYAGE_ANCHOR_RE
// allowlist (Step 16). Uses parseAnchor as the negative-form filter:
// if parseAnchor returns a non-null value, the comment is a valid
// voyage:anchor and survives; everything else (including
// "<!-- IGNORE PREVIOUS INSTRUCTIONS -->" and similar prompt-injection
// payloads embedded in artifacts) is dropped silently. Pure
// string-in-string-out — no DOM access, no I/O.
function stripUnsafeComments(text) {
if (typeof text !== 'string') return text;
return text.replace(/<!--[\s\S]*?-->/g, function (match) {
return parseAnchor(match) ? match : '';
});
}
// ---- render pipeline ----------------------------------------------
function renderArtifact(text) {
capturedFrontmatter = '';
// v4.3 Step 25 — strip unsafe HTML-comments before markdown-it sees them.
var safeText = stripUnsafeComments(text || '');
var bodyHtml = md.render(safeText);
// v4.3 Step 1 — defense in depth: sanitize bodyHtml via DOMPurify
// (finding 1d3591d4). Applied to bodyHtml ONLY — fmHtml uses our
// own escapeHtml() on capturedFrontmatter and intentional
// <details>/<summary> markup that DOMPurify would otherwise strip.
var safeBody = (typeof window !== 'undefined' && window.DOMPurify && typeof window.DOMPurify.sanitize === 'function')
? window.DOMPurify.sanitize(bodyHtml, { USE_PROFILES: { html: true } })
: escapeHtml(bodyHtml);
// Pre-render-then-wrap for <details>: prepend a folded frontmatter
// <details> block at the top if the front-matter plugin captured one.
var fmHtml = '';
if (capturedFrontmatter) {
fmHtml = '<details><summary>Frontmatter</summary><pre><code>' +
escapeHtml(capturedFrontmatter) + '</code></pre></details>';
}
return fmHtml + safeBody;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// v4.3 Step 24 — DOMPurify-backed annotation-content sanitizer.
// Strips <script>, inline styles, and event-handler attributes; keeps
// a small allowlist of inline-formatting tags so users can paste basic
// rich-text (bold/italic/code) without breaking export round-trip.
// Falls back to escapeHtml when DOMPurify is unavailable (file:// in a
// browser without the vendored bundle, or test environments).
function sanitizeAnnotation(html) {
var input = String(html == null ? '' : html);
if (typeof window !== 'undefined' && window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') {
return window.DOMPurify.sanitize(input, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'code'],
ALLOWED_ATTR: [],
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['style', 'onerror', 'onload'],
});
}
// Defensive fallback — never inject untrusted HTML if DOMPurify
// failed to load.
return escapeHtml(input);
}
// Parse frontmatter yourself (cheap line-walk) so deriveStorageKey can
// see slug/type without wire-tapping the plugin.
function quickParseFrontmatter(text) {
if (!text) return null;
var lines = String(text).split(/\r?\n/);
if (lines[0] !== '---') return null;
var end = lines.indexOf('---', 1);
if (end <= 0) return null;
var fm = {};
for (var i = 1; i < end; i++) {
var m = lines[i].match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/);
if (m) fm[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return fm;
}
// ---- v4.3 Step 8 — renderTopbar -----------------------------------
// Generate dynamic app-header from a crumb-array. Each entry:
// { label: 'Hjem', href: '#' } → trailing entry has no href
// Pattern follows llm-security-playground.html renderTopbar; voyage
// tokens via badge--scope-voyage.
function renderTopbar(crumb) {
var host = document.getElementById('app-header');
if (!host) return;
var items = Array.isArray(crumb) && crumb.length ? crumb : [{ label: 'Voyage' }];
var crumbHtml = items.map(function (c, i) {
var sep = i > 0 ? '<span aria-hidden="true"> · </span>' : '';
var label = escapeHtml(c.label || '');
var node = c.href
? '<a href="' + escapeHtml(c.href) + '">' + label + '</a>'
: '<span aria-current="page">' + label + '</span>';
return sep + node;
}).join('');
host.innerHTML =
'<a class="app-header__brand" href="#main-content">' +
'<span class="app-header__brand-mark" aria-hidden="true">V</span>' +
'<span>Voyage Annotation Playground</span>' +
'<span class="badge badge--scope-voyage">Voyage</span>' +
'</a>' +
'<nav class="app-header__breadcrumb" aria-label="Brødsmuler">' +
crumbHtml +
'</nav>' +
'<div class="app-header__spacer"></div>' +
'<div class="app-header__actions" role="group" aria-label="Hovednavigasjon">' +
'<button type="button" class="btn btn--primary" data-action="open-project-picker">Velg prosjektmappe</button>' +
// v4.3 Step 22 — A11Y panel toggle. Greenfield component built from
// DS-primitives (guide-panel--info + key-stats + findings__item).
// Initial state: empty placeholder; Wave 7 axe-spec populates it.
'<button type="button" class="btn btn--ghost" data-action="toggle-a11y-panel" aria-controls="voyage-a11y-panel" aria-expanded="false" aria-label="Vis/skjul A11Y-rapport">A11Y</button>' +
'<button type="button" class="theme-toggle" data-action="toggle-theme" aria-label="Bytt tema">' +
'<span data-theme-label aria-hidden="true">☀</span>' +
'</button>' +
'</div>';
}
// ---- v4.3 Step 11 — webkitdirectory project-loader ----------------
// Wire the [data-action="open-project-picker"] button (rendered by
// renderTopbar) to the hidden [data-load-input] file picker. On change,
// pipe files to loadProjectDirectory (Step 13). If webkitdirectory is
// unsupported, render a guide-panel--warn near the dropzone area.
function wireProjectLoader() {
var input = document.querySelector('[data-load-input]');
if (!input) return;
// Browser-support detection (Firefox <50, very old Safari)
if (!('webkitdirectory' in input) && !('directory' in input)) {
var warn = document.createElement('section');
warn.className = 'guide-panel guide-panel--warn';
warn.setAttribute('role', 'status');
warn.innerHTML =
'<div class="guide-panel__title">Nettleseren støtter ikke mappevalg</div>' +
'<div class="guide-panel__body">Bruk paste-import nedenfor for å laste inn ' +
'<code>brief.md</code>, <code>plan.md</code> eller <code>review.md</code>.</div>';
var main = document.getElementById('main-content');
if (main) main.insertBefore(warn, main.firstChild);
return;
}
// Click-delegation: clicking the visible button programmatically clicks the hidden input.
document.addEventListener('click', function (e) {
var btn = e.target && e.target.closest && e.target.closest('[data-action="open-project-picker"]');
if (!btn) return;
e.preventDefault();
input.click();
});
// Change-handler: derive basePath from first file's webkitRelativePath.
input.addEventListener('change', function (e) {
var files = e.target.files;
if (!files || !files.length) return;
var first = files[0].webkitRelativePath || '';
var basePath = first.split('/')[0] || '';
if (typeof loadProjectDirectory === 'function') {
loadProjectDirectory(files, basePath);
} else {
// Step 13 not yet wired — log-only fallback for incremental delivery.
try { console.log('[voyage] project-loader: ' + files.length + ' files in ' + basePath); } catch (_) {}
}
});
}
// ---- v4.3 Step 13 — loadProjectDirectory pipeline -----------------
// Validate → classify → read → parse-frontmatter → build
// ProjectArtifacts. Mirrors lib/parsers/project-discovery.mjs:15-24
// typedef on the browser side; storage-key derived deterministically
// from basePath so per-project draft state cannot leak across projects.
// v4.3 Step 26 — path-traversal + symlink/dotfile filter.
// Pure predicate: returns true if `inside` (the path RELATIVE to the
// project root, i.e. webkitRelativePath with the leading basePath
// stripped) is safe to read. Rejects:
// 1. Path-traversal (`..` anywhere) — covers symlinks pointing
// outside the chosen directory: webkitRelativePath returns the
// symlink's resolved relative path, so `../etc/passwd` shows up
// with literal `..`.
// 2. Dotfiles at root or any nested level (`.git/`, `.gitignore`,
// `.DS_Store`, `.env`, etc.).
// 3. Known unwanted directories at any depth (`node_modules/`,
// `dist/`, `build/`).
// Pure string-in-bool-out — no DOM, no I/O.
function isProjectPathSafe(inside) {
if (typeof inside !== 'string' || !inside) return false;
if (inside.indexOf('..') !== -1) return false;
if (inside.charAt(0) === '.') return false;
if (inside.indexOf('/.') !== -1) return false;
if (inside.indexOf('node_modules/') === 0 || inside.indexOf('/node_modules/') !== -1) return false;
if (inside.indexOf('dist/') === 0 || inside.indexOf('/dist/') !== -1) return false;
if (inside.indexOf('build/') === 0 || inside.indexOf('/build/') !== -1) return false;
return true;
}
async function loadProjectDirectory(files, basePath) {
if (!files || !files.length) return null;
var prefix = basePath ? basePath + '/' : '';
var artifacts = {
basePath: basePath || '',
storageKey: '',
brief: null,
plan: null,
review: null,
progress: null,
research: [],
architecture: { overview: null, gaps: null, looseFiles: [] },
// v4.3 Step 8 — inline screenshot gallery (finding 31d28f65).
// `screenshots` is populated below from docs/screenshots/**/*.png
// entries. Each item: { path, dataUrl }.
screenshots: [],
looseFiles: []
};
// Phase 1 — validate + classify (sync).
var classified = [];
var filteredCount = 0; // v4.3 Step 26 — track suppressed entries
for (var i = 0; i < files.length; i++) {
var f = files[i];
var rel = f.webkitRelativePath || '';
// Validate: path must start with basePath prefix.
if (basePath && rel.indexOf(prefix) !== 0) { filteredCount++; continue; }
var inside = rel.slice(prefix.length);
// v4.3 Step 26 — reject path-traversal, dotfiles, node_modules, dist, build.
if (!isProjectPathSafe(inside)) { filteredCount++; continue; }
var parts = inside.split('/');
var name = parts[parts.length - 1];
var bucket = null;
if (parts.length === 1 && name === 'brief.md') bucket = 'brief';
else if (parts.length === 1 && name === 'plan.md') bucket = 'plan';
else if (parts.length === 1 && name === 'review.md') bucket = 'review';
else if (parts.length === 1 && name === 'progress.json') bucket = 'progress';
else if (parts.length === 2 && parts[0] === 'research' && /\.md$/.test(name)) bucket = 'research';
else if (parts.length === 2 && parts[0] === 'architecture' && name === 'overview.md') bucket = 'arch_overview';
else if (parts.length === 2 && parts[0] === 'architecture' && name === 'gaps.md') bucket = 'arch_gaps';
else if (parts.length === 2 && parts[0] === 'architecture' && /\.md$/.test(name)) bucket = 'arch_loose';
// v4.3 Step 8 — docs/screenshots/**/*.png → inline gallery
else if (parts[0] === 'docs' && parts[1] === 'screenshots' && /\.png$/i.test(name)) bucket = 'screenshot';
else bucket = 'loose';
classified.push({ file: f, rel: inside, bucket: bucket });
}
// Phase 2 — read content + parse frontmatter (async).
// v4.3 Step 8 — `screenshot` bucket uses readAsDataURL with a 2 MB
// per-image cap (finding 31d28f65); oversized PNGs are skipped
// with an aria-live announce so AT users know what was suppressed.
var SCREENSHOT_MAX_BYTES = 2 * 1024 * 1024;
for (var j = 0; j < classified.length; j++) {
var c = classified[j];
if (c.bucket === 'screenshot') {
if (c.file && typeof c.file.size === 'number' && c.file.size > SCREENSHOT_MAX_BYTES) {
announce('Hopper over for stort screenshot: ' + c.rel);
continue;
}
var dataUrl = '';
try {
dataUrl = await new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () { resolve(String(reader.result || '')); };
reader.onerror = function () { reject(reader.error); };
reader.readAsDataURL(c.file);
});
} catch (_) { dataUrl = ''; }
if (dataUrl) {
artifacts.screenshots.push({ path: c.rel, dataUrl: dataUrl });
}
continue;
}
var text = '';
try { text = await c.file.text(); } catch (_) { text = ''; }
var fm = quickParseFrontmatter(text);
var entry = { path: c.rel, content: text, frontmatter: fm };
switch (c.bucket) {
case 'brief': artifacts.brief = entry; break;
case 'plan': artifacts.plan = entry; break;
case 'review': artifacts.review = entry; break;
case 'progress': artifacts.progress = entry; break;
case 'research': artifacts.research.push(entry); break;
case 'arch_overview': artifacts.architecture.overview = entry; break;
case 'arch_gaps': artifacts.architecture.gaps = entry; break;
case 'arch_loose': artifacts.architecture.looseFiles.push(entry); break;
default: artifacts.looseFiles.push(entry); break;
}
}
artifacts.research.sort(function (a, b) { return a.path.localeCompare(b.path); });
artifacts.screenshots.sort(function (a, b) { return a.path.localeCompare(b.path); });
// Phase 3 — deterministic storage-key from basePath (NOT URL params)
// so draft-annotation state is isolated per project.
artifacts.storageKey = 'voyage_proj_' + djb2Hash(artifacts.basePath || '(unnamed)');
// Phase 4 — warn if brief missing (operator may still drop a partial dir).
if (!artifacts.brief) {
announce('Advarsel: brief.md mangler i prosjektmappen.');
}
// v4.3 Step 26 — surface filter-rejection count via aria-live so
// screen-readers (and curious operators) know what was suppressed.
// Treat as informational, not error: dotfile + node_modules omission
// is the common case for a clean dev directory.
if (filteredCount > 0) {
announce(filteredCount + ' fil(er) filtrert (dotfiles / node_modules / path-traversal).');
}
// Phase 5 — render dashboard if available, else log.
if (typeof renderDashboard === 'function') {
renderDashboard(artifacts);
} else {
try {
console.log('[voyage] projectArtifacts loaded', {
basePath: artifacts.basePath,
brief: !!artifacts.brief,
plan: !!artifacts.plan,
review: !!artifacts.review,
research: artifacts.research.length,
architecture: {
overview: !!artifacts.architecture.overview,
gaps: !!artifacts.architecture.gaps,
loose: artifacts.architecture.looseFiles.length
},
progress: !!artifacts.progress,
loose: artifacts.looseFiles.length,
storageKey: artifacts.storageKey
});
} catch (_) {}
}
return artifacts;
}
// djb2 — small deterministic non-cryptographic hash for storage-keys.
// Not security-sensitive; only needs to map basePath → stable string.
function djb2Hash(s) {
var h = 5381;
for (var i = 0; i < s.length; i++) h = ((h << 5) + h) + s.charCodeAt(i);
return (h >>> 0).toString(36);
}
// ---- v4.3 Step 12 — drag-drop with webkitGetAsEntry ---------------
// Document-level dragenter shows the overlay; drop iterates
// dataTransfer.items, walks DirectoryEntry.readEntries() recursively
// (Chromium 100-entry-cap → loop until empty array), converts to
// File[] with synthetic webkitRelativePath, then forwards to
// loadProjectDirectory (Step 13). Firefox 150 on Windows triggers a
// warn-panel and returns early due to the upstream crash bug.
function wireDragDrop() {
var dropzone = document.querySelector('[data-drop-target]');
if (!dropzone) return;
var ua = navigator.userAgent || '';
var platform = navigator.platform || '';
var isFirefox150Windows = /Firefox\/150/.test(ua) && /Win/.test(platform);
function showWarnPanel(msg) {
var warn = document.createElement('section');
warn.className = 'guide-panel guide-panel--warn';
warn.setAttribute('role', 'status');
warn.innerHTML =
'<div class="guide-panel__title">Drag-drop deaktivert</div>' +
'<div class="guide-panel__body">' + escapeHtml(msg) + '</div>';
var main = document.getElementById('main-content');
if (main) main.insertBefore(warn, main.firstChild);
}
function showOverlay() {
dropzone.hidden = false;
dropzone.classList.add('voyage-dropzone--active');
dropzone.setAttribute('aria-hidden', 'false');
}
function hideOverlay() {
dropzone.hidden = true;
dropzone.classList.remove('voyage-dropzone--active');
dropzone.setAttribute('aria-hidden', 'true');
}
document.addEventListener('dragenter', function (e) {
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).indexOf('Files') !== -1) {
showOverlay();
}
});
dropzone.addEventListener('dragover', function (e) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
});
dropzone.addEventListener('dragleave', function (e) {
// Only hide when leaving the overlay entirely (relatedTarget outside).
if (!e.relatedTarget || e.relatedTarget === document.documentElement) {
hideOverlay();
}
});
dropzone.addEventListener('drop', function (e) {
e.preventDefault();
hideOverlay();
if (isFirefox150Windows) {
showWarnPanel('Drag-drop deaktivert i Firefox 150 på Windows pga kjent crash-bug. Bruk knappen «Velg prosjektmappe» i stedet.');
return;
}
var items = e.dataTransfer && e.dataTransfer.items;
if (!items || !items.length) return;
var collected = [];
var basePath = '';
var pending = 0;
var allDone = false;
function maybeFinish() {
if (allDone && pending === 0) {
if (typeof loadProjectDirectory === 'function') {
loadProjectDirectory(collected, basePath);
} else {
try { console.log('[voyage] drag-drop: ' + collected.length + ' files in ' + basePath); } catch (_) {}
}
}
}
// Recursive walker — readEntries() returns max 100 entries per call,
// so loop until it returns an empty array (Chromium spec).
function walkEntry(entry, prefix) {
if (!entry) return;
if (entry.isFile) {
pending++;
entry.file(function (file) {
try {
Object.defineProperty(file, 'webkitRelativePath', {
value: prefix + entry.name,
configurable: true
});
} catch (_) { /* property may already exist */ }
collected.push(file);
pending--;
maybeFinish();
}, function () {
pending--;
maybeFinish();
});
} else if (entry.isDirectory) {
if (!basePath) basePath = entry.name;
var reader = entry.createReader();
function readBatch() {
pending++;
reader.readEntries(function (entries) {
pending--;
if (!entries.length) {
maybeFinish();
return;
}
for (var i = 0; i < entries.length; i++) {
walkEntry(entries[i], prefix + entry.name + '/');
}
readBatch();
}, function () {
pending--;
maybeFinish();
});
}
readBatch();
}
}
for (var i = 0; i < items.length; i++) {
var item = items[i];
var entry = item.webkitGetAsEntry && item.webkitGetAsEntry();
if (entry) walkEntry(entry, '');
}
allDone = true;
maybeFinish();
});
}
// ---- v4.3 Step 9 — renderPageShell --------------------------------
// Universal page-header for dashboard + artifact-detail flater.
// Returns an HTML string wrapping body content with DS Tier 3
// page__eyebrow / page__title / page__lede / page__meta typography.
function renderPageShell(opts, bodyHtml) {
var o = opts || {};
var parts = ['<section class="page-shell">'];
if (o.eyebrow) {
parts.push('<div class="page__eyebrow">' + escapeHtml(o.eyebrow) + '</div>');
}
if (o.title) {
parts.push('<h1 class="page__title">' + escapeHtml(o.title) + '</h1>');
}
if (o.lede) {
parts.push('<p class="page__lede">' + escapeHtml(o.lede) + '</p>');
}
if (o.meta) {
parts.push('<div class="page__meta">' + escapeHtml(o.meta) + '</div>');
}
parts.push('<div class="page-shell__body">' + (bodyHtml || '') + '</div>');
parts.push('</section>');
return parts.join('');
}
// ---- v4.3 Step 14 — renderDashboard --------------------------------
// Build a fleet-grid from a ProjectArtifacts struct (produced by
// loadProjectDirectory in Step 13). Each artifact becomes a
// fleet-tile with status vocabulary + severity mapping:
// complete → low (green)
// in-progress → medium (amber)
// stale → medium
// blocked → high (orange)
// missing → critical (red)
// Clicking a tile triggers drill-down (Step 15).
var __voyageCurrentArtifacts = null;
function mapStatusToSeverity(status) {
if (status === 'missing') return 'critical';
if (status === 'blocked') return 'high';
if (status === 'in-progress' || status === 'stale') return 'medium';
return 'low';
}
function deriveArtifactStatus(entry, key) {
if (!entry) return 'missing';
var fm = entry.frontmatter || {};
if (key === 'brief') {
if (fm.brief_quality === 'partial') return 'in-progress';
return 'complete';
}
if (key === 'plan') {
if (fm.status === 'partial' || fm.status === 'in-progress') return 'in-progress';
return 'complete';
}
if (key === 'review') {
if (fm.verdict === 'BLOCK' || fm.verdict === 'REPLAN') return 'blocked';
return 'complete';
}
if (key === 'progress') {
var status = '';
try { status = JSON.parse(entry.content || '{}').status || ''; } catch (_) { status = ''; }
if (status === 'in-progress') return 'in-progress';
if (status === 'failed' || status === 'stopped') return 'blocked';
if (status === 'partial') return 'stale';
if (status === 'completed') return 'complete';
return 'in-progress';
}
return 'complete';
}
function buildArtifactKeyStat(entry, key, researchCount) {
if (key === 'research') {
return researchCount + ' research-brief' + (researchCount === 1 ? '' : 's');
}
if (!entry) return '—';
var fm = entry.frontmatter || {};
if (key === 'brief') return 'Quality: ' + (fm.brief_quality || 'complete');
if (key === 'plan') return 'Plan-critic: ' + (fm.plan_critic || '—');
if (key === 'review') return 'Verdict: ' + (fm.verdict || '—');
if (key === 'progress') {
var s = '';
try { s = JSON.parse(entry.content || '{}').status || ''; } catch (_) { s = ''; }
return 'Status: ' + (s || '—');
}
return 'OK';
}
function buildArtifactTiles(a) {
var tiles = [];
var defs = [
{ key: 'brief', title: 'Brief', entry: a.brief },
{ key: 'plan', title: 'Plan', entry: a.plan },
{ key: 'review', title: 'Review', entry: a.review },
{ key: 'research', title: 'Research', entry: (a.research && a.research.length) ? a.research[0] : null },
{ key: 'progress', title: 'Progress', entry: a.progress }
];
for (var i = 0; i < defs.length; i++) {
var d = defs[i];
var status;
if (d.key === 'research') {
status = (a.research && a.research.length) ? 'complete' : 'missing';
} else {
status = deriveArtifactStatus(d.entry, d.key);
}
tiles.push({
key: d.key,
title: d.title,
status: status,
severity: mapStatusToSeverity(status),
keyStat: buildArtifactKeyStat(d.entry, d.key, a.research ? a.research.length : 0)
});
}
return tiles;
}
function shortenBasePath(p) {
if (!p) return '(unnamed)';
var parts = String(p).split('/');
return parts[parts.length - 1] || p;
}
// v4.3 Step 8 — inline screenshot gallery (finding 31d28f65).
// Builds a <figure> grid from the screenshots[] array populated by
// loadProjectDirectory. Each item renders as a data:image/png <img>
// wrapped in a <figure>/<figcaption>. Returns an empty string when
// there are no screenshots so the dashboard layout stays clean.
function renderScreenshotGallery(screenshots) {
if (!screenshots || !screenshots.length) return '';
var items = screenshots.map(function (s) {
var name = (s.path || '').split('/').pop() || s.path || '';
return '<figure class="voyage-screenshot">' +
'<img src="' + s.dataUrl + '" alt="' + escapeHtml(name) + '" loading="lazy">' +
'<figcaption>' + escapeHtml(s.path) + '</figcaption>' +
'</figure>';
}).join('');
return '<section class="voyage-screenshot-gallery" aria-label="Screenshots">' +
'<h3>Screenshots</h3>' +
'<div class="voyage-screenshot-grid">' + items + '</div>' +
'</section>';
}
function renderDashboard(projectArtifacts, slot) {
var host = slot || $('voyage-dashboard');
if (!host) return;
__voyageCurrentArtifacts = projectArtifacts;
var tiles = buildArtifactTiles(projectArtifacts);
var tilesHtml = tiles.map(function (t) {
return '<a class="fleet-tile" data-artifact="' + t.key +
'" data-status="' + t.status + '" data-severity="' + t.severity +
'" href="#" tabindex="0" role="link" aria-label="' +
escapeHtml(t.title + ': ' + t.status) + '">' +
'<div class="fleet-tile__row">' +
'<span class="fleet-tile__name">' + escapeHtml(t.title) + '</span>' +
'<span class="fleet-tile__status-badge" data-severity="' + t.severity + '">' +
escapeHtml(t.status) + '</span>' +
'</div>' +
'<div class="fleet-tile__stat">' + escapeHtml(t.keyStat) + '</div>' +
'</a>';
}).join('');
var projectName = shortenBasePath(projectArtifacts.basePath);
var galleryHtml = renderScreenshotGallery(projectArtifacts.screenshots || []);
var bodyHtml = '<div class="fleet-grid">' + tilesHtml + '</div>' + galleryHtml;
host.innerHTML = renderPageShell({
eyebrow: 'Project dashboard',
title: projectName,
lede: tiles.length + ' artifacts oppdaget i prosjektmappen.',
meta: 'Storage-key: ' + (projectArtifacts.storageKey || '—')
}, bodyHtml);
host.hidden = false;
// Hide paste-flow stage; dashboard takes over.
var emptyState = $('empty-state'); if (emptyState) emptyState.hidden = true;
var pasteRow = document.querySelector('.paste-import-row'); if (pasteRow) pasteRow.hidden = true;
var layout = document.querySelector('.voyage-layout'); if (layout) layout.hidden = true;
var detail = $('voyage-detail'); if (detail) detail.hidden = true;
// Update topbar with project breadcrumb.
renderTopbar([
{ label: 'Voyage', href: '#' },
{ label: projectName }
]);
announce('Dashboard lastet — ' + tiles.length + ' artifacts vist.');
}
// ---- v4.3 Step 15 — drill-down + back-nav + URL routing ----------
// Click on a fleet-tile drills into renderArtifactDetail; the
// back-to-dashboard button (or breadcrumb-click) returns to the
// dashboard without state-loss. URL parameter `?project=` is
// additive: at page-load we surface a guide-panel hint because
// webkitdirectory cannot be triggered without a user-gesture.
// popstate handler keeps browser back/forward in sync with view-state.
function resolveArtifactEntry(a, key) {
if (!a) return null;
if (key === 'brief') return a.brief;
if (key === 'plan') return a.plan;
if (key === 'review') return a.review;
if (key === 'progress') return a.progress;
if (key === 'research') return (a.research && a.research.length) ? a.research[0] : null;
return null;
}
function renderArtifactDetail(artifactKey) {
var a = __voyageCurrentArtifacts;
if (!a) return;
var slot = $('voyage-detail');
if (!slot) return;
var entry = resolveArtifactEntry(a, artifactKey);
var bodyHtml;
if (artifactKey === 'research') {
if (a.research && a.research.length) {
bodyHtml = '<ul class="voyage-research-list">' + a.research.map(function (r) {
var fm = r.frontmatter || {};
var title = fm.title || r.path;
return '<li><div class="fleet-tile__name">' + escapeHtml(title) +
'</div><div class="fleet-tile__stat">' + escapeHtml(r.path) + '</div></li>';
}).join('') + '</ul>';
} else {
bodyHtml = '<p><em>Ingen research-briefs i prosjektet.</em></p>';
}
} else if (!entry) {
bodyHtml = '<p><em>Artifact mangler i prosjektmappen.</em></p>';
} else if (artifactKey === 'progress') {
bodyHtml = '<pre><code>' + escapeHtml(entry.content || '') + '</code></pre>';
} else {
bodyHtml = renderArtifact(entry.content || '');
}
var titleMap = { brief: 'Brief', plan: 'Plan', review: 'Review',
research: 'Research', progress: 'Progress' };
var artifactName = titleMap[artifactKey] || artifactKey;
var projectName = shortenBasePath(a.basePath);
slot.innerHTML = renderPageShell({
eyebrow: 'Artifact detail',
title: artifactName,
lede: 'Project: ' + projectName,
meta: entry ? ('path: ' + (entry.path || '')) : 'mangler'
}, '<button type="button" class="voyage-back-btn" data-action="back-to-dashboard" aria-label="Tilbake til dashboard">← Tilbake til dashboard</button>' + bodyHtml);
slot.hidden = false;
// Hide dashboard + paste-flow stages.
var dash = $('voyage-dashboard'); if (dash) dash.hidden = true;
var emptyState = $('empty-state'); if (emptyState) emptyState.hidden = true;
var pasteRow = document.querySelector('.paste-import-row'); if (pasteRow) pasteRow.hidden = true;
var layout = document.querySelector('.voyage-layout'); if (layout) layout.hidden = true;
renderTopbar([
{ label: 'Voyage', href: '#' },
{ label: projectName, href: '#' },
{ label: artifactName }
]);
announce('Detail-visning: ' + artifactName);
}
function showDashboardFromState() {
if (!__voyageCurrentArtifacts) return;
var detail = $('voyage-detail'); if (detail) detail.hidden = true;
renderDashboard(__voyageCurrentArtifacts);
}
function pushDashboardURL() {
try {
if (!window.history || !window.history.pushState) return;
var a = __voyageCurrentArtifacts;
var params = new URLSearchParams(window.location.search);
if (a && a.basePath) params.set('project', a.basePath);
params.delete('artifact');
var qs = params.toString();
var url = window.location.pathname + (qs ? ('?' + qs) : '');
window.history.pushState({ view: 'dashboard', basePath: a ? a.basePath : null }, '', url);
} catch (_) { /* file:// + privatmodus */ }
}
function pushDetailURL(artifactKey) {
try {
if (!window.history || !window.history.pushState) return;
var a = __voyageCurrentArtifacts;
var params = new URLSearchParams(window.location.search);
if (a && a.basePath) params.set('project', a.basePath);
params.set('artifact', artifactKey);
var qs = params.toString();
var url = window.location.pathname + (qs ? ('?' + qs) : '');
window.history.pushState({ view: 'detail', basePath: a ? a.basePath : null, artifact: artifactKey }, '', url);
} catch (_) { /* file:// + privatmodus */ }
}
function wireDashboardNavigation() {
document.addEventListener('click', function (e) {
var tile = e.target && e.target.closest && e.target.closest('.fleet-tile[data-artifact]');
if (tile) {
e.preventDefault();
var key = tile.getAttribute('data-artifact');
if (key) {
renderArtifactDetail(key);
pushDetailURL(key);
}
return;
}
var back = e.target && e.target.closest && e.target.closest('[data-action="back-to-dashboard"]');
if (back) {
e.preventDefault();
showDashboardFromState();
pushDashboardURL();
}
});
// Browser back/forward → restore view from history state.
window.addEventListener('popstate', function (e) {
var s = e.state;
if (!s || !__voyageCurrentArtifacts) return;
if (s.view === 'detail' && s.artifact) {
renderArtifactDetail(s.artifact);
} else if (s.view === 'dashboard') {
showDashboardFromState();
}
});
}
function maybeShowProjectURLHint() {
// Caveat per Step 15 spec: webkitdirectory cannot be triggered
// programmatically without a user-gesture, so a `?project=` URL
// surfaces a guide-panel hint instead of attempting auto-load.
try {
var qs = new URLSearchParams(window.location.search);
var projectQ = qs.get('project');
if (!projectQ) return;
var emptyState = $('empty-state');
if (!emptyState) return;
var titleEl = emptyState.querySelector('.guide-panel__title');
var bodyEl = emptyState.querySelector('.guide-panel__body');
if (titleEl) titleEl.textContent = 'Project deep-link oppdaget';
if (bodyEl) {
bodyEl.innerHTML = '<p>URL inneholder <code>?project=' +
escapeHtml(projectQ) + '</code>. Browseren krever et bruker-klikk ' +
'før prosjektmappen kan leses; bruk knappen <strong>«Last prosjektmappe»</strong> ' +
'eller dra mappen til vinduet for å fortsette.</p>';
}
} catch (_) { /* file:// + privatmodus */ }
}
// ---- DOM wiring ----------------------------------------------------
function $(id) { return document.getElementById(id); }
function announce(msg) {
var live = $('aria-live-region');
if (live) {
live.textContent = '';
// Yield to allow AT to register the change
setTimeout(function () { live.textContent = msg; }, 50);
}
}
function mountRender(text) {
var viewport = $('voyage-viewport');
if (!viewport) return;
if (!text) {
viewport.innerHTML = '<p class="voyage-skeleton-msg"><em>Ingen artifact lastet enda. Lim inn innhold over og trykk «Render».</em></p>';
return;
}
var fm = quickParseFrontmatter(text);
var key = deriveStorageKey(fm);
try {
window.localStorage.setItem(key + '.last-text', text);
} catch (e) {
// localStorage may be unavailable (private mode, file:// in some browsers)
}
viewport.innerHTML = renderArtifact(text);
announce('Render fullført. Tegnsett: ' + (text.length) + ' tegn.');
}
function loadSample() {
var node = $('voyage-sample-plan');
var text = node ? node.textContent.trim() : '';
var ta = $('voyage-paste-input');
if (ta) ta.value = text;
mountRender(text);
}
function clearAll() {
var ta = $('voyage-paste-input');
if (ta) ta.value = '';
mountRender('');
}
// ---- Step 9 — annotation creation gestures + modal ----------------
// Anchor-ID generation: sequential ANN-NNNN per project+file.
// The "drafts" namespace under localStorage is per-project per-file.
var DRAFTS_KEY_SUFFIX = '.drafts';
var ADDER_GRACE_MS = 300; // 300ms grace before popup hides on mouse-out
var ADDER_DEBOUNCE_MS = 200; // 200ms after mouseup to settle selection
var INTENT_DEFAULT = 'change';
function loadDrafts(key) {
try {
var raw = window.localStorage.getItem(key + DRAFTS_KEY_SUFFIX);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveDrafts(key, list) {
try {
window.localStorage.setItem(key + DRAFTS_KEY_SUFFIX, JSON.stringify(list));
} catch (e) {
/* ignore quota errors */
}
}
function nextAnchorId(drafts) {
var max = 0;
for (var i = 0; i < drafts.length; i++) {
var m = String(drafts[i].id).match(/^ANN-(\d+)$/);
if (m) max = Math.max(max, parseInt(m[1], 10));
}
var next = max + 1;
return 'ANN-' + String(next).padStart(4, '0');
}
function deriveHeadingPath(node) {
// Walk previous siblings + ancestors to find the nearest heading.
var n = node;
while (n) {
while (n && !n.previousElementSibling && n.parentElement) n = n.parentElement;
if (!n) break;
n = n.previousElementSibling || (n.parentElement && n.parentElement.previousElementSibling);
if (n && /^H[1-6]$/.test(n.tagName)) {
return slugify(n.textContent || '');
}
if (!n) break;
}
return 'page';
}
function slugify(s) {
return String(s)
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 60) || 'page';
}
function deriveLineNumber(node) {
// Approximate line from the running paragraph index — a real
// line-mapping would require pre-render line annotations. The export
// flow (Step 11) substitutes more accurate line numbers from the
// raw markdown source.
var viewport = $('voyage-viewport');
if (!viewport) return null;
var ps = viewport.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, pre');
for (var i = 0; i < ps.length; i++) {
if (ps[i] === node || ps[i].contains(node)) return i + 1;
}
return null;
}
// ---- Modal control -------------------------------------------------
var modalState = {
target: 'page',
line: null,
snippet: '',
intent: INTENT_DEFAULT,
currentStorageKey: '',
};
function openModal(opts) {
modalState.target = opts.target || 'page';
modalState.line = opts.line || null;
modalState.snippet = opts.snippet || '';
modalState.intent = INTENT_DEFAULT;
modalState.currentStorageKey = opts.storageKey || '';
var snippetEl = $('voyage-modal-snippet');
if (snippetEl) {
if (modalState.snippet) {
snippetEl.textContent = modalState.snippet;
snippetEl.hidden = false;
} else {
snippetEl.hidden = true;
snippetEl.textContent = '';
}
}
var info = $('voyage-modal-anchor-info');
if (info) {
info.textContent = 'Forankret til: ' + modalState.target +
(modalState.line ? ' (linje ' + modalState.line + ')' : '');
}
var ta = $('voyage-modal-comment');
if (ta) ta.value = '';
// Reset intent buttons
var btns = document.querySelectorAll('.voyage-modal__intent-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].setAttribute('aria-pressed', btns[i].getAttribute('data-intent') === INTENT_DEFAULT ? 'true' : 'false');
}
var bd = $('voyage-modal-backdrop');
if (bd) bd.hidden = false;
if (ta) ta.focus();
}
function closeModal() {
var bd = $('voyage-modal-backdrop');
if (bd) bd.hidden = true;
}
function saveModalAsAnnotation() {
var ta = $('voyage-modal-comment');
if (!ta || !ta.value.trim()) return false;
var key = modalState.currentStorageKey;
if (!key) return false;
var drafts = loadDrafts(key);
var annot = {
id: nextAnchorId(drafts),
target_anchor: modalState.target,
line: modalState.line,
intent: modalState.intent,
snippet: modalState.snippet || '',
comment: ta.value.trim(),
created_at: new Date().toISOString(),
exported: false,
};
drafts.push(annot);
saveDrafts(key, drafts);
announce('Annotation lagret: ' + annot.id);
return annot;
}
// ---- Gesture 1 — text-anchored adder-popup (mouseup-debounce 200ms; 300ms grace) ----
var adderPopup;
var adderTimer;
var adderGraceTimer;
function showAdderPopup(rect, sel) {
if (!adderPopup) adderPopup = $('voyage-adder-popup');
if (!adderPopup) return;
adderPopup.style.left = (rect.left + rect.width + 12) + 'px';
adderPopup.style.top = (rect.top + rect.height + 4) + 'px';
adderPopup._selection = sel;
adderPopup.hidden = false;
}
function hideAdderPopup() {
if (adderPopup) adderPopup.hidden = true;
}
function onSelectionMaybeChanged() {
clearTimeout(adderTimer);
adderTimer = setTimeout(function () {
var sel = window.getSelection ? window.getSelection() : null;
if (!sel || sel.isCollapsed || !sel.rangeCount) {
// 300ms grace before hiding (per critical decision #2)
clearTimeout(adderGraceTimer);
adderGraceTimer = setTimeout(hideAdderPopup, ADDER_GRACE_MS);
return;
}
var range = sel.getRangeAt(0);
var viewport = $('voyage-viewport');
if (!viewport || !viewport.contains(range.commonAncestorContainer)) {
hideAdderPopup();
return;
}
var rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
hideAdderPopup();
return;
}
showAdderPopup(rect, sel.toString().slice(0, 80));
}, ADDER_DEBOUNCE_MS);
}
function onAdderClick() {
if (!adderPopup) return;
var snippet = adderPopup._selection || '';
var sel = window.getSelection ? window.getSelection() : null;
var node = sel && sel.rangeCount ? sel.getRangeAt(0).startContainer : null;
var target = deriveHeadingPath(node && node.parentElement);
var line = deriveLineNumber(node && node.parentElement);
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
hideAdderPopup();
openModal({ target: target, line: line, snippet: snippet, storageKey: key });
}
// ---- v4.3 Step 18 — numbered-badge gutter (replaces pencil-icon) ---
// Iterate active drafts (loaded from storage) and inject a numbered
// circular badge into the gutter of each anchored paragraph. Ordering
// matches the export-flow numbering 1, 2, 3 ... so badges align with
// the Step 19 sidebar jumplist. Body text is never recolored; only
// the gutter element + (when active) yellow-tint highlight on the
// annotated paragraph itself.
function injectAnchorBadges() {
var viewport = $('voyage-viewport');
if (!viewport) return;
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
var drafts = loadDrafts(key);
if (!Array.isArray(drafts) || drafts.length === 0) return;
var paras = viewport.querySelectorAll('p, li, h1, h2, h3, h4, h5, h6, pre');
// Sort drafts by line ASC for stable 1..N numbering
var sorted = drafts.slice().sort(function (a, b) {
return (Number(a.line) || 0) - (Number(b.line) || 0);
});
for (var i = 0; i < sorted.length; i++) {
var d = sorted[i];
var ln = Number(d.line);
if (!ln || ln < 1 || ln > paras.length) continue;
var p = paras[ln - 1];
if (!p) continue;
if (p.querySelector('.voyage-anchor-badge')) continue;
var badge = document.createElement('button');
badge.type = 'button';
badge.className = 'voyage-anchor-badge';
badge.setAttribute('data-anchor-id', d.id);
// v4.3 Step 21 — two-opacity pattern: data-resolved drives 30% +
// strikethrough state. data-active is toggled by setActiveAnchor.
if (d.resolved) badge.setAttribute('data-resolved', 'true');
badge.setAttribute('aria-label', 'Annotering ' + (i + 1) + ': ' + (d.target_anchor || 'page'));
badge.textContent = String(i + 1);
(function (el, draftId) {
badge.addEventListener('click', function () {
setActiveAnchor(draftId);
});
})(p, d.id);
p.insertBefore(badge, p.firstChild);
}
}
// Apply yellow-tint highlight to the anchored paragraph for the given
// anchor id; remove the class from any previously-active paragraph.
function setActiveAnchor(anchorId) {
var viewport = $('voyage-viewport');
if (!viewport) return;
var prev = viewport.querySelectorAll('.voyage-anchor-active');
for (var i = 0; i < prev.length; i++) prev[i].classList.remove('voyage-anchor-active');
// v4.3 Step 21 — clear data-active from prior badge AND prior sidebar
// list-item, then set on the new ones. Mirrors two-opacity intent.
var prevBadges = viewport.querySelectorAll('.voyage-anchor-badge[data-active="true"]');
for (var pb = 0; pb < prevBadges.length; pb++) prevBadges[pb].removeAttribute('data-active');
var prevListItems = document.querySelectorAll('#voyage-jumplist li[data-active="true"]');
for (var pl = 0; pl < prevListItems.length; pl++) prevListItems[pl].removeAttribute('data-active');
if (!anchorId) return;
var badge = viewport.querySelector('.voyage-anchor-badge[data-anchor-id="' + anchorId + '"]');
if (badge && badge.parentElement) {
badge.setAttribute('data-active', 'true');
badge.parentElement.classList.add('voyage-anchor-active');
badge.parentElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
badge.focus();
}
var listItem = document.querySelector('#voyage-jumplist li[data-anchor-id="' + anchorId + '"]');
if (listItem) listItem.setAttribute('data-active', 'true');
}
// ---- Gesture 3 — page-level note (button injected near viewport) ---
function ensurePageNoteButton() {
var btn = document.getElementById('voyage-page-note-btn');
if (btn) return btn;
btn = document.createElement('button');
btn.id = 'voyage-page-note-btn';
btn.type = 'button';
btn.className = 'voyage-page-note-btn';
btn.textContent = '+ Legg til note (page-level)';
btn.addEventListener('click', function () {
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
openModal({ target: 'page', line: null, snippet: '', storageKey: key });
});
var layout = document.querySelector('.voyage-layout');
if (layout) layout.appendChild(btn);
return btn;
}
// Re-run anchor-badge (Step 18) + gesture-3 + sidebar list (Step 19) wiring after each render.
var originalMountRender = mountRender;
mountRender = function (text) {
originalMountRender(text);
injectAnchorBadges();
ensurePageNoteButton();
renderAnnotationList();
};
// ---- Modal event wiring -------------------------------------------
function wireModal() {
var bd = $('voyage-modal-backdrop');
var form = $('voyage-modal');
if (!bd || !form) return;
var cancelBtn = $('voyage-modal-cancel');
if (cancelBtn) cancelBtn.addEventListener('click', closeModal);
// Click on backdrop (outside modal) closes
bd.addEventListener('click', function (e) {
if (e.target === bd) closeModal();
});
// ESC closes
document.addEventListener('keydown', function (e) {
if (!bd.hidden && e.key === 'Escape') {
closeModal();
}
});
// Intent buttons (radiogroup-like via aria-pressed)
var intents = document.querySelectorAll('.voyage-modal__intent-btn');
for (var i = 0; i < intents.length; i++) {
intents[i].addEventListener('click', function (e) {
for (var j = 0; j < intents.length; j++) {
intents[j].setAttribute('aria-pressed', intents[j] === e.currentTarget ? 'true' : 'false');
}
modalState.intent = e.currentTarget.getAttribute('data-intent') || INTENT_DEFAULT;
});
}
// Save (form submit)
form.addEventListener('submit', function (e) {
e.preventDefault();
var saved = saveModalAsAnnotation();
if (saved) closeModal();
});
}
// ---- Step 10 — sidebar + critique-card-list + tabs ----------------
function escapeHtmlInline(s) { return escapeHtml(s); }
function intentBadgeClass(intent) {
var t = String(intent || '').toLowerCase();
if (['fix','change','question','block'].indexOf(t) === -1) t = 'change';
return 'intent-badge intent-badge--' + t;
}
function renderCritiqueCard(annot) {
var card = document.createElement('div');
card.className = 'critique-card';
card.setAttribute('data-anchor-id', annot.id);
card.setAttribute('data-target', annot.target_anchor || '');
card.setAttribute('data-line', annot.line || '');
card.setAttribute('role', 'listitem');
card.tabIndex = 0;
card.innerHTML =
'<div class="critique-card__header">' +
'<span class="' + intentBadgeClass(annot.intent) + '">' + escapeHtmlInline(annot.intent || 'change') + '</span>' +
'<span class="critique-card__id">' + escapeHtmlInline(annot.id) + '</span>' +
(annot.line ? '<span class="critique-card__line">linje ' + annot.line + '</span>' : '') +
'</div>' +
(annot.snippet ? '<div class="critique-card__snippet">' + escapeHtmlInline(annot.snippet) + '</div>' : '') +
// v4.3 Step 24 — comment is user-entered rich-text; route through
// sanitizeAnnotation (DOMPurify-backed) so basic inline-formatting
// tags survive while <script>, inline styles, and event-handler
// attributes are stripped before DOM insertion.
'<div class="critique-card__comment">' + sanitizeAnnotation(annot.comment || '') + '</div>' +
'<div class="critique-card__status' + (annot.exported ? ' critique-card__status--exported' : '') + '">' +
(annot.exported ? 'Eksportert' : 'Pending') +
'</div>';
card.addEventListener('click', function () {
scrollToAnchor(annot);
});
card.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToAnchor(annot);
}
});
return card;
}
function scrollToAnchor(annot) {
if (!annot || !annot.line) return;
var viewport = $('voyage-viewport');
if (!viewport) return;
var ps = viewport.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, pre');
var target = ps[annot.line - 1];
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('lint-annotation-glow');
setTimeout(function () { target.classList.remove('lint-annotation-glow'); }, 1000);
}
function refreshSidebar() {
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
var drafts = loadDrafts(key);
// Sort by line ascending (Hypothes.is sort-by-location pattern)
var sortedDrafts = drafts.slice().sort(function (a, b) {
var la = a.line == null ? Infinity : Number(a.line);
var lb = b.line == null ? Infinity : Number(b.line);
return la - lb;
});
var pendingCount = sortedDrafts.filter(function (d) { return !d.exported; }).length;
var historyCount = sortedDrafts.filter(function (d) { return d.exported; }).length;
var draftsPanel = $('voyage-tab-drafts-panel');
var historyPanel = $('voyage-tab-history-panel');
if (draftsPanel) {
draftsPanel.innerHTML = '';
var pending = sortedDrafts.filter(function (d) { return !d.exported; });
if (pending.length === 0) {
draftsPanel.innerHTML = '<p class="voyage-skeleton-msg"><em>Ingen drafts enda. Bruk en av annotation-gesturene over.</em></p>';
} else {
for (var i = 0; i < pending.length; i++) draftsPanel.appendChild(renderCritiqueCard(pending[i]));
}
}
if (historyPanel) {
historyPanel.innerHTML = '';
var hist = sortedDrafts.filter(function (d) { return d.exported; });
if (hist.length === 0) {
historyPanel.innerHTML = '<p class="voyage-skeleton-msg"><em>Ingen eksporterte revisjoner enda.</em></p>';
} else {
for (var j = 0; j < hist.length; j++) historyPanel.appendChild(renderCritiqueCard(hist[j]));
}
}
// Update tab counts
var draftsCountEl = $('voyage-tab-drafts-count');
if (draftsCountEl) draftsCountEl.textContent = '(' + pendingCount + ' drafts)';
var historyCountEl = $('voyage-tab-history-count');
if (historyCountEl) historyCountEl.textContent = '(' + historyCount + ' historiske)';
// Update FAB badge
var badge = $('voyage-fab-badge');
if (badge) {
if (pendingCount > 0) {
badge.textContent = String(pendingCount);
badge.hidden = false;
} else {
badge.hidden = true;
badge.textContent = '0';
}
}
}
function selectTab(tabId) {
var tabs = document.querySelectorAll('[role="tab"].voyage-tab');
for (var i = 0; i < tabs.length; i++) {
var selected = tabs[i].id === tabId;
tabs[i].setAttribute('aria-selected', selected ? 'true' : 'false');
tabs[i].tabIndex = selected ? 0 : -1;
var panelId = tabs[i].getAttribute('aria-controls');
var panel = panelId ? document.getElementById(panelId) : null;
if (panel) panel.hidden = !selected;
}
}
// v4.3 Step 19 — current sidebar filter state ('all' | 'open' | 'resolved')
var voyageFilterState = 'all';
function toggleSidebar() {
var toggle = $('voyage-sidebar-toggle');
var sidebar = $('voyage-sidebar');
if (!toggle || !sidebar) return;
var hidden = sidebar.getAttribute('aria-hidden') === 'true';
sidebar.setAttribute('aria-hidden', hidden ? 'false' : 'true');
toggle.setAttribute('aria-expanded', hidden ? 'true' : 'false');
}
// v4.3 Step 19 — render ordered annotation-list inside sidebar.
// Item ordering matches the gutter-badge numbering 1..N (sorted by line ASC).
// Filter state ('all' | 'open' | 'resolved') controls which items render.
// Click a list-item -> scrollIntoView + setActiveAnchor on the matching badge.
function renderAnnotationList() {
var list = $('voyage-jumplist');
var countEl = $('voyage-jumplist-count');
if (!list || !countEl) return;
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
var drafts = loadDrafts(key);
var sorted = (Array.isArray(drafts) ? drafts.slice() : []).sort(function (a, b) {
return (Number(a.line) || 0) - (Number(b.line) || 0);
});
var filtered = sorted.filter(function (d) {
if (voyageFilterState === 'all') return true;
if (voyageFilterState === 'resolved') return Boolean(d.resolved);
return !d.resolved; // 'open'
});
list.innerHTML = '';
for (var i = 0; i < filtered.length; i++) {
var d = filtered[i];
var origIdx = sorted.indexOf(d);
var li = document.createElement('li');
li.setAttribute('data-anchor-id', d.id);
// v4.3 Step 21 — two-opacity pattern mirror: list-item inherits the
// resolved state from the draft so CSS can render strikethrough +
// 30% opacity to match the gutter-badge.
if (d.resolved) li.setAttribute('data-resolved', 'true');
var num = document.createElement('span');
num.className = 'voyage-jumplist-num';
num.textContent = String(origIdx + 1);
var label = document.createElement('span');
label.className = 'voyage-jumplist-label';
label.textContent = (d.target_anchor || 'page') + (d.line ? ' · linje ' + d.line : '');
li.appendChild(num);
li.appendChild(label);
(function (anchorId) {
li.addEventListener('click', function () {
setActiveAnchor(anchorId);
});
})(d.id);
list.appendChild(li);
}
countEl.textContent = filtered.length + ' av ' + sorted.length;
}
function wireSidebar() {
var toggle = $('voyage-sidebar-toggle');
var sidebar = $('voyage-sidebar');
if (toggle && sidebar) {
toggle.addEventListener('click', toggleSidebar);
}
// Filter buttons (Alle / Åpne / Resolved)
document.querySelectorAll('.voyage-filter-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var f = btn.getAttribute('data-filter');
if (!f) return;
voyageFilterState = f;
document.querySelectorAll('.voyage-filter-btn').forEach(function (b) {
b.setAttribute('aria-pressed', b === btn ? 'true' : 'false');
});
renderAnnotationList();
});
});
var draftsTab = $('voyage-tab-drafts');
var historyTab = $('voyage-tab-history');
if (draftsTab) draftsTab.addEventListener('click', function () { selectTab('voyage-tab-drafts'); });
if (historyTab) historyTab.addEventListener('click', function () { selectTab('voyage-tab-history'); });
// Tab keyboard arrow nav
document.querySelectorAll('[role="tab"].voyage-tab').forEach(function (t) {
t.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
var tabs = Array.from(document.querySelectorAll('[role="tab"].voyage-tab'));
var idx = tabs.indexOf(t);
var next = e.key === 'ArrowRight'
? tabs[(idx + 1) % tabs.length]
: tabs[(idx - 1 + tabs.length) % tabs.length];
selectTab(next.id);
next.focus();
}
});
});
}
// ---- 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 () {
var result = originalSave();
refreshSidebar();
return result;
};
// Hook mountRender -> refreshSidebar (already chained in Step 9)
var prevMountRender = mountRender;
mountRender = function (text) {
prevMountRender(text);
refreshSidebar();
};
// Event wiring on DOMContentLoaded
function init() {
var renderBtn = $('voyage-render-btn');
var sampleBtn = $('voyage-load-sample-btn');
var clearBtn = $('voyage-clear-btn');
var ta = $('voyage-paste-input');
if (renderBtn) {
renderBtn.addEventListener('click', function () {
mountRender(ta ? ta.value : '');
});
}
if (sampleBtn) {
sampleBtn.addEventListener('click', loadSample);
}
if (clearBtn) {
clearBtn.addEventListener('click', clearAll);
}
// Step 9 — gesture wiring
document.addEventListener('mouseup', onSelectionMaybeChanged);
document.addEventListener('selectionchange', onSelectionMaybeChanged);
var adder = $('voyage-adder-popup');
if (adder) {
adder.addEventListener('click', onAdderClick);
adder.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAdderClick(); }
});
}
wireModal();
// Step 10 — sidebar wiring + initial render
wireSidebar();
refreshSidebar();
// Step 11 — export-flow wiring
wireExport();
// Step 7 (v4.3) — theme-toggle button (delegated data-action handler)
// Sun icon (☀) in dark mode, moon icon (☾) in light mode. Click toggles
// data-theme + colorScheme, persists to localStorage('voyage-theme').
wireThemeToggle();
// Step 22 (v4.3) — A11Y-panel toggle (delegated data-action handler).
wireA11yToggle();
// Step 8 (v4.3) — initial topbar render with single-crumb (voyage root).
// renderDashboard / drill-down (Wave 3) re-renders with deeper crumbs.
renderTopbar([{ label: 'Hjem' }]);
// Step 11 (v4.3) — webkitdirectory project-loader wiring (button +
// hidden file-input + browser-support detection).
wireProjectLoader();
// Step 12 (v4.3) — drag-drop overlay with webkitGetAsEntry recursive
// walker + Firefox 150 Windows UA-guard.
wireDragDrop();
// Step 15 (v4.3) — fleet-tile click delegation, back-to-dashboard
// handler, popstate restoration, and ?project= URL deep-link hint.
wireDashboardNavigation();
maybeShowProjectURLHint();
// Step 20 (v4.3) — J/K annotation nav + Esc clear + ] toggle sidebar
wireKeyboardNav();
}
// v4.3 Step 20 — document-level keyboard navigation.
// J = next annotation, K = prev, ] = toggle sidebar, Escape = clear
// active anchor. Aria-live region announces position + target.
// Skip when user is typing in an input/textarea/contenteditable so the
// playground never steals keystrokes from form fields.
function wireKeyboardNav() {
document.addEventListener('keydown', function (e) {
var t = e.target;
if (t && t.matches && t.matches('input, textarea, select, [contenteditable], [contenteditable="true"]')) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key === ']') {
e.preventDefault();
toggleSidebar();
return;
}
if (e.key === 'Escape') {
var actives = document.querySelectorAll('.voyage-anchor-active');
if (actives.length === 0) return;
for (var i = 0; i < actives.length; i++) actives[i].classList.remove('voyage-anchor-active');
var listActives = document.querySelectorAll('#voyage-jumplist li[data-active="true"]');
for (var j = 0; j < listActives.length; j++) listActives[j].removeAttribute('data-active');
announce('Annotering avbrutt.');
return;
}
if (e.key === 'j' || e.key === 'k') {
e.preventDefault();
var direction = e.key === 'j' ? 1 : -1;
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
var drafts = loadDrafts(key);
if (!Array.isArray(drafts) || drafts.length === 0) return;
var sorted = drafts.slice().sort(function (a, b) {
return (Number(a.line) || 0) - (Number(b.line) || 0);
});
var allBadges = document.querySelectorAll('.voyage-anchor-badge[data-anchor-id]');
var currentId = null;
for (var k = 0; k < allBadges.length; k++) {
if (allBadges[k].parentElement && allBadges[k].parentElement.classList.contains('voyage-anchor-active')) {
currentId = allBadges[k].getAttribute('data-anchor-id');
break;
}
}
var curIdx = currentId ? sorted.findIndex(function (d) { return d.id === currentId; }) : -1;
var nextIdx = curIdx === -1
? (direction === 1 ? 0 : sorted.length - 1)
: (curIdx + direction + sorted.length) % sorted.length;
var nextDraft = sorted[nextIdx];
if (!nextDraft) return;
setActiveAnchor(nextDraft.id);
var snippet = nextDraft.snippet ? (' — ' + String(nextDraft.snippet).slice(0, 60)) : '';
announce('Annotering ' + (nextIdx + 1) + ' av ' + sorted.length + ': ' + (nextDraft.target_anchor || 'page') + snippet);
return;
}
});
}
function setThemeLabel(theme) {
var lbl = document.querySelector('[data-theme-label]');
if (lbl) lbl.textContent = theme === 'light' ? '☾' : '☀';
}
// v4.3 Step 22 — A11Y-panel toggle. Click on data-action="toggle-a11y-panel"
// toggles the hidden attribute on #voyage-a11y-panel and updates aria-expanded
// on the toggle button. Panel is empty placeholder until Wave 7 axe-core
// spec calls window.__voyage.scheduleRender({ a11yViolations }) to populate.
function wireA11yToggle() {
document.addEventListener('click', function (e) {
var btn = e.target && e.target.closest && e.target.closest('[data-action="toggle-a11y-panel"]');
if (!btn) return;
var panel = document.getElementById('voyage-a11y-panel');
if (!panel) return;
var willOpen = panel.hidden;
panel.hidden = !willOpen;
btn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
if (typeof announce === 'function') {
announce(willOpen ? 'A11Y-rapport vist.' : 'A11Y-rapport skjult.');
}
});
}
function wireThemeToggle() {
var initial = document.documentElement.getAttribute('data-theme') || 'dark';
setThemeLabel(initial);
document.addEventListener('click', function (e) {
var btn = e.target && e.target.closest && e.target.closest('[data-action="toggle-theme"]');
if (!btn) return;
var cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
var next = cur === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
document.documentElement.style.colorScheme = next;
try { localStorage.setItem('voyage-theme', next); } catch (err) { /* file:// + privatmodus */ }
setThemeLabel(next);
});
}
// ---- v4.3 Step 23 — screenshots-spor convention -------------------
// Expose a minimal automation surface for headless screenshot scripts
// (Wave 7 axe-spec + manual screenshot tooling). Mirrors the
// llm-security-playground.html `window.__navigate` /
// `window.__scheduleRender` pattern but namespaced under
// `window.__voyage` to avoid global pollution. All three methods are
// intentionally read-only from the outside — they wrap existing
// functions; we never expose state-mutating internals directly.
//
// Methods:
// navigate(surface) — surface ∈ ['dashboard', 'detail', 'render', 'a11y']
// scheduleRender(state) — state.markdown → mountRender; state.artifacts → renderDashboard
// getProjectArtifacts() — returns the last-loaded ProjectArtifacts object (or null)
//
// See docs/screenshots/README.md for the screenshot mappestruktur.
window.__voyage = {
navigate: function (surface) {
var s = String(surface || '').toLowerCase();
if (s === 'dashboard') {
if (__voyageCurrentArtifacts && typeof renderDashboard === 'function') {
renderDashboard(__voyageCurrentArtifacts);
}
return s;
}
if (s === 'detail') {
// No-op without an artifact-key; renderArtifactDetail requires
// a key (brief|plan|review|progress|research:N|architecture:overview).
return s;
}
if (s === 'render') {
var ta = document.getElementById('voyage-paste-input');
if (ta && typeof mountRender === 'function') mountRender(ta.value || '');
return s;
}
if (s === 'a11y') {
var panel = document.getElementById('voyage-a11y-panel');
if (panel) panel.hidden = false;
return s;
}
return null;
},
scheduleRender: function (state) {
if (!state || typeof state !== 'object') return false;
if (typeof state.markdown === 'string' && typeof mountRender === 'function') {
var ta = document.getElementById('voyage-paste-input');
if (ta) ta.value = state.markdown;
mountRender(state.markdown);
}
if (state.artifacts && typeof renderDashboard === 'function') {
__voyageCurrentArtifacts = state.artifacts;
renderDashboard(state.artifacts);
}
return true;
},
getProjectArtifacts: function () {
return __voyageCurrentArtifacts || null;
},
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>