From 9ba8b682effa64dcbac26105d7cc0f1bf65310a7 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 13 May 2026 15:08:20 +0200 Subject: [PATCH] =?UTF-8?q?chore(voyage):=20release=20v5.0.3=20=E2=80=94?= =?UTF-8?q?=20annotation=20UX=20matches=20the=20claude-code-100x=20referen?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 2 +- README.md | 4 +- plugins/voyage/.claude-plugin/plugin.json | 4 +- plugins/voyage/CHANGELOG.md | 78 ++ plugins/voyage/CLAUDE.md | 2 +- plugins/voyage/README.md | 80 +- plugins/voyage/package-lock.json | 4 +- plugins/voyage/package.json | 4 +- plugins/voyage/scripts/annotate.mjs | 1124 +++++++++++------ .../voyage/tests/scripts/annotate.test.mjs | 157 ++- 11 files changed, 972 insertions(+), 489 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fc063f9..be2aa36 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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", diff --git a/CLAUDE.md b/CLAUDE.md index 03c17c0..97ba05b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 3c1b6a2..a701196 100644 --- a/README.md +++ b/README.md @@ -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://` 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`. diff --git a/plugins/voyage/.claude-plugin/plugin.json b/plugins/voyage/.claude-plugin/plugin.json index 82a017b..472b376 100644 --- a/plugins/voyage/.claude-plugin/plugin.json +++ b/plugins/voyage/.claude-plugin/plugin.json @@ -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" }, diff --git a/plugins/voyage/CHANGELOG.md b/plugins/voyage/CHANGELOG.md index e7ba3ce..cb63b39 100644 --- a/plugins/voyage/CHANGELOG.md +++ b/plugins/voyage/CHANGELOG.md @@ -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 + (`

`/`

`/`

    `/`
  • `/``/`
    `/`
    `), 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: 
    ` + `Quote: «»` + + `Comment: `. Copies to clipboard. + - **Clear all** — wipes every annotation for the current artifact + (after confirm). + - **Persistence** — `localStorage` key `voyage-annotate:v2:`. + 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 1431–2255 (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 diff --git a/plugins/voyage/CLAUDE.md b/plugins/voyage/CLAUDE.md index c0d0768..089a71d 100644 --- a/plugins/voyage/CLAUDE.md +++ b/plugins/voyage/CLAUDE.md @@ -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 `` 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://` 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://` 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 1431–2255). 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:`). 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. diff --git a/plugins/voyage/README.md b/plugins/voyage/README.md index 1c366ab..825f3c7 100644 --- a/plugins/voyage/README.md +++ b/plugins/voyage/README.md @@ -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:`). + 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. --- diff --git a/plugins/voyage/package-lock.json b/plugins/voyage/package-lock.json index a77c117..ce028d5 100644 --- a/plugins/voyage/package-lock.json +++ b/plugins/voyage/package-lock.json @@ -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" diff --git a/plugins/voyage/package.json b/plugins/voyage/package.json index 8c84b4b..59489ef 100644 --- a/plugins/voyage/package.json +++ b/plugins/voyage/package.json @@ -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" diff --git a/plugins/voyage/scripts/annotate.mjs b/plugins/voyage/scripts/annotate.mjs index 0426741..777ff5c 100644 --- a/plugins/voyage/scripts/annotate.mjs +++ b/plugins/voyage/scripts/annotate.mjs @@ -1,18 +1,27 @@ #!/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-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. // -// • 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. +// 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'; @@ -29,66 +38,248 @@ function escapeHtml(s) { 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 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 ? '
    ' : '
      '; + stack.push({ indent, ordered }); + } else { + html += ''; + } + html += `
    • ${renderInline(escapeHtml(text))}`; + } + while (stack.length) { + const top = stack.pop(); + html += top.ordered ? '
' : ''; + } + return html + '\n'; +} + +// --------------------------------------------------------------------------- +// Build full HTML document +// --------------------------------------------------------------------------- + function buildHtml(artifactPath, mdText) { - const 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); - + const { body } = splitFrontmatter(mdText); + const articleHtml = renderMarkdown(body); return '\n' + '\n' + '\n' + '\n' + '\n' - + '' + titleEsc + ' — annotate\n' + + '' + escapeHtml(title) + ' — annotate\n' + '\n' + '\n' - + '\n' - + '
\n' + + '\n' + '
\n' + '
\n' - + '

' + titleEsc + '

\n' - + '

' + fileNameEsc + '

\n' + + '

' + escapeHtml(title) + '

\n' + + '

' + escapeHtml(fileName) + '

\n' + + '
\n' + + '
\n' + + ' \n' + + ' \n' + '
\n' - + '
\n' + '
\n' - + '
\n' - + '
\n' - + '
Click any line to add your own annotation. Annotations are saved in your browser per artifact path.
\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' - + ' Prompt for Claude\n' - + ' \n' + + '
\n' + '
\n' + + '\n' + + '
\n' + + '
\n' + '\n' @@ -97,8 +288,9 @@ function buildHtml(artifactPath, mdText) { } // --------------------------------------------------------------------------- -// Stylesheet — design-system-aligned, light + dark, no external fonts/CDN. +// Stylesheet — light + dark + print. Design-system-aligned. // --------------------------------------------------------------------------- + const STYLE = ` :root { --bg: #f7f7f8; @@ -117,8 +309,15 @@ const STYLE = ` --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 { @@ -138,398 +337,546 @@ const STYLE = ` --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; 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); } +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-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; } +.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 { 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; +.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; } -.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); } +.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 — operator annotation surface. -// Uses concatenation (no template literals) to avoid backtick collisions -// with the outer mjs string assembly. +// 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:' + ARTIFACT_PATH; -let state = { annotations: [], openInputLine: null, editingId: null, activeId: null }; -let nextId = 1; -function load() { +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)) { - state.annotations = data.annotations; - nextId = (data.nextId || data.annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1) || 1; + annotations = data.annotations; + nextId = data.nextId || (annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1); } } catch (e) {} } -function save() { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: state.annotations, nextId: nextId })); } catch (e) {} +function saveState() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: annotations, nextId: nextId })); } catch (e) {} } -function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} -function renderInline(raw) { - // raw is the ALREADY-escaped line content - let s = raw; - s = s.replace(/\\\`([^\\\`]+)\\\`/g, '$1'); - s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '$1'); - s = s.replace(/(^|[\\s])\\*([^*\\s][^*]*?)\\*(?=\\s|[.,;:!?]|$)/g, '$1$2'); - s = s.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g, function(m, t, h) { - const safe = /^(https?:|mailto:|#)/i.test(h) ? h : '#'; - return '' + t + ''; - }); - 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 = '' + renderInline(escaped) + ''; - } else if (cls.kind === 'blank') { - inner = ' '; - } 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 = '
' - + '' + lineNum + '' - + '' + (gutter || '+') + '' - + '' + content + '' - + '
'; - if (state.openInputLine === lineNum) { - const placeholder = anns.length > 0 ? 'Add another note for line ' + lineNum + '...' : 'Your note for line ' + lineNum + '...'; - row += '
' - + '' - + '
' - + '' - + '' - + '
'; - } - return row; - }).join(''); - root.innerHTML = html; +function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } - 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); +// ── 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; } - }); - }); - 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); - } - }); - }); + } + 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)'; } -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); + +// ── 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) { - 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(); + annotations = annotations.filter(function(a) { return a.id !== id; }); + saveState(); + refreshArticleAnnotations(); + renderPanel(); + updateCounts(); + showToast('Annotasjon slettet'); } -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(); + +// ── 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]); + } + } } -function renderNotesList() { - const root = document.getElementById('notes-list'); - if (state.annotations.length === 0) { - root.innerHTML = '
No annotations yet.

