Compare commits

..

10 Commits

24 changed files with 1939 additions and 224 deletions

View File

@ -32,3 +32,15 @@ jobs:
- name: Run tests - name: Run tests
run: npm test run: npm test
- name: Install Playwright browsers
run: npx playwright install chromium
- name: Run browser regression
run: npm run test:ui
- name: Browser artifacts listing (always)
if: always()
run: |
echo "Playwright output:"
ls -R output/playwright || true

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.playwright-cli/
output/playwright/
node_modules/

View File

@ -25,6 +25,7 @@ Open:
Version metadata: Version metadata:
- REST and MCP tool responses include `api_version` and `schema_version`. - REST and MCP tool responses include `api_version` and `schema_version`.
- REST responses also include `request_id` and `x-request-id` for request correlation.
- Current values: `api_version=0.3.0`, `schema_version=1.0.0`. - Current values: `api_version=0.3.0`, `schema_version=1.0.0`.
- Compatibility policy (current): additive, backward-compatible fields may be introduced in the same API minor version. - Compatibility policy (current): additive, backward-compatible fields may be introduced in the same API minor version.
@ -37,9 +38,13 @@ Operational limits:
Docs: Docs:
- `docs/release-checklist.md` - `docs/release-checklist.md`
- `docs/operations-runbook.md` - `docs/operations-runbook.md`
- `docs/quality-gates.md`
- `docs/phase4-execution-plan.md`
- `docs/api-mcp-contracts.md`
CI: CI:
- `.gitea/workflows/ci.yml` runs syntax checks + full test suite on push/PR. - `.gitea/workflows/ci.yml` runs syntax checks + full test suite on push/PR.
- CI also runs browser regression (`npm run test:ui`) after installing Playwright Chromium.
## REST API ## REST API
@ -155,14 +160,36 @@ Tools:
## Workspace behavior highlights ## Workspace behavior highlights
- Fit-to-view default on load/import/apply - Fit-to-view default on load/import/apply
- Focus-selection + reset-view controls for faster navigation in dense schematics
- `Reset Sample` one-click deterministic baseline restore for QA/demo loops
- Space + drag pan, wheel zoom, fit button - Space + drag pan, wheel zoom, fit button
- Net/component/pin selection with dimming + isolate toggles - Net/component/pin selection with dimming + isolate toggles
- Selected panel editors for component properties, full pin properties, full symbol body/pin editing, and net connect/disconnect operations - Selected panel editors for component properties, full pin properties, full symbol body/pin editing, and net connect/disconnect operations
- Click diagnostics to jump/flash focused net/component/pin - Click diagnostics to jump/flash focused net/component/pin
- Auto Layout and Auto Tidy actions - Auto Layout and Auto Tidy actions
- `Shortcuts` helper modal and `Ctrl/Cmd+K` command palette quick actions
- Keyboard shortcuts: - Keyboard shortcuts:
- `Ctrl/Cmd+Z` undo - `Ctrl/Cmd+Z` undo
- `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo - `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo
- `Ctrl/Cmd+K` open command palette
- `Space` rotate selected components (or pan when no selection) - `Space` rotate selected components (or pan when no selection)
- `F` focus current selection
- `Alt+Enter` apply current selection editor (component/pin/net) - `Alt+Enter` apply current selection editor (component/pin/net)
- `Alt+C` connect selected pin to chosen net - `Alt+C` connect selected pin to chosen net
## Browser Regression
Run browser interaction + visual regression checks:
```bash
npx playwright install chromium
npm run test:ui
```
Refresh visual baselines intentionally:
```bash
UPDATE_SNAPSHOTS=1 npm run test:ui
```
Baselines are stored in `tests/baselines/ui/`.

68
docs/api-mcp-contracts.md Normal file
View File

@ -0,0 +1,68 @@
# API + MCP Contract Policy
This document defines compatibility expectations for Schemeta API and MCP tools.
## Version Fields
All successful API/MCP structured responses include:
- `api_version`
- `schema_version`
HTTP API responses also include:
- `request_id` (response field + `x-request-id` header)
## Compatibility Policy
- Additive changes are allowed within the same `api_version` minor release.
- Breaking shape changes require a new API major version.
- `schema_version` increments when SJM schema semantics or required fields change.
## HTTP API Contracts
Stable endpoints:
- `POST /compile`
- `POST /analyze`
- `POST /layout/auto`
- `POST /layout/tidy`
- `GET /health`
- `GET /mcp/ui-bundle`
Error envelope shape:
```json
{
"ok": false,
"request_id": "uuid-or-forwarded-id",
"error": {
"code": "stable_machine_code",
"message": "human-readable description"
}
}
```
## MCP Tool Contracts
Stable tool names:
- `schemeta_compile`
- `schemeta_analyze`
- `schemeta_ui_bundle`
Contract rules:
- Tool names are stable across minor versions.
- Input schemas may gain optional fields additively.
- Structured response fields are additive-only within a minor.
## Persistence and Restore
UI workspace persistence is expected to support:
- deterministic JSON export/import roundtrip
- local snapshot restore path (`schemeta:snapshots:v2`)
- reset-to-sample baseline recovery for QA loops
These are validated in release checklist + browser regression flow.

View File

@ -35,6 +35,18 @@ This runbook covers baseline production operation for Schemeta API + UI.
- `GET /mcp/ui-bundle` - `GET /mcp/ui-bundle`
- Metadata for MCP UI embedding. - Metadata for MCP UI embedding.
## Request Correlation and Audit Logs
- Every response includes `x-request-id`.
- API envelopes include `request_id` for correlation in clients and logs.
- Server emits one JSON audit log entry per request on response finish with:
- `request_id`
- `method`
- `path`
- `status`
- `duration_ms`
- `client`
## Production Checks ## Production Checks
1. Verify process liveness: 1. Verify process liveness:
@ -78,6 +90,6 @@ This runbook covers baseline production operation for Schemeta API + UI.
## Observability Recommendations ## Observability Recommendations
- Add structured request logs at reverse proxy layer. - Structured request logs are emitted by the app; keep proxy logs for edge-level traces.
- Track latency percentiles for `/compile` and `/analyze`. - Track latency percentiles for `/compile` and `/analyze`.
- Track per-endpoint status code rates and top warning/error IDs. - Track per-endpoint status code rates and top warning/error IDs.

View File

@ -0,0 +1,54 @@
# Phase 4 Execution Plan
Phase 4 milestone: `#3` - AAA SaaS Productization and UX Polish.
## Objectives
- Move Schemeta from prototype ergonomics to production-grade UX/UI quality.
- Enforce measurable quality gates for beta and GA releases.
- Sequence high-risk engine work (layout/routing) behind stable UX foundations.
## Workstream Order
1. `#23` AAA-00: Plan and quality gates
2. `#15` AAA-01: Visual system redesign
3. `#16` AAA-02: Fit-to-view and viewport behavior
4. `#17` AAA-03: Layout engine v3
5. `#18` AAA-04: Routing engine v3
6. `#19` AAA-05: In-canvas editing overhaul
7. `#20` AAA-06: Diagnostics UX 2.0
8. `#21` AAA-07: Accessibility/responsive/keyboard pass
9. `#22` AAA-08: SaaS + MCP production hardening
## Dependency Graph
- `#15` is prerequisite for high-fidelity baseline updates and consistent UI controls.
- `#16` depends on `#15` token/layout primitives.
- `#17` depends on `#16` viewport/fit behavior to evaluate perceived quality.
- `#18` depends on `#17` placement decisions and semantic lanes.
- `#19` depends on `#15` and should begin in parallel with late `#17` work where safe.
- `#20` depends on `#19` interaction model for click-to-fix workflows.
- `#21` runs continuously but final pass must occur after `#15/#19/#20`.
- `#22` can run in parallel, but final contract freeze occurs after `#19/#20`.
## Critical Path
`#15 -> #16 -> #17 -> #18 -> #19 -> #20 -> #21 -> #22`
## Risk Register
1. Layout/routing regressions on dense circuits.
- Mitigation: expand fixture corpus, snapshot + metric thresholds, staged rollout.
2. Visual redesign causing interaction regressions.
- Mitigation: preserve interaction test suite, baseline review checklist.
3. Editor complexity inflation.
- Mitigation: keep single source of truth for selection/edit state, strict undo invariants.
4. API/MCP compatibility drift.
- Mitigation: contract tests and explicit schema/api version policy enforcement.
## Definition of Done (Phase 4)
- All issues `#15-#23` closed with acceptance criteria verified.
- Release checklist passes all beta and GA quality gates.
- Browser regression and baseline review pass on representative fixtures (including dense analog).
- API/MCP contract tests pass with versioned compatibility notes.

53
docs/quality-gates.md Normal file
View File

@ -0,0 +1,53 @@
# Quality Gates
This document defines measurable release gates for Schemeta.
## Release Stages
- Beta: feature complete, controlled rollout quality.
- GA: production-grade quality, accessibility, and operational readiness.
## Beta Gates
1. Functional correctness
- `npm test` passes.
- `npm run test:ui` passes.
2. Visual regression
- No unexpected screenshot diffs in `tests/baselines/ui`.
- Dense analog fixture remains under threshold:
- crossings = `0`
- overlaps = `0`
- detour <= `2.5`
3. Interaction reliability
- Selection/deselection/isolate/reset flow verified.
- Undo/redo parity verified for component, pin, net, and symbol edits.
4. API/MCP compatibility
- `api_version`/`schema_version` values present and documented.
- Compile/analyze envelopes remain backward compatible (additive policy).
## GA Gates
1. Accessibility
- Core workflows keyboard-operable.
- WCAG AA contrast on primary controls/text.
2. Responsive UX
- Usable at 1280x720 and 1440x900 without clipping critical controls.
3. Schematic readability
- No component-wire overlaps from auto layout/routing.
- Label overlap count near-zero on benchmark fixtures.
4. Operational readiness
- Rate limiting + auth + health checks validated.
- Error telemetry includes structured codes and context.
5. Data durability
- Save/load/restore and JSON roundtrip validated.
- MCP UI bundle + tool endpoints verified end-to-end.
## CI Mapping
- Always required:
- syntax checks
- `npm test`
- `npm run test:ui`
- Release candidate required:
- checklist completion in `docs/release-checklist.md`
- intentional baseline updates reviewed and approved

View File

@ -1,6 +1,9 @@
# Schemeta Release Checklist # Schemeta Release Checklist
Use this checklist before cutting a release tag. Use this checklist before cutting a release tag.
Reference docs:
- `docs/quality-gates.md`
- `docs/phase4-execution-plan.md`
## Pre-merge ## Pre-merge
@ -11,6 +14,8 @@ Use this checklist before cutting a release tag.
## Validation Gates ## Validation Gates
- [ ] `npm test` passes. - [ ] `npm test` passes.
- [ ] `npm run test:ui` passes.
- [ ] Beta quality gates in `docs/quality-gates.md` are met.
- [ ] Core smoke flow tested in UI: - [ ] Core smoke flow tested in UI:
- [ ] Load sample - [ ] Load sample
- [ ] Edit component/pin/net/symbol - [ ] Edit component/pin/net/symbol
@ -22,8 +27,10 @@ Use this checklist before cutting a release tag.
## Visual Quality ## Visual Quality
- [ ] Representative circuits reviewed for routing readability. - [ ] Representative circuits reviewed for routing readability.
- [ ] Visual baselines updated intentionally (`tests/baselines/ui`) and screenshot diff checks pass.
- [ ] Labels remain legible at common zoom levels. - [ ] Labels remain legible at common zoom levels.
- [ ] No major overlap/crossing regressions vs previous release baseline. - [ ] No major overlap/crossing regressions vs previous release baseline.
- [ ] Dense analog fixture meets gate thresholds (`crossings=0`, `overlaps=0`, detour target).
## Security / Operations ## Security / Operations
@ -35,6 +42,7 @@ Use this checklist before cutting a release tag.
- [ ] `CORS_ORIGIN` - [ ] `CORS_ORIGIN`
- [ ] Rate limiting behavior manually validated. - [ ] Rate limiting behavior manually validated.
- [ ] Health endpoint checked in target environment. - [ ] Health endpoint checked in target environment.
- [ ] Structured error telemetry checked for compile/analyze failures.
## Release Artifacts ## Release Artifacts
@ -42,3 +50,4 @@ Use this checklist before cutting a release tag.
- [ ] `api_version` and `schema_version` changes reviewed/documented. - [ ] `api_version` and `schema_version` changes reviewed/documented.
- [ ] Changelog/release notes generated with notable UX/compat changes. - [ ] Changelog/release notes generated with notable UX/compat changes.
- [ ] Tag pushed and release announcement links to milestone/issues. - [ ] Tag pushed and release announcement links to milestone/issues.
- [ ] GA gates in `docs/quality-gates.md` confirmed complete.

151
examples/dense-analog.json Normal file
View File

