Kick off Phase 8 with React scaffold and deterministic store foundation
Some checks are pending
CI / test (push) Waiting to run
Some checks are pending
CI / test (push) Waiting to run
This commit is contained in:
parent
85dc9a1ac2
commit
d029e480d0
@ -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
|
||||||
|
|
||||||
|
|||||||
12
README.md
12
README.md
@ -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`
|
||||||
|
|
||||||
|
|||||||
35
docs/phase8-dual-run-kickoff.md
Normal file
35
docs/phase8-dual-run-kickoff.md
Normal 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
3
frontend-react/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.tsbuildinfo
|
||||||
22
frontend-react/README.md
Normal file
22
frontend-react/README.md
Normal 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
12
frontend-react/index.html
Normal 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>
|
||||||
24
frontend-react/package.json
Normal file
24
frontend-react/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
88
frontend-react/src/App.tsx
Normal file
88
frontend-react/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend-react/src/api/client.ts
Normal file
48
frontend-react/src/api/client.ts
Normal 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);
|
||||||
|
}
|
||||||
38
frontend-react/src/api/types.ts
Normal file
38
frontend-react/src/api/types.ts
Normal 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;
|
||||||
|
};
|
||||||
7
frontend-react/src/components/CanvasArea.tsx
Normal file
7
frontend-react/src/components/CanvasArea.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend-react/src/components/LeftPanel.tsx
Normal file
8
frontend-react/src/components/LeftPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend-react/src/components/RightInspector.tsx
Normal file
8
frontend-react/src/components/RightInspector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend-react/src/components/TopBar.tsx
Normal file
12
frontend-react/src/components/TopBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend-react/src/main.tsx
Normal file
10
frontend-react/src/main.tsx
Normal 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
1
frontend-react/src/state/index.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./store.js";
|
||||||
1
frontend-react/src/state/index.js
Normal file
1
frontend-react/src/state/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { createInitialState, createSchemetaStore } from "./store.js";
|
||||||
102
frontend-react/src/state/store.d.ts
vendored
Normal file
102
frontend-react/src/state/store.d.ts
vendored
Normal 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;
|
||||||
629
frontend-react/src/state/store.js
Normal file
629
frontend-react/src/state/store.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
129
frontend-react/src/styles.css
Normal file
129
frontend-react/src/styles.css
Normal 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
1
frontend-react/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
frontend-react/tsconfig.app.json
Normal file
21
frontend-react/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
7
frontend-react/tsconfig.json
Normal file
7
frontend-react/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
11
frontend-react/tsconfig.node.json
Normal file
11
frontend-react/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
18
frontend-react/vite.config.ts
Normal file
18
frontend-react/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
11
package.json
11
package.json
@ -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
40
scripts/react-path.mjs
Normal 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
122
tests/state-store.test.js
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user