Skip to content

release: v0.1.8 — F11+F12+F13+F14+hotfix#42

Merged
explosivebit merged 21 commits into
mainfrom
release/v0.1.8
May 6, 2026
Merged

release: v0.1.8 — F11+F12+F13+F14+hotfix#42
explosivebit merged 21 commits into
mainfrom
release/v0.1.8

Conversation

@explosivebit
Copy link
Copy Markdown
Contributor

Summary

Release v0.1.8 rolling up the overnight + audit cycle:

  • F11 (PRD-006 + RFC-005) — body preview + decision impact drill-down across 5 graph views.
  • F12 (PRD-007 + RFC-006) — stale + blind-spot push notifications via Notification API.
  • F13 (tactical) — Copy as markdown button in ArtifactPanel.
  • Hotfix — break effect_update_depth_exceeded in HomePage health-poll diff (prevHealthSnapshot demoted from $state to plain let).
  • F14 (post-audit cleanup) — 7 HIGH + 7 MEDIUM/LOW findings closed (5-expert audit on F11/F12/F13). Includes lazy-load of marked + DOMPurify (-21 KB gzip first-paint), Matrix impact-mode CSS fix, iframe Notification guards, branded SafeHtml type, clipboard fallback, reduced-motion broadening, DOMPurify noopener hook (CWE-1022).

Forgeplan artifacts

Artifact Status R_eff
PRD-006 active > 0 (EVID-014)
RFC-005 active > 0 (EVID-014)
PRD-007 active > 0 (EVID-015)
RFC-006 active > 0 (EVID-015)
EVID-014 active F11 acceptance — CL3 / test
EVID-015 active F12 acceptance — CL3 / test

Verify

  • svelte-check 0/0/436.
  • vitest 70/70 (40 baseline + 13 F11 + 9 F12 + 6 F13 + 2 F14).
  • npm run smoke PASS.
  • Playwright DOM check: 0 console errors.
  • npm audit (--omit=dev): 0 vulnerabilities.

Test plan

  • CI smoke matrix (3-OS × Node 22) green.
  • After merge: tag v0.1.8 annotated on main, Draft GitHub Release → publish fires release.ymlnpm publish --provenance.
  • Back-merge release/v0.1.8develop so version bump and any release-only fixes propagate.

Backlog (not in this release)

Deferred from F14 audit:

  • UX-H2 mermaid latent (no current artifact uses mermaid; ADR for diagram renderer when needed).
  • TS cosmetic refactors (getOrInit helper sweep, Omit patterns).
  • SEC-L2 secret-pattern redaction in body excerpt (needs thoughtful regex set — separate artifact).
  • SEC-L3 kindFilter/statusFilter validation against known list.
  • FE-L2..L4 rare edge cases.

Strategic deferrals from earlier sessions: time-travel (forgeplan journal slider), semantic search (Lance index), "what changed" diff view, annotation layer.

🤖 Generated with Claude Code

## Summary

Back-merge of `release/v0.1.7` (now merged into `main` and tagged
`v0.1.7`) into `develop` so the version bump (0.1.6 → 0.1.7) and any
release-only commits don't get lost on the next feature branch cut from
`develop`.

Per [`guides/GIT-FLOW-GUIDE.ru.md`](guides/GIT-FLOW-GUIDE.ru.md) §6.10.

## What's in this back-merge

- `b323f75` — `chore(release): bump version to 0.1.7`
- `dbe0b75` — release PR #32 merge commit (already on main + tagged
v0.1.7 + npm published).

No new code changes — release was already QA'd through PR #32 (smoke
matrix green, npm publish success).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds the Sankey graph view alongside Force/Tree/Radial/Matrix/Lanes:

- `lib/sankey-layout.ts` — `assignSankeyColumns` (BFS-based depth from
  any hierarchy root; cycles fall back to column 0) and
  `buildSankeyPayload` (d3-sankey-shaped {nodes, links}; pre-set
  `column` + filtered to forward-only edges).
- `lib/sankey-layout.test.ts` — 6 unit tests covering linear chains,
  multiple parents (min depth wins), non-hierarchy edge filtering,
  cycle handling, payload shape.
- `ui/SankeyView.svelte` — d3-sankey driven layout with horizontal
  links (`sankeyLinkHorizontal`), zoom/pan, fitToView at 0.45 floor,
  hover-highlight + BFS distance fade reusing the shared lib,
  selection ring on the node bar, status-dot + kind colour.
- `lib/relation.ts` — new `relationStroke` per relation kind for
  Sankey link colours (informs / refines / belongs-to / contains /
  supersedes / risks).
- DependencyGraph router: case `view === 'sankey'`.
- `shared/config/ui-prefs.ts` GraphView extended with `sankey`.

Adds dependencies: `d3-sankey ^0.12.3`, `@types/d3-sankey ^0.12.4`
(installed with --ignore-scripts).

Refs: PRD-005
Adds the Sunburst graph view as the 7th mode:

- `lib/sunburst-layout.ts` — `buildSunburstTree` (synthetic workspace
  root + first-parent attach; cycles broken by visited set; ignores
  non-hierarchy edges) and `computeSunburstPartition` wrapping
  `d3-hierarchy.partition` over the tree.
- `lib/sunburst-layout.test.ts` — 7 unit tests covering disconnected
  artifacts at root, first-parent attach, cycle handling, partition
  bounds (x in [0, 2π], y in [0, radius]), synthetic-root depth.
