From 2e0892cdaf2c4c70b423c0a07286efdfef94f521 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 13 May 2026 13:24:32 +0200 Subject: [PATCH 01/15] =?UTF-8?q?chore(voyage):=20release=20v5.0.1=20?= =?UTF-8?q?=E2=80=94=20drop=20standalone=20HTML=20render;=20print=20litera?= =?UTF-8?q?l=20/playground=20document-critique=20invocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v5.0.0 stop-gap had /trekbrief, /trekplan, and /trekreview each render a read-only {artifact}.html (via scripts/render-artifact.mjs) AND print a vague "run the /playground plugin" instruction. In practice the read-only HTML was redundant with what /playground produces and the instruction wasn't copy-paste-ready — the operator had to guess the right invocation. v5.0.1 deletes scripts/render-artifact.mjs + its test + npm run render, and makes each producing command end with a single boxed, literal, copy-paste-ready line: /playground build a document-critique playground for {artifact_path} One paste from the operator launches the official playground skill's document-critique template, which builds an interactive HTML — artifact on the left, per-line Approve/Reject/Comment cards on the right, Copy Prompt button at the bottom. Mark suggestions, click Copy Prompt, paste back, Claude revises the .md. Doc-consistency test pins the literal invocation so the prose cannot soften back into vagueness. npm test green: 503 tests, 501 pass, 0 fail, 2 skipped. Co-Authored-By: Claude Opus 4.7 --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 2 +- README.md | 10 +- plugins/voyage/.claude-plugin/plugin.json | 4 +- plugins/voyage/CHANGELOG.md | 46 +++ plugins/voyage/CLAUDE.md | 15 +- plugins/voyage/README.md | 78 +++-- plugins/voyage/commands/trekbrief.md | 43 +-- plugins/voyage/commands/trekplan.md | 40 ++- plugins/voyage/commands/trekreview.md | 28 +- plugins/voyage/package-lock.json | 4 +- plugins/voyage/package.json | 5 +- plugins/voyage/scripts/render-artifact.mjs | 321 ------------------ .../voyage/tests/lib/doc-consistency.test.mjs | 49 ++- .../tests/scripts/render-artifact.test.mjs | 122 ------- 15 files changed, 206 insertions(+), 563 deletions(-) delete mode 100644 plugins/voyage/scripts/render-artifact.mjs delete mode 100644 plugins/voyage/tests/scripts/render-artifact.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d87c54d..c7f26c8 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. Renders produced artifacts to self-contained HTML + link; annotation via the official /playground plugin." + "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 printing a copy-paste-ready /playground document-critique invocation for the produced artifact — one paste launches an interactive annotation HTML in the browser." }, { "name": "linkedin-thought-leadership", diff --git a/CLAUDE.md b/CLAUDE.md index d79deb4..9d2e48f 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.0 — Brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline (six-command universal pipeline + multi-session resumption + --gates autonomy chain). Renders produced artifacts to self-contained HTML + link; annotation via the official /playground plugin. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8 (NIH; duplicated /playground's document-critique). + voyage/ v5.0.1 — Brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline (six-command universal pipeline + multi-session resumption + --gates autonomy chain). At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a literal copy-paste-ready `/playground build a document-critique playground for {artifact_path}` invocation — one paste launches an interactive annotation HTML, Copy Prompt button returns the operator notes to Claude, Claude revises the .md. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8; v5.0.1 dropped the redundant standalone HTML render (`render-artifact.mjs`) and made the /playground invocation literal. 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 9438d86..a2911de 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.0` +### [Voyage](plugins/voyage/) `v5.0.1` 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.0 (breaking) **removes the bespoke playground.** v4.2/v4.3 shipped a ~388 KB bespoke playground SPA + `/trekrevise` + Handover 8 (annotation → revision); a browser walkthrough found it borderline unusable and it duplicated the official `/playground` plugin's `document-critique` / `diff-review` templates. The SPA, the `/trekrevise` command, Handover 8, the supporting `lib/` modules (`anchor-parser`, `annotation-digest`, `markdown-write`, `revision-guard`), the Playwright e2e suite, and the `@playwright/test` / `@axe-core/playwright` devDeps are all deleted. In their place: a small, zero-dependency `scripts/render-artifact.mjs` that renders any brief/plan/review `.md` to a self-contained, design-system-styled, zero-network `.html` (frontmatter folded into a `
` block). The producing commands call it on their last step and print the link; to annotate, run `/playground` (`document-critique`) on the `.md` and paste the generated prompt back — Claude revises the artifact freehand. Forks depending on the removed surfaces migrate to the `/playground` plugin. See `plugins/voyage/CHANGELOG.md` § v5.0.0. +v5.0.1 finishes the annotation-UX work that v5.0.0 started: at the end of `/trekbrief`, `/trekplan`, and `/trekreview` the operator now gets a single boxed, copy-paste-ready line — `/playground build a document-critique playground for {artifact_path}` — that launches the official `claude-plugins-official` `playground` skill's `document-critique` template. One paste builds a self-contained HTML with the artifact on the left, per-line Approve/Reject/Comment cards on the right, and a Copy Prompt button at the bottom; copy the generated prompt, paste back, Claude revises the `.md` freehand. v5.0.1 also dropped the v5.0.0 stop-gap `scripts/render-artifact.mjs` (a separate read-only HTML render) and the `npm run render` alias — they were redundant with what `/playground` produces. v5.0.0 (breaking, kept) removed the v4.2/v4.3 bespoke playground SPA, `/trekrevise`, Handover 8, the supporting `lib/` modules (`anchor-parser`, `annotation-digest`, `markdown-write`, `revision-guard`), the Playwright e2e suite, and the `@playwright/test` / `@axe-core/playwright` devDeps — a browser walkthrough had found the bespoke surface borderline unusable, and it duplicated the official `/playground` plugin. Forks depending on the removed surfaces migrate to `/playground`. See `plugins/voyage/CHANGELOG.md` § v5.0.0 + § v5.0.1. 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`. @@ -94,9 +94,9 @@ Six commands, one pipeline with clear division of labor: - **`/trekreview`** — Close the iteration loop. Independent post-hoc reviewer reads `brief.md` from scratch and evaluates the diff produced by execute. Two parallel reviewers (brief-conformance + code-correctness) plus a Judge Agent (review-coordinator) for dedup and reasonableness filtering. Severity-tagged findings (Critical/High/Medium/Low/Info) with stable 40-char hex IDs feed back into planning via Handover 6 (`/trekplan --brief review.md` → remediation plan with `source_findings:` audit trail). - **`/trekcontinue`** — Zero-friction multi-session resumption. In a fresh chat, type `/trekcontinue` — reads `.session-state.local.json` (Handover 7), prints a 3-line summary, and immediately begins executing the next session. Any session-end mechanism may write the state file (`/trekexecute` Phase 8/2.55/4 do so automatically; `/trekendsession` helper writes it for informal flows). Forward-compat schema (unknown top-level keys ignored) so future producers can extend additively. -`/trekbrief`, `/trekplan`, and `/trekreview` each finish by rendering their `.md` artifact to a self-contained `.html` next to it (`scripts/render-artifact.mjs` — zero deps, zero network) and printing the `file://` link. To annotate, run the official `/playground` plugin (`document-critique`) on the `.md` and paste its generated prompt back into the conversation. +`/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a single boxed `/playground build a document-critique playground for {artifact_path}` line. The operator copy-pastes that into Claude, the official `playground` skill (`document-critique` template) builds an interactive HTML, the operator marks Approve/Reject/Comment + clicks Copy Prompt, the operator pastes the prompt back, Claude revises the `.md`. -All artifacts land in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md` (+ `brief.html`), `research/NN-*.md`, `plan.md` (+ `plan.html`), `sessions/`, `progress.json`, `review.md` (+ `review.html`), and `.session-state.local.json` (gitignored). `--project ` works across `/trekresearch`, `/trekplan`, `/trekexecute`, `/trekreview`, and (optionally) `/trekcontinue`. +All artifacts land in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `plan.md`, `sessions/`, `progress.json`, `review.md`, and `.session-state.local.json` (gitignored). `--project ` works across `/trekresearch`, `/trekplan`, `/trekexecute`, `/trekreview`, and (optionally) `/trekcontinue`. v3.4.0 (non-breaking) adds the **autonomy chain from brief approval to main-merge** plus parallel-wave hardenings. New `lib/util/autonomy-gate.mjs` state machine (`idle → approved → executing → merge-pending → main-merged`), `lib/review/plan-review-dedup.mjs` for Phase 9 inline dedup, `lib/stats/event-emit.mjs` for autonomy-gate transitions and main-merge gate, and `--gates {open|closed|adaptive}` flag on all four pipeline commands. `commands/trekplan.md` Phase 8 seals Opus-4.7 plan/list-emission schema-drift via `plan-validator --strict`. `commands/trekexecute.md` Phase 2.6 wave-executor adds 11 hardenings for plugin-in-monorepo + gitignored-state topology (GIT_OPTIONAL_LOCKS, --max-turns, --max-budget-usd, scoped --allowedTools, push-before-cleanup ordering). New `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects session-state after compaction. SC7 synthetic determinism floor (Jaccard ≥ 0.833) for plan + review fixtures. Hook baseline regression pins. Architecture decision: Path B (sequential `--no-ff` parallel waves with manifest-driven failure recovery) ships; Path C (cache-first hybrid) deferred to v3.5.0 contingent on cache-telemetry harvest. @@ -120,7 +120,7 @@ Defense-in-depth security: plugin hooks block destructive commands and sensitive Modes: default, brief-driven, project-scoped, research-enriched, foreground, quick, decompose, export, resume -23 specialized agents · 6 commands (+ 1 helper) · 5 plugin hooks · 500+ tests · Self-contained HTML artifact rendering · No cloud dependency +23 specialized agents · 6 commands (+ 1 helper) · 5 plugin hooks · 500+ tests · Copy-paste-ready `/playground` annotation invocation · No cloud dependency → [Full documentation](plugins/voyage/README.md) · [Migration guide](plugins/voyage/MIGRATION.md) diff --git a/plugins/voyage/.claude-plugin/plugin.json b/plugins/voyage/.claude-plugin/plugin.json index c9bfcf3..42c6106 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; renders produced artifacts to HTML + link, annotate via the /playground plugin.", - "version": "5.0.0", + "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a copy-paste-ready /playground invocation that builds an interactive document-critique HTML for the artifact.", + "version": "5.0.1", "author": { "name": "Kjell Tore Guttormsen" }, diff --git a/plugins/voyage/CHANGELOG.md b/plugins/voyage/CHANGELOG.md index 275a531..c5ad231 100644 --- a/plugins/voyage/CHANGELOG.md +++ b/plugins/voyage/CHANGELOG.md @@ -4,6 +4,52 @@ 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.1 — 2026-05-13 — Drop the standalone HTML render; print a literal /playground invocation + +**No new breaking changes beyond v5.0.0.** Forks that consumed +`scripts/render-artifact.mjs` directly (or invoked `npm run render`) must +remove that integration. Nothing else moves. + +### Why + +v5.0.0 had `/trekbrief`, `/trekplan`, and `/trekreview` each finish by +*both* rendering a read-only `{artifact}.html` view (via the new +`scripts/render-artifact.mjs`) *and* printing a vague instruction to "run +the `/playground` plugin (`document-critique` template) on the `.md` and +paste the prompt back". In practice the operator saw two HTMLs in their +project dir, no annotation UI on the rendered `.html`, and had to guess +the right `/playground` invocation. The read-only `.html` added confusion +without affording annotation — it duplicated work the `/playground` +HTML already does (formatted document on the left, annotations on the +right, Copy Prompt button at the bottom). + +v5.0.1 deletes the redundant render and makes the printed `/playground` +invocation literal and copy-paste-ready. One paste from the operator +launches the `playground` skill, which loads its `document-critique` +template, reads the `.md`, builds the interactive HTML, opens it. Mark +suggestions, click Copy Prompt, paste back. Done. + +### Removed + +- **`scripts/render-artifact.mjs`** — the v5.0.0 standalone Markdown→HTML renderer (~280 lines, zero deps). Redundant with `/playground`'s HTML. +- **`tests/scripts/render-artifact.test.mjs`** (and the now-empty `tests/scripts/` dir). +- **`npm run render`** script alias in `package.json`. +- All references to `render-artifact.mjs`, `brief.html`, `plan.html`, `review.html` in `CLAUDE.md` (plugin + root), `README.md` (plugin + root), `.claude-plugin/marketplace.json`, and the three command files' final-output blocks. + +### Changed + +- **`commands/trekbrief.md` Step 4g (Finalize), `commands/trekplan.md` Phase 10 (Present and refine), `commands/trekreview.md` Phase 9 (Present summary)** — each now ends by printing a single boxed block with the literal text `/playground build a document-critique playground for {abs_path}` and a one-paragraph explanation of the paste-mark-copy-paste loop. The literal string is pinned by `tests/lib/doc-consistency.test.mjs` so it cannot soften back into "run the `/playground` plugin" without a test failure. +- **`tests/lib/doc-consistency.test.mjs`** — replaced the v5.0.0 `render-artifact.mjs exists` + `producing commands reference render-artifact.mjs` pins with v5.0.1 pins: `render-artifact.mjs` *no longer* exists; producing commands include the literal `/playground build a document-critique playground for` invocation; producing commands no longer reference `render-artifact.mjs`; `package.json scripts.render` is gone; CHANGELOG has both v5.0.0 and v5.0.1 entries. +- **Plugin `CLAUDE.md`** — "Render-and-link (v5.0.0)" paragraph rewritten to "Post-command annotation invocation (v5.0.1)" explaining the literal-paste contract; project-directory contract no longer lists `.html` siblings; "State" section's project-root inventory no longer lists `.html` files. +- **Plugin `README.md`** — "Rendered artifacts & annotation (v5.0.0)" section rewritten to "Reviewing and annotating artifacts (v5.0.1)" with a worked example of the printed output and a "What v5.0.1 changed from v5.0.0" sub-note. Top-of-README one-liner + bottom "Known limitations" note updated. +- **Root `CLAUDE.md`** + **root `README.md`** + **`.claude-plugin/marketplace.json`** — voyage description updated to v5.0.1 + the one-paste invocation model. + +### Notes + +- `/playground` is the `playground` skill from `claude-plugins-official`. It must be installed in the operator's environment for the printed command to work. If it isn't, the same effect is achievable by pasting the `.md` content into Claude with "review this and suggest changes" — manual freehand revision. +- `npm test`: 503 tests, 501 pass, 0 fail, 2 skipped (down from 509 — 8 `render-artifact.test.mjs` tests removed; the doc-consistency pins were updated to v5.0.1 contracts, net +2 tests). +- Version bump 5.0.0 → 5.0.1 in `.claude-plugin/plugin.json`, `package.json`, `package-lock.json`, plugin `README.md` badge. + ## v5.0.0 — 2026-05-12 — Remove the bespoke playground; render artifacts to HTML + link **Breaking.** `/trekrevise` is removed. The `playground/` directory, Handover 8 diff --git a/plugins/voyage/CLAUDE.md b/plugins/voyage/CLAUDE.md index 8f9c535..312ff09 100644 --- a/plugins/voyage/CLAUDE.md +++ b/plugins/voyage/CLAUDE.md @@ -232,42 +232,41 @@ 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`. -**Render-and-link (v5.0.0):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` renders the just-written `.md` artifact to a self-contained `.html` in the same project directory (`scripts/render-artifact.mjs` — zero npm deps, zero external network, design-system-styled, frontmatter folded into a `
` block) and prints the `file://` link. To annotate, the operator runs the official `/playground` plugin (`document-critique` template) on the `.md` and pastes the generated prompt back into the conversation; Claude revises the artifact freehand. This replaces the v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8 (annotation → revision), all removed in v5.0.0 — see [CHANGELOG.md](CHANGELOG.md) § v5.0.0 for why (the bespoke playground duplicated capabilities the official `/playground` plugin already provides). +**Post-command annotation invocation (v5.0.1):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` prints — verbatim — a copy-paste-ready `/playground` command pointing at the just-written artifact. Concretely: `/playground build a document-critique playground for {abs_path}/{artifact}.md`. When the operator pastes that into Claude, the official `claude-plugins-official` `playground` skill loads its `document-critique` template, reads the `.md`, generates per-line suggestions, and writes a single self-contained HTML file with the artifact nicely formatted on the left, Approve/Reject/Comment cards on the right, and a "Copy Prompt" button at the bottom. The operator marks suggestions, clicks Copy Prompt, pastes the prompt back into Claude — Claude revises the artifact freehand from the notes. **One paste → playground → mark → copy → paste back.** The v5.0.0 standalone `.html` render (`scripts/render-artifact.mjs`) was a separate read-only view that did not afford annotation; v5.0.1 dropped it because it was redundant with the `/playground` HTML (which already shows the artifact nicely) and the two HTMLs created operator confusion. The v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8 — all removed in v5.0.0 — were never re-introduced. See [CHANGELOG.md](CHANGELOG.md) § v5.0.1. **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. -**Pipeline:** `/trekbrief` produces the task brief. `/trekresearch --project ` fills in `{dir}/research/`. `/trekplan --project ` reads brief + research to produce `{dir}/plan.md` (and auto-discovers `{dir}/architecture/overview.md` if an opt-in upstream architect plugin produced one). `/trekexecute --project ` executes and writes `{dir}/progress.json`. `/trekreview --project ` produces `{dir}/review.md`. `/trekbrief`, `/trekplan`, and `/trekreview` each render their artifact to `{dir}/{artifact}.html` and print the link (annotate via the `/playground` plugin). All artifacts live in one project directory. +**Pipeline:** `/trekbrief` produces the task brief. `/trekresearch --project ` fills in `{dir}/research/`. `/trekplan --project ` reads brief + research to produce `{dir}/plan.md` (and auto-discovers `{dir}/architecture/overview.md` if an opt-in upstream architect plugin produced one). `/trekexecute --project ` executes and writes `{dir}/progress.json`. `/trekreview --project ` produces `{dir}/review.md`. `/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a copy-paste-ready `/playground build a document-critique playground for {artifact_path}` command — the operator pastes it into Claude to launch the official `playground` skill (`document-critique` template), which builds an interactive HTML for browser annotation. All `.md` artifacts live in one project directory. **Project-directory contract (v3.0.0):** trekplan owns the directory layout below. The `architecture/` subdirectory is opt-in and produced by an opt-in upstream architect plugin (not bundled) — the architect plugin is no longer publicly distributed, but the `architecture/overview.md` slot remains available for any compatible producer. ``` .claude/projects/{YYYY-MM-DD}-{slug}/ brief.md ← trekbrief writes; everyone reads - brief.html ← trekbrief renders (self-contained; for browser viewing / /playground) research/*.md ← trekresearch writes; plan + architect read architecture/ ← OPT-IN, owned by an opt-in upstream architect plugin (not bundled) overview.md gaps.md plan.md ← trekplan writes; trekexecute reads - plan.html ← trekplan renders progress.json ← trekexecute writes review.md ← trekreview writes; trekplan reads (Handover 6) - review.html ← trekreview renders ``` +When an operator runs the `/playground build a document-critique playground for ` command that the producing commands print, the `playground` skill writes the resulting HTML into its own working directory (typically next to the project dir or in `/tmp/`); voyage does not own the `.html` filename. The annotation HTML is ephemeral — re-built on each invocation from the current `.md` source. + No code-level dependency between plugins — the contract is filesystem-level only. ## State All artifacts in one project directory (default): - Project root: `.claude/projects/{YYYY-MM-DD}-{slug}/` - - `brief.md` + `brief.html` (task brief from `/trekbrief`; `.html` is the self-contained rendered view) + - `brief.md` (task brief from `/trekbrief`) - `research/{NN}-{slug}.md` (research briefs from `/trekresearch --project`) - `architecture/overview.md` + `architecture/gaps.md` (opt-in, produced by an opt-in upstream architect plugin, not bundled) - - `plan.md` + `plan.html` (from `/trekplan --project`) + - `plan.md` (from `/trekplan --project`) - `sessions/session-*.md` (from `--decompose`) - `progress.json` (from `/trekexecute --project`) - - `review.md` + `review.html` (from `/trekreview --project`) + - `review.md` (from `/trekreview --project`) - `.session-state.local.json` (Handover 7 — gitignored via `*.local.json`; written by `/trekexecute` Phase 8/2.55/4 or `/trekendsession`; read by `/trekcontinue`) Legacy paths (still work without `--project`): diff --git a/plugins/voyage/README.md b/plugins/voyage/README.md index d7fe3f9..4ea601f 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.0-blue) +![Version](https://img.shields.io/badge/version-5.0.1-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple) @@ -19,9 +19,9 @@ A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep | **`/trekreview`** | Review — independent post-hoc review of delivered code against the brief, severity-tagged findings | | **`/trekcontinue`** | Continue — read `.session-state.local.json` and resume the next session in a multi-session project | -`/trekbrief`, `/trekplan`, and `/trekreview` also render their artifact to a self-contained `.html` next to it and print the `file://` link — annotate via the official `/playground` plugin (`document-critique`) and paste its prompt back. +`/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a single copy-paste-ready `/playground build a document-critique playground for ` command. Paste it into Claude, the official `playground` skill builds a browser-readable HTML with per-line Approve/Reject/Comment annotations, copy the generated prompt, paste back — Claude revises the artifact. See [Reviewing and annotating artifacts](#reviewing-and-annotating-artifacts-v501). -Every artifact lives in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md` (+ `brief.html`), `research/NN-*.md`, `plan.md` (+ `plan.html`), `sessions/`, `progress.json`, and `review.md` (+ `review.html`). +Every artifact lives in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `plan.md`, `sessions/`, `progress.json`, and `review.md`. ### Division of labor @@ -503,37 +503,55 @@ Both arguments are required. No interactive prompt — headless-safe. --- -## Rendered artifacts & annotation (v5.0.0) +## Reviewing and annotating artifacts (v5.0.1) -`/trekbrief`, `/trekplan`, and `/trekreview` each finish by rendering their -just-written `.md` to a self-contained `.html` next to it -(`{project_dir}/brief.html`, `plan.html`, `review.html`) and printing the -`file://` link. The renderer (`scripts/render-artifact.mjs`) is a small, -zero-dependency Node script: it folds frontmatter into a `
` block, -puts code fences in styled `
`, renders tables/lists/links, and inlines a
-compact design-system-aligned stylesheet. **No external network, no build
-step, no telemetry.** Two runs on the same input produce byte-identical HTML.
+`/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a single
+copy-paste-ready command. After they finish, you see something like:
 
