Kick off Phase 8 with React scaffold and deterministic store foundation
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-19 19:18:27 -05:00
parent 85dc9a1ac2
commit d029e480d0
28 changed files with 1423 additions and 0 deletions

View File

@ -33,6 +33,9 @@ jobs:
- name: Run tests - name: Run tests
run: npm test run: npm test
- name: React path quality stubs
run: npm run frontend:react:check
- name: Install Playwright browsers - name: Install Playwright browsers
run: npx playwright install chromium run: npx playwright install chromium

View File

@ -18,6 +18,17 @@ This version includes:
npm run start npm run start
``` ```
Frontend migration command paths:
- Legacy: `npm run frontend:legacy:dev` or `npm run frontend:legacy:start`
- React (`frontend-react/`): `npm run frontend:react:dev`
- React checks: `npm run frontend:react:check`
React app bootstrap:
```bash
npm --prefix frontend-react install
npm run frontend:react:dev
```
Open: Open:
- Workspace: `http://localhost:8787/` - Workspace: `http://localhost:8787/`
- Health: `http://localhost:8787/health` - Health: `http://localhost:8787/health`
@ -41,6 +52,7 @@ Docs:
- `docs/quality-gates.md` - `docs/quality-gates.md`
- `docs/phase4-execution-plan.md` - `docs/phase4-execution-plan.md`
- `docs/phase5-execution-plan.md` - `docs/phase5-execution-plan.md`
- `docs/phase8-dual-run-kickoff.md`
- `docs/fixtures.md` - `docs/fixtures.md`
- `docs/api-mcp-contracts.md` - `docs/api-mcp-contracts.md`

View File

@ -0,0 +1,35 @@
# Phase 8 Kickoff: Dual-Run Frontend (Legacy + React)
## Purpose
Define how to run the current legacy frontend and the incoming React frontend side-by-side during migration kickoff.
## Frontend Paths
- Legacy frontend: served by `src/server.js` at `http://localhost:8787/`
- React frontend (target): expected in `frontend-react/` (Vite default expected at `http://localhost:5173/`)
## Commands
- Legacy dev server: `npm run frontend:legacy:dev`
- Legacy start (non-watch): `npm run frontend:legacy:start`
- React dev (stub-aware): `npm run frontend:react:dev`
- React quality stubs:
- `npm run frontend:react:lint`
- `npm run frontend:react:test`
- `npm run frontend:react:build`
- `npm run frontend:react:check`
If `frontend-react/package.json` does not exist, lacks the requested script, or `frontend-react/node_modules` is missing, React commands exit successfully with a deterministic skip message.
## Side-by-Side Local Run
1. Terminal A: `npm run frontend:legacy:dev`
2. Terminal B: `npm run frontend:react:dev`
3. Verify legacy UI responds on `http://localhost:8787/`
4. Verify React command behavior:
- pre-scaffold: skip message (expected)
- post-scaffold: starts actual React dev server
## Milestone Kickoff Checklist (`#37`, `#38`)
- [x] `#37` React migration kickoff commands are exposed in root `package.json`.
- [x] `#37` Dual-run operational doc exists for legacy + React side-by-side local execution.
- [x] `#37` CI includes a React-path quality command (`frontend:react:check`) that is safe before scaffold and active after scaffold.
- [x] `#38` Deterministic command wiring established for React quality gates (`lint/test/build`) via a single stub-aware runner.
- [x] `#38` Legacy and React paths are explicitly separated in command names to reduce migration ambiguity.

3
frontend-react/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
*.tsbuildinfo

22
frontend-react/README.md Normal file
View File

@ -0,0 +1,22 @@
# Schemeta React Frontend (Track A)
Bootstrap React + TypeScript + Vite shell for Schemeta.
## Scripts
- `npm run dev` - start Vite dev server
- `npm run build` - type-check and build production bundle
- `npm run preview` - preview built bundle
- `npm run lint` - TypeScript no-emit check
- `npm run test` - placeholder test command
## API endpoints wired
The app client posts to the existing backend routes without contract changes:
- `POST /compile`
- `POST /analyze`
- `POST /layout/auto`
- `POST /layout/tidy`
Vite dev server proxies these routes to `http://localhost:8787`.

12
frontend-react/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Schemeta React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,24 @@
{
"name": "schemeta-frontend-react",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc --noEmit -p tsconfig.app.json",
"test": "node -e \"console.log('frontend-react: no tests yet')\""
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.0"
}
}

View File