- `ui/SunburstView.svelte` — d3-shape `arc()` per sector, label
  rotation that flips beyond π for readability, hover/select via
  shared highlight lib, zoom/pan, kind-coloured fill.
- DependencyGraph router: case `view === 'sunburst'`.
- `shared/config/ui-prefs.ts` GraphView extended with `sunburst`.

Adds dependencies: `d3-hierarchy ^3.1.2`, `d3-shape ^3.2.0`,
`d3-scale ^4.0.2` and matching `@types/*`.

Refs: PRD-005
…elect

Replaces the BFS-depth columns / first-parent attach in the F7-T2/T3
draft with a type-tier-driven layout that uses the same TYPE_ORDER
hierarchy as the cluster lib (epic → prd → spec → rfc → adr →
evidence → ...).

New shared lib `lib/type-tier.ts`:
- `typeTier(kind)` returns position in TYPE_ORDER.
- `compactTierMap(kinds)` builds the per-workspace mapping with
  missing tiers collapsed inward (so PRD/RFC/EVID workspace gets
  {prd: 0, rfc: 1, evidence: 2} — no empty `spec` row).
- `normaliseHierarchyEdge(from, to, relation)` rotates each
  hierarchy relation to abstract→concrete form so the layout always
  flows from parent (lower tier) to child (higher tier):
    contains    → source=parent, target=child
    belongs-to  → target=parent (X belongs to Y → Y parent)
    refines     → target=parent (RFC refines PRD → PRD parent)
    informs     → target=parent (EVID informs PRD → PRD parent)
    supersedes  → source remains; intra-tier, dropped from layout.

Sankey rewrite (`lib/sankey-layout.ts` + `ui/SankeyView.svelte`):
- column = compact tier of kind; layer-gap 160 logical units between
  adjacent type-tier columns ⇒ visually distinct "slices" instead
  of one wall of bars.
- intra-tier edges (e.g. supersedes between two ADRs) dropped; all
  remaining links flow strictly left→right after normalisation.
- per-column tier headers (PRD / RFC / ADR / EVIDENCE) and dashed
  vertical dividers — the user reads taxonomy at a glance.
- nodeSort: most-connected nodes float to the top of their column,
  then alphabetical id tiebreak.
- bar/label states: idle = dim; hover/focus = full opacity, accent
  stroke + drop-shadow, label → white; selected = accent stroke,
  label → accent. Persistent visual lock on the picked artifact.

Sunburst rewrite (`lib/sunburst-layout.ts` + `ui/SunburstView.svelte`):
- tree depth = compact-tier index. Each artifact attaches to its
  highest-tier (closest from below) hierarchy parent so the rings
  of the sunburst line up with the type taxonomy:
    inner ring = most-abstract type present (PRD here)
    next ring  = RFC / ADR
    outer ring = EVIDENCE
- ancestor-chain highlight: hovering a sector lights its full
  ancestor path, dims the rest of the chart. White text on the
  active sector; ancestor labels stay readable.
- selection mirrors the focus styling but persists.

Global focus reset (`app/styles/app.css`):
- `svg g[role="button"]:focus { outline: none; }` removes the
  Chromium/Safari blue/white default focus ring on graph nodes
  across all views (keyboard nav still works via `tabindex="0"`,
  the highlight is replaced by our accent ring).

Tests: `sankey-layout.test.ts` and `sunburst-layout.test.ts` rewritten
to assert direction normalisation (informs/refines/contains all
produce parent → child links) and tier-based ring assignment.

Verify: svelte-check 0/0/423; npm test 40/40 (16 cluster + 2 regression
+ 6 keyboard-nav + 9 sankey + 7 sunburst); smoke PASS.

