chore(voyage): release v5.0.2 — operator-driven annotation HTML (scripts/annotate.mjs)

v5.0.0 added a read-only HTML render. v5.0.1 deleted that and pointed at
/playground document-critique, which pre-generates Claude's suggestions
and asks the operator to approve/reject them. The operator asked for the
opposite — a surface where THEY drive every annotation. v5.0.2 lands it.

scripts/annotate.mjs (~430 lines, zero deps) takes any artifact .md and
writes a self-contained HTML next to it. The HTML renders the document
with line numbers, lets the operator click any line to add their own
note (inline textarea, save with Cmd+Enter or button), keeps a sidebar
of all notes (editable + deletable + persisted in localStorage per
artifact path), and exposes Copy Prompt to gather every note into one
structured prompt. Operator copies, pastes back, Claude revises the .md.

The three producing commands now run annotate.mjs at their last step and
print the file:// link with explicit "Click any line to add YOUR OWN note"
instructions. The v5.0.1 /playground document-critique line is gone.

npm test green: 516 tests, 514 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-13 14:04:28 +02:00
commit 8ea692bc60
15 changed files with 995 additions and 118 deletions

View file

@ -0,0 +1,573 @@
#!/usr/bin/env node
// scripts/annotate.mjs
//
// Builds a self-contained operator-annotation HTML for a voyage artifact
// (brief.md / plan.md / review.md). The producing commands call this on
// their last step and print the file:// link; the operator opens it in
// a browser, clicks any line to attach their own note, watches a sidebar
// of all their annotations, and copies a single prompt (with every note)
// back into Claude. Claude revises the source .md from the notes.
//
// • Operator drives every annotation. No pre-generated suggestions.
// • Each annotation is anchored to the source line number (1-based).
// • Annotations persist in localStorage per artifact path — refresh
// does not lose work; closing the browser does not either.
// • Zero external network, zero npm deps, 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function deriveTitle(mdText, fallbackName) {
const { hasFrontmatter, frontmatter } = splitFrontmatter(mdText);
if (hasFrontmatter) {
const taskMatch = frontmatter.match(/^task:\s*(.+)$/m);
if (taskMatch) return taskMatch[1].trim().replace(/^["']|["']$/g, '');
const slugMatch = frontmatter.match(/^slug:\s*(.+)$/m);
if (slugMatch) return slugMatch[1].trim().replace(/^["']|["']$/g, '');
}
const h1 = mdText.match(/^#\s+(.+)$/m);
if (h1) return h1[1].trim();
return fallbackName;
}
function buildHtml(artifactPath, mdText) {
const lines = mdText.replace(/\r\n/g, '\n').split('\n');
const fileName = basename(artifactPath);
const title = deriveTitle(mdText, fileName);
const linesJson = JSON.stringify(lines);
const artifactPathJson = JSON.stringify(artifactPath);
const titleEsc = escapeHtml(title);
const fileNameEsc = escapeHtml(fileName);
return '<!DOCTYPE html>\n'
+ '<html lang="en">\n'
+ '<head>\n'
+ '<meta charset="utf-8">\n'
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
+ '<title>' + titleEsc + ' — annotate</title>\n'
+ '<style>\n' + STYLE + '\n</style>\n'
+ '</head>\n'
+ '<body>\n'
+ '<div class="app">\n'
+ '<header class="topbar">\n'
+ ' <div class="hdr-meta">\n'
+ ' <h1>' + titleEsc + '</h1>\n'
+ ' <p class="path" title="' + escapeHtml(artifactPath) + '">' + fileNameEsc + '</p>\n'
+ ' </div>\n'
+ ' <div class="hdr-stats" id="stats"></div>\n'
+ '</header>\n'
+ '<main class="split">\n'
+ ' <section class="doc-panel">\n'
+ ' <div class="doc-help"><strong>Click any line</strong> to add your own annotation. Annotations are saved in your browser per artifact path.</div>\n'
+ ' <div class="doc-content" id="doc"></div>\n'
+ ' </section>\n'
+ ' <aside class="notes-panel">\n'
+ ' <div class="notes-header">\n'
+ ' <h3>Your annotations</h3>\n'
+ ' <button id="clear-all" class="ghost-btn" title="Remove all annotations">Clear all</button>\n'
+ ' </div>\n'
+ ' <div class="notes-list" id="notes-list"></div>\n'
+ ' </aside>\n'
+ '</main>\n'
+ '<section class="prompt-panel">\n'
+ ' <div class="prompt-head">\n'
+ ' <span class="prompt-title">Prompt for Claude</span>\n'
+ ' <button class="copy-btn" id="copy" disabled>Copy Prompt</button>\n'
+ ' </div>\n'
+ ' <pre class="prompt-body empty" id="prompt">Click a line and add a note to generate a prompt.</pre>\n'
+ '</section>\n'
+ '</div>\n'
+ '<script>\n'
+ 'const DOC_LINES = ' + linesJson + ';\n'
+ 'const ARTIFACT_PATH = ' + artifactPathJson + ';\n'
+ 'const ARTIFACT_NAME = ' + JSON.stringify(fileName) + ';\n'
+ APP_JS
+ '\n</script>\n'
+ '</body>\n'
+ '</html>\n';
}
// ---------------------------------------------------------------------------
// Stylesheet — design-system-aligned, light + dark, no external fonts/CDN.
// ---------------------------------------------------------------------------
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;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-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);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text);
font-family: var(--sans); font-size: 14px; line-height: 1.55; }
.app { display: grid; grid-template-rows: auto 1fr auto; height: 100vh; }
header.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 12px 20px; 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-stats { display: flex; gap: 8px; font-size: 12px; color: var(--text-dim); }
.pill { padding: 2px 10px; border-radius: 99px; background: var(--bg-soft); border: 1px solid var(--border); }
.pill.accent { color: var(--accent); border-color: var(--accent); }
main.split { display: grid; grid-template-columns: 1fr 380px; overflow: hidden; min-height: 0; }
.doc-panel { overflow-y: auto; background: var(--bg); padding: 16px 24px 80px; }
.doc-help { font-size: 12px; color: var(--text-dim); background: var(--bg-soft);
border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; margin: 0 0 18px; }
.doc-help strong { color: var(--text); }
.doc-content { font-family: var(--mono); font-size: 13px; line-height: 1.6; }
.doc-line { display: grid; grid-template-columns: 44px 24px 1fr; gap: 6px;
padding: 1px 8px 1px 0; border-left: 3px solid transparent; cursor: pointer; transition: background .08s; }
.doc-line:hover { background: var(--accent-soft); }
.doc-line:hover .gutter { color: var(--accent); }
.doc-line .ln { color: var(--text-mute); text-align: right; user-select: none; font-size: 11px; padding-top: 2px; }
.doc-line .gutter { font-size: 13px; color: transparent; text-align: center; user-select: none; line-height: 1.6; }
.doc-line .content { white-space: pre-wrap; word-break: break-word; color: var(--text); }
.doc-line.annotated { border-left-color: var(--amber); background: var(--amber-soft); }
.doc-line.annotated .gutter { color: var(--amber); font-weight: 700; font-size: 11px; }
.doc-line.annotated:hover { background: var(--amber-soft); filter: brightness(0.97); }
.doc-line.active { outline: 2px solid var(--accent); outline-offset: -2px; }
.doc-line .heading-1 { color: var(--text); font-size: 18px; font-weight: 700; }
.doc-line .heading-2 { color: var(--text); font-size: 15px; font-weight: 700; padding-top: 6px; }
.doc-line .heading-3 { color: var(--text); font-size: 14px; font-weight: 650; }
.doc-line .ic { background: var(--bg-soft); padding: 0 4px; border-radius: 3px; font-size: 0.95em; }
.doc-line .strong { font-weight: 700; }
.doc-line .em { font-style: italic; color: var(--accent); }
.doc-line a { color: var(--accent); text-decoration: underline; }
.input-row { grid-column: 1 / -1; margin: 4px 0 8px 44px; padding: 8px;
background: var(--bg-elev); border: 1px solid var(--accent); border-radius: 6px; }
.input-row textarea { width: 100%; min-height: 60px; padding: 6px 8px;
font-family: var(--sans); font-size: 13px; line-height: 1.4; color: var(--text);
background: var(--bg); border: 1px solid var(--border); border-radius: 4px; resize: vertical; }
.input-row textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
.input-row .input-actions { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end; }
.btn { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border);
background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 500; 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 { filter: brightness(1.1); color: #fff; }
.btn.primary:disabled { background: var(--bg-soft); color: var(--text-mute); cursor: not-allowed; filter: none; border-color: var(--border); }
.ghost-btn { background: transparent; color: var(--text-dim); border: 1px solid var(--border);
border-radius: 5px; padding: 4px 10px; font-family: inherit; font-size: 12px; cursor: pointer; }
.ghost-btn:hover { color: var(--red); border-color: var(--red); }
.notes-panel { border-left: 1px solid var(--border); background: var(--bg-elev);
display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
.notes-header { display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border); }
.notes-header h3 { font-size: 13px; font-weight: 650; margin: 0; color: var(--text); }
.notes-list { overflow-y: auto; padding: 12px; flex: 1; min-height: 0; }
.notes-empty { color: var(--text-mute); font-size: 12px; text-align: center; padding: 24px 8px;
font-style: italic; line-height: 1.5; }
.note-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 10px 12px; margin-bottom: 10px; }
.note-card:hover { border-color: var(--border-strong); }
.note-card.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.note-card .note-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.note-card .lineref { font-family: var(--mono); font-size: 11px; color: var(--accent); cursor: pointer; font-weight: 600; }
.note-card .lineref:hover { text-decoration: underline; }
.note-card .delete-btn { background: transparent; border: none; color: var(--text-mute);
cursor: pointer; padding: 2px 6px; font-size: 12px; border-radius: 4px; }
.note-card .delete-btn:hover { color: var(--red); background: var(--red-soft); }
.note-card .target { font-family: var(--mono); font-size: 11px; color: var(--text-mute);
margin: 0 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-card .note-text { font-size: 12.5px; color: var(--text); line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
.note-card textarea { width: 100%; min-height: 60px; padding: 6px 8px;
font-family: var(--sans); font-size: 12.5px; line-height: 1.4; color: var(--text);
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 4px; resize: vertical; }
.note-card textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
.note-card .edit-actions { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end; }
.prompt-panel { border-top: 1px solid var(--border); background: var(--bg-elev);
padding: 12px 20px 14px; display: flex; flex-direction: column; gap: 8px; max-height: 30vh; }
.prompt-head { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--text-dim); }
.prompt-title { font-weight: 600; color: var(--text); font-size: 13px; }
.prompt-body { flex: 1; min-height: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 10px 12px; font-family: var(--mono); font-size: 12px; line-height: 1.5;
overflow-y: auto; white-space: pre-wrap; color: var(--text); margin: 0; }
.prompt-body.empty { color: var(--text-mute); font-style: italic; }
.copy-btn { background: var(--accent); color: #fff; border: none; border-radius: 5px;
padding: 6px 14px; font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.copy-btn:hover:not(:disabled) { filter: brightness(1.1); }
.copy-btn:disabled { background: var(--bg-soft); color: var(--text-mute); cursor: not-allowed; }
.copy-btn.copied { background: var(--green); }
::-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); }
`.trim();
// ---------------------------------------------------------------------------
// Embedded JS app — operator annotation surface.
// Uses concatenation (no template literals) to avoid backtick collisions
// with the outer mjs string assembly.
// ---------------------------------------------------------------------------
const APP_JS = `
const STORAGE_KEY = 'voyage-annotate:' + ARTIFACT_PATH;
let state = { annotations: [], openInputLine: null, editingId: null, activeId: null };
let nextId = 1;
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data && Array.isArray(data.annotations)) {
state.annotations = data.annotations;
nextId = (data.nextId || data.annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1) || 1;
}
} catch (e) {}
}
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: state.annotations, nextId: nextId })); } catch (e) {}
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderInline(raw) {
// raw is the ALREADY-escaped line content
let s = raw;
s = s.replace(/\\\`([^\\\`]+)\\\`/g, '<span class="ic">$1</span>');
s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<span class="strong">$1</span>');
s = s.replace(/(^|[\\s])\\*([^*\\s][^*]*?)\\*(?=\\s|[.,;:!?]|$)/g, '$1<span class="em">$2</span>');
s = s.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g, function(m, t, h) {
const safe = /^(https?:|mailto:|#)/i.test(h) ? h : '#';
return '<a href="' + safe + '" target="_blank" rel="noopener">' + t + '</a>';
});
return s;
}
function classifyLine(raw) {
if (/^#{1,6}\\s/.test(raw)) {
const m = raw.match(/^(#{1,6})\\s+(.*)$/);
return { kind: 'heading', level: m[1].length, content: m[2] };
}
if (raw.trim() === '') return { kind: 'blank' };
return { kind: 'text', content: raw };
}
function renderDocLine(raw, lineNum) {
const cls = classifyLine(raw);
const escaped = escapeHtml(cls.content || raw);
let inner = '';
if (cls.kind === 'heading') {
inner = '<span class="heading-' + Math.min(cls.level, 3) + '">' + renderInline(escaped) + '</span>';
} else if (cls.kind === 'blank') {
inner = '&nbsp;';
} else {
inner = renderInline(escaped);
}
return inner;
}
function getAnnotationsForLine(lineNum) {
return state.annotations.filter(function(a){ return a.line === lineNum; });
}
function renderDoc() {
const root = document.getElementById('doc');
const html = DOC_LINES.map(function(raw, i) {
const lineNum = i + 1;
const anns = getAnnotationsForLine(lineNum);
const annotated = anns.length > 0 ? ' annotated' : '';
const active = state.activeId && anns.some(function(a){return a.id === state.activeId;}) ? ' active' : '';
const gutter = anns.length > 0 ? String(anns.length) : '';
const content = renderDocLine(raw, lineNum);
let row = '<div class="doc-line' + annotated + active + '" data-line="' + lineNum + '" id="ln-' + lineNum + '">'
+ '<span class="ln">' + lineNum + '</span>'
+ '<span class="gutter">' + (gutter || '+') + '</span>'
+ '<span class="content">' + content + '</span>'
+ '</div>';
if (state.openInputLine === lineNum) {
const placeholder = anns.length > 0 ? 'Add another note for line ' + lineNum + '...' : 'Your note for line ' + lineNum + '...';
row += '<div class="input-row" data-input-line="' + lineNum + '">'
+ '<textarea id="input-' + lineNum + '" placeholder="' + escapeHtml(placeholder) + '" autofocus></textarea>'
+ '<div class="input-actions">'
+ '<button class="btn" data-act="cancel-input">Cancel (Esc)</button>'
+ '<button class="btn primary" data-act="save-input" data-line="' + lineNum + '" disabled>Save (⌘Enter)</button>'
+ '</div></div>';
}
return row;
}).join('');
root.innerHTML = html;
root.querySelectorAll('.doc-line').forEach(function(el) {
el.addEventListener('click', function(e) {
if (e.target.closest('.input-row')) return;
const ln = parseInt(el.getAttribute('data-line'), 10);
state.openInputLine = state.openInputLine === ln ? null : ln;
state.activeId = null;
renderAll();
if (state.openInputLine) {
setTimeout(function(){
const ta = document.getElementById('input-' + ln);
if (ta) ta.focus();
}, 0);
}
});
});
root.querySelectorAll('button[data-act]').forEach(function(b) {
b.addEventListener('click', function(e) {
e.stopPropagation();
const act = b.dataset.act;
if (act === 'cancel-input') { state.openInputLine = null; renderAll(); }
else if (act === 'save-input') {
const ln = parseInt(b.dataset.line, 10);
const ta = document.getElementById('input-' + ln);
const text = ta.value.trim();
if (!text) return;
addAnnotation(ln, text);
}
});
});
root.querySelectorAll('textarea[id^="input-"]').forEach(function(ta) {
ta.addEventListener('input', function() {
const ln = parseInt(ta.id.replace('input-', ''), 10);
const saveBtn = document.querySelector('button[data-act="save-input"][data-line="' + ln + '"]');
if (saveBtn) saveBtn.disabled = ta.value.trim().length === 0;
});
ta.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { state.openInputLine = null; renderAll(); }
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
const ln = parseInt(ta.id.replace('input-', ''), 10);
const text = ta.value.trim();
if (text) addAnnotation(ln, text);
}
});
});
}
function addAnnotation(lineNum, text) {
const raw = DOC_LINES[lineNum - 1] || '';
const snippet = raw.trim().length > 80 ? raw.trim().slice(0, 77) + '…' : raw.trim();
const a = { id: nextId++, line: lineNum, target: snippet || '(blank line)', text: text, ts: new Date().toISOString() };
state.annotations.push(a);
state.openInputLine = null;
state.activeId = a.id;
save();
renderAll();
setTimeout(function(){
const card = document.getElementById('card-' + a.id);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
}
function deleteAnnotation(id) {
state.annotations = state.annotations.filter(function(a){ return a.id !== id; });
if (state.activeId === id) state.activeId = null;
if (state.editingId === id) state.editingId = null;
save();
renderAll();
}
function updateAnnotation(id, text) {
const a = state.annotations.find(function(x){ return x.id === id; });
if (!a) return;
if (!text.trim()) { deleteAnnotation(id); return; }
a.text = text.trim();
a.ts = new Date().toISOString();
state.editingId = null;
save();
renderAll();
}
function renderNotesList() {
const root = document.getElementById('notes-list');
if (state.annotations.length === 0) {
root.innerHTML = '<div class="notes-empty">No annotations yet.<br><br>Click any line on the left to add your first note.</div>';
return;
}
const sorted = state.annotations.slice().sort(function(a, b){ return a.line - b.line || a.id - b.id; });
root.innerHTML = sorted.map(function(a) {
const active = a.id === state.activeId ? ' active' : '';
const editing = a.id === state.editingId;
return '<div class="note-card' + active + '" id="card-' + a.id + '">'
+ '<div class="note-head">'
+ '<span class="lineref" data-jump="' + a.line + '">Line ' + a.line + '</span>'
+ '<button class="delete-btn" data-del="' + a.id + '" title="Delete">✕</button>'
+ '</div>'
+ '<p class="target">' + escapeHtml(a.target) + '</p>'
+ (editing
? '<textarea id="edit-' + a.id + '">' + escapeHtml(a.text) + '</textarea>'
+ '<div class="edit-actions">'
+ '<button class="btn" data-act="cancel-edit">Cancel</button>'
+ '<button class="btn primary" data-act="save-edit" data-id="' + a.id + '">Save</button>'
+ '</div>'
: '<div class="note-text" data-edit="' + a.id + '" title="Click to edit">' + escapeHtml(a.text) + '</div>')
+ '</div>';
}).join('');
root.querySelectorAll('.lineref[data-jump]').forEach(function(el) {
el.addEventListener('click', function() {
const ln = parseInt(el.dataset.jump, 10);
const target = document.getElementById('ln-' + ln);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
const id = parseInt(el.closest('.note-card').id.replace('card-', ''), 10);
state.activeId = id;
renderAll();
});
});
root.querySelectorAll('button[data-del]').forEach(function(b) {
b.addEventListener('click', function() {
if (confirm('Delete this annotation?')) deleteAnnotation(parseInt(b.dataset.del, 10));
});
});
root.querySelectorAll('.note-text[data-edit]').forEach(function(el) {
el.addEventListener('click', function() {
state.editingId = parseInt(el.dataset.edit, 10);
renderAll();
setTimeout(function(){
const ta = document.getElementById('edit-' + state.editingId);
if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
}, 0);
});
});
root.querySelectorAll('button[data-act="cancel-edit"]').forEach(function(b) {
b.addEventListener('click', function() { state.editingId = null; renderAll(); });
});
root.querySelectorAll('button[data-act="save-edit"]').forEach(function(b) {
b.addEventListener('click', function() {
const id = parseInt(b.dataset.id, 10);
const ta = document.getElementById('edit-' + id);
updateAnnotation(id, ta.value);
});
});
}
function renderStats() {
const n = state.annotations.length;
document.getElementById('stats').innerHTML = n === 0
? '<span class="pill">No annotations yet</span>'
: '<span class="pill accent">' + n + ' annotation' + (n === 1 ? '' : 's') + '</span>';
const copyBtn = document.getElementById('copy');
copyBtn.disabled = n === 0;
}
function renderPrompt() {
const out = document.getElementById('prompt');
if (state.annotations.length === 0) {
out.classList.add('empty');
out.textContent = 'Click a line and add a note to generate a prompt.';
return;
}
out.classList.remove('empty');
const sorted = state.annotations.slice().sort(function(a, b){ return a.line - b.line || a.id - b.id; });
let p = 'Please revise the voyage artifact at \`' + ARTIFACT_PATH + '\` with the operator annotations below.\\n';
p += 'Each annotation is anchored to a specific line of the source markdown.\\n';
p += 'Treat the operator notes as authoritative direction for what should change.\\n\\n';
p += '## Annotations (' + state.annotations.length + ' total)\\n\\n';
for (let i = 0; i < sorted.length; i++) {
const a = sorted[i];
p += '### Line ' + a.line + '\\n';
p += 'Source: ' + a.target + '\\n';
p += 'Operator note: ' + a.text + '\\n\\n';
}
out.textContent = p;
}
document.getElementById('copy').addEventListener('click', async function() {
const txt = document.getElementById('prompt').textContent;
try {
await navigator.clipboard.writeText(txt);
const btn = document.getElementById('copy');
btn.classList.add('copied');
const old = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(function(){ btn.classList.remove('copied'); btn.textContent = old; }, 1500);
} catch (e) { alert('Copy failed: ' + e.message); }
});
document.getElementById('clear-all').addEventListener('click', function() {
if (state.annotations.length === 0) return;
if (confirm('Remove all ' + state.annotations.length + ' annotations? This cannot be undone.')) {
state.annotations = []; state.activeId = null; state.editingId = null;
save(); renderAll();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && state.openInputLine !== null) {
state.openInputLine = null;
renderAll();
}
});
function renderAll() {
renderDoc();
renderNotesList();
renderStats();
renderPrompt();
}
load();
renderAll();
`.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 <artifact.md> [--out <file.html>]\n\n'
+ 'Builds a self-contained operator-annotation HTML for a voyage\n'
+ 'artifact. The operator opens the HTML, clicks lines to attach\n'
+ 'their own notes, copies a structured prompt, pastes back into\n'
+ 'Claude. Annotations persist in localStorage per artifact path.\n\n'
+ 'Default output: <input-basename>.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, parseArgs };