-To **annotate** an artifact, run the official `/playground` plugin
-(`document-critique` template) on the `.md` file and paste the prompt it
-generates back into the conversation — Claude then revises the artifact
-freehand from your notes. The `/playground` plugin already produces clean,
-self-contained single-file HTML for exactly this; voyage no longer ships its
-own annotation UI.
+```
+────────────────────────────────────────────────────────────────────
+To review and annotate this brief, copy and paste this into Claude:
 
-```bash
-# Render any artifact manually (the producing commands do this automatically):
-node plugins/voyage/scripts/render-artifact.mjs \
-  .claude/projects/2026-05-09-feature/plan.md
-# → writes .claude/projects/2026-05-09-feature/plan.html, prints the path
+    /playground build a document-critique playground for .claude/projects/2026-05-13-foo/brief.md
+
+That builds a self-contained HTML file with the brief on the left,
+per-line approve/reject/comment annotations on the right, and a
+"Copy Prompt" button at the bottom. Copy the generated prompt, paste
+it back here, and Claude revises brief.md from your notes.
+────────────────────────────────────────────────────────────────────
 ```
 
-> **Removed in v5.0.0.** v4.2/v4.3 shipped a ~388 KB bespoke playground SPA +
-> `/trekrevise` + Handover 8 (annotation → revision). A browser walkthrough
-> found it borderline unusable, and it duplicated the official `/playground`
-> plugin's `document-critique` / `diff-review` templates. All of it — the SPA,
-> the command, the supporting `lib/` modules, the anchor parser, the Playwright
-> e2e suite — was deleted. See [CHANGELOG.md](CHANGELOG.md) § v5.0.0.
+You copy that one `/playground …` line, paste it into Claude, and the
+official `claude-plugins-official` `playground` skill takes over: it loads
+its `document-critique` template, reads the `.md`, analyses it, generates
+per-line suggestions, and writes a single self-contained HTML file that
+opens in your browser. In the browser:
+
+- **Left:** the artifact, nicely formatted, with line numbers and per-line
+  highlight bars for any line a suggestion targets.
+- **Right:** suggestion cards (Approve / Reject / Comment), filterable by
+  status, with a counter in the header.
+- **Bottom:** a live-updated prompt covering everything you approved or
+  commented on, with a **Copy Prompt** button.
+
+You mark what you want, click Copy Prompt, paste it back into Claude, and
+Claude revises the `.md` artifact freehand from your notes. **One paste
+→ playground → mark → copy → paste back.**
+
+> **What v5.0.1 changed from v5.0.0.** v5.0.0 had `/trekbrief`, `/trekplan`,
+> and `/trekreview` each render their `.md` to a separate read-only `.html`
+> view via `scripts/render-artifact.mjs` *and* print a vague "run the
+> `/playground` plugin" instruction. In practice the read-only `.html` was
+> redundant with what `/playground` produces (which also shows the artifact
+> nicely formatted), and the instruction wasn't copy-paste-ready — operators
+> had to guess the right invocation. v5.0.1 drops `render-artifact.mjs` and
+> its `.html` output, and makes the printed `/playground` invocation
+> literal and copy-paste-ready.
+>
+> **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 and § v5.0.1.
 
 ---
 
@@ -667,7 +685,7 @@ The `pre-compact-flush.mjs` hook directly fixes the documented P0 in `docs/treke
 
 **Infrastructure-as-code (IaC) gets reduced value.** The exploration agents are designed for application code. Terraform, Helm, Pulumi, CDK projects will get a plan, but agents like `architecture-mapper` and `test-strategist` produce less useful output for IaC. Use trekplan for the structural plan, then supplement IaC-specific steps manually.
 
