From de3a9cf3fcb9bda23860a0dc84f5cab109260799 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 00:17:46 +0300 Subject: [PATCH 1/7] plan: C2 LayeredProcessView Operations band + progressive reveal Second of three sub-plans. mode prop on dashboard, URL ?ops state hook, slot-prop API on LayeredProcessView, composition wrapper with progressive- reveal affordance, wiring into both apps' FrameView. Chrome walk validates the animation, URL state, and tributary-chip relocation. Co-Authored-By: ruflo --- ...-production-line-glance-c2-layered-view.md | 1096 +++++++++++++++++ ...ction-line-glance-surface-wiring-design.md | 2 +- 2 files changed, 1097 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-04-28-production-line-glance-c2-layered-view.md diff --git a/docs/superpowers/plans/2026-04-28-production-line-glance-c2-layered-view.md b/docs/superpowers/plans/2026-04-28-production-line-glance-c2-layered-view.md new file mode 100644 index 000000000..6f6342975 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-production-line-glance-c2-layered-view.md @@ -0,0 +1,1096 @@ +--- +title: Production-Line-Glance — C2 LayeredProcessView Operations Band Implementation Plan +audience: [engineer, architect] +category: implementation +status: in-progress +related: + [ + production-line-glance-surface-wiring-design, + production-line-glance-design, + layered-process-view, + production-line-glance-charts, + ] +date: 2026-04-28 +--- + +# Production-Line-Glance — C2 LayeredProcessView Operations Band Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox `- [ ]` syntax. + +**Goal:** Wire the production-line-glance dashboard into LayeredProcessView's Operations band with progressive reveal, in both azure-app and PWA. Land the `mode: 'spatial' | 'full'` prop on `ProductionLineGlanceDashboard`, the URL-state toggle hook (`?ops=full`), the slot-prop API change on LayeredProcessView, and the surface wiring in both `apps/azure/src/components/editor/FrameView.tsx` and `apps/pwa/src/components/views/FrameView.tsx`. + +**Architecture:** Three contract additions, no new components: +1. `ProductionLineGlanceDashboard` gets `mode?: 'spatial' | 'full'` (default `'full'`). When `'spatial'`, the temporal row's wrapper transitions `max-height: 0` (no chart re-mounts). +2. `LayeredProcessView` gets `operationsBandContent?: React.ReactNode` and `filterStripContent?: React.ReactNode` slot props. When `operationsBandContent` is provided, the band renders that node and the existing tributary chips relocate to the Outcome band's "Mapped factors" subsection. Default behavior (no slot props) is unchanged — preserves current FRAME usage. +3. `useProductionLineGlanceOpsToggle` (new in `@variscout/hooks`) syncs the `ops` URL search-param to `'spatial' | 'full'` mode state with `replaceState` semantics matching `useProductionLineGlanceFilter`. + +The new composition is plumbed in each app's FrameView: `useHubProvision` (or the existing FRAME data path) → `useProductionLineGlanceData` + `useProductionLineGlanceFilter` + `useProductionLineGlanceOpsToggle` → render the dashboard inside `LayeredProcessView`'s Operations band slot. PWA reuses the same hooks (the PWA-Hub-IA scope clarification from C1's spec applies only to Hub view; FRAME is already PWA-native). + +**Tech Stack:** TypeScript, React, Vitest, RTL. Skills: `editing-charts` (mode prop), `editing-coscout-prompts` N/A, `writing-tests`. Hard rules from `packages/charts/CLAUDE.md` (no hex; no manual memo), `packages/ui/CLAUDE.md` (semantic Tailwind tokens; functional components; props named `{ComponentName}Props`). + +**Spec reference:** `docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md` — sections "The dashboard's three forms" + "Three surfaces / 1. LayeredProcessView Operations band". + +**Critical existing files:** + +- `packages/ui/src/components/ProductionLineGlanceDashboard/ProductionLineGlanceDashboard.tsx` — Plan B target for `mode` prop (T1) +- `packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx` — V1 layout (T3) +- `apps/azure/src/components/editor/FrameView.tsx` — azure FRAME mount (T6) +- `apps/pwa/src/components/views/FrameView.tsx` — PWA FRAME mount (T6) +- `packages/hooks/src/useProductionLineGlanceFilter.ts` — pattern for T2's URL-state hook +- `packages/hooks/src/useProductionLineGlanceData.ts` — slot-input hook (used in T6) + +**Out of scope:** + +- C3 (FRAME workspace right-hand drawer with full 2×2) +- Plan D (cross-hub view) +- Engine work to expose per-snapshot line-level Cp/Cpk for the temporal row (current cpkTrend/cpkGapTrend remain empty per C1 — the spatial row is the value Plan C2 ships) +- Tributary-chip relocation as a tooltip / advanced layout — V1 just adds them in a "Mapped factors" subsection of the Outcome band + +**Rules of engagement:** TDD; one PR; subagent code review per task; never `--no-verify`; bundle followups per `feedback_bundle_followups_pre_merge.md`. + +--- + +## File structure + +### `packages/ui/src/components/ProductionLineGlanceDashboard/` — modified + +- `types.ts` — add `mode?: 'spatial' | 'full'` and `onModeChange?` to `ProductionLineGlanceDashboardProps`. +- `ProductionLineGlanceDashboard.tsx` — wrap the temporal row in a `max-height` transition container; expose `data-testid="dashboard-temporal-row"` for tests. +- `__tests__/ProductionLineGlanceDashboard.test.tsx` — extend with mode-prop tests. + +### `packages/hooks/src/` — new file + +- `useProductionLineGlanceOpsToggle.ts` — URL `?ops=full` state synchronizer. +- `__tests__/useProductionLineGlanceOpsToggle.test.tsx` + +### `packages/hooks/src/index.ts` — modified + +- Re-export the new hook. + +### `packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx` — modified + +- Add `operationsBandContent?: React.ReactNode` and `filterStripContent?: React.ReactNode` props. +- When `operationsBandContent` is provided: render that as the band content; relocate tributary chips to a new "Mapped factors" subsection of the Outcome band. +- When `filterStripContent` is provided: render it above the Outcome band. +- Defaults preserve current behavior. + +### `packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessView.test.tsx` — extended + +- New tests for `operationsBandContent` swap + tributary-chip relocation. +- New tests for `filterStripContent` placement. +- Existing tests pass unchanged. + +### `apps/azure/src/components/editor/FrameView.tsx` — modified + +- Compose the dashboard into the LayeredProcessView via the new slot props, using the C1 hooks. + +### `apps/pwa/src/components/views/FrameView.tsx` — modified + +- Same composition as azure FrameView. + +--- + +## Task 1: Add `mode` prop to `ProductionLineGlanceDashboard` + +**Files:** + +- Modify: `packages/ui/src/components/ProductionLineGlanceDashboard/types.ts` +- Modify: `packages/ui/src/components/ProductionLineGlanceDashboard/ProductionLineGlanceDashboard.tsx` +- Modify: `packages/ui/src/components/ProductionLineGlanceDashboard/__tests__/ProductionLineGlanceDashboard.test.tsx` + +- [ ] **Step 1: Extend the test with mode behavior** + +Append to `packages/ui/src/components/ProductionLineGlanceDashboard/__tests__/ProductionLineGlanceDashboard.test.tsx` (inside the existing `describe`): + +```typescript + it('renders both rows when mode is full (default)', () => { + const { container } = render(); + const temporal = container.querySelector('[data-testid="dashboard-temporal-row"]'); + expect(temporal).toBeTruthy(); + expect(temporal).toHaveAttribute('aria-hidden', 'false'); + }); + + it('collapses temporal row to max-height: 0 when mode="spatial"', () => { + const { container } = render( + + ); + const temporal = container.querySelector('[data-testid="dashboard-temporal-row"]'); + expect(temporal).toBeTruthy(); + expect(temporal).toHaveAttribute('aria-hidden', 'true'); + }); + + it('always mounts both rows in the DOM regardless of mode (no re-mount)', () => { + const { container, rerender } = render( + + ); + const initialBoxplot = container.querySelector('[data-testid="mock-capability-boxplot"]'); + expect(initialBoxplot).toBeTruthy(); + rerender(); + const afterBoxplot = container.querySelector('[data-testid="mock-capability-boxplot"]'); + expect(afterBoxplot).toBeTruthy(); + // Same DOM node identity — no re-mount. + expect(afterBoxplot).toBe(initialBoxplot); + }); +``` + +- [ ] **Step 2: Run, expect failure** + +`pnpm --filter @variscout/ui test ProductionLineGlanceDashboard` — expect FAIL on the new cases. + +- [ ] **Step 3: Add `mode` to `ProductionLineGlanceDashboardProps`** + +In `packages/ui/src/components/ProductionLineGlanceDashboard/types.ts`, append to the interface: + +```typescript + /** Reveal mode. Default 'full'. LayeredProcessView passes 'spatial'. */ + mode?: 'spatial' | 'full'; + /** Click handler when the user toggles between spatial and full. */ + onModeChange?: (next: 'spatial' | 'full') => void; +``` + +- [ ] **Step 4: Implement the collapse in `ProductionLineGlanceDashboard.tsx`** + +Wrap the existing top-row grid (`
` and `
`) inside a single container with `data-testid="dashboard-temporal-row"`, add `aria-hidden`, and apply `max-height` transition: + +The simplest effective layout: two grid rows controlled by mode. The temporal row is always rendered but its row-container has: + +```tsx +
+
+ +
+
+ +
+
+``` + +The bottom (spatial) row stays in its own grid below. Adjust the outer container's `grid-rows-2` to a flex column if needed so the collapsed row's space is reclaimed. + +Read the current `ProductionLineGlanceDashboard.tsx` and adapt the layout — preserve the 2x2 sizing in `mode='full'` and the bottom-row-only fill in `mode='spatial'`. + +If `style={{ maxHeight: ... }}` per-render churns, instead toggle a className. Tailwind v4 supports `max-h-0` / `max-h-screen` and `transition-all duration-240`. Keep it framework-idiomatic. + +Add `mode` to the destructured props with default `'full'`. Pass `onModeChange` through (no internal state — controlled). + +- [ ] **Step 5: Run tests, expect pass** + +`pnpm --filter @variscout/ui test ProductionLineGlanceDashboard` — expect all old tests + 3 new = pass. + +- [ ] **Step 6: Verify `tsc --noEmit` clean + no regressions** + +`pnpm --filter @variscout/ui tsc --noEmit && pnpm --filter @variscout/ui test 2>&1 | tail -3` + +- [ ] **Step 7: Commit** + +```bash +git add packages/ui/src/components/ProductionLineGlanceDashboard/ +git commit -m "$(cat <<'EOF' +feat(ui): add mode prop to ProductionLineGlanceDashboard + +mode: 'spatial' | 'full' (default 'full'). When 'spatial', the temporal +row's container collapses to max-height: 0 with a 240ms transition. The +chart components never re-mount — visx scales remain stable, no flicker. + +Used by LayeredProcessView Operations band (Plan C2) for progressive +reveal: the band shows mode='spatial' inline; user click toggles to +mode='full' to see the temporal row above. + +See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md +section "The dashboard's three forms". + +Co-Authored-By: ruflo +EOF +)" +``` + +--- + +## Task 2: `useProductionLineGlanceOpsToggle` URL-state hook + +**Files:** + +- Create: `packages/hooks/src/useProductionLineGlanceOpsToggle.ts` +- Create: `packages/hooks/src/__tests__/useProductionLineGlanceOpsToggle.test.tsx` +- Modify: `packages/hooks/src/index.ts` + +- [ ] **Step 1: Write failing tests** + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useProductionLineGlanceOpsToggle } from '../useProductionLineGlanceOpsToggle'; + +const setLocation = (search: string) => { + window.history.replaceState(null, '', `/test${search ? `?${search}` : ''}`); +}; + +describe('useProductionLineGlanceOpsToggle', () => { + beforeEach(() => setLocation('')); + afterEach(() => setLocation('')); + + it('returns "spatial" by default', () => { + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + expect(result.current.mode).toBe('spatial'); + }); + + it('reads "full" from ?ops=full', () => { + setLocation('ops=full'); + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + expect(result.current.mode).toBe('full'); + }); + + it('writes ops=full to URL via replaceState', () => { + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + act(() => result.current.setMode('full')); + expect(window.location.search).toContain('ops=full'); + }); + + it('removes ops param when toggling back to spatial', () => { + setLocation('ops=full'); + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + act(() => result.current.setMode('spatial')); + expect(window.location.search).not.toContain('ops='); + }); + + it('preserves filter params when toggling ops', () => { + setLocation('product=Coke&ops=spatial'); + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + act(() => result.current.setMode('full')); + expect(window.location.search).toContain('product=Coke'); + expect(window.location.search).toContain('ops=full'); + }); + + it('toggle() flips spatial <-> full', () => { + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + expect(result.current.mode).toBe('spatial'); + act(() => result.current.toggle()); + expect(result.current.mode).toBe('full'); + act(() => result.current.toggle()); + expect(result.current.mode).toBe('spatial'); + }); + + it('does not push history entries (uses replaceState)', () => { + const initial = window.history.length; + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + act(() => result.current.setMode('full')); + act(() => result.current.setMode('spatial')); + act(() => result.current.setMode('full')); + expect(window.history.length).toBe(initial); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail** + +`pnpm --filter @variscout/hooks test useProductionLineGlanceOpsToggle` + +- [ ] **Step 3: Implement** + +```typescript +import { useCallback, useEffect, useState } from 'react'; + +export type ProductionLineGlanceOpsMode = 'spatial' | 'full'; + +const PARAM_NAME = 'ops'; + +function readFromURL(): ProductionLineGlanceOpsMode { + if (typeof window === 'undefined') return 'spatial'; + const value = new URLSearchParams(window.location.search).get(PARAM_NAME); + return value === 'full' ? 'full' : 'spatial'; +} + +function writeToURL(mode: ProductionLineGlanceOpsMode): void { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + if (mode === 'spatial') { + params.delete(PARAM_NAME); + } else { + params.set(PARAM_NAME, 'full'); + } + const next = params.toString(); + const url = `${window.location.pathname}${next ? `?${next}` : ''}${window.location.hash}`; + window.history.replaceState(null, '', url); +} + +export interface UseProductionLineGlanceOpsToggleResult { + mode: ProductionLineGlanceOpsMode; + setMode: (next: ProductionLineGlanceOpsMode) => void; + toggle: () => void; +} + +/** + * URL-search-param state for the LayeredProcessView Operations band's + * progressive-reveal mode. Default 'spatial' (only the bottom row is + * visible). 'full' reveals the temporal row above the spatial row. + * + * Coexists with useProductionLineGlanceFilter via the latter's reserved + * params list. + */ +export function useProductionLineGlanceOpsToggle(): UseProductionLineGlanceOpsToggleResult { + const [mode, setModeState] = useState(() => readFromURL()); + + useEffect(() => { + const onPop = () => setModeState(readFromURL()); + window.addEventListener('popstate', onPop); + return () => window.removeEventListener('popstate', onPop); + }, []); + + const setMode = useCallback((next: ProductionLineGlanceOpsMode) => { + setModeState(next); + writeToURL(next); + }, []); + + const toggle = useCallback(() => { + setModeState(prev => { + const next: ProductionLineGlanceOpsMode = prev === 'spatial' ? 'full' : 'spatial'; + writeToURL(next); + return next; + }); + }, []); + + return { mode, setMode, toggle }; +} +``` + +- [ ] **Step 4: Run, expect pass** + +7/7. + +- [ ] **Step 5: Re-export from `packages/hooks/src/index.ts`** + +```typescript +export { useProductionLineGlanceOpsToggle } from './useProductionLineGlanceOpsToggle'; +export type { + ProductionLineGlanceOpsMode, + UseProductionLineGlanceOpsToggleResult, +} from './useProductionLineGlanceOpsToggle'; +``` + +- [ ] **Step 6: Verify hooks suite** + +`pnpm --filter @variscout/hooks test 2>&1 | tail -3` — total previous + 7. + +- [ ] **Step 7: Commit** + +```bash +git add packages/hooks/src/useProductionLineGlanceOpsToggle.ts \ + packages/hooks/src/__tests__/useProductionLineGlanceOpsToggle.test.tsx \ + packages/hooks/src/index.ts +git commit -m "feat(hooks): add useProductionLineGlanceOpsToggle (URL ?ops state) + +URL-search-param synchronizer for the LayeredProcessView Operations band's +progressive-reveal mode. Default 'spatial' (bottom row only); 'full' adds +the temporal row above. Coexists with useProductionLineGlanceFilter via +that hook's reserved-params list. + +See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md +section 'Three surfaces / 1. LayeredProcessView Operations band'. + +Co-Authored-By: ruflo " +``` + +--- + +## Task 3: `LayeredProcessView` slot-prop API + +**Files:** + +- Modify: `packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx` +- Modify: `packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessView.test.tsx` + +- [ ] **Step 1: Read the current LayeredProcessView** + +Read the full file — note the existing Outcome / Process Flow / Operations bands and the tributary-chip rendering inside the Operations band. + +- [ ] **Step 2: Extend the test** + +Append cases: + +```typescript + it('replaces Operations band content when operationsBandContent is provided', () => { + const mapWithFactors = /* same as existing test */; + render( + {}} + operationsBandContent={
CUSTOM
} + /> + ); + expect(screen.getByTestId('custom-ops')).toBeInTheDocument(); + }); + + it('relocates tributary chips to Outcome band as Mapped factors when operationsBandContent is provided', () => { + const mapWithFactors = /* map with at least one tributary */; + render( + {}} + operationsBandContent={
X
} + /> + ); + // Tributary chips appear inside the Outcome band's Mapped factors subsection + const outcome = screen.getByTestId('band-outcome'); + expect(outcome.textContent).toMatch(/Mapped factors/i); + // The chip's data-testid stays the same; it just moved to a different band + expect(within(outcome).getByTestId(/factor-chip-/)).toBeInTheDocument(); + }); + + it('renders filterStripContent above the Outcome band when provided', () => { + render( + {}} + filterStripContent={
FILTER
} + /> + ); + expect(screen.getByTestId('filter-strip')).toBeInTheDocument(); + }); + + it('renders Operations band default content (tributary chips) when slot props are absent', () => { + const mapWithFactors = /* map with one tributary */; + render( {}} />); + const ops = screen.getByTestId('band-operations'); + expect(within(ops).getByTestId(/factor-chip-/)).toBeInTheDocument(); + }); +``` + +(`within` from `@testing-library/react`.) + +- [ ] **Step 3: Run, expect failure** + +`pnpm --filter @variscout/ui test LayeredProcessView` + +- [ ] **Step 4: Implement the slot props** + +Modify `packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx`: + +```typescript +export interface LayeredProcessViewProps { + map: ProcessMap; + availableColumns: string[]; + onChange: (next: ProcessMap) => void; + gaps?: Gap[]; + disabled?: boolean; + target?: number; + usl?: number; + lsl?: number; + onSpecsChange?: (next: { target?: number; usl?: number; lsl?: number }) => void; + /** Optional content rendered inside the Operations band. When provided, + * tributary chips relocate to the Outcome band as a "Mapped factors" + * subsection. Plan C2. */ + operationsBandContent?: React.ReactNode; + /** Optional content rendered above the Outcome band (typically the + * dashboard's filter strip). Plan C2. */ + filterStripContent?: React.ReactNode; +} + +export const LayeredProcessView: React.FC = ({ + map, + availableColumns, + onChange, + gaps, + disabled, + target, + usl, + lsl, + onSpecsChange, + operationsBandContent, + filterStripContent, +}) => { + const hasOutcomeData = target !== undefined || usl !== undefined || lsl !== undefined; + 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 ( +
+ {filterStripContent ? ( +
{filterStripContent}
+ ) : null} + +
+

Outcome

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

No outcome target set

+ )} + {operationsBandContent ? ( +
+

+ Mapped factors +

+ {tributariesContent} +
+ ) : null} +
+ +
+

