#!/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 += ``; html += '\n\n'; for (const r of body) { html += ''; for (let k = 0; k < header.length; k++) html += ``; html += '\n'; } html += '\n
${renderInline(escapeHtml(c))}
${renderInline(escapeHtml(r[k] || ''))}
\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 ? '
    ' : '
' : ''; } 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' + '

' + escapeHtml(title) + '

\n' + '

' + escapeHtml(fileName) + '

\n' + '
\n' + '
\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)') + '
' + '
' + escHtml(a.comment || '(no comment)') + '
' + '
'; } } 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 };