Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
da46cc4
docs: mark layered-process-view-v1 plan delivered
jukka-matti May 3, 2026
0f5044c
docs: consolidate product vision into one canonical spec
jukka-matti May 3, 2026
f12e8b1
docs: lock vision §8 decisions; canvas replaces Frame+Analysis tabs
jukka-matti May 3, 2026
c8a7c76
docs: §8 follow-ups — glossary, ADR amendments, roadmap re-tag
jukka-matti May 3, 2026
7d4ebf1
docs: add framing-layer design spec (Spec 1 of canvas-detail decompos…
jukka-matti May 3, 2026
1ffc5d9
docs: revise Q8 — PWA persistence opt-in; .vrs files = shareable trai…
jukka-matti May 3, 2026
8d41463
plan: Framing Layer V1 Slice 1 — Foundation (16 tasks, TDD bite-sized)
jukka-matti May 3, 2026
8eaa0aa
plan: expand Tasks 6–15 to full TDD step-by-step
jukka-matti May 3, 2026
27e23e4
plan: lock 5 decisions for framing-layer V1 slice 1
jukka-matti May 3, 2026
6ab991e
feat(core): add framing-layer fields to ProcessHub
jukka-matti May 3, 2026
a70050f
feat(core): extractHubName utility
jukka-matti May 3, 2026
cc8b016
fixup! feat(core): extractHubName utility
jukka-matti May 3, 2026
d262a4a
feat(core): goal-context biasing in detectColumns
jukka-matti May 3, 2026
fe46a78
feat(parser): refactor validateData to accept string[] outcomeColumns
jukka-matti May 3, 2026
0ba145c
feat(ui): HubGoalForm component (framing layer Stage 1)
jukka-matti May 3, 2026
012e8d9
feat(ui): OutcomeCandidateRow with inline per-candidate specs
jukka-matti May 3, 2026
cb197dd
feat(core): characteristic-type-aware spec defaults
jukka-matti May 3, 2026
cc3bfe6
feat: PrimaryScopeDimensions auto-suggest + selector UI
jukka-matti May 3, 2026
0b789a5
feat(ui): OutcomeNoMatchBanner graceful degradation
jukka-matti May 3, 2026
3452feb
feat(ui): GoalBanner + OutcomePin canvas first-paint components
jukka-matti May 3, 2026
bc90c08
feat(core): .vrs file format + export/import (round-trip tested)
jukka-matti May 3, 2026
eedc5ae
feat(pwa): Dexie hubRepository for opt-in Hub-of-one persistence
jukka-matti May 3, 2026
97ac794
feat(pwa): Save-to-browser opt-in + .vrs export/import buttons
jukka-matti May 3, 2026
12186df
feat(pwa): Mode A.1 reopen wiring + SessionProvider scaffold
jukka-matti May 3, 2026
255b214
feat(pwa): inject HubGoalForm Stage 1 into Mode B paste flow
jukka-matti May 3, 2026
d49cbfe
feat(azure): mount GoalBanner above Hub tabs + Dexie v7 no-op
jukka-matti May 3, 2026
9c403b4
docs: framing-layer spec status draft -> active
jukka-matti May 3, 2026
40d3996
docs: update slice-1 row with delivery state + slice-2 deferrals
jukka-matti May 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/azure/src/components/ProcessHubView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ResponsePathAction,
} from '@variscout/core';
import {
GoalBanner,
ProductionLineGlanceMigrationBanner,
ProductionLineGlanceMigrationModal,
} from '@variscout/ui';
Expand Down Expand Up @@ -71,6 +72,18 @@ export const ProcessHubView: React.FC<ProcessHubViewProps> = ({

return (
<div className="flex h-full flex-col">
{/*
Mode A.1 reopen: surface the saved process goal immediately on Hub
load. Azure Standard tier persists Hub-level state via Dexie always
(no opt-in needed). GoalBanner self-renders nothing when goal is
empty, so unbiased Hubs (pre-Framing-Layer Hubs without processGoal)
keep the existing layout untouched.

TODO(slice-2): wire `onChange` once HubCreationFlow + Hub-update
mutation hook lands. Read-only for slice 1 because ProcessHubView
does not currently receive a Hub-update callback in its props.
*/}
<GoalBanner goal={rollup.hub.processGoal} />
<ProductionLineGlanceMigrationBanner
count={migration.count}
onMapClick={migration.openModal}
Expand Down
20 changes: 20 additions & 0 deletions apps/azure/src/components/__tests__/ProcessHubView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,24 @@ describe('ProcessHubView', () => {
fireEvent.click(screen.getByRole('tab', { name: /capability/i }));
expect(screen.getByTestId('process-hub-capability-tab-panel')).toBeInTheDocument();
});

it('renders the GoalBanner above the tab container when hub.processGoal is set', () => {
const goalHub: ProcessHub = {
id: 'h2',
name: 'Line B',
processGoal: 'We mold barrels for medical customers.',
} as ProcessHub;
const goalRollup = {
hub: goalHub,
investigations: [],
evidenceSnapshots: [],
} as unknown as ProcessHubRollup<ProcessHubInvestigation>;
render(<ProcessHubView {...baseProps} rollup={goalRollup} />);
expect(screen.getByTestId('goal-banner')).toBeInTheDocument();
});

it('does not render the GoalBanner when hub.processGoal is absent', () => {
render(<ProcessHubView {...baseProps} />);
expect(screen.queryByTestId('goal-banner')).not.toBeInTheDocument();
});
});
8 changes: 8 additions & 0 deletions apps/azure/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ export class VariScoutDatabase extends Dexie {
sustainmentReviews: 'id, recordId, investigationId, hubId, reviewedAt',
controlHandoffs: 'id, investigationId, hubId, handoffDate',
});