-**Rendered HTML is read-only.** `scripts/render-artifact.mjs` produces a static, self-contained view for browsing — it is not an editor. To revise an artifact from operator feedback, run the `/playground` plugin (`document-critique`) on the `.md` and paste its prompt back. The markdown subset the renderer supports covers what the artifact templates emit (headings, lists, code fences, tables, links, blockquotes, bold/italic, inline code); exotic markdown extensions are not rendered.
+**Annotation requires the official `/playground` skill.** Voyage no longer ships an annotation UI of its own. The pasted `/playground build a document-critique playground for …` command depends on the `claude-plugins-official` `playground` skill being installed in the operator's environment. If it isn't, paste the artifact source `.md` into Claude with a "review this" prompt and revise freehand — the same end-result, just without the visual approve/reject/comment surface.
 
 ## Installation
 
diff --git a/plugins/voyage/commands/trekbrief.md b/plugins/voyage/commands/trekbrief.md
index 288b148..451265e 100644
--- a/plugins/voyage/commands/trekbrief.md
+++ b/plugins/voyage/commands/trekbrief.md
@@ -483,30 +483,35 @@ If the validator returns errors, report them to the user and offer to
 re-enter Phase 4 with the validator's hints in scope. If only warnings,
 note them in the final report.
 
-**Render to HTML + link (annotation via /playground):** after `brief.md`
-is final, render it to a self-contained HTML view in the same directory:
+**Report — single block, printed verbatim** (substitute the bracketed
+fields, but keep the structure and the `/playground` invocation literal —
+operators copy-paste that line directly into Claude). The `/playground`
+command points at the official `claude-plugins-official` `playground`
+skill, which loads its `document-critique` template, reads the brief,
+generates per-line suggestions, and writes a single self-contained HTML
+file that opens in the browser. The HTML has the brief on the left
+(nicely formatted, line-numbered), suggestion cards on the right
+(Approve / Reject / Comment), and a "Copy Prompt" button at the bottom
+that gathers everything marked into one prompt. Paste that prompt back
+into Claude — Claude then revises `brief.md` freehand from the notes.
 