@ -0,0 +1,88 @@
import { useMemo, useState } from "react";
import { analyze, autoLayout, compile, tidyLayout } from "./api/client";
import { CanvasArea } from "./components/CanvasArea";
import { LeftPanel } from "./components/LeftPanel";
import { RightInspector } from "./components/RightInspector";
import { TopBar } from "./components/TopBar";
const SAMPLE_PAYLOAD = {
symbols: {
r_std: { template_name: "resistor" }
},
instances: [
{
ref: "R1",
symbol: "r_std",
properties: { value: "10k" },
placement: { x: null, y: null, rotation: 0, locked: false }
}
],
nets: []
};
export function App() {
const [status, setStatus] = useState("Idle");
const [lastApiVersion, setLastApiVersion] = useState("-");
const actions = useMemo(
() => [
{
id: "compile",
label: "Compile",
run: async () => compile(SAMPLE_PAYLOAD)
},
{
id: "analyze",
label: "Analyze",
run: async () => analyze(SAMPLE_PAYLOAD)
},
{
id: "layout-auto",
label: "Auto Layout",
run: async () => autoLayout(SAMPLE_PAYLOAD)
},
{
id: "layout-tidy",
label: "Auto Tidy",
run: async () => tidyLayout(SAMPLE_PAYLOAD)
}
],
[]
);
async function runAction(actionId: string) {
const action = actions.find((item) => item.id === actionId);
if (!action) {
return;
}
setStatus(`${action.label}...`);
try {
const result = await action.run();
setLastApiVersion(result.api_version ?? "-");
setStatus(`${action.label} complete`);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setStatus(`${action.label} failed: ${message}`);
}
}
return (
<div className="app-shell">
<TopBar title="React Frontend Skeleton" />
<div className="toolbar" role="toolbar" aria-label="API actions">
{actions.map((action) => (
<button key={action.id} type="button" onClick={() => runAction(action.id)}>
{action.label}
</button>
))}
<div className="toolbar__meta">Status: {status} | API: {lastApiVersion}</div>
</div>
<div className="workspace-grid">
<LeftPanel />
<CanvasArea />
<RightInspector />
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
import type {
AnalyzeResponse,
CompileResponse,
LayoutActionResponse,
SchemetaRequest
} from "./types";
async function postJson<TResponse>(path: string, body: unknown): Promise<TResponse> {
const response = await fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
});
const data = (await response.json()) as TResponse & {
error?: { message?: string };
message?: string;
};
if (!response.ok) {
const message = data?.error?.message ?? data?.message ?? `Request failed with status ${response.status}`;
throw new Error(message);
}
return data;
}
export function compile(payload: unknown, options: Record<string, unknown> = {}): Promise<CompileResponse> {
const body: SchemetaRequest = { payload, options };
return postJson<CompileResponse>("/compile", body);
}
export function analyze(payload: unknown, options: Record<string, unknown> = {}): Promise<AnalyzeResponse> {
const body: SchemetaRequest = { payload, options };
return postJson<AnalyzeResponse>("/analyze", body);
}
export function autoLayout(payload: unknown, options: Record<string, unknown> = {}): Promise<LayoutActionResponse> {
const body: SchemetaRequest = { payload, options };
return postJson<LayoutActionResponse>("/layout/auto", body);
}
export function tidyLayout(payload: unknown, options: Record<string, unknown> = {}): Promise<LayoutActionResponse> {
const body: SchemetaRequest = { payload, options };
return postJson<LayoutActionResponse>("/layout/tidy", body);
}

View File

@ -0,0 +1,38 @@
export type SchemetaRequest<TPayload = unknown, TOptions = Record<string, unknown>> = {
payload: TPayload;
options?: TOptions;
};
export type SchemetaEnvelope = {
api_version?: string;
schema_version?: string;
request_id?: string;
};
export type CompileResponse = SchemetaEnvelope & {
ok?: boolean;
errors?: unknown[];
warnings?: unknown[];
svg?: string;
layout?: {
width?: number;
height?: number;
placed?: Array<Record<string, unknown>>;
};
layout_metrics?: Record<string, number>;
topology?: Record<string, unknown>;
focus_map?: Record<string, unknown>;
};
export type AnalyzeResponse = SchemetaEnvelope & {
ok?: boolean;
errors?: unknown[];
warnings?: unknown[];
topology?: Record<string, unknown>;
};
export type LayoutActionResponse = SchemetaEnvelope & {
ok?: boolean;
model?: Record<string, unknown>;
compile?: CompileResponse;
};

View File

@ -0,0 +1,7 @@
export function CanvasArea() {
return (
<main className="canvas" aria-label="Canvas area">
<div className="canvas__placeholder">Canvas Area Placeholder</div>
</main>
);
}

View File

