diff --git a/packages/core/src/stats/basic.ts b/packages/core/src/stats/basic.ts index ee2418fa6..b7099018f 100644 --- a/packages/core/src/stats/basic.ts +++ b/packages/core/src/stats/basic.ts @@ -91,6 +91,8 @@ export function calculateStats(data: number[], usl?: number, lsl?: number): Stat let cp: number | undefined; let cpk: number | undefined; + let pp: number | undefined; + let ppk: number | undefined; // Cp/Cpk use σ_within (short-term capability, industry standard) // Guard: when sigmaWithin=0 (all values identical), Cp/Cpk would be Infinity @@ -109,6 +111,20 @@ export function calculateStats(data: number[], usl?: number, lsl?: number): Stat cpk = (mean - lsl) / (3 * sigmaWithin); } + // Pp/Ppk use σ_overall (long-term process performance). + if (stdDev !== 0) { + if (usl !== undefined && lsl !== undefined) { + pp = (usl - lsl) / (6 * stdDev); + const ppu = (usl - mean) / (3 * stdDev); + const ppl = (mean - lsl) / (3 * stdDev); + ppk = Math.min(ppu, ppl); + } else if (usl !== undefined) { + ppk = (usl - mean) / (3 * stdDev); + } else if (lsl !== undefined) { + ppk = (mean - lsl) / (3 * stdDev); + } + } + const outOfSpec = data.filter(d => { if (usl !== undefined && d > usl) return true; if (lsl !== undefined && d < lsl) return true; @@ -127,6 +143,8 @@ export function calculateStats(data: number[], usl?: number, lsl?: number): Stat lcl, cp, cpk, + pp, + ppk, outOfSpecPercentage, }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d17581724..1b1819e32 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -108,6 +108,10 @@ export interface StatsResult { cp?: number; /** Process Capability index accounting for centering (uses σ_within) */ cpk?: number; + /** Process Performance index (uses σ_overall) - requires both USL and LSL */ + pp?: number; + /** Process Performance index accounting for centering (uses σ_overall) */ + ppk?: number; /** Percentage of values outside specification limits */ outOfSpecPercentage: number; } diff --git a/packages/hooks/src/__tests__/useCanvasStepCards.test.ts b/packages/hooks/src/__tests__/useCanvasStepCards.test.ts index a33f5672b..be47ecb10 100644 --- a/packages/hooks/src/__tests__/useCanvasStepCards.test.ts +++ b/packages/hooks/src/__tests__/useCanvasStepCards.test.ts @@ -10,6 +10,8 @@ import { buildCanvasStepCards, coerceCanvasLens, enabledCanvasLenses, + isCanvasLensValidAtLevel, + suggestCanvasLevelForLens, } from '../useCanvasStepCards'; const baseMap = (overrides: Partial = {}): ProcessMap => ({ @@ -39,7 +41,12 @@ const rows: DataRow[] = Array.from({ length: 40 }, (_, i) => ({ describe('canvas lens registry', () => { it('enables default, capability, and defect while leaving future lenses registered', () => { - expect(enabledCanvasLenses().map(lens => lens.id)).toEqual(['default', 'capability', 'defect']); + expect(enabledCanvasLenses().map(lens => lens.id)).toEqual([ + 'default', + 'capability', + 'defect', + 'process-flow', + ]); expect(CANVAS_LENS_REGISTRY.performance.enabled).toBe(false); expect(CANVAS_LENS_REGISTRY.yamazumi.enabled).toBe(false); }); @@ -49,6 +56,32 @@ describe('canvas lens registry', () => { expect(coerceCanvasLens('performance')).toBe('default'); expect(coerceCanvasLens('unknown')).toBe('default'); }); + + it('declares the supported lens x level matrix for current canvas lenses', () => { + const matrix = { + default: { l1: true, l2: true, l3: true }, + capability: { l1: true, l2: true, l3: true }, + defect: { l1: true, l2: true, l3: true }, + performance: { l1: true, l2: true, l3: true }, + yamazumi: { l1: false, l2: true, l3: true }, + 'process-flow': { l1: false, l2: true, l3: false }, + } as const; + + for (const [lens, levels] of Object.entries(matrix)) { + for (const [level, expected] of Object.entries(levels)) { + expect( + isCanvasLensValidAtLevel(lens as keyof typeof matrix, level as 'l1' | 'l2' | 'l3') + ).toBe(expected); + } + } + }); + + it('suggests the nearest valid level for disabled lens x level cells', () => { + expect(suggestCanvasLevelForLens('yamazumi', 'l1')).toBe('l2'); + expect(suggestCanvasLevelForLens('process-flow', 'l1')).toBe('l2'); + expect(suggestCanvasLevelForLens('process-flow', 'l3')).toBe('l2'); + expect(suggestCanvasLevelForLens('default', 'l1')).toBe('l1'); + }); }); describe('buildCanvasStepCards drift integration', () => { diff --git a/packages/hooks/src/__tests__/useCanvasViewportShortcuts.test.ts b/packages/hooks/src/__tests__/useCanvasViewportShortcuts.test.ts index f72327b52..a7408bf58 100644 --- a/packages/hooks/src/__tests__/useCanvasViewportShortcuts.test.ts +++ b/packages/hooks/src/__tests__/useCanvasViewportShortcuts.test.ts @@ -78,6 +78,25 @@ describe('useCanvasViewportShortcuts', () => { }); }); + it('uses the caller-provided fit implementation when fitting with shortcuts', () => { + const fitToContent = (hubId: ProcessHubId, targetLevel?: 'l1' | 'l2' | 'l3') => { + useCanvasViewportStore.getState().fitToContent(hubId, targetLevel, { + zoom: 1.9, + pan: { x: 25, y: 12.5 }, + }); + }; + renderHook(() => useCanvasViewportShortcuts({ hubId: HUB_ID, fitToContent })); + + const event = keydown('1', { metaKey: true }); + + expect(event.defaultPrevented).toBe(true); + expect(viewport()).toMatchObject({ + currentLevel: 'l1', + zoom: 1.9, + pan: { x: 25, y: 12.5 }, + }); + }); + it('maps Cmd/Ctrl+3 to l3 only when the current viewport has a focal step', () => { renderHook(() => useCanvasViewportShortcuts({ hubId: HUB_ID })); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index fb25fb5a7..dcaa3025a 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -80,6 +80,8 @@ export { buildCanvasStepCards, coerceCanvasLens, enabledCanvasLenses, + isCanvasLensValidAtLevel, + suggestCanvasLevelForLens, useCanvasStepCards, type BuildCanvasStepCardsArgs, type CanvasLensDefinition, diff --git a/packages/hooks/src/useCanvasStepCards.ts b/packages/hooks/src/useCanvasStepCards.ts index b61978da0..233a4d0dc 100644 --- a/packages/hooks/src/useCanvasStepCards.ts +++ b/packages/hooks/src/useCanvasStepCards.ts @@ -15,11 +15,18 @@ import { type DriftResult, type StepCapabilityStamp, } from '@variscout/core/canvas'; +import type { CanvasLevel } from '@variscout/core/canvas'; import type { ProcessMap } from '@variscout/core/frame'; import { detectColumns } from '@variscout/core/parser'; import { parseTimeValue } from '@variscout/core/time'; -export type CanvasLensId = 'default' | 'capability' | 'defect' | 'performance' | 'yamazumi'; +export type CanvasLensId = + | 'default' + | 'capability' + | 'defect' + | 'performance' + | 'yamazumi' + | 'process-flow'; export interface CanvasLensDefinition { id: CanvasLensId; @@ -59,6 +66,12 @@ export const CANVAS_LENS_REGISTRY: Record = enabled: false, description: 'Future time-study lens.', }, + 'process-flow': { + id: 'process-flow', + label: 'Process flow', + enabled: true, + description: 'Plain process structure without per-card analytics.', + }, }; export function enabledCanvasLenses(): CanvasLensDefinition[] { @@ -71,6 +84,19 @@ export function coerceCanvasLens(value: unknown): CanvasLensId { return lens?.enabled ? lens.id : 'default'; } +export function isCanvasLensValidAtLevel(lens: CanvasLensId, level: CanvasLevel): boolean { + if (lens === 'yamazumi' && level === 'l1') return false; + if (lens === 'process-flow' && (level === 'l1' || level === 'l3')) return false; + return true; +} + +export function suggestCanvasLevelForLens(lens: CanvasLensId, level: CanvasLevel): CanvasLevel { + if (isCanvasLensValidAtLevel(lens, level)) return level; + if (lens === 'yamazumi') return 'l2'; + if (lens === 'process-flow') return 'l2'; + return 'l2'; +} + export interface CanvasStepCategory { label: string; count: number; diff --git a/packages/hooks/src/useCanvasViewportShortcuts.ts b/packages/hooks/src/useCanvasViewportShortcuts.ts index e13da1ebf..08f528973 100644 --- a/packages/hooks/src/useCanvasViewportShortcuts.ts +++ b/packages/hooks/src/useCanvasViewportShortcuts.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { useCanvasViewportStore, type ProcessHubId } from '@variscout/stores'; +import type { CanvasLevel } from '@variscout/core/canvas'; function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; @@ -14,14 +15,31 @@ function isEditableTarget(target: EventTarget | null): boolean { ); } +function fitNowAndAfterRender( + fitToContent: (hubId: ProcessHubId, targetLevel?: CanvasLevel) => void, + hubId: ProcessHubId, + targetLevel?: CanvasLevel +): void { + fitToContent(hubId, targetLevel); + if (typeof window.requestAnimationFrame !== 'function') return; + window.requestAnimationFrame(() => { + const viewport = useCanvasViewportStore.getState().getViewport(hubId); + if (targetLevel === 'l3' && !viewport.focalStepId) return; + fitToContent(hubId, targetLevel); + }); +} + export function useCanvasViewportShortcuts({ hubId, disabled = false, + fitToContent: fitToContentOverride, }: { hubId: ProcessHubId; disabled?: boolean; + fitToContent?: (hubId: ProcessHubId, targetLevel?: CanvasLevel) => void; }): void { - const fitToContent = useCanvasViewportStore(s => s.fitToContent); + const storeFitToContent = useCanvasViewportStore(s => s.fitToContent); + const fitToContent = fitToContentOverride ?? storeFitToContent; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -31,13 +49,13 @@ export function useCanvasViewportShortcuts({ if (event.key === '1') { event.preventDefault(); - fitToContent(hubId, 'l1'); + fitNowAndAfterRender(fitToContent, hubId, 'l1'); return; } if (event.key === '2') { event.preventDefault(); - fitToContent(hubId, 'l2'); + fitNowAndAfterRender(fitToContent, hubId, 'l2'); return; } @@ -46,13 +64,13 @@ export function useCanvasViewportShortcuts({ if (!viewport.focalStepId) return; event.preventDefault(); - fitToContent(hubId, 'l3'); + fitNowAndAfterRender(fitToContent, hubId, 'l3'); return; } if (event.key === '0') { event.preventDefault(); - fitToContent(hubId); + fitNowAndAfterRender(fitToContent, hubId); } }; diff --git a/packages/stores/src/canvasViewportStore.ts b/packages/stores/src/canvasViewportStore.ts index 92df2c78e..c5b704263 100644 --- a/packages/stores/src/canvasViewportStore.ts +++ b/packages/stores/src/canvasViewportStore.ts @@ -29,6 +29,7 @@ export type ProcessHubId = ProcessHub['id']; export type NodeId = string; export type TributaryId = string; export type GateNodePath = string; +export type CanvasViewportFit = { zoom: number; pan: { x: number; y: number } }; export interface CanvasViewportSnapshot { zoom: number; @@ -88,7 +89,7 @@ export interface CanvasViewportActions { setZoom: (hubId: ProcessHubId, zoom: number) => void; setPan: (hubId: ProcessHubId, pan: { x: number; y: number }) => void; setLevel: (hubId: ProcessHubId, level: CanvasLevel, focalStepId?: string) => void; - fitToContent: (hubId: ProcessHubId, level?: CanvasLevel) => void; + fitToContent: (hubId: ProcessHubId, level?: CanvasLevel, fit?: CanvasViewportFit) => void; toggleRail: () => void; setRailOpen: (open: boolean) => void; setGroupByTributary: (hubId: ProcessHubId, on: boolean) => void; @@ -185,7 +186,7 @@ export const useCanvasViewportStore = create + fitToContent: (hubId, level, fit) => set(s => ({ viewports: withViewport(s.viewports, hubId, viewport => { const targetLevel = @@ -196,8 +197,8 @@ export const useCanvasViewportStore = create; onOpenWall?: () => void; + onOpenScout?: (hubId: string) => void; onAddCausalLink?: ( fromFactor: string, toFactor: string, @@ -93,6 +104,22 @@ function toggleArray(arr: readonly T[], item: T): T[] { return arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item]; } +function fitViewportNowAndAfterRender( + fitToContent: (hubId: string, targetLevel?: CanvasLevel) => void, + hubId: string, + targetLevel: CanvasLevel +): void { + fitToContent(hubId, targetLevel); + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return; + window.requestAnimationFrame(() => { + const viewport = useCanvasViewportStore.getState().getViewport(hubId); + if (targetLevel === 'l3' && !viewport.focalStepId) return; + window.dispatchEvent( + new CustomEvent(CANVAS_FIT_REQUEST_EVENT, { detail: { hubId, level: targetLevel } }) + ); + }); +} + function numericValuesFor(column: string, rows: readonly DataRow[]): number[] { const out: number[] = []; for (const row of rows) { @@ -188,6 +215,7 @@ export const CanvasWorkspace: React.FC = ({ eventsPerWeek, activeColumns, onOpenWall, + onOpenScout, onAddCausalLink, onRemoveCausalLink, onOpenInvestigationFocus, @@ -206,6 +234,10 @@ export const CanvasWorkspace: React.FC = ({ const map: ProcessMap = processContext?.processMap ?? fallbackMap; const hubId = normalizeProcessHubId(canvasViewportHubId ?? processContext?.processHubId); + const viewport = + useCanvasViewportStore(state => state.viewports[hubId]) ?? DEFAULT_WORKSPACE_VIEWPORT; + const setViewportLevel = useCanvasViewportStore(state => state.setLevel); + const fitViewportToContent = useCanvasViewportStore(state => state.fitToContent); const scope = detectScopeFromMap(map); const ctsColumn = map.ctsColumn; const ctsSpecs = ctsColumn ? measureSpecs[ctsColumn] : undefined; @@ -228,6 +260,67 @@ export const CanvasWorkspace: React.FC = ({ [currentCanonicalMap] ); const lastHydratedMapSignature = React.useRef(null); + const appliedUrlLevelRef = React.useRef(null); + const pendingUrlLevelRef = React.useRef(null); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + const signature = `${hubId}:${window.location.search}`; + if (appliedUrlLevelRef.current === signature) return; + + const params = new URLSearchParams(window.location.search); + const rawLevel = params.get('level'); + if (!isValidLevel(rawLevel)) return; + + const urlLevel = rawLevel as CanvasLevel; + const focalStepFromUrl = params.get('focalStep'); + const hasMapNodes = map.nodes.length > 0; + const focalStepFromUrlExists = + focalStepFromUrl !== null && map.nodes.some(node => node.id === focalStepFromUrl); + + if (urlLevel === 'l3' && focalStepFromUrl && !focalStepFromUrlExists && !hasMapNodes) { + pendingUrlLevelRef.current = 'l3'; + return; + } + + appliedUrlLevelRef.current = signature; + + const focalStepId = focalStepFromUrlExists ? focalStepFromUrl : undefined; + const nextLevel = urlLevel === 'l3' && !focalStepId ? 'l2' : urlLevel; + pendingUrlLevelRef.current = nextLevel; + if (nextLevel === 'l3') { + setViewportLevel(hubId, 'l3', focalStepId); + } else { + setViewportLevel(hubId, nextLevel); + } + fitViewportNowAndAfterRender(fitViewportToContent, hubId, nextLevel); + }, [fitViewportToContent, hubId, map.nodes, setViewportLevel]); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + if (pendingUrlLevelRef.current && viewport.currentLevel !== pendingUrlLevelRef.current) return; + pendingUrlLevelRef.current = null; + const params = new URLSearchParams(window.location.search); + const currentFocalStep = params.get('focalStep'); + if ( + params.get('level') === viewport.currentLevel && + (viewport.currentLevel !== 'l3' || currentFocalStep === viewport.focalStepId) + ) { + return; + } + params.set('level', viewport.currentLevel); + if (viewport.currentLevel === 'l3' && viewport.focalStepId) { + params.set('focalStep', viewport.focalStepId); + } else { + params.delete('focalStep'); + } + const nextSearch = params.toString(); + window.history.replaceState( + null, + '', + `${window.location.pathname}?${nextSearch}${window.location.hash}` + ); + }, [viewport.currentLevel, viewport.focalStepId]); React.useEffect(() => { if (lastHydratedMapSignature.current === mapHydrationSignature) return; @@ -498,12 +591,15 @@ export const CanvasWorkspace: React.FC = ({ onOverlayToggle={toggleCanvasOverlay} activeCanvasTool={activeCanvasTool} onCanvasToolChange={setActiveCanvasTool} + systemQuestions={questions} + hypotheses={hypotheses} investigationOverlays={investigationOverlays} questions={questions} findings={findings} problemCpk={problemCpk} eventsPerWeek={eventsPerWeek} activeColumns={activeColumns ?? availableColumns} + onOpenScout={onOpenScout} onOpenWall={onOpenWall} onAddCausalLink={onAddCausalLink} onRemoveCausalLink={onRemoveCausalLink} diff --git a/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx b/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx index e84ba6657..304913a6c 100644 --- a/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx +++ b/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx @@ -335,15 +335,108 @@ describe('Canvas', () => { expect(within(card).queryByText('Pressure')).not.toBeInTheDocument(); }); - it('renders the L1 placeholder when the hub viewport is at system level', () => { + it('renders the L1 system outcome panel when the hub viewport is at system level', () => { useCanvasViewportStore.getState().setLevel('hub-l1-canvas', 'l1'); - renderCanvas({ hubId: 'hub-l1-canvas' }); + renderCanvas({ + hubId: 'hub-l1-canvas', + map: { ...mapWithSteps, ctsColumn: 'Fill Weight' }, + rows: [{ 'Fill Weight': 100 }, { 'Fill Weight': 101 }], + usl: 102, + lsl: 98, + target: 100, + cpkTarget: 1.33, + }); - expect(screen.getByText('System level coming next')).toBeInTheDocument(); + expect(screen.getByTestId('outcome-distribution')).toHaveTextContent('n=2'); + expect(screen.getByTestId('drift-indicator')).toBeInTheDocument(); + expect(screen.getByTestId('outcome-time-series')).toBeInTheDocument(); + expect(screen.getByTestId('outcome-capability')).toHaveTextContent('Cpk'); + expect(screen.getByTestId('inbox-digest')).toBeInTheDocument(); + expect(screen.getByTestId('active-investigations-summary')).toBeInTheDocument(); + expect(screen.getByText('Fill Weight')).toBeInTheDocument(); expect(screen.queryByTestId('canvas-card-surface')).not.toBeInTheDocument(); }); + it('renders an empty state for invalid lens and level cells', () => { + useCanvasViewportStore.getState().setLevel('hub-l1-yamazumi', 'l1'); + + renderCanvas({ + hubId: 'hub-l1-yamazumi', + activeLens: 'yamazumi', + map: { ...mapWithSteps, ctsColumn: 'Fill Weight' }, + rows: [{ 'Fill Weight': 100 }, { 'Fill Weight': 101 }], + }); + + expect(screen.getByTestId('canvas-lens-level-empty-state')).toHaveTextContent( + "Yamazumi isn't available at System" + ); + expect(screen.getByTestId('canvas-lens-level-empty-state')).toHaveTextContent('try Process'); + expect(screen.queryByTestId('outcome-distribution')).not.toBeInTheDocument(); + }); + + it('renders an empty state for process-flow at L3 before mounting the level renderer', () => { + useCanvasViewportStore.getState().setLevel('hub-l3-flow', 'l3', 'step-1'); + + renderCanvas({ + hubId: 'hub-l3-flow', + activeLens: 'process-flow', + }); + + expect(screen.getByTestId('canvas-lens-level-empty-state')).toHaveTextContent( + "Process flow isn't available at Step" + ); + expect(screen.queryByTestId('local-mechanism-view')).not.toBeInTheDocument(); + }); + + it('uses mounted level measurements for Cmd+1 fit-to-content', async () => { + const originalBounds = HTMLElement.prototype.getBoundingClientRect; + HTMLElement.prototype.getBoundingClientRect = function getBounds() { + if (this.hasAttribute('data-canvas-viewport-wrapper')) { + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: 1000, + bottom: 500, + width: 1000, + height: 500, + toJSON: () => ({}), + } as DOMRect; + } + if (this.getAttribute('data-canvas-level') === 'l1') { + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: 500, + bottom: 250, + width: 500, + height: 250, + toJSON: () => ({}), + } as DOMRect; + } + return originalBounds.call(this); + }; + + try { + renderCanvas({ hubId: 'hub-measured-fit' }); + + fireEvent.keyDown(window, { key: '1', metaKey: true }); + await act(() => new Promise(resolve => window.requestAnimationFrame(resolve))); + + expect(useCanvasViewportStore.getState().getViewport('hub-measured-fit')).toMatchObject({ + currentLevel: 'l1', + zoom: 1.9, + pan: { x: 25, y: 12.5 }, + }); + } finally { + HTMLElement.prototype.getBoundingClientRect = originalBounds; + } + }); + it('keeps the desktop LOD input surface mounted on L1 and can wheel back to L2', () => { const hubId = 'hub-l1-wheel-recover'; useCanvasViewportStore.getState().fitToContent(hubId, 'l1'); @@ -1234,7 +1327,7 @@ describe('Canvas', () => { useCanvasViewportStore.getState().fitToContent(hubId, 'l1'); }); - expect(screen.getByText('System level coming next')).toBeInTheDocument(); + expect(screen.getByTestId('outcome-distribution')).toBeInTheDocument(); expect(screen.queryByTestId('canvas-step-overlay')).not.toBeInTheDocument(); }); diff --git a/packages/ui/src/components/Canvas/__tests__/CanvasWorkspace.test.tsx b/packages/ui/src/components/Canvas/__tests__/CanvasWorkspace.test.tsx index 0e5f5859a..7d578d229 100644 --- a/packages/ui/src/components/Canvas/__tests__/CanvasWorkspace.test.tsx +++ b/packages/ui/src/components/Canvas/__tests__/CanvasWorkspace.test.tsx @@ -265,6 +265,12 @@ vi.mock('@variscout/hooks', () => ({ enabled: true, description: 'Defect counts projected onto process steps.', }, + 'process-flow': { + id: 'process-flow', + label: 'Process flow', + enabled: true, + description: 'Plain process structure without per-card analytics.', + }, performance: { id: 'performance', label: 'Performance', @@ -351,7 +357,20 @@ vi.mock('@variscout/hooks', () => ({ ]), CANVAS_EMPTY_DROP_ID: 'canvas:empty', coerceCanvasLens: vi.fn((value: unknown) => - value === 'capability' || value === 'defect' ? value : 'default' + value === 'capability' || value === 'defect' || value === 'process-flow' ? value : 'default' + ), + isCanvasLensValidAtLevel: vi.fn( + (lens: string, level: string) => + !( + (lens === 'yamazumi' && level === 'l1') || + (lens === 'process-flow' && (level === 'l1' || level === 'l3')) + ) + ), + suggestCanvasLevelForLens: vi.fn((lens: string, level: string) => + (lens === 'yamazumi' && level === 'l1') || + (lens === 'process-flow' && (level === 'l1' || level === 'l3')) + ? 'l2' + : level ), encodeChipDragId: (chipId: string) => `chip:${chipId}`, encodeStepDropId: (stepId: string) => `step:${stepId}`, @@ -579,6 +598,7 @@ function renderWorkspace(overrides: Partial { beforeEach(() => { + window.history.replaceState(null, '', '/'); wallIsMobileRef.current = false; localMechanismPropsRef.current = null; useCanvasStore.setState(useCanvasStore.getInitialState()); @@ -629,6 +649,97 @@ describe('CanvasWorkspace', () => { expect(screen.queryByTestId('ops-band-dashboard')).not.toBeInTheDocument(); }); + it('opens at the URL level when ?level is present', () => { + window.history.replaceState(null, '', '/?level=l1'); + + renderWorkspace({ + canvasViewportHubId: 'hub-url-level', + processContext: { processMap: mapWithStep() }, + }); + + expect(useCanvasViewportStore.getState().getViewport('hub-url-level').currentLevel).toBe('l1'); + expect(screen.getByTestId('outcome-distribution')).toBeInTheDocument(); + }); + + it('redirects a bare L3 URL level back to L2 when no focal step exists', () => { + window.history.replaceState(null, '', '/?level=l3'); + + renderWorkspace({ + canvasViewportHubId: 'hub-url-l3-bare', + processContext: { processMap: mapWithStep() }, + }); + + expect(useCanvasViewportStore.getState().getViewport('hub-url-l3-bare').currentLevel).toBe( + 'l2' + ); + expect(window.location.search).toBe('?level=l2'); + }); + + it('opens an L3 URL level when a focalStep query points to a process step', () => { + window.history.replaceState(null, '', '/?level=l3&focalStep=step-1'); + + renderWorkspace({ + canvasViewportHubId: 'hub-url-l3-focal', + processContext: { processMap: mapWithStep() }, + }); + + expect(useCanvasViewportStore.getState().getViewport('hub-url-l3-focal')).toMatchObject({ + currentLevel: 'l3', + focalStepId: 'step-1', + }); + expect(window.location.search).toBe('?level=l3&focalStep=step-1'); + }); + + it('waits for a loaded process map before resolving an L3 focalStep URL', () => { + window.history.replaceState(null, '', '/?level=l3&focalStep=step-1'); + + const Harness = () => { + const [processContext, setProcessContext] = + React.useState['processContext']>(null); + + return ( + <> + + setProcessContext(next)} + onSeeData={vi.fn()} + /> + + ); + }; + + render(); + + expect(useCanvasViewportStore.getState().getViewport('hub-url-l3-async-focal')).toMatchObject({ + currentLevel: 'l2', + }); + expect(window.location.search).toBe('?level=l3&focalStep=step-1'); + + fireEvent.click(screen.getByTestId('load-process-map')); + + expect(useCanvasViewportStore.getState().getViewport('hub-url-l3-async-focal')).toMatchObject({ + currentLevel: 'l3', + focalStepId: 'step-1', + }); + expect(window.location.search).toBe('?level=l3&focalStep=step-1'); + }); + it('passes priorStepStats into useCanvasStepCards', () => { const priorStepStats = new Map([ ['step-1', { stepId: 'step-1', n: 30, mean: 30, cpk: 0.8 }], diff --git a/packages/ui/src/components/Canvas/index.tsx b/packages/ui/src/components/Canvas/index.tsx index f7e303971..e463e78b7 100644 --- a/packages/ui/src/components/Canvas/index.tsx +++ b/packages/ui/src/components/Canvas/index.tsx @@ -8,6 +8,9 @@ import { chartColors } from '@variscout/charts'; import { coerceCanvasLens, coerceCanvasOverlays, + CANVAS_LENS_REGISTRY, + isCanvasLensValidAtLevel, + suggestCanvasLevelForLens, resolveEndpointToFactor, useCanvasViewportInput, useCanvasViewportShortcuts, @@ -29,11 +32,18 @@ import { detectColumns, type DataRow, type Finding, + type Hypothesis, type SpecLimits, + type Question, type WorkflowReadinessSignals, } from '@variscout/core'; import type { ActionItem, ColumnTypeMap } from '@variscout/core/findings'; -import { useCanvasViewportStore, type CanvasViewportSnapshot } from '@variscout/stores'; +import type { CanvasLevel } from '@variscout/core/canvas'; +import { + useCanvasViewportStore, + type CanvasViewportFit, + type CanvasViewportSnapshot, +} from '@variscout/stores'; import { type ProductionLineGlanceFilterStripProps, ProductionLineGlanceFilterStrip, @@ -59,6 +69,7 @@ import { CanvasStepOverlay, type CanvasOverlayAnchorRect } from './internal/Canv import { CanvasWallOverlay } from './internal/CanvasWallOverlay'; import { WallShortcutButton } from './internal/WallShortcutButton'; import { LocalMechanismView } from './internal/LocalMechanismView'; +import { SystemLevelView } from './internal/SystemLevelView'; import { AuthorL3View } from './internal/AuthorL3View'; import { NoFocalStepPrompt, sortedProcessSteps } from './internal/NoFocalStepPrompt'; import { useWallIsMobile } from '../InvestigationWall'; @@ -106,10 +117,55 @@ const DEFAULT_CANVAS_VIEWPORT: CanvasViewportSnapshot = { }; const CANVAS_VIEWPORT_IGNORED_TARGET = '[data-canvas-wall-overlay]'; +const CANVAS_LEVEL_LABELS = { + l1: 'System', + l2: 'Process', + l3: 'Step', +} as const; +const CANVAS_FIT_REQUEST_EVENT = 'variscout:canvas-fit-request'; +const FIT_TO_CONTENT_MARGIN = 0.95; + +interface CanvasFitRequestDetail { + hubId: string; + level?: CanvasLevel; +} + function shouldHandleCanvasViewportInput(event: Event): boolean { return !(event.target instanceof Element && event.target.closest(CANVAS_VIEWPORT_IGNORED_TARGET)); } +function measureCanvasFit( + wrapper: HTMLElement | null, + level: CanvasLevel +): CanvasViewportFit | undefined { + const contentElement = wrapper?.querySelector(`[data-canvas-level="${level}"]`); + if (!wrapper || !contentElement) return undefined; + + const viewportBounds = wrapper.getBoundingClientRect(); + const contentBounds = contentElement.getBoundingClientRect(); + if ( + viewportBounds.width <= 0 || + viewportBounds.height <= 0 || + contentBounds.width <= 0 || + contentBounds.height <= 0 + ) { + return undefined; + } + + const zoom = + Math.min( + viewportBounds.width / contentBounds.width, + viewportBounds.height / contentBounds.height + ) * FIT_TO_CONTENT_MARGIN; + return { + zoom, + pan: { + x: viewportBounds.width / 2 - (contentBounds.width / 2) * zoom, + y: viewportBounds.height / 2 - (contentBounds.height / 2) * zoom, + }, + }; +} + function areArrowSegmentsEqual(left: ArrowSegment[], right: ArrowSegment[]) { if (left.length !== right.length) return false; return left.every((segment, index) => { @@ -185,6 +241,8 @@ export interface CanvasProps { activeCanvasTool?: CanvasToolId; onCanvasToolChange?: (next: CanvasToolId) => void; questions?: ReadonlyArray; + systemQuestions?: ReadonlyArray; + hypotheses?: ReadonlyArray; onAddCausalLink?: ( fromFactor: string, toFactor: string, @@ -209,6 +267,7 @@ export interface CanvasProps { problemCpk?: number; eventsPerWeek?: number; activeColumns?: ReadonlyArray; + onOpenScout?: (hubId: string) => void; onOpenWall?: () => void; onSelectWallHub?: (hubId: string) => void; onOpenColumnDetail?: (column: string, stepId: string) => void; @@ -254,6 +313,8 @@ export const Canvas: React.FC = ({ activeCanvasTool = 'select', onCanvasToolChange, questions = EMPTY_QUESTIONS, + systemQuestions = [], + hypotheses = [], onAddCausalLink, investigationOverlays, signals, @@ -273,6 +334,7 @@ export const Canvas: React.FC = ({ problemCpk, eventsPerWeek, activeColumns, + onOpenScout, onOpenWall, onSelectWallHub, onOpenColumnDetail, @@ -282,6 +344,7 @@ export const Canvas: React.FC = ({ const viewport = useCanvasViewportStore(s => s.viewports[hubId] ? s.getViewport(hubId) : DEFAULT_CANVAS_VIEWPORT ); + const rawLens = activeLens; const resolvedLens = coerceCanvasLens(activeLens); const resolvedOverlays = React.useMemo( () => coerceCanvasOverlays(activeOverlays), @@ -402,13 +465,35 @@ export const Canvas: React.FC = ({ : undefined; const cardSurfaceRef = React.useRef(null); const lodInputSurfaceRef = React.useRef(null); + const fitToContentMeasured = React.useCallback( + (targetLevel?: CanvasLevel) => { + const level = targetLevel ?? viewport.currentLevel; + const fit = measureCanvasFit(lodInputSurfaceRef.current, level); + useCanvasViewportStore.getState().fitToContent(hubId, level, fit); + }, + [hubId, viewport.currentLevel] + ); useCanvasViewportInput({ hubId, ref: lodInputSurfaceRef, disabled: wallIsMobile || disabled || activeCanvasTool === 'draw-hypothesis', filter: shouldHandleCanvasViewportInput, }); - useCanvasViewportShortcuts({ hubId, disabled }); + useCanvasViewportShortcuts({ + hubId, + disabled, + fitToContent: (_hubId, targetLevel) => fitToContentMeasured(targetLevel), + }); + + React.useEffect(() => { + const handleFitRequest = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.hubId !== hubId) return; + fitToContentMeasured(detail.level); + }; + window.addEventListener(CANVAS_FIT_REQUEST_EVENT, handleFitRequest); + return () => window.removeEventListener(CANVAS_FIT_REQUEST_EVENT, handleFitRequest); + }, [fitToContentMeasured, hubId]); React.useEffect(() => { if (viewport.currentLevel !== 'l3' || viewport.focalStepId || !firstStepId) return; @@ -714,7 +799,11 @@ export const Canvas: React.FC = ({ ); const canvasContent = ( -
+
{isAuthorMode ? ( @@ -881,9 +970,20 @@ export const Canvas: React.FC = ({
); - const l1Placeholder = ( -
- System level coming next + const l1Content = ( +
+
); const authorL3Content = viewport.focalStepId ? ( @@ -923,7 +1023,7 @@ export const Canvas: React.FC = ({ ); const l3ContentBody = resolvedL3Archetype === 'b1' ? authorL3Content : readL3Content; const l3Content = ( -
+
{onModeChange ? (
@@ -932,16 +1032,34 @@ export const Canvas: React.FC = ({ {l3ContentBody}
); - const levelContent = ( + const lensValidAtCurrentLevel = isCanvasLensValidAtLevel(rawLens, viewport.currentLevel); + const suggestedLevel = suggestCanvasLevelForLens(rawLens, viewport.currentLevel); + const invalidLensLevelContent = ( +
+ {CANVAS_LENS_REGISTRY[rawLens].label} isn't available at{' '} + {CANVAS_LEVEL_LABELS[viewport.currentLevel]} — try {CANVAS_LEVEL_LABELS[suggestedLevel]}. +
+ ); + const levelContent = lensValidAtCurrentLevel ? ( + ) : ( + invalidLensLevelContent ); const desktopLevelContent = ( -
+
{levelContent}
); diff --git a/packages/ui/src/components/Canvas/internal/CanvasLensPicker.tsx b/packages/ui/src/components/Canvas/internal/CanvasLensPicker.tsx index a16bac313..4f39d674f 100644 --- a/packages/ui/src/components/Canvas/internal/CanvasLensPicker.tsx +++ b/packages/ui/src/components/Canvas/internal/CanvasLensPicker.tsx @@ -14,6 +14,7 @@ const orderedLenses: CanvasLensDefinition[] = [ CANVAS_LENS_REGISTRY.default, CANVAS_LENS_REGISTRY.capability, CANVAS_LENS_REGISTRY.defect, + CANVAS_LENS_REGISTRY['process-flow'], CANVAS_LENS_REGISTRY.performance, CANVAS_LENS_REGISTRY.yamazumi, ]; diff --git a/packages/ui/src/components/Canvas/internal/CanvasStepCard.tsx b/packages/ui/src/components/Canvas/internal/CanvasStepCard.tsx index 738352225..6bfa4c6ca 100644 --- a/packages/ui/src/components/Canvas/internal/CanvasStepCard.tsx +++ b/packages/ui/src/components/Canvas/internal/CanvasStepCard.tsx @@ -58,8 +58,10 @@ export const CanvasStepCard: React.FC = ({ }) => { const rootRef = React.useRef(null); const showFullDetail = zoom >= 1; + const showPlainFlow = activeLens === 'process-flow'; const showDefects = showFullDetail && activeLens === 'defect' && card.defectCount !== undefined; - const showCapability = !showFullDetail || activeLens === 'capability' || activeLens === 'default'; + const showCapability = + !showPlainFlow && (!showFullDetail || activeLens === 'capability' || activeLens === 'default'); const showInvestigations = showFullDetail && activeOverlays.includes('investigations') && investigationOverlay; const showFindings = @@ -117,7 +119,7 @@ export const CanvasStepCard: React.FC = ({ ) : null}
- {showFullDetail ? : null} + {showFullDetail && !showPlainFlow ? : null}
{showInvestigations && activityCount > 0 ? ( @@ -165,7 +167,7 @@ export const CanvasStepCard: React.FC = ({ stepLabel={card.stepName} /> ) : null} - {showFullDetail + {showFullDetail && !showPlainFlow ? card.assignedColumns.slice(0, 3).map(column => ( = ({ : null}
- {showFullDetail && card.metricColumn ? ( + {showFullDetail && !showPlainFlow && card.metricColumn ? ( + + +
+
+
+

Outcome distribution

+ n={values.length} +
+ {bins.length > 0 ? ( +
+ {bins.map((bin, index) => ( + + ))} +
+ ) : ( +

No numeric outcome

+ )} +
+ +
+
+

Outcome drift

+ + {model.drift.label} + +
+
+ {values.length > 0 ? ( + + + + ) : ( +

No outcome trend

+ )} +
+
+
+ +
+
+
Cp
+
{formatMetric(model.cp)}
+
+
+
Cpk
+
{formatMetric(model.cpk)}
+
+
+
Pp
+
{formatMetric(model.pp)}
+
+
+
Ppk
+
{formatMetric(model.ppk)}
+
+
+
Conformance
+
+ {formatPercentage(model.conformancePercentage)} +
+
+
+
Target
+
+ {formatMetric(specLimits?.cpkTarget)} +
+
+
+
+ + +
+ + ); +}; diff --git a/packages/ui/src/components/Canvas/internal/__tests__/SystemLevelView.test.tsx b/packages/ui/src/components/Canvas/internal/__tests__/SystemLevelView.test.tsx new file mode 100644 index 000000000..fb9fad615 --- /dev/null +++ b/packages/ui/src/components/Canvas/internal/__tests__/SystemLevelView.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ProcessMap } from '@variscout/core/frame'; +import type { DataRow, Finding, Hypothesis, Question } from '@variscout/core'; +import { SystemLevelView } from '../SystemLevelView'; + +const map: ProcessMap = { + version: 1, + ctsColumn: 'Fill Weight', + nodes: [ + { id: 'mix', name: 'Mix', order: 0 }, + { id: 'fill', name: 'Fill', order: 1 }, + ], + tributaries: [], + createdAt: '2026-05-13T00:00:00.000Z', + updatedAt: '2026-05-13T00:00:00.000Z', +}; + +const rows: DataRow[] = [ + { 'Fill Weight': 99.8, Timestamp: 1 }, + { 'Fill Weight': 100.1, Timestamp: 2 }, + { 'Fill Weight': 100.4, Timestamp: 3 }, + { 'Fill Weight': 101.3, Timestamp: 4 }, + { 'Fill Weight': 99.9, Timestamp: 5 }, +]; + +const findings = [ + { + id: 'finding-1', + createdAt: 1, + deletedAt: null, + text: 'High fill variation', + context: { activeFilters: {}, cumulativeScope: null }, + evidenceType: 'data', + status: 'observed', + comments: [], + statusChangedAt: 1, + investigationId: 'inv-1', + }, + { + id: 'finding-2', + createdAt: 2, + deletedAt: null, + text: 'Mixer setup stable', + context: { activeFilters: {}, cumulativeScope: null }, + evidenceType: 'data', + status: 'analyzed', + comments: [], + statusChangedAt: 2, + investigationId: 'inv-1', + }, +] as Finding[]; + +const question = (id: string, status: Question['status']): Question => ({ + id, + createdAt: 1, + deletedAt: null, + text: `${id}?`, + status, + linkedFindingIds: [], + updatedAt: 1, + investigationId: 'inv-1', +}); + +const hypothesis = (id: string, status: Hypothesis['status']): Hypothesis => ({ + id, + createdAt: 1, + deletedAt: null, + name: id, + synthesis: 'Candidate mechanism', + questionIds: [], + findingIds: [], + updatedAt: 1, + investigationId: 'inv-1', + status, +}); + +const questions = [ + question('question-1', 'open'), + question('question-2', 'investigating'), + question('question-3', 'answered'), +]; +const hypotheses = [ + hypothesis('hypothesis-1', 'proposed'), + hypothesis('hypothesis-2', 'evidenced'), + hypothesis('hypothesis-3', 'refuted'), +]; + +describe('SystemLevelView', () => { + it('renders the hub outcome panel from the hub outcome series without response-path CTAs', () => { + render( + + ); + + expect(screen.getByText('hub-fill')).toBeInTheDocument(); + expect(screen.getByText('Fill Weight')).toBeInTheDocument(); + expect(screen.getByTestId('outcome-distribution')).toHaveTextContent('n=5'); + expect(screen.getByTestId('drift-indicator')).toBeInTheDocument(); + expect(screen.getByTestId('outcome-time-series')).toBeInTheDocument(); + expect(screen.getByTestId('outcome-capability')).toHaveTextContent('Cp'); + expect(screen.getByTestId('outcome-capability')).toHaveTextContent('Cpk'); + expect(screen.getByTestId('outcome-capability')).toHaveTextContent('Pp'); + expect(screen.getByTestId('outcome-capability')).toHaveTextContent('Ppk'); + expect(screen.getByTestId('inbox-digest')).toHaveTextContent('1 prompt'); + expect(screen.getByTestId('active-investigations-summary')).toHaveTextContent( + '2 open questions' + ); + expect(screen.getByTestId('active-investigations-summary')).toHaveTextContent( + '2 active hypotheses' + ); + expect(screen.getByTestId('active-investigations-summary')).toHaveTextContent('1 open finding'); + expect(screen.getByRole('button', { name: /Open SCOUT/i })).toBeDisabled(); + expect(screen.queryByText('Quick Action')).not.toBeInTheDocument(); + expect(screen.queryByText('Focused Investigation')).not.toBeInTheDocument(); + expect(screen.queryByText('Improvement Project')).not.toBeInTheDocument(); + expect(screen.queryByText('Sustainment')).not.toBeInTheDocument(); + expect(screen.queryByText('Handoff')).not.toBeInTheDocument(); + }); + + it('falls back when no outcome is selected and calls the optional SCOUT callback', async () => { + const onOpenScout = vi.fn(); + render( + + ); + + expect(screen.getByText('Outcome not selected')).toBeInTheDocument(); + expect(screen.getByTestId('outcome-distribution')).toHaveTextContent('No numeric outcome'); + + fireEvent.click(screen.getByRole('button', { name: /Open SCOUT/i })); + + expect(onOpenScout).toHaveBeenCalledWith('hub-unframed'); + }); +}); diff --git a/packages/ui/src/components/DashboardBase/internal/systemOutcomeModel.ts b/packages/ui/src/components/DashboardBase/internal/systemOutcomeModel.ts new file mode 100644 index 000000000..800b38a83 --- /dev/null +++ b/packages/ui/src/components/DashboardBase/internal/systemOutcomeModel.ts @@ -0,0 +1,115 @@ +import { + calculateStats, + type DataRow, + type Finding, + type Hypothesis, + type Question, + type SpecLimits, +} from '@variscout/core'; +import { formatStatistic } from '@variscout/core/i18n'; +import { computeHistogramBins } from '@variscout/core/stats'; + +export interface SystemOutcomeModel { + values: number[]; + bins: ReturnType; + cp?: number; + cpk?: number; + pp?: number; + ppk?: number; + conformancePercentage: number; + outOfSpecPercentage: number; + drift: { label: string; tone: string }; + activeSummary: string; +} + +export function buildSystemOutcomeModel({ + rows, + outcomeColumn, + specLimits, + questions, + hypotheses, + findings, +}: { + rows: readonly DataRow[]; + outcomeColumn: string | undefined; + specLimits: SpecLimits | undefined; + questions: ReadonlyArray; + hypotheses: ReadonlyArray; + findings: ReadonlyArray; +}): SystemOutcomeModel { + const values = outcomeValues(rows, outcomeColumn); + const stats = calculateStats(values, specLimits?.usl, specLimits?.lsl); + return { + values, + bins: computeHistogramBins(values), + cp: stats.cp, + cpk: stats.cpk, + pp: stats.pp, + ppk: stats.ppk, + conformancePercentage: 100 - stats.outOfSpecPercentage, + outOfSpecPercentage: stats.outOfSpecPercentage, + drift: driftLabel(values), + activeSummary: buildActiveSummary({ questions, hypotheses, findings }), + }; +} + +function outcomeValues(rows: readonly DataRow[], outcomeColumn: string | undefined): number[] { + if (!outcomeColumn) return []; + return rows + .map(row => { + const value = row[outcomeColumn]; + const numberValue = typeof value === 'number' ? value : Number(value); + return Number.isFinite(numberValue) ? numberValue : null; + }) + .filter((value): value is number => value !== null); +} + +function driftLabel(values: readonly number[]): { label: string; tone: string } { + if (values.length < 4) { + return { label: 'Not enough outcome history', tone: 'bg-surface-secondary text-content-muted' }; + } + + const midpoint = Math.floor(values.length / 2); + const first = values.slice(0, midpoint); + const second = values.slice(midpoint); + const firstMean = first.reduce((sum, value) => sum + value, 0) / first.length; + const secondMean = second.reduce((sum, value) => sum + value, 0) / second.length; + const delta = secondMean - firstMean; + + if (Math.abs(delta) < 0.01) { + return { label: 'Outcome stable', tone: 'bg-surface-secondary text-content-secondary' }; + } + + const direction = delta > 0 ? 'up' : 'down'; + return { + label: `Outcome trending ${direction} ${formatStatistic(Math.abs(delta), 'en', 2)}`, + tone: + delta > 0 ? 'bg-status-pass-soft text-status-pass' : 'bg-status-fail-soft text-status-fail', + }; +} + +function buildActiveSummary({ + questions, + hypotheses, + findings, +}: { + questions: ReadonlyArray; + hypotheses: ReadonlyArray; + findings: ReadonlyArray; +}): string { + const openQuestions = questions.filter(question => + ['open', 'investigating'].includes(question.status) + ).length; + const activeHypotheses = hypotheses.filter(hypothesis => + ['proposed', 'evidenced', 'needs-disconfirmation'].includes(hypothesis.status) + ).length; + const openFindings = findings.filter( + finding => !['analyzed', 'resolved'].includes(String(finding.status)) + ).length; + const questionLabel = `${openQuestions} open ${openQuestions === 1 ? 'question' : 'questions'}`; + const hypothesisLabel = `${activeHypotheses} active ${ + activeHypotheses === 1 ? 'hypothesis' : 'hypotheses' + }`; + const findingLabel = `${openFindings} open ${openFindings === 1 ? 'finding' : 'findings'}`; + return `${questionLabel} · ${hypothesisLabel} · ${findingLabel}`; +} diff --git a/scripts/check-level-boundaries.sh b/scripts/check-level-boundaries.sh index f359d910e..b32d91518 100755 --- a/scripts/check-level-boundaries.sh +++ b/scripts/check-level-boundaries.sh @@ -45,6 +45,9 @@ check "hypothesisCanvas|hypothesisHub|gateNode" \ check "LayeredProcessView|OperationsBand" \ "packages/ui/src/components/EvidenceMap" \ "Evidence Map does not reimplement L2 flow rendering" +check "outcomeStats|outcomeBoxplot|outcomeIChart|stratifyByFactor|factorEdge|factorRelationship" \ + "packages/ui/src/components/Canvas/internal" \ + "Canvas viewport imports owner surfaces without reimplementing L1/L3 primitives" if [ "$FAILED" -gt 0 ]; then echo "" >&2 diff --git a/scripts/pr-ready-check.sh b/scripts/pr-ready-check.sh index ce8fba068..b1a267c87 100755 --- a/scripts/pr-ready-check.sh +++ b/scripts/pr-ready-check.sh @@ -53,6 +53,7 @@ fi run_step "tests (turbo)" pnpm test run_step "lint (turbo)" pnpm lint +run_step "level boundaries" bash scripts/check-level-boundaries.sh run_step "docs:check" pnpm docs:check # PWA build + dist integrity. Catches the stale-chunk-hash regression class