diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 7e672cd..cc4d34e 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -33,6 +33,9 @@ jobs:
- name: Run tests
run: npm test
+ - name: React path quality stubs
+ run: npm run frontend:react:check
+
- name: Install Playwright browsers
run: npx playwright install chromium
diff --git a/README.md b/README.md
index 6751c3f..964c689 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,17 @@ This version includes:
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:
- Workspace: `http://localhost:8787/`
- Health: `http://localhost:8787/health`
@@ -41,6 +52,7 @@ Docs:
- `docs/quality-gates.md`
- `docs/phase4-execution-plan.md`
- `docs/phase5-execution-plan.md`
+- `docs/phase8-dual-run-kickoff.md`
- `docs/fixtures.md`
- `docs/api-mcp-contracts.md`
diff --git a/docs/phase8-dual-run-kickoff.md b/docs/phase8-dual-run-kickoff.md
new file mode 100644
index 0000000..72b011f
--- /dev/null
+++ b/docs/phase8-dual-run-kickoff.md
@@ -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.
diff --git a/frontend-react/.gitignore b/frontend-react/.gitignore
new file mode 100644
index 0000000..06e6038
--- /dev/null
+++ b/frontend-react/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+*.tsbuildinfo
diff --git a/frontend-react/README.md b/frontend-react/README.md
new file mode 100644
index 0000000..74d5253
--- /dev/null
+++ b/frontend-react/README.md
@@ -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`.
diff --git a/frontend-react/index.html b/frontend-react/index.html
new file mode 100644
index 0000000..a0f7900
--- /dev/null
+++ b/frontend-react/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Schemeta React
+
+
+
+
+
+
diff --git a/frontend-react/package.json b/frontend-react/package.json
new file mode 100644
index 0000000..16177ea
--- /dev/null
+++ b/frontend-react/package.json
@@ -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"
+ }
+}
diff --git a/frontend-react/src/App.tsx b/frontend-react/src/App.tsx
new file mode 100644
index 0000000..52e608a
--- /dev/null
+++ b/frontend-react/src/App.tsx
@@ -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 (
+
+
+
+ {actions.map((action) => (
+
+ ))}
+
Status: {status} | API: {lastApiVersion}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend-react/src/api/client.ts b/frontend-react/src/api/client.ts
new file mode 100644
index 0000000..a8b4f5b
--- /dev/null
+++ b/frontend-react/src/api/client.ts
@@ -0,0 +1,48 @@
+import type {
+ AnalyzeResponse,
+ CompileResponse,
+ LayoutActionResponse,
+ SchemetaRequest
+} from "./types";
+
+async function postJson(path: string, body: unknown): Promise {
+ 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 = {}): Promise {
+ const body: SchemetaRequest = { payload, options };
+ return postJson("/compile", body);
+}
+
+export function analyze(payload: unknown, options: Record = {}): Promise {
+ const body: SchemetaRequest = { payload, options };
+ return postJson("/analyze", body);
+}
+
+export function autoLayout(payload: unknown, options: Record = {}): Promise {
+ const body: SchemetaRequest = { payload, options };
+ return postJson("/layout/auto", body);
+}
+
+export function tidyLayout(payload: unknown, options: Record = {}): Promise {
+ const body: SchemetaRequest = { payload, options };
+ return postJson("/layout/tidy", body);
+}
diff --git a/frontend-react/src/api/types.ts b/frontend-react/src/api/types.ts
new file mode 100644
index 0000000..eb921dd
--- /dev/null
+++ b/frontend-react/src/api/types.ts
@@ -0,0 +1,38 @@
+export type SchemetaRequest> = {
+ 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>;
+ };
+ layout_metrics?: Record;
+ topology?: Record;
+ focus_map?: Record;
+};
+
+export type AnalyzeResponse = SchemetaEnvelope & {
+ ok?: boolean;
+ errors?: unknown[];
+ warnings?: unknown[];
+ topology?: Record;
+};
+
+export type LayoutActionResponse = SchemetaEnvelope & {
+ ok?: boolean;
+ model?: Record;
+ compile?: CompileResponse;
+};
diff --git a/frontend-react/src/components/CanvasArea.tsx b/frontend-react/src/components/CanvasArea.tsx
new file mode 100644
index 0000000..e0130b0
--- /dev/null
+++ b/frontend-react/src/components/CanvasArea.tsx
@@ -0,0 +1,7 @@
+export function CanvasArea() {
+ return (
+
+ Canvas Area Placeholder
+
+ );
+}
diff --git a/frontend-react/src/components/LeftPanel.tsx b/frontend-react/src/components/LeftPanel.tsx
new file mode 100644
index 0000000..60ef611
--- /dev/null
+++ b/frontend-react/src/components/LeftPanel.tsx
@@ -0,0 +1,8 @@
+export function LeftPanel() {
+ return (
+
+ );
+}
diff --git a/frontend-react/src/components/RightInspector.tsx b/frontend-react/src/components/RightInspector.tsx
new file mode 100644
index 0000000..9b2a0ac
--- /dev/null
+++ b/frontend-react/src/components/RightInspector.tsx
@@ -0,0 +1,8 @@
+export function RightInspector() {
+ return (
+
+ );
+}
diff --git a/frontend-react/src/components/TopBar.tsx b/frontend-react/src/components/TopBar.tsx
new file mode 100644
index 0000000..3639f14
--- /dev/null
+++ b/frontend-react/src/components/TopBar.tsx
@@ -0,0 +1,12 @@
+type TopBarProps = {
+ title: string;
+};
+
+export function TopBar({ title }: TopBarProps) {
+ return (
+
+ );
+}
diff --git a/frontend-react/src/main.tsx b/frontend-react/src/main.tsx
new file mode 100644
index 0000000..31af7ac
--- /dev/null
+++ b/frontend-react/src/main.tsx
@@ -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(
+
+
+
+);
diff --git a/frontend-react/src/state/index.d.ts b/frontend-react/src/state/index.d.ts
new file mode 100644
index 0000000..d5a0b1b
--- /dev/null
+++ b/frontend-react/src/state/index.d.ts
@@ -0,0 +1 @@
+export * from "./store.js";
diff --git a/frontend-react/src/state/index.js b/frontend-react/src/state/index.js
new file mode 100644
index 0000000..a06a078
--- /dev/null
+++ b/frontend-react/src/state/index.js
@@ -0,0 +1 @@
+export { createInitialState, createSchemetaStore } from "./store.js";
diff --git a/frontend-react/src/state/store.d.ts b/frontend-react/src/state/store.d.ts
new file mode 100644
index 0000000..574df64
--- /dev/null
+++ b/frontend-react/src/state/store.d.ts
@@ -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;
+ placement: SchemetaPlacement;
+};
+
+export type SchemetaModel = {
+ meta?: Record;
+ symbols?: Record;
+ instances: SchemetaInstance[];
+ nets?: Array>;
+ constraints?: Record;
+ annotations?: Array>;
+};
+
+export type CompileResult = {
+ ok: boolean;
+ errors: Array>;
+ warnings: Array>;
+ layout?: Record;
+ layout_metrics?: Record;
+ 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): void;
+ moveComponent(ref: string, placement: Partial): void;
+ setViewport(viewport: Partial): void;
+ setUiFlags(uiFlags: Partial): 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;
+export function createSchemetaStore(initialState?: Partial): SchemetaStore;
diff --git a/frontend-react/src/state/store.js b/frontend-react/src/state/store.js
new file mode 100644
index 0000000..19377a3
--- /dev/null
+++ b/frontend-react/src/state/store.js
@@ -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} [properties]
+ * @property {SchemetaPlacement} placement
+ */
+
+/**
+ * @typedef {Object} SchemetaModel
+ * @property {Record} [meta]
+ * @property {Record} [symbols]
+ * @property {SchemetaInstance[]} instances
+ * @property {Array>} [nets]
+ * @property {Record} [constraints]
+ * @property {Array>} [annotations]
+ */
+
+/**
+ * @typedef {Object} CompileResult
+ * @property {boolean} ok
+ * @property {Array>} errors
+ * @property {Array>} warnings
+ * @property {Record} [layout]
+ * @property {Record} [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} 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} 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} 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} 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} 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} [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} [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} selection */
+ setSelection(selection) {
+ publish(applyMutation(state, "setSelection", (s) => setSelectionState(s, selection)), "setSelection");
+ },
+
+ /** @param {string} ref @param {Partial} placement */
+ moveComponent(ref, placement) {
+ publish(applyMutation(state, `moveComponent:${ref}`, (s) => moveComponentState(s, ref, placement ?? {})), "moveComponent");
+ },
+
+ /** @param {Partial} viewport */
+ setViewport(viewport) {
+ publish(
+ applyMutation(state, "setViewport", (s) => ({ ...s, viewport: normalizeViewport(viewport ?? {}, s.viewport) })),
+ "setViewport"
+ );
+ },
+
+ /** @param {Partial} 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
+ };
+}
diff --git a/frontend-react/src/styles.css b/frontend-react/src/styles.css
new file mode 100644
index 0000000..7a19460
--- /dev/null
+++ b/frontend-react/src/styles.css
@@ -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;
+ }
+}
diff --git a/frontend-react/src/vite-env.d.ts b/frontend-react/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/frontend-react/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/frontend-react/tsconfig.app.json b/frontend-react/tsconfig.app.json
new file mode 100644
index 0000000..b9a46dc
--- /dev/null
+++ b/frontend-react/tsconfig.app.json
@@ -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"]
+}
diff --git a/frontend-react/tsconfig.json b/frontend-react/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/frontend-react/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/frontend-react/tsconfig.node.json b/frontend-react/tsconfig.node.json
new file mode 100644
index 0000000..cbd2a63
--- /dev/null
+++ b/frontend-react/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend-react/vite.config.ts b/frontend-react/vite.config.ts
new file mode 100644
index 0000000..63d5c61
--- /dev/null
+++ b/frontend-react/vite.config.ts
@@ -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
+ }
+});
diff --git a/package.json b/package.json
index c5097ee..6e81344 100644
--- a/package.json
+++ b/package.json
@@ -6,10 +6,21 @@
"type": "module",
"scripts": {
"start": "node src/server.js",
+ "start:legacy": "node 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:legacy": "node --test",
"test:ui": "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"
},
"devDependencies": {
diff --git a/scripts/react-path.mjs b/scripts/react-path.mjs
new file mode 100644
index 0000000..2265756
--- /dev/null
+++ b/scripts/react-path.mjs
@@ -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);
diff --git a/tests/state-store.test.js b/tests/state-store.test.js
new file mode 100644
index 0000000..e917910
--- /dev/null
+++ b/tests/state-store.test.js
@@ -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);
+});