-```bash
-node ${CLAUDE_PLUGIN_ROOT}/scripts/render-artifact.mjs "{PROJECT_DIR}/brief.md"
 ```
-
-This writes `{PROJECT_DIR}/brief.html` — zero-network, design-system-styled
-(frontmatter folded into a `
` block). If it exits non-zero, surface -a one-line warning and continue — the rendered view is a convenience, not a -gate. - -Report: -``` -Brief written: {PROJECT_DIR}/brief.md -Brief rendered: file://{abs path to brief.html} +Brief written: {PROJECT_DIR}/brief.md Review iterations: {1..3} -Final quality: {complete | partial} -Validator: {PASS | warnings(N)} +Final quality: {complete | partial} +Validator: {PASS | warnings(N)} Research topics identified: {N} -To annotate: open brief.html, then run the `/playground` plugin -(document-critique template) on brief.md and paste the generated -prompt back here. Claude revises brief.md freehand from your notes. +──────────────────────────────────────────────────────────────────── +To review and annotate this brief, copy and paste this into Claude: + + /playground build a document-critique playground for {PROJECT_DIR}/brief.md + +That builds a self-contained HTML file with the brief on the left, +per-line approve/reject/comment annotations on the right, and a +"Copy Prompt" button at the bottom. Copy the generated prompt, paste +it back here, and Claude revises brief.md from your notes. +──────────────────────────────────────────────────────────────────── ``` ## Phase 5 — Auto-orchestration opt-in (if research_topics > 0) diff --git a/plugins/voyage/commands/trekplan.md b/plugins/voyage/commands/trekplan.md index 3f30d08..4c638ee 100644 --- a/plugins/voyage/commands/trekplan.md +++ b/plugins/voyage/commands/trekplan.md @@ -769,28 +769,32 @@ If the user asks questions or requests changes: - Show what changed - Re-present the summary -### Render to HTML + link (annotation via /playground) +### Print the annotation invocation -After `plan.md` is final, render it to a self-contained HTML view in the -same project directory and print the `file://` link: - -```bash -node ${CLAUDE_PLUGIN_ROOT}/scripts/render-artifact.mjs "{plan_path}" -``` - -This writes `{plan_dir}/plan.html` — a zero-network, design-system-styled -page (frontmatter folded into a `
` block, code fences in styled -`
`). Print:
+After the plan summary, print this block **verbatim** (substituting only
+`{plan_path}` with the absolute path). The `/playground` command must
+appear literally — operators copy-paste it directly into Claude. It
+points at the official `claude-plugins-official` `playground` skill,
+which loads its `document-critique` template, reads `plan.md`, generates
+per-line suggestions, and writes a single self-contained HTML file that
+opens in the browser. The HTML has the plan on the left (nicely
+formatted, line-numbered), suggestion cards on the right (Approve /
+Reject / Comment), and a "Copy Prompt" button at the bottom that gathers
+everything marked into one prompt. Paste that prompt back into Claude —
+Claude then revises `plan.md` freehand from the notes.
 
 ```
-Plan rendered: file://{abs path to plan.html}
-To annotate: open it, then run the `/playground` plugin
-(document-critique template) on plan.md and paste the generated
-prompt back here. Claude revises plan.md freehand from your notes.
-```
+────────────────────────────────────────────────────────────────────
+To review and annotate this plan, copy and paste this into Claude:
 
-If `render-artifact.mjs` exits non-zero, surface a one-line warning and
-continue — the rendered view is a convenience, not a gate.
+    /playground build a document-critique playground for {plan_path}
+
+That builds a self-contained HTML file with the plan on the left,
+per-line approve/reject/comment annotations on the right, and a
+"Copy Prompt" button at the bottom. Copy the generated prompt, paste
+it back here, and Claude revises plan.md from your notes.
+────────────────────────────────────────────────────────────────────
+```
 
 ## Phase 11 — Handoff
 
diff --git a/plugins/voyage/commands/trekreview.md b/plugins/voyage/commands/trekreview.md
index a02c59a..1c58c94 100644
--- a/plugins/voyage/commands/trekreview.md
+++ b/plugins/voyage/commands/trekreview.md
@@ -262,17 +262,6 @@ Append a stats line to `${CLAUDE_PLUGIN_DATA}/trekreview-stats.jsonl`
 If `${CLAUDE_PLUGIN_DATA}` is unset or not writable, skip stats silently.
 Never let stats failures block the main workflow.
 
-**Render to HTML + link (annotation via /playground):** after `review.md`
-is final, render it to a self-contained HTML view in the same directory:
-
-```bash
-node ${CLAUDE_PLUGIN_ROOT}/scripts/render-artifact.mjs "{review_path}"
-```
-
-This writes `{project_dir}/review.html` — zero-network, design-system-styled.
-If it exits non-zero, surface a one-line warning and continue — the rendered
-view is a convenience, not a gate.
-
 ## Phase 8.5 — Validate-only mode (`--validate`)
 
 When `mode == validate`:
@@ -293,7 +282,6 @@ After the write succeeds, print:
 **Brief:** {brief_path}
 **Project:** {project_dir}
 **Review:** {review_path}
-**Rendered:** file://{abs path to review.html}
 **Scope:** {before_sha}..{after_sha} ({reviewed_files_count} files)
 **Verdict:** {BLOCK | WARN | ALLOW}
 
@@ -308,12 +296,20 @@ After the write succeeds, print:
   ...
 {up to 5 highest-severity findings}
 
-You can:
-- Read the full review at {review_path} (or open review.html in a browser)
+────────────────────────────────────────────────────────────────────
+To review and annotate this review, copy and paste this into Claude:
+
+    /playground build a document-critique playground for {review_path}
+
+That builds a self-contained HTML file with the review on the left,
+per-line approve/reject/comment annotations on the right, and a
+"Copy Prompt" button at the bottom. Copy the generated prompt, paste
+it back here, and Claude revises review.md from your notes.
+────────────────────────────────────────────────────────────────────
+
+You can also:
 - Feed BLOCKER + MAJOR findings into a follow-up plan:
     /trekplan --brief {review_path}
-- Annotate: run the `/playground` plugin (document-critique template) on
-  review.md and paste the generated prompt back here
 - Re-run with `--quick` for a faster correctness-only pass
 - Re-run with `--since ` to narrow scope
 ```
diff --git a/plugins/voyage/package-lock.json b/plugins/voyage/package-lock.json
index 02b8e97..f0096a0 100644
--- a/plugins/voyage/package-lock.json
+++ b/plugins/voyage/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "voyage",
-  "version": "5.0.0",
+  "version": "5.0.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "voyage",
-      "version": "5.0.0",
+      "version": "5.0.1",
       "license": "MIT",
       "engines": {
         "node": ">=18"
diff --git a/plugins/voyage/package.json b/plugins/voyage/package.json
index 7bac22e..ba7a15e 100644
--- a/plugins/voyage/package.json
+++ b/plugins/voyage/package.json
@@ -1,14 +1,13 @@
 {
   "name": "voyage",
-  "version": "5.0.0",
-  "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline; renders produced artifacts to HTML + link, annotate via the /playground plugin.",
+  "version": "5.0.1",
+  "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a copy-paste-ready /playground invocation that builds an interactive document-critique HTML for the artifact.",
   "type": "module",
   "engines": {
     "node": ">=18"
   },
   "scripts": {
     "test": "node --test 'tests/**/*.test.mjs'",
-    "render": "node scripts/render-artifact.mjs",
     "verify": "bash verify.sh"
   },
   "keywords": [
diff --git a/plugins/voyage/scripts/render-artifact.mjs b/plugins/voyage/scripts/render-artifact.mjs
deleted file mode 100644
index 09b9287..0000000
--- a/plugins/voyage/scripts/render-artifact.mjs
+++ /dev/null
@@ -1,321 +0,0 @@
-#!/usr/bin/env node
-// scripts/render-artifact.mjs
-//
-// Renders a voyage artifact (brief.md / plan.md / review.md) to a
-// self-contained HTML file in the same directory, with inlined CSS and
-// zero external network references. The producing commands (/trekbrief,
-// /trekplan, /trekreview) call this at the end and print the file:// link
-// so the operator can read the artifact in a browser — and, when they want
-// to annotate it, run the official `/playground` plugin (document-critique
-// template) on it and paste the generated prompt back into Claude Code.
-//
-// Usage:
-//   node scripts/render-artifact.mjs  [--out ]
-//
-// Determinism: no timestamps, no random IDs — two runs on the same input
-// produce byte-identical output.
-//
-// Zero npm deps (marketplace convention). The markdown→HTML conversion is a
-// small hand-rolled subset that covers what the artifact templates emit:
-// ATX headings, ordered/unordered/nested lists, fenced code blocks, inline
-// code, bold, links, blockquotes, GitHub-style tables, and horizontal rules.
-
-import { readFileSync, writeFileSync, existsSync } from 'node:fs';
-import { basename } from 'node:path';
-import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
-
-// ---------------------------------------------------------------------------
-
-function escapeHtml(s) {
-  return String(s)
-    .replace(/&/g, '&')
-    .replace(//g, '>')
-    .replace(/"/g, '"');
-}
-
-// Inline spans, applied to already-HTML-escaped text. Order matters: code
-// spans first (so their contents aren't re-processed), then links, bold, em.
-function renderInline(escaped) {
-  let out = escaped.replace(/`([^`]+)`/g, (_, c) => `${c}`);
-  out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text, href) => {
-    const safe = /^(https?:|mailto:|#|\.|\/)/i.test(href) ? href : '#';
-    return `${text}`;
-  });
-  out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`);
-  out = out.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => `${pre}${c}`);
-  return out;
-}
-
-function renderTable(rows) {
-  const cells = (line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
-  const header = cells(rows[0]);
-  const body = rows.slice(2).map(cells);
-  let html = '\n';
-  for (const h of header) html += ``;
-  html += '\n\n';
-  for (const r of body) {
-    html += '';
-    for (let i = 0; i < header.length; i++) html += ``;
-    html += '\n';
-  }
-  return html + '\n
${renderInline(escapeHtml(h))}
${renderInline(escapeHtml(r[i] || ''))}
\n'; -} - -// Build nested
    /
      from a run of list lines (2-space indent = 1 level). -function renderList(items) { - let html = ''; - const stack = []; // { indent, ordered } - 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'; -} - -function renderMarkdown(md) { - const lines = md.replace(/\r\n/g, '\n').split('\n'); - let html = ''; - let i = 0; - let para = []; - - const flushPara = () => { - if (para.length) { - html += `

