From d029e480d05d6a1be5f22807926a011824aaa7c1 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Thu, 19 Feb 2026 19:18:27 -0500 Subject: [PATCH] Kick off Phase 8 with React scaffold and deterministic store foundation --- .gitea/workflows/ci.yml | 3 + README.md | 12 + docs/phase8-dual-run-kickoff.md | 35 + frontend-react/.gitignore | 3 + frontend-react/README.md | 22 + frontend-react/index.html | 12 + frontend-react/package.json | 24 + frontend-react/src/App.tsx | 88 +++ frontend-react/src/api/client.ts | 48 ++ frontend-react/src/api/types.ts | 38 ++ frontend-react/src/components/CanvasArea.tsx | 7 + frontend-react/src/components/LeftPanel.tsx | 8 + .../src/components/RightInspector.tsx | 8 + frontend-react/src/components/TopBar.tsx | 12 + frontend-react/src/main.tsx | 10 + frontend-react/src/state/index.d.ts | 1 + frontend-react/src/state/index.js | 1 + frontend-react/src/state/store.d.ts | 102 +++ frontend-react/src/state/store.js | 629 ++++++++++++++++++ frontend-react/src/styles.css | 129 ++++ frontend-react/src/vite-env.d.ts | 1 + frontend-react/tsconfig.app.json | 21 + frontend-react/tsconfig.json | 7 + frontend-react/tsconfig.node.json | 11 + frontend-react/vite.config.ts | 18 + package.json | 11 + scripts/react-path.mjs | 40 ++ tests/state-store.test.js | 122 ++++ 28 files changed, 1423 insertions(+) create mode 100644 docs/phase8-dual-run-kickoff.md create mode 100644 frontend-react/.gitignore create mode 100644 frontend-react/README.md create mode 100644 frontend-react/index.html create mode 100644 frontend-react/package.json create mode 100644 frontend-react/src/App.tsx create mode 100644 frontend-react/src/api/client.ts create mode 100644 frontend-react/src/api/types.ts create mode 100644 frontend-react/src/components/CanvasArea.tsx create mode 100644 frontend-react/src/components/LeftPanel.tsx create mode 100644 frontend-react/src/components/RightInspector.tsx create mode 100644 frontend-react/src/components/TopBar.tsx create mode 100644 frontend-react/src/main.tsx create mode 100644 frontend-react/src/state/index.d.ts create mode 100644 frontend-react/src/state/index.js create mode 100644 frontend-react/src/state/store.d.ts create mode 100644 frontend-react/src/state/store.js create mode 100644 frontend-react/src/styles.css create mode 100644 frontend-react/src/vite-env.d.ts create mode 100644 frontend-react/tsconfig.app.json create mode 100644 frontend-react/tsconfig.json create mode 100644 frontend-react/tsconfig.node.json create mode 100644 frontend-react/vite.config.ts create mode 100644 scripts/react-path.mjs create mode 100644 tests/state-store.test.js 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 ( +
+
Schemeta
+
{title}
+
+ ); +} 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); +});