@ -0,0 +1,8 @@
export function LeftPanel() {
return (
<aside className="panel panel--left" aria-label="Left panel">
<h2 className="panel__title">Left Panel</h2>
<p className="panel__body">Component tree and graph controls will live here.</p>
</aside>
);
}

View File

@ -0,0 +1,8 @@
export function RightInspector() {
return (
<aside className="panel panel--right" aria-label="Right inspector">
<h2 className="panel__title">Right Inspector</h2>
<p className="panel__body">Selection details and properties will live here.</p>
</aside>
);
}

View File

@ -0,0 +1,12 @@
type TopBarProps = {
title: string;
};
export function TopBar({ title }: TopBarProps) {
return (
<header className="topbar" role="banner">
<div className="topbar__brand">Schemeta</div>
<div className="topbar__title">{title}</div>
</header>
);
}

View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

1
frontend-react/src/state/index.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export * from "./store.js";

View File

@ -0,0 +1 @@
export { createInitialState, createSchemetaStore } from "./store.js";

102
frontend-react/src/state/store.d.ts vendored Normal file
View File

@ -0,0 +1,102 @@
export type SchemetaPlacement = {
x: number | null;
y: number | null;
rotation?: number;
locked?: boolean;
};
export type SchemetaInstance = {
ref: string;
symbol?: string;
part?: string;
properties?: Record<string, unknown>;
placement: SchemetaPlacement;
};
export type SchemetaModel = {
meta?: Record<string, unknown>;
symbols?: Record<string, unknown>;
instances: SchemetaInstance[];
nets?: Array<Record<string, unknown>>;
constraints?: Record<string, unknown>;
annotations?: Array<Record<string, unknown>>;
};
export type CompileResult = {
ok: boolean;
errors: Array<Record<string, unknown>>;
warnings: Array<Record<string, unknown>>;
layout?: Record<string, unknown>;
layout_metrics?: Record<string, unknown>;
render_mode_used?: string;
svg?: string;
};
export type SelectionSlice = {
selectedRefs: string[];
selectedNet: string | null;
selectedPin: { ref: string; pin: string } | null;
};
export type ViewportSlice = {
scale: number;
panX: number;
panY: number;
};
export type UiFlagsSlice = {
showLabels: boolean;
isolateNet: boolean;
isolateComponent: boolean;
renderMode: string;
jsonError: string | null;
};
export type StateSnapshot = {
model: SchemetaModel | null;
compileResult: CompileResult | null;
selection: SelectionSlice;
viewport: ViewportSlice;
uiFlags: UiFlagsSlice;
};
export type StoreState = {
model: SchemetaModel | null;
compileResult: CompileResult | null;
selection: SelectionSlice;
viewport: ViewportSlice;
uiFlags: UiFlagsSlice;
history: {
past: Array<{ label: string; snapshot: StateSnapshot }>;
future: Array<{ label: string; snapshot: StateSnapshot }>;
limit: number;
};
transaction: {
active: boolean;
label: string | null;
before: StateSnapshot | null;
dirty: boolean;
};
};
export type SchemetaStore = {
getState(): StoreState;
subscribe(listener: (next: StoreState, prev: StoreState, action: string) => void): () => void;
actions: {
setModel(model: unknown): void;
setCompileResult(compileResult: unknown): void;
setSelection(selection: Partial<SelectionSlice>): void;
moveComponent(ref: string, placement: Partial<SchemetaPlacement>): void;
setViewport(viewport: Partial<ViewportSlice>): void;
setUiFlags(uiFlags: Partial<UiFlagsSlice>): void;
applyJsonText(jsonText: string): { ok: true } | { ok: false; error: string };
beginTransaction(label?: string): boolean;
commitTransaction(): boolean;
rollbackTransaction(): boolean;
undo(): void;
redo(): void;
};
};
export function createInitialState(overrides?: Partial<StoreState>): StoreState;
export function createSchemetaStore(initialState?: Partial<StoreState>): SchemetaStore;

View File

