Compare commits
No commits in common. "31a47346ea5a4e18cd2875d304eeba949796fb87" and "347d547875a1ee5f5a0c54ae97c879c2a118ce81" have entirely different histories.
31a47346ea
...
347d547875
@ -32,15 +32,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: npx playwright install chromium
|
|
||||||
|
|
||||||
- name: Run browser regression
|
|
||||||
run: npm run test:ui
|
|
||||||
|
|
||||||
- name: Browser artifacts listing (always)
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "Playwright output:"
|
|
||||||
ls -R output/playwright || true
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +0,0 @@
|
|||||||
.playwright-cli/
|
|
||||||
output/playwright/
|
|
||||||
node_modules/
|
|
||||||
27
README.md
27
README.md
@ -25,7 +25,6 @@ Open:
|
|||||||
|
|
||||||
Version metadata:
|
Version metadata:
|
||||||
- REST and MCP tool responses include `api_version` and `schema_version`.
|
- REST and MCP tool responses include `api_version` and `schema_version`.
|
||||||
- REST responses also include `request_id` and `x-request-id` for request correlation.
|
|
||||||
- Current values: `api_version=0.3.0`, `schema_version=1.0.0`.
|
- Current values: `api_version=0.3.0`, `schema_version=1.0.0`.
|
||||||
- Compatibility policy (current): additive, backward-compatible fields may be introduced in the same API minor version.
|
- Compatibility policy (current): additive, backward-compatible fields may be introduced in the same API minor version.
|
||||||
|
|
||||||
@ -38,13 +37,9 @@ Operational limits:
|
|||||||
Docs:
|
Docs:
|
||||||
- `docs/release-checklist.md`
|
- `docs/release-checklist.md`
|
||||||
- `docs/operations-runbook.md`
|
- `docs/operations-runbook.md`
|
||||||
- `docs/quality-gates.md`
|
|
||||||
- `docs/phase4-execution-plan.md`
|
|
||||||
- `docs/api-mcp-contracts.md`
|
|
||||||
|
|
||||||
CI:
|
CI:
|
||||||
- `.gitea/workflows/ci.yml` runs syntax checks + full test suite on push/PR.
|
- `.gitea/workflows/ci.yml` runs syntax checks + full test suite on push/PR.
|
||||||
- CI also runs browser regression (`npm run test:ui`) after installing Playwright Chromium.
|
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
|
|
||||||
@ -160,36 +155,14 @@ Tools:
|
|||||||
## Workspace behavior highlights
|
## Workspace behavior highlights
|
||||||
|
|
||||||
- Fit-to-view default on load/import/apply
|
- Fit-to-view default on load/import/apply
|
||||||
- Focus-selection + reset-view controls for faster navigation in dense schematics
|
|
||||||
- `Reset Sample` one-click deterministic baseline restore for QA/demo loops
|
|
||||||
- Space + drag pan, wheel zoom, fit button
|
- Space + drag pan, wheel zoom, fit button
|
||||||
- Net/component/pin selection with dimming + isolate toggles
|
- Net/component/pin selection with dimming + isolate toggles
|
||||||
- Selected panel editors for component properties, full pin properties, full symbol body/pin editing, and net connect/disconnect operations
|
- Selected panel editors for component properties, full pin properties, full symbol body/pin editing, and net connect/disconnect operations
|
||||||
- Click diagnostics to jump/flash focused net/component/pin
|
- Click diagnostics to jump/flash focused net/component/pin
|
||||||
- Auto Layout and Auto Tidy actions
|
- Auto Layout and Auto Tidy actions
|
||||||
- `Shortcuts` helper modal and `Ctrl/Cmd+K` command palette quick actions
|
|
||||||
- Keyboard shortcuts:
|
- Keyboard shortcuts:
|
||||||
- `Ctrl/Cmd+Z` undo
|
- `Ctrl/Cmd+Z` undo
|
||||||
- `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo
|
- `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo
|
||||||
- `Ctrl/Cmd+K` open command palette
|
|
||||||
- `Space` rotate selected components (or pan when no selection)
|
- `Space` rotate selected components (or pan when no selection)
|
||||||
- `F` focus current selection
|
|
||||||
- `Alt+Enter` apply current selection editor (component/pin/net)
|
- `Alt+Enter` apply current selection editor (component/pin/net)
|
||||||
- `Alt+C` connect selected pin to chosen net
|
- `Alt+C` connect selected pin to chosen net
|
||||||
|
|
||||||
## Browser Regression
|
|
||||||
|
|
||||||
Run browser interaction + visual regression checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx playwright install chromium
|
|
||||||
npm run test:ui
|
|
||||||
```
|
|
||||||
|
|
||||||
Refresh visual baselines intentionally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
UPDATE_SNAPSHOTS=1 npm run test:ui
|
|
||||||
```
|
|
||||||
|
|
||||||
Baselines are stored in `tests/baselines/ui/`.
|
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
# 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,18 +35,6 @@ This runbook covers baseline production operation for Schemeta API + UI.
|
|||||||
- `GET /mcp/ui-bundle`
|
- `GET /mcp/ui-bundle`
|
||||||
- Metadata for MCP UI embedding.
|
- Metadata for MCP UI embedding.
|
||||||
|
|
||||||
## Request Correlation and Audit Logs
|
|
||||||
|
|
||||||
- Every response includes `x-request-id`.
|
|
||||||
- API envelopes include `request_id` for correlation in clients and logs.
|
|
||||||
- Server emits one JSON audit log entry per request on response finish with:
|
|
||||||
- `request_id`
|
|
||||||
- `method`
|
|
||||||
- `path`
|
|
||||||
- `status`
|
|
||||||
- `duration_ms`
|
|
||||||
- `client`
|
|
||||||
|
|
||||||
## Production Checks
|
## Production Checks
|
||||||
|
|
||||||
1. Verify process liveness:
|
1. Verify process liveness:
|
||||||
@ -90,6 +78,6 @@ This runbook covers baseline production operation for Schemeta API + UI.
|
|||||||
|
|
||||||
## Observability Recommendations
|
## Observability Recommendations
|
||||||
|
|
||||||
- Structured request logs are emitted by the app; keep proxy logs for edge-level traces.
|
- Add structured request logs at reverse proxy layer.
|
||||||
- Track latency percentiles for `/compile` and `/analyze`.
|
- Track latency percentiles for `/compile` and `/analyze`.
|
||||||
- Track per-endpoint status code rates and top warning/error IDs.
|
- Track per-endpoint status code rates and top warning/error IDs.
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
# 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,9 +1,6 @@
|
|||||||
# Schemeta Release Checklist
|
# Schemeta Release Checklist
|
||||||
|
|
||||||
Use this checklist before cutting a release tag.
|
Use this checklist before cutting a release tag.
|
||||||
Reference docs:
|
|
||||||
- `docs/quality-gates.md`
|
|
||||||
- `docs/phase4-execution-plan.md`
|
|
||||||
|
|
||||||
## Pre-merge
|
## Pre-merge
|
||||||
|
|
||||||
@ -14,8 +11,6 @@ Reference docs:
|
|||||||
## Validation Gates
|
## Validation Gates
|
||||||
|
|
||||||
- [ ] `npm test` passes.
|
- [ ] `npm test` passes.
|
||||||
- [ ] `npm run test:ui` passes.
|
|
||||||
- [ ] Beta quality gates in `docs/quality-gates.md` are met.
|
|
||||||
- [ ] Core smoke flow tested in UI:
|
- [ ] Core smoke flow tested in UI:
|
||||||
- [ ] Load sample
|
- [ ] Load sample
|
||||||
- [ ] Edit component/pin/net/symbol
|
- [ ] Edit component/pin/net/symbol
|
||||||
@ -27,10 +22,8 @@ Reference docs:
|
|||||||
## Visual Quality
|
## Visual Quality
|
||||||
|
|
||||||
- [ ] Representative circuits reviewed for routing readability.
|
- [ ] Representative circuits reviewed for routing readability.
|
||||||
- [ ] Visual baselines updated intentionally (`tests/baselines/ui`) and screenshot diff checks pass.
|
|
||||||
- [ ] Labels remain legible at common zoom levels.
|
- [ ] Labels remain legible at common zoom levels.
|
||||||
- [ ] No major overlap/crossing regressions vs previous release baseline.
|
- [ ] No major overlap/crossing regressions vs previous release baseline.
|
||||||
- [ ] Dense analog fixture meets gate thresholds (`crossings=0`, `overlaps=0`, detour target).
|
|
||||||
|
|
||||||
## Security / Operations
|
## Security / Operations
|
||||||
|
|
||||||
@ -42,7 +35,6 @@ Reference docs:
|
|||||||
- [ ] `CORS_ORIGIN`
|
- [ ] `CORS_ORIGIN`
|
||||||
- [ ] Rate limiting behavior manually validated.
|
- [ ] Rate limiting behavior manually validated.
|
||||||
- [ ] Health endpoint checked in target environment.
|
- [ ] Health endpoint checked in target environment.
|
||||||
- [ ] Structured error telemetry checked for compile/analyze failures.
|
|
||||||
|
|
||||||
## Release Artifacts
|
## Release Artifacts
|
||||||
|
|
||||||
@ -50,4 +42,3 @@ Reference docs:
|
|||||||
- [ ] `api_version` and `schema_version` changes reviewed/documented.
|
- [ ] `api_version` and `schema_version` changes reviewed/documented.
|
||||||
- [ ] Changelog/release notes generated with notable UX/compat changes.
|
- [ ] Changelog/release notes generated with notable UX/compat changes.
|
||||||
- [ ] Tag pushed and release announcement links to milestone/issues.
|
- [ ] Tag pushed and release announcement links to milestone/issues.
|
||||||
- [ ] GA gates in `docs/quality-gates.md` confirmed complete.
|
|
||||||
|
|||||||
@ -1,151 +0,0 @@
|
|||||||
{
|
|
||||||
"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." }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
553
frontend/app.js
553
frontend/app.js
@ -7,10 +7,6 @@ const PIN_SIDES = ["left", "right", "top", "bottom"];
|
|||||||
const PIN_TYPES = ["power_in", "power_out", "input", "output", "bidirectional", "passive", "analog", "ground"];
|
const PIN_TYPES = ["power_in", "power_out", "input", "output", "bidirectional", "passive", "analog", "ground"];
|
||||||
const LIST_ROW_HEIGHT = 36;
|
const LIST_ROW_HEIGHT = 36;
|
||||||
const LIST_OVERSCAN_ROWS = 8;
|
const LIST_OVERSCAN_ROWS = 8;
|
||||||
const MIN_SCALE = 0.2;
|
|
||||||
const MAX_SCALE = 5;
|
|
||||||
const FIT_MARGIN = 56;
|
|
||||||
const FOCUS_MARGIN = 96;
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
model: null,
|
model: null,
|
||||||
@ -45,8 +41,7 @@ const state = {
|
|||||||
historyFuture: [],
|
historyFuture: [],
|
||||||
historyLimit: 80,
|
historyLimit: 80,
|
||||||
historyRestoring: false,
|
historyRestoring: false,
|
||||||
symbolMigrationAckHash: null,
|
symbolMigrationAckHash: null
|
||||||
commandIndex: 0
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
@ -54,12 +49,6 @@ const el = {
|
|||||||
netList: document.getElementById("netList"),
|
netList: document.getElementById("netList"),
|
||||||
instanceFilter: document.getElementById("instanceFilter"),
|
instanceFilter: document.getElementById("instanceFilter"),
|
||||||
netFilter: document.getElementById("netFilter"),
|
netFilter: document.getElementById("netFilter"),
|
||||||
newComponentRefInput: document.getElementById("newComponentRefInput"),
|
|
||||||
newComponentTypeSelect: document.getElementById("newComponentTypeSelect"),
|
|
||||||
addComponentBtn: document.getElementById("addComponentBtn"),
|
|
||||||
newQuickNetNameInput: document.getElementById("newQuickNetNameInput"),
|
|
||||||
newQuickNetClassSelect: document.getElementById("newQuickNetClassSelect"),
|
|
||||||
addQuickNetBtn: document.getElementById("addQuickNetBtn"),
|
|
||||||
canvasViewport: document.getElementById("canvasViewport"),
|
canvasViewport: document.getElementById("canvasViewport"),
|
||||||
canvasInner: document.getElementById("canvasInner"),
|
canvasInner: document.getElementById("canvasInner"),
|
||||||
selectionBox: document.getElementById("selectionBox"),
|
selectionBox: document.getElementById("selectionBox"),
|
||||||
@ -122,7 +111,6 @@ const el = {
|
|||||||
jsonEditor: document.getElementById("jsonEditor"),
|
jsonEditor: document.getElementById("jsonEditor"),
|
||||||
jsonFeedback: document.getElementById("jsonFeedback"),
|
jsonFeedback: document.getElementById("jsonFeedback"),
|
||||||
loadSampleBtn: document.getElementById("loadSampleBtn"),
|
loadSampleBtn: document.getElementById("loadSampleBtn"),
|
||||||
resetSampleBtn: document.getElementById("resetSampleBtn"),
|
|
||||||
newProjectBtn: document.getElementById("newProjectBtn"),
|
newProjectBtn: document.getElementById("newProjectBtn"),
|
||||||
importBtn: document.getElementById("importBtn"),
|
importBtn: document.getElementById("importBtn"),
|
||||||
exportBtn: document.getElementById("exportBtn"),
|
exportBtn: document.getElementById("exportBtn"),
|
||||||
@ -131,7 +119,6 @@ const el = {
|
|||||||
zoomOutBtn: document.getElementById("zoomOutBtn"),
|
zoomOutBtn: document.getElementById("zoomOutBtn"),
|
||||||
zoomResetBtn: document.getElementById("zoomResetBtn"),
|
zoomResetBtn: document.getElementById("zoomResetBtn"),
|
||||||
fitViewBtn: document.getElementById("fitViewBtn"),
|
fitViewBtn: document.getElementById("fitViewBtn"),
|
||||||
focusSelectionBtn: document.getElementById("focusSelectionBtn"),
|
|
||||||
showLabelsInput: document.getElementById("showLabelsInput"),
|
showLabelsInput: document.getElementById("showLabelsInput"),
|
||||||
applyJsonBtn: document.getElementById("applyJsonBtn"),
|
applyJsonBtn: document.getElementById("applyJsonBtn"),
|
||||||
showSchemaBtn: document.getElementById("showSchemaBtn"),
|
showSchemaBtn: document.getElementById("showSchemaBtn"),
|
||||||
@ -141,7 +128,6 @@ const el = {
|
|||||||
copyReproBtn: document.getElementById("copyReproBtn"),
|
copyReproBtn: document.getElementById("copyReproBtn"),
|
||||||
autoLayoutBtn: document.getElementById("autoLayoutBtn"),
|
autoLayoutBtn: document.getElementById("autoLayoutBtn"),
|
||||||
autoTidyBtn: document.getElementById("autoTidyBtn"),
|
autoTidyBtn: document.getElementById("autoTidyBtn"),
|
||||||
shortcutsBtn: document.getElementById("shortcutsBtn"),
|
|
||||||
undoBtn: document.getElementById("undoBtn"),
|
undoBtn: document.getElementById("undoBtn"),
|
||||||
redoBtn: document.getElementById("redoBtn"),
|
redoBtn: document.getElementById("redoBtn"),
|
||||||
renderModeSelect: document.getElementById("renderModeSelect"),
|
renderModeSelect: document.getElementById("renderModeSelect"),
|
||||||
@ -152,13 +138,7 @@ const el = {
|
|||||||
schemaViewer: document.getElementById("schemaViewer"),
|
schemaViewer: document.getElementById("schemaViewer"),
|
||||||
closeSchemaBtn: document.getElementById("closeSchemaBtn"),
|
closeSchemaBtn: document.getElementById("closeSchemaBtn"),
|
||||||
copySchemaBtn: document.getElementById("copySchemaBtn"),
|
copySchemaBtn: document.getElementById("copySchemaBtn"),
|
||||||
downloadSchemaBtn: document.getElementById("downloadSchemaBtn"),
|
downloadSchemaBtn: document.getElementById("downloadSchemaBtn")
|
||||||
shortcutsModal: document.getElementById("shortcutsModal"),
|
|
||||||
closeShortcutsBtn: document.getElementById("closeShortcutsBtn"),
|
|
||||||
commandModal: document.getElementById("commandModal"),
|
|
||||||
closeCommandBtn: document.getElementById("closeCommandBtn"),
|
|
||||||
commandInput: document.getElementById("commandInput"),
|
|
||||||
commandList: document.getElementById("commandList")
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function toGrid(v) {
|
function toGrid(v) {
|
||||||
@ -309,17 +289,6 @@ function nextRefLike(baseRef) {
|
|||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultRefSeedForPart(partName) {
|
|
||||||
const part = String(partName ?? "").toLowerCase();
|
|
||||||
if (part === "resistor") return "R1";
|
|
||||||
if (part === "capacitor") return "C1";
|
|
||||||
if (part === "inductor") return "L1";
|
|
||||||
if (part === "diode" || part === "led") return "D1";
|
|
||||||
if (part === "connector") return "J1";
|
|
||||||
if (part === "generic") return "X1";
|
|
||||||
return "U1";
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(text) {
|
function escHtml(text) {
|
||||||
return String(text ?? "")
|
return String(text ?? "")
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
@ -348,11 +317,6 @@ function setStatus(text, ok = true) {
|
|||||||
el.compileStatus.className = ok ? "status-ok" : "";
|
el.compileStatus.className = ok ? "status-ok" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCompileStatus(result) {
|
|
||||||
const m = result?.layout_metrics ?? {};
|
|
||||||
return `Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings ?? 0} crossings, ${m.overlap_edges ?? 0} overlaps, ${m.total_bends ?? 0} bends, ${m.label_tie_routes ?? 0} tie-nets, ${(m.detour_ratio ?? 1).toFixed(2)}x detour)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultProject() {
|
function defaultProject() {
|
||||||
return {
|
return {
|
||||||
meta: { title: "Untitled Schemeta Project" },
|
meta: { title: "Untitled Schemeta Project" },
|
||||||
@ -452,11 +416,11 @@ function updateTransform() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutBounds(layout, margin = FIT_MARGIN) {
|
function fitView(layout) {
|
||||||
const w = layout?.width ?? 0;
|
const w = layout?.width ?? 0;
|
||||||
const h = layout?.height ?? 0;
|
const h = layout?.height ?? 0;
|
||||||
if (!w || !h) {
|
if (!w || !h) {
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let minX = Number.POSITIVE_INFINITY;
|
let minX = Number.POSITIVE_INFINITY;
|
||||||
@ -486,104 +450,37 @@ function layoutBounds(layout, margin = FIT_MARGIN) {
|
|||||||
maxY = h;
|
maxY = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const pad = 80;
|
||||||
x: Math.max(0, minX - margin),
|
const bbox = {
|
||||||
y: Math.max(0, minY - margin),
|
x: Math.max(0, minX - pad),
|
||||||
w: Math.max(1, Math.min(w, maxX - minX + margin * 2)),
|
y: Math.max(0, minY - pad),
|
||||||
h: Math.max(1, Math.min(h, maxY - minY + margin * 2))
|
w: Math.min(w, maxX - minX + pad * 2),
|
||||||
|
h: Math.min(h, maxY - minY + pad * 2)
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function centerOnBBox(bbox, fillRatio = 0.93, stickyAdjusted = false) {
|
|
||||||
if (!bbox) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const viewport = el.canvasViewport.getBoundingClientRect();
|
const viewport = el.canvasViewport.getBoundingClientRect();
|
||||||
if (!viewport.width || !viewport.height) {
|
const sx = (viewport.width * 0.98) / Math.max(1, bbox.w);
|
||||||
return false;
|
const sy = (viewport.height * 0.98) / Math.max(1, bbox.h);
|
||||||
}
|
state.scale = Math.max(0.2, Math.min(4, Math.min(sx, sy)));
|
||||||
const sx = (viewport.width * fillRatio) / Math.max(1, bbox.w);
|
|
||||||
const sy = (viewport.height * fillRatio) / Math.max(1, bbox.h);
|
|
||||||
state.scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, Math.min(sx, sy)));
|
|
||||||
state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale;
|
state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale;
|
||||||
state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale;
|
state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale;
|
||||||
state.userAdjustedView = stickyAdjusted;
|
state.userAdjustedView = false;
|
||||||
updateTransform();
|
updateTransform();
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitView(layout) {
|
|
||||||
const bbox = layoutBounds(layout, FIT_MARGIN);
|
|
||||||
if (!bbox) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
centerOnBBox(bbox, 0.93, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function refsBBox(refs, margin = FOCUS_MARGIN) {
|
|
||||||
if (!refs?.size || !state.compile?.layout || !state.model) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const placed = new Map((state.compile.layout.placed ?? []).map((p) => [p.ref, p]));
|
|
||||||
let minX = Number.POSITIVE_INFINITY;
|
|
||||||
let minY = Number.POSITIVE_INFINITY;
|
|
||||||
let maxX = Number.NEGATIVE_INFINITY;
|
|
||||||
let maxY = Number.NEGATIVE_INFINITY;
|
|
||||||
for (const ref of refs) {
|
|
||||||
const inst = instanceByRef(ref);
|
|
||||||
const p = placed.get(ref);
|
|
||||||
const sym = inst ? state.model.symbols?.[inst.symbol] : null;
|
|
||||||
if (!p || !sym?.body) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
minX = Math.min(minX, p.x);
|
|
||||||
minY = Math.min(minY, p.y);
|
|
||||||
maxX = Math.max(maxX, p.x + sym.body.width);
|
|
||||||
maxY = Math.max(maxY, p.y + sym.body.height);
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const w = state.compile.layout.width ?? maxX;
|
|
||||||
const h = state.compile.layout.height ?? maxY;
|
|
||||||
return {
|
|
||||||
x: Math.max(0, minX - margin),
|
|
||||||
y: Math.max(0, minY - margin),
|
|
||||||
w: Math.max(1, Math.min(w, maxX - minX + margin * 2)),
|
|
||||||
h: Math.max(1, Math.min(h, maxY - minY + margin * 2))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedFocusBBox() {
|
|
||||||
if (!state.model || !state.compile?.layout) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (state.selectedPin) {
|
|
||||||
return refsBBox(new Set([state.selectedPin.ref]), FOCUS_MARGIN);
|
|
||||||
}
|
|
||||||
if (state.selectedRefs.length) {
|
|
||||||
return refsBBox(new Set(state.selectedRefs), FOCUS_MARGIN);
|
|
||||||
}
|
|
||||||
if (state.selectedNet) {
|
|
||||||
const refs = refsConnectedToNet(state.selectedNet);
|
|
||||||
return refsBBox(refs, FOCUS_MARGIN);
|
|
||||||
}
|
|
||||||
return layoutBounds(state.compile.layout, FIT_MARGIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusSelection() {
|
|
||||||
const bbox = selectedFocusBBox();
|
|
||||||
if (!bbox) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return centerOnBBox(bbox, 0.88, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomToBBox(bbox) {
|
function zoomToBBox(bbox) {
|
||||||
if (!bbox) {
|
if (!bbox) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
centerOnBBox(bbox, 0.8, true);
|
|
||||||
|
const viewport = el.canvasViewport.getBoundingClientRect();
|
||||||
|
const scaleX = (viewport.width * 0.75) / Math.max(1, bbox.w);
|
||||||
|
const scaleY = (viewport.height * 0.75) / Math.max(1, bbox.h);
|
||||||
|
state.scale = Math.max(0.3, Math.min(4, Math.min(scaleX, scaleY)));
|
||||||
|
state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale;
|
||||||
|
state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale;
|
||||||
|
state.userAdjustedView = true;
|
||||||
|
updateTransform();
|
||||||
}
|
}
|
||||||
|
|
||||||
function canvasToSvgPoint(clientX, clientY) {
|
function canvasToSvgPoint(clientX, clientY) {
|
||||||
@ -1330,96 +1227,6 @@ function renderSelected() {
|
|||||||
el.netEditor.classList.add("hidden");
|
el.netEditor.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueById(issueId) {
|
|
||||||
const issues = [...(state.compile?.errors ?? []), ...(state.compile?.warnings ?? [])];
|
|
||||||
return issues.find((i) => i.id === issueId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRefPinFromIssueMessage(issue) {
|
|
||||||
const m = /'([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z0-9_]+)'/.exec(String(issue?.message ?? ""));
|
|
||||||
if (m) {
|
|
||||||
return { ref: m[1], pin: m[2] };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureNet(name, netClass = "signal") {
|
|
||||||
if (!state.model) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let net = netByName(name);
|
|
||||||
if (!net) {
|
|
||||||
net = { name, class: netClass, nodes: [] };
|
|
||||||
state.model.nets.push(net);
|
|
||||||
}
|
|
||||||
return net;
|
|
||||||
}
|
|
||||||
|
|
||||||
function issueFixAction(issue) {
|
|
||||||
if (!issue?.code) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (issue.code === "ground_net_missing") {
|
|
||||||
return { label: "Create GND Net", action: "create_ground" };
|
|
||||||
}
|
|
||||||
if (issue.code === "floating_input") {
|
|
||||||
const rp = parseRefPinFromIssueMessage(issue);
|
|
||||||
if (rp) {
|
|
||||||
return { label: "Create Signal Net", action: "connect_signal", ref: rp.ref, pin: rp.pin };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (issue.code === "required_power_unconnected") {
|
|
||||||
const rp = parseRefPinFromIssueMessage(issue);
|
|
||||||
if (rp) {
|
|
||||||
return { label: "Connect Power", action: "connect_power", ref: rp.ref, pin: rp.pin };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyIssueFix(issueId) {
|
|
||||||
if (!state.model) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const issue = issueById(issueId);
|
|
||||||
const fix = issueFixAction(issue);
|
|
||||||
if (!issue || !fix) {
|
|
||||||
el.jsonFeedback.textContent = "No automatic fix available for this issue.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pushHistory("issue-fix");
|
|
||||||
let applied = false;
|
|
||||||
|
|
||||||
if (fix.action === "create_ground") {
|
|
||||||
ensureNet("GND", "ground");
|
|
||||||
applied = true;
|
|
||||||
} else if (fix.action === "connect_signal") {
|
|
||||||
const name = normalizeNetName(`NET_${fix.ref}_${fix.pin}`);
|
|
||||||
applied = connectPinToNet(fix.ref, fix.pin, name || nextAutoNetName(), { netClass: "signal" }).ok;
|
|
||||||
} else if (fix.action === "connect_power") {
|
|
||||||
const upperPin = String(fix.pin).toUpperCase();
|
|
||||||
if (upperPin.includes("GND")) {
|
|
||||||
applied = connectPinToNet(fix.ref, fix.pin, "GND", { netClass: "ground" }).ok;
|
|
||||||
} else if (netByName("3V3")) {
|
|
||||||
applied = connectPinToNet(fix.ref, fix.pin, "3V3", { netClass: "power" }).ok;
|
|
||||||
} else if (netByName("5V")) {
|
|
||||||
applied = connectPinToNet(fix.ref, fix.pin, "5V", { netClass: "power" }).ok;
|
|
||||||
} else {
|
|
||||||
applied = connectPinToNet(fix.ref, fix.pin, normalizeNetName(`PWR_${fix.ref}_${fix.pin}`), { netClass: "power" }).ok;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!applied) {
|
|
||||||
el.jsonFeedback.textContent = "Automatic fix could not be applied safely.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await compileModel(state.model, { keepView: true, source: "issue-fix" });
|
|
||||||
el.jsonFeedback.textContent = `Applied fix for ${issue.code}.`;
|
|
||||||
focusIssue(issueId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderIssues() {
|
function renderIssues() {
|
||||||
const errors = state.compile?.errors ?? [];
|
const errors = state.compile?.errors ?? [];
|
||||||
const warnings = state.compile?.warnings ?? [];
|
const warnings = state.compile?.warnings ?? [];
|
||||||
@ -1431,16 +1238,12 @@ function renderIssues() {
|
|||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
...errors.map(
|
...errors.map(
|
||||||
(issue) => {
|
(issue) =>
|
||||||
const fix = issueFixAction(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>`
|
||||||
return `<div class="issueRow issueErr" data-issue-id="${issue.id}"><div class="issueTitle">[E] ${issue.message}</div><div class="issueMeta">${issue.code} · ${issue.path ?? "-"}</div><div class="issueMeta">${issue.suggestion ?? ""}</div>${fix ? `<div class="issueActions"><button type="button" data-fix-issue="${issue.id}">${fix.label}</button></div>` : ""}</div>`;
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
...warnings.map(
|
...warnings.map(
|
||||||
(issue) => {
|
(issue) =>
|
||||||
const fix = issueFixAction(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>`
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -1828,7 +1631,10 @@ async function compileModel(model, opts = {}) {
|
|||||||
fitView(result.layout);
|
fitView(result.layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(formatCompileStatus(result));
|
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)`
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Compile failed: ${err.message}`, false);
|
setStatus(`Compile failed: ${err.message}`, false);
|
||||||
el.issues.textContent = `Compile error: ${err.message}`;
|
el.issues.textContent = `Compile error: ${err.message}`;
|
||||||
@ -2147,77 +1953,6 @@ function closeSchemaModal() {
|
|||||||
el.schemaModal.classList.add("hidden");
|
el.schemaModal.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShortcutsModal() {
|
|
||||||
el.shortcutsModal?.classList.remove("hidden");
|
|
||||||
el.closeShortcutsBtn?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeShortcutsModal() {
|
|
||||||
el.shortcutsModal?.classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function commandEntries() {
|
|
||||||
return [
|
|
||||||
{ id: "auto-layout", label: "Run Auto Layout", run: () => el.autoLayoutBtn.click() },
|
|
||||||
{ id: "auto-tidy", label: "Run Auto Tidy", run: () => el.autoTidyBtn.click() },
|
|
||||||
{ id: "fit-view", label: "Fit View", run: () => el.fitViewBtn.click() },
|
|
||||||
{ id: "focus-selection", label: "Focus Selection", run: () => el.focusSelectionBtn.click() },
|
|
||||||
{ id: "toggle-labels", label: "Toggle Net Labels", run: () => el.showLabelsInput.click() },
|
|
||||||
{ id: "reset-sample", label: "Reset Sample", run: () => el.resetSampleBtn.click() },
|
|
||||||
{ id: "load-sample", label: "Load Sample", run: () => el.loadSampleBtn.click() },
|
|
||||||
{ id: "new-project", label: "New Project", run: () => el.newProjectBtn.click() },
|
|
||||||
{ id: "show-shortcuts", label: "Show Keyboard Shortcuts", run: () => openShortcutsModal() }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function filteredCommands(query) {
|
|
||||||
const q = String(query ?? "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
const cmds = commandEntries();
|
|
||||||
if (!q) {
|
|
||||||
return cmds;
|
|
||||||
}
|
|
||||||
return cmds.filter((c) => c.label.toLowerCase().includes(q) || c.id.includes(q.replace(/\s+/g, "-")));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCommandList() {
|
|
||||||
const cmds = filteredCommands(el.commandInput?.value ?? "");
|
|
||||||
state.commandIndex = Math.max(0, Math.min(state.commandIndex, Math.max(0, cmds.length - 1)));
|
|
||||||
if (!cmds.length) {
|
|
||||||
el.commandList.innerHTML = `<div class="miniRow"><span>No commands.</span></div>`;
|
|
||||||
return cmds;
|
|
||||||
}
|
|
||||||
el.commandList.innerHTML = cmds
|
|
||||||
.map(
|
|
||||||
(cmd, idx) =>
|
|
||||||
`<button type="button" role="option" class="commandRow ${idx === state.commandIndex ? "active" : ""}" data-command-id="${cmd.id}">${escHtml(cmd.label)}</button>`
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
return cmds;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCommandModal() {
|
|
||||||
state.commandIndex = 0;
|
|
||||||
el.commandModal?.classList.remove("hidden");
|
|
||||||
renderCommandList();
|
|
||||||
el.commandInput?.focus();
|
|
||||||
el.commandInput?.select();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCommandModal() {
|
|
||||||
el.commandModal?.classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCommandById(id) {
|
|
||||||
const cmd = commandEntries().find((c) => c.id === id);
|
|
||||||
if (!cmd) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeCommandModal();
|
|
||||||
cmd.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMinimalRepro(model) {
|
function buildMinimalRepro(model) {
|
||||||
if (!state.selectedRefs.length && !state.selectedNet) {
|
if (!state.selectedRefs.length && !state.selectedNet) {
|
||||||
return model;
|
return model;
|
||||||
@ -2384,56 +2119,29 @@ async function runLayoutAction(path) {
|
|||||||
renderAll();
|
renderAll();
|
||||||
fitView(out.compile.layout);
|
fitView(out.compile.layout);
|
||||||
saveSnapshot();
|
saveSnapshot();
|
||||||
setStatus(formatCompileStatus(out.compile));
|
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)`
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Layout action failed: ${err.message}`, false);
|
setStatus(`Layout action failed: ${err.message}`, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSampleModel() {
|
async function loadSample() {
|
||||||
const res = await fetch("/sample.schemeta.json");
|
const res = await fetch("/sample.schemeta.json");
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Sample missing.");
|
setStatus("Sample missing.", false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetToSample(opts = {}) {
|
const model = await res.json();
|
||||||
const push = opts.pushHistory !== false;
|
if (state.model) {
|
||||||
const before = state.model ? clone(state.model) : null;
|
pushHistory("load-sample");
|
||||||
const model = await fetchSampleModel();
|
|
||||||
if (push && state.model) {
|
|
||||||
pushHistory("reset-sample");
|
|
||||||
}
|
}
|
||||||
setSelectedRefs([]);
|
setSelectedRefs([]);
|
||||||
state.selectedNet = null;
|
state.selectedNet = null;
|
||||||
state.selectedPin = null;
|
state.selectedPin = null;
|
||||||
state.isolateNet = false;
|
|
||||||
state.isolateComponent = false;
|
|
||||||
state.userAdjustedView = false;
|
|
||||||
state.renderMode = "schematic_stub";
|
|
||||||
el.renderModeSelect.value = "schematic_stub";
|
|
||||||
state.showLabels = true;
|
|
||||||
el.showLabelsInput.checked = true;
|
|
||||||
el.instanceFilter.value = "";
|
|
||||||
el.netFilter.value = "";
|
|
||||||
el.instanceList.scrollTop = 0;
|
|
||||||
el.netList.scrollTop = 0;
|
|
||||||
closeSchemaModal();
|
|
||||||
await compileModel(model, { fit: true });
|
await compileModel(model, { fit: true });
|
||||||
if (before) {
|
|
||||||
el.jsonFeedback.textContent = `Reset sample. ${summarizeModelDelta(before, state.model)}`;
|
|
||||||
} else {
|
|
||||||
el.jsonFeedback.textContent = "Reset sample baseline loaded.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSample() {
|
|
||||||
try {
|
|
||||||
await resetToSample({ pushHistory: true });
|
|
||||||
} catch (err) {
|
|
||||||
setStatus(String(err?.message ?? "Sample missing."), false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEvents() {
|
function setupEvents() {
|
||||||
@ -2447,65 +2155,6 @@ function setupEvents() {
|
|||||||
});
|
});
|
||||||
el.instanceList.addEventListener("scroll", renderInstances, { passive: true });
|
el.instanceList.addEventListener("scroll", renderInstances, { passive: true });
|
||||||
el.netList.addEventListener("scroll", renderNets, { passive: true });
|
el.netList.addEventListener("scroll", renderNets, { passive: true });
|
||||||
if (el.newComponentRefInput && el.newComponentTypeSelect) {
|
|
||||||
const syncRefPlaceholder = () => {
|
|
||||||
el.newComponentRefInput.placeholder = defaultRefSeedForPart(el.newComponentTypeSelect.value);
|
|
||||||
};
|
|
||||||
syncRefPlaceholder();
|
|
||||||
el.newComponentTypeSelect.addEventListener("change", syncRefPlaceholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
el.addComponentBtn?.addEventListener("click", async () => {
|
|
||||||
if (!state.model) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const part = String(el.newComponentTypeSelect?.value ?? "generic").toLowerCase();
|
|
||||||
const rawRef = normalizeRef(el.newComponentRefInput?.value ?? "");
|
|
||||||
const ref = rawRef || nextRefLike(defaultRefSeedForPart(part));
|
|
||||||
if (instanceByRef(ref)) {
|
|
||||||
el.jsonFeedback.textContent = `Component '${ref}' already exists.`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pushHistory("add-component");
|
|
||||||
state.model.instances.push({
|
|
||||||
ref,
|
|
||||||
part,
|
|
||||||
properties: {},
|
|
||||||
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
||||||
});
|
|
||||||
el.newComponentRefInput.value = "";
|
|
||||||
setSelectedRefs([ref]);
|
|
||||||
state.selectedNet = null;
|
|
||||||
state.selectedPin = null;
|
|
||||||
await compileModel(state.model, { keepView: true, source: "add-component" });
|
|
||||||
el.jsonFeedback.textContent = `Added component ${ref} (${part}).`;
|
|
||||||
});
|
|
||||||
|
|
||||||
el.addQuickNetBtn?.addEventListener("click", async () => {
|
|
||||||
if (!state.model) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rawName = normalizeNetName(el.newQuickNetNameInput?.value ?? "");
|
|
||||||
const name = rawName || nextAutoNetName();
|
|
||||||
const netClass = NET_CLASSES.includes(el.newQuickNetClassSelect?.value ?? "") ? el.newQuickNetClassSelect.value : "signal";
|
|
||||||
if (netByName(name)) {
|
|
||||||
el.jsonFeedback.textContent = `Net '${name}' already exists.`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pushHistory("add-net");
|
|
||||||
const nodes = state.selectedPin ? [{ ref: state.selectedPin.ref, pin: state.selectedPin.pin }] : [];
|
|
||||||
state.model.nets.push({ name, class: netClass, nodes });
|
|
||||||
el.newQuickNetNameInput.value = "";
|
|
||||||
state.selectedNet = name;
|
|
||||||
await compileModel(state.model, { keepView: true, source: "add-net" });
|
|
||||||
el.jsonFeedback.textContent = nodes.length
|
|
||||||
? `Added net ${name} (${netClass}) and connected selected pin.`
|
|
||||||
: `Added net ${name} (${netClass}).`;
|
|
||||||
});
|
|
||||||
|
|
||||||
[el.componentSection, el.symbolSection, el.pinSection, el.netSection].forEach((section) => {
|
[el.componentSection, el.symbolSection, el.pinSection, el.netSection].forEach((section) => {
|
||||||
if (!section) {
|
if (!section) {
|
||||||
return;
|
return;
|
||||||
@ -2567,12 +2216,6 @@ function setupEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
el.issues.addEventListener("click", (evt) => {
|
el.issues.addEventListener("click", (evt) => {
|
||||||
const fixBtn = evt.target.closest("[data-fix-issue]");
|
|
||||||
if (fixBtn) {
|
|
||||||
evt.stopPropagation();
|
|
||||||
void applyIssueFix(fixBtn.getAttribute("data-fix-issue"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const row = evt.target.closest("[data-issue-id]");
|
const row = evt.target.closest("[data-issue-id]");
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return;
|
return;
|
||||||
@ -3087,27 +2730,23 @@ function setupEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
el.zoomInBtn.addEventListener("click", () => {
|
el.zoomInBtn.addEventListener("click", () => {
|
||||||
state.scale = Math.min(MAX_SCALE, state.scale + 0.1);
|
state.scale = Math.min(4, state.scale + 0.1);
|
||||||
state.userAdjustedView = true;
|
state.userAdjustedView = true;
|
||||||
updateTransform();
|
updateTransform();
|
||||||
});
|
});
|
||||||
|
|
||||||
el.zoomOutBtn.addEventListener("click", () => {
|
el.zoomOutBtn.addEventListener("click", () => {
|
||||||
state.scale = Math.max(MIN_SCALE, state.scale - 0.1);
|
state.scale = Math.max(0.2, state.scale - 0.1);
|
||||||
state.userAdjustedView = true;
|
state.userAdjustedView = true;
|
||||||
updateTransform();
|
updateTransform();
|
||||||
});
|
});
|
||||||
|
|
||||||
el.zoomResetBtn.addEventListener("click", () => {
|
el.zoomResetBtn.addEventListener("click", () => {
|
||||||
if (state.compile?.layout) {
|
|
||||||
fitView(state.compile.layout);
|
|
||||||
} else {
|
|
||||||
state.scale = 1;
|
state.scale = 1;
|
||||||
state.panX = 40;
|
state.panX = 40;
|
||||||
state.panY = 40;
|
state.panY = 40;
|
||||||
state.userAdjustedView = false;
|
state.userAdjustedView = true;
|
||||||
updateTransform();
|
updateTransform();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
el.fitViewBtn.addEventListener("click", () => {
|
el.fitViewBtn.addEventListener("click", () => {
|
||||||
@ -3116,10 +2755,6 @@ function setupEvents() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
el.focusSelectionBtn.addEventListener("click", () => {
|
|
||||||
focusSelection();
|
|
||||||
});
|
|
||||||
|
|
||||||
el.showLabelsInput.addEventListener("change", () => {
|
el.showLabelsInput.addEventListener("change", () => {
|
||||||
state.showLabels = el.showLabelsInput.checked;
|
state.showLabels = el.showLabelsInput.checked;
|
||||||
setLabelLayerVisibility();
|
setLabelLayerVisibility();
|
||||||
@ -3147,7 +2782,7 @@ function setupEvents() {
|
|||||||
(evt) => {
|
(evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
const oldScale = state.scale;
|
const oldScale = state.scale;
|
||||||
state.scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + (evt.deltaY < 0 ? 0.08 : -0.08)));
|
state.scale = Math.min(4, Math.max(0.2, state.scale + (evt.deltaY < 0 ? 0.08 : -0.08)));
|
||||||
|
|
||||||
const rect = el.canvasViewport.getBoundingClientRect();
|
const rect = el.canvasViewport.getBoundingClientRect();
|
||||||
const px = evt.clientX - rect.left;
|
const px = evt.clientX - rect.left;
|
||||||
@ -3377,40 +3012,6 @@ function setupEvents() {
|
|||||||
el.connectPinBtn.click();
|
el.connectPinBtn.click();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mod && evt.key.toLowerCase() === "k") {
|
|
||||||
evt.preventDefault();
|
|
||||||
if (el.commandModal?.classList.contains("hidden")) {
|
|
||||||
openCommandModal();
|
|
||||||
} else {
|
|
||||||
closeCommandModal();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mod && evt.shiftKey && evt.key === "?") {
|
|
||||||
if (isTypingContext(evt.target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
evt.preventDefault();
|
|
||||||
openShortcutsModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mod && !evt.altKey && !evt.shiftKey && evt.key.toLowerCase() === "f") {
|
|
||||||
if (isTypingContext(evt.target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
evt.preventDefault();
|
|
||||||
focusSelection();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
if (!state.compile?.layout || state.userAdjustedView) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fitView(state.compile.layout);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("keyup", (evt) => {
|
window.addEventListener("keyup", (evt) => {
|
||||||
@ -3421,14 +3022,6 @@ function setupEvents() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (evt.code === "Escape") {
|
if (evt.code === "Escape") {
|
||||||
if (!el.commandModal.classList.contains("hidden")) {
|
|
||||||
closeCommandModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!el.shortcutsModal.classList.contains("hidden")) {
|
|
||||||
closeShortcutsModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!el.schemaModal.classList.contains("hidden")) {
|
if (!el.schemaModal.classList.contains("hidden")) {
|
||||||
closeSchemaModal();
|
closeSchemaModal();
|
||||||
return;
|
return;
|
||||||
@ -3481,57 +3074,6 @@ function setupEvents() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
el.shortcutsBtn?.addEventListener("click", openShortcutsModal);
|
|
||||||
el.closeShortcutsBtn?.addEventListener("click", closeShortcutsModal);
|
|
||||||
el.shortcutsModal?.addEventListener("click", (evt) => {
|
|
||||||
if (evt.target === el.shortcutsModal) {
|
|
||||||
closeShortcutsModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
el.closeCommandBtn?.addEventListener("click", closeCommandModal);
|
|
||||||
el.commandModal?.addEventListener("click", (evt) => {
|
|
||||||
if (evt.target === el.commandModal) {
|
|
||||||
closeCommandModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.commandInput?.addEventListener("input", () => {
|
|
||||||
state.commandIndex = 0;
|
|
||||||
renderCommandList();
|
|
||||||
});
|
|
||||||
el.commandInput?.addEventListener("keydown", (evt) => {
|
|
||||||
const cmds = renderCommandList();
|
|
||||||
if (!cmds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (evt.key === "ArrowDown") {
|
|
||||||
evt.preventDefault();
|
|
||||||
state.commandIndex = Math.min(cmds.length - 1, state.commandIndex + 1);
|
|
||||||
renderCommandList();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (evt.key === "ArrowUp") {
|
|
||||||
evt.preventDefault();
|
|
||||||
state.commandIndex = Math.max(0, state.commandIndex - 1);
|
|
||||||
renderCommandList();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (evt.key === "Enter") {
|
|
||||||
evt.preventDefault();
|
|
||||||
const cmd = cmds[state.commandIndex];
|
|
||||||
if (cmd) {
|
|
||||||
runCommandById(cmd.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.commandList?.addEventListener("click", (evt) => {
|
|
||||||
const row = evt.target.closest("[data-command-id]");
|
|
||||||
if (!row) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
runCommandById(row.getAttribute("data-command-id"));
|
|
||||||
});
|
|
||||||
|
|
||||||
el.validateJsonBtn.addEventListener("click", validateJsonEditor);
|
el.validateJsonBtn.addEventListener("click", validateJsonEditor);
|
||||||
|
|
||||||
el.formatJsonBtn.addEventListener("click", () => {
|
el.formatJsonBtn.addEventListener("click", () => {
|
||||||
@ -3597,13 +3139,6 @@ function setupEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
el.loadSampleBtn.addEventListener("click", loadSample);
|
el.loadSampleBtn.addEventListener("click", loadSample);
|
||||||
el.resetSampleBtn.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await resetToSample({ pushHistory: true });
|
|
||||||
} catch (err) {
|
|
||||||
setStatus(`Reset failed: ${err.message}`, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
el.autoLayoutBtn.addEventListener("click", async () => {
|
el.autoLayoutBtn.addEventListener("click", async () => {
|
||||||
await runLayoutAction("/layout/auto");
|
await runLayoutAction("/layout/auto");
|
||||||
|
|||||||
@ -15,12 +15,10 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="newProjectBtn" aria-label="Create new project">New</button>
|
<button id="newProjectBtn" aria-label="Create new project">New</button>
|
||||||
<button id="loadSampleBtn" aria-label="Load sample project">Load Sample</button>
|
<button id="loadSampleBtn" aria-label="Load sample project">Load Sample</button>
|
||||||
<button id="resetSampleBtn" aria-label="Reset to deterministic sample baseline">Reset Sample</button>
|
|
||||||
<button id="importBtn" aria-label="Import Schemeta JSON file">Import JSON</button>
|
<button id="importBtn" aria-label="Import Schemeta JSON file">Import JSON</button>
|
||||||
<button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button>
|
<button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button>
|
||||||
<button id="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button>
|
<button id="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button>
|
||||||
<button id="autoTidyBtn" aria-label="Run automatic tidy layout">Auto Tidy</button>
|
<button id="autoTidyBtn" aria-label="Run automatic tidy layout">Auto Tidy</button>
|
||||||
<button id="shortcutsBtn" aria-label="Show keyboard shortcuts">Shortcuts</button>
|
|
||||||
<button id="undoBtn" title="Undo (Ctrl/Cmd+Z)">Undo</button>
|
<button id="undoBtn" title="Undo (Ctrl/Cmd+Z)">Undo</button>
|
||||||
<button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button>
|
<button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button>
|
||||||
<label class="inlineSelect">
|
<label class="inlineSelect">
|
||||||
@ -42,19 +40,6 @@
|
|||||||
<button id="isolateComponentBtn" class="chip">Isolate</button>
|
<button id="isolateComponentBtn" class="chip">Isolate</button>
|
||||||
</div>
|
</div>
|
||||||
<input id="instanceFilter" placeholder="Filter instances" aria-label="Filter instances" />
|
<input id="instanceFilter" placeholder="Filter instances" aria-label="Filter instances" />
|
||||||
<div class="quickCreate">
|
|
||||||
<input id="newComponentRefInput" placeholder="Ref (auto if empty)" aria-label="New component reference" />
|
|
||||||
<select id="newComponentTypeSelect" aria-label="New component type">
|
|
||||||
<option value="resistor">resistor</option>
|
|
||||||
<option value="capacitor">capacitor</option>
|
|
||||||
<option value="inductor">inductor</option>
|
|
||||||
<option value="diode">diode</option>
|
|
||||||
<option value="led">led</option>
|
|
||||||
<option value="connector">connector</option>
|
|
||||||
<option value="generic">generic</option>
|
|
||||||
</select>
|
|
||||||
<button id="addComponentBtn">Add Component</button>
|
|
||||||
</div>
|
|
||||||
<ul id="instanceList" class="list" aria-label="Instances list"></ul>
|
<ul id="instanceList" class="list" aria-label="Instances list"></ul>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@ -63,42 +48,16 @@
|
|||||||
<button id="isolateNetBtn" class="chip">Isolate</button>
|
<button id="isolateNetBtn" class="chip">Isolate</button>
|
||||||
</div>
|
</div>
|
||||||
<input id="netFilter" placeholder="Filter nets" aria-label="Filter nets" />
|
<input id="netFilter" placeholder="Filter nets" aria-label="Filter nets" />
|
||||||
<div class="quickCreate">
|
|
||||||
<input id="newQuickNetNameInput" placeholder="NET_1" aria-label="New net name" />
|
|
||||||
<select id="newQuickNetClassSelect" aria-label="New net class">
|
|
||||||
<option value="signal">signal</option>
|
|
||||||
<option value="analog">analog</option>
|
|
||||||
<option value="power">power</option>
|
|
||||||
<option value="ground">ground</option>
|
|
||||||
<option value="clock">clock</option>
|
|
||||||
<option value="bus">bus</option>
|
|
||||||
<option value="differential">differential</option>
|
|
||||||
</select>
|
|
||||||
<button id="addQuickNetBtn">Add Net</button>
|
|
||||||
</div>
|
|
||||||
<ul id="netList" class="list" aria-label="Nets list"></ul>
|
<ul id="netList" class="list" aria-label="Nets list"></ul>
|
||||||
</section>
|
</section>
|
||||||
<section class="legendSection" aria-label="Net color legend">
|
|
||||||
<div class="sectionHead">
|
|
||||||
<h2>Legend</h2>
|
|
||||||
</div>
|
|
||||||
<div class="netLegend">
|
|
||||||
<div class="legendRow"><span class="legendSwatch legendPower"></span>Power</div>
|
|
||||||
<div class="legendRow"><span class="legendSwatch legendGround"></span>Ground</div>
|
|
||||||
<div class="legendRow"><span class="legendSwatch legendClock"></span>Clock</div>
|
|
||||||
<div class="legendRow"><span class="legendSwatch legendSignal"></span>Signal</div>
|
|
||||||
<div class="legendRow"><span class="legendSwatch legendAnalog"></span>Analog</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="pane center">
|
<section class="pane center">
|
||||||
<div class="canvasTools">
|
<div class="canvasTools">
|
||||||
<button id="zoomOutBtn" aria-label="Zoom out">-</button>
|
<button id="zoomOutBtn" aria-label="Zoom out">-</button>
|
||||||
<button id="zoomResetBtn" aria-label="Reset view">Reset</button>
|
<button id="zoomResetBtn" aria-label="Reset zoom">100%</button>
|
||||||
<button id="zoomInBtn" aria-label="Zoom in">+</button>
|
<button id="zoomInBtn" aria-label="Zoom in">+</button>
|
||||||
<button id="fitViewBtn" aria-label="Fit schematic to viewport">Fit</button>
|
<button id="fitViewBtn" aria-label="Fit schematic to viewport">Fit</button>
|
||||||
<button id="focusSelectionBtn" aria-label="Focus current selection">Focus</button>
|
|
||||||
<label class="inlineCheck"><input id="showLabelsInput" type="checkbox" checked /> Labels</label>
|
<label class="inlineCheck"><input id="showLabelsInput" type="checkbox" checked /> Labels</label>
|
||||||
<span id="compileStatus">Idle</span>
|
<span id="compileStatus">Idle</span>
|
||||||
</div>
|
</div>
|
||||||
@ -295,37 +254,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="shortcutsModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="shortcutsTitle">
|
|
||||||
<div class="modalCard compactModal">
|
|
||||||
<div class="modalHead">
|
|
||||||
<h3 id="shortcutsTitle">Keyboard Shortcuts</h3>
|
|
||||||
<button id="closeShortcutsBtn">Close</button>
|
|
||||||
</div>
|
|
||||||
<div class="shortcutGrid">
|
|
||||||
<div><kbd>Ctrl/Cmd + Z</kbd><span>Undo</span></div>
|
|
||||||
<div><kbd>Ctrl/Cmd + Shift + Z</kbd><span>Redo</span></div>
|
|
||||||
<div><kbd>Ctrl/Cmd + K</kbd><span>Open command palette</span></div>
|
|
||||||
<div><kbd>F</kbd><span>Focus current selection</span></div>
|
|
||||||
<div><kbd>Space</kbd><span>Rotate selected component(s)</span></div>
|
|
||||||
<div><kbd>Space + Drag</kbd><span>Pan canvas</span></div>
|
|
||||||
<div><kbd>Alt + Enter</kbd><span>Apply selected editor</span></div>
|
|
||||||
<div><kbd>Alt + C</kbd><span>Connect selected pin to chosen net</span></div>
|
|
||||||
<div><kbd>Esc</kbd><span>Close modal / clear selection</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="commandModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="commandTitle">
|
|
||||||
<div class="modalCard compactModal">
|
|
||||||
<div class="modalHead">
|
|
||||||
<h3 id="commandTitle">Command Palette</h3>
|
|
||||||
<button id="closeCommandBtn">Close</button>
|
|
||||||
</div>
|
|
||||||
<input id="commandInput" placeholder="Type a command..." aria-label="Command input" />
|
|
||||||
<div id="commandList" class="commandList" role="listbox" aria-label="Command results"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module" src="/app.js"></script>
|
<script type="module" src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,50 +1,56 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-0: #f5f7fb;
|
--bg: #eef2f6;
|
||||||
--bg-1: #ebf1f8;
|
--panel: #ffffff;
|
||||||
--bg-2: #dce8f4;
|
--ink: #1d2939;
|
||||||
--panel: #fbfdff;
|
--ink-soft: #667085;
|
||||||
--panel-strong: #ffffff;
|
--line: #d0d5dd;
|
||||||
--canvas: #f4f8fd;
|
--accent: #155eef;
|
||||||
--ink: #0f1728;
|
--accent-soft: #dbe8ff;
|
||||||
--ink-muted: #4a607c;
|
--warn: #b54708;
|
||||||
--ink-subtle: #7388a3;
|
--error: #b42318;
|
||||||
--line: #c8d5e4;
|
--ok: #067647;
|
||||||
--line-strong: #9db1c9;
|
|
||||||
--accent: #1565d8;
|
|
||||||
--accent-strong: #0f4dab;
|
|
||||||
--accent-soft: #e6f0ff;
|
|
||||||
--ok: #0f7a49;
|
|
||||||
--warn: #9b6200;
|
|
||||||
--err: #ac2f24;
|
|
||||||
--power: #d6691f;
|
|
||||||
--ground: #60748d;
|
|
||||||
--clock: #d35c2f;
|
|
||||||
--signal: #2f72e7;
|
|
||||||
--analog: #0d978e;
|
|
||||||
--radius-sm: 8px;
|
|
||||||
--radius-md: 12px;
|
|
||||||
--radius-lg: 16px;
|
|
||||||
--shadow-1: 0 1px 2px rgba(16, 24, 40, 0.08);
|
|
||||||
--shadow-2: 0 10px 24px rgba(16, 24, 40, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
font-family: "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;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.topbar {
|
||||||
font-family: "IBM Plex Sans", "Manrope", "Segoe UI", sans-serif;
|
display: flex;
|
||||||
color: var(--ink);
|
justify-content: space-between;
|
||||||
background:
|
align-items: center;
|
||||||
radial-gradient(circle at 0% 0%, #fff4de 0 20%, transparent 30%),
|
padding: 10px 12px;
|
||||||
radial-gradient(circle at 96% 5%, #dbf2ff 0 18%, transparent 28%),
|
border-bottom: 1px solid var(--line);
|
||||||
linear-gradient(180deg, var(--bg-1), var(--bg-0));
|
background: #f7fbffde;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@ -54,101 +60,44 @@ textarea {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button {
|
||||||
input,
|
border: 1px solid var(--line);
|
||||||
select,
|
border-radius: 8px;
|
||||||
textarea,
|
background: #fff;
|
||||||
.list li,
|
padding: 6px 10px;
|
||||||
details > summary {
|
color: var(--ink);
|
||||||
transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, color 120ms ease;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
select:focus-visible,
|
select:focus-visible,
|
||||||
textarea:focus-visible,
|
textarea:focus-visible,
|
||||||
.list li:focus-visible,
|
.list li:focus-visible {
|
||||||
details > summary:focus-visible {
|
outline: 2px solid #155eef;
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--panel-strong);
|
|
||||||
color: var(--ink);
|
|
||||||
padding: 6px 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: var(--shadow-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
border-color: var(--line-strong);
|
|
||||||
background: #f3f8ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.48;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.primary {
|
button.primary {
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
border-color: var(--accent);
|
||||||
|
|
||||||
button.primary:hover {
|
|
||||||
background: var(--accent-strong);
|
|
||||||
border-color: var(--accent-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.chip {
|
button.chip {
|
||||||
padding: 4px 9px;
|
padding: 4px 8px;
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.activeChip {
|
button.activeChip {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent-strong);
|
color: #0f3ea3;
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
background: linear-gradient(180deg, #f9fcff, #f2f8ff);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand p {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--ink-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inlineSelect,
|
.inlineSelect,
|
||||||
@ -160,22 +109,24 @@ button.activeChip {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inlineSelect select {
|
.inlineSelect select {
|
||||||
min-width: 138px;
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
height: calc(100vh - 77px);
|
height: calc(100vh - 65px);
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 270px minmax(480px, 1fr) 380px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: 270px minmax(520px, 1fr) 392px;
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane {
|
.pane {
|
||||||
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-md);
|
border-radius: 12px;
|
||||||
background: linear-gradient(180deg, var(--panel), #f6faff);
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane.left,
|
.pane.left,
|
||||||
@ -187,27 +138,16 @@ button.activeChip {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane.center {
|
|
||||||
overflow: hidden;
|
|
||||||
background: linear-gradient(180deg, #fcfeff, #f6faff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionHead {
|
.sectionHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHead h2 {
|
.sectionHead h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.92rem;
|
font-size: 0.9rem;
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.94rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
@ -215,44 +155,32 @@ textarea,
|
|||||||
select {
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: #fff;
|
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder,
|
|
||||||
textarea::placeholder {
|
|
||||||
color: var(--ink-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
min-height: 250px;
|
min-height: 250px;
|
||||||
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
|
font-family: "JetBrains Mono", monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.42;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-height: 250px;
|
list-style: none;
|
||||||
|
max-height: 230px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list li {
|
.list li {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list li:hover {
|
|
||||||
background: #f2f7ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list li:last-child {
|
.list li:last-child {
|
||||||
@ -261,8 +189,6 @@ textarea {
|
|||||||
|
|
||||||
.list li.active {
|
.list li.active {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: #0b3a84;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list li.listSpacer {
|
.list li.listSpacer {
|
||||||
@ -272,71 +198,11 @@ textarea {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quickCreate {
|
|
||||||
margin-top: 8px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quickCreate button {
|
|
||||||
justify-self: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendSection {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.netLegend {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: #fbfdff;
|
|
||||||
padding: 8px;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendRow {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--ink-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendSwatch {
|
|
||||||
display: inline-block;
|
|
||||||
width: 20px;
|
|
||||||
height: 0;
|
|
||||||
border-top: 3px solid var(--line-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendPower {
|
|
||||||
border-top-color: var(--power);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendGround {
|
|
||||||
border-top-color: var(--ground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendClock {
|
|
||||||
border-top-color: var(--clock);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendSignal {
|
|
||||||
border-top-color: var(--signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendAnalog {
|
|
||||||
border-top-color: var(--analog);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
background: #fbfdff;
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-soft);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@ -344,9 +210,9 @@ textarea {
|
|||||||
.editorCard {
|
.editorCard {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
padding: 9px;
|
padding: 8px;
|
||||||
background: #f8fbff;
|
background: #fcfcfd;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -354,10 +220,9 @@ textarea {
|
|||||||
|
|
||||||
.editorSection {
|
.editorSection {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
background: #ffffffcc;
|
background: #fff;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorSection > summary {
|
.editorSection > summary {
|
||||||
@ -367,15 +232,11 @@ textarea {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
border-bottom: 1px solid transparent;
|
||||||
|
|
||||||
.editorSection > summary:hover {
|
|
||||||
background: #f3f8ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorSection[open] > summary {
|
.editorSection[open] > summary {
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom-color: var(--line);
|
||||||
background: #eef4ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorSection > summary::-webkit-details-marker {
|
.editorSection > summary::-webkit-details-marker {
|
||||||
@ -390,27 +251,27 @@ textarea {
|
|||||||
|
|
||||||
.editorActions {
|
.editorActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hintText {
|
.hintText {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.miniList {
|
.miniList {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
max-height: 180px;
|
max-height: 170px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miniRow {
|
.miniRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
@ -423,48 +284,47 @@ textarea {
|
|||||||
|
|
||||||
.symbolPinRow {
|
.symbolPinRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
|
||||||
grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto;
|
grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinCol {
|
.pinCol {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 5px 6px;
|
padding: 5px 6px;
|
||||||
font-size: 0.76rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.symbolPinRow.invalidRow {
|
.symbolPinRow.invalidRow {
|
||||||
background: #fff3f1;
|
background: #fff3f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.symbolValidationError {
|
.symbolValidationError {
|
||||||
color: var(--err);
|
color: var(--error);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.migrationPreview {
|
.migrationPreview {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
margin: 6px 0;
|
background: #f8fafc;
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
background: #f5f9ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasTools {
|
.canvasTools {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: linear-gradient(180deg, #f8fbff, #f2f7ff);
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#compileStatus {
|
#compileStatus {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-soft);
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-ok {
|
.status-ok {
|
||||||
@ -473,14 +333,12 @@ textarea {
|
|||||||
|
|
||||||
.canvasViewport {
|
.canvasViewport {
|
||||||
height: calc(100% - 52px);
|
height: calc(100% - 52px);
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
background:
|
background-image: linear-gradient(0deg, #ebeff3 1px, transparent 1px),
|
||||||
linear-gradient(0deg, #d9e4f0 1px, transparent 1px),
|
linear-gradient(90deg, #ebeff3 1px, transparent 1px);
|
||||||
linear-gradient(90deg, #d9e4f0 1px, transparent 1px),
|
background-size: 20px 20px;
|
||||||
linear-gradient(180deg, var(--canvas), #edf4fc);
|
|
||||||
background-size: 20px 20px, 20px 20px, auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasViewport.dragging {
|
.canvasViewport.dragging {
|
||||||
@ -488,10 +346,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.canvasInner {
|
.canvasInner {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasInner svg {
|
.canvasInner svg {
|
||||||
@ -500,23 +358,10 @@ textarea {
|
|||||||
|
|
||||||
.selectionBox {
|
.selectionBox {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
border: 1px solid #155eef;
|
||||||
|
background: rgba(21, 94, 239, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
pointer-events: none;
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
background: rgba(21, 101, 216, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pinTooltip {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 20;
|
|
||||||
pointer-events: none;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 6px 8px;
|
|
||||||
color: var(--ink);
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
box-shadow: var(--shadow-2);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
@ -530,20 +375,20 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.jsonActions button {
|
.jsonActions button {
|
||||||
font-size: 0.77rem;
|
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jsonFeedback {
|
.jsonFeedback {
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
margin-bottom: 6px;
|
color: var(--ink-soft);
|
||||||
color: var(--ink-muted);
|
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueRow {
|
.issueRow {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 7px;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -551,48 +396,51 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.issueRow:hover {
|
.issueRow:hover {
|
||||||
background: #f4f8ff;
|
background: #f8faff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueErr {
|
.issueErr {
|
||||||
border-color: #f3bab5;
|
border-color: #fecdca;
|
||||||
background: #fff5f4;
|
background: #fff6f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueWarn {
|
.issueWarn {
|
||||||
border-color: #f0d28b;
|
border-color: #fedf89;
|
||||||
background: #fffaf0;
|
background: #fffcf5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueTitle {
|
.issueTitle {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ink);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueMeta {
|
.issueMeta {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueActions {
|
.pinTooltip {
|
||||||
margin-top: 6px;
|
position: absolute;
|
||||||
}
|
pointer-events: none;
|
||||||
|
padding: 6px 8px;
|
||||||
.issueActions button {
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffffee;
|
||||||
|
color: var(--ink);
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
padding: 3px 8px;
|
z-index: 20;
|
||||||
|
box-shadow: 0 6px 20px rgba(16, 24, 40, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
z-index: 70;
|
z-index: 70;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
background: rgba(14, 22, 36, 0.54);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal.hidden {
|
.modal.hidden {
|
||||||
@ -602,37 +450,30 @@ textarea {
|
|||||||
.modalCard {
|
.modalCard {
|
||||||
width: min(1120px, 100%);
|
width: min(1120px, 100%);
|
||||||
height: min(88vh, 900px);
|
height: min(88vh, 900px);
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 24px 60px rgba(16, 24, 40, 0.24);
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactModal {
|
|
||||||
width: min(760px, 100%);
|
|
||||||
height: auto;
|
|
||||||
max-height: min(86vh, 760px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalHead {
|
.modalHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalHead h3 {
|
.modalHead h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.96rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalHint {
|
.modalHint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-soft);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -641,69 +482,6 @@ textarea {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcutGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
overflow: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcutGrid > div {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 0.86rem;
|
|
||||||
color: var(--ink-muted);
|
|
||||||
background: #f8fbff;
|
|
||||||
}
|
|
||||||
|
|
||||||
kbd {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid var(--line-strong);
|
|
||||||
border-bottom-width: 2px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 2px 6px;
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-family: "JetBrains Mono", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commandList {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: auto;
|
|
||||||
max-height: 46vh;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commandRow {
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
border-radius: 0;
|
|
||||||
background: #fff;
|
|
||||||
text-align: left;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 8px 10px;
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 0.84rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commandRow:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commandRow:hover,
|
|
||||||
.commandRow.active {
|
|
||||||
background: #edf4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash {
|
.flash {
|
||||||
animation: flashPulse 0.7s ease-in-out 0s 2;
|
animation: flashPulse 0.7s ease-in-out 0s 2;
|
||||||
}
|
}
|
||||||
@ -717,38 +495,14 @@ kbd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1460px) {
|
|
||||||
.workspace {
|
|
||||||
grid-template-columns: 248px minmax(420px, 1fr) 360px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1300px) {
|
@media (max-width: 1300px) {
|
||||||
.workspace {
|
.workspace {
|
||||||
height: auto;
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto auto auto;
|
grid-template-rows: auto auto auto;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane.center {
|
.pane.center {
|
||||||
min-height: 600px;
|
min-height: 560px;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
87
package-lock.json
generated
@ -1,87 +0,0 @@
|
|||||||
{
|
|
||||||
"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,13 +8,6 @@
|
|||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"test": "node --test",
|
"test": "node --test",
|
||||||
"test:ui": "node tests/ui-regression-runner.js",
|
|
||||||
"test:ui:update-baselines": "UPDATE_SNAPSHOTS=1 node tests/ui-regression-runner.js",
|
|
||||||
"mcp": "node src/mcp-server.js"
|
"mcp": "node src/mcp-server.js"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"pixelmatch": "^7.1.0",
|
|
||||||
"playwright": "^1.58.2",
|
|
||||||
"pngjs": "^7.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
158
src/layout.js
158
src/layout.js
@ -18,8 +18,7 @@ const NET_CLASS_PRIORITY = {
|
|||||||
const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]);
|
const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]);
|
||||||
const DEFAULT_RENDER_MODE = "schematic_stub";
|
const DEFAULT_RENDER_MODE = "schematic_stub";
|
||||||
const ROTATION_STEPS = [0, 90, 180, 270];
|
const ROTATION_STEPS = [0, 90, 180, 270];
|
||||||
const MIN_CHANNEL_SPACING_STEPS = 3;
|
const MIN_CHANNEL_SPACING_STEPS = 2;
|
||||||
const LANE_ORDER = ["power", "clock", "signal", "analog", "ground", "bus", "differential"];
|
|
||||||
|
|
||||||
function toGrid(value) {
|
function toGrid(value) {
|
||||||
return Math.round(value / GRID) * GRID;
|
return Math.round(value / GRID) * GRID;
|
||||||
@ -376,42 +375,8 @@ function connectivityDegree(model) {
|
|||||||
return deg;
|
return deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refLaneProfiles(model) {
|
|
||||||
const profiles = new Map(model.instances.map((inst) => [inst.ref, { total: 0, byClass: {} }]));
|
|
||||||
for (const net of model.nets ?? []) {
|
|
||||||
const netClass = String(net.class ?? "signal");
|
|
||||||
const refs = [...new Set((net.nodes ?? []).map((n) => n.ref))];
|
|
||||||
for (const ref of refs) {
|
|
||||||
const p = profiles.get(ref);
|
|
||||||
if (!p) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
p.total += 1;
|
|
||||||
p.byClass[netClass] = (p.byClass[netClass] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = new Map();
|
|
||||||
for (const [ref, profile] of profiles.entries()) {
|
|
||||||
const ranked = Object.entries(profile.byClass).sort((a, b) => {
|
|
||||||
if (a[1] !== b[1]) {
|
|
||||||
return b[1] - a[1];
|
|
||||||
}
|
|
||||||
return (NET_CLASS_PRIORITY[a[0]] ?? 99) - (NET_CLASS_PRIORITY[b[0]] ?? 99);
|
|
||||||
});
|
|
||||||
const dominantClass = ranked[0]?.[0] ?? "signal";
|
|
||||||
const laneIndex = Math.max(0, LANE_ORDER.indexOf(dominantClass));
|
|
||||||
out.set(ref, {
|
|
||||||
dominantClass,
|
|
||||||
laneIndex,
|
|
||||||
profile
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeGroup(model, group, start, context) {
|
function placeGroup(model, group, start, context) {
|
||||||
const { rank, degree, instanceByRef, respectLocks, laneProfiles } = context;
|
const { rank, degree, instanceByRef, respectLocks } = context;
|
||||||
const refs = [...group.members].sort((a, b) => a.localeCompare(b));
|
const refs = [...group.members].sort((a, b) => a.localeCompare(b));
|
||||||
const cols = rankColumnsForRefs(refs, rank);
|
const cols = rankColumnsForRefs(refs, rank);
|
||||||
const colOrder = [...cols.keys()].sort((a, b) => a - b);
|
const colOrder = [...cols.keys()].sort((a, b) => a - b);
|
||||||
@ -444,11 +409,6 @@ function placeGroup(model, group, start, context) {
|
|||||||
|
|
||||||
for (const col of colOrder) {
|
for (const col of colOrder) {
|
||||||
const refsInCol = [...(cols.get(col) ?? [])].sort((a, b) => {
|
const refsInCol = [...(cols.get(col) ?? [])].sort((a, b) => {
|
||||||
const la = laneProfiles.get(a)?.laneIndex ?? 2;
|
|
||||||
const lb = laneProfiles.get(b)?.laneIndex ?? 2;
|
|
||||||
if (la !== lb) {
|
|
||||||
return la - lb;
|
|
||||||
}
|
|
||||||
const da = degree.get(a) ?? 0;
|
const da = degree.get(a) ?? 0;
|
||||||
const db = degree.get(b) ?? 0;
|
const db = degree.get(b) ?? 0;
|
||||||
if (da !== db) {
|
if (da !== db) {
|
||||||
@ -457,19 +417,8 @@ function placeGroup(model, group, start, context) {
|
|||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
let yCursor = start.y;
|
||||||
const laneSequence = [...byLane.keys()].sort((a, b) => a - b);
|
for (const ref of refsInCol) {
|
||||||
for (const lane of laneSequence) {
|
|
||||||
const laneRefs = byLane.get(lane) ?? [];
|
|
||||||
for (const ref of laneRefs) {
|
|
||||||
const inst = instanceByRef.get(ref);
|
const inst = instanceByRef.get(ref);
|
||||||
if (!inst) {
|
if (!inst) {
|
||||||
continue;
|
continue;
|
||||||
@ -500,9 +449,7 @@ function placeGroup(model, group, start, context) {
|
|||||||
maxX = Math.max(maxX, x + sym.body.width);
|
maxX = Math.max(maxX, x + sym.body.width);
|
||||||
maxY = Math.max(maxY, y + sym.body.height);
|
maxY = Math.max(maxY, y + sym.body.height);
|
||||||
|
|
||||||
yCursor = y + sym.body.height + 96;
|
yCursor = y + sym.body.height + 110;
|
||||||
}
|
|
||||||
yCursor += 48;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -524,67 +471,6 @@ function placeGroup(model, group, start, context) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function rectForPlacement(model, inst) {
|
|
||||||
const sym = model.symbols[inst.symbol];
|
|
||||||
return {
|
|
||||||
x: inst.placement.x,
|
|
||||||
y: inst.placement.y,
|
|
||||||
w: sym?.body?.width ?? 120,
|
|
||||||
h: sym?.body?.height ?? 80
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function boxesOverlap(a, b, pad = 12) {
|
|
||||||
return !(
|
|
||||||
a.x + a.w + pad <= b.x ||
|
|
||||||
b.x + b.w + pad <= a.x ||
|
|
||||||
a.y + a.h + pad <= b.y ||
|
|
||||||
b.y + b.h + pad <= a.y
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePlacementOverlaps(model, placedMap, options = {}) {
|
|
||||||
const respectLocks = options.respectLocks ?? true;
|
|
||||||
const refs = [...placedMap.keys()].sort();
|
|
||||||
const iterations = Math.max(1, refs.length * 3);
|
|
||||||
|
|
||||||
for (let pass = 0; pass < iterations; pass += 1) {
|
|
||||||
let moved = false;
|
|
||||||
for (let i = 0; i < refs.length; i += 1) {
|
|
||||||
const aRef = refs[i];
|
|
||||||
const aInst = placedMap.get(aRef);
|
|
||||||
if (!aInst) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const aLocked = respectLocks ? Boolean(aInst.placement.locked) : false;
|
|
||||||
const aBox = rectForPlacement(model, aInst);
|
|
||||||
for (let j = i + 1; j < refs.length; j += 1) {
|
|
||||||
const bRef = refs[j];
|
|
||||||
const bInst = placedMap.get(bRef);
|
|
||||||
if (!bInst) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const bLocked = respectLocks ? Boolean(bInst.placement.locked) : false;
|
|
||||||
const bBox = rectForPlacement(model, bInst);
|
|
||||||
if (!boxesOverlap(aBox, bBox, 14)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = !aLocked ? aInst : !bLocked ? bInst : null;
|
|
||||||
if (!target) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const pushY = toGrid(Math.max(aBox.y + aBox.h + 56, bBox.y + bBox.h + 56));
|
|
||||||
target.placement.y = Math.max(target.placement.y, pushY);
|
|
||||||
moved = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!moved) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNodeNetMap(model) {
|
function buildNodeNetMap(model) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const net of model.nets) {
|
for (const net of model.nets) {
|
||||||
@ -695,7 +581,6 @@ function placeInstances(model, options = {}) {
|
|||||||
const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
|
const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
|
||||||
const { rank } = computeRanks(model);
|
const { rank } = computeRanks(model);
|
||||||
const degree = connectivityDegree(model);
|
const degree = connectivityDegree(model);
|
||||||
const laneProfiles = refLaneProfiles(model);
|
|
||||||
const instanceByRef = buildInstanceMap(instances);
|
const instanceByRef = buildInstanceMap(instances);
|
||||||
const groups = buildConstraintGroups(model, rank);
|
const groups = buildConstraintGroups(model, rank);
|
||||||
|
|
||||||
@ -717,7 +602,6 @@ function placeInstances(model, options = {}) {
|
|||||||
const out = placeGroup(model, group, origin, {
|
const out = placeGroup(model, group, origin, {
|
||||||
rank,
|
rank,
|
||||||
degree,
|
degree,
|
||||||
laneProfiles,
|
|
||||||
instanceByRef,
|
instanceByRef,
|
||||||
respectLocks
|
respectLocks
|
||||||
});
|
});
|
||||||
@ -734,7 +618,6 @@ function placeInstances(model, options = {}) {
|
|||||||
|
|
||||||
applyAlignmentConstraints(placedMap, model.constraints);
|
applyAlignmentConstraints(placedMap, model.constraints);
|
||||||
applyNearConstraints(model, placedMap, model.constraints);
|
applyNearConstraints(model, placedMap, model.constraints);
|
||||||
resolvePlacementOverlaps(model, placedMap, { respectLocks });
|
|
||||||
|
|
||||||
return { placed, placedMap };
|
return { placed, placedMap };
|
||||||
}
|
}
|
||||||
@ -1190,7 +1073,7 @@ function uniquePoints(points) {
|
|||||||
return [...map.values()];
|
return [...map.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeLabelTieNet(net, pinNodes, context, fallbackReason = null) {
|
function routeLabelTieNet(net, pinNodes, context) {
|
||||||
const routes = [];
|
const routes = [];
|
||||||
const tiePoints = [];
|
const tiePoints = [];
|
||||||
|
|
||||||
@ -1239,7 +1122,7 @@ function routeLabelTieNet(net, pinNodes, context, fallbackReason = null) {
|
|||||||
total_bends: 0,
|
total_bends: 0,
|
||||||
detour_ratio: 1,
|
detour_ratio: 1,
|
||||||
used_label_tie: true,
|
used_label_tie: true,
|
||||||
fallback_reason: fallbackReason
|
fallback_reason: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1493,9 +1376,6 @@ function shouldUseLabelTie(net, pinNodes, context) {
|
|||||||
const span = Math.abs(maxX - minX) + Math.abs(maxY - minY);
|
const span = Math.abs(maxX - minX) + Math.abs(maxY - minY);
|
||||||
|
|
||||||
if (context.renderMode === "explicit") {
|
if (context.renderMode === "explicit") {
|
||||||
if (context.busNetNames.has(net.name) && (pinNodes.length >= 3 || span > GRID * 36)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return LABEL_TIE_CLASSES.has(net.class) && pinNodes.length > 2;
|
return LABEL_TIE_CLASSES.has(net.class) && pinNodes.length > 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1520,26 +1400,6 @@ function shouldUseLabelTie(net, pinNodes, context) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFallbackToTieByQuality(net, pinNodes, routed) {
|
|
||||||
if (!routed || routed.mode !== "routed") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const stats = routed.route_stats ?? {};
|
|
||||||
const detour = Number(stats.detour_ratio ?? 1);
|
|
||||||
const bends = Number(stats.total_bends ?? 0);
|
|
||||||
const totalLength = Number(stats.total_length ?? 0);
|
|
||||||
const directLength = Number(stats.direct_length ?? totalLength);
|
|
||||||
const spanRatio = directLength > 0 ? totalLength / directLength : 1;
|
|
||||||
|
|
||||||
if (pinNodes.length >= 4 && (detour > 2.25 || bends >= 7)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ((net.class === "analog" || net.class === "signal") && pinNodes.length >= 3 && spanRatio > 2.7) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeAllNets(model, placed, placedMap, bounds, options) {
|
function routeAllNets(model, placed, placedMap, bounds, options) {
|
||||||
const obstacles = buildObstacles(model, placed);
|
const obstacles = buildObstacles(model, placed);
|
||||||
const edgeUsage = new Map();
|
const edgeUsage = new Map();
|
||||||
@ -1574,14 +1434,10 @@ function routeAllNets(model, placed, placedMap, bounds, options) {
|
|||||||
busNetNames
|
busNetNames
|
||||||
};
|
};
|
||||||
|
|
||||||
let routed = shouldUseLabelTie(net, pinNodes, routeContext)
|
const routed = shouldUseLabelTie(net, pinNodes, routeContext)
|
||||||
? routeLabelTieNet(net, pinNodes, routeContext)
|
? routeLabelTieNet(net, pinNodes, routeContext)
|
||||||
: routePointToPointNet(net, pinNodes, routeContext);
|
: routePointToPointNet(net, pinNodes, routeContext);
|
||||||
|
|
||||||
if (shouldFallbackToTieByQuality(net, pinNodes, routed)) {
|
|
||||||
routed = routeLabelTieNet(net, pinNodes, routeContext, "quality_policy");
|
|
||||||
}
|
|
||||||
|
|
||||||
routedByName.set(net.name, {
|
routedByName.set(net.name, {
|
||||||
net,
|
net,
|
||||||
isBusMember: busNetNames.has(net.name),
|
isBusMember: busNetNames.has(net.name),
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { extname, join, normalize } from "node:path";
|
import { extname, join, normalize } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
@ -39,18 +38,14 @@ function json(res, status, payload) {
|
|||||||
res.end(JSON.stringify(payload, null, 2));
|
res.end(JSON.stringify(payload, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorEnvelope(code, message, details = {}) {
|
export function errorEnvelope(code, message) {
|
||||||
const out = {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
code,
|
code,
|
||||||
message
|
message
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (details && typeof details === "object") {
|
|
||||||
Object.assign(out, details);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthorizedRequest(req) {
|
export function isAuthorizedRequest(req) {
|
||||||
@ -154,32 +149,11 @@ export function parsePayloadOptions(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function withEnvelopeMeta(payload) {
|
export function withEnvelopeMeta(payload) {
|
||||||
const out = {
|
return {
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
...payload
|
...payload
|
||||||
};
|
};
|
||||||
if (!out.request_id && payload?.request_id) {
|
|
||||||
out.request_id = payload.request_id;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestIdFrom(req) {
|
|
||||||
const headerId = req.headers?.["x-request-id"];
|
|
||||||
if (typeof headerId === "string" && headerId.trim()) {
|
|
||||||
return headerId.trim().slice(0, 128);
|
|
||||||
}
|
|
||||||
return randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
function auditLog(entry) {
|
|
||||||
const line = {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
service: "schemeta",
|
|
||||||
...entry
|
|
||||||
};
|
|
||||||
console.log(JSON.stringify(line));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientKey(req) {
|
function clientKey(req) {
|
||||||
@ -220,22 +194,8 @@ function withinRateLimit(req) {
|
|||||||
|
|
||||||
export function createRequestHandler() {
|
export function createRequestHandler() {
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
const requestId = requestIdFrom(req);
|
|
||||||
const startedAt = Date.now();
|
|
||||||
res.setHeader("x-request-id", requestId);
|
|
||||||
res.on("finish", () => {
|
|
||||||
auditLog({
|
|
||||||
request_id: requestId,
|
|
||||||
method: req.method,
|
|
||||||
path: req.url,
|
|
||||||
status: res.statusCode,
|
|
||||||
duration_ms: Date.now() - startedAt,
|
|
||||||
client: clientKey(req)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!req.url || !req.method) {
|
if (!req.url || !req.method) {
|
||||||
return json(res, 400, errorEnvelope("invalid_request", "Invalid request.", { request_id: requestId }));
|
return json(res, 400, errorEnvelope("invalid_request", "Invalid request."));
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathname = new URL(req.url, "http://localhost").pathname;
|
const pathname = new URL(req.url, "http://localhost").pathname;
|
||||||
@ -251,7 +211,6 @@ export function createRequestHandler() {
|
|||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: "schemeta",
|
service: "schemeta",
|
||||||
request_id: requestId,
|
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
status: "ok",
|
status: "ok",
|
||||||
@ -262,7 +221,6 @@ export function createRequestHandler() {
|
|||||||
if (req.method === "GET" && pathname === "/mcp/ui-bundle") {
|
if (req.method === "GET" && pathname === "/mcp/ui-bundle") {
|
||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
request_id: requestId,
|
|
||||||
name: "schemeta-workspace",
|
name: "schemeta-workspace",
|
||||||
version: "0.2.0",
|
version: "0.2.0",
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
@ -276,56 +234,56 @@ export function createRequestHandler() {
|
|||||||
if (req.method === "POST" && pathname === "/analyze") {
|
if (req.method === "POST" && pathname === "/analyze") {
|
||||||
if (!isAuthorizedRequest(req)) {
|
if (!isAuthorizedRequest(req)) {
|
||||||
res.setHeader("WWW-Authenticate", "Bearer");
|
res.setHeader("WWW-Authenticate", "Bearer");
|
||||||
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
|
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token."));
|
||||||
}
|
}
|
||||||
if (!withinRateLimit(req)) {
|
if (!withinRateLimit(req)) {
|
||||||
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
|
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const parsed = parsePayloadOptions(body);
|
const parsed = parsePayloadOptions(body);
|
||||||
return json(res, 200, withEnvelopeMeta({ request_id: requestId, ...analyze(parsed.payload, parsed.options) }));
|
return json(res, 200, withEnvelopeMeta(analyze(parsed.payload, parsed.options)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
||||||
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
|
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
||||||
}
|
}
|
||||||
if (err?.code === "INVALID_JSON") {
|
if (err?.code === "INVALID_JSON") {
|
||||||
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
|
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload."));
|
||||||
}
|
}
|
||||||
return json(res, 500, errorEnvelope("internal_error", "Request failed.", { request_id: requestId }));
|
return json(res, 500, errorEnvelope("internal_error", "Request failed."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && pathname === "/compile") {
|
if (req.method === "POST" && pathname === "/compile") {
|
||||||
if (!isAuthorizedRequest(req)) {
|
if (!isAuthorizedRequest(req)) {
|
||||||
res.setHeader("WWW-Authenticate", "Bearer");
|
res.setHeader("WWW-Authenticate", "Bearer");
|
||||||
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
|
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token."));
|
||||||
}
|
}
|
||||||
if (!withinRateLimit(req)) {
|
if (!withinRateLimit(req)) {
|
||||||
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
|
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const parsed = parsePayloadOptions(body);
|
const parsed = parsePayloadOptions(body);
|
||||||
return json(res, 200, withEnvelopeMeta({ request_id: requestId, ...compile(parsed.payload, parsed.options) }));
|
return json(res, 200, withEnvelopeMeta(compile(parsed.payload, parsed.options)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
||||||
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
|
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
||||||
}
|
}
|
||||||
if (err?.code === "INVALID_JSON") {
|
if (err?.code === "INVALID_JSON") {
|
||||||
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
|
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload."));
|
||||||
}
|
}
|
||||||
return json(res, 500, errorEnvelope("internal_error", "Request failed.", { request_id: requestId }));
|
return json(res, 500, errorEnvelope("internal_error", "Request failed."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && pathname === "/layout/auto") {
|
if (req.method === "POST" && pathname === "/layout/auto") {
|
||||||
if (!isAuthorizedRequest(req)) {
|
if (!isAuthorizedRequest(req)) {
|
||||||
res.setHeader("WWW-Authenticate", "Bearer");
|
res.setHeader("WWW-Authenticate", "Bearer");
|
||||||
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
|
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token."));
|
||||||
}
|
}
|
||||||
if (!withinRateLimit(req)) {
|
if (!withinRateLimit(req)) {
|
||||||
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
|
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
@ -335,7 +293,6 @@ export function createRequestHandler() {
|
|||||||
const laidOut = applyLayoutToModel(model, { respectLocks: false });
|
const laidOut = applyLayoutToModel(model, { respectLocks: false });
|
||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
request_id: requestId,
|
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
model: laidOut,
|
model: laidOut,
|
||||||
@ -343,22 +300,22 @@ export function createRequestHandler() {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
||||||
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
|
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
||||||
}
|
}
|
||||||
if (err?.code === "INVALID_JSON") {
|
if (err?.code === "INVALID_JSON") {
|
||||||
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
|
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload."));
|
||||||
}
|
}
|
||||||
return json(res, 500, errorEnvelope("internal_error", "Layout auto failed.", { request_id: requestId }));
|
return json(res, 500, errorEnvelope("internal_error", "Layout auto failed."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && pathname === "/layout/tidy") {
|
if (req.method === "POST" && pathname === "/layout/tidy") {
|
||||||
if (!isAuthorizedRequest(req)) {
|
if (!isAuthorizedRequest(req)) {
|
||||||
res.setHeader("WWW-Authenticate", "Bearer");
|
res.setHeader("WWW-Authenticate", "Bearer");
|
||||||
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
|
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token."));
|
||||||
}
|
}
|
||||||
if (!withinRateLimit(req)) {
|
if (!withinRateLimit(req)) {
|
||||||
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
|
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
@ -368,7 +325,6 @@ export function createRequestHandler() {
|
|||||||
const laidOut = applyLayoutToModel(model, { respectLocks: true });
|
const laidOut = applyLayoutToModel(model, { respectLocks: true });
|
||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
request_id: requestId,
|
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
model: laidOut,
|
model: laidOut,
|
||||||
@ -376,12 +332,12 @@ export function createRequestHandler() {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
||||||
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
|
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
||||||
}
|
}
|
||||||
if (err?.code === "INVALID_JSON") {
|
if (err?.code === "INVALID_JSON") {
|
||||||
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
|
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload."));
|
||||||
}
|
}
|
||||||
return json(res, 500, errorEnvelope("internal_error", "Layout tidy failed.", { request_id: requestId }));
|
return json(res, 500, errorEnvelope("internal_error", "Layout tidy failed."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -392,7 +348,7 @@ export function createRequestHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return json(res, 404, errorEnvelope("not_found", "Not found.", { request_id: requestId }));
|
return json(res, 404, errorEnvelope("not_found", "Not found."));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,12 +17,11 @@ import {
|
|||||||
|
|
||||||
test("REST compile contract shape is stable with version metadata", () => {
|
test("REST compile contract shape is stable with version metadata", () => {
|
||||||
const parsed = parsePayloadOptions({ payload: fixture, options: { render_mode: "schematic_stub" } });
|
const parsed = parsePayloadOptions({ payload: fixture, options: { render_mode: "schematic_stub" } });
|
||||||
const body = withRestEnvelopeMeta({ request_id: "req-test-1", ...compile(parsed.payload, parsed.options) });
|
const body = withRestEnvelopeMeta(compile(parsed.payload, parsed.options));
|
||||||
|
|
||||||
assert.equal(body.ok, true);
|
assert.equal(body.ok, true);
|
||||||
assert.equal(body.api_version, REST_API_VERSION);
|
assert.equal(body.api_version, REST_API_VERSION);
|
||||||
assert.equal(body.schema_version, REST_SCHEMA_VERSION);
|
assert.equal(body.schema_version, REST_SCHEMA_VERSION);
|
||||||
assert.equal(body.request_id, "req-test-1");
|
|
||||||
assert.ok(Array.isArray(body.errors));
|
assert.ok(Array.isArray(body.errors));
|
||||||
assert.ok(Array.isArray(body.warnings));
|
assert.ok(Array.isArray(body.warnings));
|
||||||
assert.ok(Array.isArray(body.bus_groups));
|
assert.ok(Array.isArray(body.bus_groups));
|
||||||
@ -43,11 +42,10 @@ test("REST analyze contract shape is stable with version metadata", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("REST error envelope exposes stable code/message fields", () => {
|
test("REST error envelope exposes stable code/message fields", () => {
|
||||||
const body = errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: "req-test-2" });
|
const body = errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.");
|
||||||
assert.equal(body.ok, false);
|
assert.equal(body.ok, false);
|
||||||
assert.equal(body.error.code, "rate_limited");
|
assert.equal(body.error.code, "rate_limited");
|
||||||
assert.equal(typeof body.error.message, "string");
|
assert.equal(typeof body.error.message, "string");
|
||||||
assert.equal(body.request_id, "req-test-2");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("MCP schemeta_compile returns structured content with version metadata", () => {
|
test("MCP schemeta_compile returns structured content with version metadata", () => {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 228 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 175 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 269 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 174 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 165 KiB |
@ -1,259 +0,0 @@
|
|||||||
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