Process Flow

+
+ +
+
+ +
+

Operations

+ {operationsBandContent ? ( +
{operationsBandContent}
+ ) : ( + tributariesContent + )} +
+
+ ); +}; +``` + +- [ ] **Step 5: Run, expect pass** + +`pnpm --filter @variscout/ui test LayeredProcessView` — all old tests + 4 new = pass. + +- [ ] **Step 6: Verify ui suite + tsc** + +``` +pnpm --filter @variscout/ui test +pnpm --filter @variscout/ui tsc --noEmit +``` + +- [ ] **Step 7: Commit** + +```bash +git add packages/ui/src/components/LayeredProcessView/ +git commit -m "feat(ui): add operationsBandContent + filterStripContent slot props to LayeredProcessView + +Two optional slot props for Plan C2's progressive-reveal composition. When +operationsBandContent is provided, the band renders the slot content and +the tributary-chip list relocates to the Outcome band as a 'Mapped factors' +subsection. When filterStripContent is provided, it renders above the +Outcome band as the layered-view's hoisted filter strip. + +Default behavior (both slot props absent) is unchanged — preserves current +FRAME usage. + +See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md +section 'Three surfaces / 1. LayeredProcessView Operations band'. + +Co-Authored-By: ruflo " +``` + +--- + +## Task 4: Compose `LayeredProcessViewWithCapability` wrapper + +The wrapper takes the same props as `LayeredProcessView` plus `data` (slot inputs from `useProductionLineGlanceData`) + `filter` (from `useProductionLineGlanceFilter`) + `mode/onModeToggle` (from `useProductionLineGlanceOpsToggle`). It composes the dashboard inside the Operations band and the filter strip above the Outcome band, plus the progressive-reveal affordance. + +**Files:** + +- Create: `packages/ui/src/components/LayeredProcessView/LayeredProcessViewWithCapability.tsx` +- Create: `packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessViewWithCapability.test.tsx` +- Modify: `packages/ui/src/components/LayeredProcessView/index.ts` +- Modify: `packages/ui/src/index.ts` + +- [ ] **Step 1: Write the test** + +```typescript +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 { render, screen, fireEvent } from '@testing-library/react'; +import { LayeredProcessViewWithCapability } from '../LayeredProcessViewWithCapability'; +import type { ProcessMap } from '@variscout/core/frame'; +import type { ProductionLineGlanceDashboardProps } from '../../ProductionLineGlanceDashboard'; + +const map: ProcessMap = { + version: 1, + nodes: [], + tributaries: [], +} as unknown as ProcessMap; + +const data: Pick< + ProductionLineGlanceDashboardProps, + 'cpkTrend' | 'cpkGapTrend' | 'capabilityNodes' | 'errorSteps' +> = { + cpkTrend: { data: [], stats: null, specs: { target: 1.33 } }, + cpkGapTrend: { series: [], stats: null }, + capabilityNodes: [], + errorSteps: [], +}; + +describe('LayeredProcessViewWithCapability', () => { + it('renders the dashboard inside the Operations band', () => { + render( + {}} + data={data} + filter={{ + availableContext: { hubColumns: [] }, + contextValueOptions: {}, + value: {}, + onChange: vi.fn(), + }} + mode="spatial" + onModeChange={vi.fn()} + /> + ); + expect(screen.getByTestId('mock-capability-boxplot')).toBeInTheDocument(); + expect(screen.getByTestId('mock-step-pareto')).toBeInTheDocument(); + }); + + it('shows "Show temporal trends" affordance when mode=spatial', () => { + render( + {}} + data={data} + filter={{ + availableContext: { hubColumns: [] }, + contextValueOptions: {}, + value: {}, + onChange: vi.fn(), + }} + mode="spatial" + onModeChange={vi.fn()} + /> + ); + expect(screen.getByRole('button', { name: /show temporal trends/i })).toBeInTheDocument(); + }); + + it('shows "Hide temporal trends" affordance when mode=full', () => { + render( + {}} + data={data} + filter={{ + availableContext: { hubColumns: [] }, + contextValueOptions: {}, + value: {}, + onChange: vi.fn(), + }} + mode="full" + onModeChange={vi.fn()} + /> + ); + expect(screen.getByRole('button', { name: /hide temporal trends/i })).toBeInTheDocument(); + }); + + it('fires onModeChange when affordance is clicked', () => { + const onModeChange = vi.fn(); + render( + {}} + data={data} + filter={{ + availableContext: { hubColumns: [] }, + contextValueOptions: {}, + value: {}, + onChange: vi.fn(), + }} + mode="spatial" + onModeChange={onModeChange} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /show temporal trends/i })); + expect(onModeChange).toHaveBeenCalledWith('full'); + }); + + it('renders the filter strip above the Outcome band', () => { + render( + {}} + data={data} + filter={{ + availableContext: { hubColumns: ['product'] }, + contextValueOptions: { product: ['A'] }, + value: {}, + onChange: vi.fn(), + }} + mode="spatial" + onModeChange={vi.fn()} + /> + ); + // ProductionLineGlanceFilterStrip renders the column name as a label + expect(screen.getByText('product')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail** + +- [ ] **Step 3: Implement** + +```typescript +/** + * LayeredProcessViewWithCapability — 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. + * + * 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, + ProductionLineGlanceFilterStrip, + type ProductionLineGlanceDashboardProps, + type ProductionLineGlanceFilterStripProps, +} from '../ProductionLineGlanceDashboard'; + +export type ProductionLineGlanceOpsMode = 'spatial' | 'full'; + +export interface LayeredProcessViewWithCapabilityProps + extends Omit { + data: Pick< + ProductionLineGlanceDashboardProps, + 'cpkTrend' | 'cpkGapTrend' | 'capabilityNodes' | 'errorSteps' + >; + filter: ProductionLineGlanceFilterStripProps; + mode: ProductionLineGlanceOpsMode; + onModeChange: (next: ProductionLineGlanceOpsMode) => void; + onStepClick?: (nodeId: string) => void; +} + +export const LayeredProcessViewWithCapability: React.FC = ({ + data, + filter, + mode, + onModeChange, + onStepClick, + ...layeredProps +}) => { + const isFull = mode === 'full'; + const affordanceLabel = isFull ? 'Hide temporal trends' : 'Show temporal trends'; + const affordanceArrow = isFull ? '↓' : '↑'; + + return ( + } + operationsBandContent={ +
+ +
+ +
+
+ } + /> + ); +}; + +export default LayeredProcessViewWithCapability; +``` + +- [ ] **Step 4: Run, expect pass** + +5/5. + +- [ ] **Step 5: Wire exports** + +In `packages/ui/src/components/LayeredProcessView/index.ts`: + +```typescript +export { LayeredProcessView } from './LayeredProcessView'; +export type { LayeredProcessViewProps } from './LayeredProcessView'; +export { LayeredProcessViewWithCapability } from './LayeredProcessViewWithCapability'; +export type { LayeredProcessViewWithCapabilityProps, ProductionLineGlanceOpsMode } from './LayeredProcessViewWithCapability'; +``` + +In `packages/ui/src/index.ts` append: + +```typescript +export { + LayeredProcessViewWithCapability, +} from './components/LayeredProcessView'; +export type { + LayeredProcessViewWithCapabilityProps, + ProductionLineGlanceOpsMode, +} from './components/LayeredProcessView'; +``` + +- [ ] **Step 6: Verify ui suite + tsc** + +- [ ] **Step 7: Commit** + +```bash +git add packages/ui/src/components/LayeredProcessView/ \ + packages/ui/src/index.ts +git commit -m "feat(ui): add LayeredProcessViewWithCapability composition wrapper + +Mounts ProductionLineGlanceDashboard inside LayeredProcessView's +Operations band with progressive-reveal affordance ('Show/Hide temporal +trends'). Filter strip hoisted above the Outcome band. Plan C2. + +See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md +section 'Three surfaces / 1. LayeredProcessView Operations band'. + +Co-Authored-By: ruflo " +``` + +--- + +## Task 5: Wire into apps/azure FrameView + +**Files:** + +- Modify: `apps/azure/src/components/editor/FrameView.tsx` +- Modify or extend tests: `apps/azure/src/components/editor/__tests__/FrameView.test.tsx` (if exists; otherwise add a small smoke test) + +- [ ] **Step 1: Read existing FrameView** + +`cat apps/azure/src/components/editor/FrameView.tsx | head -120` + +Understand which props the existing `` receives and where the surrounding hub/investigation context comes from. + +- [ ] **Step 2: Replace `` with ``** + +Pull the rollup (or hub + members + rows) from the existing FrameView state. Use: +- `useHubProvision({ rollup })` — but FrameView may not have a rollup yet (it's an investigation-editor surface, not a hub view). If so, BUILD a synthetic rollup from the current investigation: `{ hub: { id: 'frame-preview', canonicalProcessMap: map, ... }, investigations: [currentInvestigation] }`. The dashboard will show data scoped to the investigation being authored. +- `useProductionLineGlanceData({ hub, members, rowsByInvestigation, contextFilter })` with the synthetic rollup. +- `useProductionLineGlanceFilter()` for filter state. +- `useProductionLineGlanceOpsToggle()` for mode state. + +The exact synthetic-rollup shape depends on FrameView's existing data; the implementer adapts. + +If wiring becomes complex (FrameView's data layer doesn't naturally project to ProcessHubRollup), a smaller scope is acceptable: pass a `data` prop with empty slot inputs (the empty-state hint in the dashboard handles it gracefully) and document that real-data wiring lands in a follow-up. Plan C2's primary value is the API + composition; live data in FRAME is V2. + +- [ ] **Step 3: If a smoke test exists, update; otherwise add a minimal one** + +Add a test that mounts FrameView and asserts `data-testid="layered-process-view"` is present and `data-testid="ops-band-dashboard"` is present (using mocked chart components). + +- [ ] **Step 4: Run azure tests** + +`pnpm --filter @variscout/azure-app test FrameView` + +- [ ] **Step 5: Commit** + +```bash +git add apps/azure/src/components/editor/FrameView.tsx \ + apps/azure/src/components/editor/__tests__/FrameView.test.tsx +git commit -m "feat(azure): wire LayeredProcessViewWithCapability into FrameView + +Replaces direct LayeredProcessView mount with the C2 composition wrapper +hosting the production-line-glance dashboard inside the Operations band +with progressive-reveal affordance. Filter strip hoisted to top of the +layered view. URL ?ops state via useProductionLineGlanceOpsToggle. + +See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md. + +Co-Authored-By: ruflo " +``` + +--- + +## Task 6: Wire into apps/pwa FrameView + +Same as Task 5 but for `apps/pwa/src/components/views/FrameView.tsx`. PWA's data layer is Zustand; the implementer projects the current investigation into the synthetic rollup similarly to azure. + +- [ ] **Step 1: Read existing PWA FrameView + identify data path** +- [ ] **Step 2: Replace `` with ``** +- [ ] **Step 3: Add/update smoke test** +- [ ] **Step 4: Verify PWA tests** +- [ ] **Step 5: Commit** + +```bash +git add apps/pwa/src/components/views/FrameView.tsx +git commit -m "feat(pwa): wire LayeredProcessViewWithCapability into FrameView + +Plan C2 — same composition as the azure FrameView wiring. Dashboard +spatial row inline in the Operations band; progressive reveal toggles +the temporal row above. + +Co-Authored-By: ruflo " +``` + +--- + +## Task 7: Workspace verification + chrome walk + PR + +- [ ] **Step 1: Full workspace tests + build + pr-ready-check** + +``` +pnpm test +pnpm build +bash scripts/pr-ready-check.sh +``` + +- [ ] **Step 2: Chrome walk — start dev server and validate** + +``` +pnpm --filter @variscout/azure-app dev +``` + +Open the FrameView. Validate: +- LayeredProcessView renders three bands. +- Operations band shows the dashboard's spatial row (CapabilityBoxplot left, StepErrorPareto right). +- Filter strip appears above the Outcome band. +- Outcome band shows "Mapped factors" subsection with tributary chips. +- "Show temporal trends ↑" affordance visible. Click it: temporal row expands above the spatial row with a smooth transition. URL becomes `?ops=full`. Affordance text updates to "Hide temporal trends ↓". +- Click again: collapses. URL `?ops` removed. + +Capture screenshots before/after. + +- [ ] **Step 3: Push branch + open PR** + +```bash +git push -u origin feat/plan-c2-layered-view-progressive-reveal +gh pr create --title "feat: Plan C2 LayeredProcessView Operations band + progressive reveal" --body "$(cat <<'EOF' +## Summary + +Second of three sub-plans for the production-line-glance surface-wiring design. Wires the dashboard into LayeredProcessView's Operations band with progressive reveal in both azure-app and PWA FrameView. + +- `mode: 'spatial' | 'full'` prop on `ProductionLineGlanceDashboard` (additive; default 'full' preserves C1 behavior). +- `useProductionLineGlanceOpsToggle` URL `?ops` state hook. +- `operationsBandContent` + `filterStripContent` slot props on `LayeredProcessView` (additive; default behavior unchanged). +- `LayeredProcessViewWithCapability` composition wrapper. +- Wired into both apps' FrameView. +- Tributary chips relocated to Outcome band's "Mapped factors" subsection when slot props are used. + +## Test plan + +- [x] All package suites green +- [x] `pnpm test` 9/9 turbo tasks green +- [x] `bash scripts/pr-ready-check.sh` green +- [ ] Chrome walk: progressive reveal animation, URL state, filter strip placement, mapped-factors relocation + +## Spec + +`docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md` — sections "The dashboard's three forms" + "Three surfaces / 1. LayeredProcessView Operations band". + +🤖 Generated with [ruflo](https://github.com/ruvnet/ruflo) +EOF +)" +``` + +- [ ] **Step 4: Final code review** via `feature-dev:code-reviewer` subagent. Focus on: + - Watson aggregation safety (no new arithmetic across heterogeneous nodes/investigations). + - Hard rules: no hex; no manual memo; semantic Tailwind tokens; both Base + responsive exports for any new chart-like components. + - Test discipline: vi.mock before imports; deterministic data; no `as never` masking type errors. + - Spec coverage of C2's stated scope. + +- [ ] **Step 5: Address findings** in follow-up commits. + +- [ ] **Step 6: Squash-merge** + +```bash +gh pr merge --squash --delete-branch +``` + +--- + +## Self-review + +**Spec coverage:** +- ✅ `mode` prop on dashboard (T1) +- ✅ URL `?ops` state (T2) +- ✅ Slot-prop API (T3) +- ✅ Composition wrapper with progressive-reveal affordance (T4) +- ✅ Surface wiring in both apps' FrameView (T5, T6) +- ✅ Tributary chips relocation (T3) +- ✅ Filter strip hoisted (T3, T4) + +**Placeholder scan:** No TBD/TODO/etc. + +**Type consistency:** `ProductionLineGlanceOpsMode` defined in T2 (`@variscout/hooks`) and T4 (`@variscout/ui`). The duplication is intentional: hooks owns URL state; ui owns composition. Both reduce to `'spatial' | 'full'` and are interchangeable. + +**Risk reminders:** +- T5/T6 may discover that FrameView's data layer doesn't project cleanly to ProcessHubRollup. Pragmatic fallback: pass empty `data` props and document live-data wiring as V2 follow-up. The composition is the value; live data is icing. +- The temporal row's `max-height` transition with content of unknown height (depends on viewport) needs the right CSS — use `max-h-screen` for full or rely on actual element height via `style.maxHeight`. T1 implementer chooses. +- The "Mapped factors" subsection in Outcome band may overflow at small widths. Wrap chip list with `flex-wrap` (already in original code). diff --git a/docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md b/docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md index 216d4b1ce..74830a836 100644 --- a/docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md +++ b/docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md @@ -243,7 +243,7 @@ The design above covers all three surfaces and the data layer as a single cohere 1. **C1 — Data layer + Process Hub Capability tab.** Lands the `@variscout/core/stats` derivation utilities, the `@variscout/hooks` data hooks, the URL-state filter hook, and the dashboard wired into the Process Hub Capability tab in azure-app. Includes the B0 migration banner + mapping modal. Chrome walk validates the full data-to-render path on the highest-traffic surface first; this also closes the Plan B T3 overlay-alignment deferred check. -2. **C2 — LayeredProcessView Operations band.** Adds `mode: 'spatial' | 'full'` to `ProductionLineGlanceDashboard`, replaces the Operations band content with ``, adds the progressive-reveal affordance, hoists the filter strip to the top of LayeredProcessView, moves tributary chips to the Outcome band's "Mapped factors" section, amends the V1 LayeredProcessView spec. +2. **C2 — LayeredProcessView Operations band.** Adds `mode: 'spatial' | 'full'` to `ProductionLineGlanceDashboard`, replaces the Operations band content with ``, adds the progressive-reveal affordance, hoists the filter strip to the top of LayeredProcessView, moves tributary chips to the Outcome band's "Mapped factors" section, amends the V1 LayeredProcessView spec. Plan: [`docs/superpowers/plans/2026-04-28-production-line-glance-c2-layered-view.md`](../plans/2026-04-28-production-line-glance-c2-layered-view.md). 3. **C3 — FRAME workspace right-hand drawer.** Adds a collapsible right-hand drawer to FRAME's canonical-map authoring mode that hosts the dashboard live-bound to authoring-state stores. Drawer collapse persists per-workspace in localStorage. From d9e2b82cb715723b443de843cb0baaf343f405a2 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 00:21:26 +0300 Subject: [PATCH 2/7] feat(ui): add mode prop to ProductionLineGlanceDashboard mode: 'spatial' | 'full' (default 'full'). When 'spatial', the temporal row container collapses to max-height: 0 with a 300ms transition; the chart components never re-mount (visx scales remain stable, no flicker). Used by the LayeredProcessView Operations band (Plan C2) for progressive reveal: the band shows mode='spatial' inline; user click toggles to mode='full' to reveal the temporal row above. See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md section 'The dashboard's three forms'. Co-Authored-By: ruflo --- .../ProductionLineGlanceDashboard.tsx | 57 +++++++++++-------- .../ProductionLineGlanceDashboard.test.tsx | 25 ++++++++ .../ProductionLineGlanceDashboard/types.ts | 5 ++ 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/components/ProductionLineGlanceDashboard/ProductionLineGlanceDashboard.tsx b/packages/ui/src/components/ProductionLineGlanceDashboard/ProductionLineGlanceDashboard.tsx index 60985b449..7f9a82820 100644 --- a/packages/ui/src/components/ProductionLineGlanceDashboard/ProductionLineGlanceDashboard.tsx +++ b/packages/ui/src/components/ProductionLineGlanceDashboard/ProductionLineGlanceDashboard.tsx @@ -29,7 +29,10 @@ export const ProductionLineGlanceDashboard: React.FC { + const isSpatial = mode === 'spatial'; return (
{title ? ( @@ -47,31 +50,39 @@ export const ProductionLineGlanceDashboard: React.FC ) : null} -
-
- +
+
+
+ +
+
+ +
-
- -
- -
- - {capabilityNodes.length === 0 ? ( -
- No mapped nodes — per-step capability unavailable. -
- ) : null} -
- -
- +
+
+ + {capabilityNodes.length === 0 ? ( +
+ No mapped nodes — per-step capability unavailable. +
+ ) : null} +
+
+ +
diff --git a/packages/ui/src/components/ProductionLineGlanceDashboard/__tests__/ProductionLineGlanceDashboard.test.tsx b/packages/ui/src/components/ProductionLineGlanceDashboard/__tests__/ProductionLineGlanceDashboard.test.tsx index 0abc518a6..ec307d64b 100644 --- a/packages/ui/src/components/ProductionLineGlanceDashboard/__tests__/ProductionLineGlanceDashboard.test.tsx +++ b/packages/ui/src/components/ProductionLineGlanceDashboard/__tests__/ProductionLineGlanceDashboard.test.tsx @@ -112,4 +112,29 @@ describe('ProductionLineGlanceDashboard', () => { render(); expect(screen.getByText(/no mapped/i)).toBeInTheDocument(); }); + + it('renders both rows when mode is full (default)', () => { + const { container } = render(); + const temporal = container.querySelector('[data-testid="dashboard-temporal-row"]'); + expect(temporal).toBeTruthy(); + expect(temporal).toHaveAttribute('aria-hidden', 'false'); + }); + + it('collapses temporal row to aria-hidden when mode="spatial"', () => { + const { container } = render(); + const temporal = container.querySelector('[data-testid="dashboard-temporal-row"]'); + expect(temporal).toBeTruthy(); + expect(temporal).toHaveAttribute('aria-hidden', 'true'); + }); + + it('keeps both rows mounted across mode changes (no chart re-mount)', () => { + const { container, rerender } = render( + + ); + const initialBoxplot = container.querySelector('[data-testid="mock-capability-boxplot"]'); + expect(initialBoxplot).toBeTruthy(); + rerender(); + const afterBoxplot = container.querySelector('[data-testid="mock-capability-boxplot"]'); + expect(afterBoxplot).toBe(initialBoxplot); + }); }); diff --git a/packages/ui/src/components/ProductionLineGlanceDashboard/types.ts b/packages/ui/src/components/ProductionLineGlanceDashboard/types.ts index da0d2a2c1..23fc730ff 100644 --- a/packages/ui/src/components/ProductionLineGlanceDashboard/types.ts +++ b/packages/ui/src/components/ProductionLineGlanceDashboard/types.ts @@ -42,4 +42,9 @@ export interface ProductionLineGlanceDashboardProps { /** Optional title shown above the dashboard. */ title?: string; + + /** Reveal mode. Default 'full'. LayeredProcessView passes 'spatial'. */ + mode?: 'spatial' | 'full'; + /** Click handler when the user toggles between spatial and full. */ + onModeChange?: (next: 'spatial' | 'full') => void; } From a696002877bcc5c50f0d2b67ec22d2995496eaa4 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 00:26:21 +0300 Subject: [PATCH 3/7] feat(hooks): add useProductionLineGlanceOpsToggle (URL ?ops state) URL search-param synchronizer for the LayeredProcessView Operations band's progressive-reveal mode. Default 'spatial'; 'full' reveals the temporal row. Coexists with useProductionLineGlanceFilter via that hook's reserved-params list. See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md section 'Three surfaces / 1. LayeredProcessView Operations band'. Co-Authored-By: ruflo --- .../useProductionLineGlanceOpsToggle.test.tsx | 61 +++++++++++++++++++ packages/hooks/src/index.ts | 7 +++ .../src/useProductionLineGlanceOpsToggle.ts | 59 ++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 packages/hooks/src/__tests__/useProductionLineGlanceOpsToggle.test.tsx create mode 100644 packages/hooks/src/useProductionLineGlanceOpsToggle.ts diff --git a/packages/hooks/src/__tests__/useProductionLineGlanceOpsToggle.test.tsx b/packages/hooks/src/__tests__/useProductionLineGlanceOpsToggle.test.tsx new file mode 100644 index 000000000..a272c10c8 --- /dev/null +++ b/packages/hooks/src/__tests__/useProductionLineGlanceOpsToggle.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useProductionLineGlanceOpsToggle } from '../useProductionLineGlanceOpsToggle'; + +const setLocation = (search: string) => { + window.history.replaceState(null, '', `/test${search ? `?${search}` : ''}`); +}; + +describe('useProductionLineGlanceOpsToggle', () => { + beforeEach(() => setLocation('')); + afterEach(() => setLocation('')); + + it('returns "spatial" by default', () => { + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + expect(result.current.mode).toBe('spatial'); + }); + + it('reads "full" from ?ops=full', () => { + setLocation('ops=full'); + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + expect(result.current.mode).toBe('full'); + }); + + it('writes ops=full to URL via setMode', () => { + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + act(() => result.current.setMode('full')); + expect(window.location.search).toContain('ops=full'); + }); + + it('removes ops param when toggling back to spatial', () => { + setLocation('ops=full'); + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + act(() => result.current.setMode('spatial')); + expect(window.location.search).not.toContain('ops='); + }); + + it('preserves filter params when toggling ops', () => { + setLocation('product=Coke&ops=spatial'); + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + act(() => result.current.setMode('full')); + expect(window.location.search).toContain('product=Coke'); + expect(window.location.search).toContain('ops=full'); + }); + + it('toggle() flips spatial <-> full', () => { + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + expect(result.current.mode).toBe('spatial'); + act(() => result.current.toggle()); + expect(result.current.mode).toBe('full'); + act(() => result.current.toggle()); + expect(result.current.mode).toBe('spatial'); + }); + + it('uses replaceState (no history growth)', () => { + const initial = window.history.length; + const { result } = renderHook(() => useProductionLineGlanceOpsToggle()); + act(() => result.current.setMode('full')); + act(() => result.current.setMode('spatial')); + expect(window.history.length).toBe(initial); + }); +}); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 1c00505e0..cc4eedeaa 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -507,6 +507,13 @@ export type { export { useProductionLineGlanceFilter } from './useProductionLineGlanceFilter'; export type { UseProductionLineGlanceFilterResult } from './useProductionLineGlanceFilter'; +// Production Line Glance Ops Toggle (URL ?ops spatial/full progressive reveal) +export { useProductionLineGlanceOpsToggle } from './useProductionLineGlanceOpsToggle'; +export type { + ProductionLineGlanceOpsMode, + UseProductionLineGlanceOpsToggleResult, +} from './useProductionLineGlanceOpsToggle'; + // B0 Investigations In Hub (enumerates unmapped + non-dismissed for migration banner) export { useB0InvestigationsInHub } from './useB0InvestigationsInHub'; export type { diff --git a/packages/hooks/src/useProductionLineGlanceOpsToggle.ts b/packages/hooks/src/useProductionLineGlanceOpsToggle.ts new file mode 100644 index 000000000..aa085cbc8 --- /dev/null +++ b/packages/hooks/src/useProductionLineGlanceOpsToggle.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react'; + +export type ProductionLineGlanceOpsMode = 'spatial' | 'full'; +const PARAM_NAME = 'ops'; + +function readFromURL(): ProductionLineGlanceOpsMode { + if (typeof window === 'undefined') return 'spatial'; + const value = new URLSearchParams(window.location.search).get(PARAM_NAME); + return value === 'full' ? 'full' : 'spatial'; +} + +function writeToURL(mode: ProductionLineGlanceOpsMode): void { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + if (mode === 'spatial') { + params.delete(PARAM_NAME); + } else { + params.set(PARAM_NAME, 'full'); + } + const next = params.toString(); + const url = `${window.location.pathname}${next ? `?${next}` : ''}${window.location.hash}`; + window.history.replaceState(null, '', url); +} + +export interface UseProductionLineGlanceOpsToggleResult { + mode: ProductionLineGlanceOpsMode; + setMode: (next: ProductionLineGlanceOpsMode) => void; + toggle: () => void; +} + +/** + * URL ?ops state for the LayeredProcessView Operations band's + * progressive-reveal mode. Default 'spatial'; 'full' reveals the + * temporal row above the spatial row. + */ +export function useProductionLineGlanceOpsToggle(): UseProductionLineGlanceOpsToggleResult { + const [mode, setModeState] = useState(() => readFromURL()); + + useEffect(() => { + const onPop = () => setModeState(readFromURL()); + window.addEventListener('popstate', onPop); + return () => window.removeEventListener('popstate', onPop); + }, []); + + const setMode = useCallback((next: ProductionLineGlanceOpsMode) => { + setModeState(next); + writeToURL(next); + }, []); + + const toggle = useCallback(() => { + setModeState(prev => { + const next: ProductionLineGlanceOpsMode = prev === 'spatial' ? 'full' : 'spatial'; + writeToURL(next); + return next; + }); + }, []); + + return { mode, setMode, toggle }; +} From 7049d1f5e069584a9755a7a8579cd8bc58d7a809 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 00:32:54 +0300 Subject: [PATCH 4/7] feat(ui): add operationsBandContent + filterStripContent slot props to LayeredProcessView Two optional slot props for Plan C2's progressive-reveal composition. When operationsBandContent is provided, the band renders the slot content and the tributary-chip list relocates to the Outcome band as a 'Mapped factors' subsection. When filterStripContent is provided, it renders above the Outcome band. Default behavior (both slot props absent) is unchanged. See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md section 'Three surfaces / 1. LayeredProcessView Operations band'. Co-Authored-By: ruflo --- .../LayeredProcessView/LayeredProcessView.tsx | 74 ++++++++++++----- .../__tests__/LayeredProcessView.test.tsx | 79 +++++++++++++++++++ 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx b/packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx index aa05e68cb..150da374a 100644 --- a/packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx +++ b/packages/ui/src/components/LayeredProcessView/LayeredProcessView.tsx @@ -5,6 +5,13 @@ * Flow band wraps the existing `ProcessMapBase` river-SIPOC; the Outcome and * Operations bands surround it. See spec at * `docs/superpowers/specs/2026-04-27-layered-process-view-design.md` (V1). + * + * Plan C2 slot props: + * - `operationsBandContent` — replaces the default tributary chips in the + * Operations band. When provided, the tributary chips relocate to the + * Outcome band as a "Mapped factors" subsection. + * - `filterStripContent` — renders above the Outcome band (e.g. dashboard + * filter strip). */ import React from 'react'; @@ -21,6 +28,13 @@ export interface LayeredProcessViewProps { usl?: number; lsl?: number; onSpecsChange?: (next: { target?: number; usl?: number; lsl?: number }) => void; + /** Optional content rendered inside the Operations band. When provided, + * tributary chips relocate to the Outcome band as a 'Mapped factors' + * subsection. Plan C2. */ + operationsBandContent?: React.ReactNode; + /** Optional content rendered above the Outcome band (typically the + * dashboard's filter strip). Plan C2. */ + filterStripContent?: React.ReactNode; } export const LayeredProcessView: React.FC = ({ @@ -33,11 +47,39 @@ export const LayeredProcessView: React.FC = ({ usl, lsl, onSpecsChange, + operationsBandContent, + filterStripContent, }) => { const hasOutcomeData = target !== undefined || usl !== undefined || lsl !== undefined; + 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 (
+ {filterStripContent ? ( +
{filterStripContent}
+ ) : null} +
= ({ ) : (

No outcome target set

)} + + {operationsBandContent ? ( +
+

+ Mapped factors +

+ {tributariesContent} +
+ ) : null}
+

Process Flow

@@ -84,27 +136,13 @@ export const LayeredProcessView: React.FC = ({ />
+

Operations

- {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} -
  • - ); - })} -
