Compare commits
10 Commits
347d547875
...
31a47346ea
| Author | SHA1 | Date | |
|---|---|---|---|
| 31a47346ea | |||
| e2445980f9 | |||
| 72ea3609bb | |||
| 486092e884 | |||
| 2bda088223 | |||
| 538d137d35 | |||
| 570e89cf4e | |||
| 2ff3856941 | |||
| 543ae522df | |||
| d5836a20d1 |
@ -32,3 +32,15 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
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
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.playwright-cli/
|
||||
output/playwright/
|
||||
node_modules/
|
||||
27
README.md
27
README.md
@ -25,6 +25,7 @@ Open:
|
||||
|
||||
Version metadata:
|
||||
- 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`.
|
||||
- Compatibility policy (current): additive, backward-compatible fields may be introduced in the same API minor version.
|
||||
|
||||
@ -37,9 +38,13 @@ Operational limits:
|
||||
Docs:
|
||||
- `docs/release-checklist.md`
|
||||
- `docs/operations-runbook.md`
|
||||
- `docs/quality-gates.md`
|
||||
- `docs/phase4-execution-plan.md`
|
||||
- `docs/api-mcp-contracts.md`
|
||||
|
||||
CI:
|
||||
- `.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
|
||||
|
||||
@ -155,14 +160,36 @@ Tools:
|
||||
## Workspace behavior highlights
|
||||
|
||||
- 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
|
||||
- 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
|
||||
- Click diagnostics to jump/flash focused net/component/pin
|
||||
- Auto Layout and Auto Tidy actions
|
||||
- `Shortcuts` helper modal and `Ctrl/Cmd+K` command palette quick actions
|
||||
- Keyboard shortcuts:
|
||||
- `Ctrl/Cmd+Z` undo
|
||||
- `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo
|
||||
- `Ctrl/Cmd+K` open command palette
|
||||
- `Space` rotate selected components (or pan when no selection)
|
||||
- `F` focus current selection
|
||||
- `Alt+Enter` apply current selection editor (component/pin/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
68
docs/api-mcp-contracts.md
Normal 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.
|
||||
@ -35,6 +35,18 @@ This runbook covers baseline production operation for Schemeta API + UI.
|
||||
- `GET /mcp/ui-bundle`
|
||||
- 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
|
||||
|
||||
1. Verify process liveness:
|
||||
@ -78,6 +90,6 @@ This runbook covers baseline production operation for Schemeta API + UI.
|
||||
|
||||
## 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 per-endpoint status code rates and top warning/error IDs.
|
||||
|
||||
54
docs/phase4-execution-plan.md
Normal file
54
docs/phase4-execution-plan.md
Normal 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
53
docs/quality-gates.md
Normal 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
|
||||
@ -1,6 +1,9 @@
|
||||
# Schemeta Release Checklist
|
||||
|
||||
Use this checklist before cutting a release tag.
|
||||
Reference docs:
|
||||
- `docs/quality-gates.md`
|
||||
- `docs/phase4-execution-plan.md`
|
||||
|
||||
## Pre-merge
|
||||
|
||||
@ -11,6 +14,8 @@ Use this checklist before cutting a release tag.
|
||||
## Validation Gates
|
||||
|
||||
- [ ] `npm test` passes.
|
||||
- [ ] `npm run test:ui` passes.
|
||||
- [ ] Beta quality gates in `docs/quality-gates.md` are met.
|
||||
- [ ] Core smoke flow tested in UI:
|
||||
- [ ] Load sample
|
||||
- [ ] Edit component/pin/net/symbol
|
||||
@ -22,8 +27,10 @@ Use this checklist before cutting a release tag.
|
||||
## Visual Quality
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] No major overlap/crossing regressions vs previous release baseline.
|
||||
- [ ] Dense analog fixture meets gate thresholds (`crossings=0`, `overlaps=0`, detour target).
|
||||
|
||||
## Security / Operations
|
||||
|
||||
@ -35,6 +42,7 @@ Use this checklist before cutting a release tag.
|
||||
- [ ] `CORS_ORIGIN`
|
||||
- [ ] Rate limiting behavior manually validated.
|
||||
- [ ] Health endpoint checked in target environment.
|
||||
- [ ] Structured error telemetry checked for compile/analyze failures.
|
||||
|
||||
## Release Artifacts
|
||||
|
||||
@ -42,3 +50,4 @@ Use this checklist before cutting a release tag.
|
||||
- [ ] `api_version` and `schema_version` changes reviewed/documented.
|
||||
- [ ] Changelog/release notes generated with notable UX/compat changes.
|
||||
- [ ] 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
151
examples/dense-analog.json
Normal 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." }
|
||||
]
|
||||
}
|
||||
561
frontend/app.js
561
frontend/app.js
@ -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 LIST_ROW_HEIGHT = 36;
|
||||
const LIST_OVERSCAN_ROWS = 8;
|
||||
const MIN_SCALE = 0.2;
|
||||
const MAX_SCALE = 5;
|
||||
const FIT_MARGIN = 56;
|
||||
const FOCUS_MARGIN = 96;
|
||||
|
||||
const state = {
|
||||
model: null,
|
||||
@ -41,7 +45,8 @@ const state = {
|
||||
historyFuture: [],
|
||||
historyLimit: 80,
|
||||
historyRestoring: false,
|
||||
symbolMigrationAckHash: null
|
||||
symbolMigrationAckHash: null,
|
||||
commandIndex: 0
|
||||
};
|
||||
|
||||
const el = {
|
||||
@ -49,6 +54,12 @@ const el = {
|
||||
netList: document.getElementById("netList"),
|
||||
instanceFilter: document.getElementById("instanceFilter"),
|
||||
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"),
|
||||
canvasInner: document.getElementById("canvasInner"),
|
||||
selectionBox: document.getElementById("selectionBox"),
|
||||
@ -111,6 +122,7 @@ const el = {
|
||||
jsonEditor: document.getElementById("jsonEditor"),
|
||||
jsonFeedback: document.getElementById("jsonFeedback"),
|
||||
loadSampleBtn: document.getElementById("loadSampleBtn"),
|
||||
resetSampleBtn: document.getElementById("resetSampleBtn"),
|
||||
newProjectBtn: document.getElementById("newProjectBtn"),
|
||||
importBtn: document.getElementById("importBtn"),
|
||||
exportBtn: document.getElementById("exportBtn"),
|
||||
@ -119,6 +131,7 @@ const el = {
|
||||
zoomOutBtn: document.getElementById("zoomOutBtn"),
|
||||
zoomResetBtn: document.getElementById("zoomResetBtn"),
|
||||
fitViewBtn: document.getElementById("fitViewBtn"),
|
||||
focusSelectionBtn: document.getElementById("focusSelectionBtn"),
|
||||
showLabelsInput: document.getElementById("showLabelsInput"),
|
||||
applyJsonBtn: document.getElementById("applyJsonBtn"),
|
||||
showSchemaBtn: document.getElementById("showSchemaBtn"),
|
||||
@ -128,6 +141,7 @@ const el = {
|
||||
copyReproBtn: document.getElementById("copyReproBtn"),
|
||||
autoLayoutBtn: document.getElementById("autoLayoutBtn"),
|
||||
autoTidyBtn: document.getElementById("autoTidyBtn"),
|
||||
shortcutsBtn: document.getElementById("shortcutsBtn"),
|
||||
undoBtn: document.getElementById("undoBtn"),
|
||||
redoBtn: document.getElementById("redoBtn"),
|
||||
renderModeSelect: document.getElementById("renderModeSelect"),
|
||||
@ -138,7 +152,13 @@ const el = {
|
||||
schemaViewer: document.getElementById("schemaViewer"),
|
||||
closeSchemaBtn: document.getElementById("closeSchemaBtn"),
|
||||
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) {
|
||||
@ -289,6 +309,17 @@ function nextRefLike(baseRef) {
|
||||
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) {
|
||||
return String(text ?? "")
|
||||
.replaceAll("&", "&")
|
||||
@ -317,6 +348,11 @@ function setStatus(text, ok = true) {
|
||||
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() {
|
||||
return {
|
||||
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 h = layout?.height ?? 0;
|
||||
if (!w || !h) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
@ -450,37 +486,104 @@ function fitView(layout) {
|
||||
maxY = h;
|
||||
}
|
||||
|
||||
const pad = 80;
|
||||
const bbox = {
|
||||
x: Math.max(0, minX - pad),
|
||||
y: Math.max(0, minY - pad),
|
||||
w: Math.min(w, maxX - minX + pad * 2),
|
||||
h: Math.min(h, maxY - minY + pad * 2)
|
||||
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 centerOnBBox(bbox, fillRatio = 0.93, stickyAdjusted = false) {
|
||||
if (!bbox) {
|
||||
return false;
|
||||
}
|
||||
const viewport = el.canvasViewport.getBoundingClientRect();
|
||||
const sx = (viewport.width * 0.98) / Math.max(1, bbox.w);
|
||||
const sy = (viewport.height * 0.98) / Math.max(1, bbox.h);
|
||||
state.scale = Math.max(0.2, Math.min(4, Math.min(sx, sy)));
|
||||
if (!viewport.width || !viewport.height) {
|
||||
return false;
|
||||
}
|
||||
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.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale;
|
||||
state.userAdjustedView = false;
|
||||
state.userAdjustedView = stickyAdjusted;
|
||||
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) {
|
||||
if (!bbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
centerOnBBox(bbox, 0.8, true);
|
||||
}
|
||||
|
||||
function canvasToSvgPoint(clientX, clientY) {
|
||||
@ -1227,6 +1330,96 @@ function renderSelected() {
|
||||
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() {
|
||||
const errors = state.compile?.errors ?? [];
|
||||
const warnings = state.compile?.warnings ?? [];
|
||||
@ -1238,12 +1431,16 @@ function renderIssues() {
|
||||
|
||||
const rows = [
|
||||
...errors.map(
|
||||
(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>`
|
||||
(issue) => {
|
||||
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(
|
||||
(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>`
|
||||
(issue) => {
|
||||
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);
|
||||
}
|
||||
|
||||
const m = result.layout_metrics;
|
||||
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)`
|
||||
);
|
||||
setStatus(formatCompileStatus(result));
|
||||
} catch (err) {
|
||||
setStatus(`Compile failed: ${err.message}`, false);
|
||||
el.issues.textContent = `Compile error: ${err.message}`;
|
||||
@ -1953,6 +2147,77 @@ function closeSchemaModal() {
|
||||
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) {
|
||||
if (!state.selectedRefs.length && !state.selectedNet) {
|
||||
return model;
|
||||
@ -2119,29 +2384,56 @@ async function runLayoutAction(path) {
|
||||
renderAll();
|
||||
fitView(out.compile.layout);
|
||||
saveSnapshot();
|
||||
setStatus(
|
||||
`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)`
|
||||
);
|
||||
setStatus(formatCompileStatus(out.compile));
|
||||
} catch (err) {
|
||||
setStatus(`Layout action failed: ${err.message}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSample() {
|
||||
async function fetchSampleModel() {
|
||||
const res = await fetch("/sample.schemeta.json");
|
||||
if (!res.ok) {
|
||||
setStatus("Sample missing.", false);
|
||||
return;
|
||||
throw new Error("Sample missing.");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const model = await res.json();
|
||||
if (state.model) {
|
||||
pushHistory("load-sample");
|
||||
async function resetToSample(opts = {}) {
|
||||
const push = opts.pushHistory !== false;
|
||||
const before = state.model ? clone(state.model) : null;
|
||||
const model = await fetchSampleModel();
|
||||
if (push && state.model) {
|
||||
pushHistory("reset-sample");
|
||||
}
|
||||
setSelectedRefs([]);
|
||||
state.selectedNet = 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 });
|
||||
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() {
|
||||
@ -2155,6 +2447,65 @@ function setupEvents() {
|
||||
});
|
||||
el.instanceList.addEventListener("scroll", renderInstances, { 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) => {
|
||||
if (!section) {
|
||||
return;
|
||||
@ -2216,6 +2567,12 @@ function setupEvents() {
|
||||
});
|
||||
|
||||
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]");
|
||||
if (!row) {
|
||||
return;
|
||||
@ -2730,23 +3087,27 @@ function setupEvents() {
|
||||
});
|
||||
|
||||
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;
|
||||
updateTransform();
|
||||
});
|
||||
|
||||
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;
|
||||
updateTransform();
|
||||
});
|
||||
|
||||
el.zoomResetBtn.addEventListener("click", () => {
|
||||
state.scale = 1;
|
||||
state.panX = 40;
|
||||
state.panY = 40;
|
||||
state.userAdjustedView = true;
|
||||
updateTransform();
|
||||
if (state.compile?.layout) {
|
||||
fitView(state.compile.layout);
|
||||
} else {
|
||||
state.scale = 1;
|
||||
state.panX = 40;
|
||||
state.panY = 40;
|
||||
state.userAdjustedView = false;
|
||||
updateTransform();
|
||||
}
|
||||
});
|
||||
|
||||
el.fitViewBtn.addEventListener("click", () => {
|
||||
@ -2755,6 +3116,10 @@ function setupEvents() {
|
||||
}
|
||||
});
|
||||
|
||||
el.focusSelectionBtn.addEventListener("click", () => {
|
||||
focusSelection();
|
||||
});
|
||||
|
||||
el.showLabelsInput.addEventListener("change", () => {
|
||||
state.showLabels = el.showLabelsInput.checked;
|
||||
setLabelLayerVisibility();
|
||||
@ -2782,7 +3147,7 @@ function setupEvents() {
|
||||
(evt) => {
|
||||
evt.preventDefault();
|
||||
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 px = evt.clientX - rect.left;
|
||||
@ -3012,6 +3377,40 @@ function setupEvents() {
|
||||
el.connectPinBtn.click();
|
||||
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) => {
|
||||
@ -3022,6 +3421,14 @@ function setupEvents() {
|
||||
}
|
||||
}
|
||||
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")) {
|
||||
closeSchemaModal();
|
||||
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.formatJsonBtn.addEventListener("click", () => {
|
||||
@ -3139,6 +3597,13 @@ function setupEvents() {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await runLayoutAction("/layout/auto");
|
||||
|
||||
@ -15,10 +15,12 @@
|
||||
<div class="actions">
|
||||
<button id="newProjectBtn" aria-label="Create new project">New</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="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</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="shortcutsBtn" aria-label="Show keyboard shortcuts">Shortcuts</button>
|
||||
<button id="undoBtn" title="Undo (Ctrl/Cmd+Z)">Undo</button>
|
||||
<button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button>
|
||||
<label class="inlineSelect">
|
||||
@ -40,6 +42,19 @@
|
||||
<button id="isolateComponentBtn" class="chip">Isolate</button>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<section>
|
||||
@ -48,16 +63,42 @@
|
||||
<button id="isolateNetBtn" class="chip">Isolate</button>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<section class="pane center">
|
||||
<div class="canvasTools">
|
||||
<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="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>
|
||||
<span id="compileStatus">Idle</span>
|
||||
</div>
|
||||
@ -254,6 +295,37 @@
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,56 +1,50 @@
|
||||
:root {
|
||||
--bg: #eef2f6;
|
||||
--panel: #ffffff;
|
||||
--ink: #1d2939;
|
||||
--ink-soft: #667085;
|
||||
--line: #d0d5dd;
|
||||
--accent: #155eef;
|
||||
--accent-soft: #dbe8ff;
|
||||
--warn: #b54708;
|
||||
--error: #b42318;
|
||||
--ok: #067647;
|
||||
--bg-0: #f5f7fb;
|
||||
--bg-1: #ebf1f8;
|
||||
--bg-2: #dce8f4;
|
||||
--panel: #fbfdff;
|
||||
--panel-strong: #ffffff;
|
||||
--canvas: #f4f8fd;
|
||||
--ink: #0f1728;
|
||||
--ink-muted: #4a607c;
|
||||
--ink-subtle: #7388a3;
|
||||
--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;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
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);
|
||||
background: radial-gradient(circle at 8% 8%, #fef7e6, transparent 30%),
|
||||
radial-gradient(circle at 88% 12%, #e0f2ff, transparent 30%), var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.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;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, #fff4de 0 20%, transparent 30%),
|
||||
radial-gradient(circle at 96% 5%, #dbf2ff 0 18%, transparent 28%),
|
||||
linear-gradient(180deg, var(--bg-1), var(--bg-0));
|
||||
}
|
||||
|
||||
button,
|
||||
@ -60,44 +54,101 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 6px 10px;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
.list li,
|
||||
details > summary {
|
||||
transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
.list li:focus-visible {
|
||||
outline: 2px solid #155eef;
|
||||
.list li:focus-visible,
|
||||
details > summary:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
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 {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
button.chip {
|
||||
padding: 4px 8px;
|
||||
padding: 4px 9px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
button.activeChip {
|
||||
background: var(--accent-soft);
|
||||
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,
|
||||
@ -109,24 +160,22 @@ button.activeChip {
|
||||
}
|
||||
|
||||
.inlineSelect select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 5px 8px;
|
||||
min-width: 138px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
height: calc(100vh - 65px);
|
||||
height: calc(100vh - 77px);
|
||||
display: grid;
|
||||
grid-template-columns: 270px minmax(480px, 1fr) 380px;
|
||||
gap: 10px;
|
||||
grid-template-columns: 270px minmax(520px, 1fr) 392px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(180deg, var(--panel), #f6faff);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.pane.left,
|
||||
@ -138,16 +187,27 @@ button.activeChip {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pane.center {
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #fcfeff, #f6faff);
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sectionHead h2 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
input,
|
||||
@ -155,32 +215,44 @@ textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--ink-subtle);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 250px;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 230px;
|
||||
padding: 0;
|
||||
max-height: 250px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.list li {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.list li:hover {
|
||||
background: #f2f7ff;
|
||||
}
|
||||
|
||||
.list li:last-child {
|
||||
@ -189,6 +261,8 @@ textarea {
|
||||
|
||||
.list li.active {
|
||||
background: var(--accent-soft);
|
||||
color: #0b3a84;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list li.listSpacer {
|
||||
@ -198,11 +272,71 @@ textarea {
|
||||
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 {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fbfdff;
|
||||
padding: 8px;
|
||||
color: var(--ink-soft);
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@ -210,9 +344,9 @@ textarea {
|
||||
.editorCard {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: #fcfcfd;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px;
|
||||
background: #f8fbff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@ -220,9 +354,10 @@ textarea {
|
||||
|
||||
.editorSection {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 8px;
|
||||
background: #fff;
|
||||
background: #ffffffcc;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorSection > summary {
|
||||
@ -232,11 +367,15 @@ textarea {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.editorSection > summary:hover {
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.editorSection[open] > summary {
|
||||
border-bottom-color: var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.editorSection > summary::-webkit-details-marker {
|
||||
@ -251,27 +390,27 @@ textarea {
|
||||
|
||||
.editorActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hintText {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.miniList {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
max-height: 170px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.miniRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
@ -284,47 +423,48 @@ textarea {
|
||||
|
||||
.symbolPinRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto;
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto;
|
||||
}
|
||||
|
||||
.pinCol {
|
||||
min-width: 0;
|
||||
padding: 5px 6px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.symbolPinRow.invalidRow {
|
||||
background: #fff3f2;
|
||||
background: #fff3f1;
|
||||
}
|
||||
|
||||
.symbolValidationError {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.migrationPreview {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
background: #f8fafc;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 6px;
|
||||
margin: 6px 0;
|
||||
line-height: 1.35;
|
||||
background: #f5f9ff;
|
||||
}
|
||||
|
||||
.canvasTools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #f8fbff, #f2f7ff);
|
||||
}
|
||||
|
||||
#compileStatus {
|
||||
margin-left: auto;
|
||||
color: var(--ink-soft);
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
@ -333,12 +473,14 @@ textarea {
|
||||
|
||||
.canvasViewport {
|
||||
height: calc(100% - 52px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
background-image: linear-gradient(0deg, #ebeff3 1px, transparent 1px),
|
||||
linear-gradient(90deg, #ebeff3 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
background:
|
||||
linear-gradient(0deg, #d9e4f0 1px, transparent 1px),
|
||||
linear-gradient(90deg, #d9e4f0 1px, transparent 1px),
|
||||
linear-gradient(180deg, var(--canvas), #edf4fc);
|
||||
background-size: 20px 20px, 20px 20px, auto;
|
||||
}
|
||||
|
||||
.canvasViewport.dragging {
|
||||
@ -346,10 +488,10 @@ textarea {
|
||||
}
|
||||
|
||||
.canvasInner {
|
||||
transform-origin: 0 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.canvasInner svg {
|
||||
@ -358,10 +500,23 @@ textarea {
|
||||
|
||||
.selectionBox {
|
||||
position: absolute;
|
||||
border: 1px solid #155eef;
|
||||
background: rgba(21, 94, 239, 0.12);
|
||||
pointer-events: none;
|
||||
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 {
|
||||
@ -375,20 +530,20 @@ textarea {
|
||||
}
|
||||
|
||||
.jsonActions button {
|
||||
font-size: 0.77rem;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.jsonFeedback {
|
||||
min-height: 18px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 6px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.issueRow {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: 8px;
|
||||
padding: 7px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
@ -396,51 +551,48 @@ textarea {
|
||||
}
|
||||
|
||||
.issueRow:hover {
|
||||
background: #f8faff;
|
||||
background: #f4f8ff;
|
||||
}
|
||||
|
||||
.issueErr {
|
||||
border-color: #fecdca;
|
||||
background: #fff6f5;
|
||||
border-color: #f3bab5;
|
||||
background: #fff5f4;
|
||||
}
|
||||
|
||||
.issueWarn {
|
||||
border-color: #fedf89;
|
||||
background: #fffcf5;
|
||||
border-color: #f0d28b;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.issueTitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.issueMeta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-soft);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.pinTooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffffee;
|
||||
color: var(--ink);
|
||||
.issueActions {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.issueActions button {
|
||||
font-size: 0.74rem;
|
||||
z-index: 20;
|
||||
box-shadow: 0 6px 20px rgba(16, 24, 40, 0.12);
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(14, 22, 36, 0.54);
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
@ -450,30 +602,37 @@ textarea {
|
||||
.modalCard {
|
||||
width: min(1120px, 100%);
|
||||
height: min(88vh, 900px);
|
||||
background: #fff;
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compactModal {
|
||||
width: min(760px, 100%);
|
||||
height: auto;
|
||||
max-height: min(86vh, 760px);
|
||||
}
|
||||
|
||||
.modalHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modalHead h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.modalHint {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@ -482,6 +641,69 @@ textarea {
|
||||
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 {
|
||||
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) {
|
||||
.workspace {
|
||||
height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.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
87
package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,13 @@
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pixelmatch": "^7.1.0",
|
||||
"playwright": "^1.58.2",
|
||||
"pngjs": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
158
src/layout.js
158
src/layout.js
@ -18,7 +18,8 @@ const NET_CLASS_PRIORITY = {
|
||||
const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]);
|
||||
const DEFAULT_RENDER_MODE = "schematic_stub";
|
||||
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) {
|
||||
return Math.round(value / GRID) * GRID;
|
||||
@ -375,8 +376,42 @@ function connectivityDegree(model) {
|
||||
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) {
|
||||
const { rank, degree, instanceByRef, respectLocks } = context;
|
||||
const { rank, degree, instanceByRef, respectLocks, laneProfiles } = context;
|
||||
const refs = [...group.members].sort((a, b) => a.localeCompare(b));
|
||||
const cols = rankColumnsForRefs(refs, rank);
|
||||
const colOrder = [...cols.keys()].sort((a, b) => a - b);
|
||||
@ -409,6 +444,11 @@ function placeGroup(model, group, start, context) {
|
||||
|
||||
for (const col of colOrder) {
|
||||
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 db = degree.get(b) ?? 0;
|
||||
if (da !== db) {
|
||||
@ -417,8 +457,19 @@ function placeGroup(model, group, start, context) {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
let yCursor = start.y;
|
||||
const byLane = new Map();
|
||||
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);
|
||||
if (!inst) {
|
||||
continue;
|
||||
@ -449,7 +500,9 @@ function placeGroup(model, group, start, context) {
|
||||
maxX = Math.max(maxX, x + sym.body.width);
|
||||
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) {
|
||||
const map = new Map();
|
||||
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 { rank } = computeRanks(model);
|
||||
const degree = connectivityDegree(model);
|
||||
const laneProfiles = refLaneProfiles(model);
|
||||
const instanceByRef = buildInstanceMap(instances);
|
||||
const groups = buildConstraintGroups(model, rank);
|
||||
|
||||
@ -602,6 +717,7 @@ function placeInstances(model, options = {}) {
|
||||
const out = placeGroup(model, group, origin, {
|
||||
rank,
|
||||
degree,
|
||||
laneProfiles,
|
||||
instanceByRef,
|
||||
respectLocks
|
||||
});
|
||||
@ -618,6 +734,7 @@ function placeInstances(model, options = {}) {
|
||||
|
||||
applyAlignmentConstraints(placedMap, model.constraints);
|
||||
applyNearConstraints(model, placedMap, model.constraints);
|
||||
resolvePlacementOverlaps(model, placedMap, { respectLocks });
|
||||
|
||||
return { placed, placedMap };
|
||||
}
|
||||
@ -1073,7 +1190,7 @@ function uniquePoints(points) {
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function routeLabelTieNet(net, pinNodes, context) {
|
||||
function routeLabelTieNet(net, pinNodes, context, fallbackReason = null) {
|
||||
const routes = [];
|
||||
const tiePoints = [];
|
||||
|
||||
@ -1122,7 +1239,7 @@ function routeLabelTieNet(net, pinNodes, context) {
|
||||
total_bends: 0,
|
||||
detour_ratio: 1,
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1400,6 +1520,26 @@ function shouldUseLabelTie(net, pinNodes, context) {
|
||||
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) {
|
||||
const obstacles = buildObstacles(model, placed);
|
||||
const edgeUsage = new Map();
|
||||
@ -1434,10 +1574,14 @@ function routeAllNets(model, placed, placedMap, bounds, options) {
|
||||
busNetNames
|
||||
};
|
||||
|
||||
const routed = shouldUseLabelTie(net, pinNodes, routeContext)
|
||||
let routed = shouldUseLabelTie(net, pinNodes, routeContext)
|
||||
? routeLabelTieNet(net, pinNodes, routeContext)
|
||||
: routePointToPointNet(net, pinNodes, routeContext);
|
||||
|
||||
if (shouldFallbackToTieByQuality(net, pinNodes, routed)) {
|
||||
routed = routeLabelTieNet(net, pinNodes, routeContext, "quality_policy");
|
||||
}
|
||||
|
||||
routedByName.set(net.name, {
|
||||
net,
|
||||
isBusMember: busNetNames.has(net.name),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname, join, normalize } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@ -38,14 +39,18 @@ function json(res, status, payload) {
|
||||
res.end(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
export function errorEnvelope(code, message) {
|
||||
return {
|
||||
export function errorEnvelope(code, message, details = {}) {
|
||||
const out = {
|
||||
ok: false,
|
||||
error: {
|
||||
code,
|
||||
message
|
||||
}
|
||||
};
|
||||
if (details && typeof details === "object") {
|
||||
Object.assign(out, details);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isAuthorizedRequest(req) {
|
||||
@ -149,11 +154,32 @@ export function parsePayloadOptions(body) {
|
||||
}
|
||||
|
||||
export function withEnvelopeMeta(payload) {
|
||||
return {
|
||||
const out = {
|
||||
api_version: API_VERSION,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
...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) {
|
||||
@ -194,8 +220,22 @@ function withinRateLimit(req) {
|
||||
|
||||
export function createRequestHandler() {
|
||||
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) {
|
||||
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;
|
||||
@ -211,6 +251,7 @@ export function createRequestHandler() {
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
service: "schemeta",
|
||||
request_id: requestId,
|
||||
api_version: API_VERSION,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
status: "ok",
|
||||
@ -221,6 +262,7 @@ export function createRequestHandler() {
|
||||
if (req.method === "GET" && pathname === "/mcp/ui-bundle") {
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
request_id: requestId,
|
||||
name: "schemeta-workspace",
|
||||
version: "0.2.0",
|
||||
api_version: API_VERSION,
|
||||
@ -234,56 +276,56 @@ export function createRequestHandler() {
|
||||
if (req.method === "POST" && pathname === "/analyze") {
|
||||
if (!isAuthorizedRequest(req)) {
|
||||
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)) {
|
||||
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 {
|
||||
const body = await readBody(req);
|
||||
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) {
|
||||
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") {
|
||||
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 (!isAuthorizedRequest(req)) {
|
||||
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)) {
|
||||
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 {
|
||||
const body = await readBody(req);
|
||||
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) {
|
||||
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") {
|
||||
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 (!isAuthorizedRequest(req)) {
|
||||
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)) {
|
||||
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 {
|
||||
const body = await readBody(req);
|
||||
@ -293,6 +335,7 @@ export function createRequestHandler() {
|
||||
const laidOut = applyLayoutToModel(model, { respectLocks: false });
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
request_id: requestId,
|
||||
api_version: API_VERSION,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
model: laidOut,
|
||||
@ -300,22 +343,22 @@ export function createRequestHandler() {
|
||||
});
|
||||
} catch (err) {
|
||||
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") {
|
||||
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 (!isAuthorizedRequest(req)) {
|
||||
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)) {
|
||||
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 {
|
||||
const body = await readBody(req);
|
||||
@ -325,6 +368,7 @@ export function createRequestHandler() {
|
||||
const laidOut = applyLayoutToModel(model, { respectLocks: true });
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
request_id: requestId,
|
||||
api_version: API_VERSION,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
model: laidOut,
|
||||
@ -332,12 +376,12 @@ export function createRequestHandler() {
|
||||
});
|
||||
} catch (err) {
|
||||
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") {
|
||||
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 }));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -17,11 +17,12 @@ import {
|
||||
|
||||
test("REST compile contract shape is stable with version metadata", () => {
|
||||
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.api_version, REST_API_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.warnings));
|
||||
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", () => {
|
||||
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.error.code, "rate_limited");
|
||||
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", () => {
|
||||
|
||||
BIN
tests/baselines/ui/dense-analog.png
Normal file
BIN
tests/baselines/ui/dense-analog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
BIN
tests/baselines/ui/explicit-mode-auto-tidy.png
Normal file
BIN
tests/baselines/ui/explicit-mode-auto-tidy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
BIN
tests/baselines/ui/initial.png
Normal file
BIN
tests/baselines/ui/initial.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
BIN
tests/baselines/ui/laptop-viewport.png
Normal file
BIN
tests/baselines/ui/laptop-viewport.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
BIN
tests/baselines/ui/post-migration-apply.png
Normal file
BIN
tests/baselines/ui/post-migration-apply.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
BIN
tests/baselines/ui/selected-u2.png
Normal file
BIN
tests/baselines/ui/selected-u2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
259
tests/ui-regression-runner.js
Normal file
259
tests/ui-regression-runner.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user