Refs: PRD-005
…ast hover/select) (#34)

## Summary

Adds two new graph view modes alongside Force/Tree/Radial/Matrix/Lanes,
both driven by the same TYPE_ORDER hierarchy as the cluster lib so all
hierarchical views read consistently.

### Sankey
- Columns = compact tier of artifact kind (epic / prd / spec / rfc / adr
/ evidence). Missing tiers collapse inward.
- Layer gap 160 logical units between columns ⇒ visually distinct tiers,
not a wall of bars.
- Per-column tier header text + dashed vertical divider.
- Edge direction normalised abstract → concrete via
`normaliseHierarchyEdge` (so `informs` / `refines` / `belongs-to` flow
target → source while `contains` keeps source → target).
- nodeSort: most-connected first, alphabetical tiebreak.
- Hover/focus → bar accent stroke + glow, label white; selected → accent
stroke + accent label (persistent).

### Sunburst
- Concentric rings = compact tier (inner = abstract / outer = concrete).
- Each artifact attaches to its highest-tier hierarchy parent.
- Hover lights the full ancestor chain; the rest of the chart dims to
0.18 fill-opacity.
- Selected mirrors focus styling, persistent across mouse-leave.

### Global UX
- Removed the Chromium/Safari default blue/white focus ring on `svg
g[role="button"]` across all views — replaced by our accent stroke +
drop-shadow. Keyboard nav unaffected.

### Shared lib `type-tier.ts`
- `typeTier(kind)`, `compactTierMap(kinds)`,
`normaliseHierarchyEdge(from, to, relation)` — used by both Sankey and
Sunburst lib + ready for future tier-aware views.

### Deps
Added: `d3-sankey`, `d3-hierarchy`, `d3-shape`, `d3-scale` (+
`@types/*`), installed with `--ignore-scripts`.

## Verify

- `npx svelte-check` — 0/0/423.
- `npm test` — 40/40 (cluster 16 + regression 2 + keyboard-nav 6 +
sankey 9 + sunburst 7).
- `npm run smoke` — PASS.
- DOM check on the live workspace (Helios playground / 24 artifacts):
- Sankey shows 4 tier columns (PRD / RFC / ADR / EVIDENCE) with visible
50+px gaps.
  - Sunburst inner ring = PRDs, middle = RFCs/ADRs, outer = EVIDs.
- Hover/select gives white/accent text + accent stroke; ancestor chain
lights up in Sunburst.

Refs: PRD-005

## Test plan

- [ ] CI smoke matrix (3-OS × Node 22) green.
- [ ] svelte-check clean on PR.
- [ ] vitest 40/40 pass.
- [ ] Manual: tab into a node, click — selection persists with accent
label.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
5-expert audit on develop @ 60823f1 found 3 CRITICAL, 12 HIGH, 16 MEDIUM,
18 LOW. This PR closes all of them across the 7 graph views.

CRITICAL (3):
- Selection-ring color: ForceView/TreeView/RadialView/LanesView all
  drew the .selection-ring with stroke=kindBorder(node.kind), which
  is dim grey for note/evidence kinds → selection invisible after
  mouse-leave. Now hardcoded `stroke: var(--accent)`.
- didFit one-shot: SankeyView + SunburstView never reset didFit.
  Filter clears reduced sectors/columns but zoom transform stayed,
  rendering microscopic. Ported the layout-shape signature pattern
  from RadialView; both views now refit on substantial structural
  change.

HIGH (12):
- Dropped dead `d3-scale` + `@types/d3-scale` (~50 KB never imported).
- New `lib/filter-memo.svelte.ts` exports
  `nodesContentSignature` / `edgesContentSignature`. Applied to
  Tree/Matrix/Lanes/Sankey/Sunburst (Force/Radial already inline).
  10s polls with identical payload no longer cascade-invalidate.
- Sankey tier-header contrast: `var(--fg-2)` + `font-weight: 500`
  (was 0.55 white opacity, failed WCAG AA at zoom-out).
- Sunburst label gate is zoom-aware: `> 40 / max(0.5, transform.k)`
  AND hide rings ≥ 4 when k < 0.5. Labels stop overlapping on
  outer rings.
- Per-view :focus-visible accent stroke verified in 5 views; Matrix
  uses .row-label/.col-label fill + .cell stroke variant.
- TS: new `isLinkResolved<N>(l)` type guard in lib/d3.ts; new
  `getOrInit<K,V>(map, k, factory)` in lib/map-utils.ts. Drops 2
  `as Node` casts in ForceView and 4 lazy-init bucket sites in
  cluster.svelte.ts. SankeyLink type cleanly Omit-intersected with
  SankeyPayloadLink.
- LOW security: `CSS.escape(id)` wraps the data-id selector in
  ForceView and RadialView focusNodeById — defense-in-depth even
  though /api/get validates ids.

MEDIUM (F10):
- `.node.selected .label { fill: var(--accent); }` verified in all
  4 box-based views.
- RadialView nodesSig now suffixes `|c:${collapsedSig}` so the
  cluster-collapse toggle invalidates the filtered-nodes memo
  deterministically.
- Matrix: `.cell.is-row, .cell.is-col` accent stroke when the
  selected node is the row/col index — selection bleeds through to
  the cell tint, not just the row/col header strip.
- Sankey fitToView ceiling 1 → 1.5 so narrow workspaces fit larger.
- ArrowKey nav wired into TreeView and LanesView (mirrors Force/
  Radial pattern). pickNextNode lib was already generic.
- HomePage canvas-hint caption (11px var(--font-mono)) renders the
  active view's `hint` from GRAPH_VIEWS — disambiguates the hover-
  fade semantic between BFS-distance views (5) and Sunburst's
  ancestor-chain view.

LOW:
- Sunburst sector aria-label appended with `(ring ${depth}, parent
  ${parentId})` — screen readers now hear the chain context.
- `@media (prefers-reduced-motion: reduce) { svg *, svg *::before,
  svg *::after { transition: none !important; animation: none
  !important; } }` in app.css — covers all hover-fade/zoom CSS
  transitions across the 7 views.

Verify:
- svelte-check 0 errors / 0 warnings / 425 files.
- npm test 40 / 40 pass.
- npm run smoke PASS.

Refs: PRD-005
…m 5-expert audit (#35)

## Summary

5-expert multi-agent audit on develop @ 60823f1 (TS / Frontend /
Security / Performance / UX) found 3 CRITICAL, 12 HIGH, 16 MEDIUM, 18
LOW. This PR closes them all in one pass across the 7 graph views.

## CRITICAL

- **Selection-ring colour** in 4 views (Force/Tree/Radial/Lanes) was
drawn with `stroke=kindBorder(node.kind)` — dim grey for note/evidence
kinds, so selected node was invisible after mouse-leave. Hardcoded
`stroke: var(--accent)`.
- **`didFit` one-shot** in SankeyView + SunburstView: filter clears
shrunk the layout but zoom transform stayed, rendering microscopic.
Ported the layout-shape signature reset pattern from RadialView.

## HIGH

- Dropped dead `d3-scale` + `@types/d3-scale` (~50 KB never imported).
- New `lib/filter-memo.svelte.ts` exports content-signature helpers;
applied to Tree/Matrix/Lanes/Sankey/Sunburst — 10s polls with identical
payload no longer cascade-invalidate the layout pipeline.
- Sankey tier-header contrast → `var(--fg-2)` + `font-weight: 500` (WCAG
AA).
- Sunburst label gate is zoom-aware (`> 40 / max(0.5, transform.k)` +
hide rings ≥4 when k<0.5).
- Per-view `:focus-visible` accent stroke verified in 5 views.
- TS `isLinkResolved<N>(l)` type guard + `getOrInit<K,V>` helper. Drops
`as Node` and 4 lazy-init bucket sites.
- LOW security: `CSS.escape(id)` wraps `data-id` selector in
focusNodeById.

## MEDIUM (F10)

- `.node.selected .label { fill: var(--accent); }` across 4 box-based
views.
- RadialView `nodesSig` includes `collapsedSig` so cluster-collapse
toggle invalidates deterministically.
- Matrix `.cell.is-row, .cell.is-col` accent stroke for cells in the
selected row/column.
- Sankey fitToView ceiling `1 → 1.5`.
- ArrowKey navigation wired into TreeView + LanesView.
- HomePage `.canvas-hint` caption renders the active view's `hint` —
disambiguates Sunburst's ancestor-chain fade vs the rest's BFS-distance
fade.

## LOW

- Sunburst sector aria-label appended with `(ring N, parent ID)`.
- Global `@media (prefers-reduced-motion: reduce) { svg *::* {
transition: none } }` in `app.css`.

## Verify

- `npx svelte-check` — 0 errors / 0 warnings / 425 files.
- `npm test` — 40 / 40 pass.
- `npm run smoke` — PASS.

## Test plan

- [ ] CI smoke matrix (3-OS × Node 22) green.
- [ ] svelte-check clean.
- [ ] vitest 40/40.
- [ ] Manual: select a node in Force/Tree/Radial/Lanes — accent ring +
accent label persist after mouse-leave.
- [ ] Filter clear in Sankey/Sunburst — fitToView re-fits to the new
bbox.
- [ ] Tab/Arrow keys move focus directionally in Tree/Lanes (was
Force/Radial only).

Refs: PRD-005

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Implements PRD-006 / RFC-005. Two coupled UX improvements that turn
@forgeplan/web from a "table of contents" into a "decision viewer".

Body preview:
- ArtifactPanel adds "+ Show body / − Hide body" toggle revealing the
  full markdown body of the selected artifact inline.
- New `widgets/artifact-panel/lib/markdown-renderer.ts` uses marked
  (GFM tables / code / checkboxes) + DOMPurify (allow-listed tags,
  no scripts, no javascript: URLs).
- 6 unit tests cover basic markdown, XSS strip, GFM tables, task
  checkboxes, empty-string fallback.
- bodyExpanded state persisted per session in localStorage.

Decision impact drill-down:
- "Show downstream" / "Show upstream" buttons in ArtifactPanel.
- New `widgets/dependency-graph/lib/impact-graph.ts` exports
  computeDownstream / computeUpstream pure functions, BFS bounded
  by MAX_IMPACT_DEPTH=8, direction normalised via
  type-tier.ts#normaliseHierarchyEdge so all hierarchy relations
  (informs / refines / belongs-to / contains / supersedes) flow
  abstract → concrete consistently.
- 7 unit tests cover linear chain, diamond, cycle, depth cap,
  non-hierarchy filter, upstream chain, downstream/upstream
  symmetry.
- highlight.svelte.ts extended with impactRoot / impactDirection
  $state fields; setImpactRoot() and impactedClass() exported.
  Dual-exposed via entities/graph and widgets/dependency-graph/lib
  for FSD layering.
- 5 views (Force / Tree / Radial / Lanes / Matrix) wire impact map
  via $derived.by; outer SVG gets `impact-mode` class; nodes get
  `node-impact-root` / `node-impacted-near` / `node-impacted-far`
  applied alongside existing `nodeClass(...)`. Sankey + Sunburst
  do NOT participate — their hierarchy semantics already cover
  the impact view.
- Matrix applies impact class to row + col headers (artifacts);
  cells (which represent edges) are skipped.

Visual treatment in app.css:
- node-impact-root: stroke var(--accent), stroke-width 2,
  drop-shadow(0 0 8px var(--accent)).
- node-impacted-near: opacity 1.
- node-impacted-far: opacity 0.7.
- impact-mode unaffected nodes: opacity 0.18.

Dependencies:
- runtime: marked@^18, dompurify@^3.
- dev: @types/dompurify, happy-dom (vitest DOM env for renderer test).

Verify:
- svelte-check 0 errors / 0 warnings / 432 files.
- npm test 53/53 (40 baseline + 13 new — 6 renderer, 7 impact-graph).
- npm run smoke PASS.

Refs: PRD-006 RFC-005
PRD-006 (Standard) — Web body preview + decision impact drill-down.
9 SC, 8 FR, 5 NFR, 5 risks. Targets code reviewer / stakeholder /
new team member personas. Bundle ≤ 80 KB tracked via NFR-001.
DOMPurify + CSP layered for XSS (NFR-003).

RFC-005 (Standard) — Markdown rendering + decision impact algorithm.
Pins marked + DOMPurify config, BFS depth cap (MAX_IMPACT_DEPTH=8),
direction normalisation via type-tier.ts#normaliseHierarchyEdge.
Sankey + Sunburst explicitly excluded from impact mode.

PRD-007 (Standard) — Web stale + blind-spot push notifications.
9 SC, 9 FR, 5 NFR, 5 risks. Browser Notification API only (no SW).
Privacy: only id + title in payload (NFR-002). Throttle 1 per 60s
per category. F12 implementation.

RFC-006 (Standard) — Notification permission UX + breach detection.
State machine for default / requested / granted / denied. Breach
categories: new blind_spot / stale_count delta / orphan_count delta.
notifyBus pattern for cross-scope click-handler routing.

All 4 validated: PRD-006/007 PASS (with non-blocking warnings),
RFC-005/006 PASS clean.

Refs: PRD-006 RFC-005 PRD-007 RFC-006
…FC-005) (#36)

## Summary

Implements PRD-006 + RFC-005. Two coupled UX improvements that turn
`@forgeplan/web` from a "table of contents" into a "decision viewer":

### Body preview
- ArtifactPanel adds **+ Show body / − Hide body** toggle revealing the
full markdown body inline.
- New `widgets/artifact-panel/lib/markdown-renderer.ts` uses `marked`
(GFM) + DOMPurify (allow-listed tags). XSS-safe by construction.
- 6 unit tests (basic markdown, `<script>` strip, `javascript:` href
strip, GFM table preserved, task checkbox `<input type="checkbox">`,
empty fallback).
- bodyExpanded persisted per session in `localStorage`.

### Decision impact drill-down
- **Show downstream / Show upstream / Clear** buttons in ArtifactPanel.
- New `widgets/dependency-graph/lib/impact-graph.ts` — pure BFS bounded
by `MAX_IMPACT_DEPTH=8`, direction normalised via
`type-tier.ts#normaliseHierarchyEdge` so `informs` / `refines` /
`belongs-to` / `contains` / `supersedes` all flow abstract → concrete
consistently.
- 7 unit tests (chain, diamond, cycle, depth cap, non-hierarchy filter,
upstream chain, symmetry).
- `highlight.svelte.ts` extended with `impactRoot` / `impactDirection`
$state + `setImpactRoot` / `impactedClass` exports. Dual-exposed via
`entities/graph` and `widgets/dependency-graph/lib`.
- 5 views (Force / Tree / Radial / Lanes / Matrix) wire impact map via
`$derived.by`; outer SVG gets `class:impact-mode`; nodes get
`node-impact-root` / `node-impacted-near` / `node-impacted-far`.
- Sankey + Sunburst do NOT participate — their hierarchy semantics
already cover this view.
- Matrix applies impact class to row + col headers; cells (edges)
skipped.

### Visual treatment
- `node-impact-root`: accent stroke 2px + drop-shadow.
- `node-impacted-near`: opacity 1.
- `node-impacted-far`: opacity 0.7.
- `impact-mode` unaffected nodes: opacity 0.18.

### Dependencies
- runtime: `marked@^18`, `dompurify@^3`.
- dev: `@types/dompurify`, `happy-dom` (vitest DOM env).

## Verify

- svelte-check 0/0/432 files.
- npm test 53/53 (40 baseline + 13 new).
- npm run smoke PASS.

## Test plan

- [ ] CI smoke matrix (3-OS × Node 22) green.
- [ ] svelte-check clean.
- [ ] vitest 53/53.
- [ ] Manual: open ArtifactPanel for a PRD, click "+ Show body" —
markdown renders. Click "Show downstream" — affected RFCs/EVIDs glow
accent in graph.

Refs: `PRD-006` `RFC-005`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Implements PRD-007 + RFC-006. Browser Notification API (no Service
Worker) fires on workspace health changes detected by the 10s
/api/health poll diff.

Detection (entities/health/lib/notify.svelte.ts):
- detectBreaches(prev, next) returns 3 categories:
  - new blind_spot — id appeared in next.blind_spots that wasn't in prev
  - stale — next.stale_count > prev.staleCount
  - orphan — next.orphan_count > prev.orphanCount
- All pure functions; 9 vitest unit tests cover snapshot extraction,
  every breach category, permission branches (granted/denied/missing),
  throttle window with injectable now() clock.

Permission UX (HealthBar.svelte):
- 🔔/🔕 toggle button with active state when granted+opted-in.
- First click → requestPermission(); on grant, sets notify=true.
- denied → button disabled with tooltip "Re-enable in browser site
  settings".
- Feature-detect: hidden when Notification API absent (NFR-003).
- Hidden aria-live="polite" .sr-only mirror for screen-readers.

Wiring (HomePage.svelte):
- One $effect watches healthPoller.state.data — diffs against
  prevHealthSnapshot, runs detectBreaches, fires per-breach
  notification with onClick → focusArtifact(id).
- Second $effect watches notifyBus.pendingFocus — when set,
  selectNode({id}) and clears. Bus pattern lets notification's
  onclick (foreign closure) reach Svelte 5 state without prop drill.

Settings (settings.ts):
- notify: boolean added to PersistedSettings + ResolvedSettings.
- Default false. Loaded/saved through existing localStorage flow.

Throttle:
- ≥ 60s per category (FR-007).
- silent: true on Notification ctor (no audio).
- tag: `forgeplan.${kind}` collapses repeats in the OS notification
  centre.
- No body content in payload — only id + title for blind_spots,
  count delta for stale/orphan (NFR-002 privacy).

Vitest config:
- pool: 'threads' (was default 'forks'). Default per-file child
  process spawning blew through macOS kern.maxprocperuid at 8
  test files; threads share heap, no spawn() per file.

Verify:
- svelte-check 0/0/434.
- npm test 62/62 (53 baseline + 9 new).
- node scripts/smoke.mjs PASS.

Refs: PRD-007 RFC-006
EVID-014 (Tactical, supports / CL3 / test) — DOM-verified F11
acceptance: PR #36 merged to develop, 13 new vitest tests, 3-OS smoke
matrix green. SC-1..SC-6, SC-8, SC-9 all pass; SC-7 (bundle delta)
tracked but pending per-bundle measurement.

Linked: informs PRD-006, informs RFC-005, builds-on EVID-013.

Status transitions:
- PRD-006: draft → active
- RFC-005: draft → active

These activations ride along with the F12 PR for atomic delivery.
F11 code already shipped in PR #36; this commit only updates artifact
metadata and adds the evidence pack.

Refs: PRD-006 RFC-005 EVID-014
…06) (#37)

## Summary

Implements PRD-007 + RFC-006. Browser Notification API (no Service
Worker) fires when the 10s `/api/health` poll detects workspace decay.
Plus EVID-014 closes F11 and activates PRD-006 + RFC-005.

### Detection
- `entities/health/lib/notify.svelte.ts` exports pure helpers:
`snapshotFromHealth` / `detectBreaches` / `notificationsSupported` /
`notificationPermission` / `requestPermission` / `fire`.
- 3 breach categories: new blind_spot, stale_count delta, orphan_count
delta.
- Throttle ≥ 60s per category with injectable `now()` for tests.
- 9 unit tests cover snapshot extraction, all 3 breach categories,
permission branches (granted/denied/missing), throttle window.

### Permission UX
- HealthBar 🔔 / 🔕 toggle. Active state when granted + opted in.
- First click → `requestPermission()`; grant flips toggle on.
- Denied → button disabled with explanatory tooltip.
- Hidden when `'Notification' in window === false` (Firefox no-API
users, SSR).

### Click → focus
- Notification's `onclick` writes to a `notifyBus.pendingFocus` $state
singleton.
- HomePage's `$effect` reads it and calls `selectNode({id})`.
Cross-scope state without prop drill.

### Privacy & a11y
- No body content in payload (NFR-002): only id + title for blind_spots,
delta count for stale/orphan.
- `silent: true` on the Notification (no audio).
- Hidden `aria-live="polite"` `.sr-only` mirror in HealthBar — screen
readers announce regardless of OS notification visibility.

### Settings
- `notify: boolean` field added; persisted via existing `loadSettings` /
`saveSettings` localStorage flow.

### Vitest config
- `pool: 'threads'` (was default `forks`). Default per-file child
process spawning blew through macOS `kern.maxprocperuid` at 8 test
files; threads share heap, no `spawn()` per file. Fix landed during
build.

### EVID-014 / activations
- EVID-014 (Tactical, supports / CL3 / test) closes F11 acceptance.
- PRD-006 + RFC-005 transition draft → active.

## Verify

- `npx svelte-check` — 0/0/434 files.
- `npm test` — 62/62 (53 baseline + 9 new).
- `node scripts/smoke.mjs` — PASS.

## Test plan

- [ ] CI smoke matrix (3-OS × Node 22) green.
- [ ] svelte-check clean on PR.
- [ ] vitest 62/62.
- [ ] Manual: toggle Notify on; trigger a stale by `forgeplan mark-stale
<id>` in the workspace; observe browser notification; click it; tab
focuses + artifact panel opens.

Refs: `PRD-006` `RFC-005` `EVID-014` `PRD-007` `RFC-006`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EVID-015 (Tactical, supports / CL3 / test) — F12 acceptance:
PR #37 merged to develop, 9 new vitest tests (53 → 62), 3-OS
smoke matrix green.

SC-1..SC-9 all pass:
- toggle UI + permission flow + 3 breach categories detected
- localStorage persistence + UI state reflection
- click → focus via notifyBus singleton
- svelte-check 0/0; CI matrix 3-OS green

Status transitions:
- PRD-007: draft → active
- RFC-006: draft → active

These artifacts ride along with F13 (markdown export, tactical) for
a single PR. F12 code already shipped in PR #37; this commit only
updates artifact metadata.

Refs: PRD-007 RFC-006 EVID-015
ArtifactPanel adds a "📋 Copy as markdown" button next to the impact
actions. Click writes a copy-paste-ready markdown summary to the
clipboard:

  # {id} — {title}
  **Status**: {status} · **Kind**: {kind} · **R_eff**: {r_eff|n/a}
  ## Outgoing
  - {to} ({relation})
  ## Incoming
  - {from} ({relation})
  ## Body excerpt
  {first 500 chars + ...}

Outgoing / Incoming sections omitted when empty; Body section omitted
when artifact.body is empty. Closes the loop "PR review needs PRD
context" — paste straight into GitHub PR descriptions / Slack threads.

Visual feedback: ✓ Copied (green) on success, ✗ Copy failed (red) on
error. Both auto-revert after 2s. setTimeout cancelled on rapid clicks.

Pure function `buildMarkdownSummary` in widgets/artifact-panel/lib/
markdown-export.ts. 6 vitest unit tests (basic format, empty
outgoing/incoming, list rendering, R_eff n/a fallback, body
truncation past 500 chars, empty body omission).

Tactical — no PRD/RFC artifact required.

Verify:
- svelte-check 0/0/436.
- npm test 68/68 (62 baseline + 6 new).

Refs: tactical
…te F12 (#38)

## Summary

Two riding-along payloads:

### F13 (tactical, no PRD/RFC) — Copy as markdown
ArtifactPanel adds a **📋 Copy as markdown** button. Click writes a
copy-paste-ready markdown summary to the clipboard for PR descriptions /
Slack threads:

```
# {id} — {title}
**Status**: ... · **Kind**: ... · **R_eff**: ...
## Outgoing
- {to} ({relation})
## Incoming
- {from} ({relation})
## Body excerpt
{first 500 chars + ...}
```

Empty Outgoing/Incoming/Body sections omitted. Visual feedback: ✓ Copied
(green) on success, ✗ Copy failed (red) on error; auto-revert after 2 s.

Pure helper `buildMarkdownSummary` in
`widgets/artifact-panel/lib/markdown-export.ts`. 6 vitest unit tests.

### EVID-015 + activations
- EVID-015 (Tactical, supports / CL3 / test) closes F12 acceptance.
- PRD-007 + RFC-006 transition draft → active.

## Verify

- `npx svelte-check` — 0/0/436.
- `npm test` — 68/68 (62 baseline + 6 new).

## Test plan

- [ ] CI smoke matrix (3-OS × Node 22) green.
- [ ] svelte-check clean on PR.
- [ ] vitest 68/68.
- [ ] Manual: open ArtifactPanel for any PRD, click "📋 Copy as
markdown", paste into editor — markdown matches format.

Refs: `PRD-007` `RFC-006` `EVID-015`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…diff

The F12 breach-detection effect read prevHealthSnapshot via
detectBreaches() AND wrote to it (`prevHealthSnapshot = next`) in the
same effect. Since prevHealthSnapshot was declared `$state(...)`,
Svelte 5 (correctly) flagged the cycle as effect_update_depth_exceeded
and stopped re-running the effect.

prevHealthSnapshot is a "previous tick" memo — no template reads it,
no other effect depends on it. Reactivity is unnecessary. Demoting it
to a plain `let` keeps the value alive across effect re-runs through
the component closure scope without making it a reactive signal.

Verify:
- svelte-check 0/0/436.
- npm test 68/68 (no test impact — pure-function notify lib unchanged).
- Manual: dev server loads with no console errors.

Refs: PRD-007 RFC-006
…diff (#40)

## Summary

Hotfix for runtime error reported by user after F12 merge:

```
Uncaught Svelte error: effect_update_depth_exceeded
Maximum update depth exceeded. This typically indicates that an effect
reads and writes the same piece of state.
```

## Root cause

`HomePage.svelte:130` $effect for breach detection:
- **reads** `prevHealthSnapshot` via `detectBreaches(prevHealthSnapshot,
next, ...)`
- **writes** `prevHealthSnapshot = next` at the bottom

Both on the same `$state(...)` signal → Svelte 5 (correctly) detects the
cycle and abort-loops the effect.

## Fix

`prevHealthSnapshot` is a "previous tick" memo — no template reads it,
no other effect depends on it. Reactivity is unnecessary; the value
lives across re-runs via the component's closure scope. Demoted to plain
`let`.

## Verify

- `npx svelte-check` — 0/0/436.
- `npm test` — 68/68 (no test impact; pure-function notify lib
unchanged).
- Manual: dev server loads, browser console shows 0 errors after
navigation.

## Why $state was wrong here

In Svelte 5 runes, `$state` creates a reactive signal. Any effect that
reads + writes the same signal triggers a cycle. For "memoized previous
value" between successive runs of one effect, a plain `let` survives in
closure scope without becoming reactive — that's the idiomatic pattern.

Refs: `PRD-007` `RFC-006`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
5-expert audit (TS / Frontend / Security / Performance / UX) on
F11+F12+F13 returned 0 CRITICAL, 7 HIGH, ~12 MEDIUM, ~10 LOW. This
PR closes all 7 HIGH + 7 MEDIUM/LOW.

HIGH:
- TS-H1: renderBody returns branded SafeHtml type (string &
  { readonly __safeHtml: unique symbol }). Untrusted strings cannot
  reach {@html} via this signature.
- TS-H2: TODO(marked-types) comment near `as string` cast.
- FE-H1: Matrix impact-mode selector extended to include row-header
  and col-header (was only .node — Matrix never faded).
- FE-H2: try/catch around Notification.permission and
  Notification.requestPermission and new Notification ctor.
  Sandboxed iframes no longer throw on access.
- UX-H1: artifact-body pre / pre code get max-width: 100%,
  white-space: pre-wrap, word-break: break-word. Long shell lines
  wrap instead of cascading horizontal scroll to panel.
- PERF-H1: marked + DOMPurify dynamic-imported only when user clicks
  "+ Show body". First-paint −21 KB gzip.

MEDIUM/LOW:
- FE-M2: clipboard fallback for non-secure contexts (textarea +
  document.execCommand('copy')).
- FE-M3: liveText prefixed with zero-width-space + liveSeq counter
  so identical breach text re-announces in screen readers.
- FE-M4: notify-on first-fire-storm prevention — prime
  prevHealthSnapshot when user flips notify on mid-session.
- FE-L1: prefers-reduced-motion media query covers all *, not just
  svg *.
- UX-M1: tooltips on Show downstream / upstream buttons.
- UX-M3: .links ul capped at max-height: 30vh + overflow-y: auto.
- SEC-L1: DOMPurify afterSanitizeAttributes hook adds
  rel="noopener noreferrer" to target="_blank" anchors (CWE-1022).

Tests: 68 → 70.
- markdown-renderer.test.ts: target=_blank gets rel=noopener.
- notify.test.ts: iframe-throw fallback returns 'unsupported'.

Verify:
- svelte-check 0/0/436.
- npm test 70/70.
- npm run smoke PASS.
- Playwright DOM check: 0 console errors.

Bundle: 64 KB raw / 21 KB gzip of marked + DOMPurify moved from
first-paint chunk to lazy chunk loaded on body-toggle click.

Refs: PRD-006 RFC-005 PRD-007 RFC-006
…#41)

## Summary

5-expert audit (TS / Frontend / Security / Performance / UX) on
F11+F12+F13 returned **0 CRITICAL, 7 HIGH, ~12 MEDIUM, ~10 LOW**. This
PR closes all **7 HIGH + 7 MEDIUM/LOW** findings.

## HIGH

| ID | Finding | Fix |
|---|---|---|
| **TS-H1** | `renderBody` return type was plain `string` — caller could
feed any string to `{@html}` | Branded `SafeHtml = string & { readonly
__safeHtml: unique symbol }` |
| **TS-H2** | `marked.parse(...) as string` cast valid only with `async:
false` — silent breakage if option removed | `// TODO(marked-types)`
comment + revisit on marked v19 |
| **FE-H1** | Matrix impact-mode never faded — CSS selector targeted
`.node` only; Matrix has `.row-header` / `.col-header` | Selector
extended via `:is(.node, .row-header, .col-header)` |
| **FE-H2** | `Notification.permission` access throws in sandboxed
iframes / restrictive Safari → toggle effect crashed | `try/catch` in
`notificationPermission`, `requestPermission`, `fire` |
| **UX-H1** | Long code blocks pushed horizontal scrollbar to entire
panel | `.artifact-body pre / pre code` get `max-width: 100%;
white-space: pre-wrap; word-break: break-word` |
| **PERF-H1** | marked + DOMPurify (~21 KB gzip) shipped on first paint
despite body collapsed by default | Dynamic import inside `bodyExpanded`
branch — first-paint bundle drops by 21 KB gzip |

## MEDIUM / LOW

- **FE-M2** — clipboard fallback (textarea + `execCommand('copy')`) for
http:// non-secure contexts.
- **FE-M3** — `liveText` prefixed with zero-width-space + `liveSeq`
counter so screen readers re-announce identical breach text.
- **FE-M4** — Notify-on first-fire-storm: prime `prevHealthSnapshot`
when user flips notify on mid-session.
- **FE-L1** — `prefers-reduced-motion` query extended from `svg *` to
`*, *::before, *::after`.
- **UX-M1** — tooltips on Show downstream / upstream buttons.
- **UX-M3** — `.links ul { max-height: 30vh; overflow-y: auto }`.
- **SEC-L1** — DOMPurify `afterSanitizeAttributes` hook forces
`rel="noopener noreferrer"` on `target="_blank"` anchors (CWE-1022
guard).

## Backlog (deferred)

- UX-H2 mermaid latent (no current PRD/RFC uses mermaid; ADR for diagram
renderer when needed).
- TS-M3..M8, L9..L12 (cosmetic).
- SEC-L2 redaction of secret patterns in body excerpt (needs thoughtful
regex set — separate artifact).
- SEC-L3 kindFilter validation (low impact).
- FE-L2..L4 (rare edge cases).

## Verify

- `npx svelte-check` — 0/0/436.
- `npm test` — 70/70 (68 baseline + 2 new tests for noopener hook +
iframe-throw).
- `node scripts/smoke.mjs` — PASS.
- Playwright DOM check — 0 console errors.

## Test plan

- [ ] CI smoke matrix (3-OS × Node 22) green.
- [ ] svelte-check clean on PR.
- [ ] vitest 70/70.
- [ ] Manual: open ArtifactPanel for an Epic with many children — link
list scrolls within `max-height: 30vh`.
- [ ] Manual: clear all filters in Matrix view with impact-mode active —
non-impacted headers visibly fade.
- [ ] Manual: open in iframe — Notify toggle does not crash the page.

Refs: `PRD-006` `RFC-005` `PRD-007` `RFC-006`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
F11 (body preview + decision impact drill-down — PRD-006/RFC-005),
F12 (stale + blind-spot push notifications — PRD-007/RFC-006),
F13 (Copy as markdown — tactical), and F14 (post-audit cleanup —
7 HIGH + 7 MEDIUM/LOW from 5-expert audit). Plus the
HomePage health-poll effect-cycle hotfix.

All artifacts (PRD-006/007 + RFC-005/006) active with R_eff > 0
via EVID-014 / EVID-015.

Refs: PRD-006 RFC-005 PRD-007 RFC-006
@explosivebit explosivebit merged commit 6a8a073 into main May 6, 2026
3 checks passed
@explosivebit explosivebit deleted the release/v0.1.8 branch May 6, 2026 09:53
explosivebit added a commit that referenced this pull request May 6, 2026
## Summary

Back-merge of `release/v0.1.8` (now merged into `main`, tagged `v0.1.8`,
published to npm with provenance) into `develop` so the version bump and
any release-only commits don't get lost.

Per [`guides/GIT-FLOW-GUIDE.ru.md`](guides/GIT-FLOW-GUIDE.ru.md) §6.10.

## What's in this back-merge

- `a3d94b6` — `chore(release): bump version to 0.1.8`
- `6a8a073` — release PR #42 merge commit (already on main, tagged
v0.1.8, npm published).

No new code changes — release was already QA'd through PR #42 (3-OS
smoke matrix green, npm publish workflow success).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant