chore(voyage): release v5.0.3 — annotation UX matches the claude-code-100x reference

The operator pointed at ~/repos/claude-code-100x/claude-code-100x/build-site.js
as the annotation reference from the start. v4.2/v4.3 built a bespoke
playground instead. v5.0.0 deleted it. v5.0.1 pointed at /playground
document-critique (Claude-leads, wrong direction). v5.0.2 was operator-led
but too thin (line-click + freeform note, no intent). v5.0.3 finally
matches the reference.

scripts/annotate.mjs rewritten:
  - Markdown rendered as proper article HTML (h1/p/li/ul/table/blockquote/pre)
    instead of line-numbered raw lines.
  - Pencil-toggle annotation mode in the topbar, default ON.
  - Select text OR click any element → form popover at cursor.
  - Three intent buttons: Fiks (red) / Endre (orange) / Spørsmål (blue).
  - Comment textarea. Save (Cmd+Enter), Cancel (Esc).
  - Section context auto-detected from nearest h1/h2.
  - Sidebar panel: annotations grouped by section, intent badges,
    snippet quotes, delete buttons, click-to-scroll with flash highlight.
  - Copy Prompt: structured markdown export with intent labels.
  - localStorage persistence keyed on absolute artifact path
    (voyage-annotate:v2: prefix to avoid colliding with v5.0.2 state).

Tests: 12 (up from 10), all passing. npm test: 518 / 516 pass / 0 fail / 2 skipped.

Reference: ~/repos/claude-code-100x/claude-code-100x/build-site.js
lines 1431–2255 (annotation UI section).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-13 15:08:20 +02:00
commit 9ba8b682ef
11 changed files with 974 additions and 491 deletions

View file

