All notable changes to @forgeplan/web are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- New
widgets/hintsFSD widget — a pure rule-DSL + ranking dispatcher inlib/(fixture-driven vitest) plus Svelte 5 UI inui/, composed intoHomePageabove HealthBar (FR-001). All hint data is computed client-side from already-wired allow-listed pollers (health, list, score, blocked, log) — no/api/anomalies, no new endpoint, no allow-list widening (rule 22). - 8 hint rules (FR-004) in a single append-only
lib/hint-rules.tsarray (FR-005):stale-spike,low-r-eff-critical,valid-until-imminent,blind-spot-new,orphan-detected,draft-too-old,velocity-drop,cycle-detected. Thresholds are exported consts (single-file tunable). - Pure
computeHints(state)(FR-006) — runs every rule, dedupes byaffectedIds[0]first-rule-wins (RFC R-2), filters snoozed entries by TTL, and ranks deterministically by severity weight → stable rule priority → id (NFR-003). Deliberately not the RFC'scomputedAtrecency tiebreak, which isDate.now-based and non-deterministic. - Hint UI from existing primitives (FR-002 / FR-003) —
HintCardcomposesAlert(severity→variant),Badge,Button, andPopover(snooze menu);HintsPanelshows the top 3 by default with a collapsible header, "show all" expander, and anaria-live="polite"mirror (FR-011). No:global()into any primitive's internals (rule 24). - Snooze / dismiss (FR-007 / FR-008) — Snooze 1 day / 1 week per hint;
Dismiss == 24h snooze (re-fires if the issue persists). Snoozed ids persist in
localStorageviasettings.hintsSnoozedwith auto-cleanup of expired entries on every save and load. - Master "Hints on/off" toggle in HealthBar (FR-009) bound to
settings.hintsHidden; collapsed state persisted viasettings.hintsCollapsed. - Degraded rules + deferred config, documented —
valid-until-imminentfalls back tohealth.at_riskanddraft-too-oldtohealth.stale_draftsbecause per-artifactvalid_until/created_atare not in any aggregate read-only payload (only/api/get/[id]). FR-010 (per-rule thresholds viaforgeplan-web.json) is deferred — that file is server-only and unreachable from any allow-listed client endpoint. Neither degradation is a reason to widen the allow-list. Seedocs/hints-rules.md.
- 6th InsightsRail tab "Stats" (FR-001) — added
statsto theInsightTabunion +INSIGHT_TAB_IDS(shared/config/ui-prefs.ts) and a{ key: 'stats', label: 'Stats' }entry to the rail via the existingTabs/TabsList/TabsTriggerpattern (rule 24 — no new primitive). The tab renderswidgets/stats-pulse/StatsPanel. - Workspace health score 0..100 (FR-008 / FR-009) — deterministic
computeHealthScore()(widgets/stats-pulse/lib/health-score.ts) over 5 weighted components (R_eff median .30, activation ratio .20, evidence coverage .20, blind-spot freedom .15, recent velocity .15). Median (not mean) makes the score gaming-resistant. Rendered as a big number + 🟢/🟡/🔴 band (80+/60–79/0–59) with a breakdown disclosure (FR-010). - Four domain charts (FR-002 / FR-004 / FR-005 / FR-003) — widget-local
hand-baked SVG (mirrors
dependency-graphviews; rule 24 — colours fromapp.csstokens only): R_eff histogram (10 buckets, evidenced subset), weekly velocity line (net = activations + retirements − new drafts), 90-day status-transition flow bars, and a coarse decay-risk panel. - Plain-language interpretation + status badges (FR-006 / FR-007) — each
chart wraps its title in the shared
Tooltipprimitive, ships a static caption, and shows a shape-glyph status badge (●/◐/○ — colourblind-safe per NFR-005) driven bylib/interpret.ts. - Approximate 30-day trend sparkline (FR-011, degraded) — reconstructed
client-side by replaying the
/api/logstatus-transition stream (lib/trend.ts); shows "no trend data yet" below 7 days of history. - Constraint-driven architecture — all stats are computed client-side
from already-polled allow-listed endpoints (
/api/list,/api/score,/api/health,/api/log). The RFC's proposedGET /api/pulseendpoint and server-writtenhealth-history.jsonwere dropped as they violate the read-only proxy allow-list (rule 22) andinithost-isolation; no new endpoint, no allow-list widening, no server write. A dedicatedstatsLogPoller(/api/log?limit=5000, 30 s) supplies full history for the velocity/transition/trend math; pure stat/score/trend logic is unit-tested with co-located vitest specs.
- Risk overlay toggle (
canvas-toolbar) — a "Risk" toggle (FR-001) gates a glow halo on graph nodes whose R_eff is concerning. State persists vialocalStoragesettings (forgeplan-web:settings:v1). The toggle carries a stabledata-action="toggle-risk"automation hook (forwarded through a newdataActionprop on the sharedToggleprimitive — no internal override, rule 24) and is disabled when every visible pane is Sankey / Sunburst, where the overlay never applies (NFR-005 / SC-9). - Node glow halo (FR-002 / FR-003) — box-views (Force / Tree / Radial /
Lanes) mark a node with
class="node-risk"and avar(--bad)drop-shadowexactly when itsR_eff < 0.6(RISK_THRESHOLD, pinned by RFC-008); glow radius scales with composite risk. Sankey / Sunburst / Matrix never glow. Gating onR_eff < 0.6(rather than any imperfect evidence) keeps a healthy workspace lighting only its handful of thin places at a glance. - Pure risk-score lib (FR-004 / FR-005) —
widgets/dependency-graph/lib/risk-score.tsexportsriskScore(detail)in[0..1](multiplicative(1 − R_eff) × decay_factorover a 90-day window),nodeAtRisk(theR_eff < 0.6glow gate),glowRadiusPx,daysRemaining, andweakestInformingEvid, with co-located vitest unit tests. - Risk anatomy panel section (FR-006 / FR-007 / FR-008) — ArtifactPanel shows a "Risk anatomy" section (composite score, decay timer, informing evidence list with the weakest source highlighted) when an artifact's risk exceeds the panel threshold. CL / evidence_type render as "—" (not exposed by read-only JSON; see RFC-008).
- Hover tooltip (FR-009) — at-risk node
<title>showsR_eff, composite risk, and the weakest informing EVID id when one exists. - a11y (NFR-003) — at-risk node
aria-labels append, risk N.NN.
- Instance switcher rewritten with Popover+Command (
health-bar) — replaced the bits-ui Combobox (anchor / open-in-empty-state issues) with a Popover + Command combination that opens reliably, anchors correctly to its trigger, and renders in both populated and empty states. - Combobox ghost variant — semi-transparent background, no border, consistent hover across themes; switcher is always visible even with ≤ 1 live instance.
- Registry heartbeat race (
registry) — heartbeat now re-registers an entry evicted by a concurrentinit, preventing silent loss of a running instance. - Timeline toggle (
template) — restored Retry button and Lucide arrow icons that regressed in 0.2.0.
- Global instance registry at
~/.forgeplan-web/instances.json— every runningforgeplan-webserver registers itself with{ id, host, port, pid, scope, workspaceRoot, projectName, startedAt, heartbeatAt, webVersion, forgeplanCli }and heartbeats every 30 s. Mutations live inbin/lib/registry.mjs(file-locked, atomic rename). /api/instancesendpoint — read-only mirror of the registry with in-process liveness sweep (process.kill(pid, 0)+ heartbeat ≤ 60 s). Allow-listed extension to rule 22.- HealthBar instance switcher — Combobox-based picker that lists every live instance and opens it in a new tab. Visible only when ≥ 2 live instances are registered.
init --scope user|project— user scope writes to~/.forgeplan-web/(workspace-agnostic, gitignore-silent); project scope is the historical<cwd>/.forgeplan-web/behaviour. Interactive prompt offered when neither flag nor-yis passed.startfallback chain —<cwd>/.forgeplan-web/→~/.forgeplan-web/→ friendly error. Workspace bound viaFORGEPLAN_CWD(defaults to cwd for user scope).
bin/migrated to citty (^0.2.2, the only allow-listed runtime dep). Subcommand routing, typed args, auto-help, and a future prompt hook (#111).bin/split intocli.mjs+commands/*.mjs+lib/*.mjs.
- Multi-image scaffold pipeline —
@forgeplan/webnow ships named "images" of the scaffold. v1 ships two:stable(default) andnightly. Each image is adist*/directory in the npm tarball with its ownforgeplan-web-build.jsonmanifest (image name + feature list). --image <name>CLI flag oninitandupdate, defaultstable. Choice is sticky inforgeplan-web.json.update --image nightlyswitches tracks.config/images.json+config/features.json— single source of truth for what images ship and what feature flags each image includes. Seeconfig/IMAGES.mdfor the maintainer guide.- Build-time lifecycle validator —
scripts/build.mjsfails fast when any feature flag hasexpiresIn ≤ currentVersionor when a flag's lifetime exceeds 3 minor versions. Forces graduation. - Per-image smoke test —
npm run smokeexercises bothstableandnightly.
- Bundle approach is now the universal artifact form for every
image. The legacy SvelteKit-with-
node_modules/shape (≈14 MB) is removed from the published tarball. Every emitteddist*/is the ~1.8 MB esbuild single-file bundle, capped at 3 MB. forgeplan-web.jsongains animagefield; existing scaffolds withexperimental: trueare migrated toimage: "nightly"on firstupdate.
--experimentalflag oninitandupdate— now a deprecated alias for--image nightly. Prints a stderr warning on every invocation. Removed in 0.3.0.
dist-experimental/directory in the published tarball is replaced bydist-nightly/. Users runningnpx @forgeplan/web initsee no change; consumers that pinned the directory name in scripts must switch todist-nightly/.- Legacy build pipeline functions
installRuntimeDeps(),emitDistPackageJson(),copyToDist()removed fromscripts/build.mjs.
- Multi-tab artifact viewing — Shift+click on a graph node opens the artifact in a new in-app tab; the closure model preserves the tab order and active selection.
Comboboxprimitive inshared/ui/—bits-ui-backed, keyboard-navigable, used by the HealthBar instance switcher.
adapter-nodeprecompress disabled — shrinks the published tarball by removing.gz/.brduplicates that the upstreamforgeplanCLI never serves.
shared/ui/primitives based onbits-ui— single source of truth for visual atoms. Visual atoms (Badge / Separator / Skeleton / Spinner / Card / Alert / Progress), form basics (Label / Input / Field / InputGroup), toggles (Toggle / ToggleGroup / ButtonGroup / Switch / Checkbox), radio (Radio / RadioGroup), disclosure (Tabs / Collapsible / Accordion), overlays (Tooltip / Popover, Toaster viasvelte-sonner), and command palette (Command + Item family)./playgroundshowcase route — every primitive × every variant × every size, in both light and dark themes. The catalogue is the contract for what an atom looks like.- Rule 24 —
shared/uiownership and customisation discipline (.claude/rules/24-shared-ui-ownership.md). Forbids:global()re-skins of primitive internals fromentities//widgets//pages//routes/. Includes a verification grep snippet. - New primitive variants added by the rule-24 audit:
Button.variant= "ghost-mono",Button.size="icon",Badge.variant="mono",Toggle.variant="outline-mono",ToggleGroup.variant="outline-mono",Alert.tone="banner",TabsList.wrap. Replaces hand-rolled markup (Tabs,Collapsible) across widgets.
orchtheme — third palette (Orchestra-inspired), exposed as a hidden 5-click easter egg on the theme toggle, with a 2 s freeze after unlock to prevent immediate accidental switch.- Dense-graph render-resilience — Sankey and Tree views handle 100+ artifacts without layout thrash.
- Widget visuals migrated to primitives —
UpdateButton, mosaic pane-icon buttons,HomePageerror-bar, filter chips,HealthBartheme-switcher and notify,InsightsRailtab badges and R_eff bars,ArtifactPanelkind chip and ghost buttons.
- Force 3D experimental Threlte view mode (
#103) — reverted in#105. Thedist-experimental/cap stays at 6M for now.
/api/snapshot?at=ISO— read-only endpoint reconstructing workspace state at any past ISO 8601 timestamp. Resolvesat→ commit SHA viagit rev-list -1 --before=<at> --first-parent HEAD -- .forgeplan/, then reconstructs viagit worktree add --detachtoos.tmpdir()+forgeplan reindex(rebuilds LanceDB inside the temp worktree from markdown source-of-truth) +forgeplan list/graph --json. Two-tier cache: in-memory LRU (32 entries, 60s TTL) + on-disk (.forgeplan-web/.snapshots/<sha>.json). Cold path 660 ms (39 artifacts), warm 10–11 ms./api/timeline-events— read-only proxy overgit log --first-parent .forgeplan/. Emits one event per.forgeplan/-touching commit withat/kind/artifactId/sha/subject.kindheuristically classified from commit subject (activate / supersede / scored / created).- Timeline panel — collapsible scrubber UI below the canvas (
widgets/timeline/). SVG axis with coloured ticks per event kind, draggable scrubber with PointerEvent capture, ArrowLeft/Right step-by-event, Home/End jump, 200 ms debounce before fetch.role=sliderfor a11y. State persisted in localStorage. - Canvas snapshot hydration — when
snapshotStore.mode === 'single',nodes/edgesswitch from live pollers tosnapshotStore.current.{artifacts,edges}. Status indicator surfaces "viewing snapshot at HH:MM" / "live · now" / loading / error. gitRepoRoot()helper — detects host git top viagit rev-parse --show-toplevel, cached. Required becauseworkspaceRoot()resolves totemplate/src/in dev mode.
init --experimentalflag — opt-in to a single-file esbuild bundle of the SvelteKit server (dist-experimental/, ~1.5 MB) instead of the legacydist/(~14 MB withnode_modules/). Same CLI, same/api/*envelopes; ≈9× smaller, ~27× fewer files copied into.forgeplan-web/. Refs: PRD-014, RFC-013.--no-experimentalflag onupdate— switch a pre-existing.forgeplan-web/from bundled back to legacy on the next refresh.experimental: boolfield inforgeplan-web.json— records the dist shapeinit/updatecopied; persisted across sessions.
- COMPARE mode for time-travel (Alt-drag two scrubbers, diff overlay) — endpoint returns 501 with TODO marker. SC-4/SC-5 from PRD-008.
0.1.11 - 2026-05-06
- Minimap on Matrix / Sankey / Sunburst — F16 left these three views with a "MINIMAP N/A" stub because their layouts are layout-system-specific (matrix grid, sankey columns, polar). They DO have 2D node positions; just in different coord systems. Each view now emits
onViewStatewith projected canvas-space(x, y)per node:- Sankey — centroid of d3-sankey's
(x0, y0, x1, y1)per node. - Sunburst — polar→xy:
VIEW_W/2 + r·cos(θ − π/2),VIEW_H/2 + r·sin(θ − π/2)(matches the view's own<g translate(VIEW_W/2, VIEW_H/2)>wrap). - Matrix — diagonal centroid
MARGIN + HEADER + i·CELL + CELL/2for each artifact (matches the real cell-centre in canvas coords so click-teleport lands accurately).
- Sankey — centroid of d3-sankey's
panTo(x, y, k)exported from all 3 views (already existed on Force/Tree/Radial/Lanes). Click anywhere in minimap teleports the canvas atk=1.- Removed the
enabled={false}gate in DependencyGraph for Matrix/Sankey/Sunburst — Minimap now self-gates onnodes.length > 0.
0.1.10 - 2026-05-06
- Infinite-feel zoom —
scaleExtentraised from[0.3, 3]([0.3, 4]on Sunburst) to[0.05, 50]across Force / Tree / Radial / Lanes / Matrix / Sunburst views. Sankey untouched (its grid layout doesn't benefit from extreme zoom).fitToViewclamp ceilings raised toMath.min(2, ...)so auto-fit can zoom in moderately on small workspaces; floors dropped to 0.05 to match scaleExtent. - Minimap — new
widgets/dependency-graph/ui/Minimap.sveltemirrors the active view's nodes in a 180×120 panel anchored bottom-right of.canvas-body. Each node renders as a 1.6 px circle in its kind colour; current viewport is overlaid as an accent-stroked rectangle (var(--accent)outline +var(--accent-dim)fill). Click or drag inside the minimap teleports the canvas viewport to that point atk=1— user never gets lost at extreme zoom. Active for Force / Tree / Radial / Lanes; Matrix / Sankey / Sunburst show a muted "MINIMAP N/A" stub since their layouts are fixed-grid. lib/minimap-math.ts— pure functionscomputeBBox/bboxScale/canvasToMini/miniToCanvas/viewportRectInMini. 7 vitest unit tests cover empty bbox safe default, round-trip canvas↔mini coords, viewport-rect math at identity / k=2 / panned transforms.- View teleport API — Force / Tree / Radial / Lanes views expose an
onViewStatecallback prop emitting{nodes, transform, viewport}and apanTo(canvasX, canvasY, k?)method viabind:this. DependencyGraph wraps the active view + Minimap in aposition: relativehost and routes teleport events.
0.1.9 - 2026-05-06
- CRITICAL crash on duplicate edges — workspaces with two
from→to:relationtuples on the same artifact pair triggered Svelteeach_key_duplicateruntime error across Force / Tree / Radial / Lanes / Matrix views.filterEdgesinlib/filter.tsnow dedupes by composite key (keeps first occurrence). 4 unit tests added. - HealthBar readability — chips restructured:
<strong class="chip-value">{n}</strong> <span class="chip-label">{label}</span>with monospace tabular numerals + uppercase 0.12em letter-spacing labels. Reads as "30 TOTAL · 27 ACTIVE · 3 DRAFT" cleanly instead of mashed289total177active.... - Chrome 132+ devtools probe 404 — added
template/static/.well-known/appspecific/com.chrome.devtools.json = {}. SvelteKit serves it as static asset; browser stops probing. - ArtifactPanel button styling —
.ghostrule was missing inside the panel component (lived only in HomePage, scoped CSS didn't reach). Buttons rendered as browser-default. Added local.ghostrule: monospace, uppercase 0.06em, accent stroke on hover/aria-expanded="true". Now matches landing-style design tokens (.fp-eyebrow,.fp-meta-label).
- ArtifactPanel default width 380 → 658 px (+73%). Reading PRD bodies side-by-side with the graph is now comfortable.
PANEL_MAX_RATIOraised to 0.7 (70vw drag cap from 60vw). - Resizable left edge — 4px hit-area drag handle on the panel's left edge.
pointerdown/move/upwithsetPointerCapture;aria-orientation="vertical"; ArrowLeft/Right keyboard alt; persisted tolocalStorage.forgeplan-web.panelWidth. Range [320, 0.7×innerWidth].prefers-reduced-motionrespected. - "📋 Copy as markdown" button moved out of
.impact-actions(Show downstream / Show upstream / Clear) into a new.body-actionsrow inside<section class="body">next to+ Show body / − Hide body. - Body section opens by default —
bodyExpanded = $state(true). localStorage hydration overrides for users who explicitly muted; new users see body markdown immediately. Race-safe viabodyHydratedguard so the writer-effect can't overwrite the saved value before the reader-effect runs. - Removed inner scroll containers —
.artifact-body { max-height: 60vh; overflow-y: auto }and.links ul { max-height: 30vh; overflow-y: auto }dropped. The panel itself scrolls; no nested scrollbars to fight. - Page-level overflow —
.root { overflow: hidden }. No horizontal/vertical scroll on the page shell. - Sticky-stack panel sections + meta-trail — As the user scrolls inside the ArtifactPanel, each section (
impact-actions / meta / links / body-actions) pins below the header (top: var(--header-h)) with a solid background. JS stateactiveStickyKeyderived frompanel.scrollTopensures only one block is sticky at any moment — earlier rows getclass:passed→position: staticso they scroll away with content. (Without this, a shorter active block would leave a taller previous block visibly poking out underneath, since both would be pinned at the sametop:.) Whenbody-actionsbecomes the active sticky,depthandupdated_atfrom the meta block fade-slide into the right side of the row (pointer-events: none; aria-hidden). 220ms ease-out; honoursprefers-reduced-motion. Header z-index raised to 10. - Outgoing / Incoming collapse — Each links section now has its own toggle (
+ N · Outgoing/− Hide). Auto-collapsed on artifact change when list length > 8 to prevent the panel from blowing up on hub artifacts. Per-artifact state — toggle survives the 10s poll re-deriving identical edges (the auto-collapse $effect keys onid, not on length).
0.1.8 - 2026-05-06
5-expert audit (TS / Frontend / Security / Performance / UX) on F11+F12+F13 returned 0 CRITICAL, 7 HIGH, ~12 MEDIUM, ~10 LOW. This entry closes all 7 HIGH + 7 MEDIUM/LOW; rest deferred to backlog.
HIGH:
- TS-H1 —
renderBodyreturnsSafeHtml = string & { readonly __safeHtml: unique symbol }. Branded type makes it impossible to feed an unsanitised string into{@html}indistinguishably from sanitised output. - TS-H2 —
// TODO(marked-types)comment nearmarked.parse(...) as string; the cast is correct only whileasync: false; revisit on marked >= 19. - FE-H1 (Matrix impact-mode) —
app.cssimpact-mode selector extended from.nodeto:is(.node, .row-header, .col-header). Matrix headers now correctly fade whenimpactRootis set. - FE-H2 (iframe Notification) —
notificationPermission()/requestPermission()/fire()inentities/health/lib/notify.svelte.tsnow wrapNotification.permissionaccess andnew Notification(...)in try/catch. Sandboxed iframes / restrictive Safari contexts no longer crash the toggle effect. - UX-H1 (long code overflow) —
.artifact-body :global(pre)and:global(pre code)getmax-width: 100%; white-space: pre-wrap; word-break: break-word. Long shell lines wrap inside the<pre>instead of cascading horizontal scroll to the panel. - PERF-H1 (lazy markdown renderer) —
marked+DOMPurifyno longer ship in the first-paint bundle. ArtifactPanel dynamically importsmarkdown-rendereronly when the user clicks "+ Show body". First-paint −21 KB gzip.
MEDIUM/LOW:
- FE-M2 —
navigator.clipboard.writeTextfalls back to a<textarea>+document.execCommand('copy')shim on non-secure (http://) contexts. - FE-M3 —
liveTextprefixed with zero-width-space + monotonicliveSeqcounter so identical breach text re-announces in NVDA / VoiceOver. - FE-M4 — Notify-on first-fire-storm prevention: when user opts in mid-session,
prevHealthSnapshotis primed with current state; existing blind_spots no longer re-fire as "new". - FE-L1 —
prefers-reduced-motionmedia query selector broadened fromsvg *to*, *::before, *::after. HTML transitions (HealthBar.pulse, ArtifactPanel.copy-md) are now honoured. - UX-M1 —
title=tooltips on Show downstream / Show upstream buttons explain semantics. - UX-M3 —
.links ulcapped atmax-height: 30vh; overflow-y: auto. Epic with 50 children no longer pushes body section below the fold. - SEC-L1 — DOMPurify
afterSanitizeAttributeshook forcesrel="noopener noreferrer"on any anchor withtarget="_blank"(CWE-1022 reverse-tabnabbing guard).
Tests — 68 → 70: added markdown-renderer test for the noopener hook, notify test for the iframe-throw case.
Bundle — 64 KB raw / 21 KB gzip of marked + DOMPurify moved from first-paint chunk to lazy chunk. First-paint NFR re-checked: PASS with new headroom.
- Copy as markdown — ArtifactPanel gets a "📋 Copy as markdown" button next to the impact actions. Click writes a markdown summary of the selected artifact to the clipboard: id + title + status/kind/R_eff line + Outgoing/Incoming lists + body excerpt (first 500 chars +
...). Closes the loop "PR review needs PRD context" — paste straight into a GitHub PR description / Slack thread / commit message. Visual feedback: ✓ Copied (green) on success, ✗ Copy failed (red) on error, both auto-revert after 2s. widgets/artifact-panel/lib/markdown-export.ts— purebuildMarkdownSummary(artifact, outgoing, incoming): string. 6 vitest unit tests cover all fields, omitted Outgoing/Incoming when empty, R_eff formatting, body truncation past 500 chars, body section omitted when body is empty.
- Stale + blind-spot push notifications — HealthBar gets a 🔔 / 🔕 toggle. First click triggers
Notification.requestPermission(); on grant, the user opts in. When the 10s/api/healthpoll detects a new blind_spot, a stale_count increase, or an orphan_count increase, a browser notification fires (silent, tagged per category, throttled to ≥ 60s per category). Click on a notification focuses the tab and selects the affected artifact via thenotifyBus.pendingFocussingleton. - Permission UX —
🔔 Notify(active) when granted + opted in;🔕 Notify(inactive) otherwise;disabledwith explanatory tooltip when permission isdenied. Hidden entirely when'Notification' in window === false(Firefox no-API users, SSR). - a11y — hidden
aria-live="polite"mirror in HealthBar (.sr-only), updates on each breach so screen-readers announce independently of the OS notification. entities/health/lib/notify.svelte.ts— pure functions (snapshotFromHealth/detectBreaches/notificationsSupported/notificationPermission/requestPermission/fire) +$state notifyBus+focusArtifact. 9 unit tests cover snapshot extraction, all 3 breach categories, permission branches, throttle window.- Settings persist —
notify: booleanfield added toSettings. Default false. Loaded/saved via existing localStorage flow. - Vitest config —
pool: 'threads'(was default 'forks') to avoid macOSkern.maxprocperuidEAGAIN at 7+ test files.
- ArtifactPanel body preview — toggle "+ Show body / − Hide body" reveals the full markdown body of the selected artifact rendered in-place. State persisted per session in
localStorage["forgeplan-web.bodyExpanded"]. PRD bodies (with FR tables, code blocks, GFM checkboxes) now read inside the web UI without opening the file in an editor. - Decision impact drill-down — two new buttons in ArtifactPanel: "Show downstream" runs forward BFS through normalised hierarchy edges (informs / refines / belongs-to / contains / supersedes); "Show upstream" runs backward BFS. Affected nodes glow
var(--accent)+ stroke 2px + drop-shadow; unrelated nodes fade to opacity 0.18 across 5 views (Force / Tree / Radial / Lanes / Matrix). Sankey + Sunburst do NOT participate — their hierarchy semantics already cover this. "Clear" button drops the impact mode. lib/markdown-renderer.ts— exportsrenderBody(md)usingmarked(GFM enabled) + DOMPurify (allow-list of safe tags + attrs). Throws →<pre class="raw-fallback">escape-html fallback. 6 unit tests cover XSS strip (<script>,javascript:href), GFM tables, task-list checkboxes.lib/impact-graph.ts— pure functionscomputeDownstream(rootId, edges)/computeUpstream(rootId, edges). BFS bounded byMAX_IMPACT_DEPTH = 8. Direction normalised viatype-tier.ts#normaliseHierarchyEdge. 7 unit tests cover linear chain, diamond, cycle, depth cap, non-hierarchy filter, upstream/downstream symmetry.highlight.svelte.tsextension — addsimpactRoot: string | null,impactDirection: 'down' | 'up'to the shared $state. New exportssetImpactRoot(id, dir?)andimpactedClass(id, impacted). Re-exported from bothentities/graphandwidgets/dependency-graph/libfor FSD compatibility.- Dependencies —
marked@^18,dompurify@^3(runtime);@types/dompurify,happy-dom(dev, for vitest DOM environment in markdown-renderer tests). - Tests — 47 → 53 (+ 6 markdown-renderer + 7 impact-graph; 1 was duplicated cycle case in impact-graph that survives as extra coverage).
0.1.7 - 2026-05-05
- RadialView cluster collapse — clusters with ≥ 3 rings render a small "−/+" toggle near their centroid. Click or Enter/Space hides ring ≥ 2 (root + ring 1 stay visible); click again to expand. State stored per-view in
$state(Set<string>); fitToView re-fires on toggle so the new bbox fits. - ArrowKey navigation between graph nodes in RadialView and ForceView. New shared lib
keyboard-nav.tswithpickNextNode(current, candidates, direction)— cone-based (±60° around the cardinal axis), cost =distance · (1 + 2·angle/π), falls back to nearest neighbour when the cone is empty so the user always moves. Each<g class="node">getsdata-id;onNodeKeydownroutes focus by id.
detectClustersextendsClusterInfowithorbitsandradii(computed once in pass-2) andClusterDetectionResultwithnodeAdjacency. Both views read these instead of recomputing the orbit + ring-radius pipeline per render.- Sorted
counts.keys()iteration in the radius/maxR passes.Map.keys()returns insertion order, which depended on orbit-assignment order; without sorted iteration thecomputeRingRadiuscache resolved ring 2 before ring 1 and produced a wrong ring 2 radius (visible regression: RFC + ADR sharing one orbit position). Two regression tests now fail against the pre-fix code. - Filter memoisation in RadialView and ForceView —
filterArtifacts/filterEdges/scoreByIdwrapped in$derived.byguarded by content signatures, so a 10s poll with identical payload no longer invalidates the layout. - ForceView each-block link key changed from object identity to a stable
${source}>${target}:${relation}string. Earlier everyrebuild()recreated all<line>elements. force-cluster-repeltyped viaObject.assign(no unsafe cast);ForceClusterRepelOptions<NodeT extends SimulationNodeDatum>.forceClusterRepel's cached cluster ids are now refreshed via explicit.initialize?.(simNodes)after re-bind. Drops redundant re-bind offorceX/forceY/orbital(their accessors already readlayoutfresh per tick). Early short-circuit when only one cluster.- RadialView
didFitis$state(false)with a layout-shape signature effect that resets it on substantial transitions (filter clears / dataset reload). - Zoom legibility floor 0.45 (was 0.2) for both views and RadialView's
fitToView. Labels stay legible at min zoom. tsconfig.json:noUncheckedIndexedAccess: true. Fallout fixed incluster.svelte.ts's orphans-fill block.- 18 → 24 vitest unit tests in
template/src/widgets/dependency-graph/lib/: 16 cluster-geometry, 2 radii cache regression, 6 keyboard-nav.
- CVE-2024-47764 (cookie<0.7.0, GHSA-pxg6-pf52-xh8x) — closed via
template/package.json#overrides("cookie": ">=0.7.0", resolved to 1.1.1). Practical impact ≈ 0 (we don't serialize user-controlled cookie values), but eliminates the open dependabot alert.
lib/cluster.svelte.ts— shareddetectClusters/computeOrbitRing/computeAnchoredAngles/computeRingRadius/ringCountsand geometric constants (MIN_CHORD,RING_GAP,INTER_CLUSTER_GAP).- Geometry-first ring radii —
computeRingRadiusis chord-based, not arc-based:r ≥ MIN_CHORD / (2·sin(π/N))for the same-ring chord, plusr ≥ prev + RING_GAPfor the radial gap to the previous ring. Card centres sit exactly on the orbit; non-overlap is provable rather than empirical. - Compact orbit assignment —
computeOrbitRingmaps each member to the position of its type withinTYPE_ORDER ∩ present-typesfor that cluster. Missing types collapse inward (no empty ring). - Parent-anchored angular layout —
computeAnchoredAnglesputs each ring N+1 member's angle at the circular mean (atan2(Σ sin, Σ cos)) of its inner-ring neighbours. Orphans fill the largest free angular gap. Connected artifacts cluster angularly; ring-1 nodes spread evenly. - Radial-around-largest cluster placement — the largest cluster
occupies the canvas centre; the rest sit on a regular polygon around
it.
outerRadius = max(R_centre + INTER_CLUSTER_GAP + R_outer, worstPair / (2·sin(π/M)))covers both the radial separation and the chord between adjacent outer clusters. Edge-gap from centre to every outer cluster is uniform. lib/force-cluster-repel.ts— custom d3-force keeping cluster centroids apart (Coulomb-style strength=800, minDistance=250, alpha-scaled). Used by ForceView only.- ForceView clusters — d3 simulation extended with
clusterX/clusterY(centripetal pull),forceClusterOrbital(per-node target radius from cluster ring map), andforceClusterRepel. Initial positions seeded near each node's cluster centroid with ±10 px jitter.prefers-reduced-motionpre-ticks the simulation instead of animating. - Visible orbit rings — RadialView orbit stroke opacity 0.08 → 0.16, dash 2 4 → 3 5 — visible to the viewer without competing with relation edges.
dev:playgroundmode —template/vite.config.tsreads aFORGEPLAN_CWDoverride gated onmode === 'playground';npm run dev:playground(root + template) points the SvelteKit server atplayground/.forgeplan/instead of this repo's own workspace, so sandbox experiments don't pollute.forgeplan/. Plainvite devis unchanged.playground/— local Forgeplan workspace seeded with the fictional "Helios" observability platform: 6 epics, 16 PRDs, 19 RFCs, 13 ADRs, 17 specs, 13 problems, 9 solutions, 26 evidence packs, 4 notes (≈123 artifacts), wired with ~80 typed dependency links across all kinds and lifecycle states. Used as dogfood data at realistic scale; not published, not copied to user projects.bin/banner.mjs— ANSI Shadow ASCII banner ("Forgeplan/Web", 24-bit orange) printed as the first line ofstart(). RespectsNO_COLOR,TERM=dumb, non-TTY stdout, and-q/--quiet.<NodeRef>component +nodeHoverSvelte action (template/src/entities/graph/) — single primitive for rendering an artifact id anywhere in the side panels. Replaces ad-hoc id renderings inInsightsRail(Recent / Agents / Drafts / Blocked / Cycles / Ready / Blind spots / Orphans / Stale / Lowest R_eff) andArtifactPanel(header id, Outgoing / Incoming, parent epic). Hovering any of them now lights up the matching graph node and fades the rest of the graph.- README.md / README.ru.md — "Local dev modes" subsection covering
npm run devvsnpm run dev:playground.
- FR-001 — Selection ring no longer encloses the status dot. Each view (where applicable) renders a dedicated
<rect class="selection-ring">sized to the card content; the status dot stays independent. - FR-002..FR-006 — Hovering a graph node now highlights its connected edges (
.edge-active— accent stroke, increased width) and dims unrelated edges (.edge-dim— opacity 0.25). Applied across all 5 views (Force / Tree / Radial / Matrix / Lanes); Matrix uses cell<rect>as the edge primitive. - Hover fade is now distance-based: a BFS over
filteredEdgesscales node opacity by hop distance — direct neighbours stay near full opacity, distant nodes fade further, disconnected nodes drop to ~0.12. Smooth 180 ms ease-out transition on edges and nodes. - The same fade now triggers for the selected/opened node (softer dim via
.graph.focus-soft); hover takes priority over selection when both apply. - Hover state moved from
widgets/dependency-graph/lib/down toentities/graph/lib/sowidgets/insights-railandwidgets/artifact-panelcan share it without crossing FSD layers. The original module path is kept as a re-export shim —// TODO(fsd-cleanup)marks the follow-up to update the five graph views.
/api/healthblind_spotsnow render as[{id,title,issue}]objects inInsightsRailinstead of[object Object].HealthResponsetype updated; the rail shows artifact id + title.
- FR-001 —
template/src/routes/+error.svelteadded. SvelteKit error boundary with styled fallback (status, message, "Go home" link); replaces the unstyled default error page. - FR-002 — All 5 graph view SVGs (
Force/Lanes/Matrix/Radial/Tree) switched fromrole="application"torole="img"+ descriptivearia-label. Screen readers no longer treat them as opaque interaction zones. - FR-003 —
InsightsRail.svelterows refactored from<li role="button" tabindex="0">(withsvelte-ignoresuppression) to nested<li><button>. Native focus order, no a11y rule bypass. - FR-004 —
prefers-reduced-motionhonoured across graph views via sharedmotionDuration()helper. d3.transition().duration(300)becomes 0 for reduce-motion users; ForceView simulation settles fast (alphaDecay(0.1).alphaMin(0.05)). - FR-005 —
.has-panelgrid no longer mis-flows at viewport< 1100 px. Insights rail explicitly leaves the grid (display: noneremoves its cell) and.has-paneldrops to a 3-column template with the artifact panel correctly placed.
0.1.6 - 2026-05-04
- CWE-78 —
FORGEPLAN_BINregex-validated at module load intemplate/src/shared/server/forgeplan.ts. Refuses spawn (returns502envelope) when the env var contains characters outside[A-Za-z0-9_./:\-]. Closes a Windowsshell:truecommand-injection vector. - CWE-59 —
bin/forgeplan-web.mjs#updatenowlstats.forgeplan-web/beforermSync; refuses to operate when target is a symlink. Plus aresolve()-equality assert as defense-in-depth against future refactors. - CWE-770 —
template/src/shared/server/forgeplan.tsenforces in-process spawn concurrency cap (4 simultaneousforgeplanprocesses). Bounds loopback / LAN-bound DoS surface. - CWE-1357 —
scripts/build.mjs#installRuntimeDepspasses--ignore-scriptstonpm install. Blocks transitive postinstall hooks from baking arbitrary code into publisheddist/node_modules/.
0.1.5 - 2026-05-04
- Marketing-style README rewrite + Russian translation (
README.ru.md). docs/split:USAGE.md(end-user reference) andCONTRIBUTING.md(dev/release flow)..github/assets/hero.png— interactive map screenshot for the npm landing.LICENSE(MIT) added at repo root and declared inpackage.json#files.CHANGELOG.mdadded at repo root and declared inpackage.json#files..claude/rules/00-index.mdnow lists rule 12 (forgeplan-agent-dispatch).
- Demo console block in both READMEs aligned to the actual
initoutput. - Cross-platform CI badge URL switched from relative to absolute (renders on the npm registry landing page, not only on GitHub).
0.1.4 - 2026-05-04
updatesubcommand onbin/forgeplan-web.mjs— refreshes./.forgeplan-web/to the version bundled with the currently-resolved package, preservingworkspaceRootandcreatedAt.- Feature-Sliced Design migration of
template/src/:app/,pages/,widgets/,entities/,shared/replace the oldlib/layout. - Five graph view modes — Force, Lanes, Matrix, Radial, Tree —
in
template/src/widgets/dependency-graph/ui/. - New
/api/blindspotsand/api/journalread-only endpoints. .claude/rules/12-forgeplan-agent-dispatch.mdandADR-002covering parallel sub-agent coordination viaforgeplan_dispatch/forgeplan_claim.- Skill scaffolds for
feature-sliced-design,svelte-code-writer,svelte-core-bestpracticesunder.claude/skills/. scripts/dev.mjsentry point for the SvelteKit HMR loop.CLAUDE.mdrewrite: 8 RED LINES, Routing table, Forge Mode permission zones, EvidencePack Structured Fields example, Git workflow section, Reference (guides) block linking toguides/INDEX.md.guides/CLAUDE-MD-GUIDE.ru.mdandguides/GIT-FLOW-GUIDE.ru.md(authored methodological guides bundled into the repo).- GitHub Branch Protection on
main+develop(PR required, 3-OS smoke required, force-push blocked, delete blocked,enforce_admins: true). - Rulesets:
release-branches-no-force-push(id 15928537) andtag-protection-semver(id 15928584, target=tag, rules=update+deletion).
- Windows CI was red since day 1.
spawnSync('npm', ...)andspawn('forgeplan', ...)could not invoke.cmdshims viaCreateProcesswithoutshell: true. Addedshell: process.platform === 'win32'to the build pipeline (scripts/build.mjs), the bin probe (bin/forgeplan-web.mjs), and the live server driver (template/src/shared/server/forgeplan.ts). Smoke matrix is now green onubuntu-latest×macos-latest×windows-latest× Node 22. scripts/smoke.mjscleanup:await server.exitbeforermSync, plusmaxRetries: 20/retryDelay: 100on Windows to avoidEBUSYon the scratch dir held by the still-exiting child process.forge-safety-hook.sh: false-positive guard for text-only commands (git commit -m,git log,gh pr create,echo,printf,cat <<) so dangerous-looking strings inside commit messages and HEREDOC bodies no longer trigger the block. New explicit blocks forforgeplan init --force(overrideFORGE_ALLOW_INIT_FORCE=1) and direct push tomain/master/develop/release/*(overrideFORGE_ALLOW_PUSH_TO_PROTECTED=1).
actions/checkout@v4→@v5andactions/setup-node@v4→@v5in bothsmoke.ymlandrelease.yml(Node 24 default ahead of the June 2026 Node 20 deprecation).package.json#scripts.devnow points toscripts/dev.mjs(was inlinecd template && npm run dev).
0.1.3 - 2026-05-04
- Cross-platform smoke matrix workflow (
.github/workflows/smoke.yml) onubuntu-latest,macos-latest,windows-latest× Node 22. .forgeplan/workspace seeded with the package's own decision artifacts (RFC-001, ADR-001, EvidencePacks).
0.1.2 - 2026-05-04
repositorymetadata inpackage.jsonfor sigstore provenance — required bynpm publish --provenanceto attach the build's source revision to the signed artifact.
0.1.1 - 2026-05-04
Initial public release attempt. The Windows runtime path was silently broken end-to-end and CI matrix was red — both addressed in 0.1.4.
0.1.0 - 2026-04-04
Internal release. Pre-built SvelteKit app published as a single
@forgeplan/web npm package, scaffolded into .forgeplan-web/ of the
host project via npx @forgeplan/web init -y. No npm install at user
side: dist/ ships its own node_modules/ populated with
--omit=dev --omit=peer.