@ -0,0 +1,629 @@
const DEFAULT_HISTORY_LIMIT = 80;
/**
* @typedef {Object} SchemetaPlacement
* @property {number | null} x
* @property {number | null} y
* @property {number} [rotation]
* @property {boolean} [locked]
*/
/**
* @typedef {Object} SchemetaInstance
* @property {string} ref
* @property {string} [symbol]
* @property {string} [part]
* @property {Record<string, unknown>} [properties]
* @property {SchemetaPlacement} placement
*/
/**
* @typedef {Object} SchemetaModel
* @property {Record<string, unknown>} [meta]
* @property {Record<string, unknown>} [symbols]
* @property {SchemetaInstance[]} instances
* @property {Array<Record<string, unknown>>} [nets]
* @property {Record<string, unknown>} [constraints]
* @property {Array<Record<string, unknown>>} [annotations]
*/
/**
* @typedef {Object} CompileResult
* @property {boolean} ok
* @property {Array<Record<string, unknown>>} errors
* @property {Array<Record<string, unknown>>} warnings
* @property {Record<string, unknown>} [layout]
* @property {Record<string, unknown>} [layout_metrics]
* @property {string} [render_mode_used]
* @property {string} [svg]
*/
/**
* @typedef {{ ref: string, pin: string } | null} SelectedPin
*/
/**
* @typedef {Object} SelectionSlice
* @property {string[]} selectedRefs
* @property {string | null} selectedNet
* @property {SelectedPin} selectedPin
*/
/**
* @typedef {Object} ViewportSlice
* @property {number} scale
* @property {number} panX
* @property {number} panY
*/
/**
* @typedef {Object} UiFlagsSlice
* @property {boolean} showLabels
* @property {boolean} isolateNet
* @property {boolean} isolateComponent
* @property {string} renderMode
* @property {string | null} jsonError
*/
/**
* @typedef {Object} StateSnapshot
* @property {SchemetaModel | null} model
* @property {CompileResult | null} compileResult
* @property {SelectionSlice} selection
* @property {ViewportSlice} viewport
* @property {UiFlagsSlice} uiFlags
*/
/**
* @typedef {Object} HistoryEntry
* @property {string} label
* @property {StateSnapshot} snapshot
*/
/**
* @typedef {Object} StoreState
* @property {SchemetaModel | null} model
* @property {CompileResult | null} compileResult
* @property {SelectionSlice} selection
* @property {ViewportSlice} viewport
* @property {UiFlagsSlice} uiFlags
* @property {{ past: HistoryEntry[], future: HistoryEntry[], limit: number }} history
* @property {{ active: boolean, label: string | null, before: StateSnapshot | null, dirty: boolean }} transaction
*/
/**
* @param {unknown} value
* @returns {any}
*/
function deepClone(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value));
}
/**
* @param {SelectionSlice | Partial<SelectionSlice>} selection
* @returns {SelectionSlice}
*/
function normalizeSelection(selection) {
const selectedRefs = [...new Set((selection.selectedRefs ?? []).filter(Boolean))].sort((a, b) => a.localeCompare(b));
const selectedNet = typeof selection.selectedNet === "string" && selection.selectedNet.length > 0 ? selection.selectedNet : null;
const selectedPin =
selection.selectedPin && typeof selection.selectedPin.ref === "string" && typeof selection.selectedPin.pin === "string"
? { ref: selection.selectedPin.ref, pin: selection.selectedPin.pin }
: null;
return {
selectedRefs,
selectedNet,
selectedPin
};
}
/**
* @param {Partial<ViewportSlice>} viewport
* @param {ViewportSlice} prev
* @returns {ViewportSlice}
*/
function normalizeViewport(viewport, prev) {
const nextScale = Number.isFinite(viewport.scale) ? Number(viewport.scale) : prev.scale;
const nextPanX = Number.isFinite(viewport.panX) ? Number(viewport.panX) : prev.panX;
const nextPanY = Number.isFinite(viewport.panY) ? Number(viewport.panY) : prev.panY;
return {
scale: nextScale,
panX: nextPanX,
panY: nextPanY
};
}
/**
* @param {Partial<UiFlagsSlice>} uiFlags
* @param {UiFlagsSlice} prev
* @returns {UiFlagsSlice}
*/
function normalizeUiFlags(uiFlags, prev) {
return {
showLabels: typeof uiFlags.showLabels === "boolean" ? uiFlags.showLabels : prev.showLabels,
isolateNet: typeof uiFlags.isolateNet === "boolean" ? uiFlags.isolateNet : prev.isolateNet,
isolateComponent: typeof uiFlags.isolateComponent === "boolean" ? uiFlags.isolateComponent : prev.isolateComponent,
renderMode: typeof uiFlags.renderMode === "string" && uiFlags.renderMode.length > 0 ? uiFlags.renderMode : prev.renderMode,
jsonError:
uiFlags.jsonError === null || (typeof uiFlags.jsonError === "string" && uiFlags.jsonError.length > 0)
? uiFlags.jsonError
: prev.jsonError
};
}
/**
* @param {StoreState} state
* @returns {StateSnapshot}
*/
function snapshotState(state) {
return {
model: deepClone(state.model),
compileResult: deepClone(state.compileResult),
selection: deepClone(state.selection),
viewport: deepClone(state.viewport),
uiFlags: deepClone(state.uiFlags)
};
}
/**
* @param {StoreState} state
* @param {StateSnapshot} snapshot
* @returns {StoreState}
*/
function restoreSnapshot(state, snapshot) {
return {
...state,
model: deepClone(snapshot.model),
compileResult: deepClone(snapshot.compileResult),
selection: deepClone(snapshot.selection),
viewport: deepClone(snapshot.viewport),
uiFlags: deepClone(snapshot.uiFlags)
};
}
/**
* @param {StoreState} state
* @param {string} label
* @returns {StoreState}
*/
function pushHistoryEntry(state, label) {
const nextPast = [...state.history.past, { label, snapshot: snapshotState(state) }];
const overflow = Math.max(0, nextPast.length - state.history.limit);
const past = overflow > 0 ? nextPast.slice(overflow) : nextPast;
return {
...state,
history: {
...state.history,
past,
future: []
}
};
}
/**
* @param {StoreState} current
* @param {StoreState} candidate
* @returns {boolean}
*/
function hasCoreChange(current, candidate) {
return (
current.model !== candidate.model ||
current.compileResult !== candidate.compileResult ||
current.selection !== candidate.selection ||
current.viewport !== candidate.viewport ||
current.uiFlags !== candidate.uiFlags
);
}
/**
* @param {StoreState} state
* @returns {StoreState}
*/
function undoState(state) {
if (state.transaction.active || state.history.past.length === 0) {
return state;
}
const prev = state.history.past[state.history.past.length - 1];
const currentSnapshot = snapshotState(state);
const restored = restoreSnapshot(state, prev.snapshot);
return {
...restored,
history: {
...state.history,
past: state.history.past.slice(0, -1),
future: [...state.history.future, { label: prev.label, snapshot: currentSnapshot }]
}
};
}
/**
* @param {StoreState} state
* @returns {StoreState}
*/
function redoState(state) {
if (state.transaction.active || state.history.future.length === 0) {
return state;
}
const next = state.history.future[state.history.future.length - 1];
const currentSnapshot = snapshotState(state);
const restored = restoreSnapshot(state, next.snapshot);
return {
...restored,
history: {
...state.history,
past: [...state.history.past, { label: next.label, snapshot: currentSnapshot }],
future: state.history.future.slice(0, -1)
}
};
}
/**
* @param {StoreState} state
* @param {string} label
* @param {(state: StoreState) => StoreState} recipe
* @returns {StoreState}
*/
function applyMutation(state, label, recipe) {
const candidate = recipe(state);
if (!hasCoreChange(state, candidate)) {
return state;
}
if (state.transaction.active) {
return {
...candidate,
transaction: {
...state.transaction,
dirty: true
}
};
}
const withHistory = pushHistoryEntry(state, label);
return {
...candidate,
history: withHistory.history,
transaction: state.transaction
};
}
/**
* @param {StoreState} state
* @param {unknown} model
* @returns {StoreState}
*/
function setModelState(state, model) {
return {
...state,
model: model == null ? null : deepClone(model)
};
}
/**
* @param {StoreState} state
* @param {unknown} compileResult
* @returns {StoreState}
*/
function setCompileResultState(state, compileResult) {
return {
...state,
compileResult: compileResult == null ? null : deepClone(compileResult)
};
}
/**
* @param {StoreState} state
* @param {Partial<SelectionSlice>} selection
* @returns {StoreState}
*/
function setSelectionState(state, selection) {
const nextSelection = normalizeSelection({
selectedRefs: selection.selectedRefs ?? state.selection.selectedRefs,
selectedNet: selection.selectedNet ?? state.selection.selectedNet,
selectedPin: selection.selectedPin ?? state.selection.selectedPin
});
return {
...state,
selection: nextSelection
};
}
/**
* @param {StoreState} state
* @param {string} ref
* @param {Partial<SchemetaPlacement>} placement
* @returns {StoreState}
*/
function moveComponentState(state, ref, placement) {
if (!state.model || !Array.isArray(state.model.instances)) {
return state;
}
let touched = false;
const instances = state.model.instances.map((instance) => {
if (instance.ref !== ref) {
return instance;
}
const currentPlacement = instance.placement ?? { x: null, y: null, rotation: 0, locked: false };
const nextPlacement = {
...currentPlacement,
x: Number.isFinite(placement.x) ? Math.round(Number(placement.x)) : currentPlacement.x,
y: Number.isFinite(placement.y) ? Math.round(Number(placement.y)) : currentPlacement.y,
rotation: Number.isFinite(placement.rotation) ? Number(placement.rotation) : currentPlacement.rotation,
locked: typeof placement.locked === "boolean" ? placement.locked : currentPlacement.locked
};
if (
nextPlacement.x === currentPlacement.x &&
nextPlacement.y === currentPlacement.y &&
nextPlacement.rotation === currentPlacement.rotation &&
nextPlacement.locked === currentPlacement.locked
) {
return instance;
}
touched = true;
return {
...instance,
placement: nextPlacement
};
});
if (!touched) {
return state;
}
return {
...state,
model: {
...state.model,
instances
}
};
}
/**
* @param {StoreState} state
* @param {string} text
* @returns {StoreState}
*/
function applyJsonTextFailureState(state, text) {
return {
...state,
uiFlags: normalizeUiFlags(
{
...state.uiFlags,
jsonError: text
},
state.uiFlags
)
};
}
/**
* @param {Partial<StoreState>} [overrides]
* @returns {StoreState}
*/
export function createInitialState(overrides = {}) {
const historyLimit = Number.isInteger(overrides.history?.limit) ? overrides.history.limit : DEFAULT_HISTORY_LIMIT;
return {
model: overrides.model ? deepClone(overrides.model) : null,
compileResult: overrides.compileResult ? deepClone(overrides.compileResult) : null,
selection: normalizeSelection(overrides.selection ?? {}),
viewport: normalizeViewport(overrides.viewport ?? {}, { scale: 1, panX: 40, panY: 40 }),
uiFlags: normalizeUiFlags(overrides.uiFlags ?? {}, {
showLabels: true,
isolateNet: false,
isolateComponent: false,
renderMode: "schematic_stub",
jsonError: null
}),
history: {
past: [],
future: [],
limit: historyLimit
},
transaction: {
active: false,
label: null,
before: null,
dirty: false
}
};
}
/**
* @param {Partial<StoreState>} [initialState]
*/
export function createSchemetaStore(initialState = {}) {
/** @type {StoreState} */
let state = createInitialState(initialState);
const listeners = new Set();
/**
* @param {StoreState} nextState
* @param {string} action
*/
function publish(nextState, action) {
const prevState = state;
if (nextState === prevState) {
return;
}
state = nextState;
listeners.forEach((listener) => listener(state, prevState, action));
}
const actions = {
/** @param {unknown} model */
setModel(model) {
publish(applyMutation(state, "setModel", (s) => setModelState(s, model)), "setModel");
},
/** @param {unknown} compileResult */
setCompileResult(compileResult) {
publish(
applyMutation(state, "setCompileResult", (s) => setCompileResultState(s, compileResult)),
"setCompileResult"
);
},
/** @param {Partial<SelectionSlice>} selection */
setSelection(selection) {
publish(applyMutation(state, "setSelection", (s) => setSelectionState(s, selection)), "setSelection");
},
/** @param {string} ref @param {Partial<SchemetaPlacement>} placement */
moveComponent(ref, placement) {
publish(applyMutation(state, `moveComponent:${ref}`, (s) => moveComponentState(s, ref, placement ?? {})), "moveComponent");
},
/** @param {Partial<ViewportSlice>} viewport */
setViewport(viewport) {
publish(
applyMutation(state, "setViewport", (s) => ({ ...s, viewport: normalizeViewport(viewport ?? {}, s.viewport) })),
"setViewport"
);
},
/** @param {Partial<UiFlagsSlice>} uiFlags */
setUiFlags(uiFlags) {
publish(
applyMutation(state, "setUiFlags", (s) => ({ ...s, uiFlags: normalizeUiFlags(uiFlags ?? {}, s.uiFlags) })),
"setUiFlags"
);
},
/** @param {string} jsonText */
applyJsonText(jsonText) {
try {
const parsed = JSON.parse(jsonText);
const next = applyMutation(state, "applyJsonText", (s) => {
const withModel = setModelState(s, parsed);
return {
...withModel,
uiFlags: normalizeUiFlags({ ...withModel.uiFlags, jsonError: null }, withModel.uiFlags)
};
});
publish(next, "applyJsonText");
return { ok: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid JSON";
publish(applyJsonTextFailureState(state, message), "applyJsonTextError");
return { ok: false, error: message };
}
},
/** @param {string} [label] */
beginTransaction(label = "transaction") {
if (state.transaction.active) {
return false;
}
publish(
{
...state,
transaction: {
active: true,
label,
before: snapshotState(state),
dirty: false
}
},
"beginTransaction"
);
return true;
},
commitTransaction() {
if (!state.transaction.active) {
return false;
}
const tx = state.transaction;
let nextState = {
...state,
transaction: {
active: false,
label: null,
before: null,
dirty: false
}
};
if (tx.dirty && tx.before) {
const nextPast = [...state.history.past, { label: tx.label ?? "transaction", snapshot: tx.before }];
const overflow = Math.max(0, nextPast.length - state.history.limit);
nextState = {
...nextState,
history: {
...state.history,
past: overflow > 0 ? nextPast.slice(overflow) : nextPast,
future: []
}
};
}
publish(nextState, "commitTransaction");
return true;
},
rollbackTransaction() {
if (!state.transaction.active) {
return false;
}
const tx = state.transaction;
const rolledBack = tx.before ? restoreSnapshot(state, tx.before) : state;
publish(
{
...rolledBack,
transaction: {
active: false,
label: null,
before: null,
dirty: false
}
},
"rollbackTransaction"
);
return true;
},
undo() {
publish(undoState(state), "undo");
},
redo() {
publish(redoState(state), "redo");
}
};
return {
getState() {
return state;
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
actions
};
}

View File

@ -0,0 +1,129 @@
:root {
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: #1a1a1a;
background: #f3f6f8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
.app-shell {
display: grid;
grid-template-rows: auto auto 1fr;
min-height: 100vh;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
color: #f4f8ff;
background: linear-gradient(90deg, #0b2a42 0%, #164863 100%);
}
.topbar__brand {
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.topbar__title {
font-weight: 500;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.75rem 1rem;
border-bottom: 1px solid #d7dde3;
background: #ffffff;
}
.toolbar button {
padding: 0.45rem 0.7rem;
border-radius: 6px;
border: 1px solid #164863;
background: #ffffff;
color: #164863;
font: inherit;
cursor: pointer;
}
.toolbar button:hover {
background: #eaf2f8;
}
.toolbar__meta {
margin-left: auto;
color: #4a5b66;
font-size: 0.9rem;
}
.workspace-grid {
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 0.75rem;
padding: 0.75rem;
min-height: 0;
}
.panel,
.canvas {
border: 1px solid #d7dde3;
border-radius: 10px;
background: #ffffff;
min-height: 0;
}
.panel {
padding: 0.9rem;
}
.panel__title {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.panel__body {
margin: 0;
color: #52626e;
}
.canvas {
display: grid;
place-items: center;
background: radial-gradient(circle at 20% 20%, #eef5fb 0%, #ffffff 70%);
}
.canvas__placeholder {
border: 1px dashed #91a6b7;
border-radius: 8px;
padding: 1.2rem 1.4rem;
color: #355066;
}
@media (max-width: 1100px) {
.workspace-grid {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(260px, 1fr) auto;
}
.toolbar__meta {
width: 100%;
margin-left: 0;
}
}

1
frontend-react/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/compile": "http://localhost:8787",
"/analyze": "http://localhost:8787",
"/layout": "http://localhost:8787",
"/health": "http://localhost:8787"
}
},
preview: {
port: 4173
}
});

View File

@ -6,10 +6,21 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"start:legacy": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"dev:legacy": "node --watch src/server.js",
"frontend:legacy:start": "npm run start:legacy",
"frontend:legacy:dev": "npm run dev:legacy",
"test": "node --test", "test": "node --test",
"test:legacy": "node --test",
"test:ui": "node tests/ui-regression-runner.js", "test:ui": "node tests/ui-regression-runner.js",
"test:ui:update-baselines": "UPDATE_SNAPSHOTS=1 node tests/ui-regression-runner.js", "test:ui:update-baselines": "UPDATE_SNAPSHOTS=1 node tests/ui-regression-runner.js",
"frontend:react:dev": "node scripts/react-path.mjs dev",
"frontend:react:install": "npm --prefix frontend-react install",
"frontend:react:lint": "node scripts/react-path.mjs lint",
"frontend:react:test": "node scripts/react-path.mjs test",
"frontend:react:build": "node scripts/react-path.mjs build",
"frontend:react:check": "npm run frontend:react:lint && npm run frontend:react:test && npm run frontend:react:build",
"mcp": "node src/mcp-server.js" "mcp": "node src/mcp-server.js"
}, },
"devDependencies": { "devDependencies": {

40
scripts/react-path.mjs Normal file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
const command = process.argv[2];
if (!command) {
console.error('[react-path] Missing command argument (e.g. lint/test/build/dev).');
process.exit(1);
}
const reactDir = path.resolve(process.cwd(), 'frontend-react');
const reactPkgPath = path.join(reactDir, 'package.json');
const reactNodeModulesPath = path.join(reactDir, 'node_modules');
if (!existsSync(reactPkgPath)) {
console.log(`[react-path] Stub: frontend-react is not scaffolded yet. Skipping "${command}".`);
process.exit(0);
}
const reactPkg = JSON.parse(readFileSync(reactPkgPath, 'utf8'));
if (!reactPkg.scripts || !reactPkg.scripts[command]) {
console.log(`[react-path] Stub: frontend-react/package.json has no "${command}" script. Skipping.`);
process.exit(0);
}
if (!existsSync(reactNodeModulesPath)) {
console.log('[react-path] Stub: frontend-react dependencies are not installed. Skipping.');
process.exit(0);
}
const result = spawnSync('npm', ['--prefix', reactDir, 'run', command], {
stdio: 'inherit',
});
if (typeof result.status === 'number') {
process.exit(result.status);
}
process.exit(1);

122
tests/state-store.test.js Normal file
View File

@ -0,0 +1,122 @@
import test from "node:test";
import assert from "node:assert/strict";
import fixture from "../frontend/sample.schemeta.json" with { type: "json" };
import { createSchemetaStore } from "../frontend-react/src/state/store.js";
function baseModel() {
return {
...fixture,
instances: fixture.instances.map((instance) => ({
...instance,
placement: {
...instance.placement
}
}))
};
}
test("setSelection normalizes and sorts refs deterministically", () => {
const store = createSchemetaStore();
store.actions.setSelection({
selectedRefs: ["U7", "U1", "U2", "U1", "U3"],
selectedNet: "I2C_SCL",
selectedPin: { ref: "U1", pin: "GPIO9" }
});
const { selection } = store.getState();
assert.deepEqual(selection.selectedRefs, ["U1", "U2", "U3", "U7"]);
assert.equal(selection.selectedNet, "I2C_SCL");
assert.deepEqual(selection.selectedPin, { ref: "U1", pin: "GPIO9" });
});
test("moveComponent only updates targeted instance and is deterministic", () => {
const model = baseModel();
const storeA = createSchemetaStore();
const storeB = createSchemetaStore();
storeA.actions.setModel(model);
storeA.actions.moveComponent("U1", { x: 121.7, y: 333.2 });
storeB.actions.setModel(model);
storeB.actions.moveComponent("U1", { x: 121.7, y: 333.2 });
const stateA = storeA.getState();
const stateB = storeB.getState();
assert.deepEqual(stateA.model, stateB.model);
const moved = stateA.model.instances.find((x) => x.ref === "U1");
assert.equal(moved.placement.x, 122);
assert.equal(moved.placement.y, 333);
const untouched = stateA.model.instances.find((x) => x.ref === "U2");
assert.equal(untouched.placement.x, null);
assert.equal(untouched.placement.y, null);
});
test("applyJsonText updates model on valid JSON and clears jsonError", () => {
const store = createSchemetaStore({
uiFlags: { jsonError: "Old error" }
});
const payload = JSON.stringify(baseModel());
const result = store.actions.applyJsonText(payload);
assert.equal(result.ok, true);
assert.equal(store.getState().uiFlags.jsonError, null);
assert.equal(store.getState().model.instances.length > 0, true);
});
test("applyJsonText reports parse errors without mutating model", () => {
const store = createSchemetaStore();
store.actions.setModel(baseModel());
const before = store.getState().model;
const result = store.actions.applyJsonText("{ bad json }");
assert.equal(result.ok, false);
assert.equal(typeof result.error, "string");
assert.equal(store.getState().uiFlags.jsonError.length > 0, true);
assert.deepEqual(store.getState().model, before);
});
test("undo/redo restores deterministic snapshots", () => {
const store = createSchemetaStore();
store.actions.setModel(baseModel());
store.actions.setSelection({ selectedRefs: ["U3", "U1"] });
const beforeMove = store.getState().model;
store.actions.moveComponent("U1", { x: 80, y: 60 });
assert.equal(store.getState().history.past.length >= 3, true);
store.actions.undo();
assert.deepEqual(store.getState().model, beforeMove);
store.actions.redo();
const moved = store.getState().model.instances.find((x) => x.ref === "U1");
assert.equal(moved.placement.x, 80);
assert.equal(moved.placement.y, 60);
});
test("transaction scaffold groups multiple actions into one undo step", () => {
const store = createSchemetaStore();
store.actions.setModel(baseModel());
const pastBefore = store.getState().history.past.length;
store.actions.beginTransaction("drag-and-select");
store.actions.moveComponent("U1", { x: 240, y: 140 });
store.actions.setSelection({ selectedRefs: ["U2", "U1"] });
store.actions.commitTransaction();
assert.equal(store.getState().history.past.length, pastBefore + 1);
assert.deepEqual(store.getState().selection.selectedRefs, ["U1", "U2"]);
store.actions.undo();
assert.deepEqual(store.getState().selection.selectedRefs, []);
const u1 = store.getState().model.instances.find((x) => x.ref === "U1");
assert.equal(u1.placement.x, null);
assert.equal(u1.placement.y, null);
});