// Version 7: Framing Layer V1 Slice 1 — no-op schema bump.
// Task 1 added optional ProcessHub fields (processGoal, outcomes,
// primaryScopeDimensions). These are TypeScript-only additions; Dexie
// stores them transparently because `processHubs` uses `id` as the only
// declared index. The empty-stores object signals "no schema change" and
// flushes any cached schema for the bumped version.
this.version(7).stores({});
}
}

Expand Down
6 changes: 3 additions & 3 deletions apps/azure/src/features/data-flow/useEditorDataFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ export function useEditorDataFlow(options: UseEditorDataFlowOptions): UseEditorD
if (detected.outcome) setOutcome(detected.outcome);
if (detected.factors.length > 0) setFactors(detected.factors);

const report = validateData(data, detected.outcome);
const report = validateData(data, detected.outcome ? [detected.outcome] : []);
setDataQualityReport(report);

// Check for Yamazumi format (more specific than wide format)
Expand Down Expand Up @@ -496,15 +496,15 @@ export function useEditorDataFlow(options: UseEditorDataFlowOptions): UseEditorD
const merged = mergeRows(rawData, incoming);
reapplyTimeColumns(merged, factors);
setRawData(merged);
const report = validateData(merged, outcome!);
const report = validateData(merged, outcome ? [outcome] : []);
setDataQualityReport(report);
const feedback = `Appended ${incoming.length} rows (${merged.length} total)`;
dispatch({ type: 'APPEND_ROWS_DONE', feedback });
showFeedback(feedback);
} else {
const { data: merged, addedColumns } = mergeColumns(rawData, incoming);
setRawData(merged);
const report = validateData(merged, outcome!);
const report = validateData(merged, outcome ? [outcome] : []);
setDataQualityReport(report);
const feedback = `Added ${addedColumns.length} column${addedColumns.length !== 1 ? 's' : ''} (${addedColumns.join(', ')})`;
dispatch({ type: 'APPEND_COLUMNS_DONE', feedback });
Expand Down
2 changes: 1 addition & 1 deletion apps/azure/src/hooks/__tests__/useEditorDataFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ describe('useEditorDataFlow', () => {
expect(options.setDataFilename).toHaveBeenCalledWith('Pasted Data');
expect(options.setOutcome).toHaveBeenCalledWith('Weight');
expect(options.setFactors).toHaveBeenCalledWith(['Operator']);
expect(mockValidateData).toHaveBeenCalledWith(parsedData, 'Weight');
expect(mockValidateData).toHaveBeenCalledWith(parsedData, ['Weight']);
expect(options.setDataQualityReport).toHaveBeenCalled();
expect(result.current.isPasteMode).toBe(false);
expect(result.current.isMapping).toBe(true);
Expand Down
2 changes: 1 addition & 1 deletion apps/azure/src/hooks/useDataMerge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export function useDataMerge({
setSpecs(finalConfig.specs);
}

const report = validateData(finalData, finalConfig.outcome);
const report = validateData(finalData, finalConfig.outcome ? [finalConfig.outcome] : []);
setDataQualityReport(report);

if (
Expand Down
5 changes: 3 additions & 2 deletions apps/pwa/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# @variscout/pwa

Free PWA. Session-only (no persistence), Context-based state, education tier.
Free PWA. Session-only by default; opt-in local persistence; education + training tier.

## Hard rules

- No persistence. No IndexedDB, no localStorage, no cloud sync. Session ends, data is gone. This is the product principle.
- **Session-only by default.** Opt-in IndexedDB persistence allowed only via explicit user action ("Save to this browser" → single Hub-of-one) AND/OR `.vrs` file export/import. `.vrs` files double as **shareable training scenarios** — trainers package datasets + Hub state; students import. No cloud sync (Azure-only). Per Q8-revised in `docs/superpowers/specs/2026-05-03-framing-layer-design.md` and `docs/decision-log.md` "Q8 revised" entry.
- **No AI in free tier** (Constitution P8). CoScout is Azure-only.
- Tailwind v4 requires `@source` directives in `src/index.css` for shared packages (`@source "../../../packages/ui/src/**/*.tsx"`, etc).
- Free tier only — branding is shown in chart footers (`isPaidTier()` from `@variscout/core/tier` returns false).

Expand Down
37 changes: 37 additions & 0 deletions apps/pwa/e2e/modeB.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// apps/pwa/e2e/modeB.e2e.spec.ts
//
// Framing layer Mode B (PWA): paste → goal narrative → outcome confirm → canvas first paint.
//
// NOTE: This test is currently skipped pending full integration of the framing-layer
// flow (HubGoalForm injection between paste and column-mapping, plus canvas
// GoalBanner/OutcomePin first-paint composition). Slice 1 wires SessionProvider +
// Mode A.1 reopen end-to-end; the multi-stage paste→goal→mapping→canvas Mode B flow
// requires the column-mapping refactor that is out of scope for slice 1.
//
// Re-enable in the slice that delivers Stage 1/3 routing inside App.tsx.
import { test, expect } from '@playwright/test';

test.describe('Framing layer Mode B (PWA)', () => {
test.skip('paste → goal narrative → outcome confirm → canvas first paint', async ({ page }) => {
await page.goto('/');
await page.click('text=Paste from Excel');
await page
.getByRole('textbox', { name: /paste data/i })
.fill('weight_g,oven_temp\n4.5,178\n4.4,180\n4.6,180\n4.5,179\n4.4,178');
await page.click('text=Parse');

// Stage 1: goal narrative
await page
.getByRole('textbox', { name: /process goal/i })
.fill('We mold barrels for medical customers.');
await page.click('text=Continue');

// Stage 3: outcome auto-selected via goal context
await expect(page.getByRole('radio', { name: /weight_g/i })).toBeChecked();
await page.click('text=Confirm');

// Stage 4: canvas first paint
await expect(page.getByTestId('goal-banner')).toContainText('We mold barrels');
await expect(page.getByTestId('outcome-pin')).toContainText('weight_g');
});
});
1 change: 1 addition & 0 deletions apps/pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@visx/responsive": "^3.12.0",
"comlink": "^4.4.2",
"d3-array": "^3.2.4",
"dexie": "^4.4.2",
"html-to-image": "^1.11.13",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
Expand Down
85 changes: 82 additions & 3 deletions apps/pwa/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import {
QuestionsTabView,
JournalTabView,
QuestionLinkPrompt,
GoalBanner,
HubGoalForm,
} from '@variscout/ui';
import { SessionProvider, useSession } from './store/sessionStore';
import { hubRepository } from './db/hubRepository';
import { Beaker, Settings, Download, Table2, RotateCcw, FileText } from 'lucide-react';
import {
useFindings,
Expand Down Expand Up @@ -46,7 +50,12 @@ import AppFooter from './components/layout/AppFooter';
import { useDataIngestion } from './hooks/useDataIngestion';
import { useEmbedMessaging } from './hooks/useEmbedMessaging';
import { SAMPLES } from '@variscout/data';
import { type ExclusionReason, type Question, toNumericValue } from '@variscout/core';
import {
type ExclusionReason,
type Question,
toNumericValue,
extractHubName,
} from '@variscout/core';
import { resolveMode, getStrategy } from '@variscout/core/strategy';
import { resolveCpkTarget } from '@variscout/core/capability';
import { computeCenteringOpportunity } from '@variscout/core/variation';
Expand Down Expand Up @@ -127,10 +136,31 @@ function App() {
return <EvidenceMapPopout />;
}

return <AppMain />;
return (
<SessionProvider>
<AppMain />
</SessionProvider>
);
}

function AppMain() {
// ── Session (current Hub + opt-in persistence hydration) ───────────────
// Mode A.1 (D5): on mount, check the persistence opt-in flag. If set, load
// the saved Hub-of-one from IndexedDB and seed the session. Otherwise the
// app stays session-only (default PWA invariant).
const { hub: sessionHub, setHub: setSessionHub, goalNarrative, setGoalNarrative } = useSession();
useEffect(() => {
let cancelled = false;
void hubRepository.getOptInFlag().then(async opted => {
if (!opted || cancelled) return;
const loaded = await hubRepository.loadHub();
if (loaded && !cancelled) setSessionHub(loaded);
});
return () => {
cancelled = true;
};
}, [setSessionHub]);

// ── Zustand store selectors (replaces useDataStateCtx) ──────────────────
const rawData = useProjectStore(s => s.rawData);
const outcome = useProjectStore(s => s.outcome);
Expand Down Expand Up @@ -321,6 +351,8 @@ function AppMain() {
if (rawData.length === 0) {
setMobileActiveTab('analysis');
panels.showAnalysis();
// Mode B: reset Stage 1 narrative gate so the next paste flow re-asks.
setGoalNarrative(null);
}
}, [rawData.length]); // eslint-disable-line react-hooks/exhaustive-deps

Expand Down Expand Up @@ -598,6 +630,38 @@ function AppMain() {
setQuestionLinkPromptOpen(false);
}, []);

// Mode B: when ColumnMapping confirms, fold the Stage 1 narrative into the
// session Hub so the GoalBanner picks it up immediately. Slice 1 keeps the
// Hub minimal (id + name + processGoal + createdAt); the slice-2 refactor
// will populate `outcomes` / `primaryScopeDimensions` from the new Stage 3
// mapping rows. We preserve any pre-existing sessionHub fields (e.g. when
// restored from opt-in persistence — Mode A.1) by spreading first.
const handleMappingConfirmWithGoal = useCallback(
(
newOutcome: string,
newFactors: string[],
newSpecs?: { target?: number; lsl?: number; usl?: number }
) => {
importFlow.handleMappingConfirm(newOutcome, newFactors, newSpecs);
if (goalNarrative && goalNarrative.trim()) {
const base = sessionHub ?? {
id: crypto.randomUUID(),
name: '',
createdAt: new Date().toISOString(),
};
setSessionHub({
...base,
name: extractHubName(goalNarrative) || base.name || 'Untitled hub',
processGoal: goalNarrative,
updatedAt: new Date().toISOString(),
});
}
// TODO(slice-2): wire outcomes[] + primaryScopeDimensions into Hub
// construction once Stage 3 ColumnMapping refactor lands.
},
[importFlow, goalNarrative, sessionHub, setSessionHub]
);

// Phase tab navigation handler (used by AppHeader inline tabs)
const handlePhaseChange = useCallback(
(phase: PhaseId) => {
Expand Down Expand Up @@ -755,6 +819,10 @@ function AppMain() {
</div>
)}

{/* Goal banner — surfaces the Hub processGoal when restored from
opt-in persistence (Mode A.1) or set via the framing layer flow. */}
{sessionHub?.processGoal ? <GoalBanner goal={sessionHub.processGoal} /> : null}

{/* Main Content */}
<main id="main-content" className="flex-1 overflow-hidden relative flex">
{/* Stats Sidebar (left) */}
Expand Down Expand Up @@ -803,6 +871,17 @@ function AppMain() {
onOpenPaste={importFlow.handleOpenPaste}
onOpenManualEntry={importFlow.handleOpenManualEntry}
/>
) : importFlow.isMapping && goalNarrative === null ? (
// Mode B Stage 1: ask for the process goal narrative before
// showing ColumnMapping. The sentinel pattern (null = unasked,
// '' = skipped, string = provided) lets us gate exactly once
// per import. ColumnMapping internals are unchanged in slice 1.
<div className="max-w-2xl mx-auto p-6 w-full">
<HubGoalForm
onConfirm={narrative => setGoalNarrative(narrative)}
onSkip={() => setGoalNarrative('')}
/>
</div>
) : importFlow.isMapping ? (
<ColumnMapping
columnAnalysis={importFlow.mappingColumnAnalysis}
Expand All @@ -814,7 +893,7 @@ function AppMain() {
initialOutcome={outcome}
initialFactors={factors}
datasetName={dataFilename || undefined}
onConfirm={importFlow.handleMappingConfirm}
onConfirm={handleMappingConfirmWithGoal}
onCancel={importFlow.handleMappingCancel}
dataQualityReport={dataQualityReport}
onViewExcludedRows={panels.openDataTableExcluded}
Expand Down
Loading