+ {operationsBandContent ? ( +
{operationsBandContent}
) : ( -

No factors mapped yet

+ tributariesContent )}
diff --git a/packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessView.test.tsx b/packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessView.test.tsx index 67872e24d..3f4587284 100644 --- a/packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessView.test.tsx +++ b/packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessView.test.tsx @@ -128,6 +128,85 @@ describe('LayeredProcessView', () => { expect(operationsBand).toHaveTextContent('No factors mapped yet'); }); + it('replaces Operations band content when operationsBandContent is provided', () => { + const mapWithFactors: ProcessMap = { + ...emptyMap, + nodes: [ + { id: 'step-1', name: 'Mix', order: 0 }, + { id: 'step-2', name: 'Coat', order: 1 }, + ], + tributaries: [ + { id: 't-1', stepId: 'step-1', column: 'Temperature' }, + { id: 't-2', stepId: 'step-2', column: 'Speed' }, + ], + }; + render( + {}} + operationsBandContent={
CUSTOM
} + /> + ); + expect(screen.getByTestId('custom-ops')).toBeInTheDocument(); + }); + + it('relocates tributary chips to Outcome band as Mapped factors when operationsBandContent is provided', () => { + const mapWithFactors: ProcessMap = { + ...emptyMap, + nodes: [ + { id: 'step-1', name: 'Mix', order: 0 }, + { id: 'step-2', name: 'Coat', order: 1 }, + ], + tributaries: [ + { id: 't-1', stepId: 'step-1', column: 'Temperature' }, + { id: 't-2', stepId: 'step-2', column: 'Speed' }, + ], + }; + const { getByTestId } = render( + {}} + operationsBandContent={
X
} + /> + ); + const outcome = getByTestId('band-outcome'); + expect(outcome.textContent).toMatch(/Mapped factors/i); + expect(outcome.querySelector('[data-testid^="factor-chip-"]')).toBeTruthy(); + }); + + it('renders filterStripContent above the Outcome band when provided', () => { + render( + {}} + filterStripContent={
FILTER
} + /> + ); + expect(screen.getByTestId('filter-strip')).toBeInTheDocument(); + }); + + it('keeps Operations band default content (tributary chips) when slot props are absent', () => { + const mapWithFactors: ProcessMap = { + ...emptyMap, + nodes: [ + { id: 'step-1', name: 'Mix', order: 0 }, + { id: 'step-2', name: 'Coat', order: 1 }, + ], + tributaries: [ + { id: 't-1', stepId: 'step-1', column: 'Temperature' }, + { id: 't-2', stepId: 'step-2', column: 'Speed' }, + ], + }; + const { getByTestId } = render( + {}} /> + ); + const ops = getByTestId('band-operations'); + expect(ops.querySelector('[data-testid^="factor-chip-"]')).toBeTruthy(); + }); + it('renders all three band frames even when the map is fully empty', () => { render( {}} />); From 16872b57ab3770b5bec0c5f83e2132115dbee6e6 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 00:39:46 +0300 Subject: [PATCH 5/7] feat(ui): add LayeredProcessViewWithCapability composition wrapper Mounts ProductionLineGlanceDashboard inside LayeredProcessView's Operations band with a 'Show/Hide temporal trends' affordance for progressive reveal. Filter strip hoisted above the Outcome band. Pure props-based composition; state owned by the consumer. See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md. Co-Authored-By: ruflo --- .../LayeredProcessViewWithCapability.tsx | 72 +++++++++++ .../LayeredProcessViewWithCapability.test.tsx | 122 ++++++++++++++++++ .../components/LayeredProcessView/index.ts | 5 + packages/ui/src/index.ts | 5 + 4 files changed, 204 insertions(+) create mode 100644 packages/ui/src/components/LayeredProcessView/LayeredProcessViewWithCapability.tsx create mode 100644 packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessViewWithCapability.test.tsx diff --git a/packages/ui/src/components/LayeredProcessView/LayeredProcessViewWithCapability.tsx b/packages/ui/src/components/LayeredProcessView/LayeredProcessViewWithCapability.tsx new file mode 100644 index 000000000..d9dcd2d35 --- /dev/null +++ b/packages/ui/src/components/LayeredProcessView/LayeredProcessViewWithCapability.tsx @@ -0,0 +1,72 @@ +/** + * LayeredProcessViewWithCapability — 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. + * + * 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'; + +export type ProductionLineGlanceOpsMode = 'spatial' | 'full'; + +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 const LayeredProcessViewWithCapability: React.FC = ({ + data, + filter, + mode, + onModeChange, + onStepClick, + ...layeredProps +}) => { + const isFull = mode === 'full'; + const affordanceLabel = isFull ? 'Hide temporal trends' : 'Show temporal trends'; + const affordanceArrow = isFull ? '↓' : '↑'; + + return ( + } + operationsBandContent={ +
+ +
+ +
+
+ } + /> + ); +}; + +export default LayeredProcessViewWithCapability; diff --git a/packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessViewWithCapability.test.tsx b/packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessViewWithCapability.test.tsx new file mode 100644 index 000000000..2dd977bdb --- /dev/null +++ b/packages/ui/src/components/LayeredProcessView/__tests__/LayeredProcessViewWithCapability.test.tsx @@ -0,0 +1,122 @@ +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 { render, screen, fireEvent } from '@testing-library/react'; +import { LayeredProcessViewWithCapability } from '../LayeredProcessViewWithCapability'; +import type { ProcessMap } from '@variscout/core/frame'; + +const map: ProcessMap = { + version: 1, + nodes: [], + tributaries: [], + createdAt: '2026-04-28T00:00:00.000Z', + updatedAt: '2026-04-28T00: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('LayeredProcessViewWithCapability', () => { + it('renders the dashboard inside the Operations band slot', () => { + render( + {}} + data={data} + filter={filter} + mode="spatial" + onModeChange={vi.fn()} + /> + ); + expect(screen.getByTestId('mock-capability-boxplot')).toBeInTheDocument(); + expect(screen.getByTestId('mock-step-pareto')).toBeInTheDocument(); + }); + + it('shows "Show temporal trends" affordance when mode=spatial', () => { + render( + {}} + data={data} + filter={filter} + mode="spatial" + onModeChange={vi.fn()} + /> + ); + expect(screen.getByRole('button', { name: /show temporal trends/i })).toBeInTheDocument(); + }); + + it('shows "Hide temporal trends" affordance when mode=full', () => { + render( + {}} + data={data} + filter={filter} + mode="full" + onModeChange={vi.fn()} + /> + ); + expect(screen.getByRole('button', { name: /hide temporal trends/i })).toBeInTheDocument(); + }); + + it('fires onModeChange("full") when toggling from spatial', () => { + const onModeChange = vi.fn(); + render( + {}} + data={data} + filter={filter} + mode="spatial" + onModeChange={onModeChange} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /show temporal trends/i })); + expect(onModeChange).toHaveBeenCalledWith('full'); + }); + + it('renders filter strip above the Outcome band', () => { + render( + {}} + data={data} + filter={{ + ...filter, + availableContext: { hubColumns: ['product'] }, + contextValueOptions: { product: ['A'] }, + }} + mode="spatial" + onModeChange={vi.fn()} + /> + ); + expect(screen.getByText('product')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/LayeredProcessView/index.ts b/packages/ui/src/components/LayeredProcessView/index.ts index c1bfc8f71..643109f3d 100644 --- a/packages/ui/src/components/LayeredProcessView/index.ts +++ b/packages/ui/src/components/LayeredProcessView/index.ts @@ -1,2 +1,7 @@ export { LayeredProcessView } from './LayeredProcessView'; export type { LayeredProcessViewProps } from './LayeredProcessView'; +export { LayeredProcessViewWithCapability } from './LayeredProcessViewWithCapability'; +export type { + LayeredProcessViewWithCapabilityProps, + ProductionLineGlanceOpsMode, +} from './LayeredProcessViewWithCapability'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4f91729d0..2f303fa5a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -594,6 +594,11 @@ export { SubgroupConfigPopover, type SubgroupConfigProps } from './components/Su // FRAME workspace — visual Process Map (ADR-070) export { ProcessMapBase, type ProcessMapBaseProps } from './components/ProcessMap/ProcessMapBase'; export { LayeredProcessView, type LayeredProcessViewProps } from './components/LayeredProcessView'; +export { LayeredProcessViewWithCapability } from './components/LayeredProcessView'; +export type { + LayeredProcessViewWithCapabilityProps, + ProductionLineGlanceOpsMode, +} from './components/LayeredProcessView'; export { ProcessHubCurrentStatePanel, type ProcessHubCurrentStatePanelProps, From 61812d95fdbc0d8b1af5fef8093c313852159bf1 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 00:45:59 +0300 Subject: [PATCH 6/7] feat(azure): wire LayeredProcessViewWithCapability into FrameView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the direct LayeredProcessView mount with the C2 composition wrapper. The dashboard renders inside the Operations band with a progressive-reveal affordance ('Show/Hide temporal trends'). Filter strip is hoisted to the top of the layered view via filterStripContent. URL ?ops state via useProductionLineGlanceOpsToggle. V1 uses an empty preview rollup (no investigation rows) — live-data wiring during authoring lands in C3 (right-hand drawer with full 2x2). See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md. Co-Authored-By: ruflo --- .../azure/src/components/editor/FrameView.tsx | 56 +++++++++++++++++-- .../editor/__tests__/FrameView.test.tsx | 51 +++++++++++++++-- 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/apps/azure/src/components/editor/FrameView.tsx b/apps/azure/src/components/editor/FrameView.tsx index 0d6c01431..e74ff0e71 100644 --- a/apps/azure/src/components/editor/FrameView.tsx +++ b/apps/azure/src/components/editor/FrameView.tsx @@ -1,18 +1,28 @@ /** * FrameView (Azure) — FRAME workspace (ADR-070). * - * Azure-app equivalent of the PWA FrameView. Renders `LayeredProcessView` + * Azure-app equivalent of the PWA FrameView. Renders `LayeredProcessViewWithCapability` * wired to `projectStore.processContext.processMap` with live gap detection from * `@variscout/core/frame`. * * V1 is deterministic-only: no CoScout, no templates. Pre-data hunches * persist as draft SuspectedCause hubs through the projectStore + * investigationStore; the full integration lands in follow-up. + * + * Plan C2: ProductionLineGlanceDashboard is wired into the Operations band + * via a synthetic preview rollup (empty rows — authoring surface has no + * investigation data). Live-data wiring lands in C3 (right-hand drawer). */ import React from 'react'; -import { LayeredProcessView } from '@variscout/ui'; +import { LayeredProcessViewWithCapability } from '@variscout/ui'; +import { + useProductionLineGlanceData, + useProductionLineGlanceFilter, + useProductionLineGlanceOpsToggle, +} from '@variscout/hooks'; import { useProjectStore } from '@variscout/stores'; -import type { ProcessContext } from '@variscout/core'; +import type { ProcessContext, ProcessHub, ProcessHubInvestigation } from '@variscout/core'; +import type { DataRow } from '@variscout/core'; import { createEmptyMap, detectGaps, type ProcessMap } from '@variscout/core/frame'; const FrameView: React.FC = () => { @@ -50,6 +60,35 @@ const FrameView: React.FC = () => { setSpecs({ ...(specs ?? {}), ...next }); }; + // Plan C2: URL-backed filter + ops-mode state. + const filter = useProductionLineGlanceFilter(); + const ops = useProductionLineGlanceOpsToggle(); + + // 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, + }); + return (
@@ -61,7 +100,7 @@ const FrameView: React.FC = () => { least one rational-subgroup axis.

- { lsl={specs?.lsl} usl={specs?.usl} onSpecsChange={handleSpecsChange} + data={data} + filter={{ + availableContext: data.availableContext, + contextValueOptions: data.contextValueOptions, + value: filter.value, + onChange: filter.onChange, + }} + mode={ops.mode} + onModeChange={ops.setMode} />
diff --git a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx index fadf7db47..b5568c8e6 100644 --- a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx +++ b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock MUST come before component imports (per writing-tests skill / testing.md rule) -// vi.mock MUST come before component imports (per writing-tests skill) vi.mock('@variscout/stores', () => { const setProcessContext = vi.fn(); const setSpecs = vi.fn(); @@ -19,15 +20,57 @@ vi.mock('@variscout/stores', () => { }; }); +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: {}, + })), + }; +}); + +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 FrameView from '../FrameView'; -describe('FrameView (Azure)', () => { - it('renders LayeredProcessView with three bands', () => { +describe('FrameView (Plan C2 wiring)', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/test'); + }); + + it('renders LayeredProcessViewWithCapability composition (three bands + ops dashboard)', () => { 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(); }); }); From 68ba20e2affdc8d0ddb8a09f72615b681939d7f9 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 00:50:19 +0300 Subject: [PATCH 7/7] feat(pwa): wire LayeredProcessViewWithCapability into FrameView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan C2 — same composition as the azure FrameView wiring (commit 61812d95). Dashboard's spatial row renders inline in the Operations band; progressive reveal toggles the temporal row above. Filter strip hoisted to top of the layered view. URL ?ops state synced. V1 uses an empty preview rollup; live-data wiring lands in C3. See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md. Co-Authored-By: ruflo --- apps/pwa/src/components/views/FrameView.tsx | 56 +++++++++++++++++-- .../views/__tests__/FrameView.test.tsx | 49 +++++++++++++++- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/apps/pwa/src/components/views/FrameView.tsx b/apps/pwa/src/components/views/FrameView.tsx index 8214de26b..f456f1f30 100644 --- a/apps/pwa/src/components/views/FrameView.tsx +++ b/apps/pwa/src/components/views/FrameView.tsx @@ -1,15 +1,25 @@ /** * FrameView — PWA FRAME workspace (ADR-070). * - * Renders the `LayeredProcessView` component wired to `projectStore.processContext.processMap`, + * Renders `LayeredProcessViewWithCapability` wired to `projectStore.processContext.processMap`, * with live gap detection from `@variscout/core/frame`. The user builds the process map * (SIPOC spine + tributaries + ocean/CTS + hunches). V2+ will add CoScout drafting, * template libraries, and data-seeded skeletons — V1 is deterministic-only. + * + * Plan C2: ProductionLineGlanceDashboard is wired into the Operations band + * via a synthetic preview rollup (empty rows — authoring surface has no + * investigation data). Live-data wiring lands in C3 (right-hand drawer). */ import React from 'react'; -import { LayeredProcessView } from '@variscout/ui'; +import { LayeredProcessViewWithCapability } from '@variscout/ui'; +import { + useProductionLineGlanceData, + useProductionLineGlanceFilter, + useProductionLineGlanceOpsToggle, +} from '@variscout/hooks'; import { useProjectStore } from '@variscout/stores'; -import type { ProcessContext } from '@variscout/core'; +import type { ProcessContext, ProcessHub, ProcessHubInvestigation } from '@variscout/core'; +import type { DataRow } from '@variscout/core'; import { createEmptyMap, detectGaps, type ProcessMap } from '@variscout/core/frame'; const FrameView: React.FC = () => { @@ -47,6 +57,35 @@ const FrameView: React.FC = () => { setSpecs({ ...(specs ?? {}), ...next }); }; + // Plan C2: URL-backed filter + ops-mode state. + const filter = useProductionLineGlanceFilter(); + const ops = useProductionLineGlanceOpsToggle(); + + // 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, + }); + return (
@@ -58,7 +97,7 @@ const FrameView: React.FC = () => { least one rational-subgroup axis.

- { lsl={specs?.lsl} usl={specs?.usl} onSpecsChange={handleSpecsChange} + data={data} + filter={{ + availableContext: data.availableContext, + contextValueOptions: data.contextValueOptions, + value: filter.value, + onChange: filter.onChange, + }} + mode={ops.mode} + onModeChange={ops.setMode} />
diff --git a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx index 0ec35ed45..59b09b9f0 100644 --- a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx +++ b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock MUST come before component imports (per writing-tests skill / testing.md rule) -// vi.mock MUST come before component imports (per writing-tests skill) vi.mock('@variscout/stores', () => { const setProcessContext = vi.fn(); const setSpecs = vi.fn(); @@ -19,15 +20,57 @@ vi.mock('@variscout/stores', () => { }; }); +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: {}, + })), + }; +}); + +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 FrameView from '../FrameView'; describe('FrameView (PWA)', () => { - it('renders LayeredProcessView with three bands', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/test'); + }); + + it('renders LayeredProcessViewWithCapability composition (three bands + ops dashboard)', () => { 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(); }); });