#!/usr/bin/env node
// scripts/annotate.mjs
//
// Operator-annotation HTML for a voyage artifact (brief.md / plan.md /
// review.md). The producing commands run this on their last step and
// print the file:// link. The operator opens the HTML in their browser,
// the page renders the artifact as a proper article (headings, lists,
// paragraphs, code blocks — not raw lines), and the operator drives every
// annotation themselves: select text or click any element, choose intent
// (Fiks / Endre / Spørsmål), write a comment, save. The sidebar shows
// every annotation grouped by section; Copy Prompt assembles them into
// one structured markdown the operator pastes back into Claude.
//
// UX modelled on the claude-code-100x annotation surface
// (build-site.js, 2026 — same pencil-toggle, intent buttons, form popover,
// localStorage persistence, structured markdown export).
//
// • Operator drives every annotation. No Claude-generated suggestions.
// • Three intent categories: Fiks (fix) / Endre (change) / Spørsmål (question).
// • Element + selection anchoring — clicking an element captures it whole;
// selecting text inside an element captures the exact substring.
// • Section context auto-detected (nearest h1/h2 above).
// • Annotations persist in localStorage keyed on the absolute artifact path.
// • Zero npm deps, zero external network, deterministic output.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function deriveTitle(mdText, fallbackName) {
const { hasFrontmatter, frontmatter } = splitFrontmatter(mdText);
if (hasFrontmatter) {
const m = frontmatter.match(/^task:\s*(.+)$/m) || frontmatter.match(/^slug:\s*(.+)$/m);
if (m) return m[1].trim().replace(/^["']|["']$/g, '');
}
const h1 = mdText.match(/^#\s+(.+)$/m);
if (h1) return h1[1].trim();
return fallbackName;
}
// ---------------------------------------------------------------------------
// Markdown → HTML with data-anchor-id on every annotatable element.
// Hand-rolled subset matching what artifact templates emit.
// ---------------------------------------------------------------------------
function renderInline(escaped) {
let s = escaped.replace(/`([^`]+)`/g, (_, c) => `${c}`);
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, t, h) => {
const safe = /^(https?:|mailto:|#|\.|\/)/i.test(h) ? h : '#';
return `${t} `;
});
s = s.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c} `);
s = s.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => `${pre}${c} `);
return s;
}
function renderMarkdown(md) {
const lines = md.replace(/\r\n/g, '\n').split('\n');
let html = '';
let anchorId = 0;
const anchor = () => `anch-${anchorId++}`;
let i = 0;
let paraBuf = [];
const flushPara = () => {
if (paraBuf.length) {
const text = paraBuf.join(' ');
html += `
${renderInline(escapeHtml(text))}
\n`;
paraBuf = [];
}
};
while (i < lines.length) {
const line = lines[i];
// Fenced code block — NOT annotatable as a whole; we keep it readable
// but skip the data-anchor-id so the operator clicks around it.
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
if (fence) {
flushPara();
const marker = fence[2][0];
const lang = (fence[3] || '').trim().split(/\s+/)[0];
const buf = [];
i++;
while (i < lines.length && !new RegExp('^\\s*' + marker + '{3,}\\s*$').test(lines[i])) {
buf.push(lines[i]);
i++;
}
i++; // closing fence
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
html += `${escapeHtml(buf.join('\n'))}\n \n`;
continue;
}
// ATX heading
const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
if (h) {
flushPara();
const lvl = h[1].length;
html += `${renderInline(escapeHtml(h[2]))} \n`;
i++;
continue;
}
// Horizontal rule
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
flushPara();
html += ' \n';
i++;
continue;
}
// Table
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length &&
/^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
flushPara();
const rows = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(lines[i]); i++; }
const cells = (l) => l.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
const header = cells(rows[0]);
const body = rows.slice(2).map(cells);
html += '\n';
for (const c of header) html += `${renderInline(escapeHtml(c))} `;
html += ' \n\n';
for (const r of body) {
html += '';
for (let k = 0; k < header.length; k++) html += `${renderInline(escapeHtml(r[k] || ''))} `;
html += ' \n';
}
html += ' \n
\n';
continue;
}
// Blockquote
if (/^\s*>\s?/.test(line)) {
flushPara();
const buf = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
buf.push(lines[i].replace(/^\s*>\s?/, ''));
i++;
}
html += `${renderInline(escapeHtml(buf.join(' ')))} \n`;
continue;
}
// Lists — one block, allow blank lines between items
const listMatch = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
if (listMatch) {
flushPara();
const items = [];
while (i < lines.length) {
const m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
if (m) {
items.push({ indent: m[1].length, ordered: /\d/.test(m[2]), text: m[3] });
i++;
} else if (lines[i].trim() === '' && i + 1 < lines.length &&
lines[i + 1].match(/^(\s*)([-*+]|\d+[.)])\s+/)) {
i++;
} else {
break;
}
}
html += renderList(items, anchor);
continue;
}
// Blank
if (line.trim() === '') {
flushPara();
i++;
continue;
}
// Default: paragraph accumulation
paraBuf.push(line.trim());
i++;
}
flushPara();
return html;
}
function renderList(items, anchor) {
let html = '';
const stack = [];
for (const { indent, ordered, text } of items) {
while (stack.length && (indent < stack[stack.length - 1].indent ||
(indent === stack[stack.length - 1].indent && ordered !== stack[stack.length - 1].ordered))) {
const top = stack.pop();
html += top.ordered ? '' : '';
}
if (!stack.length || indent > stack[stack.length - 1].indent) {
html += ordered ? '' : '';
stack.push({ indent, ordered });
} else {
html += '';
}
html += `${renderInline(escapeHtml(text))}`;
}
while (stack.length) {
const top = stack.pop();
html += top.ordered ? ' ' : '';
}
return html + '\n';
}
// ---------------------------------------------------------------------------
// Build full HTML document
// ---------------------------------------------------------------------------
function buildHtml(artifactPath, mdText) {
const fileName = basename(artifactPath);
const title = deriveTitle(mdText, fileName);
const { body } = splitFrontmatter(mdText);
const articleHtml = renderMarkdown(body);
return '\n'
+ '\n'
+ '\n'
+ ' \n'
+ ' \n'
+ '' + escapeHtml(title) + ' — annotate \n'
+ '\n'
+ '\n'
+ '\n'
+ '\n'
+ '\n'
+ ' Click any heading, paragraph, list item, table cell, or quote to add an annotation. To anchor on a specific phrase, select the text first , then click. Toggle annotation mode off (pencil button) to read normally / follow links.
\n'
+ ' \n'
+ articleHtml
+ '\n \n'
+ ' \n'
+ '\n'
+ '\n'
+ '
\n'
+ '
\n'
+ '\n'
+ '\n'
+ '\n';
}
// ---------------------------------------------------------------------------
// Stylesheet — light + dark + print. Design-system-aligned.
// ---------------------------------------------------------------------------
const STYLE = `
:root {
--bg: #f7f7f8;
--bg-elev: #ffffff;
--bg-soft: #ececef;
--border: #d6d8dc;
--border-strong: #b3b7bd;
--text: #1a1a1a;
--text-dim: #555a63;
--text-mute: #8a8f97;
--accent: #0855a8;
--accent-soft: #e4ecf6;
--amber: #a86b00;
--amber-soft: #fbeed1;
--green: #1a7f37;
--green-soft: #d5ecdb;
--red: #b3262d;
--red-soft: #f6d9da;
--blue: #0855a8;
--blue-soft: #e4ecf6;
--orange: #d4790a;
--orange-soft: #fceede;
--purple: #6638b6;
--purple-soft: #ebe1f9;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
--serif: ui-serif, "Source Serif 4", Georgia, "Times New Roman", serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1218;
--bg-elev: #161b22;
--bg-soft: #1c232c;
--border: #2a323c;
--border-strong: #3b4554;
--text: #e5e9ef;
--text-dim: #a5adba;
--text-mute: #6e7681;
--accent: #6db0ee;
--accent-soft: rgba(109, 176, 238, 0.15);
--amber: #d4a017;
--amber-soft: rgba(212, 160, 23, 0.12);
--green: #3fb950;
--green-soft: rgba(63, 185, 80, 0.12);
--red: #f0626a;
--red-soft: rgba(240, 98, 106, 0.12);
--blue: #6db0ee;
--blue-soft: rgba(109, 176, 238, 0.15);
--orange: #f6ad55;
--orange-soft: rgba(246, 173, 85, 0.15);
--purple: #d2a8ff;
--purple-soft: rgba(210, 168, 255, 0.15);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text);
font-family: var(--sans); font-size: 15px; line-height: 1.6; }
body { min-height: 100vh; }
/* Topbar */
.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 12px 24px; background: var(--bg-elev); border-bottom: 1px solid var(--border); }
.hdr-meta h1 { font-size: 16px; font-weight: 650; margin: 0; }
.hdr-meta .path { color: var(--text-dim); font-size: 12px; font-family: var(--mono); margin: 2px 0 0; word-break: break-all; }
.hdr-actions { display: flex; gap: 8px; align-items: center; }
.ann-toggle { display: inline-flex; align-items: center; gap: 6px;
background: var(--accent); color: #fff; border: 1px solid var(--accent);
border-radius: 5px; padding: 6px 12px; font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.ann-toggle:hover { filter: brightness(1.05); }
body:not(.ann-mode) .ann-toggle { background: var(--bg-soft); color: var(--text-dim); border-color: var(--border); }
body:not(.ann-mode) .ann-toggle:hover { color: var(--text); border-color: var(--border-strong); }
.ann-badge { background: rgba(255,255,255,0.25); color: inherit; padding: 0 6px; border-radius: 99px; font-size: 11px; font-weight: 700; }
body:not(.ann-mode) .ann-badge { background: var(--bg); color: var(--text-dim); }
.ghost-btn { background: transparent; color: var(--text-dim); border: 1px solid var(--border);
border-radius: 5px; padding: 6px 12px; font-family: inherit; font-size: 13px; cursor: pointer; }
.ghost-btn:hover { color: var(--text); border-color: var(--border-strong); }
.icon-btn { background: transparent; border: none; color: var(--text-dim); cursor: pointer;
font-size: 16px; padding: 4px 8px; border-radius: 4px; }
.icon-btn:hover { color: var(--text); background: var(--bg-soft); }
/* Article */
.article-wrap { max-width: 820px; margin: 0 auto; padding: 24px 32px 96px; }
.article-help { font-size: 13px; color: var(--text-dim); background: var(--accent-soft);
border: 1px solid var(--accent); border-radius: 6px; padding: 10px 14px; margin: 0 0 24px; line-height: 1.5; }
body:not(.ann-mode) .article-help { display: none; }
.article-help strong { color: var(--text); }
.article { font-size: 15px; line-height: 1.7; }
.article h1, .article h2, .article h3, .article h4, .article h5, .article h6 {
font-family: var(--serif); font-weight: 700; line-height: 1.25; margin: 1.8em 0 .55em; color: var(--text); }
.article h1 { font-size: 2rem; margin-top: 0; }
.article h2 { font-size: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: .3em; }
.article h3 { font-size: 1.2rem; }
.article h4 { font-size: 1.05rem; }
.article p { margin: .9em 0; }
.article a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
.article code { font-family: var(--mono); font-size: .9em; background: var(--bg-soft);
padding: .12em .4em; border-radius: 4px; }
.article pre { background: #1e1e24; color: #e6e6eb; padding: 16px 18px; border-radius: 8px;
overflow-x: auto; font-size: .88rem; line-height: 1.55; margin: 1.2em 0; }
.article pre code { background: none; padding: 0; color: inherit; font-size: inherit; }
.article blockquote { margin: 1.2em 0; padding: .5em 1.2em; border-left: 4px solid var(--accent);
background: var(--accent-soft); color: var(--text-dim); border-radius: 0 6px 6px 0; }
.article ul, .article ol { padding-left: 1.8em; margin: .9em 0; }
.article li { margin: .3em 0; }
.article table { border-collapse: collapse; width: 100%; margin: 1.4em 0; font-size: .92em; }
.article th, .article td { border: 1px solid var(--border); padding: .55em .8em; text-align: left; vertical-align: top; }
.article th { background: var(--bg-soft); font-weight: 650; }
.article hr { border: none; border-top: 1px solid var(--border); margin: 2.2em 0; }
.article strong { font-weight: 700; }
.article em { font-style: italic; }
/* Annotation mode: highlight annotatable elements on hover, mark annotated ones */
.article [data-anchor-id] { position: relative; transition: background .08s, outline .08s; border-radius: 3px; }
body.ann-mode .article [data-anchor-id] { cursor: pointer; }
body.ann-mode .article [data-anchor-id]:hover {
outline: 2px dashed var(--accent); outline-offset: 2px; background: var(--accent-soft);
}
.article [data-anchor-id].annotated {
background: var(--amber-soft);
outline: 1px solid var(--amber); outline-offset: 1px;
}
.article [data-anchor-id].annotated::after {
content: attr(data-ann-count); position: absolute; right: -22px; top: 2px;
background: var(--amber); color: #fff; font-size: 10px; font-weight: 700;
padding: 1px 6px; border-radius: 99px; font-family: var(--sans);
}
body.ann-mode .article [data-anchor-id].annotated:hover { outline-color: var(--amber); }
.article [data-anchor-id].flash {
animation: flash 1.6s ease-out;
}
@keyframes flash {
0% { background: var(--accent-soft); outline: 2px solid var(--accent); }
100% { background: var(--amber-soft); outline: 1px solid var(--amber); }
}
/* Form popover */
.ann-form { position: fixed; z-index: 200; background: var(--bg-elev); border: 1px solid var(--border-strong);
border-radius: 8px; padding: 14px; box-shadow: 0 8px 24px rgba(0,0,0,0.25);
width: 380px; max-width: calc(100vw - 24px); display: none; flex-direction: column; gap: 10px;
font-family: var(--sans); }
.ann-form.visible { display: flex; }
.ann-form-section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-mute); font-weight: 600; margin-bottom: 3px; }
.ann-form-section-value { font-size: 13px; color: var(--text-dim); font-style: italic; }
.ann-form-snippet-text { margin: 0; padding: 6px 10px; border-left: 3px solid var(--accent);
background: var(--bg); border-radius: 0 4px 4px 0; font-family: var(--mono); font-size: 12px;
color: var(--text); max-height: 100px; overflow-y: auto; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.ann-form-intents { display: flex; gap: 6px; }
.ann-intent { flex: 1; padding: 7px 10px; border-radius: 5px; border: 1px solid var(--border);
background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.ann-intent:hover { color: var(--text); border-color: var(--border-strong); }
.ann-intent[data-intent="fiks"].selected { background: var(--red); color: #fff; border-color: var(--red); }
.ann-intent[data-intent="endre"].selected { background: var(--orange); color: #fff; border-color: var(--orange); }
.ann-intent[data-intent="spørsmål"].selected { background: var(--blue); color: #fff; border-color: var(--blue); }
.ann-form-comment { width: 100%; min-height: 80px; padding: 8px 10px;
font-family: inherit; font-size: 13px; line-height: 1.5; color: var(--text);
background: var(--bg); border: 1px solid var(--border); border-radius: 5px; resize: vertical; }
.ann-form-comment:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
.ann-form-actions { display: flex; gap: 6px; justify-content: flex-end; }
.btn { padding: 6px 14px; border-radius: 5px; border: 1px solid var(--border);
background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.btn:hover { color: var(--text); border-color: var(--border-strong); }
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn.primary:hover:not(:disabled) { filter: brightness(1.1); color: #fff; }
.btn.primary:disabled { background: var(--bg-soft); color: var(--text-mute); border-color: var(--border); cursor: not-allowed; filter: none; }
/* Annotations panel (slide-in sidebar) */
.ann-panel { position: fixed; top: 0; right: 0; bottom: 0; width: 420px; max-width: 100vw;
background: var(--bg-elev); border-left: 1px solid var(--border); z-index: 150;
transform: translateX(100%); transition: transform .2s ease;
display: flex; flex-direction: column; box-shadow: -4px 0 20px rgba(0,0,0,0.15); }
.ann-panel.open { transform: translateX(0); }
.ann-panel-head { display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--border); }
.ann-panel-head h2 { font-size: 14px; font-weight: 650; margin: 0; }
.ann-panel-body { flex: 1; overflow-y: auto; padding: 12px 14px; }
.ann-panel-foot { display: flex; justify-content: space-between; gap: 8px;
padding: 12px 14px; border-top: 1px solid var(--border); }
.ann-panel-empty { color: var(--text-mute); font-size: 13px; text-align: center; padding: 32px 12px;
font-style: italic; line-height: 1.5; }
.ann-section { margin: 12px 0 6px; font-size: 11px; font-weight: 650; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--text-mute); padding: 0 4px; }
.ann-section:first-child { margin-top: 0; }
.ann-item { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 10px 12px; margin-bottom: 8px; cursor: pointer; }
.ann-item:hover { border-color: var(--border-strong); }
.ann-item .ann-item-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; gap: 6px; }
.ann-item-intent { font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em; padding: 2px 8px; border-radius: 99px; }
.ann-item-intent.fiks { background: var(--red-soft); color: var(--red); }
.ann-item-intent.endre { background: var(--orange-soft); color: var(--orange); }
.ann-item-intent.spørsmål { background: var(--blue-soft); color: var(--blue); }
.ann-item-delete { background: transparent; border: none; color: var(--text-mute);
cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.ann-item-delete:hover { color: var(--red); background: var(--red-soft); }
.ann-item-snippet { font-family: var(--mono); font-size: 11px; color: var(--text-mute);
margin: 0 0 6px; line-height: 1.5; padding: 4px 8px; background: var(--bg-soft);
border-left: 2px solid var(--border-strong); border-radius: 0 4px 4px 0;
max-height: 60px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; }
.ann-item-comment { font-size: 13px; color: var(--text); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.ann-item-comment.empty { color: var(--text-mute); font-style: italic; }
/* Toast */
.ann-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--text); color: var(--bg-elev); padding: 9px 16px; border-radius: 6px;
font-size: 13px; font-weight: 500; opacity: 0; pointer-events: none;
transition: opacity .2s, transform .2s; z-index: 300; }
.ann-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
/* Overlay (form backdrop) */
.ann-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 100;
opacity: 0; pointer-events: none; transition: opacity .15s; }
.ann-overlay.visible { opacity: 1; pointer-events: auto; }
/* Scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 6px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-mute); }
/* Print: hide annotation chrome, show article only */
@media print {
.topbar, .ann-form, .ann-panel, .ann-toast, .ann-overlay, .article-help { display: none !important; }
.article-wrap { max-width: none; padding: 0; }
body { background: #fff; color: #000; }
}
`.trim();
// ---------------------------------------------------------------------------
// Embedded JS app. Uses concatenation (no template literals) to avoid
// backtick collisions with the outer mjs string assembly.
// ---------------------------------------------------------------------------
const APP_JS = `
const STORAGE_KEY = 'voyage-annotate:v2:' + ARTIFACT_PATH;
const INTENT_LABELS = { fiks: 'Fiks', endre: 'Endre', 'spørsmål': 'Spørsmål' };
const INTENT_ORDER = ['fiks', 'endre', 'spørsmål'];
let annotations = [];
let nextId = 1;
let mode = true;
let currentTarget = null;
let currentSection = null;
let currentSnippet = null;
let currentIntent = null;
// ── Storage ──
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data && Array.isArray(data.annotations)) {
annotations = data.annotations;
nextId = data.nextId || (annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1);
}
} catch (e) {}
}
function saveState() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: annotations, nextId: nextId })); } catch (e) {}
}
function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }
// ── DOM refs ──
const body = document.body;
const article = document.getElementById('article');
const form = document.getElementById('ann-form');
const formSection = document.getElementById('ann-form-section');
const formSnippet = document.getElementById('ann-form-snippet');
const formComment = document.getElementById('ann-form-comment');
const formSave = document.getElementById('ann-form-save');
const formCancel = document.getElementById('ann-form-cancel');
const intents = document.querySelectorAll('.ann-intent');
const panel = document.getElementById('ann-panel');
const panelBody = document.getElementById('ann-panel-body');
const panelCloseBtn = document.getElementById('ann-panel-close');
const openPanelBtn = document.getElementById('open-panel');
const clearAllBtn = document.getElementById('ann-clear-all');
const copyBtn = document.getElementById('ann-copy');
const annToggle = document.getElementById('ann-toggle');
const annToggleLabel = document.getElementById('ann-toggle-label');
const annBadge = document.getElementById('ann-badge');
const toast = document.getElementById('ann-toast');
const overlay = document.getElementById('ann-overlay');
// ── Section lookup ──
function findSection(el) {
let p = el;
while (p) {
if (p.previousElementSibling) {
let s = p.previousElementSibling;
while (s) {
if (s.matches && (s.matches('h1') || s.matches('h2'))) return s.textContent.trim();
s = s.previousElementSibling;
}
}
p = p.parentElement;
if (p && p.tagName === 'ARTICLE') break;
}
// Fallback: first h1 in article
const firstH = article.querySelector('h1, h2');
return firstH ? firstH.textContent.trim() : '(top)';
}
// ── Snippet from selection or element text ──
function captureSnippet(el) {
const sel = window.getSelection();
if (sel && sel.toString().trim().length > 0 && el.contains(sel.anchorNode)) {
return sel.toString().trim().slice(0, 300);
}
return (el.textContent || '').trim().slice(0, 200);
}
// ── Form open/close ──
function openForm(evt, target) {
currentTarget = target;
currentSection = findSection(target);
currentSnippet = captureSnippet(target);
currentIntent = null;
formSection.textContent = currentSection || '(top)';
formSnippet.textContent = currentSnippet || '(empty)';
formComment.value = '';
intents.forEach(function(b) { b.classList.remove('selected'); });
formSave.disabled = true;
// Position near the click (clamped to viewport)
const fw = 380, fh = 320;
let x = evt.clientX + 14;
let y = evt.clientY + 14;
if (x + fw > window.innerWidth) x = window.innerWidth - fw - 12;
if (y + fh > window.innerHeight) y = Math.max(12, window.innerHeight - fh - 12);
if (x < 12) x = 12;
if (y < 12) y = 12;
form.style.left = x + 'px';
form.style.top = y + 'px';
form.classList.add('visible');
overlay.classList.add('visible');
setTimeout(function() { formComment.focus(); }, 50);
}
function closeForm() {
form.classList.remove('visible');
overlay.classList.remove('visible');
currentTarget = null;
currentSection = null;
currentSnippet = null;
currentIntent = null;
}
// ── Save ──
function saveAnnotation() {
if (!currentIntent || !currentTarget) return;
const a = {
id: nextId++,
anchorId: currentTarget.getAttribute('data-anchor-id'),
section: currentSection || '(top)',
snippet: currentSnippet || '',
intent: currentIntent,
comment: (formComment.value || '').trim(),
ts: new Date().toISOString(),
};
annotations.push(a);
saveState();
closeForm();
refreshArticleAnnotations();
renderPanel();
updateCounts();
showToast('Annotasjon lagret (' + annotations.length + ')');
}
// ── Delete ──
function deleteAnnotation(id) {
annotations = annotations.filter(function(a) { return a.id !== id; });
saveState();
refreshArticleAnnotations();
renderPanel();
updateCounts();
showToast('Annotasjon slettet');
}
// ── Refresh article markers ──
function refreshArticleAnnotations() {
// Clear all current markers
article.querySelectorAll('[data-anchor-id].annotated').forEach(function(el) {
el.classList.remove('annotated');
el.removeAttribute('data-ann-count');
});
// Group by anchorId
const byAnchor = {};
for (const a of annotations) {
if (!a.anchorId) continue;
if (!byAnchor[a.anchorId]) byAnchor[a.anchorId] = 0;
byAnchor[a.anchorId]++;
}
for (const anchorId in byAnchor) {
const el = article.querySelector('[data-anchor-id="' + CSS.escape(anchorId) + '"]');
if (el) {
el.classList.add('annotated');
el.setAttribute('data-ann-count', byAnchor[anchorId]);
}
}
}
// ── Sidebar panel render ──
function renderPanel() {
if (annotations.length === 0) {
panelBody.innerHTML = 'No annotations yet. Click any heading, paragraph, list item, or quote in the article to add one.
';
return;
}
// Group by section (preserve insertion order)
const groups = [];
const groupMap = {};
// Sort by document order using anchorId numerical suffix
const sorted = annotations.slice().sort(function(a, b) {
const ai = parseInt((a.anchorId || '').replace('anch-', ''), 10) || 0;
const bi = parseInt((b.anchorId || '').replace('anch-', ''), 10) || 0;
if (ai !== bi) return ai - bi;
return a.id - b.id;
});
for (const a of sorted) {
if (!groupMap[a.section]) {
groupMap[a.section] = { section: a.section, items: [] };
groups.push(groupMap[a.section]);
}
groupMap[a.section].items.push(a);
}
let html = '';
for (const g of groups) {
html += '' + escHtml(g.section) + '
';
for (const a of g.items) {
html += ''
+ '
'
+ '' + escHtml(INTENT_LABELS[a.intent] || a.intent) + ' '
+ '✕ '
+ '
'
+ '
' + escHtml(a.snippet || '(empty)') + ' '
+ ''
+ '
';
}
}
panelBody.innerHTML = html;
panelBody.querySelectorAll('.ann-item-delete').forEach(function(b) {
b.addEventListener('click', function(e) {
e.stopPropagation();
if (confirm('Delete this annotation?')) deleteAnnotation(parseInt(b.dataset.del, 10));
});
});
panelBody.querySelectorAll('.ann-item').forEach(function(card) {
card.addEventListener('click', function() {
const anchor = card.getAttribute('data-anchor-id');
const el = article.querySelector('[data-anchor-id="' + CSS.escape(anchor) + '"]');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
}
});
});
}
// ── Counts + toggle label ──
function updateCounts() {
annBadge.textContent = String(annotations.length);
copyBtn.disabled = annotations.length === 0;
}
function setMode(on) {
mode = on;
body.classList.toggle('ann-mode', on);
annToggleLabel.textContent = on ? 'Annotation mode: ON' : 'Annotation mode: OFF';
if (!on) closeForm();
}
// ── Toast ──
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('visible');
setTimeout(function() { toast.classList.remove('visible'); }, 1800);
}
// ── Copy Prompt ──
function buildPromptMarkdown() {
if (annotations.length === 0) return '';
const sorted = annotations.slice().sort(function(a, b) {
const ai = parseInt((a.anchorId || '').replace('anch-', ''), 10) || 0;
const bi = parseInt((b.anchorId || '').replace('anch-', ''), 10) || 0;
if (ai !== bi) return ai - bi;
return a.id - b.id;
});
let p = 'Please revise the voyage artifact at \\\`' + ARTIFACT_PATH + '\\\` with the operator annotations below.\\n';
p += 'Each annotation has an intent — **Fiks** (something is wrong / fix it), **Endre** (change wording/content),\\n';
p += 'or **Spørsmål** (operator question — clarify or answer). The quote shows what the operator anchored to.\\n';
p += 'Treat the operator notes as authoritative direction.\\n\\n';
p += '## Annotations (' + annotations.length + ' total)\\n\\n';
let n = 0;
for (const a of sorted) {
n++;
p += '### ' + n + '. [' + (INTENT_LABELS[a.intent] || a.intent) + '] Section: ' + a.section + '\\n';
if (a.snippet) p += 'Quote: «' + a.snippet + '»\\n';
p += 'Comment: ' + (a.comment || '(no comment)') + '\\n\\n';
}
return p;
}
async function copyPrompt() {
const md = buildPromptMarkdown();
if (!md) return;
try {
await navigator.clipboard.writeText(md);
showToast('Prompt copied (' + annotations.length + ' annotation' + (annotations.length === 1 ? '' : 's') + ')');
} catch (e) {
// Fallback
const ta = document.createElement('textarea');
ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); showToast('Prompt copied'); } catch (e2) { alert('Copy failed: ' + e2.message); }
ta.remove();
}
}
// ── Wiring ──
article.addEventListener('click', function(e) {
if (!mode) return;
const target = e.target.closest('[data-anchor-id]');
if (!target) return;
// Don't open form when clicking inside an already-open form (overlay catches outside clicks)
if (e.target.closest('.ann-form')) return;
// Don't open form when clicking a link the user wants to follow — but only if they didn't select text
if (e.target.tagName === 'A' && (!window.getSelection() || window.getSelection().toString().trim().length === 0)) {
// Allow link clicks in mode if no selection
return;
}
e.preventDefault();
openForm(e, target);
});
intents.forEach(function(b) {
b.addEventListener('click', function() {
intents.forEach(function(x) { x.classList.remove('selected'); });
b.classList.add('selected');
currentIntent = b.dataset.intent;
formSave.disabled = false;
});
});
formSave.addEventListener('click', saveAnnotation);
formCancel.addEventListener('click', closeForm);
overlay.addEventListener('click', closeForm);
formComment.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !formSave.disabled) {
saveAnnotation();
} else if (e.key === 'Escape') {
closeForm();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && form.classList.contains('visible')) closeForm();
});
annToggle.addEventListener('click', function() { setMode(!mode); });
openPanelBtn.addEventListener('click', function() {
panel.classList.toggle('open');
});
panelCloseBtn.addEventListener('click', function() { panel.classList.remove('open'); });
clearAllBtn.addEventListener('click', function() {
if (annotations.length === 0) return;
if (confirm('Remove all ' + annotations.length + ' annotations? This cannot be undone.')) {
annotations = [];
saveState();
refreshArticleAnnotations();
renderPanel();
updateCounts();
showToast('All annotations cleared');
}
});
copyBtn.addEventListener('click', copyPrompt);
// ── Init ──
loadState();
refreshArticleAnnotations();
renderPanel();
updateCounts();
setMode(true);
`.trim();
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { input: null, out: null, help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--out') args.out = argv[++i];
else if (a === '--help' || a === '-h') args.help = true;
else if (!args.input) args.input = a;
}
return args;
}
function render(inputPath, outputPath) {
if (!existsSync(inputPath)) {
process.stderr.write('annotate: input not found: ' + inputPath + '\n');
process.exit(2);
}
const text = readFileSync(inputPath, 'utf-8');
const html = buildHtml(resolve(inputPath), text);
const out = outputPath || inputPath.replace(/\.md$/, '.html');
writeFileSync(out, html);
return out;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.input) {
process.stdout.write(
'Usage: annotate [--out ]\n\n'
+ 'Builds a self-contained operator-annotation HTML for a voyage\n'
+ 'artifact. The operator opens the HTML, selects text or clicks any\n'
+ 'element, picks an intent (Fiks / Endre / Spørsmål), writes a\n'
+ 'comment, and copies a structured prompt to paste back into Claude.\n'
+ 'Annotations persist in localStorage per artifact path.\n\n'
+ 'Default output: .html next to input.\n',
);
process.exit(args.help ? 0 : 2);
}
const out = render(args.input, args.out);
process.stdout.write(out + '\n');
}
export { render, buildHtml, renderMarkdown, parseArgs };