From 73dd385b41c689e90aa1cfdd07185f1988a51214 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Mon, 27 Apr 2026 22:14:44 +0300 Subject: [PATCH 01/10] feat(core): add ResponsePathAction discriminated union + deriveResponsePathAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure mapping from a state item's response-path to a domain action. Exhaustive on ProcessStateResponsePath. Returns 'unsupported' for paths with no current Azure surface (monitor → informational, MSA → planned). Phase 2 V2 PR #4, Task 5. Co-Authored-By: ruflo --- .../src/__tests__/responsePathAction.test.ts | 106 ++++++++++++++++++ packages/core/src/index.ts | 5 + packages/core/src/responsePathAction.ts | 53 +++++++++ 3 files changed, 164 insertions(+) create mode 100644 packages/core/src/__tests__/responsePathAction.test.ts create mode 100644 packages/core/src/responsePathAction.ts diff --git a/packages/core/src/__tests__/responsePathAction.test.ts b/packages/core/src/__tests__/responsePathAction.test.ts new file mode 100644 index 000000000..176dbac45 --- /dev/null +++ b/packages/core/src/__tests__/responsePathAction.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import type { ProcessStateItem } from '../processState'; +import { deriveResponsePathAction } from '../responsePathAction'; + +const baseItem = (overrides: Partial = {}): ProcessStateItem => ({ + id: 'item-1', + lens: 'outcome', + severity: 'amber', + responsePath: 'monitor', + source: 'review-signal', + label: 'Item label', + ...overrides, +}); + +const DEFAULT_ID = 'inv-default'; + +describe('deriveResponsePathAction', () => { + it('returns unsupported/informational for monitor', () => { + const action = deriveResponsePathAction(baseItem({ responsePath: 'monitor' }), DEFAULT_ID); + expect(action).toEqual({ kind: 'unsupported', reason: 'informational' }); + }); + + it('returns unsupported/planned for measurement-system-work', () => { + const action = deriveResponsePathAction( + baseItem({ responsePath: 'measurement-system-work' }), + DEFAULT_ID + ); + expect(action).toEqual({ kind: 'unsupported', reason: 'planned' }); + }); + + it('maps quick-action to open-investigation/quick using defaultInvestigationId', () => { + const action = deriveResponsePathAction(baseItem({ responsePath: 'quick-action' }), DEFAULT_ID); + expect(action).toEqual({ + kind: 'open-investigation', + investigationId: DEFAULT_ID, + intent: 'quick', + }); + }); + + it('maps focused-investigation to open-investigation/focused', () => { + const action = deriveResponsePathAction( + baseItem({ responsePath: 'focused-investigation' }), + DEFAULT_ID + ); + expect(action).toEqual({ + kind: 'open-investigation', + investigationId: DEFAULT_ID, + intent: 'focused', + }); + }); + + it('maps chartered-project to open-investigation/chartered', () => { + const action = deriveResponsePathAction( + baseItem({ responsePath: 'chartered-project' }), + DEFAULT_ID + ); + expect(action).toEqual({ + kind: 'open-investigation', + investigationId: DEFAULT_ID, + intent: 'chartered', + }); + }); + + it('maps sustainment-review to open-sustainment/review', () => { + const action = deriveResponsePathAction( + baseItem({ responsePath: 'sustainment-review' }), + DEFAULT_ID + ); + expect(action).toEqual({ + kind: 'open-sustainment', + investigationId: DEFAULT_ID, + surface: 'review', + }); + }); + + it('maps control-handoff to open-sustainment/handoff', () => { + const action = deriveResponsePathAction( + baseItem({ responsePath: 'control-handoff' }), + DEFAULT_ID + ); + expect(action).toEqual({ + kind: 'open-sustainment', + investigationId: DEFAULT_ID, + surface: 'handoff', + }); + }); + + it('uses item.investigationIds[0] when present (queue items)', () => { + const action = deriveResponsePathAction( + baseItem({ + responsePath: 'focused-investigation', + investigationIds: ['inv-from-item', 'inv-other'], + }), + DEFAULT_ID + ); + expect(action).toMatchObject({ kind: 'open-investigation', investigationId: 'inv-from-item' }); + }); + + it('falls back to defaultInvestigationId when item.investigationIds is empty', () => { + const action = deriveResponsePathAction( + baseItem({ responsePath: 'focused-investigation', investigationIds: [] }), + DEFAULT_ID + ); + expect(action).toMatchObject({ kind: 'open-investigation', investigationId: DEFAULT_ID }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index beab910ae..aa929f6e6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,9 @@ export type { DataCellValue, DataRow } from './types'; export { isNumericValue, isStringValue, toNumericValue, inferCharacteristicType } from './types'; +// Utility helpers +export { assertNever } from './types'; + // Types - Statistics and Analysis export type { StatsResult, @@ -499,6 +502,8 @@ export type { ProcessStateSeverity, ProcessStateSource, } from './processState'; +export { deriveResponsePathAction } from './responsePathAction'; +export type { ResponsePathAction } from './responsePathAction'; // Sustainment (Phase 6) export type { diff --git a/packages/core/src/responsePathAction.ts b/packages/core/src/responsePathAction.ts new file mode 100644 index 000000000..a2100fac2 --- /dev/null +++ b/packages/core/src/responsePathAction.ts @@ -0,0 +1,53 @@ +import type { ProcessStateItem, ProcessStateResponsePath } from './processState'; +import { assertNever } from './types'; + +export type ResponsePathAction = + | { + kind: 'open-investigation'; + investigationId: string; + intent: 'focused' | 'chartered' | 'quick'; + } + | { + kind: 'open-sustainment'; + investigationId: string; + surface: 'review' | 'handoff'; + } + | { kind: 'unsupported'; reason: 'planned' | 'informational' }; + +/** + * Pure mapping from a state item's response-path to a domain action. + * Exhaustive on ProcessStateResponsePath. Returns 'unsupported' for paths + * with no current Azure surface — those render as 'Planned' / 'Informational' + * pills rather than fallback-routing. + * + * For items without their own investigation linkage, the caller passes + * defaultInvestigationId. The Dashboard's heuristic for choosing the + * default lives in the Dashboard (typically the rollup's most-recently- + * updated investigation). + */ +export function deriveResponsePathAction( + item: ProcessStateItem, + defaultInvestigationId: string +): ResponsePathAction { + const investigationId = item.investigationIds?.[0] ?? defaultInvestigationId; + const path: ProcessStateResponsePath = item.responsePath; + + switch (path) { + case 'monitor': + return { kind: 'unsupported', reason: 'informational' }; + case 'measurement-system-work': + return { kind: 'unsupported', reason: 'planned' }; + case 'quick-action': + return { kind: 'open-investigation', investigationId, intent: 'quick' }; + case 'focused-investigation': + return { kind: 'open-investigation', investigationId, intent: 'focused' }; + case 'chartered-project': + return { kind: 'open-investigation', investigationId, intent: 'chartered' }; + case 'sustainment-review': + return { kind: 'open-sustainment', investigationId, surface: 'review' }; + case 'control-handoff': + return { kind: 'open-sustainment', investigationId, surface: 'handoff' }; + default: + return assertNever(path); + } +} From ca175307083011941c60e4151fa8744a3bee3a59 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Mon, 27 Apr 2026 22:22:47 +0300 Subject: [PATCH 02/10] test(core): add compile-time exhaustiveness guard for deriveResponsePathAction Adds a @ts-expect-error test that fails to compile if a future ProcessStateResponsePath variant is added without a matching case in deriveResponsePathAction. Phase 2 V2 PR #4, code-review followup. Co-Authored-By: ruflo --- .../core/src/__tests__/responsePathAction.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/core/src/__tests__/responsePathAction.test.ts b/packages/core/src/__tests__/responsePathAction.test.ts index 176dbac45..da46a6a28 100644 --- a/packages/core/src/__tests__/responsePathAction.test.ts +++ b/packages/core/src/__tests__/responsePathAction.test.ts @@ -103,4 +103,16 @@ describe('deriveResponsePathAction', () => { ); expect(action).toMatchObject({ kind: 'open-investigation', investigationId: DEFAULT_ID }); }); + + it('exhaustive switch — adding a new ProcessStateResponsePath without a case is a compile error', () => { + // The @ts-expect-error below asserts that passing a plain string (not a valid + // ProcessStateResponsePath) to deriveResponsePathAction is a type error. + // If this directive becomes "unused" (i.e. tsc stops erroring here), it means + // the function's signature was widened unexpectedly — investigate before suppressing. + // At runtime the invalid value hits assertNever and throws — that is expected behaviour. + expect(() => + // @ts-expect-error — 'not-a-real-response-path' is not assignable to ProcessStateResponsePath + deriveResponsePathAction(baseItem({ responsePath: 'not-a-real-response-path' }), DEFAULT_ID) + ).toThrow(); + }); }); From a3b4ce19e1550b1f1ec5436012e043d3e6bce3ae Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Mon, 27 Apr 2026 22:29:53 +0300 Subject: [PATCH 03/10] feat(azure): add actionToHref URL adapter for ResponsePathAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin Azure-side adapter mapping ResponsePathAction discriminated union to URL strings. Single URL source — adding a new ResponsePathAction variant in core triggers a TS build error here via the exhaustive switch. Phase 2 V2 PR #4, Task 7. Co-Authored-By: ruflo --- .../__tests__/processHubRoutes.test.ts | 94 +++++++++++++++++++ apps/azure/src/routing/processHubRoutes.ts | 20 ++++ 2 files changed, 114 insertions(+) create mode 100644 apps/azure/src/routing/__tests__/processHubRoutes.test.ts create mode 100644 apps/azure/src/routing/processHubRoutes.ts diff --git a/apps/azure/src/routing/__tests__/processHubRoutes.test.ts b/apps/azure/src/routing/__tests__/processHubRoutes.test.ts new file mode 100644 index 000000000..79e948f43 --- /dev/null +++ b/apps/azure/src/routing/__tests__/processHubRoutes.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { ResponsePathAction } from '@variscout/core'; +import { actionToHref } from '../processHubRoutes'; + +describe('actionToHref', () => { + it('returns null for unsupported actions', () => { + const action: ResponsePathAction = { kind: 'unsupported', reason: 'planned' }; + expect(actionToHref(action)).toBeNull(); + }); + + it('returns null for unsupported/informational', () => { + const action: ResponsePathAction = { kind: 'unsupported', reason: 'informational' }; + expect(actionToHref(action)).toBeNull(); + }); + + it('builds /editor/:id?intent=focused for open-investigation/focused', () => { + const action: ResponsePathAction = { + kind: 'open-investigation', + investigationId: 'inv-123', + intent: 'focused', + }; + expect(actionToHref(action)).toBe('/editor/inv-123?intent=focused'); + }); + + it('builds /editor/:id?intent=chartered for open-investigation/chartered', () => { + const action: ResponsePathAction = { + kind: 'open-investigation', + investigationId: 'inv-abc', + intent: 'chartered', + }; + expect(actionToHref(action)).toBe('/editor/inv-abc?intent=chartered'); + }); + + it('builds /editor/:id?intent=quick for open-investigation/quick', () => { + const action: ResponsePathAction = { + kind: 'open-investigation', + investigationId: 'inv-q', + intent: 'quick', + }; + expect(actionToHref(action)).toBe('/editor/inv-q?intent=quick'); + }); + + it('builds /editor/:id/sustainment for open-sustainment/review', () => { + const action: ResponsePathAction = { + kind: 'open-sustainment', + investigationId: 'inv-s', + surface: 'review', + }; + expect(actionToHref(action)).toBe('/editor/inv-s/sustainment'); + }); + + it('builds /editor/:id/sustainment?surface=handoff for open-sustainment/handoff', () => { + const action: ResponsePathAction = { + kind: 'open-sustainment', + investigationId: 'inv-h', + surface: 'handoff', + }; + expect(actionToHref(action)).toBe('/editor/inv-h/sustainment?surface=handoff'); + }); + + it('snapshot — URL shapes are stable', () => { + expect({ + focused: actionToHref({ + kind: 'open-investigation', + investigationId: 'X', + intent: 'focused', + }), + chartered: actionToHref({ + kind: 'open-investigation', + investigationId: 'X', + intent: 'chartered', + }), + quick: actionToHref({ kind: 'open-investigation', investigationId: 'X', intent: 'quick' }), + sustainmentReview: actionToHref({ + kind: 'open-sustainment', + investigationId: 'X', + surface: 'review', + }), + sustainmentHandoff: actionToHref({ + kind: 'open-sustainment', + investigationId: 'X', + surface: 'handoff', + }), + unsupportedPlanned: actionToHref({ kind: 'unsupported', reason: 'planned' }), + unsupportedInfo: actionToHref({ kind: 'unsupported', reason: 'informational' }), + }).toMatchSnapshot(); + }); + + it('exhaustive switch — adding a new ResponsePathAction kind without a case is a compile error', () => { + // @ts-expect-error — if this stops erroring, a new ResponsePathAction kind + // was added in @variscout/core without a matching case in actionToHref. + expect(() => actionToHref({ kind: 'not-a-real-kind' } as ResponsePathAction)).toThrow(); + }); +}); diff --git a/apps/azure/src/routing/processHubRoutes.ts b/apps/azure/src/routing/processHubRoutes.ts new file mode 100644 index 000000000..96349d1f8 --- /dev/null +++ b/apps/azure/src/routing/processHubRoutes.ts @@ -0,0 +1,20 @@ +import { assertNever, type ResponsePathAction } from '@variscout/core'; + +/** + * Single URL source for ProcessHub state-item actions. + * Exhaustive switch on action.kind. Returns null for 'unsupported' actions. + */ +export function actionToHref(action: ResponsePathAction): string | null { + switch (action.kind) { + case 'unsupported': + return null; + case 'open-investigation': + return `/editor/${action.investigationId}?intent=${action.intent}`; + case 'open-sustainment': { + const base = `/editor/${action.investigationId}/sustainment`; + return action.surface === 'handoff' ? `${base}?surface=handoff` : base; + } + default: + return assertNever(action); + } +} From d0f4d119247c85e9bca58c6b5caa5f562f09e0f8 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Mon, 27 Apr 2026 22:35:52 +0300 Subject: [PATCH 04/10] refactor(azure): move processHubRoutes from routing/ to lib/ Honors apps/azure/CLAUDE.md FSD rule: 'Don't introduce new top-level directories. Feature-Sliced Design: features/, hooks/, components/, services/, auth/, db/, lib/.' Phase 2 V2 PR #4, code-review followup. Co-Authored-By: ruflo --- .../__snapshots__/processHubRoutes.test.ts.snap | 13 +++++++++++++ .../__tests__/processHubRoutes.test.ts | 0 apps/azure/src/{routing => lib}/processHubRoutes.ts | 0 3 files changed, 13 insertions(+) create mode 100644 apps/azure/src/lib/__tests__/__snapshots__/processHubRoutes.test.ts.snap rename apps/azure/src/{routing => lib}/__tests__/processHubRoutes.test.ts (100%) rename apps/azure/src/{routing => lib}/processHubRoutes.ts (100%) diff --git a/apps/azure/src/lib/__tests__/__snapshots__/processHubRoutes.test.ts.snap b/apps/azure/src/lib/__tests__/__snapshots__/processHubRoutes.test.ts.snap new file mode 100644 index 000000000..e36f1a5f4 --- /dev/null +++ b/apps/azure/src/lib/__tests__/__snapshots__/processHubRoutes.test.ts.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`actionToHref > snapshot — URL shapes are stable 1`] = ` +{ + "chartered": "/editor/X?intent=chartered", + "focused": "/editor/X?intent=focused", + "quick": "/editor/X?intent=quick", + "sustainmentHandoff": "/editor/X/sustainment?surface=handoff", + "sustainmentReview": "/editor/X/sustainment", + "unsupportedInfo": null, + "unsupportedPlanned": null, +} +`; diff --git a/apps/azure/src/routing/__tests__/processHubRoutes.test.ts b/apps/azure/src/lib/__tests__/processHubRoutes.test.ts similarity index 100% rename from apps/azure/src/routing/__tests__/processHubRoutes.test.ts rename to apps/azure/src/lib/__tests__/processHubRoutes.test.ts diff --git a/apps/azure/src/routing/processHubRoutes.ts b/apps/azure/src/lib/processHubRoutes.ts similarity index 100% rename from apps/azure/src/routing/processHubRoutes.ts rename to apps/azure/src/lib/processHubRoutes.ts From aefb1366cf392c86de1a04974c802b9db971f023 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Mon, 27 Apr 2026 22:40:19 +0300 Subject: [PATCH 05/10] feat(azure): add safeTrackEvent wrapper for no-PII telemetry events Try/catch'd facade over App Insights trackEvent. Silently swallows failures so telemetry can never block UX. Caller is responsible for ADR-059 no-PII compliance in the payload (enum values, opaque IDs, integers only). Phase 2 V2 PR #4, Task 8. Co-Authored-By: ruflo --- apps/azure/src/lib/appInsights.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/azure/src/lib/appInsights.ts b/apps/azure/src/lib/appInsights.ts index ac24cea10..67f1966dc 100644 --- a/apps/azure/src/lib/appInsights.ts +++ b/apps/azure/src/lib/appInsights.ts @@ -110,6 +110,28 @@ export function trackException(error: Error, severityLevel?: number): void { appInsights?.trackException({ exception: error, severityLevel }); } +/** + * Safe wrapper around App Insights' trackEvent. + * + * Telemetry must NEVER block UX. If App Insights is unavailable (local dev, + * SDK not loaded, transient failure), this silently swallows the error. + * + * Per ADR-059, payload MUST NOT contain PII (no labels, names, descriptions, + * customer text, raw column names). Stick to enum values, hashed/opaque IDs, + * and integers. + */ +export function safeTrackEvent( + name: string, + properties: Record +): void { + if (!appInsights) return; + try { + appInsights.trackEvent({ name }, properties); + } catch { + // Telemetry failure is never load-bearing. + } +} + /** * Flush pending AI traces to Application Insights as custom events. * Reads from the in-memory trace buffer and sends only new traces From f73b8f3081533cdb9713c26a6df4076d354e3ca5 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Mon, 27 Apr 2026 22:50:50 +0300 Subject: [PATCH 06/10] refactor(ui): require actions + evidence contracts on ProcessHubCurrentStatePanel Refactors panel to take 3 required props (state, actions, evidence) instead of 1 (state). State-item cards become clickable affordances when the action is supported; 'monitor' and 'measurement-system-work' paths render as 'Informational' / 'Planned' pills with no click affordance. evidence contract is unused in this PR (passes through stubbed); PR #5 wires the chip count + click. Per feedback_no_backcompat_clean_architecture memory: required props by default. The Azure consumer (ProcessHubReviewPanel) is refactored in the next subagent task to pass these contracts. Adds keyboard activation (Enter/Space), aria-label, and tooltip text on planned/informational cards. 8 existing tests rewired + 6 new action-behavior tests = 14 tests. Phase 2 V2 PR #4, Tasks 9-10. Co-Authored-By: ruflo --- .../ProcessHubCurrentStatePanel.tsx | 90 +++++++- .../ProcessHubCurrentStatePanel.test.tsx | 207 +++++++++++++++++- 2 files changed, 285 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx index 1c66e600d..7b7c790cc 100644 --- a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx @@ -2,15 +2,30 @@ import React from 'react'; import { Activity, Gauge, GitBranch, Layers3, ShieldCheck } from 'lucide-react'; import type { CurrentProcessState, + Finding, ProcessStateItem, ProcessStateLens, ProcessStateResponsePath, ProcessStateSeverity, + ResponsePathAction, } from '@variscout/core'; +import { assertNever } from '@variscout/core'; import { formatPlural, formatStatistic } from '@variscout/core/i18n'; +export interface ProcessHubActionsContract { + actionFor: (item: ProcessStateItem) => ResponsePathAction; + onInvoke: (item: ProcessStateItem, action: ResponsePathAction) => void; +} + +export interface ProcessHubEvidenceContract { + findingsFor: (item: ProcessStateItem) => readonly Finding[]; + onChipClick: (item: ProcessStateItem, findings: readonly Finding[]) => void; +} + export interface ProcessHubCurrentStatePanelProps { state: CurrentProcessState; + actions: ProcessHubActionsContract; + evidence: ProcessHubEvidenceContract; } const LENS_LABELS: Record = { @@ -55,6 +70,16 @@ const LENS_ICONS: Record = { const LENSES: ProcessStateLens[] = ['outcome', 'flow', 'conversion', 'measurement', 'sustainment']; +const UNSUPPORTED_PILL_LABEL: Record<'planned' | 'informational', string> = { + planned: 'Planned', + informational: 'Informational', +}; + +const UNSUPPORTED_TOOLTIP: Record<'planned' | 'informational', string> = { + planned: 'This response path is planned for a future horizon.', + informational: 'No action needed — this item is informational only.', +}; + const formatMetric = (value: number): string => formatStatistic(value, 'en', 2); const formatChangeSignals = (count: number): string => @@ -92,13 +117,62 @@ const formatStateDetail = (item: ProcessStateItem): string | null => { return item.detail ?? null; }; -const StateItemCard: React.FC<{ item: ProcessStateItem }> = ({ item }) => { +const StateItemCard: React.FC<{ + item: ProcessStateItem; + action: ResponsePathAction; + onInvoke: (item: ProcessStateItem, action: ResponsePathAction) => void; +}> = ({ item, action, onInvoke }) => { const detail = formatStateDetail(item); + const isSupported = action.kind !== 'unsupported'; + + const handleActivate = () => { + if (isSupported) onInvoke(item, action); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!isSupported) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onInvoke(item, action); + } + }; + + let unsupportedReason: 'planned' | 'informational' | null = null; + let tooltipText: string | undefined; + switch (action.kind) { + case 'unsupported': + unsupportedReason = action.reason; + tooltipText = UNSUPPORTED_TOOLTIP[action.reason]; + break; + case 'open-investigation': + case 'open-sustainment': + tooltipText = undefined; + break; + default: + assertNever(action); + tooltipText = undefined; + } + + const interactiveProps = isSupported + ? { + role: 'button' as const, + tabIndex: 0, + onClick: handleActivate, + onKeyDown: handleKeyDown, + 'aria-label': `${item.label} — ${RESPONSE_LABELS[item.responsePath]}`, + } + : { 'aria-disabled': true as const }; return (
@@ -113,7 +187,8 @@ const StateItemCard: React.FC<{ item: ProcessStateItem }> = ({ item }) => {

- {RESPONSE_LABELS[item.responsePath]} + {RESPONSE_LABELS[item.responsePath]} + {unsupportedReason !== null && · {UNSUPPORTED_PILL_LABEL[unsupportedReason]}}

); @@ -121,6 +196,8 @@ const StateItemCard: React.FC<{ item: ProcessStateItem }> = ({ item }) => { export const ProcessHubCurrentStatePanel: React.FC = ({ state, + actions, + evidence: _evidence, // unused in PR #4 — wired in PR #5 }) => { const visibleItems = state.items.slice(0, 6); const hiddenCount = Math.max(0, state.items.length - visibleItems.length); @@ -148,7 +225,12 @@ export const ProcessHubCurrentStatePanel: React.FC 0 ? (
{visibleItems.map(item => ( - + ))} {hiddenCount > 0 && (

diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx index 0c9fca612..16c755c33 100644 --- a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx @@ -1,6 +1,8 @@ import { render, screen, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; import type { CurrentProcessState, ProcessStateItem, ProcessStateLens } from '@variscout/core'; +import type { ResponsePathAction } from '@variscout/core'; import { ProcessHubCurrentStatePanel } from '../ProcessHubCurrentStatePanel'; const HUB = { @@ -38,9 +40,33 @@ const buildItem = (overrides: Partial = {}): ProcessStateItem ...overrides, }); +const NOOP_ACTION: ResponsePathAction = { kind: 'unsupported', reason: 'informational' }; + +function makeActions( + overrides: { actionFor?: (item: ProcessStateItem) => ResponsePathAction } = {} +) { + const actionFor = overrides.actionFor ?? (() => NOOP_ACTION); + const onInvoke = vi.fn(); + return { actionFor, onInvoke }; +} + +function makeEvidence() { + // Stubbed evidence contract for PR #4 tests; chip behavior is tested in PR #5. + return { + findingsFor: () => [], + onChipClick: vi.fn(), + }; +} + describe('ProcessHubCurrentStatePanel', () => { it('renders the heading and overall severity badge', () => { - render(); + render( + + ); expect(screen.getByTestId('current-process-state')).toBeInTheDocument(); expect(screen.getByText('Current Process State')).toBeInTheDocument(); expect(screen.getByText('Red')).toBeInTheDocument(); @@ -50,7 +76,13 @@ describe('ProcessHubCurrentStatePanel', () => { const state = buildState({ lensCounts: { outcome: 3, flow: 1, conversion: 0, measurement: 2, sustainment: 5 }, }); - render(); + render( + + ); expect(screen.getByTestId('current-state-lens-outcome')).toHaveTextContent('3'); expect(screen.getByTestId('current-state-lens-flow')).toHaveTextContent('1'); expect(screen.getByTestId('current-state-lens-conversion')).toHaveTextContent('0'); @@ -59,7 +91,13 @@ describe('ProcessHubCurrentStatePanel', () => { }); it('shows the empty placeholder when there are no items', () => { - render(); + render( + + ); expect(screen.getByText('No current process state signals yet')).toBeInTheDocument(); }); @@ -67,7 +105,13 @@ describe('ProcessHubCurrentStatePanel', () => { const items = Array.from({ length: 9 }, (_, i) => buildItem({ id: `item-${i}`, label: `Item ${i + 1}` }) ); - render(); + render( + + ); expect(screen.getAllByTestId('current-state-item')).toHaveLength(6); expect(screen.getByText('+3 more current-state items')).toBeInTheDocument(); }); @@ -77,7 +121,13 @@ describe('ProcessHubCurrentStatePanel', () => { lens: 'outcome', metric: { cpk: 1.05, cpkTarget: 1.33 }, }); - render(); + render( + + ); const card = screen.getByTestId('current-state-item'); expect(within(card).getByText(/Cpk 1\.05 vs target 1\.33/)).toBeInTheDocument(); }); @@ -87,20 +137,161 @@ describe('ProcessHubCurrentStatePanel', () => { buildItem({ id: 'a', lens: 'flow', metric: { changeSignalCount: 1 } }), buildItem({ id: 'b', lens: 'flow', metric: { changeSignalCount: 4 } }), ]; - render(); + render( + + ); expect(screen.getByText('1 change signal')).toBeInTheDocument(); expect(screen.getByText('4 change signals')).toBeInTheDocument(); }); it('falls back to item.detail when no metric formatter applies', () => { const item = buildItem({ detail: 'Free-text fallback' }); - render(); + render( + + ); expect(screen.getByText('Free-text fallback')).toBeInTheDocument(); }); it('renders the response path label per item', () => { const item = buildItem({ responsePath: 'chartered-project' }); - render(); + render( + + ); expect(screen.getByText('Chartered project')).toBeInTheDocument(); }); }); + +describe('ProcessHubCurrentStatePanel — actions', () => { + it('fires onInvoke with the supported action when card is clicked', async () => { + const supportedAction: ResponsePathAction = { + kind: 'open-investigation', + investigationId: 'inv-1', + intent: 'focused', + }; + const item = buildItem({ id: 'item-x', responsePath: 'focused-investigation' }); + const actions = makeActions({ actionFor: () => supportedAction }); + + render( + + ); + + const card = screen.getByTestId('current-state-item'); + card.click(); + + expect(actions.onInvoke).toHaveBeenCalledWith(item, supportedAction); + }); + + it('does NOT fire onInvoke for unsupported/planned cards', async () => { + const item = buildItem({ id: 'item-msa', responsePath: 'measurement-system-work' }); + const actions = makeActions({ + actionFor: () => ({ kind: 'unsupported', reason: 'planned' }), + }); + + render( + + ); + + const card = screen.getByTestId('current-state-item'); + card.click(); + + expect(actions.onInvoke).not.toHaveBeenCalled(); + }); + + it('renders Planned pill on cards with unsupported/planned action', () => { + const item = buildItem({ id: 'item-msa', responsePath: 'measurement-system-work' }); + const actions = makeActions({ + actionFor: () => ({ kind: 'unsupported', reason: 'planned' }), + }); + + render( + + ); + + const card = screen.getByTestId('current-state-item'); + expect(within(card).getByText(/Planned/)).toBeInTheDocument(); + }); + + it('renders Informational pill on cards with unsupported/informational action', () => { + const item = buildItem({ id: 'item-mon', responsePath: 'monitor' }); + const actions = makeActions({ + actionFor: () => ({ kind: 'unsupported', reason: 'informational' }), + }); + + render( + + ); + + const card = screen.getByTestId('current-state-item'); + expect(within(card).getByText(/Informational/)).toBeInTheDocument(); + }); + + it('exposes a tooltip-text attribute on Planned cards', () => { + const item = buildItem({ id: 'item-msa', responsePath: 'measurement-system-work' }); + const actions = makeActions({ + actionFor: () => ({ kind: 'unsupported', reason: 'planned' }), + }); + + render( + + ); + + const card = screen.getByTestId('current-state-item'); + expect(card).toHaveAttribute('title', expect.stringMatching(/planned/i)); + }); + + it('makes the supported card keyboard-activatable (Enter key)', () => { + const action: ResponsePathAction = { + kind: 'open-sustainment', + investigationId: 'inv-y', + surface: 'review', + }; + const item = buildItem({ id: 'item-y', responsePath: 'sustainment-review' }); + const actions = makeActions({ actionFor: () => action }); + + render( + + ); + + const card = screen.getByTestId('current-state-item'); + card.focus(); + card.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(actions.onInvoke).toHaveBeenCalledWith(item, action); + }); +}); From db87adf485968723f6bf1c12283d1baecc584cc9 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Mon, 27 Apr 2026 22:57:16 +0300 Subject: [PATCH 07/10] test(ui): add Space-key activation test + a11y TODO on title tooltip Code-review followups for ProcessHubCurrentStatePanel: - Add a Space-key keyboard activation test to mirror the Enter-key test (ARIA button convention). - Document the title-attribute tooltip's accessibility limitation (touch unreachable, screen-reader unreliable per WCAG 1.3.3 / 4.1.2) as an inline TODO. Pill text already conveys the state visually; upgrade to a Tooltip primitive when the design system grows one. Phase 2 V2 PR #4, code-review followup. Co-Authored-By: ruflo --- .../ProcessHubCurrentStatePanel.tsx | 4 ++++ .../ProcessHubCurrentStatePanel.test.tsx | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx index 7b7c790cc..37552aa0f 100644 --- a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx @@ -171,6 +171,10 @@ const StateItemCard: React.FC<{ : '' }`} data-testid="current-state-item" + // title attribute is unreachable on touch and unreliable for screen + // readers (WCAG 1.3.3 / 4.1.2). Visible 'Planned'/'Informational' + // pill text already conveys the state. Replace with a Tooltip / + // Popover primitive when the design system grows one. title={tooltipText} {...interactiveProps} > diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx index 16c755c33..0da004543 100644 --- a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx @@ -294,4 +294,28 @@ describe('ProcessHubCurrentStatePanel — actions', () => { expect(actions.onInvoke).toHaveBeenCalledWith(item, action); }); + + it('makes the supported card keyboard-activatable (Space key) per ARIA button conventions', () => { + const action: ResponsePathAction = { + kind: 'open-investigation', + investigationId: 'inv-z', + intent: 'quick', + }; + const item = buildItem({ id: 'item-z', responsePath: 'quick-action' }); + const actions = makeActions({ actionFor: () => action }); + + render( + + ); + + const card = screen.getByTestId('current-state-item'); + card.focus(); + card.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + + expect(actions.onInvoke).toHaveBeenCalledWith(item, action); + }); }); From f2b924f83892ae6ca7a483466c635a3f2166d63e Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Mon, 27 Apr 2026 23:05:04 +0300 Subject: [PATCH 08/10] feat(azure): wire ProcessHubReviewPanel + Dashboard to ProcessHubCurrentStatePanel actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessHubReviewPanel now passes the required actions + evidence contracts to the panel. Adds defaultInvestigationId memo (most-recent investigation heuristic) and actionFor closure that calls deriveResponsePathAction. Dashboard wires handleResponsePathAction with safeTrackEvent telemetry (hubId/responsePath/lens/severity — no PII per ADR-059) and routes via the existing onOpenProject callback. evidence contract is stubbed in this PR (passes empty findingsFor + no-op onChipClick); PR #5 wires the chip count + click. Phase 2 V2 PR #4, Tasks 11-12. Co-Authored-By: ruflo --- .../src/components/ProcessHubReviewPanel.tsx | 37 +++++++++++++++++-- apps/azure/src/pages/Dashboard.tsx | 27 ++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx index 1ed710725..880295c69 100644 --- a/apps/azure/src/components/ProcessHubReviewPanel.tsx +++ b/apps/azure/src/components/ProcessHubReviewPanel.tsx @@ -1,7 +1,16 @@ import React from 'react'; import { Plus } from 'lucide-react'; -import { buildCurrentProcessState, buildProcessHubCadence } from '@variscout/core'; -import type { ProcessHubInvestigation, ProcessHubRollup } from '@variscout/core'; +import { + buildCurrentProcessState, + buildProcessHubCadence, + deriveResponsePathAction, +} from '@variscout/core'; +import type { + ProcessHubInvestigation, + ProcessHubRollup, + ProcessStateItem, + ResponsePathAction, +} from '@variscout/core'; import { ProcessHubCurrentStatePanel } from '@variscout/ui'; import ProcessHubCadenceQuestions from './ProcessHubCadenceQuestions'; import ProcessHubCadenceQueues from './ProcessHubCadenceQueues'; @@ -14,6 +23,7 @@ interface ProcessHubReviewPanelProps { onSetupSustainment: (investigationId: string) => void; onLogReview: (recordId: string) => void; onRecordHandoff: (investigationId: string) => void; + onResponsePathAction: (item: ProcessStateItem, action: ResponsePathAction) => void; } const SnapshotCard: React.FC<{ @@ -42,9 +52,26 @@ const ProcessHubReviewPanel: React.FC = ({ onSetupSustainment, onLogReview, onRecordHandoff, + onResponsePathAction, }) => { const cadence = buildProcessHubCadence(rollup); const currentState = buildCurrentProcessState(rollup, cadence); + + // Pick the most-recently-modified investigation in this hub as the + // default navigation target for hub-aggregate state items (capability-gap, + // change-signals, top-focus). For per-investigation items, the action + // uses item.investigationIds[0] instead. + const defaultInvestigationId = React.useMemo(() => { + const sorted = [...rollup.investigations].sort((a, b) => + (b.modified ?? '').localeCompare(a.modified ?? '') + ); + return sorted[0]?.id ?? ''; + }, [rollup.investigations]); + + const actionFor = React.useCallback( + (item: ProcessStateItem) => deriveResponsePathAction(item, defaultInvestigationId), + [defaultInvestigationId] + ); const headingId = `process-hub-current-state-${rollup.hub.id}`; return ( @@ -75,7 +102,11 @@ const ProcessHubReviewPanel: React.FC = ({

- + [], onChipClick: () => {} }} + />
= ({ [onOpenProject] ); + const handleResponsePathAction = useCallback( + (item: ProcessStateItem, action: ResponsePathAction) => { + const href = actionToHref(action); + if (!href) return; // unsupported + + safeTrackEvent('process_hub.response_path_click', { + hubId: item.investigationIds?.[0] ?? 'aggregate', + responsePath: item.responsePath, + lens: item.lens, + severity: item.severity, + }); + + // Dashboard already exposes onOpenProject for investigation navigation. + // For now, route through that callback by extracting the investigation + // id from the action. Full URL routing (intent + sustainment surface + // query params) is a follow-up — see plan PR #4 Task 12 note. + if (action.kind === 'open-investigation' || action.kind === 'open-sustainment') { + onOpenProject(action.investigationId); + } + }, + [onOpenProject] + ); + const handleSampleSelect = (sample: SampleDataset): void => { if (onLoadSample) { onLoadSample(sample); @@ -437,6 +463,7 @@ export const Dashboard: React.FC = ({ onSetupSustainment={handleSetupSustainment} onLogReview={handleLogReview} onRecordHandoff={handleRecordHandoff} + onResponsePathAction={handleResponsePathAction} /> Date: Mon, 27 Apr 2026 23:13:08 +0300 Subject: [PATCH 09/10] fix(azure): pass real hubId to telemetry instead of investigation ID ADR-059 forbids logging customer-meaningful identifiers. The previous handler labeled an investigation ID as 'hubId' in the telemetry payload, which is a real leak. Now ProcessHubReviewPanel passes rollup.hub.id (an admin-assigned slug) explicitly to the Dashboard handler. Also: - Add safeTrackEvent to handleResponsePathAction useCallback deps - Document the intentional empty-string defaultInvestigationId fallback Phase 2 V2 PR #4, code-review followup. Co-Authored-By: ruflo --- apps/azure/src/components/ProcessHubReviewPanel.tsx | 10 ++++++++-- apps/azure/src/pages/Dashboard.tsx | 6 +++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx index 880295c69..524052f60 100644 --- a/apps/azure/src/components/ProcessHubReviewPanel.tsx +++ b/apps/azure/src/components/ProcessHubReviewPanel.tsx @@ -23,7 +23,7 @@ interface ProcessHubReviewPanelProps { onSetupSustainment: (investigationId: string) => void; onLogReview: (recordId: string) => void; onRecordHandoff: (investigationId: string) => void; - onResponsePathAction: (item: ProcessStateItem, action: ResponsePathAction) => void; + onResponsePathAction: (item: ProcessStateItem, action: ResponsePathAction, hubId: string) => void; } const SnapshotCard: React.FC<{ @@ -65,6 +65,9 @@ const ProcessHubReviewPanel: React.FC = ({ const sorted = [...rollup.investigations].sort((a, b) => (b.modified ?? '').localeCompare(a.modified ?? '') ); + // Empty fallback when the rollup has no investigations: deriveResponsePathAction + // will then return unsupported actions for hub-aggregate items, which actionToHref + // maps to null, producing a silent no-op (correct UX — nothing to navigate to). return sorted[0]?.id ?? ''; }, [rollup.investigations]); @@ -104,7 +107,10 @@ const ProcessHubReviewPanel: React.FC = ({ onResponsePathAction(item, action, rollup.hub.id), + }} evidence={{ findingsFor: () => [], onChipClick: () => {} }} /> diff --git a/apps/azure/src/pages/Dashboard.tsx b/apps/azure/src/pages/Dashboard.tsx index 136b2d848..2a01d7a26 100644 --- a/apps/azure/src/pages/Dashboard.tsx +++ b/apps/azure/src/pages/Dashboard.tsx @@ -223,12 +223,12 @@ export const Dashboard: React.FC = ({ ); const handleResponsePathAction = useCallback( - (item: ProcessStateItem, action: ResponsePathAction) => { + (item: ProcessStateItem, action: ResponsePathAction, hubId: string) => { const href = actionToHref(action); if (!href) return; // unsupported safeTrackEvent('process_hub.response_path_click', { - hubId: item.investigationIds?.[0] ?? 'aggregate', + hubId, responsePath: item.responsePath, lens: item.lens, severity: item.severity, @@ -242,7 +242,7 @@ export const Dashboard: React.FC = ({ onOpenProject(action.investigationId); } }, - [onOpenProject] + [onOpenProject, safeTrackEvent] ); const handleSampleSelect = (sample: SampleDataset): void => { From 0ceddcfa31d2711aff687c3f36aba46050aa3545 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Mon, 27 Apr 2026 23:19:50 +0300 Subject: [PATCH 10/10] chore(core): commit assertNever helper that powers exhaustive switches The assertNever function was added to types.ts and exported from index.ts as part of the ResponsePathAction work, but the types.ts change was inadvertently left uncommitted while the export and its consumers landed across several commits. Local tests passed because the working directory had it; CI would have failed with an unresolved export. Phase 2 V2 PR #4, missing-commit recovery. Co-Authored-By: ruflo --- packages/core/src/types.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b7453f6ec..52409d013 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -616,3 +616,12 @@ export interface ComplementInsight { suggestedLsl?: number; suggestedUsl?: number; } + +/** + * Exhaustive switch helper. The TypeScript compiler enforces that all cases + * are handled — if a new variant is added to a union, calls to assertNever + * become a compile error at the default case. + */ +export function assertNever(value: never): never { + throw new Error(`Unhandled variant: ${JSON.stringify(value)}`); +}