Step 18 of v4.3 playground plan. Replaces v4.2 Gesture 2 pencil-icon hover-reveal with numbered circular badges in the left gutter (one per anchored paragraph; ordering matches sidebar jumplist). 2-3px accent stripe extends right from the badge into the gutter. Yellow-tint highlight (rgba 255, 235, 59, 0.25 — Google Docs pattern) applies to the anchored paragraph when an annotation is active. Body text never reflowed or recolored. Gesture 1 (text-select adder) and Gesture 3 (page-level note) remain for new annotation creation. CSS uses --color-scope-voyage token for badge background and stripe. JS adds injectAnchorBadges() + setActiveAnchor() and rewires mountRender. Trace: SC1 + SC6, research/04 Insight 3 + Recommendation marker-design.
2514 lines
97 KiB
HTML
2514 lines
97 KiB
HTML
<!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, '"') + '"');
|
||
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: transform 150ms ease;
|
||
}
|
||
.voyage-anchor-badge:hover {
|
||
transform: scale(1.1);
|
||
}
|
||
.voyage-anchor-badge:focus-visible {
|
||
outline: 2px solid var(--color-focus-ring, #4d90fe);
|
||
outline-offset: 2px;
|
||
}
|
||
/* 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) */
|
||
.voyage-fab {
|
||
position: relative;
|
||
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; }
|
||
|
||
/* 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); }
|
||
</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><!-- voyage:anchor --></code>-kommentarer for senere annotation creation gestures (Step 9-11).</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 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 / research / 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 # 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>
|
||
|
||
<!-- Step 10 — sidebar with tabs + critique-card-list -->
|
||
<aside
|
||
id="voyage-sidebar"
|
||
class="voyage-sidebar"
|
||
aria-label="Annotation drafts sidebar"
|
||
aria-hidden="false"
|
||
>
|
||
<div class="voyage-sidebar__rail">
|
||
<button
|
||
type="button"
|
||
id="voyage-sidebar-toggle"
|
||
class="voyage-fab"
|
||
aria-controls="voyage-sidebar"
|
||
aria-expanded="true"
|
||
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>
|
||
</div>
|
||
<div class="voyage-sidebar__panel">
|
||
<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>
|
||
|
||
<!-- 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);
|
||
}
|
||
|
||
// ---- render pipeline ----------------------------------------------
|
||
function renderArtifact(text) {
|
||
capturedFrontmatter = '';
|
||
var bodyHtml = md.render(text || '');
|
||
// 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 + bodyHtml;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// 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>' +
|
||
'<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.
|
||
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: [] },
|
||
looseFiles: []
|
||
};
|
||
|
||
// Phase 1 — validate + classify (sync).
|
||
var classified = [];
|
||
for (var i = 0; i < files.length; i++) {
|
||
var f = files[i];
|
||
var rel = f.webkitRelativePath || '';
|
||
// Validate: path must start with basePath prefix; reject parent traversal.
|
||
if (basePath && rel.indexOf(prefix) !== 0) continue;
|
||
if (rel.indexOf('..') !== -1) continue;
|
||
var inside = rel.slice(prefix.length);
|
||
if (!inside) 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';
|
||
else bucket = 'loose';
|
||
classified.push({ file: f, rel: inside, bucket: bucket });
|
||
}
|
||
|
||
// Phase 2 — read content + parse frontmatter (async).
|
||
for (var j = 0; j < classified.length; j++) {
|
||
var c = classified[j];
|
||
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); });
|
||
|
||
// 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.');
|
||
}
|
||
|
||
// 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 'Profile: ' + (fm.profile || '—');
|
||
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;
|
||
}
|
||
|
||
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 bodyHtml = '<div class="fleet-grid">' + tilesHtml + '</div>';
|
||
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);
|
||
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');
|
||
if (!anchorId) return;
|
||
var badge = viewport.querySelector('.voyage-anchor-badge[data-anchor-id="' + anchorId + '"]');
|
||
if (badge && badge.parentElement) {
|
||
badge.parentElement.classList.add('voyage-anchor-active');
|
||
badge.parentElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
badge.focus();
|
||
}
|
||
}
|
||
|
||
// ---- 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 wiring after each render.
|
||
var originalMountRender = mountRender;
|
||
mountRender = function (text) {
|
||
originalMountRender(text);
|
||
injectAnchorBadges();
|
||
ensurePageNoteButton();
|
||
};
|
||
|
||
// ---- 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>' : '') +
|
||
'<div class="critique-card__comment">' + escapeHtmlInline(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;
|
||
}
|
||
}
|
||
|
||
function wireSidebar() {
|
||
var toggle = $('voyage-sidebar-toggle');
|
||
var sidebar = $('voyage-sidebar');
|
||
if (toggle && sidebar) {
|
||
toggle.addEventListener('click', function () {
|
||
var hidden = sidebar.getAttribute('aria-hidden') === 'true';
|
||
sidebar.setAttribute('aria-hidden', hidden ? 'false' : 'true');
|
||
toggle.setAttribute('aria-expanded', hidden ? 'true' : 'false');
|
||
});
|
||
}
|
||
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, '"') + '"');
|
||
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 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();
|
||
}
|
||
|
||
function setThemeLabel(theme) {
|
||
var lbl = document.querySelector('[data-theme-label]');
|
||
if (lbl) lbl.textContent = theme === 'light' ? '☾' : '☀';
|
||
}
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|