diff --git a/apps/skills/plannotator-setup-goal/SKILL.md b/apps/skills/plannotator-setup-goal/SKILL.md index 303c3b775..467e1805c 100644 --- a/apps/skills/plannotator-setup-goal/SKILL.md +++ b/apps/skills/plannotator-setup-goal/SKILL.md @@ -139,12 +139,27 @@ If the user edits or removes facts in the UI, apply that result directly. If the Explore the codebase. Discover and validate implementation paths toward each accepted fact. Treat facts with `automatedVerification: true` as requiring concrete automated checks unless you document a blocker. Trace through code, identify files and systems involved, surface risks and unknowns. Refine until you have a confident order of operations. -Write `goals//plan.md`: +Write `goals//plan.md` using Plannotator's rich directive syntax for structured sections: -- Solution approach (brief) -- Ordered steps with the files/systems each touches -- Verification for each step (concrete commands or checks) -- Risks or open questions worth flagging +**Available directives** (rendered as themed visual components in the reviewer): + +- `:::stats` — key numbers. Each line: `value | label` or `value | label | color` (success/destructive/warning/primary). +- `:::milestone [status]` — timeline phase. Status: `done`, `warn`, `blocked`, or omitted. `###` heading = title. Backtick lines = tag chips. Consecutive milestones render as a connected timeline. +- `:::risks` — risk grid. Each line: `severity | name | mitigation` (HIGH/MED/LOW). +- `:::cols` — multi-column layout. Split with `:::col` markers. Use `:::cols N` for explicit count. +- `:::diagram [caption]` — wraps mermaid/graphviz code fences or inline SVG in a captioned panel. +- `:::note`, `:::tip`, `:::warning`, `:::caution`, `:::important` — callouts. + +**Plan structure:** + +- `:::stats` strip with scope numbers (files touched, facts covered, steps) +- `:::milestone` entries for ordered implementation steps, each listing files/systems touched +- Verification for each step (concrete commands or checks, inside the milestone body or as a task list) +- `:::risks` grid for anything worth flagging +- `:::cols` for side-by-side comparisons (before/after, option A vs B) when useful +- `:::diagram` for architecture or data flow when 3+ components interact + +Keep it scannable. One idea per section. Skip directives that don't serve the content — plain prose, headings, code fences, and task lists are always fine. Gate the plan with Plannotator: diff --git a/apps/skills/plannotator-visual-explainer/SKILL.md b/apps/skills/plannotator-visual-explainer/SKILL.md index 7a887d05d..7109c7f8c 100644 --- a/apps/skills/plannotator-visual-explainer/SKILL.md +++ b/apps/skills/plannotator-visual-explainer/SKILL.md @@ -2,62 +2,124 @@ name: plannotator-visual-explainer disable-model-invocation: true description: > - Generate self-contained HTML visualizations with Plannotator theming. Use for implementation - plans, PR explainers, architecture diagrams, data tables, slide decks, and any visual - explanation of technical concepts. Plans and PR explainers follow Plannotator's prescriptive - approach; all other visual content delegates to nicobailon/visual-explainer. + Generate visual plans, PR explainers, and technical documents with Plannotator. + Use rich directives (:::stats, :::milestone, :::risks, :::cols, :::diagram) for + structured layouts in markdown. Fall back to HTML only for PR diffs (Pierre CDN), + UI mockups, or complex SVG positioning that directives don't cover. --- # Plannotator Visual Explainer -Three paths depending on content type. Each has its own references and structure. - ## Route by content type -**Implementation plan, design doc, or proposal** → Follow the [Plan path](#plan-path). Read `references/design-system.md` and `references/svg-patterns.md`. Prescriptive structure. +**Implementation plan, design doc, or proposal** → Follow the [Plan path](#plan-path). Uses directives for structure. No HTML references needed. + +**PR explainer, diff review, or code change walkthrough** → Follow the [PR path](#pr-path). Read `references/pr-components.md` for Pierre diff rendering. Directives for summary sections; HTML for diff hunks. -**PR explainer, diff review, or code change walkthrough** → Follow the [PR path](#pr-path). Read `references/design-system.md` and `references/pr-components.md`. Prescriptive structure. +**Everything else** (data tables, slide decks, project recaps, general visual explanations) → Follow the [Visual explainer path](#visual-explainer-path). Delegates to nicobailon/visual-explainer with Plannotator theme tokens. -**Everything else** (architecture diagrams, data tables, slide decks, project recaps, general visual explanations) → Follow the [Visual explainer path](#visual-explainer-path). Delegates to nicobailon/visual-explainer with Plannotator theme tokens. +**HTML escape hatch** → Only when directives don't cover the layout (custom SVG positioning, UI mockups, bespoke grids). Read `references/design-system.md` + `references/svg-patterns.md` at that point, not before. ## Delivery Always deliver via Plannotator's annotation UI. Do NOT use `open` or `xdg-open`. -**Plans/proposals** (user should approve/deny): +**Markdown with directives** (default): ```bash -plannotator annotate --render-html --gate +plannotator annotate ``` -**Everything else** (informational): +**Plans/proposals with gate** (user should approve/deny): +```bash +plannotator annotate --gate +``` + +**HTML escape hatch** (only when directives don't suffice): ```bash plannotator annotate --render-html ``` --- -## Plan path +## Directive path -For implementation plans, design docs, feature specs, migration guides, and proposals. +Rich directives render structured visual components directly in markdown — no HTML, no CSS tokens, no iframe. They inherit the active Plannotator theme automatically. -**Before generating, read:** -1. `references/design-system.md` — Plannotator theme tokens, typography, component patterns -2. `references/svg-patterns.md` — inline SVG building blocks for architecture diagrams, flowcharts, data flow +Available directives: + +**`:::stats`** — Summary strip of stat cards +``` +:::stats +4 | MRs +2 | Approved | success +2 | Blocked | destructive +::: +``` +Each line: `value | label` or `value | label | color`. Colors: `success`, `destructive`, `warning`, `primary`. + +**`:::milestone [status]`** — Timeline entry +``` +:::milestone done +### Deploy to staging +All checks green. +`backend-api` +::: +``` +Status on opening line: `done` (green), `warn` (yellow), `blocked` (red), or omitted (default). `###` heading = title. Backtick-only lines = tag chips. Consecutive milestones render as a connected vertical timeline. + +**`:::risks`** — Risk grid with severity badges +``` +:::risks +HIGH | Merge conflicts | Rebase before merging +MED | Stale pipeline | Retrigger CI +LOW | Reviewer OOO | Not blocking +::: +``` +Each line: `severity | name | mitigation`. HIGH/MED/LOW map to badge colors. + +**`:::cols`** — Multi-column layout +``` +:::cols +:::col +Left column content. +:::col +Right column content. +::: +``` +N columns auto-detected from `:::col` count. Use `:::cols 3` for explicit count. Collapses to single column on narrow viewports. + +**`:::diagram [caption]`** — Diagram panel +```` +:::diagram Request flow through the gateway +```mermaid +graph LR + A[Browser] --> B[Gateway] + B --> C[API] +``` +::: +```` +Wraps code fences (mermaid, graphviz) or inline `` in a bordered, captioned panel. Inline SVG inherits theme CSS variables — use `var(--primary)` etc. instead of hardcoded colors. + +--- + +## Plan path + +For implementation plans, design docs, feature specs, migration guides, and proposals. Uses directives — no HTML references to read. **Document structure (in order, pick what fits):** -1. **Header** — eyebrow label (mono, uppercase), title (serif, large), prompt box (the original brief) -2. **Summary strip** — 3-5 stat cards showing key numbers at a glance (components, endpoints, tables, etc.) -3. **Milestones / timeline** — vertical timeline showing phases without time estimates. Phases show sequence and dependencies, not duration. -4. **Architecture / data flow** — inline SVG diagram. Use for 3+ interacting components. Highlighted boxes for new components, dashed arrows for async paths. -5. **Mockups** — build UI mockups in HTML/CSS directly, not as descriptions -6. **Key code** — dark-theme code blocks with syntax highlighting. Only architecturally significant interfaces/schemas — not every function. -7. **Risks & mitigations** — table with severity badges (HIGH/MED/LOW) -8. **Open questions** — callout cards with decision owner ("Decide with: backend team") +1. **Header** — `#` title, then a `>` blockquote with the original brief +2. **Summary strip** — `:::stats` with 3-5 key numbers at a glance +3. **Milestones / timeline** — consecutive `:::milestone` blocks showing phases. No time estimates — phases show sequence and dependencies, not duration. +4. **Architecture / data flow** — `:::diagram` wrapping mermaid or inline SVG. Use for 3+ interacting components. +5. **Side-by-side comparison** — `:::cols` for before/after, option A vs B, or any two-pane layout +6. **Key code** — fenced code blocks. Only architecturally significant interfaces/schemas. +7. **Risks & mitigations** — `:::risks` with severity and mitigation per row +8. **Open questions** — `:::note` or `:::warning` callouts with decision owner Not every plan needs every section. Skip what doesn't serve the content. Never include time estimates, boilerplate sections, or exhaustive file lists. -**Adapt to the task:** Backend → lead with data flow. Frontend → lead with mockups. Refactoring → lead with before/after diagrams. Infrastructure → lead with architecture. +**Adapt to the task:** Backend → lead with data flow diagram. Frontend → lead with columns (mockup vs spec). Refactoring → lead with before/after columns. Infrastructure → lead with architecture diagram. **Quality bar:** The plan answers "what, why, and how" within 30 seconds of reading. Whitespace is a feature — one idea per viewport. @@ -67,22 +129,17 @@ Not every plan needs every section. Skip what doesn't serve the content. Never i For PR walkthroughs, diff reviews, code change explainers, and reviewer guides. -**Before generating, read:** -1. `references/design-system.md` — Plannotator theme tokens, typography, component patterns -2. `references/pr-components.md` — diff rendering, review comment bubbles, risk chips, file cards, before/after panels +**Before generating, read:** `references/pr-components.md` — Pierre diff CDN, file cards, before/after panels. Use directives for summary sections (stats, risks, cols); HTML only for diff hunks that need Pierre syntax highlighting. **Document structure (in order, pick what fits):** -1. **Header** — PR title, meta strip (file count, +/- lines, branch, author) -2. **TL;DR** — bordered card with primary accent left border. 2-3 sentences. Readers who see nothing else should get the gist. -3. **Why** — motivation and before/after comparison (two-column grid) -4. **File tour** — collapsible cards per file. Each has: file path + badge (NEW/MOD/DEL) + line stats, a "why" paragraph, and important diff hunks. High-risk files expanded, safe files collapsed. -5. **Risk map** — visual chips showing which files need careful review vs. which are mechanical. Three tiers: attention (destructive), medium (warning), safe (success). -6. **Where to focus** — numbered callout cards. Each names a file/function and describes the concern. -7. **Test plan** — checkbox-style verification checklist -8. **Rollout** (if applicable) — phased deployment with feature flags - -Use Pierre diffs via CDN for syntax-highlighted inline diffs — see `references/pr-components.md` for the pattern. +1. **Header** — PR title, `:::stats` meta strip (file count, +/- lines) +2. **TL;DR** — `:::note` callout. 2-3 sentences. +3. **Why** — `:::cols` with before/after comparison +4. **File tour** — HTML file cards with Pierre diffs (this is the HTML escape hatch — directives can't render syntax-highlighted diffs) +5. **Risk map** — `:::risks` showing which files need careful review +6. **Where to focus** — numbered `:::warning` callouts per concern +7. **Test plan** — checkbox task list (`- [ ]`) --- diff --git a/apps/skills/plannotator-visual-explainer/references/design-system.md b/apps/skills/plannotator-visual-explainer/references/design-system.md index f28d6c14e..acbf7f7ab 100644 --- a/apps/skills/plannotator-visual-explainer/references/design-system.md +++ b/apps/skills/plannotator-visual-explainer/references/design-system.md @@ -1,5 +1,7 @@ # Design System Reference +> **Prefer rich directives** for stat strips, milestones, risk grids, column layouts, and diagram panels — they render as themed components directly in markdown without any CSS. See SKILL.md → [Directive path]. Use this design system reference only for HTML escape-hatch layouts (custom SVG positioning, PR diffs, UI mockups) or when you need component patterns not covered by directives. + Plan documents use Plannotator's semantic theme tokens. This makes them theme-aware: standalone files render with bundled defaults; embedded in the Plannotator UI, they inherit whatever theme is active (30+ themes, light and dark variants). ## Standalone defaults diff --git a/apps/skills/plannotator-visual-explainer/references/svg-patterns.md b/apps/skills/plannotator-visual-explainer/references/svg-patterns.md index e0e4c0c28..cc0e928ed 100644 --- a/apps/skills/plannotator-visual-explainer/references/svg-patterns.md +++ b/apps/skills/plannotator-visual-explainer/references/svg-patterns.md @@ -2,6 +2,8 @@ Building blocks for creating diagrams in implementation plans. All SVGs are inline — no external dependencies. Compose these patterns to build architecture diagrams, data flow visualizations, flowcharts, and charts. +> **For simple diagrams**, use `:::diagram` directives — they wrap mermaid/graphviz code fences or inline `` in a captioned, themed panel without any HTML boilerplate. Use these SVG patterns only for complex architecture diagrams with precise positioning that need the full HTML escape hatch. + All colors reference Plannotator theme tokens. In SVG, use the CSS custom property values directly via `style` attributes or the corresponding CSS classes. ## Table of Contents diff --git a/apps/skills/plannotator-visual-explainer/references/theme-override.md b/apps/skills/plannotator-visual-explainer/references/theme-override.md index 2d1f2c2f2..7709110f8 100644 --- a/apps/skills/plannotator-visual-explainer/references/theme-override.md +++ b/apps/skills/plannotator-visual-explainer/references/theme-override.md @@ -1,5 +1,7 @@ # Plannotator Theme Override +> **Rich directives inherit the active theme automatically** — no `:root` token block, no variable mapping, no override needed. This document is only relevant when generating standalone HTML files via `--render-html` that need to work outside the Plannotator UI. + When visual-explainer's workflow says to pick a palette and font pairing, use these Plannotator tokens instead. Everything else — layout, structure, components, anti-slop rules — stays as visual-explainer prescribes. ## CSS Custom Properties diff --git a/bun.lock b/bun.lock index 61d9b8d4a..d552160ea 100644 --- a/bun.lock +++ b/bun.lock @@ -16,9 +16,11 @@ "sonner": "^2.0.7", }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", "bun-types": "^1.3.11", + "playwright": "^1.60.0", }, }, "apps/hook": { @@ -64,7 +66,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.19.22", + "version": "0.19.24", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,7 +88,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.19.22", + "version": "0.19.24", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "^1.1.12", @@ -201,7 +203,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.19.22", + "version": "0.19.24", "dependencies": { "@pierre/diffs": "^1.1.12", "@plannotator/ai": "workspace:*", @@ -718,6 +720,8 @@ "@plannotator/web-highlighter": ["@plannotator/web-highlighter@0.8.1", "", {}, "sha512-FlteNOwRj9iNSY/AhFMtqOnVS4FvsACvTw6IiOM1y8iDyhiU/WeZOgjURENvIY+wuUaiS9DDFmg0PrHMyuMR1Q=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -1656,7 +1660,7 @@ "fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -2212,6 +2216,10 @@ "plannotator-webview": ["plannotator-webview@workspace:apps/vscode-extension"], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -2886,6 +2894,8 @@ "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "rollup-plugin-inject/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], "rollup-plugin-inject/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], @@ -2910,10 +2920,14 @@ "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "wrangler/esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="], + "wrangler/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "wrangler/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/package.json b/package.json index d8173526e..4ed01896c 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,10 @@ "sonner": "^2.0.7" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", - "bun-types": "^1.3.11" + "bun-types": "^1.3.11", + "playwright": "^1.60.0" } } diff --git a/packages/shared/pfm-reminder.ts b/packages/shared/pfm-reminder.ts index 9edf68f65..8ce7d7a23 100644 --- a/packages/shared/pfm-reminder.ts +++ b/packages/shared/pfm-reminder.ts @@ -62,6 +62,46 @@ Or use directive containers for richer blocks: Body with **inline markdown**. ::: +Rich directives (structured visual blocks) +Beyond callouts, these directives render as purpose-built visual components: + + :::stats + 4 | MRs + 2 | Approved | success + 2 | Blocked | destructive + ::: + Each line: value | label or value | label | color (success, destructive, warning, primary). + + :::milestone done + ### Deploy to staging + All checks green. + \`backend-api\` + ::: + Status on opening line: done, warn, blocked, or omitted. ### heading = title. Backtick lines = tag chips. Consecutive milestones render as a connected timeline. + + :::risks + HIGH | Merge conflicts | Rebase before merging + MED | Stale pipeline | Retrigger CI + LOW | Reviewer OOO | Not blocking + ::: + Each line: severity | name | mitigation. HIGH/MED/LOW map to badge colors. + + :::cols + :::col + Left content. + :::col + Right content. + ::: + N columns auto-detected. Use :::cols 3 for explicit count. Collapses on narrow viewports. + + :::diagram Caption text here + \`\`\`mermaid + graph LR + A --> B + \`\`\` + ::: + Wraps code fences (mermaid, graphviz) or inline in a captioned panel. SVG inherits theme CSS vars. + Tables Pipe tables are interactive — the reviewer can copy as Markdown/CSV from a hover toolbar, or expand to a sortable, filterable popout. Reach for them for comparisons, files-to-change lists, or risk summaries. diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx index f0c7bbb29..432d234f8 100644 --- a/packages/ui/components/BlockRenderer.tsx +++ b/packages/ui/components/BlockRenderer.tsx @@ -7,6 +7,11 @@ import { HtmlBlock } from "./blocks/HtmlBlock"; import { Callout } from "./blocks/Callout"; import { AlertBlock } from "./blocks/AlertBlock"; import { TableBlock } from "./blocks/TableBlock"; +import { StatStrip } from "./blocks/StatStrip"; +import { MilestoneTimeline } from "./blocks/MilestoneTimeline"; +import { RiskGrid } from "./blocks/RiskGrid"; +import { Columns } from "./blocks/Columns"; +import { DiagramPanel } from "./blocks/DiagramPanel"; export const BlockRenderer: React.FC<{ block: Block; @@ -139,22 +144,76 @@ export const BlockRenderer: React.FC<{ case 'directive': { const kind = block.directiveKind || 'note'; - return ( - - ); + switch (kind) { + case 'stats': + return ; + case 'milestone': + return ( + + ); + case 'risks': + return ( + + ); + case 'cols': + return ( + + ); + case 'diagram': + return ( + + ); + default: + return ( + + ); + } } default: diff --git a/packages/ui/components/blocks/Columns.tsx b/packages/ui/components/blocks/Columns.tsx new file mode 100644 index 000000000..befd6a371 --- /dev/null +++ b/packages/ui/components/blocks/Columns.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { renderProseBody } from './proseBody'; + +interface ColumnsProps { + blockId: string; + body: string; + columnCount?: number; + imageBaseDir?: string; + onImageClick?: (src: string, alt: string) => void; + onOpenLinkedDoc?: (path: string) => void; + onOpenCodeFile?: (path: string) => void; + githubRepo?: string; + onNavigateAnchor?: (hash: string) => void; +} + +/** + * Split the :::cols body on :::col markers. + */ +function parseColumns(body: string): string[] { + const parts = body.split(/^:::col\s*$/m); + const columns = parts + .slice(1) + .map(col => col.replace(/^:::\s*$/m, '').trim()) + .filter(Boolean); + + if (columns.length === 0 && body.trim()) { + return [body.trim()]; + } + return columns; +} + +export const Columns: React.FC = ({ + blockId, + body, + columnCount, + imageBaseDir, + onImageClick, + onOpenLinkedDoc, + onOpenCodeFile, + githubRepo, + onNavigateAnchor, +}) => { + const columns = parseColumns(body); + const count = columnCount || columns.length; + + const proseProps = { + imageBaseDir, + onImageClick, + onOpenLinkedDoc, + onOpenCodeFile, + onNavigateAnchor, + githubRepo, + }; + + return ( +
+ {/* Design-system spec: gap 24px, responsive at 720px */} +
+ {columns.map((col, i) => ( +
+ {renderProseBody({ + body: col, + paragraphClassName: 'leading-relaxed text-foreground/90', + listClassName: 'leading-relaxed text-foreground/90', + ...proseProps, + })} +
+ ))} +
+
+ ); +}; diff --git a/packages/ui/components/blocks/DiagramPanel.tsx b/packages/ui/components/blocks/DiagramPanel.tsx new file mode 100644 index 000000000..3e0030662 --- /dev/null +++ b/packages/ui/components/blocks/DiagramPanel.tsx @@ -0,0 +1,81 @@ +import React, { useRef, useEffect, useMemo } from 'react'; +import { Block } from '../../types'; +import { MermaidBlock } from '../MermaidBlock'; +import { GraphvizBlock } from '../GraphvizBlock'; +import { isMermaidLanguage, isGraphvizLanguage } from '../diagramLanguages'; +import { sanitizeBlockHtml } from '../../utils/sanitizeHtml'; + +interface DiagramPanelProps { + blockId: string; + body: string; + caption?: string; +} + +function detectContent(body: string): { type: 'svg' | 'diagram' | 'code'; content: string; language?: string } { + const trimmed = body.trim(); + + const fenceMatch = trimmed.match(/^```(\w*)\s*\n([\s\S]*?)(?:\n```\s*)?$/); + if (fenceMatch) { + const lang = fenceMatch[1] || undefined; + const content = fenceMatch[2] || ''; + if (isMermaidLanguage(lang) || isGraphvizLanguage(lang)) { + return { type: 'diagram', content, language: lang }; + } + return { type: 'code', content, language: lang }; + } + + if (/]/i.test(trimmed)) { + return { type: 'svg', content: trimmed }; + } + + return { type: 'code', content: trimmed }; +} + +const SvgContent: React.FC<{ svg: string }> = ({ svg }) => { + const ref = useRef(null); + const sanitized = useMemo(() => sanitizeBlockHtml(svg), [svg]); + + useEffect(() => { + if (!ref.current) return; + ref.current.innerHTML = sanitized; + }, [sanitized]); + + return
; +}; + +export const DiagramPanel: React.FC = ({ blockId, body, caption }) => { + const detected = detectContent(body); + + const syntheticBlock: Block = { + id: blockId, + type: 'code', + content: detected.content, + language: detected.language, + order: 0, + startLine: 0, + }; + + return ( +
+ {detected.type === 'svg' ? ( + + ) : detected.type === 'diagram' && isMermaidLanguage(detected.language) ? ( + + ) : detected.type === 'diagram' && isGraphvizLanguage(detected.language) ? ( + + ) : ( +
+          
+            {detected.content}
+          
+        
+ )} + {caption &&

{caption}

} +
+ ); +}; diff --git a/packages/ui/components/blocks/MilestoneTimeline.tsx b/packages/ui/components/blocks/MilestoneTimeline.tsx new file mode 100644 index 000000000..e135d694a --- /dev/null +++ b/packages/ui/components/blocks/MilestoneTimeline.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { InlineMarkdown } from '../InlineMarkdown'; + +interface MilestoneTimelineProps { + blockId: string; + body: string; + status?: string; + imageBaseDir?: string; + onImageClick?: (src: string, alt: string) => void; + onOpenLinkedDoc?: (path: string) => void; + onOpenCodeFile?: (path: string) => void; + githubRepo?: string; + onNavigateAnchor?: (hash: string) => void; +} + +type MilestoneStatus = 'done' | 'warn' | 'blocked' | 'default'; + +function parseStatus(raw?: string): MilestoneStatus { + if (!raw) return 'default'; + const s = raw.toLowerCase().trim(); + if (s === 'done' || s === 'warn' || s === 'blocked') return s; + return 'default'; +} + +function parseMilestoneBody(body: string): { + title: string; + prose: string[]; + tags: string[]; +} { + const lines = body.split('\n'); + let title = ''; + const prose: string[] = []; + const tags: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!title && trimmed.startsWith('### ')) { + title = trimmed.replace(/^###\s*/, ''); + } else if (/^`[^`]+`$/.test(trimmed)) { + tags.push(trimmed.slice(1, -1)); + } else if (trimmed) { + prose.push(trimmed); + } + } + + return { title, prose, tags }; +} + +export const MilestoneTimeline: React.FC = ({ + blockId, body, status: rawStatus, + imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, onNavigateAnchor, +}) => { + const status = parseStatus(rawStatus); + const { title, prose, tags } = parseMilestoneBody(body); + const dotMod = status === 'default' ? '' : ` milestone-dot--${status}`; + const lineMod = status === 'default' ? '' : ` milestone-line--${status}`; + const inlineProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, onNavigateAnchor }; + + return ( +
+
+
+
+
+
+ {title && ( +
+ +
+ )} + {prose.length > 0 && ( +
+ +
+ )} + {tags.length > 0 && ( +
+ {tags.map((tag, i) => ( + {tag} + ))} +
+ )} +
+
+ ); +}; diff --git a/packages/ui/components/blocks/RiskGrid.tsx b/packages/ui/components/blocks/RiskGrid.tsx new file mode 100644 index 000000000..bfaab167e --- /dev/null +++ b/packages/ui/components/blocks/RiskGrid.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { InlineMarkdown } from '../InlineMarkdown'; + +interface RiskGridProps { + blockId: string; + body: string; + imageBaseDir?: string; + onImageClick?: (src: string, alt: string) => void; + onOpenLinkedDoc?: (path: string) => void; + onOpenCodeFile?: (path: string) => void; + githubRepo?: string; + onNavigateAnchor?: (hash: string) => void; +} + +interface RiskEntry { + severity: string; + name: string; + mitigation: string; +} + +const BADGE_CLASS: Record = { + high: 'risk-badge--high', + med: 'risk-badge--med', + low: 'risk-badge--low', +}; + +function parseRisks(body: string): RiskEntry[] { + return body + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const parts = line.split('|').map(s => s.trim()); + return { + severity: (parts[0] || '').toUpperCase(), + name: parts[1] || '', + mitigation: parts[2] || '', + }; + }); +} + +export const RiskGrid: React.FC = ({ + blockId, body, + imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, onNavigateAnchor, +}) => { + const risks = parseRisks(body); + if (risks.length === 0) return null; + + const inlineProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, onNavigateAnchor }; + + return ( +
+ {risks.map((risk, i) => ( +
+ + {risk.severity} + + + + + + + +
+ ))} +
+ ); +}; diff --git a/packages/ui/components/blocks/StatStrip.tsx b/packages/ui/components/blocks/StatStrip.tsx new file mode 100644 index 000000000..f0ba4a733 --- /dev/null +++ b/packages/ui/components/blocks/StatStrip.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +interface StatStripProps { + blockId: string; + body: string; +} + +function parseStats(body: string): { value: string; label: string; color: string }[] { + return body + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const parts = line.split('|').map(s => s.trim()); + return { + value: parts[0] || '', + label: parts[1] || '', + color: parts[2] || 'primary', + }; + }); +} + +const COLOR_VALUE: Record = { + success: 'text-success', + destructive: 'text-destructive', + warning: 'text-warning', + primary: 'text-primary', +}; + +export const StatStrip: React.FC = ({ blockId, body }) => { + const stats = parseStats(body); + if (stats.length === 0) return null; + + return ( +
+ {stats.map((stat, i) => ( +
+ + {stat.value} + + {stat.label} +
+ ))} +
+ ); +}; diff --git a/packages/ui/components/blocks/proseBody.tsx b/packages/ui/components/blocks/proseBody.tsx index 04fe3e2fc..822f4a169 100644 --- a/packages/ui/components/blocks/proseBody.tsx +++ b/packages/ui/components/blocks/proseBody.tsx @@ -82,6 +82,27 @@ export function renderProseBody(args: { flushList(); continue; } + // Headings (## through ######) + const headingMatch = line.match(/^(#{2,6})\s+(.+)$/); + if (headingMatch) { + flushPara(); + flushList(); + const level = headingMatch[1].length as 2 | 3 | 4 | 5 | 6; + const Tag = `h${level}` as React.ElementType; + const styles: Record = { + 2: 'text-lg font-semibold mt-4 mb-2 text-foreground/90', + 3: 'text-base font-semibold mt-3 mb-1.5 text-foreground/85', + 4: 'text-sm font-semibold mt-2.5 mb-1 text-foreground/80', + 5: 'text-sm font-medium mt-2 mb-1 text-foreground/75', + 6: 'text-xs font-medium mt-2 mb-1 text-foreground/70 uppercase tracking-wide', + }; + out.push( + + {inline(headingMatch[2])} + , + ); + continue; + } const listMatch = line.match(/^\s*(\*|-|\d+\.)\s+(.*)$/); if (listMatch) { flushPara(); diff --git a/packages/ui/theme.css b/packages/ui/theme.css index 415745ade..5ca9a7f4e 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -483,6 +483,125 @@ body:has([data-popout="true"]) .os-scrollbar { .directive-danger, .directive-caution { background: color-mix(in srgb, #ef4444 10%, transparent); border-color: color-mix(in srgb, #ef4444 40%, transparent); } .directive-danger .directive-title, .directive-caution .directive-title { color: #ef4444; } +/* ------------------------------------------------------------------ */ +/* Rich directive components */ +/* Design-system spec values live here, not inline in components. */ +/* ------------------------------------------------------------------ */ + +/* StatStrip -------------------------------------------------------- */ +.directive-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; + container-type: inline-size; + margin: 32px 0; +} +@container (max-width: 480px) { + .directive-stats { grid-template-columns: repeat(2, 1fr) !important; } +} +.directive-stats .stat-card { + border: 1.5px solid var(--border); + border-radius: var(--radius); + padding: 16px 24px; + text-align: center; + background: var(--card); +} +.directive-stats .stat-value { + font-family: var(--font-display, ui-serif, Georgia, serif); + font-size: 1.8rem; + font-weight: 500; + display: block; +} +.directive-stats .stat-label { + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted-foreground); + margin-top: 4px; + display: block; +} +/* Semantic stat card border tints */ +.stat-card--primary { border-color: color-mix(in oklab, var(--primary) 40%, var(--border)); } +.stat-card--success { border-color: color-mix(in oklab, var(--success) 40%, var(--border)); } +.stat-card--warning { border-color: color-mix(in oklab, var(--warning) 40%, var(--border)); } +.stat-card--destructive { border-color: color-mix(in oklab, var(--destructive) 40%, var(--border)); } + +/* MilestoneTimeline ------------------------------------------------ */ +.directive-milestone { display: flex; gap: 18px; } +.directive-milestone + .directive-milestone { margin-top: -0.25rem; } +.directive-milestone .milestone-track { display: flex; flex-direction: column; align-items: center; padding-top: 4px; } +.directive-milestone .milestone-dot { + width: 14px; height: 14px; border-radius: 50%; + border: 3px solid var(--primary); background: var(--card); + flex-shrink: 0; +} +.directive-milestone .milestone-dot--done { background: var(--success); border-color: var(--success); } +.directive-milestone .milestone-dot--warn { background: var(--warning); border-color: var(--warning); } +.directive-milestone .milestone-dot--blocked { background: var(--destructive); border-color: var(--destructive); } +.directive-milestone .milestone-line { width: 2px; flex: 1; background: var(--border); margin-top: 4px; } +.directive-milestone .milestone-line--done { background: color-mix(in oklab, var(--success) 30%, transparent); } +.directive-milestone .milestone-line--warn { background: color-mix(in oklab, var(--warning) 30%, transparent); } +.directive-milestone .milestone-line--blocked { background: color-mix(in oklab, var(--destructive) 30%, transparent); } +.directive-milestone:last-child .milestone-line { display: none; } +.directive-milestone .milestone-body { padding-bottom: 36px; } +.directive-milestone .milestone-title { + font-family: var(--font-sans); font-size: 1rem; font-weight: 600; margin-bottom: 4px; +} +.directive-milestone .milestone-prose { + font-size: 0.88rem; line-height: 1.55; color: var(--muted-foreground); max-width: 620px; margin-bottom: 10px; +} +.directive-milestone .milestone-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.directive-milestone .milestone-tag { + font-family: var(--font-mono); font-size: 0.68rem; padding: 2px 8px; + border-radius: calc(var(--radius) - 4px); + background: var(--muted); color: var(--muted-foreground); border: 1px solid var(--border); +} + +/* RiskGrid --------------------------------------------------------- */ +.directive-risks { + border: 1.5px solid var(--border); border-radius: var(--radius); overflow: hidden; +} +.directive-risks .risk-row { + display: grid; grid-template-columns: auto 1fr 1.5fr; gap: 16px; + padding: 14px 24px; align-items: center; + border-bottom: 1px solid var(--border); +} +.directive-risks .risk-row:last-child { border-bottom: none; } +.directive-risks .risk-badge { + font-family: var(--font-mono); font-size: 0.68rem; font-weight: 600; + padding: 2px 8px; border-radius: calc(var(--radius) - 4px); + text-transform: uppercase; letter-spacing: 0.04em; +} +.risk-badge--high { background: color-mix(in oklab, var(--destructive) 15%, transparent); color: var(--destructive); } +.risk-badge--med { background: color-mix(in oklab, var(--warning) 15%, transparent); color: var(--warning); } +.risk-badge--low { background: color-mix(in oklab, var(--success) 15%, transparent); color: var(--success); } +.directive-risks .risk-name { font-weight: 500; font-size: 0.9rem; } +.directive-risks .risk-mitigation { font-size: 0.85rem; color: var(--muted-foreground); } + +/* Columns ---------------------------------------------------------- */ +.directive-cols-grid { display: grid; gap: 24px; } +@media (max-width: 720px) { + .directive-cols-grid { grid-template-columns: 1fr !important; } +} +.directive-col { + border-left: 2px solid color-mix(in oklab, var(--border) 40%, transparent); + padding-left: 1rem; +} +.directive-col:first-child { border-left: none; padding-left: 0; } + +/* DiagramPanel ----------------------------------------------------- */ +.directive-diagram { + border: 1.5px solid var(--border); border-radius: var(--radius); + padding: 24px; margin: 24px 0; background: var(--card); +} +.directive-diagram .diagram-svg svg { max-width: 100%; height: auto; } +.directive-diagram .diagram-caption { + font-family: var(--font-mono); font-size: 0.72rem; + color: var(--muted-foreground); text-align: center; margin-top: 12px; +} + /* Shared AI chat rendering */ .ai-streaming-cursor { display: inline-block; diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 26c1da740..1cd6f5243 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -66,6 +66,7 @@ export interface Block { orderedStart?: number; // For ordered list items: integer parsed from the marker (e.g. 5 for "5.") alertKind?: AlertKind; // For blockquotes starting with [!NOTE] / [!TIP] / etc. directiveKind?: string; // For directive containers (e.g. ':::note' → 'note') + directiveArgs?: string; // Optional trailing text on directive opening line (e.g. ':::milestone done' → 'done') order: number; // Sorting order startLine: number; // 1-based line number in source } diff --git a/packages/ui/utils/parser.test.ts b/packages/ui/utils/parser.test.ts index 1f0ed8af7..9ccf62126 100644 --- a/packages/ui/utils/parser.test.ts +++ b/packages/ui/utils/parser.test.ts @@ -674,6 +674,45 @@ describe("parseMarkdownToBlocks — directive containers", () => { }); }); +describe("parseMarkdownToBlocks — directive args", () => { + test("captures trailing text as directiveArgs", () => { + const md = ":::milestone done\nDeploy to staging.\n:::"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("directive"); + expect(blocks[0].directiveKind).toBe("milestone"); + expect(blocks[0].directiveArgs).toBe("done"); + expect(blocks[0].content).toBe("Deploy to staging."); + }); + + test("captures multi-word args", () => { + const md = ":::diagram Request flow through the gateway\n```mermaid\ngraph LR\n```\n:::"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks[0].directiveKind).toBe("diagram"); + expect(blocks[0].directiveArgs).toBe("Request flow through the gateway"); + }); + + test("directiveArgs is undefined when no trailing text", () => { + const md = ":::note\nbody\n:::"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks[0].directiveArgs).toBeUndefined(); + }); + + test("cols with numeric arg", () => { + const md = ":::cols 3\ncol content\n:::"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks[0].directiveKind).toBe("cols"); + expect(blocks[0].directiveArgs).toBe("3"); + }); + + test("milestone statuses: done, warn, blocked", () => { + for (const status of ["done", "warn", "blocked"]) { + const blocks = parseMarkdownToBlocks(`:::milestone ${status}\nbody\n:::`); + expect(blocks[0].directiveArgs).toBe(status); + } + }); +}); + describe("parseMarkdownToBlocks — raw HTML blocks", () => { test("
/ parsed as a single html block", () => { const md = "
\nTitle\nBody text\n
"; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index 2123aea25..741d62935 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -323,11 +323,12 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => { // Directive container: `:::kind` opens, `:::` closes. Inline kind is // restricted to simple identifiers (letters, digits, hyphens). Body is // accumulated verbatim and rendered with inline markdown. - const directiveOpen = trimmed.match(/^:::\s*([a-zA-Z][a-zA-Z0-9-]*)\s*$/); + const directiveOpen = trimmed.match(/^:::\s*([a-zA-Z][a-zA-Z0-9-]*)(?:\s+(.+?))?\s*$/); if (directiveOpen) { flush(); const directiveStartLine = currentLineNum; const kind = directiveOpen[1].toLowerCase(); + const args = directiveOpen[2] || undefined; const bodyLines: string[] = []; while (i + 1 < lines.length) { i++; @@ -339,6 +340,7 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => { type: 'directive', content: bodyLines.join('\n'), directiveKind: kind, + directiveArgs: args, order: currentId, startLine: directiveStartLine, }); diff --git a/plan/directive-demo.md b/plan/directive-demo.md new file mode 100644 index 000000000..ab90fa494 --- /dev/null +++ b/plan/directive-demo.md @@ -0,0 +1,169 @@ +# Implementation Plan: User Notification Service + +> **Brief:** Add a notification service that delivers real-time alerts to users via WebSocket, email digest, and in-app badge. Must integrate with the existing event bus and respect per-user quiet hours. + +--- + +:::stats +3 | New Services | primary +5 | Files Changed | warning +2 | API Endpoints +1 | DB Migration | destructive +12 | Tests Added | success +::: + +--- + +## Milestones + +:::milestone done +### Database schema & migration +Add `notifications` and `user_preferences` tables. Online DDL with zero-downtime migration. +`packages/db/migrations` +`packages/db/schema.ts` +::: + +:::milestone done +### Event bus consumer +Subscribe to domain events, transform into notification payloads, fan out to delivery channels. +`packages/server/consumers` +`packages/shared/events.ts` +::: + +:::milestone warn +### WebSocket delivery channel +Persistent connections per user session. Reconnect with exponential backoff. Heartbeat every 30s. +`packages/server/ws` +`packages/client/hooks/useNotifications.ts` +::: + +:::milestone blocked +### Email digest aggregation +Batch notifications into hourly/daily digests. Respect quiet hours. Requires email provider API key. +`packages/server/digests` +::: + +:::milestone +### In-app badge & notification center +Badge count in nav bar. Dropdown panel with mark-as-read, dismiss, and bulk actions. +`packages/client/components/NotificationBadge.tsx` +`packages/client/components/NotificationPanel.tsx` +::: + +--- + +## Architecture + +:::diagram Notification delivery pipeline +```mermaid +graph LR + A[Domain Event] --> B[Event Bus] + B --> C[Notification Consumer] + C --> D{Channel Router} + D -->|realtime| E[WebSocket] + D -->|batch| F[Email Digest] + D -->|passive| G[In-App Badge] + E --> H[Client] + F --> I[Mailgun] + G --> H +``` +::: + +--- + +## Key Interface + +```typescript +interface NotificationPayload { + userId: string; + type: 'mention' | 'review' | 'deploy' | 'alert'; + title: string; + body: string; + channel: ('ws' | 'email' | 'badge')[]; + priority: 'low' | 'normal' | 'urgent'; + metadata?: Record; +} +``` + +--- + +## Comparison + +:::cols +:::col +#### Current State +Events fire into the void. Users poll dashboards manually. No notification preferences. Email is all-or-nothing — users either get every event or unsubscribe entirely. + +- No real-time delivery +- No per-user preferences +- No quiet hours +- ~2min latency via polling +:::col +#### After This Change +Real-time WebSocket push with < 200ms latency. Per-user channel preferences and quiet hours. Smart batching for email to reduce noise. Badge count for passive awareness. + +- Sub-200ms WebSocket delivery +- Granular channel preferences +- Quiet hours respected +- Hourly/daily email digests +::: + +--- + +## Risk Assessment + +:::risks +HIGH | Database migration on large users table | Run during off-peak window with online DDL — no locks +HIGH | WebSocket connection storms at deploy | Staggered reconnect with jittered backoff (2-30s range) +MED | Email provider rate limits | Queue with exponential retry, circuit breaker at 80% quota +MED | Quiet hours timezone edge cases | Store preferences in UTC, convert at delivery time +LOW | Badge count drift on network partition | Reconcile on next successful WebSocket heartbeat +LOW | Digest email formatting across clients | Use MJML templates, test with Litmus +::: + +--- + +## Open Questions + +:::warning +**Should WebSocket connections be per-tab or per-user?** Per-tab is simpler but wastes server resources for users with 10+ tabs. Per-user with SharedWorker saves connections but adds browser compat complexity. +Decide with: infrastructure team +::: + +:::note +**Email provider choice:** Mailgun vs SendGrid vs SES. Mailgun is cheapest at our volume (< 50k/month). SendGrid has better templates. SES needs more operational overhead. +Decide with: platform lead +::: + +:::tip +**Quick win:** The in-app badge can ship independently before WebSocket and email channels are ready. It only needs the DB schema and a polling endpoint — no event bus dependency. +::: + +--- + +## Implementation Details + +
+WebSocket reconnection strategy + +Initial delay: 1s. Backoff factor: 2x with ±30% jitter. Max delay: 30s. After 5 failed attempts, show a "reconnecting..." banner in the UI. After 10, switch to polling fallback at 15s intervals. + +
+ +
+Email digest batching algorithm + +Notifications are bucketed by user + digest frequency (hourly/daily). A cron job fires every 15 minutes, collects all pending notifications for users whose bucket window has elapsed, renders the MJML template, and enqueues to the email provider. Failed sends are retried 3x with exponential backoff before being marked as dropped. + +
+ +--- + +## Verification + +- [ ] Migration runs in < 30s on staging (5M rows) +- [ ] WebSocket reconnects within 5s after network drop +- [ ] Quiet hours block delivery between configured times +- [ ] Email digest batches correctly across timezone boundaries +- [ ] Badge count matches unread notification count after page refresh +- [ ] Load test: 1000 concurrent WebSocket connections, < 200ms p95 delivery diff --git a/plan/previews/design-system-reference.html b/plan/previews/design-system-reference.html new file mode 100644 index 000000000..a4477cb27 --- /dev/null +++ b/plan/previews/design-system-reference.html @@ -0,0 +1,433 @@ + + + + + +Design System Reference — Notification Service Plan + + + + +
+ + +
+ Implementation plan · Notifications +

User Notification Service

+
+ Brief +

Add a notification service that delivers real-time alerts to users via WebSocket, email digest, and in-app badge. Must integrate with the existing event bus and respect per-user quiet hours.

+
+
+ + +
+
3New Services
+
5Files Changed
+
2API Endpoints
+
1DB Migration
+
12Tests Added
+
+ + +
+
01

Milestones

+
+
+
+
+

Database schema & migration

+

Add notifications and user_preferences tables. Online DDL with zero-downtime migration.

+
packages/db/migrationspackages/db/schema.ts
+
+
+
+
+
+

Event bus consumer

+

Subscribe to domain events, transform into notification payloads, fan out to delivery channels.

+
packages/server/consumerspackages/shared/events.ts
+
+
+
+
+
+

WebSocket delivery channel

+

Persistent connections per user session. Reconnect with exponential backoff. Heartbeat every 30s.

+
packages/server/wspackages/client/hooks/useNotifications.ts
+
+
+
+
+
+

Email digest aggregation

+

Batch notifications into hourly/daily digests. Respect quiet hours. Requires email provider API key.

+
packages/server/digests
+
+
+
+
+
+

In-app badge & notification center

+

Badge count in nav bar. Dropdown panel with mark-as-read, dismiss, and bulk actions.

+
packages/client/components/NotificationBadge.tsxpackages/client/components/NotificationPanel.tsx
+
+
+
+
+ + +
+
02

Architecture

+
+ + + + + + + Domain Event + + + Event Bus + + + Consumer + + + WebSocket + + + Email Digest + + + In-App Badge + + + Client + + + + + + + + + + + Notification delivery pipeline +
+
+ + +
+
03

Key Interface

+
+ packages/shared/types.ts +
interface NotificationPayload {
+  userId: string;
+  type: 'mention' | 'review' | 'deploy' | 'alert';
+  title: string;
+  body: string;
+  channel: ('ws' | 'email' | 'badge')[];
+  priority: 'low' | 'normal' | 'urgent';
+  metadata?: Record<string, unknown>;
+}
+
+
+ + +
+
04

Comparison

+
+
+

Current State

+

Events fire into the void. Users poll dashboards manually. No notification preferences. Email is all-or-nothing.

+
    +
  • No real-time delivery
  • +
  • No per-user preferences
  • +
  • No quiet hours
  • +
  • ~2min latency via polling
  • +
+
+
+

After This Change

+

Real-time WebSocket push with < 200ms latency. Per-user channel preferences and quiet hours. Smart batching for email.

+
    +
  • Sub-200ms WebSocket delivery
  • +
  • Granular channel preferences
  • +
  • Quiet hours respected
  • +
  • Hourly/daily email digests
  • +
+
+
+
+ + +
+
05

Risk Assessment

+
+
HIGH
Database migration on large users table
Run during off-peak window with online DDL — no locks
+
HIGH
WebSocket connection storms at deploy
Staggered reconnect with jittered backoff (2-30s range)
+
MED
Email provider rate limits
Queue with exponential retry, circuit breaker at 80% quota
+
MED
Quiet hours timezone edge cases
Store preferences in UTC, convert at delivery time
+
LOW
Badge count drift on network partition
Reconcile on next successful WebSocket heartbeat
+
LOW
Digest email formatting across clients
Use MJML templates, test with Litmus
+
+
+ + +
+
06

Open Questions

+
+

Should WebSocket connections be per-tab or per-user?

+

Per-tab is simpler but wastes server resources for users with 10+ tabs. Per-user with SharedWorker saves connections but adds browser compat complexity.

+ Decide with: infrastructure team +
+
+

Email provider choice

+

Mailgun vs SendGrid vs SES. Mailgun is cheapest at our volume (< 50k/month). SendGrid has better templates. SES needs more operational overhead.

+ Decide with: platform lead +
+
+

Quick win

+

The in-app badge can ship independently before WebSocket and email channels are ready. It only needs the DB schema and a polling endpoint — no event bus dependency.

+
+
+ + +
+
07

Implementation Details

+
+ WebSocket reconnection strategy +
+

Initial delay: 1s. Backoff factor: 2x with ±30% jitter. Max delay: 30s. After 5 failed attempts, show a "reconnecting..." banner in the UI. After 10, switch to polling fallback at 15s intervals.

+
+
+
+ Email digest batching algorithm +
+

Notifications are bucketed by user + digest frequency (hourly/daily). A cron job fires every 15 minutes, collects all pending notifications for users whose bucket window has elapsed, renders the MJML template, and enqueues to the email provider. Failed sends are retried 3x with exponential backoff before being marked as dropped.

+
+
+
+ + +
+
08

Verification

+
    +
  • Migration runs in < 30s on staging (5M rows)
  • +
  • WebSocket reconnects within 5s after network drop
  • +
  • Quiet hours block delivery between configured times
  • +
  • Email digest batches correctly across timezone boundaries
  • +
  • Badge count matches unread notification count after page refresh
  • +
  • Load test: 1000 concurrent WebSocket connections, < 200ms p95 delivery
  • +
+
+ +
+ + diff --git a/plan/previews/design-system-reference.png b/plan/previews/design-system-reference.png new file mode 100644 index 000000000..1391ee994 Binary files /dev/null and b/plan/previews/design-system-reference.png differ diff --git a/plan/previews/directive-demo-full.png b/plan/previews/directive-demo-full.png new file mode 100644 index 000000000..42cce8a0a Binary files /dev/null and b/plan/previews/directive-demo-full.png differ diff --git a/plan/rich-directives.md b/plan/rich-directives.md new file mode 100644 index 000000000..c0f359477 --- /dev/null +++ b/plan/rich-directives.md @@ -0,0 +1,212 @@ +# Rich Directive Components for Plannotator + +## Brief + +Agents generating plans, explainers, and PR walkthroughs through Plannotator currently write 20-30KB of raw HTML with copy-pasted CSS tokens because the markdown renderer lacks structured visual components. The directive system (`:::kind ... :::`) already parses arbitrary kinds but only renders a generic `` for all of them. This proposal extends the directive system with purpose-built React components — stat strips, milestone timelines, risk grids, multi-column layouts, and diagram panels — so agents write concise markdown instead of verbose, inconsistent HTML. + +## Problem + +The `plannotator-visual-explainer` skill instructs agents to generate self-contained HTML files with: +- ~100 lines of `:root` CSS token declarations (copied verbatim every time) +- ~200 lines of component CSS (stat cards, milestones, risk grids, badges, etc.) +- Custom SVG diagrams with inline styles referencing those tokens +- Delivery via `--render-html` iframe path (loses native annotation precision) + +This causes: +1. **Token drift** — agents regenerate CSS from memory, introducing inconsistencies between documents +2. **Bloated output** — 30KB HTML for what could be 3KB of structured markdown +3. **Annotation degradation** — HTML viewer uses iframe + bridge; markdown viewer has native text selection +4. **Theme disconnection** — standalone HTML hardcodes light-theme tokens; only inherits runtime theme through iframe injection + +## Solution: extend `:::directive` kinds + +The parser already handles `:::kind ... :::` → `Block { type: 'directive', directiveKind: kind }`. Currently `BlockRenderer` routes all directive kinds to a generic ``. The change: add new `directiveKind` → component mappings for structured visual blocks. + +### New directive kinds + +#### `:::stats` — Summary strip + +``` +:::stats +4 | MRs +2 | Approved | success +2 | Blocked | destructive +5 | Jira Subtasks +::: +``` + +Each line: `value | label` or `value | label | color`. Color is a semantic token name (`success`, `destructive`, `warning`, `primary`). Renders as a responsive grid of stat cards matching the design-system.md `summary-strip` pattern. + +#### `:::milestone [status]` — Timeline entry + +``` +:::milestone done +### Deploy to staging +All checks green. +`backend-api` +::: + +:::milestone blocked +### Rebase feature branch +Conflicts with main. +`data-service` +::: +``` + +Status: `done` (green dot), `warn` (yellow), `blocked` (red), or omitted (default purple outline). Status lives on the opening line — same pattern as `:::note`. First `###` heading becomes the title. Body is prose (rendered with `InlineMarkdown`). Backtick-only lines become tag chips. Consecutive `:::milestone` blocks render as a connected vertical timeline. + +#### `:::risks` — Risk grid + +``` +:::risks +HIGH | Merge conflicts on main | Rebase before merging +MED | Stale pipeline | Retrigger CI +LOW | Reviewer OOO | Not a hard blocker +::: +``` + +Each line: `severity | name | mitigation`. Severity maps to badge color (`HIGH` → destructive, `MED` → warning, `LOW` → success). Renders as the design-system.md risk-grid pattern. + +#### `:::cols [N]` — Multi-column layout + +``` +:::cols +:::col +#### Left column +Content here. +::: +:::col +#### Right column +More content. +::: +::: +``` + +Nesting support: `:::cols` contains N `:::col` children (default 2, auto-detected from child count). Each column renders its body with full block rendering (headings, lists, code, inline markdown). Responsive: collapses to single column below 720px. For explicit column count: `:::cols 3`. + +#### `:::diagram [caption]` — Diagram panel + +```` +:::diagram Request flow through the API gateway +```mermaid +graph LR + A[Browser] --> B[Gateway] + B --> C[API] +``` +::: +```` + +Wraps a code fence (mermaid, graphviz) or inline `` in a bordered panel with an optional caption. Inline SVG is sanitized through the same DOMPurify path as `HtmlBlock` and inherits theme tokens from CSS variables — no hardcoded colors needed. The design-system.md `diagram-panel` pattern. + +### Inline extensions (future, not in this PR) + +- `[badge:ok]Approved` → green badge chip +- `[tag:highlight]evo-conversions` → highlighted tag chip + +These require `InlineMarkdown` tokenizer changes and are out of scope for this PR. + +## Implementation + +### Files to change + +| File | Change | LOC | +|---|---|---| +| `packages/ui/components/blocks/StatStrip.tsx` | New component | ~60 | +| `packages/ui/components/blocks/MilestoneTimeline.tsx` | New component | ~80 | +| `packages/ui/components/blocks/RiskGrid.tsx` | New component | ~70 | +| `packages/ui/components/blocks/TwoCol.tsx` | New component + nested block rendering | ~50 | +| `packages/ui/components/blocks/DiagramPanel.tsx` | New component wrapping existing mermaid/graphviz + sanitized SVG | ~50 | +| `packages/ui/components/BlockRenderer.tsx` | New `directiveKind` cases in switch | ~25 | +| `packages/ui/theme.css` | Directive-specific CSS classes | ~80 | +| `packages/shared/pfm-reminder.ts` | Document new directive kinds | ~30 | +| `packages/ui/utils/parser.ts` | No changes needed | 0 | +| `packages/ui/utils/parser.test.ts` | Tests for directive body parsing | ~60 | +| `apps/skills/plannotator-visual-explainer/SKILL.md` | Add directive path + update delivery | ~30 | +| `apps/skills/plannotator-visual-explainer/references/design-system.md` | Directive syntax examples | ~60 | +| `apps/skills/plannotator-visual-explainer/references/theme-override.md` | Directive theme inheritance note | ~10 | +| `apps/skills/plannotator-visual-explainer/references/svg-patterns.md` | `:::diagram` SVG embedding note | ~10 | +| **Total** | | **~615** | + +### Parser: zero changes + +The existing directive parser already captures arbitrary `:::kind` with the regex `/^:::\s*([a-zA-Z][a-zA-Z0-9-]*)\s*$/`. Content between `:::kind` and closing `:::` is stored raw in `block.content`. Each new component parses its own body format. + +### BlockRenderer dispatch + +```tsx +case 'directive': { + const kind = block.directiveKind || 'note'; + switch (kind) { + case 'stats': + return ; + case 'milestone': + return ; + case 'risks': + return ; + case 'cols': + return ; + case 'diagram': + return ; + default: + return ; // existing behavior preserved + } +} +``` + +### Theme integration + +Components use Tailwind classes that resolve through the existing CSS variable bridge: + +```css +/* theme.css additions */ +.directive-stats { /* grid layout */ } +.directive-milestone .dot--done { background: var(--success); } +.directive-milestone .dot--blocked { background: var(--destructive); } +.directive-risks .badge--high { color: var(--destructive); } +``` + +No `:root` token declarations in components. Theme tokens flow from the existing `theme-{name}` class on ``. + +### Annotation compatibility + +Each component renders with `data-block-id={block.id}` and `data-block-type="directive"`. The existing web-highlighter selection works on any rendered text. No annotation model changes needed. + +### Backward compatibility + +All existing `:::note`, `:::tip`, `:::warning`, `:::caution`, `:::important` directives continue routing to `` via the `default` case. No breaking changes. + +## Skill updates (in this repo) + +The `plannotator-visual-explainer` skill and its references ship with plannotator at `apps/skills/plannotator-visual-explainer/`. The installer deploys them to `~/.agents/skills/`. These updates are part of the same PR: + +| File | Change | +|---|---| +| `apps/skills/plannotator-visual-explainer/SKILL.md` | Add directive path: "For structured layouts, use rich directives instead of raw HTML." Update delivery to `plannotator annotate ` (no `--render-html`). Keep HTML path only for custom SVG architecture diagrams. | +| `apps/skills/plannotator-visual-explainer/references/design-system.md` | Add directive syntax examples alongside existing HTML/CSS component patterns. Show both formats so agents can choose. | +| `apps/skills/plannotator-visual-explainer/references/theme-override.md` | Note that directives inherit theme natively — no token copy needed. The override doc becomes relevant only for the HTML escape hatch. | +| `apps/skills/plannotator-visual-explainer/references/svg-patterns.md` | Add note that SVG can be embedded inside `:::diagram` directives, inheriting theme tokens from CSS vars. | + +## Open questions + +:::callout +### Should `:::diagram` support inline SVG directly? +Yes — included in this PR. Inline `` inside `:::diagram` is sanitized via DOMPurify (same path as `HtmlBlock`) and inherits theme tokens from CSS variables. This lets agents write SVG architecture diagrams with `var(--primary)` etc. without the `--render-html` iframe path. +::: + +## Prior art + +- **GitHub Alerts** (`> [!NOTE]`) — Plannotator already supports these as `alertKind` blocks +- **Docusaurus admonitions** (`:::tip`) — same directive syntax, richer component set +- **Starlight** (Astro) — `:::note[Custom Title]` with title in brackets +- **Obsidian callouts** (`> [!info]`) — metadata-driven block styling +- **remark-directive** — the unified plugin that standardized `:::` syntax + +The `:::kind` syntax is widely adopted. Extending it with structured kinds follows the same pattern as all of the above. + +## Contribution process + +1. **Open a GitHub issue** on `backnotprop/plannotator` describing the feature with a link to the PR +2. **Fork → branch → implement → PR** per CONTRIBUTING.md (dual MIT/Apache-2.0 license) +3. PR targets `main`, includes the components + theme CSS + pfm-reminder update + tests +4. Maintain the fork regardless — the implementation is immediately useful on our machines before upstream merge +5. After upstream merge (if accepted): update operator-owned skills in dotfiles diff --git a/plan/screenshot.ts b/plan/screenshot.ts new file mode 100644 index 000000000..b77fa3cd8 --- /dev/null +++ b/plan/screenshot.ts @@ -0,0 +1,137 @@ +/** + * Full-page screenshots: directive demo (via dev portal) + HTML reference (standalone). + * Usage: bun run plan/screenshot.ts + */ + +import { chromium } from 'playwright'; +import { readFileSync, writeFileSync } from 'fs'; +import { spawn } from 'child_process'; +import { join } from 'path'; + +const ROOT = join(import.meta.dir, '..'); +const DEMO_PATH = join(ROOT, 'packages/editor/demoPlan.ts'); +const DEMO_MD = join(ROOT, 'plan/directive-demo.md'); +const HTML_REF = join(ROOT, 'plan/previews/design-system-reference.html'); +const OUT_DIR = join(ROOT, 'plan/previews'); + +async function screenshotDirectiveDemo(browser: ReturnType extends Promise ? T : never) { + // Swap demo content + const originalDemo = readFileSync(DEMO_PATH, 'utf8'); + const demoMd = readFileSync(DEMO_MD, 'utf8'); + const escaped = demoMd.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); + writeFileSync(DEMO_PATH, `export const DEMO_PLAN_CONTENT = \`${escaped}\`;\n`); + + // Start vite + const vite = spawn('bun', ['run', '--cwd', 'apps/portal', 'dev'], { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let port = 3001; + const ready = new Promise((resolve) => { + vite.stdout?.on('data', (data: Buffer) => { + const line = data.toString(); + const match = line.match(/localhost:(\d+)/); + if (match) port = parseInt(match[1]); + if (line.includes('ready in')) resolve(); + }); + }); + + try { + await ready; + console.log(`Vite ready on :${port}`); + + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + await page.goto(`http://localhost:${port}`, { waitUntil: 'networkidle' }); + await page.waitForSelector('[data-directive-kind]', { timeout: 15000 }); + // Wait for mermaid to render (it's async) + await page.waitForTimeout(3000); + + // Strip chrome — content only + await page.evaluate(() => { + const hide = (sel: string) => document.querySelectorAll(sel).forEach(el => (el as HTMLElement).style.display = 'none'); + hide('[data-app-header="true"]'); + hide('[data-sidebar-tabs="true"]'); + hide('[data-annotation-panel="true"]'); + hide('[data-sticky-header-lane="true"]'); + + // Nuke everything fixed/sticky AND any element with z-index > 0 + // that sits above the plan content + document.querySelectorAll('*').forEach(el => { + const style = getComputedStyle(el); + if (style.position === 'fixed' || style.position === 'sticky') { + (el as HTMLElement).style.setProperty('display', 'none', 'important'); + } + }); + + // Hide Demo badge and action buttons by walking the DOM + document.querySelectorAll('span, button').forEach(el => { + const t = (el.textContent || '').trim(); + if (['Demo', 'Select', 'Markup', 'Pinpoint', 'Wide', 'Focus', + 'Images', 'Comment', 'Copy', 'Copy plan', 'Global comment', + 'how does this work?'].includes(t)) { + (el as HTMLElement).style.setProperty('display', 'none', 'important'); + } + }); + // Also hide SVG-only icon buttons (gear icon, etc.) + document.querySelectorAll('button').forEach(btn => { + if (btn.querySelector('svg') && !btn.textContent?.trim()) { + (btn as HTMLElement).style.setProperty('display', 'none', 'important'); + } + }); + + // Expand scroll containers + document.querySelectorAll('[data-overlayscrollbars], [data-overlayscrollbars-viewport]').forEach(el => { + (el as HTMLElement).style.overflow = 'visible'; + (el as HTMLElement).style.maxHeight = 'none'; + (el as HTMLElement).style.height = 'auto'; + }); + document.querySelectorAll('.h-screen, .h-full, .overflow-hidden, .overflow-y-auto, .overflow-auto').forEach(el => { + (el as HTMLElement).style.overflow = 'visible'; + (el as HTMLElement).style.maxHeight = 'none'; + (el as HTMLElement).style.height = 'auto'; + }); + document.body.style.overflow = 'visible'; + document.body.style.height = 'auto'; + document.documentElement.style.overflow = 'visible'; + document.documentElement.style.height = 'auto'; + }); + await page.waitForTimeout(500); + + await page.screenshot({ + path: join(OUT_DIR, 'directive-demo-full.png'), + fullPage: true, + }); + const sz = readFileSync(join(OUT_DIR, 'directive-demo-full.png')).length; + console.log(`directive-demo-full.png (${(sz/1024).toFixed(0)}KB)`); + + await page.close(); + } finally { + writeFileSync(DEMO_PATH, originalDemo); + vite.kill('SIGTERM'); + } +} + +async function screenshotHtmlReference(browser: ReturnType extends Promise ? T : never) { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + await page.goto(`file://${HTML_REF}`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(500); + + await page.screenshot({ + path: join(OUT_DIR, 'design-system-reference.png'), + fullPage: true, + }); + const sz = readFileSync(join(OUT_DIR, 'design-system-reference.png')).length; + console.log(`design-system-reference.png (${(sz/1024).toFixed(0)}KB)`); + await page.close(); +} + +const browser = await chromium.launch(); +try { + console.log('--- HTML reference ---'); + await screenshotHtmlReference(browser); + console.log('--- Directive demo ---'); + await screenshotDirectiveDemo(browser); +} finally { + await browser.close(); + console.log('Done.'); +}