@ -0,0 +1,151 @@
{
"meta": {
"title": "Electret mic + NPN preamp -> ESP32 ADC (dense analog)",
"version": "1.0"
},
"symbols": {
"electret_mic_2pin": {
"symbol_id": "electret_mic_2pin",
"category": "analog",
"body": { "width": 90, "height": 56 },
"pins": [
{ "name": "MIC+", "number": "1", "side": "right", "offset": 20, "type": "analog" },
{ "name": "MIC-", "number": "2", "side": "right", "offset": 38, "type": "ground" }
]
},
"npn_bjt_generic": {
"symbol_id": "npn_bjt_generic",
"category": "analog",
"body": { "width": 92, "height": 74 },
"pins": [
{ "name": "B", "number": "1", "side": "left", "offset": 37, "type": "analog" },
{ "name": "C", "number": "2", "side": "top", "offset": 46, "type": "analog" },
{ "name": "E", "number": "3", "side": "bottom", "offset": 46, "type": "analog" }
]
},
"mcu_adc_1pin": {
"symbol_id": "mcu_adc_1pin",
"category": "generic",
"body": { "width": 126, "height": 64 },
"pins": [
{ "name": "ADC_IN", "number": "1", "side": "left", "offset": 32, "type": "analog" },
{ "name": "3V3", "number": "2", "side": "top", "offset": 32, "type": "power_in" },
{ "name": "GND", "number": "3", "side": "bottom", "offset": 32, "type": "ground" }
]
}
},
"instances": [
{ "ref": "V1", "part": "connector", "properties": { "value": "3.3V/GND source" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "M1", "symbol": "electret_mic_2pin", "properties": { "value": "Electret mic" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "Q1", "symbol": "npn_bjt_generic", "properties": { "value": "2N3904" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "R1", "part": "resistor", "properties": { "value": "4.7k", "role": "mic bias" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C1", "part": "capacitor", "properties": { "value": "1uF", "role": "mic AC coupling" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "R2", "part": "resistor", "properties": { "value": "220k", "role": "base bias top" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "R3", "part": "resistor", "properties": { "value": "100k", "role": "base bias bottom" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "RC", "part": "resistor", "properties": { "value": "47k", "role": "collector resistor" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "RE", "part": "resistor", "properties": { "value": "1k", "role": "emitter resistor" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C2", "part": "capacitor", "properties": { "value": "1uF", "role": "output AC coupling" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "R4", "part": "resistor", "properties": { "value": "100k", "role": "ADC mid-rail top" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "R5", "part": "resistor", "properties": { "value": "100k", "role": "ADC mid-rail bottom" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C3", "part": "capacitor", "properties": { "value": "100nF", "role": "ADC mid-rail filter" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C4", "part": "capacitor", "properties": { "value": "100nF", "role": "local decoupling" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U1", "symbol": "mcu_adc_1pin", "properties": { "value": "ESP32-S3 (ADC pin)" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
],
"nets": [
{
"name": "3V3",
"class": "power",
"nodes": [
{ "ref": "V1", "pin": "3V3" },
{ "ref": "R1", "pin": "1" },
{ "ref": "R2", "pin": "1" },
{ "ref": "RC", "pin": "1" },
{ "ref": "R4", "pin": "1" },
{ "ref": "C4", "pin": "1" },
{ "ref": "U1", "pin": "3V3" }
]
},
{
"name": "GND",
"class": "ground",
"nodes": [
{ "ref": "V1", "pin": "GND" },
{ "ref": "M1", "pin": "MIC-" },
{ "ref": "R3", "pin": "2" },
{ "ref": "RE", "pin": "2" },
{ "ref": "R5", "pin": "2" },
{ "ref": "C3", "pin": "2" },
{ "ref": "C4", "pin": "2" },
{ "ref": "U1", "pin": "GND" }
]
},
{
"name": "MIC_BIAS",
"class": "analog",
"nodes": [
{ "ref": "R1", "pin": "2" },
{ "ref": "M1", "pin": "MIC+" },
{ "ref": "C1", "pin": "1" }
]
},
{
"name": "BASE_NODE",
"class": "analog",
"nodes": [
{ "ref": "C1", "pin": "2" },
{ "ref": "R2", "pin": "2" },
{ "ref": "R3", "pin": "1" },
{ "ref": "Q1", "pin": "B" }
]
},
{
"name": "COLLECTOR_NODE",
"class": "analog",
"nodes": [
{ "ref": "Q1", "pin": "C" },
{ "ref": "RC", "pin": "2" },
{ "ref": "C2", "pin": "1" }
]
},
{
"name": "EMITTER_NODE",
"class": "analog",
"nodes": [
{ "ref": "Q1", "pin": "E" },
{ "ref": "RE", "pin": "1" }
]
},
{
"name": "ADC_MID",
"class": "analog",
"nodes": [
{ "ref": "R4", "pin": "2" },
{ "ref": "R5", "pin": "1" },
{ "ref": "C3", "pin": "1" },
{ "ref": "C2", "pin": "2" },
{ "ref": "U1", "pin": "ADC_IN" }
]
}
],
"constraints": {
"groups": [
{
"name": "MicFrontEndCluster",
"members": ["M1", "R1", "C1", "Q1", "R2", "R3", "RC", "RE", "C4"],
"layout": "cluster"
},
{
"name": "ADCBiasCluster",
"members": ["R4", "R5", "C3", "C2", "U1"],
"layout": "cluster"
}
],
"near": [
{ "component": "C4", "target_pin": { "ref": "Q1", "pin": "C" } },
{ "component": "C3", "target_pin": { "ref": "U1", "pin": "ADC_IN" } }
]
},
"annotations": [
{ "text": "Dense analog frontend + ADC bias test case." }
]
}

View File

@ -7,6 +7,10 @@ const PIN_SIDES = ["left", "right", "top", "bottom"];
const PIN_TYPES = ["power_in", "power_out", "input", "output", "bidirectional", "passive", "analog", "ground"]; const PIN_TYPES = ["power_in", "power_out", "input", "output", "bidirectional", "passive", "analog", "ground"];
const LIST_ROW_HEIGHT = 36; const LIST_ROW_HEIGHT = 36;
const LIST_OVERSCAN_ROWS = 8; const LIST_OVERSCAN_ROWS = 8;
const MIN_SCALE = 0.2;
const MAX_SCALE = 5;
const FIT_MARGIN = 56;
const FOCUS_MARGIN = 96;
const state = { const state = {
model: null, model: null,
@ -41,7 +45,8 @@ const state = {
historyFuture: [], historyFuture: [],
historyLimit: 80, historyLimit: 80,
historyRestoring: false, historyRestoring: false,
symbolMigrationAckHash: null symbolMigrationAckHash: null,
commandIndex: 0
}; };
const el = { const el = {
@ -49,6 +54,12 @@ const el = {
netList: document.getElementById("netList"), netList: document.getElementById("netList"),
instanceFilter: document.getElementById("instanceFilter"), instanceFilter: document.getElementById("instanceFilter"),
netFilter: document.getElementById("netFilter"), netFilter: document.getElementById("netFilter"),
newComponentRefInput: document.getElementById("newComponentRefInput"),
newComponentTypeSelect: document.getElementById("newComponentTypeSelect"),
addComponentBtn: document.getElementById("addComponentBtn"),
newQuickNetNameInput: document.getElementById("newQuickNetNameInput"),
newQuickNetClassSelect: document.getElementById("newQuickNetClassSelect"),
addQuickNetBtn: document.getElementById("addQuickNetBtn"),
canvasViewport: document.getElementById("canvasViewport"), canvasViewport: document.getElementById("canvasViewport"),
canvasInner: document.getElementById("canvasInner"), canvasInner: document.getElementById("canvasInner"),
selectionBox: document.getElementById("selectionBox"), selectionBox: document.getElementById("selectionBox"),
@ -111,6 +122,7 @@ const el = {
jsonEditor: document.getElementById("jsonEditor"), jsonEditor: document.getElementById("jsonEditor"),
jsonFeedback: document.getElementById("jsonFeedback"), jsonFeedback: document.getElementById("jsonFeedback"),
loadSampleBtn: document.getElementById("loadSampleBtn"), loadSampleBtn: document.getElementById("loadSampleBtn"),
resetSampleBtn: document.getElementById("resetSampleBtn"),
newProjectBtn: document.getElementById("newProjectBtn"), newProjectBtn: document.getElementById("newProjectBtn"),
importBtn: document.getElementById("importBtn"), importBtn: document.getElementById("importBtn"),
exportBtn: document.getElementById("exportBtn"), exportBtn: document.getElementById("exportBtn"),
@ -119,6 +131,7 @@ const el = {
zoomOutBtn: document.getElementById("zoomOutBtn"), zoomOutBtn: document.getElementById("zoomOutBtn"),
zoomResetBtn: document.getElementById("zoomResetBtn"), zoomResetBtn: document.getElementById("zoomResetBtn"),
fitViewBtn: document.getElementById("fitViewBtn"), fitViewBtn: document.getElementById("fitViewBtn"),
focusSelectionBtn: document.getElementById("focusSelectionBtn"),
showLabelsInput: document.getElementById("showLabelsInput"), showLabelsInput: document.getElementById("showLabelsInput"),
applyJsonBtn: document.getElementById("applyJsonBtn"), applyJsonBtn: document.getElementById("applyJsonBtn"),
showSchemaBtn: document.getElementById("showSchemaBtn"), showSchemaBtn: document.getElementById("showSchemaBtn"),
@ -128,6 +141,7 @@ const el = {
copyReproBtn: document.getElementById("copyReproBtn"), copyReproBtn: document.getElementById("copyReproBtn"),
autoLayoutBtn: document.getElementById("autoLayoutBtn"), autoLayoutBtn: document.getElementById("autoLayoutBtn"),
autoTidyBtn: document.getElementById("autoTidyBtn"), autoTidyBtn: document.getElementById("autoTidyBtn"),
shortcutsBtn: document.getElementById("shortcutsBtn"),
undoBtn: document.getElementById("undoBtn"), undoBtn: document.getElementById("undoBtn"),
redoBtn: document.getElementById("redoBtn"), redoBtn: document.getElementById("redoBtn"),
renderModeSelect: document.getElementById("renderModeSelect"), renderModeSelect: document.getElementById("renderModeSelect"),
@ -138,7 +152,13 @@ const el = {
schemaViewer: document.getElementById("schemaViewer"), schemaViewer: document.getElementById("schemaViewer"),
closeSchemaBtn: document.getElementById("closeSchemaBtn"), closeSchemaBtn: document.getElementById("closeSchemaBtn"),
copySchemaBtn: document.getElementById("copySchemaBtn"), copySchemaBtn: document.getElementById("copySchemaBtn"),
downloadSchemaBtn: document.getElementById("downloadSchemaBtn") downloadSchemaBtn: document.getElementById("downloadSchemaBtn"),
shortcutsModal: document.getElementById("shortcutsModal"),
closeShortcutsBtn: document.getElementById("closeShortcutsBtn"),
commandModal: document.getElementById("commandModal"),
closeCommandBtn: document.getElementById("closeCommandBtn"),
commandInput: document.getElementById("commandInput"),
commandList: document.getElementById("commandList")
}; };
function toGrid(v) { function toGrid(v) {
@ -289,6 +309,17 @@ function nextRefLike(baseRef) {
return candidate; return candidate;
} }
function defaultRefSeedForPart(partName) {
const part = String(partName ?? "").toLowerCase();
if (part === "resistor") return "R1";
if (part === "capacitor") return "C1";
if (part === "inductor") return "L1";
if (part === "diode" || part === "led") return "D1";
if (part === "connector") return "J1";
if (part === "generic") return "X1";
return "U1";
}
function escHtml(text) { function escHtml(text) {
return String(text ?? "") return String(text ?? "")
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@ -317,6 +348,11 @@ function setStatus(text, ok = true) {
el.compileStatus.className = ok ? "status-ok" : ""; el.compileStatus.className = ok ? "status-ok" : "";
} }
function formatCompileStatus(result) {
const m = result?.layout_metrics ?? {};
return `Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings ?? 0} crossings, ${m.overlap_edges ?? 0} overlaps, ${m.total_bends ?? 0} bends, ${m.label_tie_routes ?? 0} tie-nets, ${(m.detour_ratio ?? 1).toFixed(2)}x detour)`;
}
function defaultProject() { function defaultProject() {
return { return {
meta: { title: "Untitled Schemeta Project" }, meta: { title: "Untitled Schemeta Project" },
@ -416,11 +452,11 @@ function updateTransform() {
} }
} }
function fitView(layout) { function layoutBounds(layout, margin = FIT_MARGIN) {
const w = layout?.width ?? 0; const w = layout?.width ?? 0;
const h = layout?.height ?? 0; const h = layout?.height ?? 0;
if (!w || !h) { if (!w || !h) {
return; return null;
} }
let minX = Number.POSITIVE_INFINITY; let minX = Number.POSITIVE_INFINITY;
@ -450,37 +486,104 @@ function fitView(layout) {
maxY = h; maxY = h;
} }
const pad = 80; return {
const bbox = { x: Math.max(0, minX - margin),
x: Math.max(0, minX - pad), y: Math.max(0, minY - margin),
y: Math.max(0, minY - pad), w: Math.max(1, Math.min(w, maxX - minX + margin * 2)),
w: Math.min(w, maxX - minX + pad * 2), h: Math.max(1, Math.min(h, maxY - minY + margin * 2))
h: Math.min(h, maxY - minY + pad * 2)
}; };
}
function centerOnBBox(bbox, fillRatio = 0.93, stickyAdjusted = false) {
if (!bbox) {
return false;
}
const viewport = el.canvasViewport.getBoundingClientRect(); const viewport = el.canvasViewport.getBoundingClientRect();
const sx = (viewport.width * 0.98) / Math.max(1, bbox.w); if (!viewport.width || !viewport.height) {
const sy = (viewport.height * 0.98) / Math.max(1, bbox.h); return false;
state.scale = Math.max(0.2, Math.min(4, Math.min(sx, sy))); }
const sx = (viewport.width * fillRatio) / Math.max(1, bbox.w);
const sy = (viewport.height * fillRatio) / Math.max(1, bbox.h);
state.scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, Math.min(sx, sy)));
state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale; state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale;
state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale; state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale;
state.userAdjustedView = false; state.userAdjustedView = stickyAdjusted;
updateTransform(); updateTransform();
return true;
}
function fitView(layout) {
const bbox = layoutBounds(layout, FIT_MARGIN);
if (!bbox) {
return;
}
centerOnBBox(bbox, 0.93, false);
}
function refsBBox(refs, margin = FOCUS_MARGIN) {
if (!refs?.size || !state.compile?.layout || !state.model) {
return null;
}
const placed = new Map((state.compile.layout.placed ?? []).map((p) => [p.ref, p]));
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const ref of refs) {
const inst = instanceByRef(ref);
const p = placed.get(ref);
const sym = inst ? state.model.symbols?.[inst.symbol] : null;
if (!p || !sym?.body) {
continue;
}
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x + sym.body.width);
maxY = Math.max(maxY, p.y + sym.body.height);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
return null;
}
const w = state.compile.layout.width ?? maxX;
const h = state.compile.layout.height ?? maxY;
return {
x: Math.max(0, minX - margin),
y: Math.max(0, minY - margin),
w: Math.max(1, Math.min(w, maxX - minX + margin * 2)),
h: Math.max(1, Math.min(h, maxY - minY + margin * 2))
};
}
function selectedFocusBBox() {
if (!state.model || !state.compile?.layout) {
return null;
}
if (state.selectedPin) {
return refsBBox(new Set([state.selectedPin.ref]), FOCUS_MARGIN);
}
if (state.selectedRefs.length) {
return refsBBox(new Set(state.selectedRefs), FOCUS_MARGIN);
}
if (state.selectedNet) {
const refs = refsConnectedToNet(state.selectedNet);
return refsBBox(refs, FOCUS_MARGIN);
}
return layoutBounds(state.compile.layout, FIT_MARGIN);
}
function focusSelection() {
const bbox = selectedFocusBBox();
if (!bbox) {
return false;
}
return centerOnBBox(bbox, 0.88, true);
} }
function zoomToBBox(bbox) { function zoomToBBox(bbox) {
if (!bbox) { if (!bbox) {
return; return;
} }
centerOnBBox(bbox, 0.8, true);
const viewport = el.canvasViewport.getBoundingClientRect();
const scaleX = (viewport.width * 0.75) / Math.max(1, bbox.w);
const scaleY = (viewport.height * 0.75) / Math.max(1, bbox.h);
state.scale = Math.max(0.3, Math.min(4, Math.min(scaleX, scaleY)));
state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale;
state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale;
state.userAdjustedView = true;
updateTransform();
} }
function canvasToSvgPoint(clientX, clientY) { function canvasToSvgPoint(clientX, clientY) {
@ -1227,6 +1330,96 @@ function renderSelected() {
el.netEditor.classList.add("hidden"); el.netEditor.classList.add("hidden");
} }
function issueById(issueId) {
const issues = [...(state.compile?.errors ?? []), ...(state.compile?.warnings ?? [])];
return issues.find((i) => i.id === issueId) ?? null;
}
function parseRefPinFromIssueMessage(issue) {
const m = /'([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z0-9_]+)'/.exec(String(issue?.message ?? ""));
if (m) {
return { ref: m[1], pin: m[2] };
}
return null;
}
function ensureNet(name, netClass = "signal") {
if (!state.model) {
return null;
}
let net = netByName(name);
if (!net) {
net = { name, class: netClass, nodes: [] };
state.model.nets.push(net);
}
return net;
}
function issueFixAction(issue) {
if (!issue?.code) {
return null;
}
if (issue.code === "ground_net_missing") {
return { label: "Create GND Net", action: "create_ground" };
}
if (issue.code === "floating_input") {
const rp = parseRefPinFromIssueMessage(issue);
if (rp) {
return { label: "Create Signal Net", action: "connect_signal", ref: rp.ref, pin: rp.pin };
}
}
if (issue.code === "required_power_unconnected") {
const rp = parseRefPinFromIssueMessage(issue);
if (rp) {
return { label: "Connect Power", action: "connect_power", ref: rp.ref, pin: rp.pin };
}
}
return null;
}
async function applyIssueFix(issueId) {
if (!state.model) {
return;
}
const issue = issueById(issueId);
const fix = issueFixAction(issue);
if (!issue || !fix) {
el.jsonFeedback.textContent = "No automatic fix available for this issue.";
return;
}
pushHistory("issue-fix");
let applied = false;
if (fix.action === "create_ground") {
ensureNet("GND", "ground");
applied = true;
} else if (fix.action === "connect_signal") {
const name = normalizeNetName(`NET_${fix.ref}_${fix.pin}`);
applied = connectPinToNet(fix.ref, fix.pin, name || nextAutoNetName(), { netClass: "signal" }).ok;
} else if (fix.action === "connect_power") {
const upperPin = String(fix.pin).toUpperCase();
if (upperPin.includes("GND")) {
applied = connectPinToNet(fix.ref, fix.pin, "GND", { netClass: "ground" }).ok;
} else if (netByName("3V3")) {
applied = connectPinToNet(fix.ref, fix.pin, "3V3", { netClass: "power" }).ok;
} else if (netByName("5V")) {
applied = connectPinToNet(fix.ref, fix.pin, "5V", { netClass: "power" }).ok;
} else {
applied = connectPinToNet(fix.ref, fix.pin, normalizeNetName(`PWR_${fix.ref}_${fix.pin}`), { netClass: "power" }).ok;
}
}
if (!applied) {
el.jsonFeedback.textContent = "Automatic fix could not be applied safely.";
return;
}
await compileModel(state.model, { keepView: true, source: "issue-fix" });
el.jsonFeedback.textContent = `Applied fix for ${issue.code}.`;
focusIssue(issueId);
}
function renderIssues() { function renderIssues() {
const errors = state.compile?.errors ?? []; const errors = state.compile?.errors ?? [];
const warnings = state.compile?.warnings ?? []; const warnings = state.compile?.warnings ?? [];
@ -1238,12 +1431,16 @@ function renderIssues() {
const rows = [ const rows = [
...errors.map( ...errors.map(
(issue) => (issue) => {
`<div class="issueRow issueErr" data-issue-id="${issue.id}"><div class="issueTitle">[E] ${issue.message}</div><div class="issueMeta">${issue.code} · ${issue.path ?? "-"}</div><div class="issueMeta">${issue.suggestion ?? ""}</div></div>` const fix = issueFixAction(issue);
return `<div class="issueRow issueErr" data-issue-id="${issue.id}"><div class="issueTitle">[E] ${issue.message}</div><div class="issueMeta">${issue.code} · ${issue.path ?? "-"}</div><div class="issueMeta">${issue.suggestion ?? ""}</div>${fix ? `<div class="issueActions"><button type="button" data-fix-issue="${issue.id}">${fix.label}</button></div>` : ""}</div>`;
}
), ),
...warnings.map( ...warnings.map(
(issue) => (issue) => {
`<div class="issueRow issueWarn" data-issue-id="${issue.id}"><div class="issueTitle">[W] ${issue.message}</div><div class="issueMeta">${issue.code} · ${issue.path ?? "-"}</div><div class="issueMeta">${issue.suggestion ?? ""}</div></div>` const fix = issueFixAction(issue);
return `<div class="issueRow issueWarn" data-issue-id="${issue.id}"><div class="issueTitle">[W] ${issue.message}</div><div class="issueMeta">${issue.code} · ${issue.path ?? "-"}</div><div class="issueMeta">${issue.suggestion ?? ""}</div>${fix ? `<div class="issueActions"><button type="button" data-fix-issue="${issue.id}">${fix.label}</button></div>` : ""}</div>`;
}
) )
]; ];
@ -1631,10 +1828,7 @@ async function compileModel(model, opts = {}) {
fitView(result.layout); fitView(result.layout);
} }
const m = result.layout_metrics; setStatus(formatCompileStatus(result));
setStatus(
`Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings} crossings, ${m.overlap_edges} overlaps, ${m.total_bends ?? 0} bends, ${m.label_tie_routes ?? 0} tie-nets, ${(m.detour_ratio ?? 1).toFixed(2)}x detour)`
);
} catch (err) { } catch (err) {
setStatus(`Compile failed: ${err.message}`, false); setStatus(`Compile failed: ${err.message}`, false);
el.issues.textContent = `Compile error: ${err.message}`; el.issues.textContent = `Compile error: ${err.message}`;
@ -1953,6 +2147,77 @@ function closeSchemaModal() {
el.schemaModal.classList.add("hidden"); el.schemaModal.classList.add("hidden");
} }
function openShortcutsModal() {
el.shortcutsModal?.classList.remove("hidden");
el.closeShortcutsBtn?.focus();
}
function closeShortcutsModal() {
el.shortcutsModal?.classList.add("hidden");
}
function commandEntries() {
return [
{ id: "auto-layout", label: "Run Auto Layout", run: () => el.autoLayoutBtn.click() },
{ id: "auto-tidy", label: "Run Auto Tidy", run: () => el.autoTidyBtn.click() },
{ id: "fit-view", label: "Fit View", run: () => el.fitViewBtn.click() },
{ id: "focus-selection", label: "Focus Selection", run: () => el.focusSelectionBtn.click() },
{ id: "toggle-labels", label: "Toggle Net Labels", run: () => el.showLabelsInput.click() },
{ id: "reset-sample", label: "Reset Sample", run: () => el.resetSampleBtn.click() },
{ id: "load-sample", label: "Load Sample", run: () => el.loadSampleBtn.click() },
{ id: "new-project", label: "New Project", run: () => el.newProjectBtn.click() },
{ id: "show-shortcuts", label: "Show Keyboard Shortcuts", run: () => openShortcutsModal() }
];
}
function filteredCommands(query) {
const q = String(query ?? "")
.trim()
.toLowerCase();
const cmds = commandEntries();
if (!q) {
return cmds;
}
return cmds.filter((c) => c.label.toLowerCase().includes(q) || c.id.includes(q.replace(/\s+/g, "-")));
}
function renderCommandList() {
const cmds = filteredCommands(el.commandInput?.value ?? "");
state.commandIndex = Math.max(0, Math.min(state.commandIndex, Math.max(0, cmds.length - 1)));
if (!cmds.length) {
el.commandList.innerHTML = `<div class="miniRow"><span>No commands.</span></div>`;
return cmds;
}
el.commandList.innerHTML = cmds
.map(
(cmd, idx) =>
`<button type="button" role="option" class="commandRow ${idx === state.commandIndex ? "active" : ""}" data-command-id="${cmd.id}">${escHtml(cmd.label)}</button>`
)
.join("");
return cmds;
}
function openCommandModal() {
state.commandIndex = 0;
el.commandModal?.classList.remove("hidden");
renderCommandList();
el.commandInput?.focus();
el.commandInput?.select();
}
function closeCommandModal() {
el.commandModal?.classList.add("hidden");
}
function runCommandById(id) {
const cmd = commandEntries().find((c) => c.id === id);
if (!cmd) {
return;
}
closeCommandModal();
cmd.run();
}
function buildMinimalRepro(model) { function buildMinimalRepro(model) {
if (!state.selectedRefs.length && !state.selectedNet) { if (!state.selectedRefs.length && !state.selectedNet) {
return model; return model;
@ -2119,29 +2384,56 @@ async function runLayoutAction(path) {
renderAll(); renderAll();
fitView(out.compile.layout); fitView(out.compile.layout);
saveSnapshot(); saveSnapshot();
setStatus( setStatus(formatCompileStatus(out.compile));
`Compiled (${out.compile.errors.length}E, ${out.compile.warnings.length}W | ${out.compile.layout_metrics.crossings} crossings, ${out.compile.layout_metrics.overlap_edges} overlaps, ${out.compile.layout_metrics.total_bends ?? 0} bends, ${out.compile.layout_metrics.label_tie_routes ?? 0} tie-nets)`
);
} catch (err) { } catch (err) {
setStatus(`Layout action failed: ${err.message}`, false); setStatus(`Layout action failed: ${err.message}`, false);
} }
} }
async function loadSample() { async function fetchSampleModel() {
const res = await fetch("/sample.schemeta.json"); const res = await fetch("/sample.schemeta.json");
if (!res.ok) { if (!res.ok) {
setStatus("Sample missing.", false); throw new Error("Sample missing.");
return;
} }
return res.json();
}
const model = await res.json(); async function resetToSample(opts = {}) {
if (state.model) { const push = opts.pushHistory !== false;
pushHistory("load-sample"); const before = state.model ? clone(state.model) : null;
const model = await fetchSampleModel();
if (push && state.model) {
pushHistory("reset-sample");
} }
setSelectedRefs([]); setSelectedRefs([]);
state.selectedNet = null; state.selectedNet = null;
state.selectedPin = null; state.selectedPin = null;
state.isolateNet = false;
state.isolateComponent = false;
state.userAdjustedView = false;
state.renderMode = "schematic_stub";
el.renderModeSelect.value = "schematic_stub";
state.showLabels = true;
el.showLabelsInput.checked = true;
el.instanceFilter.value = "";
el.netFilter.value = "";
el.instanceList.scrollTop = 0;
el.netList.scrollTop = 0;
closeSchemaModal();
await compileModel(model, { fit: true }); await compileModel(model, { fit: true });
if (before) {
el.jsonFeedback.textContent = `Reset sample. ${summarizeModelDelta(before, state.model)}`;
} else {
el.jsonFeedback.textContent = "Reset sample baseline loaded.";
}
}
async function loadSample() {
try {
await resetToSample({ pushHistory: true });
} catch (err) {
setStatus(String(err?.message ?? "Sample missing."), false);
}
} }
function setupEvents() { function setupEvents() {
@ -2155,6 +2447,65 @@ function setupEvents() {
}); });
el.instanceList.addEventListener("scroll", renderInstances, { passive: true }); el.instanceList.addEventListener("scroll", renderInstances, { passive: true });
el.netList.addEventListener("scroll", renderNets, { passive: true }); el.netList.addEventListener("scroll", renderNets, { passive: true });
if (el.newComponentRefInput && el.newComponentTypeSelect) {
const syncRefPlaceholder = () => {
el.newComponentRefInput.placeholder = defaultRefSeedForPart(el.newComponentTypeSelect.value);
};
syncRefPlaceholder();
el.newComponentTypeSelect.addEventListener("change", syncRefPlaceholder);
}
el.addComponentBtn?.addEventListener("click", async () => {
if (!state.model) {
return;
}
const part = String(el.newComponentTypeSelect?.value ?? "generic").toLowerCase();
const rawRef = normalizeRef(el.newComponentRefInput?.value ?? "");
const ref = rawRef || nextRefLike(defaultRefSeedForPart(part));
if (instanceByRef(ref)) {
el.jsonFeedback.textContent = `Component '${ref}' already exists.`;
return;
}
pushHistory("add-component");
state.model.instances.push({
ref,
part,
properties: {},
placement: { x: null, y: null, rotation: 0, locked: false }
});
el.newComponentRefInput.value = "";
setSelectedRefs([ref]);
state.selectedNet = null;
state.selectedPin = null;
await compileModel(state.model, { keepView: true, source: "add-component" });
el.jsonFeedback.textContent = `Added component ${ref} (${part}).`;
});
el.addQuickNetBtn?.addEventListener("click", async () => {
if (!state.model) {
return;
}
const rawName = normalizeNetName(el.newQuickNetNameInput?.value ?? "");
const name = rawName || nextAutoNetName();
const netClass = NET_CLASSES.includes(el.newQuickNetClassSelect?.value ?? "") ? el.newQuickNetClassSelect.value : "signal";
if (netByName(name)) {
el.jsonFeedback.textContent = `Net '${name}' already exists.`;
return;
}
pushHistory("add-net");
const nodes = state.selectedPin ? [{ ref: state.selectedPin.ref, pin: state.selectedPin.pin }] : [];
state.model.nets.push({ name, class: netClass, nodes });
el.newQuickNetNameInput.value = "";
state.selectedNet = name;
await compileModel(state.model, { keepView: true, source: "add-net" });
el.jsonFeedback.textContent = nodes.length
? `Added net ${name} (${netClass}) and connected selected pin.`
: `Added net ${name} (${netClass}).`;
});
[el.componentSection, el.symbolSection, el.pinSection, el.netSection].forEach((section) => { [el.componentSection, el.symbolSection, el.pinSection, el.netSection].forEach((section) => {
if (!section) { if (!section) {
return; return;
@ -2216,6 +2567,12 @@ function setupEvents() {
}); });
el.issues.addEventListener("click", (evt) => { el.issues.addEventListener("click", (evt) => {
const fixBtn = evt.target.closest("[data-fix-issue]");
if (fixBtn) {
evt.stopPropagation();
void applyIssueFix(fixBtn.getAttribute("data-fix-issue"));
return;
}
const row = evt.target.closest("[data-issue-id]"); const row = evt.target.closest("[data-issue-id]");
if (!row) { if (!row) {
return; return;
@ -2730,23 +3087,27 @@ function setupEvents() {
}); });
el.zoomInBtn.addEventListener("click", () => { el.zoomInBtn.addEventListener("click", () => {
state.scale = Math.min(4, state.scale + 0.1); state.scale = Math.min(MAX_SCALE, state.scale + 0.1);
state.userAdjustedView = true; state.userAdjustedView = true;
updateTransform(); updateTransform();
}); });
el.zoomOutBtn.addEventListener("click", () => { el.zoomOutBtn.addEventListener("click", () => {
state.scale = Math.max(0.2, state.scale - 0.1); state.scale = Math.max(MIN_SCALE, state.scale - 0.1);
state.userAdjustedView = true; state.userAdjustedView = true;
updateTransform(); updateTransform();
}); });
el.zoomResetBtn.addEventListener("click", () => { el.zoomResetBtn.addEventListener("click", () => {
if (state.compile?.layout) {
fitView(state.compile.layout);
} else {
state.scale = 1; state.scale = 1;
state.panX = 40; state.panX = 40;
state.panY = 40; state.panY = 40;
state.userAdjustedView = true; state.userAdjustedView = false;
updateTransform(); updateTransform();
}
}); });
el.fitViewBtn.addEventListener("click", () => { el.fitViewBtn.addEventListener("click", () => {
@ -2755,6 +3116,10 @@ function setupEvents() {
} }
}); });
el.focusSelectionBtn.addEventListener("click", () => {
focusSelection();
});
el.showLabelsInput.addEventListener("change", () => { el.showLabelsInput.addEventListener("change", () => {
state.showLabels = el.showLabelsInput.checked; state.showLabels = el.showLabelsInput.checked;
setLabelLayerVisibility(); setLabelLayerVisibility();
@ -2782,7 +3147,7 @@ function setupEvents() {
(evt) => { (evt) => {
evt.preventDefault(); evt.preventDefault();
const oldScale = state.scale; const oldScale = state.scale;
state.scale = Math.min(4, Math.max(0.2, state.scale + (evt.deltaY < 0 ? 0.08 : -0.08))); state.scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + (evt.deltaY < 0 ? 0.08 : -0.08)));
const rect = el.canvasViewport.getBoundingClientRect(); const rect = el.canvasViewport.getBoundingClientRect();
const px = evt.clientX - rect.left; const px = evt.clientX - rect.left;
@ -3012,6 +3377,40 @@ function setupEvents() {
el.connectPinBtn.click(); el.connectPinBtn.click();
return; return;
} }
if (mod && evt.key.toLowerCase() === "k") {
evt.preventDefault();
if (el.commandModal?.classList.contains("hidden")) {
openCommandModal();
} else {
closeCommandModal();
}
return;
}
if (!mod && evt.shiftKey && evt.key === "?") {
if (isTypingContext(evt.target)) {
return;
}
evt.preventDefault();
openShortcutsModal();
return;
}
if (!mod && !evt.altKey && !evt.shiftKey && evt.key.toLowerCase() === "f") {
if (isTypingContext(evt.target)) {
return;
}
evt.preventDefault();
focusSelection();
}
});
window.addEventListener("resize", () => {
if (!state.compile?.layout || state.userAdjustedView) {
return;
}
fitView(state.compile.layout);
}); });
window.addEventListener("keyup", (evt) => { window.addEventListener("keyup", (evt) => {
@ -3022,6 +3421,14 @@ function setupEvents() {
} }
} }
if (evt.code === "Escape") { if (evt.code === "Escape") {
if (!el.commandModal.classList.contains("hidden")) {
closeCommandModal();
return;
}
if (!el.shortcutsModal.classList.contains("hidden")) {
closeShortcutsModal();
return;
}
if (!el.schemaModal.classList.contains("hidden")) { if (!el.schemaModal.classList.contains("hidden")) {
closeSchemaModal(); closeSchemaModal();
return; return;
@ -3074,6 +3481,57 @@ function setupEvents() {
} }
}); });
el.shortcutsBtn?.addEventListener("click", openShortcutsModal);
el.closeShortcutsBtn?.addEventListener("click", closeShortcutsModal);
el.shortcutsModal?.addEventListener("click", (evt) => {
if (evt.target === el.shortcutsModal) {
closeShortcutsModal();
}
});
el.closeCommandBtn?.addEventListener("click", closeCommandModal);
el.commandModal?.addEventListener("click", (evt) => {
if (evt.target === el.commandModal) {
closeCommandModal();
}
});
el.commandInput?.addEventListener("input", () => {
state.commandIndex = 0;
renderCommandList();
});
el.commandInput?.addEventListener("keydown", (evt) => {
const cmds = renderCommandList();
if (!cmds.length) {
return;
}
if (evt.key === "ArrowDown") {
evt.preventDefault();
state.commandIndex = Math.min(cmds.length - 1, state.commandIndex + 1);
renderCommandList();
return;
}
if (evt.key === "ArrowUp") {
evt.preventDefault();
state.commandIndex = Math.max(0, state.commandIndex - 1);
renderCommandList();
return;
}
if (evt.key === "Enter") {
evt.preventDefault();
const cmd = cmds[state.commandIndex];
if (cmd) {
runCommandById(cmd.id);
}
}
});
el.commandList?.addEventListener("click", (evt) => {
const row = evt.target.closest("[data-command-id]");
if (!row) {
return;
}
runCommandById(row.getAttribute("data-command-id"));
});
el.validateJsonBtn.addEventListener("click", validateJsonEditor); el.validateJsonBtn.addEventListener("click", validateJsonEditor);
el.formatJsonBtn.addEventListener("click", () => { el.formatJsonBtn.addEventListener("click", () => {
@ -3139,6 +3597,13 @@ function setupEvents() {
}); });
el.loadSampleBtn.addEventListener("click", loadSample); el.loadSampleBtn.addEventListener("click", loadSample);
el.resetSampleBtn.addEventListener("click", async () => {
try {
await resetToSample({ pushHistory: true });
} catch (err) {
setStatus(`Reset failed: ${err.message}`, false);
}
});
el.autoLayoutBtn.addEventListener("click", async () => { el.autoLayoutBtn.addEventListener("click", async () => {
await runLayoutAction("/layout/auto"); await runLayoutAction("/layout/auto");

View File

@ -15,10 +15,12 @@
<div class="actions"> <div class="actions">
<button id="newProjectBtn" aria-label="Create new project">New</button> <button id="newProjectBtn" aria-label="Create new project">New</button>
<button id="loadSampleBtn" aria-label="Load sample project">Load Sample</button> <button id="loadSampleBtn" aria-label="Load sample project">Load Sample</button>
<button id="resetSampleBtn" aria-label="Reset to deterministic sample baseline">Reset Sample</button>
<button id="importBtn" aria-label="Import Schemeta JSON file">Import JSON</button> <button id="importBtn" aria-label="Import Schemeta JSON file">Import JSON</button>
<button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button> <button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button>
<button id="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button> <button id="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button>
<button id="autoTidyBtn" aria-label="Run automatic tidy layout">Auto Tidy</button> <button id="autoTidyBtn" aria-label="Run automatic tidy layout">Auto Tidy</button>
<button id="shortcutsBtn" aria-label="Show keyboard shortcuts">Shortcuts</button>
<button id="undoBtn" title="Undo (Ctrl/Cmd+Z)">Undo</button> <button id="undoBtn" title="Undo (Ctrl/Cmd+Z)">Undo</button>
<button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button> <button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button>
<label class="inlineSelect"> <label class="inlineSelect">
@ -40,6 +42,19 @@
<button id="isolateComponentBtn" class="chip">Isolate</button> <button id="isolateComponentBtn" class="chip">Isolate</button>
</div> </div>
<input id="instanceFilter" placeholder="Filter instances" aria-label="Filter instances" /> <input id="instanceFilter" placeholder="Filter instances" aria-label="Filter instances" />
<div class="quickCreate">
<input id="newComponentRefInput" placeholder="Ref (auto if empty)" aria-label="New component reference" />
<select id="newComponentTypeSelect" aria-label="New component type">
<option value="resistor">resistor</option>
<option value="capacitor">capacitor</option>
<option value="inductor">inductor</option>
<option value="diode">diode</option>
<option value="led">led</option>
<option value="connector">connector</option>
<option value="generic">generic</option>
</select>
<button id="addComponentBtn">Add Component</button>
</div>
<ul id="instanceList" class="list" aria-label="Instances list"></ul> <ul id="instanceList" class="list" aria-label="Instances list"></ul>
</section> </section>
<section> <section>
@ -48,16 +63,42 @@
<button id="isolateNetBtn" class="chip">Isolate</button> <button id="isolateNetBtn" class="chip">Isolate</button>
</div> </div>
<input id="netFilter" placeholder="Filter nets" aria-label="Filter nets" /> <input id="netFilter" placeholder="Filter nets" aria-label="Filter nets" />
<div class="quickCreate">
<input id="newQuickNetNameInput" placeholder="NET_1" aria-label="New net name" />
<select id="newQuickNetClassSelect" aria-label="New net class">
<option value="signal">signal</option>
<option value="analog">analog</option>
<option value="power">power</option>
<option value="ground">ground</option>
<option value="clock">clock</option>
<option value="bus">bus</option>
<option value="differential">differential</option>
</select>
<button id="addQuickNetBtn">Add Net</button>
</div>
<ul id="netList" class="list" aria-label="Nets list"></ul> <ul id="netList" class="list" aria-label="Nets list"></ul>
</section> </section>
<section class="legendSection" aria-label="Net color legend">
<div class="sectionHead">
<h2>Legend</h2>
</div>
<div class="netLegend">
<div class="legendRow"><span class="legendSwatch legendPower"></span>Power</div>
<div class="legendRow"><span class="legendSwatch legendGround"></span>Ground</div>
<div class="legendRow"><span class="legendSwatch legendClock"></span>Clock</div>
<div class="legendRow"><span class="legendSwatch legendSignal"></span>Signal</div>
<div class="legendRow"><span class="legendSwatch legendAnalog"></span>Analog</div>
</div>
</section>
</aside> </aside>
<section class="pane center"> <section class="pane center">
<div class="canvasTools"> <div class="canvasTools">
<button id="zoomOutBtn" aria-label="Zoom out">-</button> <button id="zoomOutBtn" aria-label="Zoom out">-</button>
<button id="zoomResetBtn" aria-label="Reset zoom">100%</button> <button id="zoomResetBtn" aria-label="Reset view">Reset</button>
<button id="zoomInBtn" aria-label="Zoom in">+</button> <button id="zoomInBtn" aria-label="Zoom in">+</button>
<button id="fitViewBtn" aria-label="Fit schematic to viewport">Fit</button> <button id="fitViewBtn" aria-label="Fit schematic to viewport">Fit</button>
<button id="focusSelectionBtn" aria-label="Focus current selection">Focus</button>
<label class="inlineCheck"><input id="showLabelsInput" type="checkbox" checked /> Labels</label> <label class="inlineCheck"><input id="showLabelsInput" type="checkbox" checked /> Labels</label>
<span id="compileStatus">Idle</span> <span id="compileStatus">Idle</span>
</div> </div>
@ -254,6 +295,37 @@
</div> </div>
</div> </div>
<div id="shortcutsModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="shortcutsTitle">
<div class="modalCard compactModal">
<div class="modalHead">
<h3 id="shortcutsTitle">Keyboard Shortcuts</h3>
<button id="closeShortcutsBtn">Close</button>
</div>
<div class="shortcutGrid">
<div><kbd>Ctrl/Cmd + Z</kbd><span>Undo</span></div>
<div><kbd>Ctrl/Cmd + Shift + Z</kbd><span>Redo</span></div>
<div><kbd>Ctrl/Cmd + K</kbd><span>Open command palette</span></div>
<div><kbd>F</kbd><span>Focus current selection</span></div>
<div><kbd>Space</kbd><span>Rotate selected component(s)</span></div>
<div><kbd>Space + Drag</kbd><span>Pan canvas</span></div>
<div><kbd>Alt + Enter</kbd><span>Apply selected editor</span></div>
<div><kbd>Alt + C</kbd><span>Connect selected pin to chosen net</span></div>
<div><kbd>Esc</kbd><span>Close modal / clear selection</span></div>
</div>
</div>
</div>
<div id="commandModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="commandTitle">
<div class="modalCard compactModal">
<div class="modalHead">
<h3 id="commandTitle">Command Palette</h3>
<button id="closeCommandBtn">Close</button>
</div>
<input id="commandInput" placeholder="Type a command..." aria-label="Command input" />
<div id="commandList" class="commandList" role="listbox" aria-label="Command results"></div>
</div>
</div>
<script type="module" src="/app.js"></script> <script type="module" src="/app.js"></script>
</body> </body>
</html> </html>

View File

@ -1,56 +1,50 @@
:root { :root {
--bg: #eef2f6; --bg-0: #f5f7fb;
--panel: #ffffff; --bg-1: #ebf1f8;
--ink: #1d2939; --bg-2: #dce8f4;
--ink-soft: #667085; --panel: #fbfdff;
--line: #d0d5dd; --panel-strong: #ffffff;
--accent: #155eef; --canvas: #f4f8fd;
--accent-soft: #dbe8ff; --ink: #0f1728;
--warn: #b54708; --ink-muted: #4a607c;
--error: #b42318; --ink-subtle: #7388a3;
--ok: #067647; --line: #c8d5e4;
--line-strong: #9db1c9;
--accent: #1565d8;
--accent-strong: #0f4dab;
--accent-soft: #e6f0ff;
--ok: #0f7a49;
--warn: #9b6200;
--err: #ac2f24;
--power: #d6691f;
--ground: #60748d;
--clock: #d35c2f;
--signal: #2f72e7;
--analog: #0d978e;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-1: 0 1px 2px rgba(16, 24, 40, 0.08);
--shadow-2: 0 10px 24px rgba(16, 24, 40, 0.08);
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html,
body { body {
margin: 0; margin: 0;
font-family: "Manrope", "Segoe UI", sans-serif; min-height: 100%;
}
body {
font-family: "IBM Plex Sans", "Manrope", "Segoe UI", sans-serif;
color: var(--ink); color: var(--ink);
background: radial-gradient(circle at 8% 8%, #fef7e6, transparent 30%), background:
radial-gradient(circle at 88% 12%, #e0f2ff, transparent 30%), var(--bg); radial-gradient(circle at 0% 0%, #fff4de 0 20%, transparent 30%),
min-height: 100vh; radial-gradient(circle at 96% 5%, #dbf2ff 0 18%, transparent 28%),
} linear-gradient(180deg, var(--bg-1), var(--bg-0));
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: #f7fbffde;
backdrop-filter: blur(4px);
}
.brand h1 {
margin: 0;
font-size: 1.2rem;
letter-spacing: 0.08em;
}
.brand p {
margin: 2px 0 0;
color: var(--ink-soft);
font-size: 0.8rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
} }
button, button,
@ -60,44 +54,101 @@ textarea {
font: inherit; font: inherit;
} }
button { button,
border: 1px solid var(--line); input,
border-radius: 8px; select,
background: #fff; textarea,
padding: 6px 10px; .list li,
color: var(--ink); details > summary {
cursor: pointer; transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, color 120ms ease;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
button:focus-visible, button:focus-visible,
input:focus-visible, input:focus-visible,
select:focus-visible, select:focus-visible,
textarea:focus-visible, textarea:focus-visible,
.list li:focus-visible { .list li:focus-visible,
outline: 2px solid #155eef; details > summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px; outline-offset: 1px;
} }
button {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-strong);
color: var(--ink);
padding: 6px 11px;
cursor: pointer;
box-shadow: var(--shadow-1);
}
button:hover {
border-color: var(--line-strong);
background: #f3f8ff;
}
button:disabled {
opacity: 0.48;
cursor: not-allowed;
}
button.primary { button.primary {
border-color: var(--accent);
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border-color: var(--accent); }
button.primary:hover {
background: var(--accent-strong);
border-color: var(--accent-strong);
} }
button.chip { button.chip {
padding: 4px 8px; padding: 4px 9px;
font-size: 0.76rem; font-size: 0.76rem;
} }
button.activeChip { button.activeChip {
background: var(--accent-soft); background: var(--accent-soft);
border-color: var(--accent); border-color: var(--accent);
color: #0f3ea3; color: var(--accent-strong);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #f9fcff, #f2f8ff);
backdrop-filter: blur(2px);
position: sticky;
top: 0;
z-index: 40;
}
.brand h1 {
margin: 0;
font-size: 1.6rem;
letter-spacing: 0.08em;
line-height: 1;
}
.brand p {
margin: 2px 0 0;
font-size: 0.84rem;
color: var(--ink-muted);
font-weight: 600;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 7px;
} }
.inlineSelect, .inlineSelect,
@ -109,24 +160,22 @@ button.activeChip {
} }
.inlineSelect select { .inlineSelect select {
border: 1px solid var(--line); min-width: 138px;
border-radius: 8px;
padding: 5px 8px;
} }
.workspace { .workspace {
height: calc(100vh - 65px); height: calc(100vh - 77px);
display: grid; display: grid;
grid-template-columns: 270px minmax(480px, 1fr) 380px;
gap: 10px; gap: 10px;
grid-template-columns: 270px minmax(520px, 1fr) 392px;
padding: 10px; padding: 10px;
} }
.pane { .pane {
background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: var(--radius-md);
overflow: hidden; background: linear-gradient(180deg, var(--panel), #f6faff);
box-shadow: var(--shadow-1);
} }
.pane.left, .pane.left,
@ -138,16 +187,27 @@ button.activeChip {
overflow: auto; overflow: auto;
} }
.pane.center {
overflow: hidden;
background: linear-gradient(180deg, #fcfeff, #f6faff);
}
.sectionHead { .sectionHead {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
gap: 8px; gap: 8px;
} }
.sectionHead h2 { .sectionHead h2 {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.92rem;
color: var(--ink);
}
h2 {
margin: 0;
font-size: 0.94rem;
} }
input, input,
@ -155,32 +215,44 @@ textarea,
select { select {
width: 100%; width: 100%;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-sm);
padding: 8px; padding: 8px;
background: #fff;
color: var(--ink); color: var(--ink);
} }
input::placeholder,
textarea::placeholder {
color: var(--ink-subtle);
}
textarea { textarea {
min-height: 250px; min-height: 250px;
font-family: "JetBrains Mono", monospace; font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 12px; font-size: 12px;
line-height: 1.45; line-height: 1.42;
} }
.list { .list {
margin: 8px 0 0; margin: 8px 0 0;
padding: 0;
list-style: none; list-style: none;
max-height: 230px; padding: 0;
max-height: 250px;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.72);
} }
.list li { .list li {
padding: 8px; padding: 8px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
cursor: pointer; cursor: pointer;
color: var(--ink);
}
.list li:hover {
background: #f2f7ff;
} }
.list li:last-child { .list li:last-child {
@ -189,6 +261,8 @@ textarea {
.list li.active { .list li.active {
background: var(--accent-soft); background: var(--accent-soft);
color: #0b3a84;
font-weight: 600;
} }
.list li.listSpacer { .list li.listSpacer {
@ -198,11 +272,71 @@ textarea {
background: transparent; background: transparent;
} }
.quickCreate {
margin-top: 8px;
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.quickCreate button {
justify-self: start;
}
.legendSection {
margin-top: 2px;
}
.netLegend {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fbfdff;
padding: 8px;
display: grid;
gap: 6px;
}
.legendRow {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
color: var(--ink-muted);
}
.legendSwatch {
display: inline-block;
width: 20px;
height: 0;
border-top: 3px solid var(--line-strong);
}
.legendPower {
border-top-color: var(--power);
}
.legendGround {
border-top-color: var(--ground);
}
.legendClock {
border-top-color: var(--clock);
}
.legendSignal {
border-top-color: var(--signal);
}
.legendAnalog {
border-top-color: var(--analog);
}
.card { .card {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-sm);
background: #fbfdff;
padding: 8px; padding: 8px;
color: var(--ink-soft); color: var(--ink-muted);
font-size: 0.85rem; font-size: 0.85rem;
white-space: pre-wrap; white-space: pre-wrap;
} }
@ -210,9 +344,9 @@ textarea {
.editorCard { .editorCard {
margin-top: 8px; margin-top: 8px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-sm);
padding: 8px; padding: 9px;
background: #fcfcfd; background: #f8fbff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@ -220,9 +354,10 @@ textarea {
.editorSection { .editorSection {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-sm);
margin-top: 8px; margin-top: 8px;
background: #fff; background: #ffffffcc;
overflow: hidden;
} }
.editorSection > summary { .editorSection > summary {
@ -232,11 +367,15 @@ textarea {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
color: var(--ink); color: var(--ink);
border-bottom: 1px solid transparent; }
.editorSection > summary:hover {
background: #f3f8ff;
} }
.editorSection[open] > summary { .editorSection[open] > summary {
border-bottom-color: var(--line); border-bottom: 1px solid var(--line);
background: #eef4ff;
} }
.editorSection > summary::-webkit-details-marker { .editorSection > summary::-webkit-details-marker {
@ -251,27 +390,27 @@ textarea {
.editorActions { .editorActions {
display: flex; display: flex;
gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px;
} }
.hintText { .hintText {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--ink-soft); color: var(--ink-muted);
} }
.miniList { .miniList {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-sm);
background: #fff; background: #fff;
max-height: 170px; max-height: 180px;
overflow: auto; overflow: auto;
} }
.miniRow { .miniRow {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
gap: 8px; gap: 8px;
padding: 6px 8px; padding: 6px 8px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@ -284,47 +423,48 @@ textarea {
.symbolPinRow { .symbolPinRow {
display: grid; display: grid;
grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto;
align-items: center; align-items: center;
grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto;
} }
.pinCol { .pinCol {
min-width: 0; min-width: 0;
padding: 5px 6px; padding: 5px 6px;
font-size: 0.75rem; font-size: 0.76rem;
} }
.symbolPinRow.invalidRow { .symbolPinRow.invalidRow {
background: #fff3f2; background: #fff3f1;
} }
.symbolValidationError { .symbolValidationError {
color: var(--error); color: var(--err);
font-size: 0.76rem; font-size: 0.76rem;
} }
.migrationPreview { .migrationPreview {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-sm);
padding: 8px; padding: 8px;
background: #f8fafc; margin: 6px 0;
margin-top: 6px;
margin-bottom: 6px;
line-height: 1.35; line-height: 1.35;
background: #f5f9ff;
} }
.canvasTools { .canvasTools {
display: flex; display: flex;
gap: 8px;
align-items: center; align-items: center;
border-bottom: 1px solid var(--line); gap: 8px;
padding: 8px; padding: 8px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #f8fbff, #f2f7ff);
} }
#compileStatus { #compileStatus {
margin-left: auto; margin-left: auto;
color: var(--ink-soft); color: var(--ink-muted);
font-size: 0.84rem; font-size: 0.84rem;
font-weight: 600;
} }
.status-ok { .status-ok {
@ -333,12 +473,14 @@ textarea {
.canvasViewport { .canvasViewport {
height: calc(100% - 52px); height: calc(100% - 52px);
overflow: hidden;
position: relative; position: relative;
overflow: hidden;
cursor: grab; cursor: grab;
background-image: linear-gradient(0deg, #ebeff3 1px, transparent 1px), background:
linear-gradient(90deg, #ebeff3 1px, transparent 1px); linear-gradient(0deg, #d9e4f0 1px, transparent 1px),
background-size: 20px 20px; linear-gradient(90deg, #d9e4f0 1px, transparent 1px),
linear-gradient(180deg, var(--canvas), #edf4fc);
background-size: 20px 20px, 20px 20px, auto;
} }
.canvasViewport.dragging { .canvasViewport.dragging {
@ -346,10 +488,10 @@ textarea {
} }
.canvasInner { .canvasInner {
transform-origin: 0 0;
position: absolute; position: absolute;
left: 0;
top: 0; top: 0;
left: 0;
transform-origin: 0 0;
} }
.canvasInner svg { .canvasInner svg {
@ -358,10 +500,23 @@ textarea {
.selectionBox { .selectionBox {
position: absolute; position: absolute;
border: 1px solid #155eef;
background: rgba(21, 94, 239, 0.12);
pointer-events: none;
z-index: 15; z-index: 15;
pointer-events: none;
border: 1px solid var(--accent);
background: rgba(21, 101, 216, 0.12);
}
.pinTooltip {
position: absolute;
z-index: 20;
pointer-events: none;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 6px 8px;
color: var(--ink);
background: rgba(255, 255, 255, 0.95);
box-shadow: var(--shadow-2);
font-size: 0.74rem;
} }
.hidden { .hidden {
@ -375,20 +530,20 @@ textarea {
} }
.jsonActions button { .jsonActions button {
font-size: 0.77rem;
padding: 4px 8px; padding: 4px 8px;
font-size: 0.78rem;
} }
.jsonFeedback { .jsonFeedback {
min-height: 18px; min-height: 18px;
color: var(--ink-soft);
font-size: 0.78rem;
margin-bottom: 6px; margin-bottom: 6px;
color: var(--ink-muted);
font-size: 0.78rem;
} }
.issueRow { .issueRow {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 7px; border-radius: 8px;
padding: 7px; padding: 7px;
margin-bottom: 6px; margin-bottom: 6px;
cursor: pointer; cursor: pointer;
@ -396,51 +551,48 @@ textarea {
} }
.issueRow:hover { .issueRow:hover {
background: #f8faff; background: #f4f8ff;
} }
.issueErr { .issueErr {
border-color: #fecdca; border-color: #f3bab5;
background: #fff6f5; background: #fff5f4;
} }
.issueWarn { .issueWarn {
border-color: #fedf89; border-color: #f0d28b;
background: #fffcf5; background: #fffaf0;
} }
.issueTitle { .issueTitle {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
color: var(--ink);
} }
.issueMeta { .issueMeta {
font-size: 0.72rem; font-size: 0.72rem;
color: var(--ink-soft); color: var(--ink-muted);
} }
.pinTooltip { .issueActions {
position: absolute; margin-top: 6px;
pointer-events: none; }
padding: 6px 8px;
border: 1px solid var(--line); .issueActions button {
border-radius: 8px;
background: #ffffffee;
color: var(--ink);
font-size: 0.74rem; font-size: 0.74rem;
z-index: 20; padding: 3px 8px;
box-shadow: 0 6px 20px rgba(16, 24, 40, 0.12);
} }
.modal { .modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(15, 23, 42, 0.45);
z-index: 70; z-index: 70;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 18px; padding: 18px;
background: rgba(14, 22, 36, 0.54);
} }
.modal.hidden { .modal.hidden {
@ -450,30 +602,37 @@ textarea {
.modalCard { .modalCard {
width: min(1120px, 100%); width: min(1120px, 100%);
height: min(88vh, 900px); height: min(88vh, 900px);
background: #fff;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: var(--radius-lg);
background: #fff;
box-shadow: 0 24px 60px rgba(16, 24, 40, 0.24);
padding: 12px; padding: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.compactModal {
width: min(760px, 100%);
height: auto;
max-height: min(86vh, 760px);
}
.modalHead { .modalHead {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
gap: 10px; gap: 10px;
} }
.modalHead h3 { .modalHead h3 {
margin: 0; margin: 0;
font-size: 0.95rem; font-size: 0.96rem;
} }
.modalHint { .modalHint {
margin: 0; margin: 0;
color: var(--ink-soft); color: var(--ink-muted);
font-size: 0.8rem; font-size: 0.8rem;
} }
@ -482,6 +641,69 @@ textarea {
min-height: 0; min-height: 0;
} }
.shortcutGrid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
overflow: auto;
padding-right: 4px;
}
.shortcutGrid > div {
display: flex;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: 0.86rem;
color: var(--ink-muted);
background: #f8fbff;
}
kbd {
display: inline-flex;
align-items: center;
border: 1px solid var(--line-strong);
border-bottom-width: 2px;
border-radius: 6px;
background: #fff;
padding: 2px 6px;
color: var(--ink);
font-size: 0.74rem;
font-family: "JetBrains Mono", monospace;
}
.commandList {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow: auto;
max-height: 46vh;
background: #fff;
}
.commandRow {
width: 100%;
border: none;
border-bottom: 1px solid var(--line);
border-radius: 0;
background: #fff;
text-align: left;
box-shadow: none;
padding: 8px 10px;
color: var(--ink);
font-size: 0.84rem;
}
.commandRow:last-child {
border-bottom: none;
}
.commandRow:hover,
.commandRow.active {
background: #edf4ff;
}
.flash { .flash {
animation: flashPulse 0.7s ease-in-out 0s 2; animation: flashPulse 0.7s ease-in-out 0s 2;
} }
@ -495,14 +717,38 @@ textarea {
} }
} }
@media (max-width: 1460px) {
.workspace {
grid-template-columns: 248px minmax(420px, 1fr) 360px;
}
}
@media (max-width: 1300px) { @media (max-width: 1300px) {
.workspace { .workspace {
height: auto;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
height: auto;
} }
.pane.center { .pane.center {
min-height: 560px; min-height: 600px;
}
}
@media (max-width: 820px) {
.topbar {
position: static;
}
.brand h1 {
font-size: 1.3rem;
}
.actions {
justify-content: flex-start;
}
.editorGrid {
grid-template-columns: 1fr;
} }
} }

87
package-lock.json generated Normal file
View File

@ -0,0 +1,87 @@
{
"name": "schemeta",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "schemeta",
"version": "0.1.0",
"devDependencies": {
"pixelmatch": "^7.1.0",
"playwright": "^1.58.2",
"pngjs": "^7.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pixelmatch": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
"dev": true,
"license": "ISC",
"dependencies": {
"pngjs": "^7.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
}
}
}

View File

@ -8,6 +8,13 @@
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"test": "node --test", "test": "node --test",
"test:ui": "node tests/ui-regression-runner.js",
"test:ui:update-baselines": "UPDATE_SNAPSHOTS=1 node tests/ui-regression-runner.js",
"mcp": "node src/mcp-server.js" "mcp": "node src/mcp-server.js"
},
"devDependencies": {
"pixelmatch": "^7.1.0",
"playwright": "^1.58.2",
"pngjs": "^7.0.0"
} }
} }

View File

@ -18,7 +18,8 @@ const NET_CLASS_PRIORITY = {
const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]); const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]);
const DEFAULT_RENDER_MODE = "schematic_stub"; const DEFAULT_RENDER_MODE = "schematic_stub";
const ROTATION_STEPS = [0, 90, 180, 270]; const ROTATION_STEPS = [0, 90, 180, 270];
const MIN_CHANNEL_SPACING_STEPS = 2; const MIN_CHANNEL_SPACING_STEPS = 3;
const LANE_ORDER = ["power", "clock", "signal", "analog", "ground", "bus", "differential"];
function toGrid(value) { function toGrid(value) {
return Math.round(value / GRID) * GRID; return Math.round(value / GRID) * GRID;
@ -375,8 +376,42 @@ function connectivityDegree(model) {
return deg; return deg;
} }
function refLaneProfiles(model) {
const profiles = new Map(model.instances.map((inst) => [inst.ref, { total: 0, byClass: {} }]));
for (const net of model.nets ?? []) {
const netClass = String(net.class ?? "signal");
const refs = [...new Set((net.nodes ?? []).map((n) => n.ref))];
for (const ref of refs) {
const p = profiles.get(ref);
if (!p) {
continue;
}
p.total += 1;
p.byClass[netClass] = (p.byClass[netClass] ?? 0) + 1;
}
}
const out = new Map();
for (const [ref, profile] of profiles.entries()) {
const ranked = Object.entries(profile.byClass).sort((a, b) => {
if (a[1] !== b[1]) {
return b[1] - a[1];
}
return (NET_CLASS_PRIORITY[a[0]] ?? 99) - (NET_CLASS_PRIORITY[b[0]] ?? 99);
});
const dominantClass = ranked[0]?.[0] ?? "signal";
const laneIndex = Math.max(0, LANE_ORDER.indexOf(dominantClass));
out.set(ref, {
dominantClass,
laneIndex,
profile
});
}
return out;
}
function placeGroup(model, group, start, context) { function placeGroup(model, group, start, context) {
const { rank, degree, instanceByRef, respectLocks } = context; const { rank, degree, instanceByRef, respectLocks, laneProfiles } = context;
const refs = [...group.members].sort((a, b) => a.localeCompare(b)); const refs = [...group.members].sort((a, b) => a.localeCompare(b));
const cols = rankColumnsForRefs(refs, rank); const cols = rankColumnsForRefs(refs, rank);
const colOrder = [...cols.keys()].sort((a, b) => a - b); const colOrder = [...cols.keys()].sort((a, b) => a - b);
@ -409,6 +444,11 @@ function placeGroup(model, group, start, context) {
for (const col of colOrder) { for (const col of colOrder) {
const refsInCol = [...(cols.get(col) ?? [])].sort((a, b) => { const refsInCol = [...(cols.get(col) ?? [])].sort((a, b) => {
const la = laneProfiles.get(a)?.laneIndex ?? 2;
const lb = laneProfiles.get(b)?.laneIndex ?? 2;
if (la !== lb) {
return la - lb;
}
const da = degree.get(a) ?? 0; const da = degree.get(a) ?? 0;
const db = degree.get(b) ?? 0; const db = degree.get(b) ?? 0;
if (da !== db) { if (da !== db) {
@ -417,8 +457,19 @@ function placeGroup(model, group, start, context) {
return a.localeCompare(b); return a.localeCompare(b);
}); });
let yCursor = start.y; const byLane = new Map();
for (const ref of refsInCol) { for (const ref of refsInCol) {
const lane = laneProfiles.get(ref)?.laneIndex ?? 2;
const list = byLane.get(lane) ?? [];
list.push(ref);
byLane.set(lane, list);
}
let yCursor = start.y;
const laneSequence = [...byLane.keys()].sort((a, b) => a - b);
for (const lane of laneSequence) {
const laneRefs = byLane.get(lane) ?? [];
for (const ref of laneRefs) {
const inst = instanceByRef.get(ref); const inst = instanceByRef.get(ref);
if (!inst) { if (!inst) {
continue; continue;
@ -449,7 +500,9 @@ function placeGroup(model, group, start, context) {
maxX = Math.max(maxX, x + sym.body.width); maxX = Math.max(maxX, x + sym.body.width);
maxY = Math.max(maxY, y + sym.body.height); maxY = Math.max(maxY, y + sym.body.height);
yCursor = y + sym.body.height + 110; yCursor = y + sym.body.height + 96;
}
yCursor += 48;
} }
} }
@ -471,6 +524,67 @@ function placeGroup(model, group, start, context) {
}; };
} }
function rectForPlacement(model, inst) {
const sym = model.symbols[inst.symbol];
return {
x: inst.placement.x,
y: inst.placement.y,
w: sym?.body?.width ?? 120,
h: sym?.body?.height ?? 80
};
}
function boxesOverlap(a, b, pad = 12) {
return !(
a.x + a.w + pad <= b.x ||
b.x + b.w + pad <= a.x ||
a.y + a.h + pad <= b.y ||
b.y + b.h + pad <= a.y
);
}
function resolvePlacementOverlaps(model, placedMap, options = {}) {
const respectLocks = options.respectLocks ?? true;
const refs = [...placedMap.keys()].sort();
const iterations = Math.max(1, refs.length * 3);
for (let pass = 0; pass < iterations; pass += 1) {
let moved = false;
for (let i = 0; i < refs.length; i += 1) {
const aRef = refs[i];
const aInst = placedMap.get(aRef);
if (!aInst) {
continue;
}
const aLocked = respectLocks ? Boolean(aInst.placement.locked) : false;
const aBox = rectForPlacement(model, aInst);
for (let j = i + 1; j < refs.length; j += 1) {
const bRef = refs[j];
const bInst = placedMap.get(bRef);
if (!bInst) {
continue;
}
const bLocked = respectLocks ? Boolean(bInst.placement.locked) : false;
const bBox = rectForPlacement(model, bInst);
if (!boxesOverlap(aBox, bBox, 14)) {
continue;
}
const target = !aLocked ? aInst : !bLocked ? bInst : null;
if (!target) {
continue;
}
const pushY = toGrid(Math.max(aBox.y + aBox.h + 56, bBox.y + bBox.h + 56));
target.placement.y = Math.max(target.placement.y, pushY);
moved = true;
}
}
if (!moved) {
break;
}
}
}
function buildNodeNetMap(model) { function buildNodeNetMap(model) {
const map = new Map(); const map = new Map();
for (const net of model.nets) { for (const net of model.nets) {
@ -581,6 +695,7 @@ function placeInstances(model, options = {}) {
const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref)); const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
const { rank } = computeRanks(model); const { rank } = computeRanks(model);
const degree = connectivityDegree(model); const degree = connectivityDegree(model);
const laneProfiles = refLaneProfiles(model);
const instanceByRef = buildInstanceMap(instances); const instanceByRef = buildInstanceMap(instances);
const groups = buildConstraintGroups(model, rank); const groups = buildConstraintGroups(model, rank);
@ -602,6 +717,7 @@ function placeInstances(model, options = {}) {
const out = placeGroup(model, group, origin, { const out = placeGroup(model, group, origin, {
rank, rank,
degree, degree,
laneProfiles,
instanceByRef, instanceByRef,
respectLocks respectLocks
}); });
@ -618,6 +734,7 @@ function placeInstances(model, options = {}) {
applyAlignmentConstraints(placedMap, model.constraints); applyAlignmentConstraints(placedMap, model.constraints);
applyNearConstraints(model, placedMap, model.constraints); applyNearConstraints(model, placedMap, model.constraints);
resolvePlacementOverlaps(model, placedMap, { respectLocks });
return { placed, placedMap }; return { placed, placedMap };
} }
@ -1073,7 +1190,7 @@ function uniquePoints(points) {
return [...map.values()]; return [...map.values()];
} }
function routeLabelTieNet(net, pinNodes, context) { function routeLabelTieNet(net, pinNodes, context, fallbackReason = null) {
const routes = []; const routes = [];
const tiePoints = []; const tiePoints = [];
@ -1122,7 +1239,7 @@ function routeLabelTieNet(net, pinNodes, context) {
total_bends: 0, total_bends: 0,
detour_ratio: 1, detour_ratio: 1,
used_label_tie: true, used_label_tie: true,
fallback_reason: null fallback_reason: fallbackReason
} }
}; };
} }
@ -1376,6 +1493,9 @@ function shouldUseLabelTie(net, pinNodes, context) {
const span = Math.abs(maxX - minX) + Math.abs(maxY - minY); const span = Math.abs(maxX - minX) + Math.abs(maxY - minY);
if (context.renderMode === "explicit") { if (context.renderMode === "explicit") {
if (context.busNetNames.has(net.name) && (pinNodes.length >= 3 || span > GRID * 36)) {
return true;
}
return LABEL_TIE_CLASSES.has(net.class) && pinNodes.length > 2; return LABEL_TIE_CLASSES.has(net.class) && pinNodes.length > 2;
} }
@ -1400,6 +1520,26 @@ function shouldUseLabelTie(net, pinNodes, context) {
return false; return false;
} }
function shouldFallbackToTieByQuality(net, pinNodes, routed) {
if (!routed || routed.mode !== "routed") {
return false;
}
const stats = routed.route_stats ?? {};
const detour = Number(stats.detour_ratio ?? 1);
const bends = Number(stats.total_bends ?? 0);
const totalLength = Number(stats.total_length ?? 0);
const directLength = Number(stats.direct_length ?? totalLength);
const spanRatio = directLength > 0 ? totalLength / directLength : 1;
if (pinNodes.length >= 4 && (detour > 2.25 || bends >= 7)) {
return true;
}
if ((net.class === "analog" || net.class === "signal") && pinNodes.length >= 3 && spanRatio > 2.7) {
return true;
}
return false;
}
function routeAllNets(model, placed, placedMap, bounds, options) { function routeAllNets(model, placed, placedMap, bounds, options) {
const obstacles = buildObstacles(model, placed); const obstacles = buildObstacles(model, placed);
const edgeUsage = new Map(); const edgeUsage = new Map();
@ -1434,10 +1574,14 @@ function routeAllNets(model, placed, placedMap, bounds, options) {
busNetNames busNetNames
}; };
const routed = shouldUseLabelTie(net, pinNodes, routeContext) let routed = shouldUseLabelTie(net, pinNodes, routeContext)
? routeLabelTieNet(net, pinNodes, routeContext) ? routeLabelTieNet(net, pinNodes, routeContext)
: routePointToPointNet(net, pinNodes, routeContext); : routePointToPointNet(net, pinNodes, routeContext);
if (shouldFallbackToTieByQuality(net, pinNodes, routed)) {
routed = routeLabelTieNet(net, pinNodes, routeContext, "quality_policy");
}
routedByName.set(net.name, { routedByName.set(net.name, {
net, net,
isBusMember: busNetNames.has(net.name), isBusMember: busNetNames.has(net.name),

View File

@ -1,4 +1,5 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { extname, join, normalize } from "node:path"; import { extname, join, normalize } from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
@ -38,14 +39,18 @@ function json(res, status, payload) {
res.end(JSON.stringify(payload, null, 2)); res.end(JSON.stringify(payload, null, 2));
} }
export function errorEnvelope(code, message) { export function errorEnvelope(code, message, details = {}) {
return { const out = {
ok: false, ok: false,
error: { error: {
code, code,
message message
} }
}; };
if (details && typeof details === "object") {
Object.assign(out, details);
}
return out;
} }
export function isAuthorizedRequest(req) { export function isAuthorizedRequest(req) {
@ -149,11 +154,32 @@ export function parsePayloadOptions(body) {
} }
export function withEnvelopeMeta(payload) { export function withEnvelopeMeta(payload) {
return { const out = {
api_version: API_VERSION, api_version: API_VERSION,
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
...payload ...payload
}; };
if (!out.request_id && payload?.request_id) {
out.request_id = payload.request_id;
}
return out;
}
function requestIdFrom(req) {
const headerId = req.headers?.["x-request-id"];
if (typeof headerId === "string" && headerId.trim()) {
return headerId.trim().slice(0, 128);
}
return randomUUID();
}
function auditLog(entry) {
const line = {
ts: new Date().toISOString(),
service: "schemeta",
...entry
};
console.log(JSON.stringify(line));
} }
function clientKey(req) { function clientKey(req) {
@ -194,8 +220,22 @@ function withinRateLimit(req) {
export function createRequestHandler() { export function createRequestHandler() {
return async (req, res) => { return async (req, res) => {
const requestId = requestIdFrom(req);
const startedAt = Date.now();
res.setHeader("x-request-id", requestId);
res.on("finish", () => {
auditLog({
request_id: requestId,
method: req.method,
path: req.url,
status: res.statusCode,
duration_ms: Date.now() - startedAt,
client: clientKey(req)
});
});
if (!req.url || !req.method) { if (!req.url || !req.method) {
return json(res, 400, errorEnvelope("invalid_request", "Invalid request.")); return json(res, 400, errorEnvelope("invalid_request", "Invalid request.", { request_id: requestId }));
} }
const pathname = new URL(req.url, "http://localhost").pathname; const pathname = new URL(req.url, "http://localhost").pathname;
@ -211,6 +251,7 @@ export function createRequestHandler() {
return json(res, 200, { return json(res, 200, {
ok: true, ok: true,
service: "schemeta", service: "schemeta",
request_id: requestId,
api_version: API_VERSION, api_version: API_VERSION,
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
status: "ok", status: "ok",
@ -221,6 +262,7 @@ export function createRequestHandler() {
if (req.method === "GET" && pathname === "/mcp/ui-bundle") { if (req.method === "GET" && pathname === "/mcp/ui-bundle") {
return json(res, 200, { return json(res, 200, {
ok: true, ok: true,
request_id: requestId,
name: "schemeta-workspace", name: "schemeta-workspace",
version: "0.2.0", version: "0.2.0",
api_version: API_VERSION, api_version: API_VERSION,
@ -234,56 +276,56 @@ export function createRequestHandler() {
if (req.method === "POST" && pathname === "/analyze") { if (req.method === "POST" && pathname === "/analyze") {
if (!isAuthorizedRequest(req)) { if (!isAuthorizedRequest(req)) {
res.setHeader("WWW-Authenticate", "Bearer"); res.setHeader("WWW-Authenticate", "Bearer");
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.")); return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
} }
if (!withinRateLimit(req)) { if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
} }
try { try {
const body = await readBody(req); const body = await readBody(req);
const parsed = parsePayloadOptions(body); const parsed = parsePayloadOptions(body);
return json(res, 200, withEnvelopeMeta(analyze(parsed.payload, parsed.options))); return json(res, 200, withEnvelopeMeta({ request_id: requestId, ...analyze(parsed.payload, parsed.options) }));
} catch (err) { } catch (err) {
if (err?.code === "PAYLOAD_TOO_LARGE") { if (err?.code === "PAYLOAD_TOO_LARGE") {
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`)); return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
} }
if (err?.code === "INVALID_JSON") { if (err?.code === "INVALID_JSON") {
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.")); return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
} }
return json(res, 500, errorEnvelope("internal_error", "Request failed.")); return json(res, 500, errorEnvelope("internal_error", "Request failed.", { request_id: requestId }));
} }
} }
if (req.method === "POST" && pathname === "/compile") { if (req.method === "POST" && pathname === "/compile") {
if (!isAuthorizedRequest(req)) { if (!isAuthorizedRequest(req)) {
res.setHeader("WWW-Authenticate", "Bearer"); res.setHeader("WWW-Authenticate", "Bearer");
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.")); return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
} }
if (!withinRateLimit(req)) { if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
} }
try { try {
const body = await readBody(req); const body = await readBody(req);
const parsed = parsePayloadOptions(body); const parsed = parsePayloadOptions(body);
return json(res, 200, withEnvelopeMeta(compile(parsed.payload, parsed.options))); return json(res, 200, withEnvelopeMeta({ request_id: requestId, ...compile(parsed.payload, parsed.options) }));
} catch (err) { } catch (err) {
if (err?.code === "PAYLOAD_TOO_LARGE") { if (err?.code === "PAYLOAD_TOO_LARGE") {
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`)); return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
} }
if (err?.code === "INVALID_JSON") { if (err?.code === "INVALID_JSON") {
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.")); return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
} }
return json(res, 500, errorEnvelope("internal_error", "Request failed.")); return json(res, 500, errorEnvelope("internal_error", "Request failed.", { request_id: requestId }));
} }
} }
if (req.method === "POST" && pathname === "/layout/auto") { if (req.method === "POST" && pathname === "/layout/auto") {
if (!isAuthorizedRequest(req)) { if (!isAuthorizedRequest(req)) {
res.setHeader("WWW-Authenticate", "Bearer"); res.setHeader("WWW-Authenticate", "Bearer");
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.")); return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
} }
if (!withinRateLimit(req)) { if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
} }
try { try {
const body = await readBody(req); const body = await readBody(req);
@ -293,6 +335,7 @@ export function createRequestHandler() {
const laidOut = applyLayoutToModel(model, { respectLocks: false }); const laidOut = applyLayoutToModel(model, { respectLocks: false });
return json(res, 200, { return json(res, 200, {
ok: true, ok: true,
request_id: requestId,
api_version: API_VERSION, api_version: API_VERSION,
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
model: laidOut, model: laidOut,
@ -300,22 +343,22 @@ export function createRequestHandler() {
}); });
} catch (err) { } catch (err) {
if (err?.code === "PAYLOAD_TOO_LARGE") { if (err?.code === "PAYLOAD_TOO_LARGE") {
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`)); return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
} }
if (err?.code === "INVALID_JSON") { if (err?.code === "INVALID_JSON") {
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.")); return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
} }
return json(res, 500, errorEnvelope("internal_error", "Layout auto failed.")); return json(res, 500, errorEnvelope("internal_error", "Layout auto failed.", { request_id: requestId }));
} }
} }
if (req.method === "POST" && pathname === "/layout/tidy") { if (req.method === "POST" && pathname === "/layout/tidy") {
if (!isAuthorizedRequest(req)) { if (!isAuthorizedRequest(req)) {
res.setHeader("WWW-Authenticate", "Bearer"); res.setHeader("WWW-Authenticate", "Bearer");
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.")); return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
} }
if (!withinRateLimit(req)) { if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
} }
try { try {
const body = await readBody(req); const body = await readBody(req);
@ -325,6 +368,7 @@ export function createRequestHandler() {
const laidOut = applyLayoutToModel(model, { respectLocks: true }); const laidOut = applyLayoutToModel(model, { respectLocks: true });
return json(res, 200, { return json(res, 200, {
ok: true, ok: true,
request_id: requestId,
api_version: API_VERSION, api_version: API_VERSION,
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
model: laidOut, model: laidOut,
@ -332,12 +376,12 @@ export function createRequestHandler() {
}); });
} catch (err) { } catch (err) {
if (err?.code === "PAYLOAD_TOO_LARGE") { if (err?.code === "PAYLOAD_TOO_LARGE") {
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`)); return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
} }
if (err?.code === "INVALID_JSON") { if (err?.code === "INVALID_JSON") {
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.")); return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
} }
return json(res, 500, errorEnvelope("internal_error", "Layout tidy failed.")); return json(res, 500, errorEnvelope("internal_error", "Layout tidy failed.", { request_id: requestId }));
} }
} }
@ -348,7 +392,7 @@ export function createRequestHandler() {
} }
} }
return json(res, 404, errorEnvelope("not_found", "Not found.")); return json(res, 404, errorEnvelope("not_found", "Not found.", { request_id: requestId }));
}; };
} }

View File

@ -17,11 +17,12 @@ import {
test("REST compile contract shape is stable with version metadata", () => { test("REST compile contract shape is stable with version metadata", () => {
const parsed = parsePayloadOptions({ payload: fixture, options: { render_mode: "schematic_stub" } }); const parsed = parsePayloadOptions({ payload: fixture, options: { render_mode: "schematic_stub" } });
const body = withRestEnvelopeMeta(compile(parsed.payload, parsed.options)); const body = withRestEnvelopeMeta({ request_id: "req-test-1", ...compile(parsed.payload, parsed.options) });
assert.equal(body.ok, true); assert.equal(body.ok, true);
assert.equal(body.api_version, REST_API_VERSION); assert.equal(body.api_version, REST_API_VERSION);
assert.equal(body.schema_version, REST_SCHEMA_VERSION); assert.equal(body.schema_version, REST_SCHEMA_VERSION);
assert.equal(body.request_id, "req-test-1");
assert.ok(Array.isArray(body.errors)); assert.ok(Array.isArray(body.errors));
assert.ok(Array.isArray(body.warnings)); assert.ok(Array.isArray(body.warnings));
assert.ok(Array.isArray(body.bus_groups)); assert.ok(Array.isArray(body.bus_groups));
@ -42,10 +43,11 @@ test("REST analyze contract shape is stable with version metadata", () => {
}); });
test("REST error envelope exposes stable code/message fields", () => { test("REST error envelope exposes stable code/message fields", () => {
const body = errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."); const body = errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: "req-test-2" });
assert.equal(body.ok, false); assert.equal(body.ok, false);
assert.equal(body.error.code, "rate_limited"); assert.equal(body.error.code, "rate_limited");
assert.equal(typeof body.error.message, "string"); assert.equal(typeof body.error.message, "string");
assert.equal(body.request_id, "req-test-2");
}); });
test("MCP schemeta_compile returns structured content with version metadata", () => { test("MCP schemeta_compile returns structured content with version metadata", () => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

View File

@ -0,0 +1,259 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { createServer as createNetServer } from "node:net";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
import { chromium } from "playwright";
const UPDATE_SNAPSHOTS = process.env.UPDATE_SNAPSHOTS === "1";
const MAX_DIFF_PIXELS = Number(process.env.UI_MAX_DIFF_PIXELS ?? 220);
const BASELINE_DIR = join(process.cwd(), "tests", "baselines", "ui");
const OUTPUT_DIR = join(process.cwd(), "output", "playwright");
const CURRENT_DIR = join(OUTPUT_DIR, "current");
const DIFF_DIR = join(OUTPUT_DIR, "diff");
const DENSE_ANALOG_PATH = join(process.cwd(), "examples", "dense-analog.json");
async function getFreePort() {
const probe = createNetServer();
await new Promise((resolve, reject) => {
probe.once("error", reject);
probe.listen(0, "127.0.0.1", resolve);
});
const addr = probe.address();
const port = typeof addr === "object" && addr ? addr.port : 8787;
await new Promise((resolve) => probe.close(resolve));
return port;
}
async function waitFor(predicate, timeoutMs = 10_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
if (await predicate()) {
return;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, 80));
}
throw new Error(`Timed out after ${timeoutMs}ms`);
}
async function startServer(port) {
const child = spawn("node", ["src/server.js"], {
cwd: process.cwd(),
env: { ...process.env, PORT: String(port) },
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => {
stdout += String(d);
});
child.stderr.on("data", (d) => {
stderr += String(d);
});
const baseUrl = `http://127.0.0.1:${port}`;
await waitFor(async () => {
const res = await fetch(`${baseUrl}/health`);
return res.ok;
});
return {
baseUrl,
async stop() {
if (child.exitCode !== null) return;
child.kill("SIGTERM");
await waitFor(() => child.exitCode !== null, 4_000).catch(() => {
throw new Error(`Server did not stop cleanly.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
});
}
};
}
async function ensureDirs() {
await mkdir(BASELINE_DIR, { recursive: true });
await mkdir(CURRENT_DIR, { recursive: true });
await mkdir(DIFF_DIR, { recursive: true });
}
async function compareScene(page, scene) {
const currentPath = join(CURRENT_DIR, `${scene}.png`);
const baselinePath = join(BASELINE_DIR, `${scene}.png`);
const diffPath = join(DIFF_DIR, `${scene}.png`);
await page.screenshot({ path: currentPath, fullPage: true });
if (UPDATE_SNAPSHOTS || !existsSync(baselinePath)) {
await copyFile(currentPath, baselinePath);
return;
}
const [actualBuf, baselineBuf] = await Promise.all([readFile(currentPath), readFile(baselinePath)]);
const actual = PNG.sync.read(actualBuf);
const baseline = PNG.sync.read(baselineBuf);
assert.equal(actual.width, baseline.width, `scene '${scene}' width changed`);
assert.equal(actual.height, baseline.height, `scene '${scene}' height changed`);
const diff = new PNG({ width: actual.width, height: actual.height });
const diffPixels = pixelmatch(actual.data, baseline.data, diff.data, actual.width, actual.height, {
threshold: 0.15,
includeAA: false
});
if (diffPixels > MAX_DIFF_PIXELS) {
await writeFile(diffPath, PNG.sync.write(diff));
assert.fail(`scene '${scene}' changed: ${diffPixels} differing pixels (max ${MAX_DIFF_PIXELS}). diff: ${diffPath}`);
}
}
function parseStatusMetrics(statusText) {
const m = /(\d+)E,\s*(\d+)W\s*\|\s*(\d+)\s*crossings,\s*(\d+)\s*overlaps,\s*(\d+)\s*bends,\s*(\d+)\s*tie-nets,\s*([0-9.]+)x\s*detour/.exec(
statusText
);
if (!m) return null;
return {
errors: Number(m[1]),
warnings: Number(m[2]),
crossings: Number(m[3]),
overlaps: Number(m[4]),
bends: Number(m[5]),
tieNets: Number(m[6]),
detour: Number(m[7])
};
}
function parseZoomPercent(text) {
const m = /(\d+)%/.exec(String(text ?? ""));
return m ? Number(m[1]) : NaN;
}
async function run() {
await ensureDirs();
const port = await getFreePort();
const srv = await startServer(port);
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
try {
await page.goto(`${srv.baseUrl}/`);
await page.waitForSelector("#compileStatus");
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
await compareScene(page, "initial");
await page.keyboard.press("Control+k");
await page.getByRole("dialog", { name: "Command Palette" }).waitFor();
await page.locator("#commandInput").fill("fit");
await page.keyboard.press("Enter");
await page.waitForFunction(() => document.querySelector("#commandModal")?.classList.contains("hidden"));
await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
await expectText(page, "#selectedSummary", /U2 \(dac_i2s\)/);
const preFocusZoom = parseZoomPercent(await page.locator("#zoomResetBtn").textContent());
await page.getByRole("button", { name: "Focus current selection" }).click();
const postFocusZoom = parseZoomPercent(await page.locator("#zoomResetBtn").textContent());
assert.ok(Number.isFinite(preFocusZoom) && Number.isFinite(postFocusZoom), "zoom label should remain parseable");
assert.ok(postFocusZoom >= preFocusZoom, `focus should not zoom out selected view (${preFocusZoom}% -> ${postFocusZoom}%)`);
await page.getByRole("button", { name: "Reset view" }).click();
await compareScene(page, "selected-u2");
await page.locator("#canvasViewport").click({ position: { x: 40, y: 40 } });
await expectText(page, "#selectedSummary", /Click a component, net, or pin/);
await page.getByRole("button", { name: "View Schema" }).click();
await page.getByRole("dialog", { name: "Schemeta JSON Schema" }).waitFor();
await page.getByRole("button", { name: "Close" }).click();
await page.waitForFunction(() => document.querySelector("#schemaModal")?.classList.contains("hidden"));
await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
const rowForPin = await findSymbolPinRowIndex(page, "AOUT");
assert.ok(rowForPin >= 0, "AOUT pin row should exist");
page.once("dialog", (d) => d.dismiss());
await page.locator("#symbolPinsList .symbolPinRow").nth(rowForPin).getByRole("button", { name: "Remove" }).click();
assert.ok((await findSymbolPinRowIndex(page, "AOUT")) >= 0, "AOUT should remain after dismiss");
const rowForPin2 = await findSymbolPinRowIndex(page, "AOUT");
page.once("dialog", (d) => d.accept());
await page.locator("#symbolPinsList .symbolPinRow").nth(rowForPin2).getByRole("button", { name: "Remove" }).click();
assert.equal(await findSymbolPinRowIndex(page, "AOUT"), -1, "AOUT should be removed in draft");
await page.getByRole("button", { name: "Apply Symbol" }).click();
await expectText(page, "#jsonFeedback", /Destructive symbol edit detected/);
await page.getByRole("button", { name: "Preview Migration" }).click();
await page.getByRole("button", { name: "Apply Symbol" }).click();
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
await expectText(page, "#symbolMeta", /\(5 pins\)/);
await compareScene(page, "post-migration-apply");
await page.selectOption("#renderModeSelect", "explicit");
await page.getByRole("button", { name: "Run automatic tidy layout" }).click();
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
const explicitStatus = await page.locator("#compileStatus").textContent();
assert.ok(/x detour/.test(explicitStatus ?? ""), "status should include detour metric in explicit mode");
await compareScene(page, "explicit-mode-auto-tidy");
await page.getByRole("button", { name: "Reset to deterministic sample baseline" }).click();
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
await expectText(page, "#selectedSummary", /Click a component, net, or pin/);
await expectText(page, "#compileStatus", /x detour/);
const mode = await page.locator("#renderModeSelect").inputValue();
assert.equal(mode, "schematic_stub");
await page.locator("#newComponentTypeSelect").selectOption("resistor");
const beforeInstanceCount = await page.locator("[data-ref-item]").count();
await page.getByRole("button", { name: "Add Component" }).click();
await waitFor(async () => (await page.locator("[data-ref-item]").count()) >= beforeInstanceCount + 1);
await page.locator("#newQuickNetClassSelect").selectOption("signal");
await page.locator("#newQuickNetNameInput").fill("UI_TEST_NET");
await page.getByRole("button", { name: "Add Net" }).click();
await expectText(page, "#netList", /UI_TEST_NET/);
const dense = await readFile(DENSE_ANALOG_PATH, "utf8");
await page.locator("#jsonEditor").fill(dense);
await page.getByRole("button", { name: "Apply JSON" }).click();
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
const denseStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
const metrics = parseStatusMetrics(denseStatus);
assert.ok(metrics, `Unable to parse compile status metrics from: ${denseStatus}`);
assert.equal(metrics.errors, 0, "dense analog scene should compile with no errors");
assert.ok(metrics.crossings <= 2, `dense analog crossings too high: ${metrics.crossings}`);
assert.ok(metrics.overlaps <= 2, `dense analog overlaps too high: ${metrics.overlaps}`);
await compareScene(page, "dense-analog");
await page.setViewportSize({ width: 1280, height: 720 });
await page.getByRole("button", { name: "Fit schematic to viewport" }).click();
await expectText(page, "#compileStatus", /Compiled/);
assert.ok(await page.locator("#applyJsonBtn").isVisible(), "Apply JSON button should remain visible at laptop viewport");
await compareScene(page, "laptop-viewport");
} finally {
await page.close().catch(() => {});
await browser.close().catch(() => {});
await srv.stop().catch(() => {});
}
}
async function expectText(page, selector, pattern) {
await waitFor(async () => {
const text = await page.locator(selector).textContent();
return pattern.test(String(text ?? ""));
}, 10_000);
}
async function findSymbolPinRowIndex(page, pinName) {
return page.locator("#symbolPinsList .symbolPinRow .pinName").evaluateAll(
(nodes, target) => nodes.findIndex((node) => (node).value?.trim() === target),
pinName
);
}
run().catch((err) => {
console.error(err);
process.exit(1);
});