Click any line on the left to add your first note.
'; + +// ── 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; } - 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 '
' - + '
' - + 'Line ' + a.line + '' - + '' - + '
' - + '

' + escapeHtml(a.target) + '

' - + (editing - ? '' - + '
' - + '' - + '' - + '
' - : '
' + escapeHtml(a.text) + '
') - + '
'; - }).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(); - }); + // 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; }); - root.querySelectorAll('button[data-del]').forEach(function(b) { - b.addEventListener('click', function() { + 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)); }); }); - 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); + 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'); + } }); }); } -function renderStats() { - const n = state.annotations.length; - document.getElementById('stats').innerHTML = n === 0 - ? 'No annotations yet' - : '' + n + ' annotation' + (n === 1 ? '' : 's') + ''; - const copyBtn = document.getElementById('copy'); - copyBtn.disabled = n === 0; + +// ── Counts + toggle label ── +function updateCounts() { + annBadge.textContent = String(annotations.length); + copyBtn.disabled = annotations.length === 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.'; + +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; } - 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); } + e.preventDefault(); + openForm(e, target); }); -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(); + +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' && state.openInputLine !== null) { - state.openInputLine = null; - renderAll(); + 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'); } }); -function renderAll() { - renderDoc(); - renderNotesList(); - renderStats(); - renderPrompt(); -} -load(); -renderAll(); + +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++) { @@ -559,9 +906,10 @@ if (import.meta.url === `file://${process.argv[1]}`) { 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, 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' + + '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); @@ -570,4 +918,4 @@ if (import.meta.url === `file://${process.argv[1]}`) { process.stdout.write(out + '\n'); } -export { render, buildHtml, parseArgs }; +export { render, buildHtml, renderMarkdown, parseArgs }; diff --git a/plugins/voyage/tests/scripts/annotate.test.mjs b/plugins/voyage/tests/scripts/annotate.test.mjs index 4bd2a9f..3044447 100644 --- a/plugins/voyage/tests/scripts/annotate.test.mjs +++ b/plugins/voyage/tests/scripts/annotate.test.mjs @@ -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 or "\n---\n\n# Foo\n'; const html = buildHtml('/abs/path/brief.md', md); - // The raw