diff --git a/apps/azure/src/components/editor/FrameView.tsx b/apps/azure/src/components/editor/FrameView.tsx index 072721f50..2ed3d7e17 100644 --- a/apps/azure/src/components/editor/FrameView.tsx +++ b/apps/azure/src/components/editor/FrameView.tsx @@ -1,130 +1,15 @@ /** - * FrameView (Azure) — FRAME workspace (ADR-070). + * FrameView (Azure) — FRAME workspace shell. * - * Branches on scope (b0 vs b1/b2): - * - * - **b0** (no process steps yet): renders the lightweight `` - * — Y / X picker + inline spec editor + collapsed steps expander wrapping - * the canvas + "See the data" CTA. Existing canvas runs with - * `showGaps={false}` because b0 surfaces missing-spec via inline - * affordances, not the upfront GapStrip warning. - * - **b1 / b2** (one or more process steps): renders the existing - * `` canvas as before. - * - * Plan C2: ProductionLineGlanceDashboard is wired into the Operations band - * via a synthetic preview rollup (empty rows — authoring surface has no - * investigation data). + * CanvasWorkspace owns the shared b0/b1 canvas composition. The Azure shell + * only reads app/store state and wires the app-specific Analysis navigation. */ import React from 'react'; -import { - CanvasFilterChips, - FrameViewB0, - type FrameViewB0YCandidate, - LayeredProcessViewWithCapability, -} from '@variscout/ui'; -import { - useCanvasFilters, - useProductionLineGlanceData, - useProductionLineGlanceFilter, - useProductionLineGlanceOpsToggle, - useTranslation, -} from '@variscout/hooks'; +import { CanvasWorkspace } from '@variscout/ui'; import { useProjectStore } from '@variscout/stores'; -import { - detectColumns, - detectScopeFromMap, - rankYCandidates, - type ColumnAnalysis, - type DataRow, - type ProcessContext, - type ProcessHub, - type ProcessHubInvestigation, - type ProcessHubInvestigationMetadata, - type SpecLimits, - type TimelineWindow, -} from '@variscout/core'; -import { createEmptyMap, detectGaps, type ProcessMap } from '@variscout/core/frame'; -import type { XCandidate } from '@variscout/ui'; import { usePanelsStore } from '../../features/panels/panelsStore'; -const DEFAULT_CPK_TARGET = 1.33; - -/** - * Format a TimelineWindow into a user-readable label for the canvas filter chip. - * Covers all four kinds (cumulative, fixed, rolling, openEnded). - */ -function formatTimelineWindow(w: TimelineWindow): string { - if (w.kind === 'cumulative') return 'Cumulative'; - if (w.kind === 'fixed') return `${w.startISO} → ${w.endISO}`; - if (w.kind === 'rolling') return `Last ${w.windowDays}d`; - if (w.kind === 'openEnded') return `From ${w.startISO}`; - // Exhaustive fallback — narrows to never; string cast satisfies the return type - // while preserving forward-compat for future TimelineWindow kinds. - return (w as { kind: string }).kind; -} - -/** Stable sentinel used when FrameView has no real investigation in scope. */ -const FRAME_CANVAS_INVESTIGATION_ID = 'frame-canvas-local'; - -/** Toggle a value in/out of an array, returning a fresh array. */ -function toggleArray(arr: readonly T[], item: T): T[] { - return arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item]; -} - -/** Extract finite numeric values for a column. Used for chip sparklines. */ -function numericValuesFor(column: string, rows: readonly DataRow[]): number[] { - const out: number[] = []; - for (const row of rows) { - const raw = row[column]; - const n = typeof raw === 'number' ? raw : Number(raw); - if (Number.isFinite(n)) out.push(n); - } - return out; -} - -/** - * Compute mean ± 3σ as a spec suggestion. Returns undefined when there is - * no Y or fewer than 2 finite samples (sample-stdev needs n ≥ 2). - */ -function computeMeanPlusMinusSigma( - outcome: string | null, - rawData: readonly DataRow[] -): { target?: number; usl?: number; lsl?: number } | undefined { - if (!outcome) return undefined; - const values = numericValuesFor(outcome, rawData); - if (values.length < 2) return undefined; - const mean = values.reduce((s, v) => s + v, 0) / values.length; - const variance = values.reduce((s, v) => s + (v - mean) * (v - mean), 0) / (values.length - 1); - const sigma = Math.sqrt(variance); - if (!Number.isFinite(sigma)) return undefined; - return { target: mean, usl: mean + 3 * sigma, lsl: mean - 3 * sigma }; -} - -/** Build a level breakdown for a categorical column (for X picker chip). */ -function levelsFor( - column: string, - rows: readonly DataRow[] -): ReadonlyArray<{ label: string; count: number }> { - const counts = new Map(); - for (const row of rows) { - const raw = row[column]; - if (raw === null || raw === undefined || raw === '') continue; - const label = String(raw); - counts.set(label, (counts.get(label) ?? 0) + 1); - } - return Array.from(counts.entries()).map(([label, count]) => ({ label, count })); -} - -/** Map a ColumnAnalysis to an XCandidate, choosing values vs. levels by type. */ -function toXCandidate(column: ColumnAnalysis, rows: readonly DataRow[]): XCandidate { - if (column.type === 'numeric') { - return { column, numericValues: numericValuesFor(column.name, rows) }; - } - return { column, levels: levelsFor(column.name, rows) }; -} - const FrameView: React.FC = () => { - const { t } = useTranslation(); const rawData = useProjectStore(s => s.rawData); const outcome = useProjectStore(s => s.outcome); const factors = useProjectStore(s => s.factors); @@ -135,239 +20,23 @@ const FrameView: React.FC = () => { const processContext = useProjectStore(s => s.processContext); const setProcessContext = useProjectStore(s => s.setProcessContext); - const availableColumns = React.useMemo( - () => (rawData.length > 0 ? Object.keys(rawData[0]) : []), - [rawData] - ); - - const map: ProcessMap = processContext?.processMap ?? createEmptyMap(); - const scope = detectScopeFromMap(map); - - // Phase D: per-column FRAME spec edits keyed off the canonical map's CTS column. - const ctsColumn = map.ctsColumn; - const ctsSpecs = ctsColumn ? measureSpecs[ctsColumn] : undefined; - - const gaps = React.useMemo( - () => - detectGaps({ - processMap: map, - columns: availableColumns, - outcomeColumn: outcome ?? undefined, - specs: ctsSpecs, - }), - [map, availableColumns, outcome, ctsSpecs] - ); - - const handleChange = (next: ProcessMap) => { - const baseContext: ProcessContext = processContext ?? {}; - setProcessContext({ ...baseContext, processMap: next }); - }; - - const handleSpecsChange = (next: { - target?: number; - usl?: number; - lsl?: number; - cpkTarget?: number; - }) => { - if (!ctsColumn) return; // No CTS picked yet — silently ignore until the user picks one. - setMeasureSpec(ctsColumn, next); - }; - - // Plan C2: URL-backed filter + ops-mode state. - const filter = useProductionLineGlanceFilter(); - const ops = useProductionLineGlanceOpsToggle(); - - // ── Canvas filter chips (slice 4 — P3.6) ───────────────────────────────── - // FrameView is a canonical-map authoring surface with no real investigation - // in scope. We use session-local metadata state (same pattern as previewRollup - // below) — chips are live during the session but not persisted. - const [canvasFilterMeta, setCanvasFilterMeta] = React.useState( - {} - ); - const syntheticInvestigation = React.useMemo>( - () => ({ id: FRAME_CANVAS_INVESTIGATION_ID, metadata: canvasFilterMeta }), - [canvasFilterMeta] - ); - const handleCanvasFilterChange = React.useCallback( - (_id: string, patch: Partial) => { - setCanvasFilterMeta(prev => ({ ...prev, ...patch })); - }, - [] - ); - const { - timelineWindow, - setTimelineWindow, - scopeFilter, - setScopeFilter, - paretoGroupBy, - setParetoGroupBy, - } = useCanvasFilters({ - investigation: syntheticInvestigation, - onChange: handleCanvasFilterChange, - }); - const canvasFilterChipsNode = ( - setTimelineWindow({ kind: 'cumulative' })} - onClearScopeFilter={() => setScopeFilter(undefined)} - onClearParetoGroupBy={() => setParetoGroupBy(undefined)} - /> - ); - - // Synthetic preview rollup — FrameView is a canonical-map authoring surface; - // investigation rows are not loaded here. The dashboard renders empty-state - // gracefully. Live data wiring lands in C3 (right-hand drawer). - const previewRollup = React.useMemo(() => { - const previewHub: ProcessHub = { - id: 'frame-preview', - name: 'Frame preview', - canonicalProcessMap: map, - canonicalMapVersion: 'preview', - contextColumns: [], - } as unknown as ProcessHub; - return { - hub: previewHub, - members: [] as ProcessHubInvestigation[], - rowsByInvestigation: new Map>(), - }; - }, [map]); - - const data = useProductionLineGlanceData({ - hub: previewRollup.hub, - members: previewRollup.members, - rowsByInvestigation: previewRollup.rowsByInvestigation, - contextFilter: filter.value, - }); - - // ── b0 derived inputs (column ranking, spec suggestion, callbacks) ──────── - const detected = React.useMemo( - () => (rawData.length > 0 ? detectColumns(rawData) : null), - [rawData] - ); - const runOrderColumn = detected?.timeColumn ?? null; - const columnAnalysis = React.useMemo(() => detected?.columnAnalysis ?? [], [detected]); - - const yCandidates: FrameViewB0YCandidate[] = React.useMemo(() => { - const ranked = rankYCandidates(columnAnalysis); - return ranked.map(({ column }) => ({ - column, - numericValues: numericValuesFor(column.name, rawData), - })); - }, [columnAnalysis, rawData]); - - const xCandidates: XCandidate[] = React.useMemo(() => { - return columnAnalysis - .filter( - col => - col.name !== outcome && - col.name !== runOrderColumn && - // Spec §3.4: X picker shows "categorical + continuous-not-Y". Exclude - // date/text types (e.g. a secondary unspecified date column or a - // high-cardinality free-text column) — they are not plausible factors. - (col.type === 'numeric' || col.type === 'categorical') - ) - .map(col => toXCandidate(col, rawData)); - }, [columnAnalysis, outcome, runOrderColumn, rawData]); - - const yspecSuggestion = React.useMemo( - () => computeMeanPlusMinusSigma(outcome, rawData), - [outcome, rawData] - ); - - const handleConfirmYSpec = React.useCallback( - (values: Partial) => { - if (!outcome) return; - setMeasureSpec(outcome, values); - }, - [outcome, setMeasureSpec] - ); - const handleSeeData = React.useCallback(() => { usePanelsStore.getState().showAnalysis(); }, []); - // ── Render ──────────────────────────────────────────────────────────────── - if (scope === 'b0') { - return ( -
- setFactors(toggleArray(factors, name))} - runOrderColumn={runOrderColumn} - currentYSpec={outcome ? measureSpecs[outcome] : undefined} - yspecSuggestion={yspecSuggestion} - defaultCpkTarget={DEFAULT_CPK_TARGET} - onConfirmYSpec={handleConfirmYSpec} - onSeeData={handleSeeData} - > - setMeasureSpec(column, next)} - data={data} - filter={{ - availableContext: data.availableContext, - contextValueOptions: data.contextValueOptions, - value: filter.value, - onChange: filter.onChange, - }} - mode={ops.mode} - onModeChange={ops.setMode} - showGaps={false} - canvasFilterChips={canvasFilterChipsNode} - /> - -
- ); - } - return ( -
-
-
-

{t('frame.b1.heading')}

-

{t('frame.b1.description')}

-
- setMeasureSpec(column, next)} - data={data} - filter={{ - availableContext: data.availableContext, - contextValueOptions: data.contextValueOptions, - value: filter.value, - onChange: filter.onChange, - }} - mode={ops.mode} - onModeChange={ops.setMode} - canvasFilterChips={canvasFilterChipsNode} - /> -
-
+ ); }; diff --git a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx index 58d14cfc0..e9fc75d3b 100644 --- a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx +++ b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx @@ -1,13 +1,12 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// vi.mock MUST come before component imports (per writing-tests skill / testing.md rule) +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const setProcessContextMock = vi.fn(); const setMeasureSpecMock = vi.fn(); const setOutcomeMock = vi.fn(); const setFactorsMock = vi.fn(); const showAnalysisMock = vi.fn(); + const storeStateRef: { current: Record } = { current: { rawData: [], @@ -22,316 +21,77 @@ const storeStateRef: { current: Record } = { }, }; +const hoisted = vi.hoisted(() => ({ + canvasWorkspaceMock: vi.fn(), +})); + vi.mock('@variscout/stores', () => ({ useProjectStore: vi.fn((selector: (s: unknown) => unknown) => selector(storeStateRef.current)), })); -// Azure panelsStore — only the slice FrameView reads (showAnalysis via getState()). +vi.mock('@variscout/ui', async () => { + const React = await import('react'); + return { + CanvasWorkspace: (props: { onSeeData: () => void }) => { + hoisted.canvasWorkspaceMock(props); + return React.createElement( + 'button', + { type: 'button', 'data-testid': 'canvas-workspace', onClick: props.onSeeData }, + 'Canvas workspace' + ); + }, + }; +}); + vi.mock('../../../features/panels/panelsStore', () => ({ usePanelsStore: Object.assign(vi.fn(), { getState: () => ({ showAnalysis: showAnalysisMock }), }), })); -// Shared ref for useCanvasFilters state so tests can manipulate it. -const canvasFiltersStateRef: { - current: { - timelineWindow: import('@variscout/core').TimelineWindow; - scopeFilter: import('@variscout/core').ScopeFilter | undefined; - paretoGroupBy: string | undefined; - setTimelineWindow: ReturnType; - setScopeFilter: ReturnType; - setParetoGroupBy: ReturnType; - }; -} = { - current: { - timelineWindow: { kind: 'cumulative' }, - scopeFilter: undefined, - paretoGroupBy: undefined, - setTimelineWindow: vi.fn(), - setScopeFilter: vi.fn(), - setParetoGroupBy: vi.fn(), - }, -}; - -vi.mock('@variscout/hooks', async () => { - const actual = await import('@variscout/hooks'); - return { - ...actual, - useProductionLineGlanceFilter: vi.fn(() => ({ - value: {}, - onChange: vi.fn(), - })), - useProductionLineGlanceOpsToggle: vi.fn(() => ({ - mode: 'spatial' as const, - setMode: vi.fn(), - toggle: vi.fn(), - })), - useProductionLineGlanceData: vi.fn(() => ({ - cpkTrend: { data: [], stats: null, specs: {} }, - cpkGapTrend: { series: [], stats: null }, - capabilityNodes: [], - errorSteps: [], - availableContext: { hubColumns: [], tributaryGroups: [] }, - contextValueOptions: {}, - })), - useCanvasFilters: vi.fn(() => canvasFiltersStateRef.current), - }; -}); - -vi.mock('@variscout/charts', async importOriginal => { - const actual = await importOriginal(); - const React = await import('react'); - return { - ...actual, - IChart: () => React.createElement('div', { 'data-testid': 'mock-cpk-trend' }), - CapabilityGapTrendChart: () => React.createElement('div', { 'data-testid': 'mock-gap-trend' }), - CapabilityBoxplot: () => - React.createElement('div', { 'data-testid': 'mock-capability-boxplot' }), - StepErrorPareto: () => React.createElement('div', { 'data-testid': 'mock-step-pareto' }), - }; -}); - -import { fireEvent } from '@testing-library/react'; -import type { ScopeFilter, TimelineWindow } from '@variscout/core'; import FrameView from '../FrameView'; -describe('FrameView (Plan C2 wiring)', () => { +describe('FrameView (Azure shell)', () => { beforeEach(() => { - window.history.replaceState(null, '', '/test'); - setProcessContextMock.mockClear(); - setMeasureSpecMock.mockClear(); - setOutcomeMock.mockClear(); - setFactorsMock.mockClear(); + hoisted.canvasWorkspaceMock.mockClear(); showAnalysisMock.mockClear(); - // Reset canvas filter state to defaults for each test. - canvasFiltersStateRef.current = { - timelineWindow: { kind: 'cumulative' }, - scopeFilter: undefined, - paretoGroupBy: undefined, - setTimelineWindow: vi.fn(), - setScopeFilter: vi.fn(), - setParetoGroupBy: vi.fn(), - }; storeStateRef.current = { - rawData: [], - outcome: null, - factors: [], + rawData: [{ Fill_Weight: 12 }], + outcome: 'Fill_Weight', + factors: ['Machine'], setOutcome: setOutcomeMock, setFactors: setFactorsMock, - measureSpecs: {}, + measureSpecs: { Fill_Weight: { target: 12 } }, setMeasureSpec: setMeasureSpecMock, - processContext: null, + processContext: { currentUnderstanding: 'fill line' }, setProcessContext: setProcessContextMock, }; }); - it('renders LayeredProcessViewWithCapability composition (three bands + ops dashboard) when scope is b1/b2', () => { - // Add a step so detectScopeFromMap returns b2 — the b1/b2 path renders - // the canvas directly (b0 wraps it inside the closed expander). - storeStateRef.current = { - ...storeStateRef.current, - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Bake', order: 0 }], - tributaries: [], - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - }; - render(); - - expect(screen.getByTestId('layered-process-view')).toBeInTheDocument(); - expect(screen.getByTestId('band-outcome')).toBeInTheDocument(); - expect(screen.getByTestId('band-process-flow')).toBeInTheDocument(); - expect(screen.getByTestId('band-operations')).toBeInTheDocument(); - expect(screen.getByTestId('ops-band-dashboard')).toBeInTheDocument(); - }); - - it('renders FrameViewB0 when scope is b0 (no process steps)', () => { - storeStateRef.current = { - ...storeStateRef.current, - rawData: [{ Fill_Weight: 12, Machine: 'A' }], - // No processContext → empty map → b0 - }; - render(); - expect(screen.getByTestId('frame-view-b0')).toBeInTheDocument(); - expect(screen.getByTestId('y-picker-section')).toBeInTheDocument(); - // Canvas hidden inside the collapsed expander - expect(screen.queryByTestId('layered-process-view')).toBeNull(); - expect(screen.getByTestId('see-the-data-cta')).toBeInTheDocument(); - }); - - it('See the data CTA fires panelsStore.showAnalysis() when a Y is picked', () => { - storeStateRef.current = { - ...storeStateRef.current, - rawData: [ - { Fill_Weight: 12, Machine: 'A' }, - { Fill_Weight: 13, Machine: 'B' }, - { Fill_Weight: 11, Machine: 'A' }, - ], - outcome: 'Fill_Weight', - }; - render(); - fireEvent.click(screen.getByTestId('see-the-data-cta')); - expect(showAnalysisMock).toHaveBeenCalledTimes(1); - }); - - it('writes per-column to measureSpecs[ctsColumn] when LSL changes (Phase D)', () => { - // Add a step so we're in b1 scope and the canvas renders directly. - storeStateRef.current = { - ...storeStateRef.current, - rawData: [{ Fill_Weight: 12, Machine: 'A' }], - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Mix', order: 0 }], - tributaries: [], - ctsColumn: 'Fill_Weight', - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - measureSpecs: { Fill_Weight: { target: 12, usl: 13, lsl: 11 } }, - }; + it('passes app store state into the shared Canvas workspace', () => { render(); - fireEvent.change(screen.getByTestId('process-map-ocean-lsl'), { target: { value: '10.5' } }); - expect(setMeasureSpecMock).toHaveBeenCalledWith('Fill_Weight', { - target: 12, - usl: 13, - lsl: 10.5, - cpkTarget: undefined, - }); - }); - it('writes per-step CTQ specs to measureSpecs[ctqColumn] when a StepCard USL changes (Task B)', () => { - storeStateRef.current = { - ...storeStateRef.current, - rawData: [{ Fill_Weight: 12, Bake_Time: 30, Machine: 'A' }], - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Bake', order: 0, ctqColumn: 'Bake_Time' }], - tributaries: [], - ctsColumn: 'Fill_Weight', - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - measureSpecs: { Bake_Time: { target: 30, lsl: 28, usl: 32 } }, - }; - render(); - fireEvent.change(screen.getByTestId('process-map-step-specs-step-1-usl'), { - target: { value: '34' }, - }); - expect(setMeasureSpecMock).toHaveBeenCalledWith( - 'Bake_Time', + expect(screen.getByTestId('canvas-workspace')).toBeInTheDocument(); + expect(hoisted.canvasWorkspaceMock).toHaveBeenCalledWith( expect.objectContaining({ - target: 30, - lsl: 28, - usl: 34, + rawData: [{ Fill_Weight: 12 }], + outcome: 'Fill_Weight', + factors: ['Machine'], + measureSpecs: { Fill_Weight: { target: 12 } }, + processContext: { currentUnderstanding: 'fill line' }, + setOutcome: setOutcomeMock, + setFactors: setFactorsMock, + setMeasureSpec: setMeasureSpecMock, + setProcessContext: setProcessContextMock, }) ); }); - it('writes per-column to measureSpecs[ctsColumn] when Cpk target changes (Phase D)', () => { - // Add a step so we're in b1 scope and the canvas renders directly. - storeStateRef.current = { - ...storeStateRef.current, - rawData: [{ Fill_Weight: 12, Machine: 'A' }], - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Mix', order: 0 }], - tributaries: [], - ctsColumn: 'Fill_Weight', - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - measureSpecs: { Fill_Weight: { target: 12, usl: 13, lsl: 11 } }, - }; + it('wires See Data to the Azure Analysis panel action', () => { render(); - fireEvent.change(screen.getByTestId('process-map-ocean-cpk-target'), { - target: { value: '1.67' }, - }); - expect(setMeasureSpecMock).toHaveBeenCalledWith('Fill_Weight', { - target: 12, - usl: 13, - lsl: 11, - cpkTarget: 1.67, - }); - }); - // ── P3.6: CanvasFilterChips integration ──────────────────────────────────── + fireEvent.click(screen.getByTestId('canvas-workspace')); - it('renders the canvasFilterChips slot (layered-canvas-filter-chips) in b1/b2 when timelineWindow is non-cumulative', () => { - canvasFiltersStateRef.current = { - ...canvasFiltersStateRef.current, - timelineWindow: { kind: 'rolling', windowDays: 30 } satisfies TimelineWindow, - }; - storeStateRef.current = { - ...storeStateRef.current, - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Bake', order: 0 }], - tributaries: [], - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - }; - render(); - // The slot wrapper is always present; the chip inside renders only when active. - expect(screen.getByTestId('layered-canvas-filter-chips')).toBeInTheDocument(); - expect(screen.getByTestId('filter-chip-window')).toBeInTheDocument(); - }); - - it('renders the scope chip in b1/b2 when scopeFilter is set', () => { - canvasFiltersStateRef.current = { - ...canvasFiltersStateRef.current, - scopeFilter: { factor: 'Machine', values: ['A'] } satisfies ScopeFilter, - }; - storeStateRef.current = { - ...storeStateRef.current, - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Mix', order: 0 }], - tributaries: [], - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - }; - render(); - expect(screen.getByTestId('filter-chip-scope')).toBeInTheDocument(); - }); - - it('clear time-window chip calls setTimelineWindow with cumulative', () => { - canvasFiltersStateRef.current = { - ...canvasFiltersStateRef.current, - timelineWindow: { kind: 'rolling', windowDays: 7 } satisfies TimelineWindow, - }; - storeStateRef.current = { - ...storeStateRef.current, - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Mix', order: 0 }], - tributaries: [], - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - }; - render(); - fireEvent.click(screen.getByLabelText(/Clear Last 7d/i)); - expect(canvasFiltersStateRef.current.setTimelineWindow).toHaveBeenCalledWith({ - kind: 'cumulative', - }); + expect(showAnalysisMock).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/pwa/src/components/views/FrameView.tsx b/apps/pwa/src/components/views/FrameView.tsx index f9bb59e91..c7301edf2 100644 --- a/apps/pwa/src/components/views/FrameView.tsx +++ b/apps/pwa/src/components/views/FrameView.tsx @@ -1,132 +1,15 @@ /** - * FrameView — PWA FRAME workspace (ADR-070). + * FrameView — PWA FRAME workspace shell. * - * Branches on scope (b0 vs b1/b2): - * - * - **b0** (no process steps yet): renders the lightweight `` - * — Y / X picker + inline spec editor + collapsed steps expander wrapping - * the canvas + "See the data" CTA. Existing canvas runs with - * `showGaps={false}` because b0 surfaces missing-spec via inline - * affordances, not the upfront GapStrip warning. - * - **b1 / b2** (one or more process steps): renders the existing - * `` canvas as before. Adding the first - * step in the b0 expander auto-flips the scope detector and brings the user - * into b2 (then b1 once a second step is added). - * - * Plan C2: ProductionLineGlanceDashboard is wired into the Operations band - * via a synthetic preview rollup (empty rows — authoring surface has no - * investigation data). + * CanvasWorkspace owns the shared b0/b1 canvas composition. The PWA shell only + * reads app/store state and wires the app-specific Analysis navigation. */ import React from 'react'; -import { - CanvasFilterChips, - FrameViewB0, - type FrameViewB0YCandidate, - LayeredProcessViewWithCapability, -} from '@variscout/ui'; -import { - useCanvasFilters, - useProductionLineGlanceData, - useProductionLineGlanceFilter, - useProductionLineGlanceOpsToggle, - useTranslation, -} from '@variscout/hooks'; +import { CanvasWorkspace } from '@variscout/ui'; import { useProjectStore } from '@variscout/stores'; -import { - detectColumns, - detectScopeFromMap, - rankYCandidates, - type ColumnAnalysis, - type DataRow, - type ProcessContext, - type ProcessHub, - type ProcessHubInvestigation, - type ProcessHubInvestigationMetadata, - type SpecLimits, - type TimelineWindow, -} from '@variscout/core'; -import { createEmptyMap, detectGaps, type ProcessMap } from '@variscout/core/frame'; -import type { XCandidate } from '@variscout/ui'; import { usePanelsStore } from '../../features/panels/panelsStore'; -const DEFAULT_CPK_TARGET = 1.33; - -/** - * Format a TimelineWindow into a user-readable label for the canvas filter chip. - * Covers all four kinds (cumulative, fixed, rolling, openEnded). - */ -function formatTimelineWindow(w: TimelineWindow): string { - if (w.kind === 'cumulative') return 'Cumulative'; - if (w.kind === 'fixed') return `${w.startISO} → ${w.endISO}`; - if (w.kind === 'rolling') return `Last ${w.windowDays}d`; - if (w.kind === 'openEnded') return `From ${w.startISO}`; - // Exhaustive fallback — narrows to never; string cast satisfies the return type - // while preserving forward-compat for future TimelineWindow kinds. - return (w as { kind: string }).kind; -} - -/** Stable sentinel used when FrameView has no real investigation in scope. */ -const FRAME_CANVAS_INVESTIGATION_ID = 'frame-canvas-local'; - -/** Toggle a value in/out of an array, returning a fresh array. */ -function toggleArray(arr: readonly T[], item: T): T[] { - return arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item]; -} - -/** Extract finite numeric values for a column. Used for chip sparklines. */ -function numericValuesFor(column: string, rows: readonly DataRow[]): number[] { - const out: number[] = []; - for (const row of rows) { - const raw = row[column]; - const n = typeof raw === 'number' ? raw : Number(raw); - if (Number.isFinite(n)) out.push(n); - } - return out; -} - -/** - * Compute mean ± 3σ as a spec suggestion. Returns undefined when there is - * no Y or fewer than 2 finite samples (sample-stdev needs n ≥ 2). - */ -function computeMeanPlusMinusSigma( - outcome: string | null, - rawData: readonly DataRow[] -): { target?: number; usl?: number; lsl?: number } | undefined { - if (!outcome) return undefined; - const values = numericValuesFor(outcome, rawData); - if (values.length < 2) return undefined; - const mean = values.reduce((s, v) => s + v, 0) / values.length; - const variance = values.reduce((s, v) => s + (v - mean) * (v - mean), 0) / (values.length - 1); - const sigma = Math.sqrt(variance); - if (!Number.isFinite(sigma)) return undefined; - return { target: mean, usl: mean + 3 * sigma, lsl: mean - 3 * sigma }; -} - -/** Build a level breakdown for a categorical column (for X picker chip). */ -function levelsFor( - column: string, - rows: readonly DataRow[] -): ReadonlyArray<{ label: string; count: number }> { - const counts = new Map(); - for (const row of rows) { - const raw = row[column]; - if (raw === null || raw === undefined || raw === '') continue; - const label = String(raw); - counts.set(label, (counts.get(label) ?? 0) + 1); - } - return Array.from(counts.entries()).map(([label, count]) => ({ label, count })); -} - -/** Map a ColumnAnalysis to an XCandidate, choosing values vs. levels by type. */ -function toXCandidate(column: ColumnAnalysis, rows: readonly DataRow[]): XCandidate { - if (column.type === 'numeric') { - return { column, numericValues: numericValuesFor(column.name, rows) }; - } - return { column, levels: levelsFor(column.name, rows) }; -} - const FrameView: React.FC = () => { - const { t } = useTranslation(); const rawData = useProjectStore(s => s.rawData); const outcome = useProjectStore(s => s.outcome); const factors = useProjectStore(s => s.factors); @@ -137,239 +20,23 @@ const FrameView: React.FC = () => { const processContext = useProjectStore(s => s.processContext); const setProcessContext = useProjectStore(s => s.setProcessContext); - const availableColumns = React.useMemo( - () => (rawData.length > 0 ? Object.keys(rawData[0]) : []), - [rawData] - ); - - const map: ProcessMap = processContext?.processMap ?? createEmptyMap(); - const scope = detectScopeFromMap(map); - - // Phase D: per-column FRAME spec edits keyed off the canonical map's CTS column. - const ctsColumn = map.ctsColumn; - const ctsSpecs = ctsColumn ? measureSpecs[ctsColumn] : undefined; - - const gaps = React.useMemo( - () => - detectGaps({ - processMap: map, - columns: availableColumns, - outcomeColumn: outcome ?? undefined, - specs: ctsSpecs, - }), - [map, availableColumns, outcome, ctsSpecs] - ); - - const handleChange = (next: ProcessMap) => { - const baseContext: ProcessContext = processContext ?? {}; - setProcessContext({ ...baseContext, processMap: next }); - }; - - const handleSpecsChange = (next: { - target?: number; - usl?: number; - lsl?: number; - cpkTarget?: number; - }) => { - if (!ctsColumn) return; // No CTS picked yet — silently ignore until the user picks one. - setMeasureSpec(ctsColumn, next); - }; - - // Plan C2: URL-backed filter + ops-mode state. - const filter = useProductionLineGlanceFilter(); - const ops = useProductionLineGlanceOpsToggle(); - - // ── Canvas filter chips (slice 4 — P3.6) ───────────────────────────────── - // FrameView is a canonical-map authoring surface with no real investigation - // in scope. We use session-local metadata state (same pattern as previewRollup - // below) — chips are live during the session but not persisted. - const [canvasFilterMeta, setCanvasFilterMeta] = React.useState( - {} - ); - const syntheticInvestigation = React.useMemo>( - () => ({ id: FRAME_CANVAS_INVESTIGATION_ID, metadata: canvasFilterMeta }), - [canvasFilterMeta] - ); - const handleCanvasFilterChange = React.useCallback( - (_id: string, patch: Partial) => { - setCanvasFilterMeta(prev => ({ ...prev, ...patch })); - }, - [] - ); - const { - timelineWindow, - setTimelineWindow, - scopeFilter, - setScopeFilter, - paretoGroupBy, - setParetoGroupBy, - } = useCanvasFilters({ - investigation: syntheticInvestigation, - onChange: handleCanvasFilterChange, - }); - const canvasFilterChipsNode = ( - setTimelineWindow({ kind: 'cumulative' })} - onClearScopeFilter={() => setScopeFilter(undefined)} - onClearParetoGroupBy={() => setParetoGroupBy(undefined)} - /> - ); - - // Synthetic preview rollup — FrameView is a canonical-map authoring surface; - // investigation rows are not loaded here. The dashboard renders empty-state - // gracefully. Live data wiring lands in C3 (right-hand drawer). - const previewRollup = React.useMemo(() => { - const previewHub: ProcessHub = { - id: 'frame-preview', - name: 'Frame preview', - canonicalProcessMap: map, - canonicalMapVersion: 'preview', - contextColumns: [], - } as unknown as ProcessHub; - return { - hub: previewHub, - members: [] as ProcessHubInvestigation[], - rowsByInvestigation: new Map>(), - }; - }, [map]); - - const data = useProductionLineGlanceData({ - hub: previewRollup.hub, - members: previewRollup.members, - rowsByInvestigation: previewRollup.rowsByInvestigation, - contextFilter: filter.value, - }); - - // ── b0 derived inputs (column ranking, spec suggestion, callbacks) ──────── - const detected = React.useMemo( - () => (rawData.length > 0 ? detectColumns(rawData) : null), - [rawData] - ); - const runOrderColumn = detected?.timeColumn ?? null; - const columnAnalysis = React.useMemo(() => detected?.columnAnalysis ?? [], [detected]); - - const yCandidates: FrameViewB0YCandidate[] = React.useMemo(() => { - const ranked = rankYCandidates(columnAnalysis); - return ranked.map(({ column }) => ({ - column, - numericValues: numericValuesFor(column.name, rawData), - })); - }, [columnAnalysis, rawData]); - - const xCandidates: XCandidate[] = React.useMemo(() => { - return columnAnalysis - .filter( - col => - col.name !== outcome && - col.name !== runOrderColumn && - // Spec §3.4: X picker shows "categorical + continuous-not-Y". Exclude - // date/text types (e.g. a secondary unspecified date column or a - // high-cardinality free-text column) — they are not plausible factors. - (col.type === 'numeric' || col.type === 'categorical') - ) - .map(col => toXCandidate(col, rawData)); - }, [columnAnalysis, outcome, runOrderColumn, rawData]); - - const yspecSuggestion = React.useMemo( - () => computeMeanPlusMinusSigma(outcome, rawData), - [outcome, rawData] - ); - - const handleConfirmYSpec = React.useCallback( - (values: Partial) => { - if (!outcome) return; - setMeasureSpec(outcome, values); - }, - [outcome, setMeasureSpec] - ); - const handleSeeData = React.useCallback(() => { usePanelsStore.getState().showAnalysis(); }, []); - // ── Render ──────────────────────────────────────────────────────────────── - if (scope === 'b0') { - return ( -
- setFactors(toggleArray(factors, name))} - runOrderColumn={runOrderColumn} - currentYSpec={outcome ? measureSpecs[outcome] : undefined} - yspecSuggestion={yspecSuggestion} - defaultCpkTarget={DEFAULT_CPK_TARGET} - onConfirmYSpec={handleConfirmYSpec} - onSeeData={handleSeeData} - > - setMeasureSpec(column, next)} - data={data} - filter={{ - availableContext: data.availableContext, - contextValueOptions: data.contextValueOptions, - value: filter.value, - onChange: filter.onChange, - }} - mode={ops.mode} - onModeChange={ops.setMode} - showGaps={false} - canvasFilterChips={canvasFilterChipsNode} - /> - -
- ); - } - return ( -
-
-
-

{t('frame.b1.heading')}

-

{t('frame.b1.description')}

-
- setMeasureSpec(column, next)} - data={data} - filter={{ - availableContext: data.availableContext, - contextValueOptions: data.contextValueOptions, - value: filter.value, - onChange: filter.onChange, - }} - mode={ops.mode} - onModeChange={ops.setMode} - canvasFilterChips={canvasFilterChipsNode} - /> -
-
+ ); }; diff --git a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx index 2de48ff24..ee0f5f0ac 100644 --- a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx +++ b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx @@ -1,13 +1,12 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// vi.mock MUST come before component imports (per writing-tests skill / testing.md rule) +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const setProcessContextMock = vi.fn(); const setMeasureSpecMock = vi.fn(); const setOutcomeMock = vi.fn(); const setFactorsMock = vi.fn(); const showAnalysisMock = vi.fn(); + const storeStateRef: { current: Record } = { current: { rawData: [], @@ -22,291 +21,77 @@ const storeStateRef: { current: Record } = { }, }; +const hoisted = vi.hoisted(() => ({ + canvasWorkspaceMock: vi.fn(), +})); + vi.mock('@variscout/stores', () => ({ useProjectStore: vi.fn((selector: (s: unknown) => unknown) => selector(storeStateRef.current)), })); -// PWA panelsStore — only the slice FrameView reads (showAnalysis via getState()). +vi.mock('@variscout/ui', async () => { + const React = await import('react'); + return { + CanvasWorkspace: (props: { onSeeData: () => void }) => { + hoisted.canvasWorkspaceMock(props); + return React.createElement( + 'button', + { type: 'button', 'data-testid': 'canvas-workspace', onClick: props.onSeeData }, + 'Canvas workspace' + ); + }, + }; +}); + vi.mock('../../../features/panels/panelsStore', () => ({ usePanelsStore: Object.assign(vi.fn(), { getState: () => ({ showAnalysis: showAnalysisMock }), }), })); -// Shared ref for useCanvasFilters state so tests can manipulate it. -const canvasFiltersStateRef: { - current: { - timelineWindow: import('@variscout/core').TimelineWindow; - scopeFilter: import('@variscout/core').ScopeFilter | undefined; - paretoGroupBy: string | undefined; - setTimelineWindow: ReturnType; - setScopeFilter: ReturnType; - setParetoGroupBy: ReturnType; - }; -} = { - current: { - timelineWindow: { kind: 'cumulative' }, - scopeFilter: undefined, - paretoGroupBy: undefined, - setTimelineWindow: vi.fn(), - setScopeFilter: vi.fn(), - setParetoGroupBy: vi.fn(), - }, -}; - -vi.mock('@variscout/hooks', async () => { - const actual = await import('@variscout/hooks'); - return { - ...actual, - useProductionLineGlanceFilter: vi.fn(() => ({ - value: {}, - onChange: vi.fn(), - })), - useProductionLineGlanceOpsToggle: vi.fn(() => ({ - mode: 'spatial' as const, - setMode: vi.fn(), - toggle: vi.fn(), - })), - useProductionLineGlanceData: vi.fn(() => ({ - cpkTrend: { data: [], stats: null, specs: {} }, - cpkGapTrend: { series: [], stats: null }, - capabilityNodes: [], - errorSteps: [], - availableContext: { hubColumns: [], tributaryGroups: [] }, - contextValueOptions: {}, - })), - useCanvasFilters: vi.fn(() => canvasFiltersStateRef.current), - }; -}); - -vi.mock('@variscout/charts', async importOriginal => { - const actual = await importOriginal(); - const React = await import('react'); - return { - ...actual, - IChart: () => React.createElement('div', { 'data-testid': 'mock-cpk-trend' }), - CapabilityGapTrendChart: () => React.createElement('div', { 'data-testid': 'mock-gap-trend' }), - CapabilityBoxplot: () => - React.createElement('div', { 'data-testid': 'mock-capability-boxplot' }), - StepErrorPareto: () => React.createElement('div', { 'data-testid': 'mock-step-pareto' }), - }; -}); - -import { fireEvent } from '@testing-library/react'; -import type { ScopeFilter, TimelineWindow } from '@variscout/core'; import FrameView from '../FrameView'; -describe('FrameView (PWA)', () => { +describe('FrameView (PWA shell)', () => { beforeEach(() => { - window.history.replaceState(null, '', '/test'); - setProcessContextMock.mockClear(); - setMeasureSpecMock.mockClear(); - setOutcomeMock.mockClear(); - setFactorsMock.mockClear(); + hoisted.canvasWorkspaceMock.mockClear(); showAnalysisMock.mockClear(); - // Reset canvas filter state to defaults for each test. - canvasFiltersStateRef.current = { - timelineWindow: { kind: 'cumulative' }, - scopeFilter: undefined, - paretoGroupBy: undefined, - setTimelineWindow: vi.fn(), - setScopeFilter: vi.fn(), - setParetoGroupBy: vi.fn(), - }; storeStateRef.current = { - rawData: [], - outcome: null, - factors: [], + rawData: [{ Fill_Weight: 12 }], + outcome: 'Fill_Weight', + factors: ['Machine'], setOutcome: setOutcomeMock, setFactors: setFactorsMock, - measureSpecs: {}, + measureSpecs: { Fill_Weight: { target: 12 } }, setMeasureSpec: setMeasureSpecMock, - processContext: null, + processContext: { currentUnderstanding: 'fill line' }, setProcessContext: setProcessContextMock, }; }); - it('renders LayeredProcessViewWithCapability composition (three bands + ops dashboard) when scope is b1/b2', () => { - // Add a step so detectScopeFromMap returns b2 — the b1/b2 path renders - // the canvas directly (b0 wraps it inside the closed expander). - storeStateRef.current = { - ...storeStateRef.current, - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Bake', order: 0 }], - tributaries: [], - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - }; + it('passes app store state into the shared Canvas workspace', () => { render(); - expect(screen.getByTestId('layered-process-view')).toBeInTheDocument(); - expect(screen.getByTestId('band-outcome')).toBeInTheDocument(); - expect(screen.getByTestId('band-process-flow')).toBeInTheDocument(); - expect(screen.getByTestId('band-operations')).toBeInTheDocument(); - expect(screen.getByTestId('ops-band-dashboard')).toBeInTheDocument(); - }); - - it('renders FrameViewB0 when scope is b0 (no process steps)', () => { - storeStateRef.current = { - ...storeStateRef.current, - rawData: [{ Fill_Weight: 12, Machine: 'A' }], - // No processContext → empty map → b0 - }; - render(); - expect(screen.getByTestId('frame-view-b0')).toBeInTheDocument(); - expect(screen.getByTestId('y-picker-section')).toBeInTheDocument(); - // The canvas is inside the collapsed expander, so layered-process-view is hidden - expect(screen.queryByTestId('layered-process-view')).toBeNull(); - // CTA exists at the bottom - expect(screen.getByTestId('see-the-data-cta')).toBeInTheDocument(); - }); - - it('writes per-step CTQ specs to measureSpecs[ctqColumn] when a StepCard USL changes (Task B)', () => { - storeStateRef.current = { - ...storeStateRef.current, - rawData: [{ Fill_Weight: 12, Bake_Time: 30, Machine: 'A' }], - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Bake', order: 0, ctqColumn: 'Bake_Time' }], - tributaries: [], - ctsColumn: 'Fill_Weight', - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - measureSpecs: { Bake_Time: { target: 30, lsl: 28, usl: 32 } }, - }; - render(); - fireEvent.change(screen.getByTestId('process-map-step-specs-step-1-usl'), { - target: { value: '34' }, - }); - expect(setMeasureSpecMock).toHaveBeenCalledWith( - 'Bake_Time', + expect(screen.getByTestId('canvas-workspace')).toBeInTheDocument(); + expect(hoisted.canvasWorkspaceMock).toHaveBeenCalledWith( expect.objectContaining({ - target: 30, - lsl: 28, - usl: 34, + rawData: [{ Fill_Weight: 12 }], + outcome: 'Fill_Weight', + factors: ['Machine'], + measureSpecs: { Fill_Weight: { target: 12 } }, + processContext: { currentUnderstanding: 'fill line' }, + setOutcome: setOutcomeMock, + setFactors: setFactorsMock, + setMeasureSpec: setMeasureSpecMock, + setProcessContext: setProcessContextMock, }) ); }); - it('writes per-column to measureSpecs[ctsColumn] when Cpk target changes (Phase D)', () => { - // Add a step so the canvas is rendered directly (b1 path) instead of - // hidden inside the b0 expander. - storeStateRef.current = { - ...storeStateRef.current, - rawData: [{ Fill_Weight: 12, Machine: 'A' }], - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Mix', order: 0 }], - tributaries: [], - ctsColumn: 'Fill_Weight', - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - measureSpecs: { Fill_Weight: { target: 12, usl: 13, lsl: 11 } }, - }; - render(); - fireEvent.change(screen.getByTestId('process-map-ocean-cpk-target'), { - target: { value: '1.67' }, - }); - expect(setMeasureSpecMock).toHaveBeenCalledWith('Fill_Weight', { - target: 12, - usl: 13, - lsl: 11, - cpkTarget: 1.67, - }); - }); - - it('See the data CTA fires panelsStore.showAnalysis() when a Y is picked', () => { - storeStateRef.current = { - ...storeStateRef.current, - rawData: [ - { Fill_Weight: 12, Machine: 'A' }, - { Fill_Weight: 13, Machine: 'B' }, - { Fill_Weight: 11, Machine: 'A' }, - ], - outcome: 'Fill_Weight', - }; - render(); - fireEvent.click(screen.getByTestId('see-the-data-cta')); - expect(showAnalysisMock).toHaveBeenCalledTimes(1); - }); - - // ── P3.6: CanvasFilterChips integration ──────────────────────────────────── - - it('renders the canvasFilterChips slot (layered-canvas-filter-chips) in b1/b2 when timelineWindow is non-cumulative', () => { - canvasFiltersStateRef.current = { - ...canvasFiltersStateRef.current, - timelineWindow: { kind: 'rolling', windowDays: 30 } satisfies TimelineWindow, - }; - storeStateRef.current = { - ...storeStateRef.current, - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Bake', order: 0 }], - tributaries: [], - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - }; + it('wires See Data to the PWA Analysis panel action', () => { render(); - // The slot wrapper is always present; the chip inside renders only when active. - expect(screen.getByTestId('layered-canvas-filter-chips')).toBeInTheDocument(); - expect(screen.getByTestId('filter-chip-window')).toBeInTheDocument(); - }); - it('renders the scope chip in b1/b2 when scopeFilter is set', () => { - canvasFiltersStateRef.current = { - ...canvasFiltersStateRef.current, - scopeFilter: { factor: 'Machine', values: ['A'] } satisfies ScopeFilter, - }; - storeStateRef.current = { - ...storeStateRef.current, - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Mix', order: 0 }], - tributaries: [], - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - }; - render(); - expect(screen.getByTestId('filter-chip-scope')).toBeInTheDocument(); - }); + fireEvent.click(screen.getByTestId('canvas-workspace')); - it('clear time-window chip calls setTimelineWindow with cumulative', () => { - canvasFiltersStateRef.current = { - ...canvasFiltersStateRef.current, - timelineWindow: { kind: 'rolling', windowDays: 7 } satisfies TimelineWindow, - }; - storeStateRef.current = { - ...storeStateRef.current, - processContext: { - processMap: { - version: 1, - nodes: [{ id: 'step-1', name: 'Mix', order: 0 }], - tributaries: [], - createdAt: '2026-04-29T00:00:00.000Z', - updatedAt: '2026-04-29T00:00:00.000Z', - }, - }, - }; - render(); - fireEvent.click(screen.getByLabelText(/Clear Last 7d/i)); - expect(canvasFiltersStateRef.current.setTimelineWindow).toHaveBeenCalledWith({ - kind: 'cumulative', - }); + expect(showAnalysisMock).toHaveBeenCalledTimes(1); }); }); diff --git a/docs/07-decisions/adr-076-frame-b0-lightweight-render.md b/docs/07-decisions/adr-076-frame-b0-lightweight-render.md index c4724768f..f4d7f5322 100644 --- a/docs/07-decisions/adr-076-frame-b0-lightweight-render.md +++ b/docs/07-decisions/adr-076-frame-b0-lightweight-render.md @@ -2,7 +2,9 @@ title: 'ADR-076: FRAME b0 lightweight render — investigator vs author archetypes' audience: [product, designer, engineer, analyst] category: architecture -status: accepted +status: superseded +superseded-by: docs/superpowers/specs/2026-05-04-canvas-migration-design.md +superseded-on: 2026-05-04 date: 2026-05-02 related: - adr-070-frame-workspace @@ -14,7 +16,9 @@ related: # ADR-076: FRAME b0 lightweight render — investigator vs author archetypes -**Status**: Accepted +> **Superseded:** This ADR is superseded by the Canvas Migration spec. b0 ceases to exist as a separate render path after PR2 of the canvas migration; Canvas handles the empty-to-full spectrum natively. ADR-076 retained as historical record only. + +**Status**: Superseded **Date**: 2026-05-02 diff --git a/packages/core/src/stats/stepErrorAggregation.ts b/packages/core/src/stats/stepErrorAggregation.ts index 8eeeb5299..1717cba5d 100644 --- a/packages/core/src/stats/stepErrorAggregation.ts +++ b/packages/core/src/stats/stepErrorAggregation.ts @@ -2,8 +2,10 @@ import type { ProcessHub, ProcessHubInvestigation } from '../processHub'; import type { ProcessMap, ProcessMapNode } from '../frame/types'; import type { DataRow, SpecLookupContext } from '../types'; +export type StepErrorRollupHub = Pick; + export interface StepErrorRollupInput { - hub: ProcessHub; + hub: StepErrorRollupHub; members: readonly ProcessHubInvestigation[]; defectColumns?: readonly string[]; contextFilter?: SpecLookupContext; diff --git a/packages/hooks/src/__tests__/useSessionCanvasFilters.test.ts b/packages/hooks/src/__tests__/useSessionCanvasFilters.test.ts new file mode 100644 index 000000000..c09bc78d4 --- /dev/null +++ b/packages/hooks/src/__tests__/useSessionCanvasFilters.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useSessionCanvasFilters } from '../useSessionCanvasFilters'; + +describe('useSessionCanvasFilters', () => { + it('starts with cumulative timeline and no scope or Pareto filter', () => { + const { result } = renderHook(() => useSessionCanvasFilters()); + + expect(result.current.timelineWindow).toEqual({ kind: 'cumulative' }); + expect(result.current.scopeFilter).toBeUndefined(); + expect(result.current.paretoGroupBy).toBeUndefined(); + }); + + it('updates all filter values in session-local React state', () => { + const { result } = renderHook(() => useSessionCanvasFilters()); + + act(() => { + result.current.setTimelineWindow({ kind: 'rolling', windowDays: 14 }); + result.current.setScopeFilter({ factor: 'Line', values: ['A'] }); + result.current.setParetoGroupBy('Shift'); + }); + + expect(result.current.timelineWindow).toEqual({ kind: 'rolling', windowDays: 14 }); + expect(result.current.scopeFilter).toEqual({ factor: 'Line', values: ['A'] }); + expect(result.current.paretoGroupBy).toBe('Shift'); + }); + + it('clears optional filters with undefined', () => { + const { result } = renderHook(() => useSessionCanvasFilters()); + + act(() => { + result.current.setScopeFilter({ factor: 'Line', values: ['A'] }); + result.current.setParetoGroupBy('Shift'); + }); + act(() => { + result.current.setScopeFilter(undefined); + result.current.setParetoGroupBy(undefined); + }); + + expect(result.current.scopeFilter).toBeUndefined(); + expect(result.current.paretoGroupBy).toBeUndefined(); + }); + + it('retains state across rerenders without persistence callbacks', () => { + const persistenceSpy = vi.fn(); + const { result, rerender } = renderHook(() => useSessionCanvasFilters()); + + act(() => result.current.setTimelineWindow({ kind: 'rolling', windowDays: 30 })); + const { setTimelineWindow, setScopeFilter, setParetoGroupBy } = result.current; + + rerender(); + + expect(result.current.timelineWindow).toEqual({ kind: 'rolling', windowDays: 30 }); + expect(result.current.setTimelineWindow).toBe(setTimelineWindow); + expect(result.current.setScopeFilter).toBe(setScopeFilter); + expect(result.current.setParetoGroupBy).toBe(setParetoGroupBy); + expect(persistenceSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 7c92cd816..b1bfebb4a 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -558,3 +558,7 @@ export { type UseCanvasFiltersArgs, type UseCanvasFiltersResult, } from './useCanvasFilters'; +export { + useSessionCanvasFilters, + type UseSessionCanvasFiltersResult, +} from './useSessionCanvasFilters'; diff --git a/packages/hooks/src/useProductionLineGlanceData.ts b/packages/hooks/src/useProductionLineGlanceData.ts index 1f95f396e..e40bc7734 100644 --- a/packages/hooks/src/useProductionLineGlanceData.ts +++ b/packages/hooks/src/useProductionLineGlanceData.ts @@ -10,17 +10,22 @@ import { applyWindow } from '@variscout/core'; import type { DataRow, IChartDataPoint, - StatsResult, ProcessHub, ProcessHubInvestigation, ProcessHubInvestigationMetadata, + StatsResult, TimelineWindow, } from '@variscout/core'; const DEFAULT_CPK_TARGET = 1.33; +export type ProductionLineGlanceHub = Pick< + ProcessHub, + 'id' | 'canonicalProcessMap' | 'contextColumns' +>; + export interface UseProductionLineGlanceDataInput { - hub: ProcessHub; + hub: ProductionLineGlanceHub; members: readonly ProcessHubInvestigation[]; rowsByInvestigation: ReadonlyMap; contextFilter: SpecLookupContext; diff --git a/packages/hooks/src/useSessionCanvasFilters.ts b/packages/hooks/src/useSessionCanvasFilters.ts new file mode 100644 index 000000000..af2fa7e81 --- /dev/null +++ b/packages/hooks/src/useSessionCanvasFilters.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; +import type { ScopeFilter, TimelineWindow } from '@variscout/core'; +import type { UseCanvasFiltersResult } from './useCanvasFilters'; + +const DEFAULT_CUMULATIVE: TimelineWindow = { kind: 'cumulative' }; + +export type UseSessionCanvasFiltersResult = UseCanvasFiltersResult; + +export function useSessionCanvasFilters(): UseSessionCanvasFiltersResult { + const [timelineWindow, setTimelineWindow] = useState(DEFAULT_CUMULATIVE); + const [scopeFilter, setScopeFilter] = useState(undefined); + const [paretoGroupBy, setParetoGroupBy] = useState(undefined); + + return { + timelineWindow, + setTimelineWindow, + scopeFilter, + setScopeFilter, + paretoGroupBy, + setParetoGroupBy, + }; +} diff --git a/packages/ui/CLAUDE.md b/packages/ui/CLAUDE.md index a547a7583..2a7a2733d 100644 --- a/packages/ui/CLAUDE.md +++ b/packages/ui/CLAUDE.md @@ -15,6 +15,7 @@ - PI Panel tabs config via `PIPanelBase` (PITabConfig API). Store-aware tab content is the default. - `TimelineWindowPicker` lives in the `DashboardLayoutBase` chrome (above the chart grid), not in `FilterContextBar`. Slot ownership: chrome above grid = window; FilterContextBar = per-chart filter summary. - Error service (`errorService`) and hooks (`useIsMobile`, `useTheme`, `useGlossary`, `BREAKPOINTS`) are also exported from @variscout/ui. +- `Canvas` is the canonical canvas implementation for FRAME / Process Hub migration work. `LayeredProcessViewWithCapability` and `ProcessMapBase` remain deprecated compatibility wrappers/stubs during the migration; new work should target `Canvas` or `CanvasWorkspace`. ## Per-characteristic specs (Phase B) diff --git a/packages/ui/src/components/Canvas/CanvasWorkspace.tsx b/packages/ui/src/components/Canvas/CanvasWorkspace.tsx new file mode 100644 index 000000000..1b9faffc4 --- /dev/null +++ b/packages/ui/src/components/Canvas/CanvasWorkspace.tsx @@ -0,0 +1,291 @@ +import React from 'react'; +import { + useProductionLineGlanceData, + useProductionLineGlanceFilter, + useProductionLineGlanceOpsToggle, + useSessionCanvasFilters, + useTranslation, +} from '@variscout/hooks'; +import { + detectColumns, + detectScopeFromMap, + rankYCandidates, + type ColumnAnalysis, + type DataRow, + type ProcessContext, + type ProcessHubInvestigation, + type SpecLimits, + type TimelineWindow, +} from '@variscout/core'; +import { createEmptyMap, detectGaps, type ProcessMap } from '@variscout/core/frame'; +import { Canvas } from './index'; +import { CanvasFilterChips } from '../CanvasFilterChips'; +import { FrameViewB0, type FrameViewB0YCandidate } from '../FrameViewB0'; +import type { XCandidate } from '../XPickerSection'; + +const DEFAULT_CPK_TARGET = 1.33; + +export interface CanvasWorkspaceProps { + rawData: readonly DataRow[]; + outcome: string | null; + factors: readonly string[]; + measureSpecs: Record; + processContext: ProcessContext | null; + setOutcome: (outcome: string | null) => void; + setFactors: (factors: string[]) => void; + setMeasureSpec: (column: string, partial: Partial) => void; + setProcessContext: (context: ProcessContext | null) => void; + onSeeData: () => void; +} + +function formatTimelineWindow(w: TimelineWindow): string { + if (w.kind === 'cumulative') return 'Cumulative'; + if (w.kind === 'fixed') return `${w.startISO} → ${w.endISO}`; + if (w.kind === 'rolling') return `Last ${w.windowDays}d`; + if (w.kind === 'openEnded') return `From ${w.startISO}`; + return (w as { kind: string }).kind; +} + +function toggleArray(arr: readonly T[], item: T): T[] { + return arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item]; +} + +function numericValuesFor(column: string, rows: readonly DataRow[]): number[] { + const out: number[] = []; + for (const row of rows) { + const raw = row[column]; + const n = typeof raw === 'number' ? raw : Number(raw); + if (Number.isFinite(n)) out.push(n); + } + return out; +} + +function computeMeanPlusMinusSigma( + outcome: string | null, + rawData: readonly DataRow[] +): { target?: number; usl?: number; lsl?: number } | undefined { + if (!outcome) return undefined; + const values = numericValuesFor(outcome, rawData); + if (values.length < 2) return undefined; + const mean = values.reduce((s, v) => s + v, 0) / values.length; + const variance = values.reduce((s, v) => s + (v - mean) * (v - mean), 0) / (values.length - 1); + const sigma = Math.sqrt(variance); + if (!Number.isFinite(sigma)) return undefined; + return { target: mean, usl: mean + 3 * sigma, lsl: mean - 3 * sigma }; +} + +function levelsFor( + column: string, + rows: readonly DataRow[] +): ReadonlyArray<{ label: string; count: number }> { + const counts = new Map(); + for (const row of rows) { + const raw = row[column]; + if (raw === null || raw === undefined || raw === '') continue; + const label = String(raw); + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return Array.from(counts.entries()).map(([label, count]) => ({ label, count })); +} + +function toXCandidate(column: ColumnAnalysis, rows: readonly DataRow[]): XCandidate { + if (column.type === 'numeric') { + return { column, numericValues: numericValuesFor(column.name, rows) }; + } + return { column, levels: levelsFor(column.name, rows) }; +} + +export const CanvasWorkspace: React.FC = ({ + rawData, + outcome, + factors, + measureSpecs, + processContext, + setOutcome, + setFactors, + setMeasureSpec, + setProcessContext, + onSeeData, +}) => { + const { t } = useTranslation(); + const availableColumns = React.useMemo( + () => (rawData.length > 0 ? Object.keys(rawData[0]) : []), + [rawData] + ); + + const map: ProcessMap = processContext?.processMap ?? createEmptyMap(); + const scope = detectScopeFromMap(map); + const ctsColumn = map.ctsColumn; + const ctsSpecs = ctsColumn ? measureSpecs[ctsColumn] : undefined; + + const gaps = React.useMemo( + () => + detectGaps({ + processMap: map, + columns: availableColumns, + outcomeColumn: outcome ?? undefined, + specs: ctsSpecs, + }), + [map, availableColumns, outcome, ctsSpecs] + ); + + const handleChange = React.useCallback( + (next: ProcessMap) => { + const baseContext: ProcessContext = processContext ?? {}; + setProcessContext({ ...baseContext, processMap: next }); + }, + [processContext, setProcessContext] + ); + + const handleSpecsChange = React.useCallback( + (next: Partial) => { + if (!ctsColumn) return; + setMeasureSpec(ctsColumn, next); + }, + [ctsColumn, setMeasureSpec] + ); + + const filter = useProductionLineGlanceFilter(); + const ops = useProductionLineGlanceOpsToggle(); + const { + timelineWindow, + setTimelineWindow, + scopeFilter, + setScopeFilter, + paretoGroupBy, + setParetoGroupBy, + } = useSessionCanvasFilters(); + + const canvasFilterChipsNode = ( + setTimelineWindow({ kind: 'cumulative' })} + onClearScopeFilter={() => setScopeFilter(undefined)} + onClearParetoGroupBy={() => setParetoGroupBy(undefined)} + /> + ); + + const previewRollup = React.useMemo(() => { + const previewHub = { + id: 'frame-preview', + canonicalProcessMap: map, + contextColumns: [], + }; + return { + hub: previewHub, + members: [] as ProcessHubInvestigation[], + rowsByInvestigation: new Map>(), + }; + }, [map]); + + const data = useProductionLineGlanceData({ + hub: previewRollup.hub, + members: previewRollup.members, + rowsByInvestigation: previewRollup.rowsByInvestigation, + contextFilter: filter.value, + }); + + const detected = React.useMemo( + () => (rawData.length > 0 ? detectColumns([...rawData]) : null), + [rawData] + ); + const runOrderColumn = detected?.timeColumn ?? null; + const columnAnalysis = React.useMemo(() => detected?.columnAnalysis ?? [], [detected]); + + const yCandidates: FrameViewB0YCandidate[] = React.useMemo(() => { + const ranked = rankYCandidates(columnAnalysis); + return ranked.map(({ column }) => ({ + column, + numericValues: numericValuesFor(column.name, rawData), + })); + }, [columnAnalysis, rawData]); + + const xCandidates: XCandidate[] = React.useMemo(() => { + return columnAnalysis + .filter( + col => + col.name !== outcome && + col.name !== runOrderColumn && + (col.type === 'numeric' || col.type === 'categorical') + ) + .map(col => toXCandidate(col, rawData)); + }, [columnAnalysis, outcome, runOrderColumn, rawData]); + + const yspecSuggestion = React.useMemo( + () => computeMeanPlusMinusSigma(outcome, rawData), + [outcome, rawData] + ); + + const handleConfirmYSpec = React.useCallback( + (values: Partial) => { + if (!outcome) return; + setMeasureSpec(outcome, values); + }, + [outcome, setMeasureSpec] + ); + + const canvasNode = ( + setMeasureSpec(column, next)} + data={data} + filter={{ + availableContext: data.availableContext, + contextValueOptions: data.contextValueOptions, + value: filter.value, + onChange: filter.onChange, + }} + mode={ops.mode} + onModeChange={ops.setMode} + showGaps={scope !== 'b0'} + canvasFilterChips={canvasFilterChipsNode} + /> + ); + + if (scope === 'b0') { + return ( +
+ setFactors(toggleArray(factors, name))} + runOrderColumn={runOrderColumn} + currentYSpec={outcome ? measureSpecs[outcome] : undefined} + yspecSuggestion={yspecSuggestion} + defaultCpkTarget={DEFAULT_CPK_TARGET} + onConfirmYSpec={handleConfirmYSpec} + onSeeData={onSeeData} + > + {canvasNode} + +
+ ); + } + + return ( +
+
+
+

{t('frame.b1.heading')}

+

{t('frame.b1.description')}

+
+ {canvasNode} +
+
+ ); +}; diff --git a/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx b/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx new file mode 100644 index 000000000..ad9b80a6f --- /dev/null +++ b/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@variscout/charts', async () => { + const React = await import('react'); + return { + IChart: () => React.createElement('div', { 'data-testid': 'mock-cpk-trend' }), + CapabilityGapTrendChart: () => React.createElement('div', { 'data-testid': 'mock-gap-trend' }), + CapabilityBoxplot: () => + React.createElement('div', { 'data-testid': 'mock-capability-boxplot' }), + StepErrorPareto: () => React.createElement('div', { 'data-testid': 'mock-step-pareto' }), + }; +}); + +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ProcessMap } from '@variscout/core/frame'; +import { Canvas } from '../index'; + +const map: ProcessMap = { + version: 1, + nodes: [], + tributaries: [], + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', +}; + +const data = { + cpkTrend: { data: [], stats: null, specs: { target: 1.33 } }, + cpkGapTrend: { series: [], stats: null }, + capabilityNodes: [], + errorSteps: [], +}; + +const filter = { + availableContext: { hubColumns: [] }, + contextValueOptions: {}, + value: {}, + onChange: vi.fn(), +}; + +describe('Canvas', () => { + it('owns the three-band rendering path without changing behavior', () => { + const onModeChange = vi.fn(); + + render( + {}} + data={data} + filter={filter} + mode="spatial" + onModeChange={onModeChange} + /> + ); + + expect(screen.getByTestId('layered-process-view')).toBeInTheDocument(); + expect(screen.getByTestId('ops-band-dashboard')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /show temporal trends/i })); + + expect(onModeChange).toHaveBeenCalledWith('full'); + }); +}); diff --git a/packages/ui/src/components/ProcessMap/__tests__/ProcessMapBase.test.tsx b/packages/ui/src/components/Canvas/__tests__/CanvasProcessMap.test.tsx similarity index 96% rename from packages/ui/src/components/ProcessMap/__tests__/ProcessMapBase.test.tsx rename to packages/ui/src/components/Canvas/__tests__/CanvasProcessMap.test.tsx index dcfe11fee..bd7873144 100644 --- a/packages/ui/src/components/ProcessMap/__tests__/ProcessMapBase.test.tsx +++ b/packages/ui/src/components/Canvas/__tests__/CanvasProcessMap.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; -import { ProcessMapBase } from '../ProcessMapBase'; +import { ProcessMapBase } from '../internal/ProcessMapBase'; import type { ProcessMap, Gap } from '@variscout/core/frame'; const isoNow = () => new Date('2026-04-18T12:00:00.000Z').toISOString(); @@ -36,7 +36,7 @@ const mapWithTwoSteps = (): ProcessMap => ({ const COLUMNS = ['Fill_Weight', 'Machine', 'Shift', 'Lot', 'Timestamp']; -describe('ProcessMapBase — rendering', () => { +describe('Canvas internal process map — rendering', () => { it('renders steps in `order`, regardless of array order', () => { const map = emptyMap(); map.nodes = [ @@ -77,7 +77,7 @@ describe('ProcessMapBase — rendering', () => { }); }); -describe('ProcessMapBase — step CRUD', () => { +describe('Canvas internal process map — step CRUD', () => { it('invokes onChange with a new step appended when the "+ step" button is clicked', () => { const onChange = vi.fn(); render(); @@ -144,7 +144,7 @@ describe('ProcessMapBase — step CRUD', () => { }); }); -describe('ProcessMapBase — tributary CRUD', () => { +describe('Canvas internal process map — tributary CRUD', () => { it('adds a tributary to a step via the inline selector', () => { const onChange = vi.fn(); render( @@ -194,7 +194,7 @@ describe('ProcessMapBase — tributary CRUD', () => { }); }); -describe('ProcessMapBase — CTS / ocean', () => { +describe('Canvas internal process map — CTS / ocean', () => { it('sets the CTS column via the ocean dropdown', () => { const onChange = vi.fn(); render(); @@ -269,7 +269,7 @@ describe('ProcessMapBase — CTS / ocean', () => { }); }); -describe('ProcessMapBase — hunches', () => { +describe('Canvas internal process map — hunches', () => { it('adds a hunch via the text input + "+ hunch" button', () => { const onChange = vi.fn(); render( @@ -310,7 +310,7 @@ describe('ProcessMapBase — hunches', () => { }); }); -describe('ProcessMapBase — gap rendering', () => { +describe('Canvas internal process map — gap rendering', () => { const requiredGap: Gap = { kind: 'missing-spec-limits', severity: 'required', @@ -377,7 +377,7 @@ describe('ProcessMapBase — gap rendering', () => { }); }); -describe('ProcessMapBase — disabled mode', () => { +describe('Canvas internal process map — disabled mode', () => { it('hides destructive / additive controls when disabled', () => { render( { }); }); -describe('ProcessMapBase — per-step CTQ specs editor (Task B)', () => { +describe('Canvas internal process map — per-step CTQ specs editor (Task B)', () => { it('renders the per-step specs editor when the step has a CTQ column and onStepSpecsChange is provided', () => { render( { + const actual = await importOriginal(); + const React = await import('react'); + return { + ...actual, + IChart: () => React.createElement('div', { 'data-testid': 'mock-cpk-trend' }), + CapabilityGapTrendChart: () => React.createElement('div', { 'data-testid': 'mock-gap-trend' }), + CapabilityBoxplot: () => + React.createElement('div', { 'data-testid': 'mock-capability-boxplot' }), + StepErrorPareto: () => React.createElement('div', { 'data-testid': 'mock-step-pareto' }), + }; +}); + +const canvasFiltersStateRef: { + current: { + timelineWindow: TimelineWindow; + scopeFilter: ScopeFilter | undefined; + paretoGroupBy: string | undefined; + setTimelineWindow: ReturnType; + setScopeFilter: ReturnType; + setParetoGroupBy: ReturnType; + }; +} = { + current: { + timelineWindow: { kind: 'cumulative' }, + scopeFilter: undefined, + paretoGroupBy: undefined, + setTimelineWindow: vi.fn(), + setScopeFilter: vi.fn(), + setParetoGroupBy: vi.fn(), + }, +}; + +vi.mock('@variscout/hooks', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + tf: (key: string, values?: Record) => + values ? `${key} ${Object.values(values).join(' ')}` : key, + }), + useProductionLineGlanceFilter: vi.fn(() => ({ + value: {}, + onChange: vi.fn(), + })), + useProductionLineGlanceOpsToggle: vi.fn(() => ({ + mode: 'spatial' as const, + setMode: vi.fn(), + toggle: vi.fn(), + })), + useProductionLineGlanceData: vi.fn(() => ({ + cpkTrend: { data: [], stats: null, specs: {} }, + cpkGapTrend: { series: [], stats: null }, + capabilityNodes: [], + errorSteps: [], + availableContext: { hubColumns: [], tributaryGroups: [] }, + contextValueOptions: {}, + })), + useSessionCanvasFilters: vi.fn(() => canvasFiltersStateRef.current), +})); + +import { CanvasWorkspace } from '../CanvasWorkspace'; + +const rawData = [ + { Fill_Weight: 12, Bake_Time: 30, Machine: 'A' }, + { Fill_Weight: 13, Bake_Time: 31, Machine: 'B' }, + { Fill_Weight: 11, Bake_Time: 29, Machine: 'A' }, +]; + +const emptyMap = (): ProcessMap => ({ + version: 1, + nodes: [], + tributaries: [], + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', +}); + +const mapWithStep = (): ProcessMap => ({ + version: 1, + nodes: [{ id: 'step-1', name: 'Bake', order: 0, ctqColumn: 'Bake_Time' }], + tributaries: [], + ctsColumn: 'Fill_Weight', + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', +}); + +function renderWorkspace(overrides: Partial> = {}) { + const props: React.ComponentProps = { + rawData, + outcome: 'Fill_Weight', + factors: [], + measureSpecs: {}, + processContext: null, + setOutcome: vi.fn(), + setFactors: vi.fn(), + setMeasureSpec: vi.fn(), + setProcessContext: vi.fn(), + onSeeData: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe('CanvasWorkspace', () => { + beforeEach(() => { + canvasFiltersStateRef.current = { + timelineWindow: { kind: 'cumulative' }, + scopeFilter: undefined, + paretoGroupBy: undefined, + setTimelineWindow: vi.fn(), + setScopeFilter: vi.fn(), + setParetoGroupBy: vi.fn(), + }; + }); + + it('renders b0 with the lightweight picker and collapsed canvas expander', () => { + renderWorkspace({ processContext: { processMap: emptyMap() } }); + + expect(screen.getByTestId('frame-view-b0')).toBeInTheDocument(); + expect(screen.getByTestId('y-picker-section')).toBeInTheDocument(); + expect(screen.queryByTestId('layered-process-view')).toBeNull(); + }); + + it('renders b1/b2 directly with three bands and the operations dashboard', () => { + renderWorkspace({ processContext: { processMap: mapWithStep() } }); + + expect(screen.getByTestId('layered-process-view')).toBeInTheDocument(); + expect(screen.getByTestId('band-outcome')).toBeInTheDocument(); + expect(screen.getByTestId('band-process-flow')).toBeInTheDocument(); + expect(screen.getByTestId('band-operations')).toBeInTheDocument(); + expect(screen.getByTestId('ops-band-dashboard')).toBeInTheDocument(); + }); + + it('writes per-step CTQ specs through the provided spec callback', () => { + const setMeasureSpec = vi.fn(); + renderWorkspace({ + processContext: { processMap: mapWithStep() }, + measureSpecs: { Bake_Time: { target: 30, lsl: 28, usl: 32 } }, + setMeasureSpec, + }); + + fireEvent.change(screen.getByTestId('process-map-step-specs-step-1-usl'), { + target: { value: '34' }, + }); + + expect(setMeasureSpec).toHaveBeenCalledWith( + 'Bake_Time', + expect.objectContaining({ target: 30, lsl: 28, usl: 34 }) + ); + }); + + it('writes CTS specs through the provided spec callback', () => { + const setMeasureSpec = vi.fn(); + renderWorkspace({ + processContext: { processMap: mapWithStep() }, + measureSpecs: { Fill_Weight: { target: 12, lsl: 11, usl: 13 } }, + setMeasureSpec, + }); + + fireEvent.change(screen.getByTestId('process-map-ocean-cpk-target'), { + target: { value: '1.67' }, + }); + + expect(setMeasureSpec).toHaveBeenCalledWith('Fill_Weight', { + target: 12, + lsl: 11, + usl: 13, + cpkTarget: 1.67, + } satisfies Partial); + }); + + it('fires the See Data callback from b0', () => { + const onSeeData = vi.fn(); + renderWorkspace({ processContext: { processMap: emptyMap() }, onSeeData }); + + fireEvent.click(screen.getByTestId('see-the-data-cta')); + + expect(onSeeData).toHaveBeenCalledTimes(1); + }); + + it('renders and clears session canvas filter chips', () => { + canvasFiltersStateRef.current = { + ...canvasFiltersStateRef.current, + timelineWindow: { kind: 'rolling', windowDays: 7 }, + }; + renderWorkspace({ processContext: { processMap: mapWithStep() } }); + + expect(screen.getByTestId('filter-chip-window')).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText(/Clear Last 7d/i)); + + expect(canvasFiltersStateRef.current.setTimelineWindow).toHaveBeenCalledWith({ + kind: 'cumulative', + }); + }); +}); diff --git a/packages/ui/src/components/Canvas/index.tsx b/packages/ui/src/components/Canvas/index.tsx new file mode 100644 index 000000000..e8f792a64 --- /dev/null +++ b/packages/ui/src/components/Canvas/index.tsx @@ -0,0 +1,212 @@ +/** + * Canvas is the canonical FRAME canvas surface for process-map, outcome, and + * operations-band rendering. + */ +import React from 'react'; +import type { ProcessMap, Gap } from '@variscout/core/frame'; +import type { SpecLimits } from '@variscout/core'; +import { + ProductionLineGlanceDashboard, + type ProductionLineGlanceFilterStripProps, + ProductionLineGlanceFilterStrip, +} from '../ProductionLineGlanceDashboard'; +import type { ProductionLineGlanceDashboardProps } from '../ProductionLineGlanceDashboard/types'; +import { ProcessMapBase } from './internal/ProcessMapBase'; + +/** + * Canonical FRAME canvas surface. + * + * Canvas renders the controlled process-map, outcome, and operations bands. It + * owns no app store, persistence, or session state: callers pass the full + * current canvas state in props and receive all edits through callbacks. That + * keeps the surface ready for later CRDT-backed ownership because every map and + * spec mutation remains explicit at the boundary. + * + * `CanvasWorkspace` owns b0/b1 routing and app-session filter composition. This + * component stays focused on the rendered canvas bands. + */ +export type ProductionLineGlanceOpsMode = 'spatial' | 'full'; + +/** + * Controlled inputs for the canonical Canvas implementation. + * + * `map`, specs, dashboard data, filter state, and operations mode are supplied + * by the caller. `onChange`, `onSpecsChange`, `onStepSpecsChange`, and + * `onModeChange` are the only mutation channels; Canvas must not write stores + * or persistence directly. + */ +export interface CanvasProps { + map: ProcessMap; + availableColumns: string[]; + onChange: (next: ProcessMap) => void; + gaps?: Gap[]; + disabled?: boolean; + target?: number; + usl?: number; + lsl?: number; + cpkTarget?: number; + onSpecsChange?: (next: { + target?: number; + usl?: number; + lsl?: number; + cpkTarget?: number; + }) => void; + stepSpecs?: Record; + onStepSpecsChange?: (column: string, next: SpecLimits) => void; + canvasFilterChips?: React.ReactNode; + showGaps?: boolean; + data: Pick< + ProductionLineGlanceDashboardProps, + 'cpkTrend' | 'cpkGapTrend' | 'capabilityNodes' | 'errorSteps' + >; + filter: ProductionLineGlanceFilterStripProps; + mode: ProductionLineGlanceOpsMode; + onModeChange: (next: ProductionLineGlanceOpsMode) => void; + onStepClick?: (nodeId: string) => void; +} + +export const Canvas: React.FC = ({ + map, + availableColumns, + onChange, + gaps, + disabled, + target, + usl, + lsl, + cpkTarget, + onSpecsChange, + stepSpecs, + onStepSpecsChange, + canvasFilterChips, + showGaps = true, + data, + filter, + mode, + onModeChange, + onStepClick, +}) => { + const hasOutcomeData = + target !== undefined || usl !== undefined || lsl !== undefined || cpkTarget !== undefined; + const isFull = mode === 'full'; + const affordanceLabel = isFull ? 'Hide temporal trends' : 'Show temporal trends'; + const affordanceArrow = isFull ? '↓' : '↑'; + + const tributariesContent = + map.tributaries.length > 0 ? ( +
    + {map.tributaries.map(trib => { + const parentStep = map.nodes.find(n => n.id === trib.stepId); + const stepLabel = parentStep?.name ?? 'Unmapped'; + return ( +
  • + {trib.column} + at {stepLabel} +
  • + ); + })} +
+ ) : ( +

No factors mapped yet

+ ); + + return ( +
+ {canvasFilterChips ? ( +
{canvasFilterChips}
+ ) : null} +
+ +
+ +
+

Outcome

+ {hasOutcomeData ? ( +
+ {target !== undefined && ( +
+
Target:
+
{target}
+
+ )} + {usl !== undefined && ( +
+
USL:
+
{usl}
+
+ )} + {lsl !== undefined && ( +
+
LSL:
+
{lsl}
+
+ )} + {cpkTarget !== undefined && ( +
+
Cpk target:
+
{cpkTarget}
+
+ )} +
+ ) : ( +

No outcome target set

+ )} + +
+

+ Mapped factors +

+ {tributariesContent} +
+
+ +
+

Process Flow

+
+ +
+
+ +
+

Operations

+
+
+ +
+ +
+
+
+
+
+ ); +}; + +export default Canvas; diff --git a/packages/ui/src/components/Canvas/internal/ProcessMapBase.tsx b/packages/ui/src/components/Canvas/internal/ProcessMapBase.tsx new file mode 100644 index 000000000..6f43e7382 --- /dev/null +++ b/packages/ui/src/components/Canvas/internal/ProcessMapBase.tsx @@ -0,0 +1,802 @@ +/** + * ProcessMapBase — the interactive river-styled SIPOC Process Map rendered + * inside the Canvas surface. + * + * See ADR-070 and the design spec `docs/superpowers/specs/2026-04-18-frame- + * process-map-design.md`. Composes three regions on one canvas: + * + * - a left→right spine of process steps (SIPOC temporal axis), + * - tributaries (little xs / factors) feeding each step from both banks, + * - an ocean at the right with the CTS (customer-felt outcome) + specs. + * + * V1 interactions are deliberately structured (buttons, dropdowns, inline + * inputs) — not a freeform drag-and-drop canvas. That comes in V2+. + * + * Props-based. No store coupling. Parent Canvas owns the map state and + * passes `onChange` callbacks + detected gaps. + */ + +import React from 'react'; +import type { Gap, ProcessMap, ProcessMapTributary, ProcessMapHunch } from '@variscout/core/frame'; +import type { SpecLimits } from '@variscout/core'; + +// ──────────────────────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────────────────────── + +export interface ProcessMapBaseProps { + /** The current map. Parent owns state; pass a new object on every edit. */ + map: ProcessMap; + /** Column names available from the uploaded dataset. */ + availableColumns: string[]; + /** Called with the next map whenever the user edits. */ + onChange: (next: ProcessMap) => void; + /** Gaps detected by `detectGaps()` in the parent — rendered inline. */ + gaps?: Gap[]; + /** Disable all edits (read-only mode, e.g. Analysis sidebar thumbnail). */ + disabled?: boolean; + /** Optional target value for the CTS (current UI: delegated to parent form). */ + target?: number; + /** Optional USL. */ + usl?: number; + /** Optional LSL. */ + lsl?: number; + /** Optional per-characteristic Cpk target ("capability bar" for the CTS column). */ + cpkTarget?: number; + /** Called when target/usl/lsl/cpkTarget change. Single shape; callers refactor. */ + onSpecsChange?: (next: { + target?: number; + usl?: number; + lsl?: number; + cpkTarget?: number; + }) => void; + /** + * Per-CTQ-column specs lookup. Each StepCard reads `stepSpecs[step.ctqColumn]` + * to render its own USL / LSL / target / cpkTarget editor. Mirrors the Ocean + * pattern (V1 Phase D) — AIAG control plans assume each step's CTQ has its + * own quality requirement. + */ + stepSpecs?: Record; + /** + * Called when a StepCard's specs change. `column` is the CTQ column for that + * step. The full `SpecLimits` shape is passed so consumers can `setMeasureSpec(column, next)`. + */ + onStepSpecsChange?: (column: string, next: SpecLimits) => void; + /** + * Whether to render the GapStrip warning bar. Defaults to `true` for backward + * compatibility with b1+ (process-map authoring) flows. The b0 FrameView passes + * `false` because the lightweight investigator entry uses inline `+ add spec` + * affordances and a soft Capability-tab prompt instead of upfront warnings. + */ + showGaps?: boolean; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────────── + +const uid = (prefix: string): string => `${prefix}-${crypto.randomUUID()}`; + +const bumpUpdated = (map: ProcessMap): ProcessMap => ({ + ...map, + updatedAt: new Date().toISOString(), +}); + +/** Gaps scoped to a given step, for inline rendering next to the step card. */ +const gapsForStep = (gaps: Gap[] | undefined, stepId: string): Gap[] => + (gaps ?? []).filter(g => g.stepId === stepId); + +/** Gaps that apply to the whole map (no stepId) — rendered in the GapStrip. */ +const globalGaps = (gaps: Gap[] | undefined): Gap[] => (gaps ?? []).filter(g => !g.stepId); + +// ──────────────────────────────────────────────────────────────────────────── +// Sub-components (co-located; not exported from the package) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * SpecsGrid — shared 2x2 input grid for editing USL / LSL / target / cpkTarget. + * + * Used by both `OceanCard` (CTS column) and `StepCard` (per-step CTQ column). + * The grid is fully controlled: it renders the four numeric inputs and emits + * the full `SpecLimits` shape on each change so callers can route to either + * the project-wide `setSpecs` or the per-column `setMeasureSpec(column, …)`. + * + * `idPrefix` and `ariaPrefix` parameterise the data-testid + aria-label values + * so the same grid renders distinct accessibility names per surface. + */ +interface SpecsGridProps { + target?: number; + usl?: number; + lsl?: number; + cpkTarget?: number; + disabled?: boolean; + idPrefix: string; + ariaPrefix: string; + onChange: (next: SpecLimits) => void; +} + +const toNum = (s: string): number | undefined => { + if (s === '') return undefined; + const n = Number(s); + return Number.isFinite(n) ? n : undefined; +}; + +const SpecsGrid: React.FC = ({ + target, + usl, + lsl, + cpkTarget, + disabled, + idPrefix, + ariaPrefix, + onChange, +}) => { + return ( +
+ + + + +
+ ); +}; + +interface StepCardProps { + step: ProcessMap['nodes'][number]; + tributaries: ProcessMapTributary[]; + subgroupAxes: string[]; + availableColumns: string[]; + gaps: Gap[]; + disabled?: boolean; + /** Per-step CTQ specs (USL/LSL/target/cpkTarget). Only rendered when ctqColumn is set. */ + ctqSpecs?: SpecLimits; + onRename: (name: string) => void; + onCtqChange: (column: string | undefined) => void; + onRemove: () => void; + onAddTributary: (column: string) => void; + onRemoveTributary: (tributaryId: string) => void; + onToggleSubgroupAxis: (tributaryId: string) => void; + /** Called with the full new SpecLimits shape when any per-step spec input changes. */ + onCtqSpecsChange?: (next: SpecLimits) => void; +} + +const StepCard: React.FC = ({ + step, + tributaries, + subgroupAxes, + availableColumns, + gaps, + disabled, + ctqSpecs, + onRename, + onCtqChange, + onRemove, + onAddTributary, + onRemoveTributary, + onToggleSubgroupAxis, + onCtqSpecsChange, +}) => { + const [newTribCol, setNewTribCol] = React.useState(''); + const availableForTrib = availableColumns.filter( + c => !tributaries.some(t => t.column === c) && c !== step.ctqColumn + ); + + return ( +
+
+ onRename(e.target.value)} + disabled={disabled} + placeholder="Step name" + aria-label={`Step ${step.order + 1} name`} + className="flex-1 text-sm font-medium bg-transparent border-none focus:outline-none focus:ring-1 focus:ring-edge-strong rounded px-1 disabled:text-content-secondary" + data-testid={`process-map-step-name-${step.id}`} + /> + {!disabled && ( + + )} +
+ + + + {step.ctqColumn !== undefined && onCtqSpecsChange && ( + + onCtqSpecsChange({ ...next, characteristicType: ctqSpecs?.characteristicType }) + } + /> + )} + + {tributaries.length > 0 && ( +
    + {tributaries.map(t => { + const isAxis = subgroupAxes.includes(t.id); + return ( +
  • + + {!disabled && ( + + )} +
  • + ); + })} +
+ )} + + {!disabled && availableForTrib.length > 0 && ( +
+ + +
+ )} + + {gaps.length > 0 && ( +
    + {gaps.map((g, i) => ( +
  • + ⚠ {g.message} +
  • + ))} +
+ )} +
+ ); +}; + +interface OceanCardProps { + ctsColumn?: string; + availableColumns: string[]; + target?: number; + usl?: number; + lsl?: number; + cpkTarget?: number; + disabled?: boolean; + onCtsChange: (column: string | undefined) => void; + onSpecsChange?: (next: { + target?: number; + usl?: number; + lsl?: number; + cpkTarget?: number; + }) => void; +} + +const OceanCard: React.FC = ({ + ctsColumn, + availableColumns, + target, + usl, + lsl, + cpkTarget, + disabled, + onCtsChange, + onSpecsChange, +}) => { + return ( +
+
Customer outcome (CTS)
+ + {onSpecsChange && ( + + onSpecsChange({ + target: next.target, + usl: next.usl, + lsl: next.lsl, + cpkTarget: next.cpkTarget, + }) + } + /> + )} +
+ ); +}; + +interface HunchListProps { + hunches: ProcessMapHunch[]; + steps: ProcessMap['nodes']; + tributaries: ProcessMapTributary[]; + disabled?: boolean; + onAdd: (text: string, pin: { stepId?: string; tributaryId?: string }) => void; + onRemove: (id: string) => void; +} + +const HunchList: React.FC = ({ + hunches, + steps, + tributaries, + disabled, + onAdd, + onRemove, +}) => { + const [text, setText] = React.useState(''); + const [pinKey, setPinKey] = React.useState(''); + + const pinOptions = [ + ...steps.map(s => ({ key: `step:${s.id}`, label: `step · ${s.name || '(unnamed)'}` })), + ...tributaries.map(t => ({ key: `trib:${t.id}`, label: `x · ${t.label || t.column}` })), + ]; + + const submit = () => { + const t = text.trim(); + if (!t) return; + const [kind, id] = pinKey.split(':'); + onAdd(t, kind === 'step' ? { stepId: id } : kind === 'trib' ? { tributaryId: id } : {}); + setText(''); + setPinKey(''); + }; + + const labelForHunch = (h: ProcessMapHunch): string | undefined => { + if (h.stepId) return steps.find(s => s.id === h.stepId)?.name; + if (h.tributaryId) { + const t = tributaries.find(x => x.id === h.tributaryId); + return t?.label || t?.column; + } + return undefined; + }; + + return ( +
+

Hunches

+ {hunches.length > 0 && ( +
    + {hunches.map(h => { + const pin = labelForHunch(h); + return ( +
  • + ⚑ {h.text} + {pin && pinned · {pin}} + {!disabled && ( + + )} +
  • + ); + })} +
+ )} + {!disabled && ( +
+ setText(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') submit(); + }} + placeholder="What do you think is causing the variation?" + aria-label="Hunch text" + className="flex-1 text-xs bg-surface-primary border border-edge rounded px-2 py-1" + data-testid="process-map-hunch-text" + /> + + +
+ )} +
+ ); +}; + +interface GapStripProps { + gaps: Gap[]; +} + +const GapStrip: React.FC = ({ gaps }) => { + if (gaps.length === 0) return null; + const required = gaps.filter(g => g.severity === 'required'); + const recommended = gaps.filter(g => g.severity === 'recommended'); + return ( +
+

Missing from your map ({gaps.length})

+ {required.length > 0 && ( +
    + {required.map((g, i) => ( +
  • + ● required · {g.message} +
  • + ))} +
+ )} + {recommended.length > 0 && ( +
    + {recommended.map((g, i) => ( +
  • + ○ recommended · {g.message} +
  • + ))} +
+ )} +
+ ); +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Main component +// ──────────────────────────────────────────────────────────────────────────── + +export const ProcessMapBase: React.FC = ({ + map, + availableColumns, + onChange, + gaps, + disabled, + target, + usl, + lsl, + cpkTarget, + onSpecsChange, + stepSpecs, + onStepSpecsChange, + showGaps = true, +}) => { + const sortedSteps = React.useMemo( + () => [...map.nodes].sort((a, b) => a.order - b.order), + [map.nodes] + ); + + const update = (next: ProcessMap) => onChange(bumpUpdated(next)); + + const addStep = () => { + const newOrder = sortedSteps.length; + update({ + ...map, + nodes: [...map.nodes, { id: uid('step'), name: '', order: newOrder }], + }); + }; + + const renameStep = (stepId: string, name: string) => { + update({ + ...map, + nodes: map.nodes.map(n => (n.id === stepId ? { ...n, name } : n)), + }); + }; + + const setStepCtq = (stepId: string, ctqColumn: string | undefined) => { + update({ + ...map, + nodes: map.nodes.map(n => (n.id === stepId ? { ...n, ctqColumn } : n)), + }); + }; + + const removeStep = (stepId: string) => { + const remaining = map.nodes.filter(n => n.id !== stepId); + // Re-pack `order` so it stays 0..N-1 monotonic. + const reordered = [...remaining] + .sort((a, b) => a.order - b.order) + .map((n, i) => ({ ...n, order: i })); + update({ + ...map, + nodes: reordered, + tributaries: map.tributaries.filter(t => t.stepId !== stepId), + hunches: (map.hunches ?? []).filter(h => h.stepId !== stepId), + }); + }; + + const addTributary = (stepId: string, column: string) => { + const newT: ProcessMapTributary = { id: uid('trib'), stepId, column }; + update({ ...map, tributaries: [...map.tributaries, newT] }); + }; + + const removeTributary = (tributaryId: string) => { + update({ + ...map, + tributaries: map.tributaries.filter(t => t.id !== tributaryId), + subgroupAxes: (map.subgroupAxes ?? []).filter(id => id !== tributaryId), + hunches: (map.hunches ?? []).filter(h => h.tributaryId !== tributaryId), + }); + }; + + const toggleSubgroupAxis = (tributaryId: string) => { + const current = map.subgroupAxes ?? []; + const next = current.includes(tributaryId) + ? current.filter(id => id !== tributaryId) + : [...current, tributaryId]; + update({ ...map, subgroupAxes: next }); + }; + + const setCts = (ctsColumn: string | undefined) => { + update({ ...map, ctsColumn }); + }; + + const addHunch = (text: string, pin: { stepId?: string; tributaryId?: string }) => { + const hunch: ProcessMapHunch = { id: uid('hunch'), text, ...pin }; + update({ ...map, hunches: [...(map.hunches ?? []), hunch] }); + }; + + const removeHunch = (hunchId: string) => { + update({ + ...map, + hunches: (map.hunches ?? []).filter(h => h.id !== hunchId), + }); + }; + + return ( +
+
+

Process Map

+ {!disabled && ( + + )} +
+ +
+ {sortedSteps.map((step, i) => ( + + t.stepId === step.id)} + subgroupAxes={map.subgroupAxes ?? []} + availableColumns={availableColumns} + gaps={gapsForStep(gaps, step.id)} + disabled={disabled} + ctqSpecs={step.ctqColumn ? stepSpecs?.[step.ctqColumn] : undefined} + onRename={name => renameStep(step.id, name)} + onCtqChange={col => setStepCtq(step.id, col)} + onRemove={() => removeStep(step.id)} + onAddTributary={col => addTributary(step.id, col)} + onRemoveTributary={removeTributary} + onToggleSubgroupAxis={toggleSubgroupAxis} + onCtqSpecsChange={ + onStepSpecsChange && step.ctqColumn + ? next => onStepSpecsChange(step.ctqColumn!, next) + : undefined + } + /> + {i < sortedSteps.length - 1 && ( + + )} + + ))} + {sortedSteps.length > 0 && ( + + )} + +
+ + + + {showGaps && } +
+ ); +}; diff --git a/packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx b/packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx index 4df4b45dc..149cd4e16 100644 --- a/packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx +++ b/packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx @@ -17,7 +17,7 @@ import React from 'react'; import type { ProcessMap, Gap } from '@variscout/core/frame'; import type { SpecLimits } from '@variscout/core'; -import { ProcessMapBase } from '../ProcessMap/ProcessMapBase'; +import { ProcessMapBase } from '../Canvas/internal/ProcessMapBase'; export interface LayeredProcessViewProps { map: ProcessMap; diff --git a/packages/ui/src/components/LayeredProcessView/LayeredProcessViewWithCapability.tsx b/packages/ui/src/components/LayeredProcessView/LayeredProcessViewWithCapability.tsx index d9dcd2d35..d31a5b48e 100644 --- a/packages/ui/src/components/LayeredProcessView/LayeredProcessViewWithCapability.tsx +++ b/packages/ui/src/components/LayeredProcessView/LayeredProcessViewWithCapability.tsx @@ -1,72 +1,23 @@ /** - * LayeredProcessViewWithCapability — composition wrapper. + * LayeredProcessViewWithCapability — legacy composition wrapper. * - * Mounts ProductionLineGlanceDashboard inside LayeredProcessView's Operations - * band slot, the dashboard's filter strip above the Outcome band, and a - * "Show/Hide temporal trends" affordance for progressive reveal. - * - * Pure props-based composition — state (mode, filter) owned by the consumer. + * Canvas is the canonical FRAME surface. This wrapper remains public during + * the canvas migration and delegates to Canvas without changing props. * * See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md * section "Three surfaces / 1. LayeredProcessView Operations band". */ import React from 'react'; -import { LayeredProcessView, type LayeredProcessViewProps } from './LayeredProcessView'; -import { ProductionLineGlanceDashboard } from '../ProductionLineGlanceDashboard/ProductionLineGlanceDashboard'; -import { - ProductionLineGlanceFilterStrip, - type ProductionLineGlanceFilterStripProps, -} from '../ProductionLineGlanceDashboard/ProductionLineGlanceFilterStrip'; -import type { ProductionLineGlanceDashboardProps } from '../ProductionLineGlanceDashboard/types'; +import { Canvas, type CanvasProps, type ProductionLineGlanceOpsMode } from '../Canvas'; -export type ProductionLineGlanceOpsMode = 'spatial' | 'full'; +export type { ProductionLineGlanceOpsMode }; -export interface LayeredProcessViewWithCapabilityProps extends Omit< - LayeredProcessViewProps, - 'operationsBandContent' | 'filterStripContent' -> { - data: Pick< - ProductionLineGlanceDashboardProps, - 'cpkTrend' | 'cpkGapTrend' | 'capabilityNodes' | 'errorSteps' - >; - filter: ProductionLineGlanceFilterStripProps; - mode: ProductionLineGlanceOpsMode; - onModeChange: (next: ProductionLineGlanceOpsMode) => void; - onStepClick?: (nodeId: string) => void; -} +export type LayeredProcessViewWithCapabilityProps = CanvasProps; export const LayeredProcessViewWithCapability: React.FC = ({ - data, - filter, - mode, - onModeChange, - onStepClick, - ...layeredProps + ...props }) => { - const isFull = mode === 'full'; - const affordanceLabel = isFull ? 'Hide temporal trends' : 'Show temporal trends'; - const affordanceArrow = isFull ? '↓' : '↑'; - - return ( - } - operationsBandContent={ -
- -
- -
-
- } - /> - ); + return ; }; export default LayeredProcessViewWithCapability; diff --git a/packages/ui/src/components/ProcessMap/ProcessMapBase.tsx b/packages/ui/src/components/ProcessMap/ProcessMapBase.tsx index 422ed98fe..d68eb5eef 100644 --- a/packages/ui/src/components/ProcessMap/ProcessMapBase.tsx +++ b/packages/ui/src/components/ProcessMap/ProcessMapBase.tsx @@ -1,807 +1,5 @@ /** - * ProcessMapBase — the interactive river-styled SIPOC Process Map rendered - * in the FRAME workspace. - * - * See ADR-070 and the design spec `docs/superpowers/specs/2026-04-18-frame- - * process-map-design.md`. Composes three regions on one canvas: - * - * - a left→right spine of process steps (SIPOC temporal axis), - * - tributaries (little xs / factors) feeding each step from both banks, - * - an ocean at the right with the CTS (customer-felt outcome) + specs. - * - * V1 interactions are deliberately structured (buttons, dropdowns, inline - * inputs) — not a freeform drag-and-drop canvas. That comes in V2+. - * - * Props-based. No store coupling. Parent (FrameView) owns the map state and - * passes `onChange` callbacks + detected gaps. + * @deprecated Canvas owns process-map rendering. Import Canvas instead. + * This compatibility re-export remains during the canvas migration. */ - -import React from 'react'; -import type { Gap, ProcessMap, ProcessMapTributary, ProcessMapHunch } from '@variscout/core/frame'; -import type { SpecLimits } from '@variscout/core'; - -// ──────────────────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────────────────── - -export interface ProcessMapBaseProps { - /** The current map. Parent owns state; pass a new object on every edit. */ - map: ProcessMap; - /** Column names available from the uploaded dataset. */ - availableColumns: string[]; - /** Called with the next map whenever the user edits. */ - onChange: (next: ProcessMap) => void; - /** Gaps detected by `detectGaps()` in the parent — rendered inline. */ - gaps?: Gap[]; - /** Disable all edits (read-only mode, e.g. Analysis sidebar thumbnail). */ - disabled?: boolean; - /** Optional target value for the CTS (current UI: delegated to parent form). */ - target?: number; - /** Optional USL. */ - usl?: number; - /** Optional LSL. */ - lsl?: number; - /** Optional per-characteristic Cpk target ("capability bar" for the CTS column). */ - cpkTarget?: number; - /** Called when target/usl/lsl/cpkTarget change. Single shape; callers refactor. */ - onSpecsChange?: (next: { - target?: number; - usl?: number; - lsl?: number; - cpkTarget?: number; - }) => void; - /** - * Per-CTQ-column specs lookup. Each StepCard reads `stepSpecs[step.ctqColumn]` - * to render its own USL / LSL / target / cpkTarget editor. Mirrors the Ocean - * pattern (V1 Phase D) — AIAG control plans assume each step's CTQ has its - * own quality requirement. - */ - stepSpecs?: Record; - /** - * Called when a StepCard's specs change. `column` is the CTQ column for that - * step. The full `SpecLimits` shape is passed so consumers can `setMeasureSpec(column, next)`. - */ - onStepSpecsChange?: (column: string, next: SpecLimits) => void; - /** - * Whether to render the GapStrip warning bar. Defaults to `true` for backward - * compatibility with b1+ (process-map authoring) flows. The b0 FrameView passes - * `false` because the lightweight investigator entry uses inline `+ add spec` - * affordances and a soft Capability-tab prompt instead of upfront warnings. - */ - showGaps?: boolean; -} - -// ──────────────────────────────────────────────────────────────────────────── -// Helpers -// ──────────────────────────────────────────────────────────────────────────── - -const uid = (prefix: string): string => - `${prefix}-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`; - -const bumpUpdated = (map: ProcessMap): ProcessMap => ({ - ...map, - updatedAt: new Date().toISOString(), -}); - -/** Gaps scoped to a given step, for inline rendering next to the step card. */ -const gapsForStep = (gaps: Gap[] | undefined, stepId: string): Gap[] => - (gaps ?? []).filter(g => g.stepId === stepId); - -/** Gaps that apply to the whole map (no stepId) — rendered in the GapStrip. */ -const globalGaps = (gaps: Gap[] | undefined): Gap[] => (gaps ?? []).filter(g => !g.stepId); - -// ──────────────────────────────────────────────────────────────────────────── -// Sub-components (co-located; not exported from the package) -// ──────────────────────────────────────────────────────────────────────────── - -/** - * SpecsGrid — shared 2x2 input grid for editing USL / LSL / target / cpkTarget. - * - * Used by both `OceanCard` (CTS column) and `StepCard` (per-step CTQ column). - * The grid is fully controlled: it renders the four numeric inputs and emits - * the full `SpecLimits` shape on each change so callers can route to either - * the project-wide `setSpecs` or the per-column `setMeasureSpec(column, …)`. - * - * `idPrefix` and `ariaPrefix` parameterise the data-testid + aria-label values - * so the same grid renders distinct accessibility names per surface. - */ -interface SpecsGridProps { - target?: number; - usl?: number; - lsl?: number; - cpkTarget?: number; - disabled?: boolean; - idPrefix: string; - ariaPrefix: string; - onChange: (next: SpecLimits) => void; -} - -const toNum = (s: string): number | undefined => { - if (s === '') return undefined; - const n = Number(s); - return Number.isFinite(n) ? n : undefined; -}; - -const SpecsGrid: React.FC = ({ - target, - usl, - lsl, - cpkTarget, - disabled, - idPrefix, - ariaPrefix, - onChange, -}) => { - return ( -
- - - - -
- ); -}; - -interface StepCardProps { - step: ProcessMap['nodes'][number]; - tributaries: ProcessMapTributary[]; - subgroupAxes: string[]; - availableColumns: string[]; - gaps: Gap[]; - disabled?: boolean; - /** Per-step CTQ specs (USL/LSL/target/cpkTarget). Only rendered when ctqColumn is set. */ - ctqSpecs?: SpecLimits; - onRename: (name: string) => void; - onCtqChange: (column: string | undefined) => void; - onRemove: () => void; - onAddTributary: (column: string) => void; - onRemoveTributary: (tributaryId: string) => void; - onToggleSubgroupAxis: (tributaryId: string) => void; - /** Called with the full new SpecLimits shape when any per-step spec input changes. */ - onCtqSpecsChange?: (next: SpecLimits) => void; -} - -const StepCard: React.FC = ({ - step, - tributaries, - subgroupAxes, - availableColumns, - gaps, - disabled, - ctqSpecs, - onRename, - onCtqChange, - onRemove, - onAddTributary, - onRemoveTributary, - onToggleSubgroupAxis, - onCtqSpecsChange, -}) => { - const [newTribCol, setNewTribCol] = React.useState(''); - const availableForTrib = availableColumns.filter( - c => !tributaries.some(t => t.column === c) && c !== step.ctqColumn - ); - - return ( -
-
- onRename(e.target.value)} - disabled={disabled} - placeholder="Step name" - aria-label={`Step ${step.order + 1} name`} - className="flex-1 text-sm font-medium bg-transparent border-none focus:outline-none focus:ring-1 focus:ring-edge-strong rounded px-1 disabled:text-content-secondary" - data-testid={`process-map-step-name-${step.id}`} - /> - {!disabled && ( - - )} -
- - - - {step.ctqColumn !== undefined && onCtqSpecsChange && ( - - onCtqSpecsChange({ ...next, characteristicType: ctqSpecs?.characteristicType }) - } - /> - )} - - {tributaries.length > 0 && ( -
    - {tributaries.map(t => { - const isAxis = subgroupAxes.includes(t.id); - return ( -
  • - - {!disabled && ( - - )} -
  • - ); - })} -
- )} - - {!disabled && availableForTrib.length > 0 && ( -
- - -
- )} - - {gaps.length > 0 && ( -
    - {gaps.map((g, i) => ( -
  • - ⚠ {g.message} -
  • - ))} -
- )} -
- ); -}; - -interface OceanCardProps { - ctsColumn?: string; - availableColumns: string[]; - target?: number; - usl?: number; - lsl?: number; - cpkTarget?: number; - disabled?: boolean; - onCtsChange: (column: string | undefined) => void; - onSpecsChange?: (next: { - target?: number; - usl?: number; - lsl?: number; - cpkTarget?: number; - }) => void; -} - -const OceanCard: React.FC = ({ - ctsColumn, - availableColumns, - target, - usl, - lsl, - cpkTarget, - disabled, - onCtsChange, - onSpecsChange, -}) => { - return ( -
-
- Customer outcome (CTS) -
- - {onSpecsChange && ( - - onSpecsChange({ - target: next.target, - usl: next.usl, - lsl: next.lsl, - cpkTarget: next.cpkTarget, - }) - } - /> - )} -
- ); -}; - -interface HunchListProps { - hunches: ProcessMapHunch[]; - steps: ProcessMap['nodes']; - tributaries: ProcessMapTributary[]; - disabled?: boolean; - onAdd: (text: string, pin: { stepId?: string; tributaryId?: string }) => void; - onRemove: (id: string) => void; -} - -const HunchList: React.FC = ({ - hunches, - steps, - tributaries, - disabled, - onAdd, - onRemove, -}) => { - const [text, setText] = React.useState(''); - const [pinKey, setPinKey] = React.useState(''); - - const pinOptions = [ - ...steps.map(s => ({ key: `step:${s.id}`, label: `step · ${s.name || '(unnamed)'}` })), - ...tributaries.map(t => ({ key: `trib:${t.id}`, label: `x · ${t.label || t.column}` })), - ]; - - const submit = () => { - const t = text.trim(); - if (!t) return; - const [kind, id] = pinKey.split(':'); - onAdd(t, kind === 'step' ? { stepId: id } : kind === 'trib' ? { tributaryId: id } : {}); - setText(''); - setPinKey(''); - }; - - const labelForHunch = (h: ProcessMapHunch): string | undefined => { - if (h.stepId) return steps.find(s => s.id === h.stepId)?.name; - if (h.tributaryId) { - const t = tributaries.find(x => x.id === h.tributaryId); - return t?.label || t?.column; - } - return undefined; - }; - - return ( -
-

Hunches

- {hunches.length > 0 && ( -
    - {hunches.map(h => { - const pin = labelForHunch(h); - return ( -
  • - ⚑ {h.text} - {pin && pinned · {pin}} - {!disabled && ( - - )} -
  • - ); - })} -
- )} - {!disabled && ( -
- setText(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') submit(); - }} - placeholder="What do you think is causing the variation?" - aria-label="Hunch text" - className="flex-1 text-xs bg-surface-primary border border-edge rounded px-2 py-1" - data-testid="process-map-hunch-text" - /> - - -
- )} -
- ); -}; - -interface GapStripProps { - gaps: Gap[]; -} - -const GapStrip: React.FC = ({ gaps }) => { - if (gaps.length === 0) return null; - const required = gaps.filter(g => g.severity === 'required'); - const recommended = gaps.filter(g => g.severity === 'recommended'); - return ( -
-

- Missing from your map ({gaps.length}) -

- {required.length > 0 && ( -
    - {required.map((g, i) => ( -
  • - ● required · {g.message} -
  • - ))} -
- )} - {recommended.length > 0 && ( -
    - {recommended.map((g, i) => ( -
  • - ○ recommended · {g.message} -
  • - ))} -
- )} -
- ); -}; - -// ──────────────────────────────────────────────────────────────────────────── -// Main component -// ──────────────────────────────────────────────────────────────────────────── - -export const ProcessMapBase: React.FC = ({ - map, - availableColumns, - onChange, - gaps, - disabled, - target, - usl, - lsl, - cpkTarget, - onSpecsChange, - stepSpecs, - onStepSpecsChange, - showGaps = true, -}) => { - const sortedSteps = React.useMemo( - () => [...map.nodes].sort((a, b) => a.order - b.order), - [map.nodes] - ); - - const update = (next: ProcessMap) => onChange(bumpUpdated(next)); - - const addStep = () => { - const newOrder = sortedSteps.length; - update({ - ...map, - nodes: [...map.nodes, { id: uid('step'), name: '', order: newOrder }], - }); - }; - - const renameStep = (stepId: string, name: string) => { - update({ - ...map, - nodes: map.nodes.map(n => (n.id === stepId ? { ...n, name } : n)), - }); - }; - - const setStepCtq = (stepId: string, ctqColumn: string | undefined) => { - update({ - ...map, - nodes: map.nodes.map(n => (n.id === stepId ? { ...n, ctqColumn } : n)), - }); - }; - - const removeStep = (stepId: string) => { - const remaining = map.nodes.filter(n => n.id !== stepId); - // Re-pack `order` so it stays 0..N-1 monotonic. - const reordered = [...remaining] - .sort((a, b) => a.order - b.order) - .map((n, i) => ({ ...n, order: i })); - update({ - ...map, - nodes: reordered, - tributaries: map.tributaries.filter(t => t.stepId !== stepId), - hunches: (map.hunches ?? []).filter(h => h.stepId !== stepId), - }); - }; - - const addTributary = (stepId: string, column: string) => { - const newT: ProcessMapTributary = { id: uid('trib'), stepId, column }; - update({ ...map, tributaries: [...map.tributaries, newT] }); - }; - - const removeTributary = (tributaryId: string) => { - update({ - ...map, - tributaries: map.tributaries.filter(t => t.id !== tributaryId), - subgroupAxes: (map.subgroupAxes ?? []).filter(id => id !== tributaryId), - hunches: (map.hunches ?? []).filter(h => h.tributaryId !== tributaryId), - }); - }; - - const toggleSubgroupAxis = (tributaryId: string) => { - const current = map.subgroupAxes ?? []; - const next = current.includes(tributaryId) - ? current.filter(id => id !== tributaryId) - : [...current, tributaryId]; - update({ ...map, subgroupAxes: next }); - }; - - const setCts = (ctsColumn: string | undefined) => { - update({ ...map, ctsColumn }); - }; - - const addHunch = (text: string, pin: { stepId?: string; tributaryId?: string }) => { - const hunch: ProcessMapHunch = { id: uid('hunch'), text, ...pin }; - update({ ...map, hunches: [...(map.hunches ?? []), hunch] }); - }; - - const removeHunch = (hunchId: string) => { - update({ - ...map, - hunches: (map.hunches ?? []).filter(h => h.id !== hunchId), - }); - }; - - return ( -
-
-

Process Map

- {!disabled && ( - - )} -
- -
- {sortedSteps.map((step, i) => ( - - t.stepId === step.id)} - subgroupAxes={map.subgroupAxes ?? []} - availableColumns={availableColumns} - gaps={gapsForStep(gaps, step.id)} - disabled={disabled} - ctqSpecs={step.ctqColumn ? stepSpecs?.[step.ctqColumn] : undefined} - onRename={name => renameStep(step.id, name)} - onCtqChange={col => setStepCtq(step.id, col)} - onRemove={() => removeStep(step.id)} - onAddTributary={col => addTributary(step.id, col)} - onRemoveTributary={removeTributary} - onToggleSubgroupAxis={toggleSubgroupAxis} - onCtqSpecsChange={ - onStepSpecsChange && step.ctqColumn - ? next => onStepSpecsChange(step.ctqColumn!, next) - : undefined - } - /> - {i < sortedSteps.length - 1 && ( - - )} - - ))} - {sortedSteps.length > 0 && ( - - )} - -
- - - - {showGaps && } -
- ); -}; +export { ProcessMapBase, type ProcessMapBaseProps } from '../Canvas/internal/ProcessMapBase'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index dd4799e8b..5b0aba45a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -656,7 +656,8 @@ export { export { SubgroupConfigPopover, type SubgroupConfigProps } from './components/SubgroupConfig'; // FRAME workspace — visual Process Map (ADR-070) -export { ProcessMapBase, type ProcessMapBaseProps } from './components/ProcessMap/ProcessMapBase'; +export { Canvas, type CanvasProps } from './components/Canvas'; +export { CanvasWorkspace, type CanvasWorkspaceProps } from './components/Canvas/CanvasWorkspace'; export { LayeredProcessView, type LayeredProcessViewProps } from './components/LayeredProcessView'; export { LayeredProcessViewWithCapability } from './components/LayeredProcessView'; export type {