${renderInline(escapeHtml(para.join(' ')))}

\n`; - para = []; - } - }; - - while (i < lines.length) { - const line = lines[i]; - - // Fenced code block - const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); - if (fence) { - flushPara(); - const marker = fence[2]; - const lang = (fence[3] || '').trim().split(/\s+/)[0]; - const buf = []; - i++; - while (i < lines.length && !lines[i].match(new RegExp('^\\s*' + marker[0] + '{3,}\\s*$'))) { - buf.push(lines[i]); - i++; - } - i++; // consume 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 (header row + separator row) - 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++; } - // include the separator that was matched as part of rows already - html += renderTable(rows); - 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 (consume a contiguous block, allowing 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++; // blank line inside the list - } else { - break; - } - } - html += renderList(items); - continue; - } - - // Blank line — paragraph break - if (line.trim() === '') { - flushPara(); - i++; - continue; - } - - // Default — accumulate into paragraph - para.push(line.trim()); - i++; - } - flushPara(); - return html; -} - -// --------------------------------------------------------------------------- - -const STYLE = ` -:root { color-scheme: light; } -* { box-sizing: border-box; } -body { - margin: 0; padding: 2.5rem 1.25rem 4rem; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 16px; line-height: 1.6; color: #1a1a1a; background: #f7f7f8; -} -main { max-width: 56rem; margin: 0 auto; background: #fff; border: 1px solid #e2e2e6; - border-radius: 12px; padding: 2.5rem 3rem; box-shadow: 0 1px 3px rgba(0,0,0,.06); } -h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.8em 0 .6em; font-weight: 650; } -h1 { font-size: 2rem; margin-top: 0; } -h2 { font-size: 1.5rem; border-bottom: 1px solid #ececef; padding-bottom: .3em; } -h3 { font-size: 1.2rem; } -h4 { font-size: 1.05rem; } -p { margin: .8em 0; } -a { color: #0855a8; text-decoration: underline; text-underline-offset: 2px; } -a:hover { color: #06408a; } -code { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; - font-size: .9em; background: #f0f0f3; padding: .12em .35em; border-radius: 4px; } -pre { background: #1e1e24; color: #e6e6eb; padding: 1rem 1.25rem; border-radius: 8px; - overflow-x: auto; font-size: .85rem; line-height: 1.5; } -pre code { background: none; padding: 0; color: inherit; font-size: inherit; } -blockquote { margin: 1em 0; padding: .4em 1.2em; border-left: 4px solid #0855a8; - background: #f0f5fb; color: #34495e; border-radius: 0 6px 6px 0; } -ul, ol { padding-left: 1.6em; margin: .8em 0; } -li { margin: .25em 0; } -table { border-collapse: collapse; width: 100%; margin: 1.2em 0; font-size: .92rem; } -th, td { border: 1px solid #e2e2e6; padding: .5em .75em; text-align: left; vertical-align: top; } -th { background: #f0f0f3; font-weight: 600; } -tr:nth-child(even) td { background: #fafafb; } -hr { border: none; border-top: 1px solid #e2e2e6; margin: 2em 0; } -details.frontmatter { margin: 0 0 2rem; border: 1px solid #e2e2e6; border-radius: 8px; - background: #fafafb; padding: .6em 1em; } -details.frontmatter > summary { cursor: pointer; font-weight: 600; font-size: .9rem; color: #555; } -details.frontmatter pre { margin: .8em 0 .2em; background: #f4f4f6; color: #333; } -.artifact-meta { color: #888; font-size: .82rem; margin: 0 0 1.5rem; } -@media (prefers-color-scheme: dark) { - :root { color-scheme: dark; } - body { color: #e6e6eb; background: #18181b; } - main { background: #1f1f23; border-color: #2e2e34; box-shadow: none; } - h2 { border-bottom-color: #2e2e34; } - a { color: #6db0ee; } a:hover { color: #93c5fd; } - code { background: #2a2a30; } - blockquote { background: #1a242f; color: #b6c5d4; border-left-color: #6db0ee; } - th, td { border-color: #2e2e34; } th { background: #26262c; } - tr:nth-child(even) td { background: #222226; } - hr { border-top-color: #2e2e34; } - details.frontmatter { background: #222226; border-color: #2e2e34; } - details.frontmatter > summary { color: #aaa; } - details.frontmatter pre { background: #1a1a1d; color: #ccc; } - .artifact-meta { color: #777; } -} -@media print { body { background: #fff; padding: 0; } main { border: none; box-shadow: none; max-width: none; } } -`.trim(); - -function buildHtml(mdPath, mdText) { - const { hasFrontmatter, frontmatter, body } = splitFrontmatter(mdText); - const fm = hasFrontmatter ? frontmatter : ''; - const fmLine = (key) => { - const m = fm.match(new RegExp('^' + key + ':\\s*(.+)$', 'm')); - return m ? m[1].trim().replace(/^["']|["']$/g, '') : null; - }; - const title = fmLine('task') || fmLine('slug') || (body.match(/^#\s+(.+)$/m) || [])[1] || basename(mdPath); - const kind = fmLine('type') || basename(mdPath).replace(/\.md$/, ''); - - const fmBlock = hasFrontmatter - ? `
Frontmatter
${escapeHtml(fm)}\n
\n` - : ''; - - const bodyHtml = renderMarkdown(body); - - return '\n' - + '\n\n\n' - + '\n' - + `${escapeHtml(String(title))}\n` - + `\n\n\n
\n` - + `

voyage artifact — ${escapeHtml(String(kind))}

\n` - + fmBlock - + bodyHtml - + '
\n\n\n'; -} - -function render(inputPath, outputPath) { - if (!existsSync(inputPath)) { - process.stderr.write(`render-artifact: input not found: ${inputPath}\n`); - process.exit(2); - } - const text = readFileSync(inputPath, 'utf-8'); - const html = buildHtml(inputPath, text); - const out = outputPath || inputPath.replace(/\.md$/, '.html'); - writeFileSync(out, html); - return out; -} - -function parseArgs(argv) { - const args = { input: null, out: null, help: false }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--out') args.out = argv[++i]; - else if (a === '--help' || a === '-h') args.help = true; - else if (!args.input) args.input = a; - } - return args; -} - -if (import.meta.url === `file://${process.argv[1]}`) { - const args = parseArgs(process.argv.slice(2)); - if (args.help || !args.input) { - process.stdout.write( - 'Usage: render-artifact [--out ]\n\n' - + 'Renders a voyage artifact to a self-contained HTML file (zero network).\n' - + 'Default output: .html next to the input.\n', - ); - process.exit(args.help ? 0 : 2); - } - const out = render(args.input, args.out); - process.stdout.write(out + '\n'); -} - -export { render, buildHtml, renderMarkdown, parseArgs }; diff --git a/plugins/voyage/tests/lib/doc-consistency.test.mjs b/plugins/voyage/tests/lib/doc-consistency.test.mjs index debb032..cbc1c81 100644 --- a/plugins/voyage/tests/lib/doc-consistency.test.mjs +++ b/plugins/voyage/tests/lib/doc-consistency.test.mjs @@ -400,13 +400,13 @@ test('commands/trekplan.md Phase 8 seals Opus-4.7 schema-drift defense', () => { ); }); -// --- v5.0.0 — bespoke playground + /trekrevise + Handover 8 removed --- +// --- v5.0.0 / v5.0.1 — bespoke playground removed; /playground invocation explicit --- // -// The v4.2/v4.3 bespoke playground SPA, the /trekrevise command, and -// Handover 8 (annotation → revision) were removed in v5.0.0. Producing -// commands now render artifacts to self-contained HTML via -// scripts/render-artifact.mjs and direct operators at the official -// `/playground` plugin for annotation. These pins lock the removal in. +// v5.0.0 removed the bespoke playground SPA, /trekrevise, and Handover 8. +// v5.0.1 dropped the v5.0.0 stop-gap (scripts/render-artifact.mjs) and made +// the producing commands print a literal, copy-paste-ready /playground +// document-critique invocation instead. These pins lock both removals in +// AND pin the new copy-paste invocation as the operator-facing contract. import { existsSync } from 'node:fs'; @@ -430,36 +430,55 @@ test('Handover 8 deleted from HANDOVER-CONTRACTS.md (back to seven handovers)', assert.ok(text.includes('## Handover 7'), 'Handover 7 must remain'); }); -test('scripts/render-artifact.mjs exists (v5.0.0 render-and-link step)', () => { +test('scripts/render-artifact.mjs no longer exists (removed in v5.0.1)', () => { assert.ok( - existsSync(join(ROOT, 'scripts/render-artifact.mjs')), - 'scripts/render-artifact.mjs is required — producing commands call it to render artifacts to HTML', + !existsSync(join(ROOT, 'scripts/render-artifact.mjs')), + 'scripts/render-artifact.mjs should be deleted — v5.0.1 drops the redundant standalone HTML render in favour of the /playground document-critique invocation printed by the producing commands', ); }); -test('producing commands reference render-artifact.mjs (render-and-link step)', () => { +test('producing commands print a literal /playground document-critique invocation', () => { + // The exact substring must appear in each producing command's prose so the + // operator copy-pastes a verbatim line. Drift on this is the friction point + // that motivated v5.0.1 — fail loudly if the prose softens back to "run the + // /playground plugin" without the literal command. + const REQUIRED = '/playground build a document-critique playground for'; for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { assert.ok( - read(`commands/${f}`).includes('render-artifact.mjs'), - `commands/${f} must wire the render-artifact.mjs render-and-link step (v5.0.0)`, + read(`commands/${f}`).includes(REQUIRED), + `commands/${f} must include the literal invocation "${REQUIRED}" so the operator copy-pastes it directly (v5.0.1)`, ); } }); -test('producing commands point operators at the /playground plugin for annotation', () => { +test('producing commands no longer reference the removed scripts/render-artifact.mjs', () => { for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { assert.ok( - read(`commands/${f}`).includes('/playground'), - `commands/${f} must mention the /playground plugin as the annotation path (v5.0.0)`, + !read(`commands/${f}`).includes('render-artifact.mjs'), + `commands/${f} still references scripts/render-artifact.mjs — that script was removed in v5.0.1`, ); } }); +test('package.json no longer has an "npm run render" script (removed in v5.0.1)', () => { + const pkg = JSON.parse(read('package.json')); + assert.equal( + pkg.scripts && pkg.scripts.render, + undefined, + 'package.json scripts.render should be gone in v5.0.1', + ); +}); + test('CHANGELOG.md has v5.0.0 entry', () => { const cl = read('CHANGELOG.md'); assert.match(cl, /## v5\.0\.0\b/, 'CHANGELOG.md must include "## v5.0.0" entry'); }); +test('CHANGELOG.md has v5.0.1 entry', () => { + const cl = read('CHANGELOG.md'); + assert.match(cl, /## v5\.0\.1\b/, 'CHANGELOG.md must include "## v5.0.1" entry'); +}); + test('CHANGELOG.md retains v4.2.0 entry (history is not rewritten)', () => { const cl = read('CHANGELOG.md'); assert.match(cl, /## v4\.2\.0\b/, 'CHANGELOG.md must keep the historical "## v4.2.0" entry'); diff --git a/plugins/voyage/tests/scripts/render-artifact.test.mjs b/plugins/voyage/tests/scripts/render-artifact.test.mjs deleted file mode 100644 index 835f68a..0000000 --- a/plugins/voyage/tests/scripts/render-artifact.test.mjs +++ /dev/null @@ -1,122 +0,0 @@ -// tests/scripts/render-artifact.test.mjs -// Covers scripts/render-artifact.mjs — the v5.0.0 self-contained HTML -// renderer that /trekbrief, /trekplan, /trekreview call at the end of their -// run to produce a browser-readable view of the just-written artifact. - -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, renderMarkdown, render, parseArgs } from '../../scripts/render-artifact.mjs'; - -const SAMPLE = `--- -type: trekplan -plan_version: "1.7" -task: "Render-artifact smoke test" -slug: render-smoke ---- - -# Render-artifact smoke test - -A paragraph with **bold**, \`inline code\`, and a [link](https://example.com). - -## Steps - -- top item - - nested item -- second top item - -1. ordered one -2. ordered two - -\`\`\`js -const x = 1; -\`\`\` - -> a blockquote line - -| Col A | Col B | -|-------|-------| -| 1 | 2 | -`; - -test('buildHtml produces a complete self-contained HTML document', () => { - const html = buildHtml('plan.md', SAMPLE); - assert.ok(html.startsWith(''), 'must start with doctype'); - assert.ok(html.includes(''), 'must close html'); - assert.ok(html.includes('\n' + + '\n' + + '\n' + + '
\n' + + '
\n' + + '
\n' + + '

' + titleEsc + '

\n' + + '

' + fileNameEsc + '

\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' + + '
\n' + + '
\n' + + ' Prompt for Claude\n' + + ' \n' + + '
\n' + + '
Click a line and add a note to generate a prompt.
\n' + + '
\n' + + '
\n' + + '\n' + + '\n' + + '\n'; +} + +// --------------------------------------------------------------------------- +// Stylesheet — design-system-aligned, light + dark, no external fonts/CDN. +// --------------------------------------------------------------------------- +const STYLE = ` +:root { + --bg: #f7f7f8; + --bg-elev: #ffffff; + --bg-soft: #ececef; + --border: #d6d8dc; + --border-strong: #b3b7bd; + --text: #1a1a1a; + --text-dim: #555a63; + --text-mute: #8a8f97; + --accent: #0855a8; + --accent-soft: #e4ecf6; + --amber: #a86b00; + --amber-soft: #fbeed1; + --green: #1a7f37; + --green-soft: #d5ecdb; + --red: #b3262d; + --red-soft: #f6d9da; + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0e1218; + --bg-elev: #161b22; + --bg-soft: #1c232c; + --border: #2a323c; + --border-strong: #3b4554; + --text: #e5e9ef; + --text-dim: #a5adba; + --text-mute: #6e7681; + --accent: #6db0ee; + --accent-soft: rgba(109, 176, 238, 0.15); + --amber: #d4a017; + --amber-soft: rgba(212, 160, 23, 0.12); + --green: #3fb950; + --green-soft: rgba(63, 185, 80, 0.12); + --red: #f0626a; + --red-soft: rgba(240, 98, 106, 0.12); + } +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); + font-family: var(--sans); font-size: 14px; line-height: 1.55; } +.app { display: grid; grid-template-rows: auto 1fr auto; height: 100vh; } +header.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 12px 20px; background: var(--bg-elev); border-bottom: 1px solid var(--border); } +.hdr-meta h1 { font-size: 16px; font-weight: 650; margin: 0; } +.hdr-meta .path { color: var(--text-dim); font-size: 12px; font-family: var(--mono); margin: 2px 0 0; word-break: break-all; } +.hdr-stats { display: flex; gap: 8px; font-size: 12px; color: var(--text-dim); } +.pill { padding: 2px 10px; border-radius: 99px; background: var(--bg-soft); border: 1px solid var(--border); } +.pill.accent { color: var(--accent); border-color: var(--accent); } +main.split { display: grid; grid-template-columns: 1fr 380px; overflow: hidden; min-height: 0; } +.doc-panel { overflow-y: auto; background: var(--bg); padding: 16px 24px 80px; } +.doc-help { font-size: 12px; color: var(--text-dim); background: var(--bg-soft); + border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; margin: 0 0 18px; } +.doc-help strong { color: var(--text); } +.doc-content { font-family: var(--mono); font-size: 13px; line-height: 1.6; } +.doc-line { display: grid; grid-template-columns: 44px 24px 1fr; gap: 6px; + padding: 1px 8px 1px 0; border-left: 3px solid transparent; cursor: pointer; transition: background .08s; } +.doc-line:hover { background: var(--accent-soft); } +.doc-line:hover .gutter { color: var(--accent); } +.doc-line .ln { color: var(--text-mute); text-align: right; user-select: none; font-size: 11px; padding-top: 2px; } +.doc-line .gutter { font-size: 13px; color: transparent; text-align: center; user-select: none; line-height: 1.6; } +.doc-line .content { white-space: pre-wrap; word-break: break-word; color: var(--text); } +.doc-line.annotated { border-left-color: var(--amber); background: var(--amber-soft); } +.doc-line.annotated .gutter { color: var(--amber); font-weight: 700; font-size: 11px; } +.doc-line.annotated:hover { background: var(--amber-soft); filter: brightness(0.97); } +.doc-line.active { outline: 2px solid var(--accent); outline-offset: -2px; } +.doc-line .heading-1 { color: var(--text); font-size: 18px; font-weight: 700; } +.doc-line .heading-2 { color: var(--text); font-size: 15px; font-weight: 700; padding-top: 6px; } +.doc-line .heading-3 { color: var(--text); font-size: 14px; font-weight: 650; } +.doc-line .ic { background: var(--bg-soft); padding: 0 4px; border-radius: 3px; font-size: 0.95em; } +.doc-line .strong { font-weight: 700; } +.doc-line .em { font-style: italic; color: var(--accent); } +.doc-line a { color: var(--accent); text-decoration: underline; } +.input-row { grid-column: 1 / -1; margin: 4px 0 8px 44px; padding: 8px; + background: var(--bg-elev); border: 1px solid var(--accent); border-radius: 6px; } +.input-row textarea { width: 100%; min-height: 60px; padding: 6px 8px; + font-family: var(--sans); font-size: 13px; line-height: 1.4; color: var(--text); + background: var(--bg); border: 1px solid var(--border); border-radius: 4px; resize: vertical; } +.input-row textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); } +.input-row .input-actions { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end; } +.btn { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border); + background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 500; cursor: pointer; } +.btn:hover { color: var(--text); border-color: var(--border-strong); } +.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); } +.btn.primary:hover { filter: brightness(1.1); color: #fff; } +.btn.primary:disabled { background: var(--bg-soft); color: var(--text-mute); cursor: not-allowed; filter: none; border-color: var(--border); } +.ghost-btn { background: transparent; color: var(--text-dim); border: 1px solid var(--border); + border-radius: 5px; padding: 4px 10px; font-family: inherit; font-size: 12px; cursor: pointer; } +.ghost-btn:hover { color: var(--red); border-color: var(--red); } +.notes-panel { border-left: 1px solid var(--border); background: var(--bg-elev); + display: flex; flex-direction: column; overflow: hidden; min-height: 0; } +.notes-header { display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px; border-bottom: 1px solid var(--border); } +.notes-header h3 { font-size: 13px; font-weight: 650; margin: 0; color: var(--text); } +.notes-list { overflow-y: auto; padding: 12px; flex: 1; min-height: 0; } +.notes-empty { color: var(--text-mute); font-size: 12px; text-align: center; padding: 24px 8px; + font-style: italic; line-height: 1.5; } +.note-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + padding: 10px 12px; margin-bottom: 10px; } +.note-card:hover { border-color: var(--border-strong); } +.note-card.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } +.note-card .note-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; } +.note-card .lineref { font-family: var(--mono); font-size: 11px; color: var(--accent); cursor: pointer; font-weight: 600; } +.note-card .lineref:hover { text-decoration: underline; } +.note-card .delete-btn { background: transparent; border: none; color: var(--text-mute); + cursor: pointer; padding: 2px 6px; font-size: 12px; border-radius: 4px; } +.note-card .delete-btn:hover { color: var(--red); background: var(--red-soft); } +.note-card .target { font-family: var(--mono); font-size: 11px; color: var(--text-mute); + margin: 0 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.note-card .note-text { font-size: 12.5px; color: var(--text); line-height: 1.45; white-space: pre-wrap; word-break: break-word; } +.note-card textarea { width: 100%; min-height: 60px; padding: 6px 8px; + font-family: var(--sans); font-size: 12.5px; line-height: 1.4; color: var(--text); + background: var(--bg-elev); border: 1px solid var(--border); border-radius: 4px; resize: vertical; } +.note-card textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); } +.note-card .edit-actions { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end; } +.prompt-panel { border-top: 1px solid var(--border); background: var(--bg-elev); + padding: 12px 20px 14px; display: flex; flex-direction: column; gap: 8px; max-height: 30vh; } +.prompt-head { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--text-dim); } +.prompt-title { font-weight: 600; color: var(--text); font-size: 13px; } +.prompt-body { flex: 1; min-height: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + padding: 10px 12px; font-family: var(--mono); font-size: 12px; line-height: 1.5; + overflow-y: auto; white-space: pre-wrap; color: var(--text); margin: 0; } +.prompt-body.empty { color: var(--text-mute); font-style: italic; } +.copy-btn { background: var(--accent); color: #fff; border: none; border-radius: 5px; + padding: 6px 14px; font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; } +.copy-btn:hover:not(:disabled) { filter: brightness(1.1); } +.copy-btn:disabled { background: var(--bg-soft); color: var(--text-mute); cursor: not-allowed; } +.copy-btn.copied { background: var(--green); } +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 6px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-mute); } +`.trim(); + +// --------------------------------------------------------------------------- +// Embedded JS app — operator annotation surface. +// Uses concatenation (no template literals) to avoid backtick collisions +// with the outer mjs string assembly. +// --------------------------------------------------------------------------- +const APP_JS = ` +const STORAGE_KEY = 'voyage-annotate:' + ARTIFACT_PATH; +let state = { annotations: [], openInputLine: null, editingId: null, activeId: null }; +let nextId = 1; + +function load() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const data = JSON.parse(raw); + if (data && Array.isArray(data.annotations)) { + state.annotations = data.annotations; + nextId = (data.nextId || data.annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1) || 1; + } + } catch (e) {} +} +function save() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: state.annotations, nextId: nextId })); } catch (e) {} +} +function escapeHtml(s) { + return String(s).replace(/&/g, '&').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; + + root.querySelectorAll('.doc-line').forEach(function(el) { + el.addEventListener('click', function(e) { + if (e.target.closest('.input-row')) return; + const ln = parseInt(el.getAttribute('data-line'), 10); + state.openInputLine = state.openInputLine === ln ? null : ln; + state.activeId = null; + renderAll(); + if (state.openInputLine) { + setTimeout(function(){ + const ta = document.getElementById('input-' + ln); + if (ta) ta.focus(); + }, 0); + } + }); + }); + root.querySelectorAll('button[data-act]').forEach(function(b) { + b.addEventListener('click', function(e) { + e.stopPropagation(); + const act = b.dataset.act; + if (act === 'cancel-input') { state.openInputLine = null; renderAll(); } + else if (act === 'save-input') { + const ln = parseInt(b.dataset.line, 10); + const ta = document.getElementById('input-' + ln); + const text = ta.value.trim(); + if (!text) return; + addAnnotation(ln, text); + } + }); + }); + root.querySelectorAll('textarea[id^="input-"]').forEach(function(ta) { + ta.addEventListener('input', function() { + const ln = parseInt(ta.id.replace('input-', ''), 10); + const saveBtn = document.querySelector('button[data-act="save-input"][data-line="' + ln + '"]'); + if (saveBtn) saveBtn.disabled = ta.value.trim().length === 0; + }); + ta.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { state.openInputLine = null; renderAll(); } + else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + const ln = parseInt(ta.id.replace('input-', ''), 10); + const text = ta.value.trim(); + if (text) addAnnotation(ln, text); + } + }); + }); +} +function addAnnotation(lineNum, text) { + const raw = DOC_LINES[lineNum - 1] || ''; + const snippet = raw.trim().length > 80 ? raw.trim().slice(0, 77) + '…' : raw.trim(); + const a = { id: nextId++, line: lineNum, target: snippet || '(blank line)', text: text, ts: new Date().toISOString() }; + state.annotations.push(a); + state.openInputLine = null; + state.activeId = a.id; + save(); + renderAll(); + setTimeout(function(){ + const card = document.getElementById('card-' + a.id); + if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 50); +} +function deleteAnnotation(id) { + state.annotations = state.annotations.filter(function(a){ return a.id !== id; }); + if (state.activeId === id) state.activeId = null; + if (state.editingId === id) state.editingId = null; + save(); + renderAll(); +} +function updateAnnotation(id, text) { + const a = state.annotations.find(function(x){ return x.id === id; }); + if (!a) return; + if (!text.trim()) { deleteAnnotation(id); return; } + a.text = text.trim(); + a.ts = new Date().toISOString(); + state.editingId = null; + save(); + renderAll(); +} +function renderNotesList() { + const root = document.getElementById('notes-list'); + if (state.annotations.length === 0) { + root.innerHTML = '
No annotations yet.

Click any line on the left to add your first note.
'; + 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(); + }); + }); + root.querySelectorAll('button[data-del]').forEach(function(b) { + b.addEventListener('click', function() { + if (confirm('Delete this annotation?')) deleteAnnotation(parseInt(b.dataset.del, 10)); + }); + }); + root.querySelectorAll('.note-text[data-edit]').forEach(function(el) { + el.addEventListener('click', function() { + state.editingId = parseInt(el.dataset.edit, 10); + renderAll(); + setTimeout(function(){ + const ta = document.getElementById('edit-' + state.editingId); + if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } + }, 0); + }); + }); + root.querySelectorAll('button[data-act="cancel-edit"]').forEach(function(b) { + b.addEventListener('click', function() { state.editingId = null; renderAll(); }); + }); + root.querySelectorAll('button[data-act="save-edit"]').forEach(function(b) { + b.addEventListener('click', function() { + const id = parseInt(b.dataset.id, 10); + const ta = document.getElementById('edit-' + id); + updateAnnotation(id, ta.value); + }); + }); +} +function renderStats() { + const n = state.annotations.length; + document.getElementById('stats').innerHTML = n === 0 + ? 'No annotations yet' + : '' + n + ' annotation' + (n === 1 ? '' : 's') + ''; + const copyBtn = document.getElementById('copy'); + copyBtn.disabled = n === 0; +} +function renderPrompt() { + const out = document.getElementById('prompt'); + if (state.annotations.length === 0) { + out.classList.add('empty'); + out.textContent = 'Click a line and add a note to generate a prompt.'; + return; + } + out.classList.remove('empty'); + const sorted = state.annotations.slice().sort(function(a, b){ return a.line - b.line || a.id - b.id; }); + let p = 'Please revise the voyage artifact at \`' + ARTIFACT_PATH + '\` with the operator annotations below.\\n'; + p += 'Each annotation is anchored to a specific line of the source markdown.\\n'; + p += 'Treat the operator notes as authoritative direction for what should change.\\n\\n'; + p += '## Annotations (' + state.annotations.length + ' total)\\n\\n'; + for (let i = 0; i < sorted.length; i++) { + const a = sorted[i]; + p += '### Line ' + a.line + '\\n'; + p += 'Source: ' + a.target + '\\n'; + p += 'Operator note: ' + a.text + '\\n\\n'; + } + out.textContent = p; +} +document.getElementById('copy').addEventListener('click', async function() { + const txt = document.getElementById('prompt').textContent; + try { + await navigator.clipboard.writeText(txt); + const btn = document.getElementById('copy'); + btn.classList.add('copied'); + const old = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(function(){ btn.classList.remove('copied'); btn.textContent = old; }, 1500); + } catch (e) { alert('Copy failed: ' + e.message); } +}); +document.getElementById('clear-all').addEventListener('click', function() { + if (state.annotations.length === 0) return; + if (confirm('Remove all ' + state.annotations.length + ' annotations? This cannot be undone.')) { + state.annotations = []; state.activeId = null; state.editingId = null; + save(); renderAll(); + } +}); +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && state.openInputLine !== null) { + state.openInputLine = null; + renderAll(); + } +}); +function renderAll() { + renderDoc(); + renderNotesList(); + renderStats(); + renderPrompt(); +} +load(); +renderAll(); +`.trim(); + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- +function parseArgs(argv) { + const args = { input: null, out: null, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--out') args.out = argv[++i]; + else if (a === '--help' || a === '-h') args.help = true; + else if (!args.input) args.input = a; + } + return args; +} + +function render(inputPath, outputPath) { + if (!existsSync(inputPath)) { + process.stderr.write('annotate: input not found: ' + inputPath + '\n'); + process.exit(2); + } + const text = readFileSync(inputPath, 'utf-8'); + const html = buildHtml(resolve(inputPath), text); + const out = outputPath || inputPath.replace(/\.md$/, '.html'); + writeFileSync(out, html); + return out; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.input) { + process.stdout.write( + 'Usage: annotate [--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' + + 'Default output: .html next to input.\n', + ); + process.exit(args.help ? 0 : 2); + } + const out = render(args.input, args.out); + process.stdout.write(out + '\n'); +} + +export { render, buildHtml, parseArgs }; diff --git a/plugins/voyage/tests/lib/doc-consistency.test.mjs b/plugins/voyage/tests/lib/doc-consistency.test.mjs index cbc1c81..5c7bef8 100644 --- a/plugins/voyage/tests/lib/doc-consistency.test.mjs +++ b/plugins/voyage/tests/lib/doc-consistency.test.mjs @@ -430,42 +430,67 @@ test('Handover 8 deleted from HANDOVER-CONTRACTS.md (back to seven handovers)', assert.ok(text.includes('## Handover 7'), 'Handover 7 must remain'); }); -test('scripts/render-artifact.mjs no longer exists (removed in v5.0.1)', () => { +test('scripts/render-artifact.mjs is still removed (v5.0.1 + v5.0.2)', () => { assert.ok( !existsSync(join(ROOT, 'scripts/render-artifact.mjs')), - 'scripts/render-artifact.mjs should be deleted — v5.0.1 drops the redundant standalone HTML render in favour of the /playground document-critique invocation printed by the producing commands', + 'scripts/render-artifact.mjs should be deleted — v5.0.1 dropped the standalone HTML render; v5.0.2 kept it removed (annotate.mjs is the replacement)', ); }); -test('producing commands print a literal /playground document-critique invocation', () => { - // The exact substring must appear in each producing command's prose so the - // operator copy-pastes a verbatim line. Drift on this is the friction point - // that motivated v5.0.1 — fail loudly if the prose softens back to "run the - // /playground plugin" without the literal command. - const REQUIRED = '/playground build a document-critique playground for'; +test('scripts/annotate.mjs exists (v5.0.2 operator-annotation HTML generator)', () => { + assert.ok( + existsSync(join(ROOT, 'scripts/annotate.mjs')), + 'scripts/annotate.mjs is required — producing commands call it to build the operator-annotation HTML', + ); +}); + +test('producing commands reference scripts/annotate.mjs (v5.0.2 render-and-link step)', () => { + // v5.0.0 → v5.0.1 → v5.0.2 chain: v5.0.0 added an HTML render that didn't + // afford annotation; v5.0.1 pointed at /playground document-critique (which + // pre-generates Claude's suggestions, not operator-driven annotation); v5.0.2 + // ships scripts/annotate.mjs — an operator-driven annotation surface where + // the OPERATOR clicks lines and writes their own notes. Pin the wiring. for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { assert.ok( - read(`commands/${f}`).includes(REQUIRED), - `commands/${f} must include the literal invocation "${REQUIRED}" so the operator copy-pastes it directly (v5.0.1)`, + read(`commands/${f}`).includes('scripts/annotate.mjs'), + `commands/${f} must invoke scripts/annotate.mjs to build the operator-annotation HTML (v5.0.2)`, ); } }); -test('producing commands no longer reference the removed scripts/render-artifact.mjs', () => { +test('producing commands no longer print the v5.0.1 /playground document-critique line', () => { + // v5.0.1 told operators to copy-paste "/playground build a document-critique + // playground for X" — but that flow pre-generates Claude's suggestions. The + // operator asked for their own annotations, not a critique of Claude's. + // v5.0.2 removes that line from the producing commands' final report. for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { assert.ok( - !read(`commands/${f}`).includes('render-artifact.mjs'), - `commands/${f} still references scripts/render-artifact.mjs — that script was removed in v5.0.1`, + !read(`commands/${f}`).includes('/playground build a document-critique'), + `commands/${f} must not print the v5.0.1 /playground document-critique invocation — v5.0.2 replaces it with annotate.mjs`, ); } }); -test('package.json no longer has an "npm run render" script (removed in v5.0.1)', () => { +test('producing commands tell the operator the flow is THEIR own annotations', () => { + // Pin language: every producing command's prose must mention that the + // OPERATOR drives annotation, not Claude. Phrase variants are allowed + // ("YOUR OWN note", "operator drives", etc.) — we look for the operator- + // ownership signal. + for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { + const text = read(`commands/${f}`); + assert.ok( + /YOUR OWN|operator drives|your own/i.test(text), + `commands/${f} must signal that the operator drives annotation (v5.0.2 contract)`, + ); + } +}); + +test('package.json still has no "npm run render" script (removed in v5.0.1)', () => { const pkg = JSON.parse(read('package.json')); assert.equal( pkg.scripts && pkg.scripts.render, undefined, - 'package.json scripts.render should be gone in v5.0.1', + 'package.json scripts.render should remain gone', ); }); @@ -479,6 +504,11 @@ test('CHANGELOG.md has v5.0.1 entry', () => { assert.match(cl, /## v5\.0\.1\b/, 'CHANGELOG.md must include "## v5.0.1" entry'); }); +test('CHANGELOG.md has v5.0.2 entry', () => { + const cl = read('CHANGELOG.md'); + assert.match(cl, /## v5\.0\.2\b/, 'CHANGELOG.md must include "## v5.0.2" entry'); +}); + test('CHANGELOG.md retains v4.2.0 entry (history is not rewritten)', () => { const cl = read('CHANGELOG.md'); assert.match(cl, /## v4\.2\.0\b/, 'CHANGELOG.md must keep the historical "## v4.2.0" entry'); diff --git a/plugins/voyage/tests/scripts/annotate.test.mjs b/plugins/voyage/tests/scripts/annotate.test.mjs new file mode 100644 index 0000000..4bd2a9f --- /dev/null +++ b/plugins/voyage/tests/scripts/annotate.test.mjs @@ -0,0 +1,167 @@ +// 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. +// +// What we pin: +// • Output is a complete, self-contained HTML document. +// • Zero external network references in the static HTML. +// • The embedded inline "\n---\n\n# Foo\n'; + const html = buildHtml('/abs/path/brief.md', md); + // The raw \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