Sprint 2: wire React workflows, store lifecycle, and interactive canvas
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-19 22:32:13 -05:00
parent d029e480d0
commit 5a4e116475
12 changed files with 1098 additions and 75 deletions

View File

@ -1,87 +1,191 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { analyze, autoLayout, compile, tidyLayout } from "./api/client"; import { analyze, autoLayout, compile, tidyLayout } from "./api/client";
import { CanvasArea } from "./components/CanvasArea"; import { CanvasArea } from "./components/CanvasArea";
import { LeftPanel } from "./components/LeftPanel"; import { LeftPanel } from "./components/LeftPanel";
import { RightInspector } from "./components/RightInspector"; import { RightInspector } from "./components/RightInspector";
import { TopBar } from "./components/TopBar"; import { TopBar } from "./components/TopBar";
import type { TopBarStatusTone } from "./components/TopBar";
import sampleFixture from "./fixtures/sample.schemeta.json";
import { useSchemetaActions, useSchemetaSelector } from "./hooks";
import { createSchemetaStore } from "./state";
import type { SchemetaModel } from "./state";
const SAMPLE_PAYLOAD = { type EndpointActionId = "compile" | "analyze" | "layout-auto" | "layout-tidy";
symbols: { type LocalActionId = "load-sample" | "reset-sample";
r_std: { template_name: "resistor" } type WorkflowActionId = EndpointActionId | LocalActionId;
},
instances: [ type WorkflowAction = {
{ id: WorkflowActionId;
ref: "R1", label: string;
symbol: "r_std",
properties: { value: "10k" },
placement: { x: null, y: null, rotation: 0, locked: false }
}
],
nets: []
}; };
export function App() { const SAMPLE_PAYLOAD = sampleFixture as SchemetaModel;
const [status, setStatus] = useState("Idle");
const [lastApiVersion, setLastApiVersion] = useState("-");
const actions = useMemo( function getPayloadOrThrow(payload: unknown): SchemetaModel {
if (typeof payload !== "object" || payload === null) {
throw new Error("No model loaded. Run 'Load Sample JSON' first.");
}
return payload as SchemetaModel;
}
function toCompileFromAnalyze(result: Awaited<ReturnType<typeof analyze>>): Record<string, unknown> {
return {
api_version: result.api_version,
schema_version: result.schema_version,
request_id: result.request_id,
ok: result.ok ?? true,
errors: Array.isArray(result.errors) ? result.errors : [],
warnings: Array.isArray(result.warnings) ? result.warnings : [],
topology: result.topology ?? {}
};
}
export function App() {
const store = useMemo(() => createSchemetaStore({ model: SAMPLE_PAYLOAD }), []);
const actionsApi = useSchemetaActions(store);
const model = useSchemetaSelector(store, (state) => state.model);
const selection = useSchemetaSelector(store, (state) => state.selection);
const compileResult = useSchemetaSelector(store, (state) => state.compileResult);
const lifecycle = useSchemetaSelector(store, (state) => state.lifecycle);
const jsonError = useSchemetaSelector(store, (state) => state.uiFlags.jsonError);
const lastApiVersion = useSchemetaSelector(store, (state) => {
const apiVersion = state.compileResult && typeof state.compileResult.api_version === "string" ? state.compileResult.api_version : null;
return apiVersion ?? "-";
});
const actions = useMemo<WorkflowAction[]>(
() => [ () => [
{ { id: "load-sample", label: "Load Sample JSON" },
id: "compile", { id: "compile", label: "Compile" },
label: "Compile", { id: "analyze", label: "Analyze" },
run: async () => compile(SAMPLE_PAYLOAD) { id: "layout-auto", label: "Auto Layout" },
}, { id: "layout-tidy", label: "Auto Tidy" },
{ { id: "reset-sample", label: "Reset Sample" }
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 status = jsonError
? `JSON parse failed: ${jsonError}`
: lifecycle.isCompiling
? `${lifecycle.lastAction ?? "Working"}...`
: lifecycle.lastError
? `${lifecycle.lastAction ?? "Action"} failed: ${lifecycle.lastError}`
: lifecycle.lastAction
? `${lifecycle.lastAction} complete`
: "Idle";
const statusTone: TopBarStatusTone = jsonError
? "error"
: lifecycle.isCompiling
? "busy"
: lifecycle.lastError
? "error"
: lifecycle.lastAction
? "success"
: "idle";
async function runAction(actionId: WorkflowActionId) {
const action = actions.find((item) => item.id === actionId); const action = actions.find((item) => item.id === actionId);
if (!action) { if (!action) {
return; return;
} }
setStatus(`${action.label}...`); actionsApi.beginCompile(action.label);
try { try {
const result = await action.run(); if (action.id === "load-sample" || action.id === "reset-sample") {
setLastApiVersion(result.api_version ?? "-"); const parsed = actionsApi.applyJsonText(JSON.stringify(SAMPLE_PAYLOAD));
setStatus(`${action.label} complete`); if (!parsed.ok) {
throw new Error(parsed.error);
}
actionsApi.setCompileResult(null);
actionsApi.setSelection({ selectedRefs: [], selectedNet: null, selectedPin: null });
actionsApi.setViewport({ scale: 1, panX: 40, panY: 40 });
actionsApi.completeCompile(action.label);
return;
}
const payload = getPayloadOrThrow(store.getState().model);
switch (action.id) {
case "compile":
{
const result = await compile(payload);
actionsApi.setCompileResult(result);
}
break;
case "analyze":
{
const result = await analyze(payload);
actionsApi.setCompileResult(toCompileFromAnalyze(result));
}
break;
case "layout-auto":
{
const result = await autoLayout(payload);
if (result.model) {
actionsApi.setModel(result.model);
}
if (result.compile) {
actionsApi.setCompileResult(result.compile);
}
}
break;
case "layout-tidy":
{
const result = await tidyLayout(payload);
if (result.model) {
actionsApi.setModel(result.model);
}
if (result.compile) {
actionsApi.setCompileResult(result.compile);
}
}
break;
}
actionsApi.completeCompile(action.label);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unknown error"; actionsApi.failCompile(action.label, err);
setStatus(`${action.label} failed: ${message}`);
} }
} }
return ( return (
<div className="app-shell"> <div className="app-shell">
<TopBar title="React Frontend Skeleton" /> <TopBar
<div className="toolbar" role="toolbar" aria-label="API actions"> title="React Frontend Skeleton"
{actions.map((action) => ( actions={actions.map((action) => ({
<button key={action.id} type="button" onClick={() => runAction(action.id)}> id: action.id,
{action.label} label: action.label,
</button> onClick: () => {
))} void runAction(action.id);
<div className="toolbar__meta">Status: {status} | API: {lastApiVersion}</div> },
</div> disabled: lifecycle.isCompiling
}))}
statusMessage={status}
statusTone={statusTone}
apiVersion={lastApiVersion}
/>
<div className="workspace-grid"> <div className="workspace-grid">
<LeftPanel /> <LeftPanel
model={model}
selection={selection}
onSelectInstance={(ref) =>
actionsApi.setSelection({
selectedRefs: selection.selectedRefs.includes(ref) ? [] : [ref],
selectedNet: null,
selectedPin: null
})
}
onSelectNet={(name) =>
actionsApi.setSelection({
selectedRefs: [],
selectedNet: selection.selectedNet === name ? null : name,
selectedPin: null
})
}
/>
<CanvasArea /> <CanvasArea />
<RightInspector /> <RightInspector model={model} selection={selection} compileResult={compileResult} />
</div> </div>
</div> </div>
); );

View File

@ -1,7 +1,304 @@
export function CanvasArea() { import { useMemo, useState, useSyncExternalStore } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { createSchemetaStore } from "../state/store.js";
import type { SchemetaInstance, SchemetaModel, SchemetaStore } from "../state/store.js";
const MIN_SCALE = 0.4;
const MAX_SCALE = 2.5;
const SCALE_STEP = 0.1;
const AUTO_COLUMNS = 4;
const AUTO_X_START = 60;
const AUTO_Y_START = 60;
const AUTO_X_GAP = 210;
const AUTO_Y_GAP = 150;
const BOOTSTRAP_MODEL: SchemetaModel = {
symbols: {
mcu: {
body: { width: 180, height: 140 },
pins: [
{ name: "3V3", number: "1", side: "left", offset: 28 },
{ name: "GND", number: "2", side: "left", offset: 56 },
{ name: "GPIO1", number: "6", side: "right", offset: 40 },
{ name: "GPIO2", number: "7", side: "right", offset: 72 }
]
},
sensor: {
body: { width: 150, height: 110 },
pins: [
{ name: "3V3", number: "1", side: "left", offset: 30 },
{ name: "GND", number: "2", side: "left", offset: 58 },
{ name: "SCL", number: "3", side: "right", offset: 40 },
{ name: "SDA", number: "4", side: "right", offset: 68 }
]
},
conn: {
body: { width: 130, height: 90 },
pins: [
{ name: "TX", number: "1", side: "right", offset: 30 },
{ name: "RX", number: "2", side: "right", offset: 56 }
]
}
},
instances: [
{ ref: "U1", symbol: "mcu", properties: { value: "Control MCU" }, placement: { x: 120, y: 120, rotation: 0 } },
{ ref: "U2", symbol: "sensor", properties: { value: "IMU" }, placement: { x: 420, y: 130, rotation: 0 } },
{ ref: "U3", symbol: "sensor", properties: { value: "Temp Sensor" }, placement: { x: null, y: null, rotation: 0 } },
{ ref: "J1", symbol: "conn", properties: { value: "Debug Header" }, placement: { x: 180, y: 320, rotation: 0 } }
],
nets: []
};
type SymbolPin = {
name?: string;
number?: string;
side?: string;
offset?: number;
};
type NodePlacement = {
x: number;
y: number;
width: number;
height: number;
rotation: number;
locked: boolean;
leftPins: SymbolPin[];
rightPins: SymbolPin[];
};
type DragState = {
ref: string;
pointerId: number;
startClientX: number;
startClientY: number;
startX: number;
startY: number;
};
const fallbackStore = createSchemetaStore({
model: BOOTSTRAP_MODEL,
viewport: { scale: 1, panX: 30, panY: 30 },
selection: { selectedRefs: [], selectedNet: null, selectedPin: null }
});
function useStoreState(activeStore: SchemetaStore): ReturnType<SchemetaStore["getState"]> {
return useSyncExternalStore(
(listener: () => void) => activeStore.subscribe(() => listener()),
() => activeStore.getState(),
() => activeStore.getState()
);
}
function toFinite(value: unknown, fallback: number) {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function toDimension(value: unknown, fallback: number, min = 80, max = 320) {
const parsed = toFinite(value, fallback);
return Math.max(min, Math.min(max, Math.round(parsed)));
}
function getSymbolNode(symbols: SchemetaModel["symbols"], instance: SchemetaInstance) {
if (!instance.symbol || !symbols || typeof symbols !== "object") {
return null;
}
const candidate = symbols[instance.symbol];
return candidate && typeof candidate === "object" ? candidate : null;
}
function getNodePlacement(instance: SchemetaInstance, index: number, symbols: SchemetaModel["symbols"]): NodePlacement {
const col = index % AUTO_COLUMNS;
const row = Math.floor(index / AUTO_COLUMNS);
const symbol = getSymbolNode(symbols, instance) as
| { body?: { width?: number; height?: number }; pins?: SymbolPin[] }
| null;
const width = toDimension(symbol?.body?.width, 150);
const height = toDimension(symbol?.body?.height, 100);
const x = toFinite(instance.placement?.x, AUTO_X_START + col * AUTO_X_GAP);
const y = toFinite(instance.placement?.y, AUTO_Y_START + row * AUTO_Y_GAP);
const rotation = toFinite(instance.placement?.rotation, 0);
const locked = Boolean(instance.placement?.locked);
const pins = Array.isArray(symbol?.pins) ? symbol.pins : [];
const leftPins = pins.filter((pin) => pin?.side === "left").slice(0, 6);
const rightPins = pins.filter((pin) => pin?.side === "right").slice(0, 6);
return { x, y, width, height, rotation, locked, leftPins, rightPins };
}
function formatZoom(scale: number) {
return `${Math.round(scale * 100)}%`;
}
type CanvasAreaProps = {
store?: SchemetaStore;
};
export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
const store = providedStore ?? fallbackStore;
const state = useStoreState(store);
const [drag, setDrag] = useState<DragState | null>(null);
const instances = state.model?.instances ?? [];
const symbols = state.model?.symbols ?? {};
const selectedRef = state.selection.selectedRefs[0] ?? null;
const placements = useMemo(() => {
const byRef = new Map<string, NodePlacement>();
instances.forEach((instance, index) => {
byRef.set(instance.ref, getNodePlacement(instance, index, symbols));
});
return byRef;
}, [instances, symbols]);
function setSelection(ref: string | null) {
store.actions.setSelection({
selectedRefs: ref ? [ref] : [],
selectedNet: null,
selectedPin: null
});
}
function updateScale(nextScale: number) {
const clamped = Math.max(MIN_SCALE, Math.min(MAX_SCALE, nextScale));
store.actions.setViewport({ scale: clamped });
}
function handleCanvasPointerDown(event: ReactPointerEvent<HTMLElement>) {
if (event.target === event.currentTarget) {
setSelection(null);
}
}
function handleNodePointerDown(event: ReactPointerEvent<HTMLDivElement>, ref: string, placement: NodePlacement) {
if (event.button !== 0) {
return;
}
setSelection(ref);
if (placement.locked) {
return;
}
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
setDrag({
ref,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startX: placement.x,
startY: placement.y
});
}
function handleNodePointerMove(event: ReactPointerEvent<HTMLDivElement>, ref: string) {
if (!drag || drag.ref !== ref || drag.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const dx = (event.clientX - drag.startClientX) / state.viewport.scale;
const dy = (event.clientY - drag.startClientY) / state.viewport.scale;
store.actions.moveComponent(ref, { x: drag.startX + dx, y: drag.startY + dy });
}
function handleNodePointerUpOrCancel(event: ReactPointerEvent<HTMLDivElement>, ref: string) {
if (!drag || drag.ref !== ref || drag.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setDrag(null);
}
return ( return (
<main className="canvas" aria-label="Canvas area"> <main className="canvas" aria-label="Canvas area">
<div className="canvas__placeholder">Canvas Area Placeholder</div> <div className="canvas__toolbar" role="toolbar" aria-label="Canvas controls">
<button
type="button"
onClick={() => updateScale(state.viewport.scale - SCALE_STEP)}
disabled={state.viewport.scale <= MIN_SCALE}
>
Zoom Out
</button>
<div className="canvas__zoom-readout">Zoom {formatZoom(state.viewport.scale)}</div>
<button
type="button"
onClick={() => updateScale(state.viewport.scale + SCALE_STEP)}
disabled={state.viewport.scale >= MAX_SCALE}
>
Zoom In
</button>
<button type="button" className="canvas__placeholder-control" disabled title="Fit viewport is not implemented yet">
Fit (Soon)
</button>
<button
type="button"
className="canvas__placeholder-control"
disabled
title="Focus selection is not implemented yet"
>
Focus (Soon)
</button>
</div>
<section className="canvas__surface" onPointerDown={handleCanvasPointerDown}>
<div
className="canvas__viewport"
style={{
transform: `translate(${state.viewport.panX}px, ${state.viewport.panY}px) scale(${state.viewport.scale})`
}}
>
{instances.map((instance, index) => {
const placement = placements.get(instance.ref) ?? getNodePlacement(instance, index, symbols);
const isSelected = selectedRef === instance.ref;
const valueLabel =
typeof instance.properties?.value === "string" && instance.properties.value.length > 0
? instance.properties.value
: instance.symbol ?? instance.part ?? "Component";
return (
<div
key={instance.ref}
className={`canvas-node ${isSelected ? "is-selected" : ""} ${drag?.ref === instance.ref ? "is-dragging" : ""}`}
style={{ left: `${placement.x}px`, top: `${placement.y}px`, width: `${placement.width}px`, minHeight: `${placement.height}px` }}
onPointerDown={(event) => handleNodePointerDown(event, instance.ref, placement)}
onPointerMove={(event) => handleNodePointerMove(event, instance.ref)}
onPointerUp={(event) => handleNodePointerUpOrCancel(event, instance.ref)}
onPointerCancel={(event) => handleNodePointerUpOrCancel(event, instance.ref)}
>
<div className="canvas-node__chrome" style={{ transform: `rotate(${placement.rotation}deg)` }}>
<div className="canvas-node__content" style={{ transform: `rotate(${-placement.rotation}deg)` }}>
<div className="canvas-node__ref">{instance.ref}</div>
<div className="canvas-node__value">{valueLabel}</div>
<div className="canvas-node__pins">
<div className="canvas-node__pin-col">
{placement.leftPins.map((pin) => (
<div key={`${instance.ref}-left-${pin.number ?? pin.name}`} className="canvas-node__pin">
<span className="canvas-node__pin-number">{pin.number ?? "-"}</span>
<span className="canvas-node__pin-name">{pin.name ?? "PIN"}</span>
</div>
))}
</div>
<div className="canvas-node__pin-col canvas-node__pin-col--right">
{placement.rightPins.map((pin) => (
<div key={`${instance.ref}-right-${pin.number ?? pin.name}`} className="canvas-node__pin">
<span className="canvas-node__pin-name">{pin.name ?? "PIN"}</span>
<span className="canvas-node__pin-number">{pin.number ?? "-"}</span>
</div>
))}
</div>
</div>
{placement.locked ? <div className="canvas-node__lock">Locked</div> : null}
</div>
</div>
</div>
);
})}
</div>
</section>
</main> </main>
); );
} }

View File

@ -1,8 +1,98 @@
export function LeftPanel() { import { useMemo, useState } from "react";
import type { SchemetaModel, SelectionSlice } from "../state";
type LeftPanelProps = {
model: SchemetaModel | null;
selection: SelectionSlice;
onSelectInstance: (ref: string) => void;
onSelectNet: (name: string) => void;
};
type NetRow = {
id: string;
label: string;
};
function asNetLabel(net: Record<string, unknown>, index: number): NetRow {
const name = typeof net.name === "string" && net.name.length > 0 ? net.name : null;
const id = name ?? `net-${index + 1}`;
return {
id,
label: name ?? `Net ${index + 1}`
};
}
export function LeftPanel({ model, selection, onSelectInstance, onSelectNet }: LeftPanelProps) {
const [filterText, setFilterText] = useState("");
const filter = filterText.trim().toLowerCase();
const instances = model?.instances ?? [];
const netsRaw = (model?.nets ?? []).filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null);
const nets = netsRaw.map(asNetLabel);
const filteredInstances = useMemo(
() =>
instances.filter((instance) => {
if (!filter) {
return true;
}
const symbol = typeof instance.symbol === "string" ? instance.symbol : "";
const part = typeof instance.part === "string" ? instance.part : "";
return `${instance.ref} ${symbol} ${part}`.toLowerCase().includes(filter);
}),
[filter, instances]
);
const filteredNets = useMemo(
() =>
nets.filter((net) => {
if (!filter) {
return true;
}
return net.label.toLowerCase().includes(filter) || net.id.toLowerCase().includes(filter);
}),
[filter, nets]
);
return ( return (
<aside className="panel panel--left" aria-label="Left panel"> <aside className="panel panel--left" aria-label="Left panel">
<h2 className="panel__title">Left Panel</h2> <h2 className="panel__title">Model Browser</h2>
<p className="panel__body">Component tree and graph controls will live here.</p> <input
aria-label="Filter instances and nets"
type="search"
value={filterText}
onChange={(event) => setFilterText(event.target.value)}
placeholder="Filter refs, parts, nets..."
/>
<h3>Instances ({filteredInstances.length})</h3>
<ul>
{filteredInstances.map((instance) => {
const isSelected = selection.selectedRefs.includes(instance.ref);
const descriptor = instance.symbol ?? instance.part ?? "component";
return (
<li key={instance.ref}>
<button type="button" onClick={() => onSelectInstance(instance.ref)}>
{isSelected ? "[x]" : "[ ]"} {instance.ref} - {descriptor}
</button>
</li>
);
})}
</ul>
<h3>Nets ({filteredNets.length})</h3>
<ul>
{filteredNets.map((net) => {
const isSelected = selection.selectedNet === net.id;
return (
<li key={net.id}>
<button type="button" onClick={() => onSelectNet(net.id)}>
{isSelected ? "[x]" : "[ ]"} {net.label}
</button>
</li>
);
})}
</ul>
</aside> </aside>
); );
} }

View File

@ -1,8 +1,71 @@
export function RightInspector() { import type { SchemetaModel, SelectionSlice } from "../state";
type RightInspectorProps = {
model: SchemetaModel | null;
selection: SelectionSlice;
compileResult: unknown | null;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null) {
return null;
}
return value as Record<string, unknown>;
}
function asList(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function toSnippet(value: unknown, fallback: string): string {
if (value == null) {
return fallback;
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return fallback;
}
}
export function RightInspector({ model, selection, compileResult }: RightInspectorProps) {
const selectedRef = selection.selectedRefs[0] ?? null;
const selectedInstance = selectedRef ? (model?.instances ?? []).find((instance) => instance.ref === selectedRef) : null;
const compile = asRecord(compileResult);
const errors = asList(compile?.errors);
const warnings = asList(compile?.warnings);
const topology = compile?.topology;
const layoutMetrics = compile?.layout_metrics;
return ( return (
<aside className="panel panel--right" aria-label="Right inspector"> <aside className="panel panel--right" aria-label="Right inspector">
<h2 className="panel__title">Right Inspector</h2> <h2 className="panel__title">Selection Summary</h2>
<p className="panel__body">Selection details and properties will live here.</p> <p className="panel__body">Refs: {selection.selectedRefs.length > 0 ? selection.selectedRefs.join(", ") : "None"}</p>
<p className="panel__body">Net: {selection.selectedNet ?? "None"}</p>
<p className="panel__body">
Pin: {selection.selectedPin ? `${selection.selectedPin.ref}.${selection.selectedPin.pin}` : "None"}
</p>
<p className="panel__body">
Focused Component: {selectedInstance ? `${selectedInstance.ref} (${selectedInstance.symbol ?? selectedInstance.part ?? "component"})` : "None"}
</p>
<h2 className="panel__title">Diagnostics</h2>
<p className="panel__body">Errors: {errors.length}</p>
<p className="panel__body">Warnings: {warnings.length}</p>
<pre>{toSnippet(errors[0], "No error diagnostics yet.")}</pre>
<pre>{toSnippet(warnings[0], "No warning diagnostics yet.")}</pre>
<h2 className="panel__title">Topology Snippet</h2>
<pre>{toSnippet(topology, "No topology available yet.")}</pre>
<h2 className="panel__title">Layout Metrics</h2>
<pre>{toSnippet(layoutMetrics, "No layout metrics available yet.")}</pre>
</aside> </aside>
); );
} }

View File

@ -1,12 +1,37 @@
type TopBarProps = { export type TopBarAction = {
title: string; id: string;
label: string;
onClick: () => void;
disabled?: boolean;
}; };
export function TopBar({ title }: TopBarProps) { export type TopBarStatusTone = "idle" | "busy" | "success" | "error";
type TopBarProps = {
title: string;
actions: TopBarAction[];
statusMessage: string;
statusTone: TopBarStatusTone;
apiVersion: string;
};
export function TopBar({ title, actions, statusMessage, statusTone, apiVersion }: TopBarProps) {
return ( return (
<header className="topbar" role="banner"> <header className="topbar" role="banner">
<div className="topbar__brand">Schemeta</div> <div className="topbar__brand">Schemeta</div>
<div className="topbar__title">{title}</div> <div className="topbar__title">{title}</div>
<div className="topbar__actions" role="toolbar" aria-label="Workflow actions">
{actions.map((action) => (
<button key={action.id} type="button" onClick={action.onClick} disabled={action.disabled === true}>
{action.label}
</button>
))}
</div>
<div className="topbar__meta">
<span>Status: {statusMessage}</span>
<span>Level: {statusTone}</span>
<span>API: {apiVersion}</span>
</div>
</header> </header>
); );
} }

View File

@ -0,0 +1,37 @@
{
"meta": {
"title": "Dual-Rail Power Tree",
"version": "1.0"
},
"symbols": {},
"instances": [
{ "ref": "J1", "part": "connector", "properties": { "value": "VIN/GND" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U1", "part": "generic", "properties": { "value": "Buck 12V->5V" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U2", "part": "generic", "properties": { "value": "LDO 5V->3V3" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U3", "part": "generic", "properties": { "value": "MCU" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U4", "part": "generic", "properties": { "value": "RF Module" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C1", "part": "capacitor", "properties": { "value": "22uF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C2", "part": "capacitor", "properties": { "value": "10uF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C3", "part": "capacitor", "properties": { "value": "100nF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C4", "part": "capacitor", "properties": { "value": "100nF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
],
"nets": [
{ "name": "VIN", "class": "power", "nodes": [ { "ref": "J1", "pin": "1" }, { "ref": "U1", "pin": "VIN" } ] },
{ "name": "5V", "class": "power", "nodes": [ { "ref": "U1", "pin": "VOUT" }, { "ref": "U2", "pin": "VIN" }, { "ref": "C1", "pin": "1" }, { "ref": "C2", "pin": "1" } ] },
{ "name": "3V3", "class": "power", "nodes": [ { "ref": "U2", "pin": "VOUT" }, { "ref": "U3", "pin": "VCC" }, { "ref": "U4", "pin": "VCC" }, { "ref": "C3", "pin": "1" }, { "ref": "C4", "pin": "1" } ] },
{ "name": "GND", "class": "ground", "nodes": [ { "ref": "J1", "pin": "2" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }, { "ref": "U4", "pin": "GND" }, { "ref": "C1", "pin": "2" }, { "ref": "C2", "pin": "2" }, { "ref": "C3", "pin": "2" }, { "ref": "C4", "pin": "2" } ] },
{ "name": "SPI_CLK", "class": "clock", "nodes": [ { "ref": "U3", "pin": "SCLK" }, { "ref": "U4", "pin": "SCLK" } ] },
{ "name": "SPI_MOSI", "class": "signal", "nodes": [ { "ref": "U3", "pin": "MOSI" }, { "ref": "U4", "pin": "MOSI" } ] },
{ "name": "SPI_MISO", "class": "signal", "nodes": [ { "ref": "U3", "pin": "MISO" }, { "ref": "U4", "pin": "MISO" } ] }
],
"constraints": {
"groups": [
{ "name": "source", "members": ["J1", "U1", "C1"], "layout": "cluster" },
{ "name": "regulation", "members": ["U2", "C2"], "layout": "cluster" },
{ "name": "load", "members": ["U3", "U4", "C3", "C4"], "layout": "cluster" }
]
},
"annotations": [
{ "text": "Power-chain fixture with SPI branch used for readability QA.", "x": 24, "y": 24 }
]
}

View File

@ -0,0 +1 @@
export { useSchemetaActions, useSchemetaSelector, useSchemetaStoreState } from "./useSchemetaStore";

View File

@ -0,0 +1,17 @@
import { useMemo, useSyncExternalStore } from "react";
import type { SchemetaStore, StoreState } from "../state";
type Selector<TSelected> = (state: StoreState) => TSelected;
export function useSchemetaStoreState(store: SchemetaStore): StoreState {
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
}
export function useSchemetaSelector<TSelected>(store: SchemetaStore, selector: Selector<TSelected>): TSelected {
const state = useSchemetaStoreState(store);
return useMemo(() => selector(state), [selector, state]);
}
export function useSchemetaActions(store: SchemetaStore): SchemetaStore["actions"] {
return store.actions;
}

View File

@ -23,6 +23,9 @@ export type SchemetaModel = {
}; };
export type CompileResult = { export type CompileResult = {
api_version?: string;
schema_version?: string;
request_id?: string;
ok: boolean; ok: boolean;
errors: Array<Record<string, unknown>>; errors: Array<Record<string, unknown>>;
warnings: Array<Record<string, unknown>>; warnings: Array<Record<string, unknown>>;
@ -52,6 +55,12 @@ export type UiFlagsSlice = {
jsonError: string | null; jsonError: string | null;
}; };
export type LifecycleSlice = {
isCompiling: boolean;
lastError: string | null;
lastAction: string | null;
};
export type StateSnapshot = { export type StateSnapshot = {
model: SchemetaModel | null; model: SchemetaModel | null;
compileResult: CompileResult | null; compileResult: CompileResult | null;
@ -66,6 +75,7 @@ export type StoreState = {
selection: SelectionSlice; selection: SelectionSlice;
viewport: ViewportSlice; viewport: ViewportSlice;
uiFlags: UiFlagsSlice; uiFlags: UiFlagsSlice;
lifecycle: LifecycleSlice;
history: { history: {
past: Array<{ label: string; snapshot: StateSnapshot }>; past: Array<{ label: string; snapshot: StateSnapshot }>;
future: Array<{ label: string; snapshot: StateSnapshot }>; future: Array<{ label: string; snapshot: StateSnapshot }>;
@ -89,6 +99,10 @@ export type SchemetaStore = {
moveComponent(ref: string, placement: Partial<SchemetaPlacement>): void; moveComponent(ref: string, placement: Partial<SchemetaPlacement>): void;
setViewport(viewport: Partial<ViewportSlice>): void; setViewport(viewport: Partial<ViewportSlice>): void;
setUiFlags(uiFlags: Partial<UiFlagsSlice>): void; setUiFlags(uiFlags: Partial<UiFlagsSlice>): void;
setLifecycle(lifecycle: Partial<LifecycleSlice>): void;
beginCompile(action: string): void;
completeCompile(action: string): void;
failCompile(action: string, error: unknown): void;
applyJsonText(jsonText: string): { ok: true } | { ok: false; error: string }; applyJsonText(jsonText: string): { ok: true } | { ok: false; error: string };
beginTransaction(label?: string): boolean; beginTransaction(label?: string): boolean;
commitTransaction(): boolean; commitTransaction(): boolean;

View File

@ -29,6 +29,9 @@ const DEFAULT_HISTORY_LIMIT = 80;
/** /**
* @typedef {Object} CompileResult * @typedef {Object} CompileResult
* @property {string} [api_version]
* @property {string} [schema_version]
* @property {string} [request_id]
* @property {boolean} ok * @property {boolean} ok
* @property {Array<Record<string, unknown>>} errors * @property {Array<Record<string, unknown>>} errors
* @property {Array<Record<string, unknown>>} warnings * @property {Array<Record<string, unknown>>} warnings
@ -65,6 +68,13 @@ const DEFAULT_HISTORY_LIMIT = 80;
* @property {string | null} jsonError * @property {string | null} jsonError
*/ */
/**
* @typedef {Object} LifecycleSlice
* @property {boolean} isCompiling
* @property {string | null} lastError
* @property {string | null} lastAction
*/
/** /**
* @typedef {Object} StateSnapshot * @typedef {Object} StateSnapshot
* @property {SchemetaModel | null} model * @property {SchemetaModel | null} model
@ -87,6 +97,7 @@ const DEFAULT_HISTORY_LIMIT = 80;
* @property {SelectionSlice} selection * @property {SelectionSlice} selection
* @property {ViewportSlice} viewport * @property {ViewportSlice} viewport
* @property {UiFlagsSlice} uiFlags * @property {UiFlagsSlice} uiFlags
* @property {LifecycleSlice} lifecycle
* @property {{ past: HistoryEntry[], future: HistoryEntry[], limit: number }} history * @property {{ past: HistoryEntry[], future: HistoryEntry[], limit: number }} history
* @property {{ active: boolean, label: string | null, before: StateSnapshot | null, dirty: boolean }} transaction * @property {{ active: boolean, label: string | null, before: StateSnapshot | null, dirty: boolean }} transaction
*/ */
@ -157,6 +168,25 @@ function normalizeUiFlags(uiFlags, prev) {
}; };
} }
/**
* @param {Partial<LifecycleSlice>} lifecycle
* @param {LifecycleSlice} prev
* @returns {LifecycleSlice}
*/
function normalizeLifecycle(lifecycle, prev) {
return {
isCompiling: typeof lifecycle.isCompiling === "boolean" ? lifecycle.isCompiling : prev.isCompiling,
lastError:
lifecycle.lastError === null || (typeof lifecycle.lastError === "string" && lifecycle.lastError.length > 0)
? lifecycle.lastError
: prev.lastError,
lastAction:
lifecycle.lastAction === null || (typeof lifecycle.lastAction === "string" && lifecycle.lastAction.length > 0)
? lifecycle.lastAction
: prev.lastAction
};
}
/** /**
* @param {StoreState} state * @param {StoreState} state
* @returns {StateSnapshot} * @returns {StateSnapshot}
@ -413,6 +443,18 @@ function applyJsonTextFailureState(state, text) {
}; };
} }
/**
* @param {StoreState} state
* @param {Partial<LifecycleSlice>} lifecycle
* @returns {StoreState}
*/
function setLifecycleState(state, lifecycle) {
return {
...state,
lifecycle: normalizeLifecycle(lifecycle, state.lifecycle)
};
}
/** /**
* @param {Partial<StoreState>} [overrides] * @param {Partial<StoreState>} [overrides]
* @returns {StoreState} * @returns {StoreState}
@ -432,6 +474,11 @@ export function createInitialState(overrides = {}) {
renderMode: "schematic_stub", renderMode: "schematic_stub",
jsonError: null jsonError: null
}), }),
lifecycle: normalizeLifecycle(overrides.lifecycle ?? {}, {
isCompiling: false,
lastError: null,
lastAction: null
}),
history: { history: {
past: [], past: [],
future: [], future: [],
@ -508,6 +555,48 @@ export function createSchemetaStore(initialState = {}) {
); );
}, },
/** @param {Partial<LifecycleSlice>} lifecycle */
setLifecycle(lifecycle) {
publish(setLifecycleState(state, lifecycle ?? {}), "setLifecycle");
},
/** @param {string} action */
beginCompile(action) {
publish(
setLifecycleState(state, {
isCompiling: true,
lastAction: action || "compile",
lastError: null
}),
"beginCompile"
);
},
/** @param {string} action */
completeCompile(action) {
publish(
setLifecycleState(state, {
isCompiling: false,
lastAction: action || state.lifecycle.lastAction,
lastError: null
}),
"completeCompile"
);
},
/** @param {string} action @param {unknown} error */
failCompile(action, error) {
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
publish(
setLifecycleState(state, {
isCompiling: false,
lastAction: action || state.lifecycle.lastAction,
lastError: message
}),
"failCompile"
);
},
/** @param {string} jsonText */ /** @param {string} jsonText */
applyJsonText(jsonText) { applyJsonText(jsonText) {
try { try {

View File

@ -43,6 +43,41 @@ body {
font-weight: 500; font-weight: 500;
} }
.topbar__actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
justify-content: center;
}
.topbar__actions button {
padding: 0.35rem 0.6rem;
border: 1px solid rgba(232, 242, 250, 0.65);
border-radius: 6px;
background: rgba(8, 24, 38, 0.24);
color: #f2f8ff;
font: inherit;
cursor: pointer;
}
.topbar__actions button:hover:not(:disabled) {
background: rgba(8, 24, 38, 0.38);
}
.topbar__actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.topbar__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.1rem;
font-size: 0.84rem;
color: #d7e6f6;
}
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
@ -91,6 +126,7 @@ body {
.panel { .panel {
padding: 0.9rem; padding: 0.9rem;
overflow: auto;
} }
.panel__title { .panel__title {
@ -103,20 +139,227 @@ body {
color: #52626e; color: #52626e;
} }
.canvas { .panel input[type="search"] {
display: grid; width: 100%;
place-items: center; margin-bottom: 0.7rem;
background: radial-gradient(circle at 20% 20%, #eef5fb 0%, #ffffff 70%); padding: 0.4rem 0.5rem;
border: 1px solid #9bb0c0;
border-radius: 6px;
font: inherit;
} }
.canvas__placeholder { .panel h3 {
border: 1px dashed #91a6b7; margin: 0.7rem 0 0.4rem;
font-size: 0.9rem;
color: #264258;
}
.panel ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.25rem;
}
.panel li button {
width: 100%;
text-align: left;
border: 1px solid #d2dee8;
border-radius: 6px;
background: #fafcff;
padding: 0.32rem 0.45rem;
color: #173247;
font: inherit;
cursor: pointer;
}
.panel li button:hover {
background: #eef6fd;
}
.panel pre {
margin: 0.35rem 0 0.55rem;
border: 1px solid #dbe5ee;
border-radius: 8px; border-radius: 8px;
padding: 1.2rem 1.4rem; background: #f7fbff;
color: #355066; color: #274054;
padding: 0.45rem 0.55rem;
max-height: 8rem;
overflow: auto;
font-size: 0.76rem;
}
.canvas {
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
background: #f7fbff;
}
.canvas__toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid #dbe5ec;
background: linear-gradient(180deg, #ffffff 0%, #f3f8fc 100%);
}
.canvas__toolbar button {
padding: 0.35rem 0.6rem;
border: 1px solid #8ca1b3;
border-radius: 6px;
background: #ffffff;
color: #173247;
font: inherit;
cursor: pointer;
}
.canvas__toolbar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.canvas__zoom-readout {
min-width: 6rem;
text-align: center;
font-weight: 600;
color: #21435a;
}
.canvas__placeholder-control {
border-style: dashed;
}
.canvas__surface {
position: relative;
overflow: auto;
background-color: #f7fbff;
background-image:
linear-gradient(to right, #e6edf3 1px, transparent 1px),
linear-gradient(to bottom, #e6edf3 1px, transparent 1px);
background-size: 28px 28px;
}
.canvas__viewport {
position: relative;
width: 2200px;
height: 1500px;
transform-origin: 0 0;
}
.canvas-node {
position: absolute;
user-select: none;
touch-action: none;
}
.canvas-node__chrome {
width: 100%;
min-height: inherit;
border: 1px solid #406078;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f6fbff 100%);
box-shadow: 0 3px 10px rgba(38, 64, 89, 0.18);
}
.canvas-node__content {
width: 100%;
height: 100%;
padding: 0.45rem 0.55rem 0.5rem;
transform-origin: 50% 50%;
}
.canvas-node__ref {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #113148;
text-transform: uppercase;
}
.canvas-node__value {
margin-top: 0.15rem;
margin-bottom: 0.35rem;
font-size: 0.74rem;
color: #405260;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.canvas-node__pins {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.canvas-node__pin-col {
display: flex;
flex-direction: column;
gap: 0.14rem;
min-width: 0;
flex: 1;
}
.canvas-node__pin-col--right {
text-align: right;
}
.canvas-node__pin {
display: grid;
grid-template-columns: auto 1fr;
align-items: baseline;
gap: 0.3rem;
min-width: 0;
}
.canvas-node__pin-col--right .canvas-node__pin {
grid-template-columns: 1fr auto;
}
.canvas-node__pin-number {
font-size: 0.66rem;
color: #6c8090;
white-space: nowrap;
}
.canvas-node__pin-name {
font-size: 0.68rem;
color: #2d475a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-node__lock {
margin-top: 0.35rem;
font-size: 0.64rem;
color: #925d00;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.canvas-node.is-selected .canvas-node__chrome {
border-color: #0a79c2;
box-shadow: 0 0 0 2px rgba(10, 121, 194, 0.25), 0 4px 14px rgba(18, 53, 78, 0.25);
}
.canvas-node.is-dragging .canvas-node__chrome {
cursor: grabbing;
box-shadow: 0 0 0 2px rgba(10, 121, 194, 0.3), 0 8px 18px rgba(12, 42, 66, 0.26);
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.topbar {
align-items: flex-start;
}
.topbar__meta {
align-items: flex-start;
}
.workspace-grid { .workspace-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto minmax(260px, 1fr) auto; grid-template-rows: auto minmax(260px, 1fr) auto;

View File

@ -120,3 +120,46 @@ test("transaction scaffold groups multiple actions into one undo step", () => {
assert.equal(u1.placement.x, null); assert.equal(u1.placement.x, null);
assert.equal(u1.placement.y, null); assert.equal(u1.placement.y, null);
}); });
test("compile lifecycle state transitions are deterministic", () => {
const storeA = createSchemetaStore();
const storeB = createSchemetaStore();
storeA.actions.beginCompile("Compile");
storeA.actions.failCompile("Compile", "Bad payload");
storeA.actions.beginCompile("Analyze");
storeA.actions.completeCompile("Analyze");
storeB.actions.beginCompile("Compile");
storeB.actions.failCompile("Compile", "Bad payload");
storeB.actions.beginCompile("Analyze");
storeB.actions.completeCompile("Analyze");
assert.deepEqual(storeA.getState().lifecycle, storeB.getState().lifecycle);
assert.equal(storeA.getState().lifecycle.isCompiling, false);
assert.equal(storeA.getState().lifecycle.lastError, null);
assert.equal(storeA.getState().lifecycle.lastAction, "Analyze");
});
test("compile lifecycle updates do not alter undo/redo history", () => {
const store = createSchemetaStore();
store.actions.setModel(baseModel());
store.actions.moveComponent("U1", { x: 90, y: 70 });
const beforeUndo = store.getState();
const pastBeforeLifecycle = beforeUndo.history.past.length;
store.actions.beginCompile("Compile");
store.actions.failCompile("Compile", "Synthetic error");
store.actions.beginCompile("Compile");
store.actions.completeCompile("Compile");
assert.equal(store.getState().history.past.length, pastBeforeLifecycle);
assert.equal(store.getState().lifecycle.lastAction, "Compile");
assert.equal(store.getState().lifecycle.lastError, null);
store.actions.undo();
const u1 = store.getState().model.instances.find((x) => x.ref === "U1");
assert.equal(u1.placement.x, null);
assert.equal(u1.placement.y, null);
});