From 487cc84a6a8342b4fb09f3cd216548c98f1e46c4 Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 10:38:04 +0300 Subject: [PATCH 01/43] feat(a11y): add +error.svelte SvelteKit error boundary (FR-001) Any throw in load/render or in a (e.g. one of 8 pollers on HomePage) used to crash to the default unstyled SvelteKit error page. New +error.svelte renders a styled fallback: large status code in --accent, error message, monospace Go home link. Reuses CSS tokens from app/styles/app.css; no new deps; Svelte 5 runes ( from $app/state). Refs: PRD-003 --- template/src/routes/+error.svelte | 87 +++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 template/src/routes/+error.svelte diff --git a/template/src/routes/+error.svelte b/template/src/routes/+error.svelte new file mode 100644 index 0000000..e1b70b0 --- /dev/null +++ b/template/src/routes/+error.svelte @@ -0,0 +1,87 @@ + + + +
+
+

forgeplan-web

+

{page.status}

+

{page.error?.message ?? 'Unexpected error'}

+ + + Go home + +
+
+ + From 51edb7afdad4ce88a3da2251b3cbc7616c61afe4 Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 10:38:12 +0300 Subject: [PATCH 02/43] fix(a11y): role=img on graph SVGs replaces role=application (FR-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 5 graph views (Force/Lanes/Matrix/Radial/Tree) declared role=application — screen readers treat that as do not interpret content. Today these views have only click interaction; role=img with descriptive aria-label is the conformant semantic. If we ever add custom keyboard pan/zoom to ForceView, that one can flip back. aria-label per view names the layout: Force-directed dependency graph, Lanes view by status, Adjacency matrix, Radial hierarchy, Tree hierarchy. Refs: PRD-003 (FR-002) --- .../widgets/dependency-graph/ui/ForceView.svelte | 13 ++++++++++--- .../widgets/dependency-graph/ui/LanesView.svelte | 5 +++-- .../widgets/dependency-graph/ui/MatrixView.svelte | 5 +++-- .../widgets/dependency-graph/ui/RadialView.svelte | 5 +++-- .../src/widgets/dependency-graph/ui/TreeView.svelte | 7 ++++--- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/template/src/widgets/dependency-graph/ui/ForceView.svelte b/template/src/widgets/dependency-graph/ui/ForceView.svelte index f8f5a99..c06d4df 100644 --- a/template/src/widgets/dependency-graph/ui/ForceView.svelte +++ b/template/src/widgets/dependency-graph/ui/ForceView.svelte @@ -24,6 +24,7 @@ import { CHAR_W, NODE_H, NODE_PAD_X } from '@/widgets/dependency-graph/lib/sizing'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationClass } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; interface Node extends SimulationNodeDatum { id: string; @@ -235,6 +236,12 @@ .alphaDecay(0.025) .on('tick', bumpTick); + // FR-004 (PRD-003): respect prefers-reduced-motion — settle simulation fast + // instead of long-running spring animation. + if (motionDuration(300) === 0) { + sim.alphaDecay(0.1).alphaMin(0.05); + } + // Pre-settle synchronously so the first paint already shows nodes // arranged near the canvas centre instead of the (0,0) phyllotaxis seed. // sim.tick(N) does NOT fire 'tick' listeners (by design in d3-force), so @@ -296,7 +303,7 @@ export function resetZoom() { if (!svgEl || !zoomBehavior) return; - select(svgEl).transition().duration(300).call(zoomBehavior.transform, zoomIdentity); + select(svgEl).transition().duration(motionDuration(300)).call(zoomBehavior.transform, zoomIdentity); } function endpoint(p: Node | string | undefined): Node | null { @@ -312,8 +319,8 @@ diff --git a/template/src/widgets/dependency-graph/ui/LanesView.svelte b/template/src/widgets/dependency-graph/ui/LanesView.svelte index fcd2010..bc6029f 100644 --- a/template/src/widgets/dependency-graph/ui/LanesView.svelte +++ b/template/src/widgets/dependency-graph/ui/LanesView.svelte @@ -12,6 +12,7 @@ import { CHAR_W, NODE_H, NODE_PAD_X } from '@/widgets/dependency-graph/lib/sizing'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationClass } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; let { nodes = [], @@ -188,7 +189,7 @@ const tx = (viewportW - layout.width * k) / 2; const ty = (viewportH - layout.height * k) / 2; const target = zoomIdentity.translate(tx, ty).scale(k); - const sel = animated ? select(svgEl).transition().duration(300) : select(svgEl); + const sel = animated ? select(svgEl).transition().duration(motionDuration(300)) : select(svgEl); sel.call(zoomBehavior.transform, target); } @@ -225,7 +226,7 @@ } - + diff --git a/template/src/widgets/dependency-graph/ui/MatrixView.svelte b/template/src/widgets/dependency-graph/ui/MatrixView.svelte index f62a81f..dd0a2bc 100644 --- a/template/src/widgets/dependency-graph/ui/MatrixView.svelte +++ b/template/src/widgets/dependency-graph/ui/MatrixView.svelte @@ -9,6 +9,7 @@ import type { ScoreEntry } from '@/entities/score'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationFill } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; let { nodes = [], @@ -87,7 +88,7 @@ const tx = (viewportW - totalW * k) / 2; const ty = (viewportH - totalH * k) / 2; const target = zoomIdentity.translate(tx, ty).scale(k); - const sel = animated ? select(svgEl).transition().duration(300) : select(svgEl); + const sel = animated ? select(svgEl).transition().duration(motionDuration(300)) : select(svgEl); sel.call(zoomBehavior.transform, target); } @@ -126,7 +127,7 @@ const selectedRow = $derived(selectedId ? indexById.get(selectedId) ?? -1 : -1); - + FROM \ TO diff --git a/template/src/widgets/dependency-graph/ui/RadialView.svelte b/template/src/widgets/dependency-graph/ui/RadialView.svelte index a84ee5b..6c769d2 100644 --- a/template/src/widgets/dependency-graph/ui/RadialView.svelte +++ b/template/src/widgets/dependency-graph/ui/RadialView.svelte @@ -11,6 +11,7 @@ import { CHAR_W, NODE_H, NODE_PAD_X } from '@/widgets/dependency-graph/lib/sizing'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationClass } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; let { nodes = [], @@ -185,7 +186,7 @@ const tx = (viewportW - w * k) / 2; const ty = (viewportH - h * k) / 2; const target = zoomIdentity.translate(tx, ty).scale(k); - const sel = animated ? select(svgEl).transition().duration(300) : select(svgEl); + const sel = animated ? select(svgEl).transition().duration(motionDuration(300)) : select(svgEl); sel.call(zoomBehavior.transform, target); } @@ -222,7 +223,7 @@ } - + diff --git a/template/src/widgets/dependency-graph/ui/TreeView.svelte b/template/src/widgets/dependency-graph/ui/TreeView.svelte index d7cb57b..7783f3e 100644 --- a/template/src/widgets/dependency-graph/ui/TreeView.svelte +++ b/template/src/widgets/dependency-graph/ui/TreeView.svelte @@ -11,6 +11,7 @@ import { CHAR_W, NODE_H, NODE_PAD_X } from '@/widgets/dependency-graph/lib/sizing'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationClass } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; let { nodes = [], @@ -212,7 +213,7 @@ const tx = (viewportW - layout.width * k) / 2; const ty = (viewportH - layout.height * k) / 2; const target = zoomIdentity.translate(tx, ty).scale(k); - const sel = animated ? select(svgEl).transition().duration(300) : select(svgEl); + const sel = animated ? select(svgEl).transition().duration(motionDuration(300)) : select(svgEl); sel.call(zoomBehavior.transform, target); } @@ -258,8 +259,8 @@ From bc7c4f52f0c9b37c6ce3fcbd5417fb1cee7c6e08 Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 10:38:40 +0300 Subject: [PATCH 03/43] fix(a11y): prefers-reduced-motion shared helper + ForceView guard (FR-004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New shared helper template/src/widgets/dependency-graph/lib/reduced-motion.ts exports motionDuration(defaultMs) — returns 0 when window.matchMedia('(prefers-reduced-motion: reduce)') matches, otherwise the default. SSR-safe (window guard). Note: imports + .transition().duration(motionDuration(300)) call-site wrapping in 5 view files landed together with the role=img swap (51edb7a) because the same files were touched. Keeping that as a single revertable surface for the views; this commit owns the helper + ForceView simulation guard (alphaDecay(0.1).alphaMin(0.05) when reduce-motion is set). Refs: PRD-003 (FR-004) --- .../src/widgets/dependency-graph/lib/reduced-motion.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 template/src/widgets/dependency-graph/lib/reduced-motion.ts diff --git a/template/src/widgets/dependency-graph/lib/reduced-motion.ts b/template/src/widgets/dependency-graph/lib/reduced-motion.ts new file mode 100644 index 0000000..8ac722b --- /dev/null +++ b/template/src/widgets/dependency-graph/lib/reduced-motion.ts @@ -0,0 +1,9 @@ +// FR-004 (PRD-003): single source of truth for the prefers-reduced-motion +// preference. Returns 0 (no animation) when reduce is set, otherwise the +// passed default. Safe on server: matchMedia is window-only, so we guard. +export function motionDuration(defaultMs: number): number { + if (typeof window === "undefined") return defaultMs; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? 0 + : defaultMs; +} From b52b0204e446c54c6174aaf066c8549702a4d5d1 Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 10:38:50 +0300 Subject: [PATCH 04/43] fix(a11y): nested button replaces li role=button in InsightsRail (FR-003) Two row patterns in InsightsRail (recent activity at L107 and agents claim card at L133) used
  • with svelte-ignore a11y_no_noninteractive_element_to_interactive_role. That suppresses the rule rather than fixing it; native focus order was masked. Refactored to
  • . Inner button strips browser defaults (background:transparent, border:0, padding:0, font:inherit, etc.) and carries hover/focus styles. .row.card uses :has(.row-trigger:hover/:focus-visible) on the outer
  • to keep card-outline accent. Removed both svelte-ignore directives, the rowKey helper, and all tabindex=0. Refs: PRD-003 (FR-003) --- .../insights-rail/ui/InsightsRail.svelte | 130 ++++++++++-------- 1 file changed, 72 insertions(+), 58 deletions(-) diff --git a/template/src/widgets/insights-rail/ui/InsightsRail.svelte b/template/src/widgets/insights-rail/ui/InsightsRail.svelte index 56c2aa1..8df2b0a 100644 --- a/template/src/widgets/insights-rail/ui/InsightsRail.svelte +++ b/template/src/widgets/insights-rail/ui/InsightsRail.svelte @@ -78,13 +78,6 @@ function selectId(id: string) { onSelect?.({ id }); } - - function rowKey(ev: KeyboardEvent, id: string) { - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - selectId(id); - } - }