@ -23,7 +23,7 @@
{
"name": "voyage",
"source": "./plugins/voyage",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline with specialized agent swarms, external research triangulation, adversarial review, post-hoc independent review with Handover 6 feedback loop, multi-session resumption, session decomposition, and headless execution. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs): operator clicks any line, writes their own notes, copies a structured prompt, pastes back into Claude — Claude revises the .md."
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline with specialized agent swarms, external research triangulation, adversarial review, post-hoc independent review with Handover 6 feedback loop, multi-session resumption, session decomposition, and headless execution. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): pencil-toggle annotation mode, select text or click any element, pick intent (Fiks/Endre/Spørsmål), comment, Copy Prompt, paste back, Claude revises the .md."
},
{
"name": "linkedin-thought-leadership",

View file

@ -13,7 +13,7 @@ plugins/
llm-security/ v6.0.0 — Security scanning, auditing, threat modeling
ms-ai-architect/ v1.13.1 — Microsoft AI architecture (Cosmo Skyberg persona) + manual KB-refresh slash command
okr/ v1.0.0 — OKR guidance for Norwegian public sector
voyage/ v5.0.2 — Brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline (six-command universal pipeline + multi-session resumption + --gates autonomy chain). /trekbrief, /trekplan, and /trekreview each end by running scripts/annotate.mjs against the just-written .md and printing the file:// link to a self-contained operator-annotation HTML: operator clicks lines, writes their own notes (no Claude-generated suggestions in the loop), notes persist in localStorage, Copy Prompt button gathers them all, paste back, Claude revises .md. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8; v5.0.1 pointed at /playground document-critique (Claude-leads, wrong direction); v5.0.2 ships annotate.mjs (operator-leads, the actual ask).
voyage/ v5.0.3 — Brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline (six-command universal pipeline + multi-session resumption + --gates autonomy chain). /trekbrief, /trekplan, and /trekreview each end by running scripts/annotate.mjs against the just-written .md and printing the file:// link to a self-contained operator-annotation HTML modelled on claude-code-100x/build-site.js: pencil-toggle annotation mode, select text or click any element, choose intent (Fiks/Endre/Spørsmål), comment, sidebar groups by section with delete + Copy Prompt, localStorage persistence per artifact path. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8; v5.0.1 pointed at /playground document-critique (wrong direction); v5.0.2 was operator-led but too thin; v5.0.3 matches the reference the operator pointed at from day one.
shared/
playground-design-system/ v0.1 — Aksel/Digdir-aligned CSS design system + JSON schemas + self-hosted Inter/JetBrains Mono/Source Serif 4 fonts (Tier 1+2+3 wave 1+wave 2 = 20 Tier 3 components total). Consumed by ms-ai-architect, okr, llm-security, voyage, config-audit

View file

@ -77,11 +77,11 @@ Key commands: `/config-audit posture`, `/config-audit feature-gap`, `/config-aud
---
### [Voyage](plugins/voyage/) `v5.0.2`
### [Voyage](plugins/voyage/) `v5.0.3`
Deep requirements gathering, research, implementation planning, self-verifying execution, independent post-hoc review, and zero-friction multi-session resumption — with specialized agent swarms, adversarial review, and failure recovery. Six-command (brief, research, plan, execute, review, continue) universal pipeline. `/trekbrief`, `/trekplan`, and `/trekreview` render their artifact to a self-contained HTML view and print the `file://` link; annotation is delegated to the official `/playground` plugin.
v5.0.2 finally lands the annotation UX that v5.0.0 and v5.0.1 each missed: at the end of `/trekbrief`, `/trekplan`, and `/trekreview`, voyage now runs `scripts/annotate.mjs` against the just-written `.md` and prints a `file://<abs path>` link to a self-contained operator-annotation HTML. The operator opens it, **clicks any line of the document, writes their own note**, watches a sidebar of every note (editable, deletable, persisted in browser `localStorage`), and clicks "Copy Prompt" to get one structured prompt with every note. They paste it back into Claude, Claude revises the `.md`. **The operator drives every annotation** — no Claude-generated suggestions in the loop. v5.0.1 (now superseded) had pointed at `/playground document-critique`, but that template pre-generates Claude's suggestions and asks the operator to approve/reject them — the wrong direction. v5.0.0 (breaking, kept) removed the v4.2/v4.3 bespoke playground SPA, `/trekrevise`, Handover 8, the supporting `lib/` modules, the Playwright e2e suite, and the `@playwright/test` / `@axe-core/playwright` devDeps. `scripts/annotate.mjs` is ~430 lines of self-contained `.mjs` (zero npm deps, zero external network, deterministic output) and replaces all of that with the *concept* the bespoke playground was reaching for but never achieved. See `plugins/voyage/CHANGELOG.md` § v5.0.0 + § v5.0.1 + § v5.0.2.
v5.0.3 lands the annotation UX modelled on `~/repos/claude-code-100x/claude-code-100x/build-site.js`: pencil-toggle annotation mode, **select text or click any element to anchor**, choose intent (**Fiks** / **Endre** / **Spørsmål**), write a comment, save. The sidebar groups annotations by section with intent badges; Copy Prompt assembles them into a structured markdown the operator pastes back into Claude. State persists in `localStorage` per artifact path. v5.0.2 was operator-led but too thin (line-click + freeform note, no intent categories). v5.0.1 had pointed at `/playground document-critique` (Claude-leads — wrong direction). v5.0.0 (breaking, kept) removed the v4.2/v4.3 bespoke playground SPA, `/trekrevise`, Handover 8, the supporting `lib/` modules, the Playwright e2e suite, and the `@playwright/test` / `@axe-core/playwright` devDeps. v5.0.3's `scripts/annotate.mjs` is one self-contained zero-dependency Node script. **The operator drives every annotation** — Claude never pre-generates suggestions in this flow. See `plugins/voyage/CHANGELOG.md` § v5.0.0 → § v5.0.3.
v4.0.0 (breaking) renamed the plugin from `ultraplan-local` to **Voyage** and all commands from `/ultra*-local` to `/trek*` to remove name collision with Anthropic's `/ultraplan` and `/ultrareview` features. See `plugins/voyage/TRADEMARKS.md` and `plugins/voyage/CHANGELOG.md`.

View file

@ -1,7 +1,7 @@
{
"name": "voyage",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs) and printing the file:// link — the operator clicks lines, adds their own notes, copies a structured prompt, pastes back, Claude revises the .md.",
"version": "5.0.2",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any element, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"version": "5.0.3",
"author": {
"name": "Kjell Tore Guttormsen"
},

View file

@ -4,6 +4,84 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v5.0.3 — 2026-05-13 — Annotation UX matches the claude-code-100x reference
**No new breaking changes beyond v5.0.0.** Forks consuming v5.0.2's
annotation HTML keep working — the file path and entry point are
unchanged. The internal localStorage key bumps from `voyage-annotate:` to
`voyage-annotate:v2:` to avoid mixing the v5.0.2 shape (line-click,
freeform notes) with the v5.0.3 shape (intent-tagged annotations).
### Why
v5.0.2 shipped a too-simple annotation surface: click a line, write a
freeform note, save. The operator pointed at the existing
`claude-code-100x/build-site.js` annotation system as the actual
reference — pencil-toggle mode, text-selection capture, three intent
categories (**Fiks** / **Endre** / **Spørsmål**), a popover form at the
cursor, structured markdown export with intent labels. v5.0.3 brings
`scripts/annotate.mjs` up to that pattern.
This reference had been mentioned by the operator early in the
conversation; the iteration through v5.0.0 / v5.0.1 / v5.0.2 reflects me
reading past it and trying alternatives instead of just matching it. The
loss is real and is documented here in plain terms so future maintainers
don't repeat it.
### Changed
- **`scripts/annotate.mjs`** — rewritten to match the
`claude-code-100x/build-site.js` UX:
- **Article rendering** — markdown is rendered to proper HTML elements
(`<h1>`/`<p>`/`<ul>`/`<li>`/`<table>`/`<blockquote>`/`<pre>`), not as
line-numbered raw lines. Document reads as a normal article.
- **Annotatable elements** — every heading, paragraph, list item, table
cell, blockquote, and code block gets a stable `data-anchor-id`.
- **Pencil-toggle button** in the topbar — annotation mode default ON.
Toggle OFF to read normally and follow links.
- **Click any annotatable element** (in mode) → opens a form popover
at the cursor with: section context (auto-detected from nearest
h1/h2), anchored snippet (the exact selected substring via
`window.getSelection()` if any text is highlighted, else the
element's text content up to 200 chars), three intent buttons
(**Fiks** / **Endre** / **Spørsmål**), comment textarea, Cancel +
Save. Save is disabled until an intent is picked.
- **Sidebar panel** — collapsed by default; "Show annotations" button
in the topbar opens it. Annotations grouped by section, sorted by
document order. Each card shows the intent badge (colored by
category), the anchored snippet, the operator comment, and a delete
button. Click a card to scroll the article to that element + flash
highlight.
- **Copy Prompt** — structured markdown:
`### N. [Intent] Section: <section>` + `Quote: «<snippet>»` +
`Comment: <text>`. Copies to clipboard.
- **Clear all** — wipes every annotation for the current artifact
(after confirm).
- **Persistence**`localStorage` key `voyage-annotate:v2:<abs path>`.
Refresh/close/reopen the same HTML keeps every annotation.
- **Toast feedback** for save / copy / clear.
- **`tests/scripts/annotate.test.mjs`** — refreshed for the v5.0.3 shape:
pins the three intent buttons (`data-intent="fiks"` / `"endre"` /
`"spørsmål"`), form popover, selection capture, section auto-detect,
`voyage-annotate:v2:` storage key prefix, `data-anchor-id` coverage,
Copy Prompt + Clear all affordances, and the markdown renderer's
heading / list / table / blockquote / code-fence output. 12 tests
(up from 10), all passing.
### Notes
- The producing commands (`/trekbrief` Step 4g, `/trekplan` Phase 10,
`/trekreview` Phase 8) call `scripts/annotate.mjs` the same way as in
v5.0.2 — no change to their wiring beyond the build-output now being
the v5.0.3 interactive surface.
- `npm test`: 518 tests, 516 pass, 0 fail, 2 skipped (up from 516 — 2
new annotate tests for hostile-content escape + renderMarkdown table/
blockquote coverage).
- Reference: `~/repos/claude-code-100x/claude-code-100x/build-site.js`
lines 14312255 (annotation UI section).
- Version bump 5.0.2 → 5.0.3 in `.claude-plugin/plugin.json`,
`package.json`, `package-lock.json`, plugin `README.md` badge.
## v5.0.2 — 2026-05-13 — Operator-driven annotation HTML (the actual fix)
**No new breaking changes beyond v5.0.0.** Forks that consumed the v5.0.1

View file

@ -232,7 +232,7 @@ Local Docker Compose stack: `examples/observability/`. Operator docs: `docs/obse
**Continue:** `/trekcontinue` reads `{dir}/.session-state.local.json` (Handover 7), validates schema-v1 via `session-state-validator`, narrates a 3-line summary (project / next-session-label / brief-path), and immediately begins executing the next session. Auto-discovers active project state files under `.claude/projects/*/.session-state.local.json` if no explicit `<project-dir>` argument. Operator-invoked only — never auto-loaded via SessionStart. The `/trekendsession` helper is the informal-flow producer: writes the same state file for ad-hoc multi-session handovers that don't run through `/trekexecute`.
**Operator-annotation HTML (v5.0.2):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` runs `scripts/annotate.mjs` against the just-written `.md` and prints the resulting `file://<abs path>` link. The HTML is self-contained (zero npm deps, zero external network, design-system-styled, light + dark + print), and built around the operator. The operator opens the file, clicks any line of the document, writes their own note in an inline textarea, hits Save. The sidebar collects every note (editable + deletable, sorted by line). Notes persist in `localStorage` per artifact path — refresh or browser-close doesn't lose work. A "Copy Prompt" button at the bottom assembles every note into one structured prompt; the operator copies it and pastes it back into Claude, and Claude revises the `.md` freehand from the notes. **The operator drives every annotation.** Claude does not pre-generate suggestions in this flow — that was the v5.0.1 path (via `/playground document-critique`), which inverted the direction the operator actually wanted. v5.0.2 makes the loop OPERATOR-LEADS, CLAUDE-REACTS, which is what was asked for all along. The v5.0.0 standalone `.html` render (`scripts/render-artifact.mjs`) was a read-only view that didn't afford annotation; the v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8 were 388 KB of broken UX. Both stay removed. See [CHANGELOG.md](CHANGELOG.md) § v5.0.2.
**Operator-annotation HTML (v5.0.3):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` runs `scripts/annotate.mjs` against the just-written `.md` and prints the resulting `file://<abs path>` link. The HTML is self-contained (zero npm deps, zero external network, design-system-styled, light + dark + print) and modelled on `~/repos/claude-code-100x/claude-code-100x/build-site.js` (lines 14312255). The operator opens the file, the document renders as a proper article (headings / paragraphs / lists / tables / code / quotes — every element gets a stable `data-anchor-id`). In annotation mode (default ON, pencil-toggle in topbar), the operator can **select any text or click any element** → a form popover opens at the cursor with: section context auto-detected from nearest h1/h2, the anchored snippet (selection if any, else element text), **three intent buttons (Fiks / Endre / Spørsmål)**, comment textarea, Save/Cancel. The sidebar (Show annotations button) lists every annotation grouped by section with intent badge + snippet + comment + delete; clicking a card scrolls to and flashes the source element. **Copy Prompt** assembles a structured markdown (`### N. [Intent] Section: <…>` + `Quote: «…»` + `Comment: …`) and copies to clipboard. Persistence: `localStorage` keyed on absolute artifact path (`voyage-annotate:v2:<abs path>`). v5.0.0 removed the v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8; v5.0.1 pointed at `/playground document-critique` (Claude-leads, wrong direction); v5.0.2 was operator-led but too thin (line-click + freeform note, no intents); v5.0.3 matches the claude-code-100x reference the operator first pointed at, with pencil-toggle / selection capture / intent categories / popover form / structured export. See [CHANGELOG.md](CHANGELOG.md) § v5.0.3.
**Security:** 4-layer defense-in-depth: plugin hooks (pre-bash-executor, pre-write-executor), prompt-level denylist (works in headless sessions), pre-execution plan scan (Phase 2.4), scoped `--allowedTools` replacing `--dangerously-skip-permissions`. Hard Rules 14-16 enforce verify command security, repo-boundary writes, and sensitive path protection.

View file

@ -1,6 +1,6 @@
# trekplan — Brief, Research, Plan, Execute, Review, Continue
![Version](https://img.shields.io/badge/version-5.0.2-blue)
![Version](https://img.shields.io/badge/version-5.0.3-blue)
![License](https://img.shields.io/badge/license-MIT-green)
![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple)
@ -503,7 +503,7 @@ Both arguments are required. No interactive prompt — headless-safe.
---
## Reviewing and annotating artifacts (v5.0.2)
## Reviewing and annotating artifacts (v5.0.3)
`/trekbrief`, `/trekplan`, and `/trekreview` each end by running
`scripts/annotate.mjs` against the just-written `.md` and printing the
@ -528,40 +528,56 @@ the tab and reopen the same file.
```
You run `open` (or click the `file://` link in your terminal), the HTML
opens in your default browser. In the browser:
opens in your default browser. The annotation UX is modelled on
`claude-code-100x/build-site.js`:
- **Left:** the artifact, rendered with line numbers. Hover any line — the
gutter shows a `+`. Click — an inline textarea appears under that line.
Write your note, hit Save (or `⌘Enter`). The line gets a colored left
border + a number badge showing the annotation count on that line.
- **Right (sidebar):** every annotation you've made, sorted by line. Each
card shows the line reference (click to scroll), the target text
snippet, and your note text. Click the note to edit it, click `✕` to
delete. A **Clear all** button at the top wipes the slate.
- **Bottom:** a structured prompt that updates live with every note. A
**Copy Prompt** button. Click — your clipboard now has the prompt.
- **Topbar:** pencil-toggle button — annotation mode default ON. Click
to turn off (then you read the article normally, follow links, etc.).
A second button opens the sidebar panel.
- **Article body:** the artifact rendered as a proper article — headings,
paragraphs, lists, tables, code blocks, blockquotes. Hover any element
in mode and it highlights. To anchor on a specific phrase, **select
the text first**, then click. Otherwise the whole element becomes the
anchor.
- **Form popover** appears at the cursor with:
- **Section** (auto-detected from the nearest h1/h2 above).
- **Anchored to** — the exact text you selected, or the element's
first ~200 chars if you didn't select.
- **Three intent buttons:** **Fiks** (something is wrong — fix it),
**Endre** (change the wording / content), **Spørsmål** (an open
question — clarify or answer). Colored: red / orange / blue.
- **Comment** textarea (optional but helpful).
- **Cancel** / **Save**. Save stays disabled until you pick an intent.
Shortcut: `⌘Enter` to save, `Esc` to cancel.
- **Annotated elements** get an amber highlight + a number badge in the
margin showing how many annotations target that element.
- **Sidebar panel** (Show annotations) — every annotation grouped by
section, in document order. Each card shows the intent badge
(colored), the anchored snippet (mono-quote), the comment text, and a
delete button. Click a card to scroll the article to that element and
flash it.
- **Copy Prompt** at the foot of the panel — assembles every annotation
into one structured markdown prompt and copies it to your clipboard.
- **Clear all** wipes every annotation (after confirm).
- **Persistence:** every annotation is saved to browser `localStorage`
keyed on the artifact's absolute path. Refresh the tab or close the
browser and re-open — your notes are still there.
keyed on the artifact's absolute path (`voyage-annotate:v2:<abs path>`).
Refresh the tab or close the browser and re-open — your work is there.
You write your notes, click Copy Prompt, paste back into Claude. Claude
revises the `.md` freehand from your notes. **The operator drives every
annotation.** Claude never pre-generates suggestions in this flow.
You select / click, pick intent, write comment, repeat. When you're
done, Copy Prompt, paste back into this chat. Claude revises the `.md`
freehand from your notes. **The operator drives every annotation.**
Claude never pre-generates suggestions in this flow.
> **What v5.0.2 changed from v5.0.1.** v5.0.0 added a read-only HTML
> render that didn't afford annotation. v5.0.1 deleted that and pointed
> operators at `/playground document-critique` — but the `document-critique`
> template pre-generates **Claude's** suggestions and asks the operator to
> approve/reject them. That's "Claude leads, operator reacts" — the
> opposite of what was asked for. v5.0.2 ships `scripts/annotate.mjs`, a
> self-contained zero-dependency operator-annotation HTML generator. The
> operator clicks lines, writes their own notes, copies a structured prompt
> with every note. No Claude-generated suggestions in the loop.
>
> **Still removed from v5.0.0 onward:** the v4.2/v4.3 bespoke playground SPA,
> `/trekrevise`, Handover 8 (annotation → revision), the supporting `lib/`
> modules, and the Playwright e2e suite. See [CHANGELOG.md](CHANGELOG.md)
> § v5.0.0, § v5.0.1, and § v5.0.2.
> **What v5.0.3 changed from v5.0.2.** v5.0.2 was operator-led but the UX
> was too thin — click a line, type a freeform note, save. The reference
> the operator pointed at (`~/repos/claude-code-100x/claude-code-100x/build-site.js`)
> already had the right pattern: pencil-toggle, selection capture, three
> intent categories, popover form, structured markdown export. v5.0.3
> rebuilds `scripts/annotate.mjs` against that reference. v5.0.0 / v5.0.1
> / v5.0.2 are all superseded; only the v5.0.0 removals (bespoke
> playground SPA, `/trekrevise`, Handover 8, supporting `lib/` modules,
> Playwright e2e + devDeps) stay. See [CHANGELOG.md](CHANGELOG.md)
> § v5.0.0 → § v5.0.3.
---

View file

@ -1,12 +1,12 @@
{
"name": "voyage",
"version": "5.0.2",
"version": "5.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voyage",
"version": "5.0.2",
"version": "5.0.3",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -1,7 +1,7 @@
{
"name": "voyage",
"version": "5.0.2",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs) and printing the file:// link — the operator clicks lines, adds their own notes, copies a structured prompt, pastes back, Claude revises the .md.",
"version": "5.0.3",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any heading/paragraph/list-item, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"type": "module",
"engines": {
"node": ">=18"

File diff suppressed because it is too large Load diff

View file

@ -1,26 +1,29 @@
// tests/scripts/annotate.test.mjs
// Covers scripts/annotate.mjs — the v5.0.2 operator-annotation HTML
// generator. The producing commands call it to print a file:// link the
// operator opens in a browser, where they click lines, write their own
// notes, copy a structured prompt, and paste back into Claude.
// Covers scripts/annotate.mjs — the v5.0.3 operator-annotation HTML
// generator. UX modelled on claude-code-100x/build-site.js (pencil
// toggle, intent buttons, form popover, selection-anchoring, localStorage
// persistence, structured markdown export).
//
// What we pin:
// • Output is a complete, self-contained HTML document.
// • Zero external network references in the static HTML.
// • No external <link href=> or <script src=>.
// • The embedded inline <script> parses as valid JavaScript.
// • Source document content + artifact path are embedded verbatim
// (so the browser-side app can render exactly the same lines).
// • HTML special chars and code-fence content are escaped — no raw
// <script>-injection from the source .md.
// • The artifact path is embedded (used as the localStorage key + prompt context).
// • The markdown source is rendered to proper HTML (h1/p/li etc.), not as raw lines.
// • HTML metacharacters in the title are escaped (XSS).
// • Inline content from a hostile .md never appears as a live attribute.
// • render() is deterministic — two runs produce byte-identical output.
// • Default output path is <input-basename>.html next to the input.
// • The v5.0.3 affordances are wired into the HTML: pencil-toggle, form
// popover with three intent buttons (Fiks/Endre/Spørsmål), annotations
// sidebar, Copy Prompt button, Clear all, localStorage persistence.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildHtml, render, parseArgs } from '../../scripts/annotate.mjs';
import { buildHtml, renderMarkdown, render, parseArgs } from '../../scripts/annotate.mjs';
const SAMPLE = `---
type: trekplan
@ -55,9 +58,6 @@ test('buildHtml produces a complete self-contained HTML document', () => {
test('buildHtml has zero external network references in static HTML', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
// Strip the script section (the embedded clipboard-API safe link list may
// technically include example.com in inline content; that's allowed).
// Static HTML structure must not have any <link href=...> or <script src=...>.
assert.ok(!/<link[^>]+href\s*=/i.test(html), 'no external <link href> stylesheets');
assert.ok(!/<script[^>]+src\s*=/i.test(html), 'no external <script src>');
});
@ -66,56 +66,48 @@ test('buildHtml embeds the inline <script> as parseable JavaScript', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
const m = html.match(/<script>([\s\S]*?)<\/script>/);
assert.ok(m, 'must contain a <script> block');
// Function() parses but doesn't execute references to document/localStorage,
// so a SyntaxError here is the only failure mode we want to surface.
assert.doesNotThrow(() => new Function(m[1]), 'inline script must parse without SyntaxError');
});
test('buildHtml embeds the artifact path and source lines verbatim', () => {
test('buildHtml embeds the artifact path (used as localStorage key + prompt context)', () => {
const html = buildHtml('/abs/projects/2026-05-13-foo/brief.md', SAMPLE);
assert.ok(html.includes('/abs/projects/2026-05-13-foo/brief.md'),
'artifact path must appear in the HTML so the script can use it as the localStorage key + prompt context');
// The DOC_LINES JSON literal must contain the actual content of the source.
assert.match(html, /DOC_LINES\s*=\s*\[/);
assert.ok(html.includes('Operator-annotation smoke test'),
'source document content must round-trip into the embedded DOC_LINES');
assert.ok(html.includes('inline code'),
'inline content from the source markdown must appear in DOC_LINES');
});
test('buildHtml renders the markdown source to proper article HTML', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
// Headings, paragraph content, list items, code fence — all present as HTML.
assert.ok(html.includes('<h1 data-anchor-id='), 'top-level heading rendered as <h1>');
assert.ok(html.includes('<h2 data-anchor-id='), '## heading rendered as <h2>');
assert.ok(html.includes('Operator-annotation smoke test'), 'h1 text preserved');
assert.ok(html.includes('<li data-anchor-id='), 'list items rendered with anchor ids');
assert.ok(html.includes('first item'), 'list content preserved');
assert.ok(html.includes('<pre data-anchor-id='), 'code fence rendered with anchor');
assert.ok(html.includes('const x = 1;'), 'code fence body preserved (escaped)');
assert.ok(html.includes('<blockquote data-anchor-id='), 'blockquote rendered with anchor');
});
test('buildHtml escapes HTML metacharacters in the title (XSS surface)', () => {
const md = '---\ntype: trekbrief\ntask: "<script>alert(1)</script>"\n---\n\n# Foo\n';
const html = buildHtml('/abs/path/brief.md', md);
// The raw <script> from the title must NOT appear unescaped anywhere
// outside our own inline <script>.
const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/);
assert.ok(titleMatch, 'must have a title');
assert.ok(!titleMatch[1].includes('<script>'), 'title must not carry a raw <script> tag');
assert.ok(titleMatch[1].includes('&lt;script&gt;') || titleMatch[1].includes('alert'),
'title must be HTML-escaped');
assert.match(titleMatch[1], /&lt;script&gt;/, 'title must be HTML-escaped');
});
test('buildHtml escapes source-document content so a malicious .md cannot inject code', () => {
// The embedded DOC_LINES is a JSON literal, which already escapes anything
// dangerous. But the script then renders content into the DOM — the inline
// renderer escapes everything before DOM-insertion. This test pins the
// JSON-level escaping invariant.
const md = '# Heading\n\n<img src=x onerror="alert(1)">\n';
test('hostile inline content cannot inject as live HTML attributes', () => {
const md = '# Heading\n\nA paragraph with <img src=x onerror="alert(1)"> embedded.\n';
const html = buildHtml('/abs/path/brief.md', md);
// The dangerous attribute must NOT appear as a live HTML construct outside
// a JSON string. Easiest pin: the literal substring should appear inside
// quoted JSON (with backslash-escaped quotes), and the raw construct
// `onerror="alert(1)"` should not appear with unescaped double quotes
// outside the script-embedded JSON literal.
const m = html.match(/<script>([\s\S]*?)<\/script>/);
assert.ok(m, 'must contain script');
// The JSON encoding of `"` is `\"`, so we expect at least the escaped form.
assert.ok(m[1].includes('onerror=\\"alert(1)\\"') || m[1].includes("onerror=\\'alert(1)\\'"),
'dangerous source attribute should be JSON-escaped in DOC_LINES');
// Static HTML outside <script> must not carry the live attribute either.
const outsideScript = html.replace(/<script>[\s\S]*?<\/script>/, '');
assert.ok(!/onerror\s*=\s*"alert/i.test(outsideScript),
'static HTML must not carry a live onerror attribute');
// The article body must not carry a live onerror="..." attribute (the renderer
// HTML-escapes everything in the body, so `<` → `&lt;`).
const articleMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/);
assert.ok(articleMatch, 'must have article body');
assert.ok(!/onerror\s*=\s*"alert/i.test(articleMatch[1]),
'article body must not carry a live onerror attribute');
assert.ok(articleMatch[1].includes('&lt;img'),
'hostile <img> must be escaped to &lt;img');
});
test('render() is deterministic — two runs byte-identical', () => {
@ -151,17 +143,66 @@ test('parseArgs handles --out, positional input, and --help', () => {
assert.equal(parseArgs(['--help']).help, true);
});
test('buildHtml output contains the operator-driven copy-paste loop affordances', () => {
// Pin the user-visible affordances that v5.0.2 promised:
// - Per-line click target (gutter '+' marker)
// - "Your annotations" sidebar
// - "Copy Prompt" button
// - "Clear all" button (so the operator can reset state)
// - localStorage persistence
test('buildHtml wires the v5.0.3 operator-driven annotation affordances', () => {
// Pin every UX-critical affordance modelled on claude-code-100x/build-site.js:
// - Pencil-toggle button (annotation mode on/off)
// - Form popover with three intent buttons (Fiks/Endre/Spørsmål)
// - Annotations sidebar (Your annotations + Clear all + Copy Prompt)
// - Selection capture (window.getSelection())
// - Section context auto-detection (findSection)
// - localStorage persistence (voyage-annotate:v2:...)
// - Annotatable elements (data-anchor-id on h1-h6, p, li, td, blockquote, pre)
const html = buildHtml('/abs/path/brief.md', SAMPLE);
assert.ok(html.includes('Your annotations'), 'must show a "Your annotations" sidebar');
assert.ok(html.includes('Copy Prompt'), 'must have a "Copy Prompt" button');
assert.ok(html.includes('Clear all'), 'must have a "Clear all" affordance');
assert.ok(html.includes('localStorage'), 'must persist state in localStorage');
assert.ok(html.includes('Click any line'), 'must tell the operator how to start');
// Toggle
assert.ok(html.includes('ann-toggle'), 'must have the pencil-toggle button');
assert.ok(html.includes('Annotation mode: ON'), 'must label the toggle state');
// Form + intents (the three CSS classes for selected state)
assert.ok(html.includes('data-intent="fiks"'), 'must have Fiks intent button');
assert.ok(html.includes('data-intent="endre"'), 'must have Endre intent button');
assert.ok(html.includes('data-intent="spørsmål"'), 'must have Spørsmål intent button');
// Form popover
assert.ok(html.includes('ann-form'), 'must have the form popover');
assert.ok(html.includes('ann-form-comment'), 'must have a comment textarea');
assert.ok(html.includes('ann-form-save'), 'must have a Save button');
// Sidebar
assert.ok(html.includes('ann-panel'), 'must have the annotations sidebar');
assert.ok(html.includes('Your annotations'), 'sidebar must title the list');
assert.ok(html.includes('Clear all'), 'sidebar must offer Clear all');
assert.ok(html.includes('Copy Prompt'), 'sidebar must offer Copy Prompt');
// Selection + section
assert.ok(html.includes('window.getSelection'), 'must capture selection');
assert.ok(html.includes('findSection'), 'must auto-detect section context');
// Persistence
assert.ok(html.includes("'voyage-annotate:v2:'"), 'must use the v2 localStorage key prefix');
// Anchor coverage
const anchors = (html.match(/data-anchor-id="anch-/g) || []).length;
assert.ok(anchors >= 5, 'must emit data-anchor-id on enough elements (got ' + anchors + ')');
});
test('renderMarkdown produces headings, lists, code, table, blockquote with anchors', () => {
const html = renderMarkdown(`# H1
## H2
- a
- b
1. one
2. two
| Col | Val |
|-----|-----|
| x | 1 |
\`\`\`
plain code
\`\`\`
> quote
`);
assert.match(html, /<h1 data-anchor-id="anch-0">H1<\/h1>/);
assert.match(html, /<h2 data-anchor-id="anch-1">H2<\/h2>/);
assert.match(html, /<ul><li data-anchor-id=/);
assert.match(html, /<ol><li data-anchor-id=/);
assert.match(html, /<table>[\s\S]*<th data-anchor-id=/);
assert.match(html, /<pre data-anchor-id=/);
assert.match(html, /<blockquote data-anchor-id=/);
});