diff --git a/frontend/app.js b/frontend/app.js
index 5ad4b67..9dd02a3 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -11,6 +11,7 @@ const MIN_SCALE = 0.2;
const MAX_SCALE = 5;
const FIT_MARGIN = 56;
const FOCUS_MARGIN = 96;
+const THEME_KEY = "schemeta:theme:v1";
const state = {
model: null,
@@ -31,7 +32,8 @@ const state = {
showLabels: true,
isolateNet: false,
isolateComponent: false,
- renderMode: "schematic_stub",
+ renderMode: "explicit",
+ theme: "dark",
userAdjustedView: false,
spacePan: false,
schemaText: "",
@@ -141,6 +143,7 @@ const el = {
copyReproBtn: document.getElementById("copyReproBtn"),
autoLayoutBtn: document.getElementById("autoLayoutBtn"),
autoTidyBtn: document.getElementById("autoTidyBtn"),
+ themeToggleBtn: document.getElementById("themeToggleBtn"),
shortcutsBtn: document.getElementById("shortcutsBtn"),
undoBtn: document.getElementById("undoBtn"),
redoBtn: document.getElementById("redoBtn"),
@@ -158,7 +161,8 @@ const el = {
commandModal: document.getElementById("commandModal"),
closeCommandBtn: document.getElementById("closeCommandBtn"),
commandInput: document.getElementById("commandInput"),
- commandList: document.getElementById("commandList")
+ commandList: document.getElementById("commandList"),
+ selectedNoteHint: null
};
function toGrid(v) {
@@ -348,6 +352,27 @@ function setStatus(text, ok = true) {
el.compileStatus.className = ok ? "status-ok" : "";
}
+function selectThemePreference() {
+ const saved = localStorage.getItem(THEME_KEY);
+ if (saved === "dark" || saved === "light") {
+ return saved;
+ }
+ return "dark";
+}
+
+function applyTheme(theme, persist = true) {
+ const next = theme === "light" ? "light" : "dark";
+ state.theme = next;
+ document.body.classList.toggle("theme-dark", next === "dark");
+ if (el.themeToggleBtn) {
+ el.themeToggleBtn.textContent = next === "dark" ? "Light" : "Dark";
+ el.themeToggleBtn.setAttribute("aria-label", next === "dark" ? "Switch to light mode" : "Switch to dark mode");
+ }
+ if (persist) {
+ localStorage.setItem(THEME_KEY, next);
+ }
+}
+
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)`;
@@ -368,6 +393,8 @@ function compileOptions(extra = {}) {
return {
render_mode: state.renderMode,
show_labels: state.showLabels,
+ show_annotations: false,
+ show_legend: false,
generic_symbols: true,
...extra
};
@@ -427,6 +454,58 @@ function refreshJsonEditor() {
el.jsonEditor.value = JSON.stringify(state.model, null, 2);
}
+function selectedComponentNote() {
+ if (!state.model || state.selectedNet || state.selectedPin || state.selectedRefs.length !== 1) {
+ return null;
+ }
+ const inst = instanceByRef(state.selectedRefs[0]);
+ if (!inst) {
+ return null;
+ }
+ const raw = String(inst.properties?.note ?? inst.properties?.notes ?? "").trim();
+ if (!raw) {
+ return null;
+ }
+ return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
+}
+
+function updateSelectedNoteHint() {
+ if (!el.selectedNoteHint) {
+ return;
+ }
+ const note = selectedComponentNote();
+ if (!note || !state.compile?.layout || !state.model) {
+ el.selectedNoteHint.classList.add("hidden");
+ return;
+ }
+ const ref = state.selectedRefs[0];
+ const inst = instanceByRef(ref);
+ const sym = inst ? state.model.symbols?.[inst.symbol] : null;
+ const placed = (state.compile.layout.placed ?? []).find((p) => p.ref === ref);
+ if (!inst || !sym || !placed) {
+ el.selectedNoteHint.classList.add("hidden");
+ return;
+ }
+
+ el.selectedNoteHint.textContent = note;
+ el.selectedNoteHint.classList.remove("hidden");
+ el.selectedNoteHint.style.visibility = "hidden";
+
+ const viewportRect = el.canvasViewport.getBoundingClientRect();
+ const x = (placed.x + sym.body.width / 2) * state.scale + state.panX;
+ const y = (placed.y - 12) * state.scale + state.panY;
+ el.selectedNoteHint.style.left = `${Math.round(x)}px`;
+ el.selectedNoteHint.style.top = `${Math.round(y)}px`;
+ el.selectedNoteHint.style.transform = "translate(-50%, -100%)";
+
+ const hintRect = el.selectedNoteHint.getBoundingClientRect();
+ const minX = 14 + hintRect.width / 2;
+ const maxX = Math.max(minX, viewportRect.width - 14 - hintRect.width / 2);
+ const clampedX = Math.max(minX, Math.min(maxX, x));
+ el.selectedNoteHint.style.left = `${Math.round(clampedX)}px`;
+ el.selectedNoteHint.style.visibility = "";
+}
+
function saveSnapshot() {
if (!state.model) {
return;
@@ -451,6 +530,7 @@ function updateTransform() {
applyLabelDensityByZoom(svg);
resolveLabelCollisions(svg);
}
+ updateSelectedNoteHint();
}
function layoutBounds(layout, margin = FIT_MARGIN) {
@@ -746,8 +826,8 @@ function applyLabelDensityByZoom(svg) {
const pinLabels = svg.querySelectorAll("[data-pin-label]");
const valueLabels = svg.querySelectorAll("[data-value-label]");
const refLabels = svg.querySelectorAll("[data-ref-label]");
- const dense = state.scale < 0.85;
- const veryDense = state.scale < 0.65;
+ const dense = state.scale < 1.05;
+ const veryDense = state.scale < 0.8;
pinLabels.forEach((n) => {
n.style.display = dense ? "none" : "";
@@ -973,14 +1053,18 @@ function symbolPinRowHtml(pin) {
const sideOptions = PIN_SIDES.map((s) => ``).join("");
const typeOptions = PIN_TYPES.map((t) => ``).join("");
return `
`;
}
@@ -1301,7 +1385,7 @@ function renderSelected() {
el.lockedInput.checked = Boolean(inst.placement.locked);
el.instRefInput.value = inst.ref;
el.instValueInput.value = String(inst.properties?.value ?? "");
- el.instNotesInput.value = String(inst.properties?.notes ?? "");
+ el.instNotesInput.value = String(inst.properties?.note ?? inst.properties?.notes ?? "");
el.duplicateComponentBtn.disabled = false;
el.deleteComponentBtn.disabled = false;
el.isolateSelectedComponentBtn.disabled = false;
@@ -1693,7 +1777,11 @@ function bindSvgInteractions() {
}
const pt = canvasToSvgPoint(evt.clientX, evt.clientY);
- const dragRefs = state.selectedRefs.length ? [...state.selectedRefs] : [ref];
+ const dragRefsRaw = state.selectedRefs.length ? [...state.selectedRefs] : [ref];
+ const dragRefs = dragRefsRaw.filter((r) => !instanceByRef(r)?.placement?.locked);
+ if (!dragRefs.length) {
+ return;
+ }
const baseByRef = {};
for (const r of dragRefs) {
const ii = state.model.instances.find((x) => x.ref === r);
@@ -1772,6 +1860,7 @@ function bindSvgInteractions() {
function renderCanvas() {
if (!state.compile?.svg) {
el.canvasInner.innerHTML = "";
+ updateSelectedNoteHint();
return;
}
@@ -1784,6 +1873,7 @@ function renderCanvas() {
applyLabelDensityByZoom(svg);
resolveLabelCollisions(svg);
}
+ updateSelectedNoteHint();
}
function renderAll() {
@@ -1868,7 +1958,7 @@ function queueCompile(keepView = true, source = "edit") {
}
state.compileDebounceId = setTimeout(() => {
state.compileDebounceId = null;
- compileModel(state.model, { keepView, source });
+ compileModel(state.model, { keepView, source, preservePlacement: true });
}, 150);
}
@@ -2403,7 +2493,9 @@ async function runLayoutAction(path) {
try {
const out = await apiPost(path, {
payload: state.model,
- options: compileOptions()
+ options: compileOptions({
+ respect_locks: true
+ })
});
state.model = applyCompileLayoutToModel(out.model, out.compile);
@@ -2439,8 +2531,8 @@ async function resetToSample(opts = {}) {
state.isolateNet = false;
state.isolateComponent = false;
state.userAdjustedView = false;
- state.renderMode = "schematic_stub";
- el.renderModeSelect.value = "schematic_stub";
+ state.renderMode = "explicit";
+ el.renderModeSelect.value = "explicit";
state.showLabels = true;
el.showLabelsInput.checked = true;
el.instanceFilter.value = "";
@@ -2642,6 +2734,7 @@ function setupEvents() {
properties: {
...(inst.properties ?? {}),
value: el.instValueInput.value,
+ note: el.instNotesInput.value,
notes: el.instNotesInput.value
}
});
@@ -3156,7 +3249,7 @@ function setupEvents() {
el.renderModeSelect.addEventListener("change", async () => {
state.renderMode = el.renderModeSelect.value;
if (state.model) {
- await compileModel(state.model, { keepView: true });
+ await compileModel(state.model, { keepView: true, preservePlacement: true });
}
});
@@ -3374,7 +3467,7 @@ function setupEvents() {
inst.placement.rotation = ((Math.round(current / 90) * 90 + 90) % 360 + 360) % 360;
inst.placement.locked = true;
}
- compileModel(state.model, { source: "rotate", keepView: true });
+ compileModel(state.model, { source: "rotate", keepView: true, preservePlacement: true });
evt.preventDefault();
return;
}
@@ -3646,6 +3739,10 @@ function setupEvents() {
await runLayoutAction("/layout/tidy");
});
+ el.themeToggleBtn?.addEventListener("click", () => {
+ applyTheme(state.theme === "dark" ? "light" : "dark");
+ });
+
el.undoBtn.addEventListener("click", async () => {
await performUndo();
});
@@ -3705,6 +3802,10 @@ function setupEvents() {
}
(async function init() {
+ el.selectedNoteHint = document.createElement("div");
+ el.selectedNoteHint.className = "selectedNoteHint hidden";
+ el.canvasViewport.appendChild(el.selectedNoteHint);
+ applyTheme(selectThemePreference());
setupEvents();
loadInspectorSectionState();
updateTransform();
diff --git a/frontend/index.html b/frontend/index.html
index 3890801..9757b63 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -20,14 +20,15 @@
+
diff --git a/frontend/styles.css b/frontend/styles.css
index 872a0b0..85e776d 100644
--- a/frontend/styles.css
+++ b/frontend/styles.css
@@ -28,6 +28,33 @@
--shadow-2: 0 10px 24px rgba(16, 24, 40, 0.08);
}
+body.theme-dark {
+ --bg-0: #0b1220;
+ --bg-1: #0f172a;
+ --bg-2: #172237;
+ --panel: #0f1c2e;
+ --panel-strong: #13233a;
+ --canvas: #0d1728;
+ --ink: #e6edf7;
+ --ink-muted: #a7b6cd;
+ --ink-subtle: #7f91ad;
+ --line: #2a3c57;
+ --line-strong: #436086;
+ --accent: #60a5fa;
+ --accent-strong: #3b82f6;
+ --accent-soft: #162945;
+ --ok: #42ba86;
+ --warn: #f0b54f;
+ --err: #ef7367;
+ --power: #f59a4b;
+ --ground: #9ab0ca;
+ --clock: #f1835f;
+ --signal: #7aa4ff;
+ --analog: #4ec2ba;
+ --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
+ --shadow-2: 0 10px 24px rgba(0, 0, 0, 0.36);
+}
+
* {
box-sizing: border-box;
}
@@ -38,6 +65,13 @@ body {
min-height: 100%;
}
+body.theme-dark {
+ background:
+ radial-gradient(circle at 0% 0%, #2f1f17 0 18%, transparent 30%),
+ radial-gradient(circle at 96% 5%, #122739 0 18%, transparent 30%),
+ linear-gradient(180deg, var(--bg-1), var(--bg-0));
+}
+
body {
font-family: "IBM Plex Sans", "Manrope", "Segoe UI", sans-serif;
color: var(--ink);
@@ -85,7 +119,7 @@ button {
button:hover {
border-color: var(--line-strong);
- background: #f3f8ff;
+ background: color-mix(in oklab, var(--accent-soft) 58%, var(--panel-strong));
}
button:disabled {
@@ -122,7 +156,7 @@ button.activeChip {
gap: 14px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
- background: linear-gradient(180deg, #f9fcff, #f2f8ff);
+ background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 88%, #ffffff), var(--panel));
backdrop-filter: blur(2px);
position: sticky;
top: 0;
@@ -174,7 +208,7 @@ button.activeChip {
.pane {
border: 1px solid var(--line);
border-radius: var(--radius-md);
- background: linear-gradient(180deg, var(--panel), #f6faff);
+ background: linear-gradient(180deg, var(--panel), color-mix(in oklab, var(--panel) 88%, #081120));
box-shadow: var(--shadow-1);
}
@@ -189,7 +223,7 @@ button.activeChip {
.pane.center {
overflow: hidden;
- background: linear-gradient(180deg, #fcfeff, #f6faff);
+ background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 90%, #ffffff), var(--panel));
}
.sectionHead {
@@ -217,7 +251,7 @@ select {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 8px;
- background: #fff;
+ background: var(--panel-strong);
color: var(--ink);
}
@@ -241,7 +275,7 @@ textarea {
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
- background: rgba(255, 255, 255, 0.72);
+ background: color-mix(in oklab, var(--panel-strong) 84%, transparent);
}
.list li {
@@ -252,7 +286,7 @@ textarea {
}
.list li:hover {
- background: #f2f7ff;
+ background: color-mix(in oklab, var(--accent-soft) 70%, var(--panel-strong));
}
.list li:last-child {
@@ -290,7 +324,7 @@ textarea {
.netLegend {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
- background: #fbfdff;
+ background: var(--panel-strong);
padding: 8px;
display: grid;
gap: 6px;
@@ -334,7 +368,7 @@ textarea {
.card {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
- background: #fbfdff;
+ background: var(--panel-strong);
padding: 8px;
color: var(--ink-muted);
font-size: 0.85rem;
@@ -346,7 +380,7 @@ textarea {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 9px;
- background: #f8fbff;
+ background: color-mix(in oklab, var(--panel-strong) 86%, var(--panel));
display: flex;
flex-direction: column;
gap: 8px;
@@ -356,7 +390,7 @@ textarea {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
margin-top: 8px;
- background: #ffffffcc;
+ background: color-mix(in oklab, var(--panel-strong) 88%, transparent);
overflow: hidden;
}
@@ -370,12 +404,12 @@ textarea {
}
.editorSection > summary:hover {
- background: #f3f8ff;
+ background: color-mix(in oklab, var(--accent-soft) 60%, var(--panel-strong));
}
.editorSection[open] > summary {
border-bottom: 1px solid var(--line);
- background: #eef4ff;
+ background: color-mix(in oklab, var(--accent-soft) 76%, var(--panel-strong));
}
.editorSection > summary::-webkit-details-marker {
@@ -402,7 +436,7 @@ textarea {
.miniList {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
- background: #fff;
+ background: var(--panel-strong);
max-height: 180px;
overflow: auto;
}
@@ -423,8 +457,27 @@ textarea {
.symbolPinRow {
display: grid;
- align-items: center;
- grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto;
+ align-items: start;
+ grid-template-columns: 1fr;
+ gap: 6px;
+}
+
+.symbolPinMain {
+ display: grid;
+ grid-template-columns: minmax(72px, 1fr) minmax(58px, 0.8fr) minmax(76px, 0.9fr) minmax(66px, 0.7fr) minmax(110px, 1fr);
+ gap: 6px;
+}
+
+.symbolPinActions {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.symbolPinActions button {
+ min-width: 0;
+ padding: 4px 6px;
+ font-size: 0.75rem;
}
.pinCol {
@@ -457,7 +510,7 @@ textarea {
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--line);
- background: linear-gradient(180deg, #f8fbff, #f2f7ff);
+ background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 90%, #ffffff), var(--panel));
}
#compileStatus {
@@ -477,9 +530,9 @@ textarea {
overflow: hidden;
cursor: grab;
background:
- linear-gradient(0deg, #d9e4f0 1px, transparent 1px),
- linear-gradient(90deg, #d9e4f0 1px, transparent 1px),
- linear-gradient(180deg, var(--canvas), #edf4fc);
+ linear-gradient(0deg, color-mix(in oklab, var(--line) 72%, transparent) 1px, transparent 1px),
+ linear-gradient(90deg, color-mix(in oklab, var(--line) 72%, transparent) 1px, transparent 1px),
+ linear-gradient(180deg, var(--canvas), color-mix(in oklab, var(--canvas) 82%, #1c2f49));
background-size: 20px 20px, 20px 20px, auto;
}
@@ -514,11 +567,26 @@ textarea {
border-radius: var(--radius-sm);
padding: 6px 8px;
color: var(--ink);
- background: rgba(255, 255, 255, 0.95);
+ background: color-mix(in oklab, var(--panel-strong) 92%, transparent);
box-shadow: var(--shadow-2);
font-size: 0.74rem;
}
+.selectedNoteHint {
+ position: absolute;
+ z-index: 18;
+ pointer-events: none;
+ max-width: min(460px, 56vw);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: color-mix(in oklab, var(--panel-strong) 92%, transparent);
+ color: var(--ink);
+ box-shadow: var(--shadow-2);
+ font-size: 0.78rem;
+ line-height: 1.35;
+ padding: 8px 10px;
+}
+
.hidden {
display: none;
}
@@ -547,11 +615,11 @@ textarea {
padding: 7px;
margin-bottom: 6px;
cursor: pointer;
- background: #fff;
+ background: var(--panel-strong);
}
.issueRow:hover {
- background: #f4f8ff;
+ background: color-mix(in oklab, var(--accent-soft) 66%, var(--panel-strong));
}
.issueErr {
@@ -604,7 +672,7 @@ textarea {
height: min(88vh, 900px);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
- background: #fff;
+ background: var(--panel-strong);
box-shadow: 0 24px 60px rgba(16, 24, 40, 0.24);
padding: 12px;
display: flex;
@@ -658,7 +726,7 @@ textarea {
padding: 8px 10px;
font-size: 0.86rem;
color: var(--ink-muted);
- background: #f8fbff;
+ background: color-mix(in oklab, var(--panel-strong) 88%, var(--panel));
}
kbd {
@@ -667,7 +735,7 @@ kbd {
border: 1px solid var(--line-strong);
border-bottom-width: 2px;
border-radius: 6px;
- background: #fff;
+ background: var(--panel-strong);
padding: 2px 6px;
color: var(--ink);
font-size: 0.74rem;
@@ -679,7 +747,7 @@ kbd {
border-radius: var(--radius-sm);
overflow: auto;
max-height: 46vh;
- background: #fff;
+ background: var(--panel-strong);
}
.commandRow {
@@ -687,7 +755,7 @@ kbd {
border: none;
border-bottom: 1px solid var(--line);
border-radius: 0;
- background: #fff;
+ background: var(--panel-strong);
text-align: left;
box-shadow: none;
padding: 8px 10px;
@@ -701,7 +769,7 @@ kbd {
.commandRow:hover,
.commandRow.active {
- background: #edf4ff;
+ background: color-mix(in oklab, var(--accent-soft) 70%, var(--panel-strong));
}
.flash {
@@ -801,6 +869,10 @@ kbd {
grid-template-columns: 1fr;
}
+ .symbolPinMain {
+ grid-template-columns: 1fr 1fr;
+ }
+
.jsonActions {
flex-direction: column;
gap: 4px;
diff --git a/src/render.js b/src/render.js
index 5cf72f5..ca6721e 100644
--- a/src/render.js
+++ b/src/render.js
@@ -441,6 +441,7 @@ function renderAnnotationPanel(entries, maxWidth = 380) {
export function renderSvgFromLayout(model, layout, options = {}) {
const showLabels = options.show_labels !== false;
const showAnnotations = options.show_annotations !== false;
+ const showLegend = options.show_legend !== false;
const pinNets = pinNetMap(model);
const netClassByName = new Map((model.nets ?? []).map((n) => [n.name, n.class]));
const allPinPoints = [];
@@ -635,7 +636,7 @@ export function renderSvgFromLayout(model, layout, options = {}) {
...(annotationPanel.height > 0
? [{ x: annotationPanel.x - 2, y: annotationPanel.y - 2, width: annotationPanel.width + 4, height: annotationPanel.height + 4 }]
: []),
- { x: 6, y: legendY - 8, width: 126, height: 86 },
+ ...(showLegend ? [{ x: 6, y: legendY - 8, width: 126, height: 86 }] : []),
...componentRects
];
@@ -761,7 +762,7 @@ export function renderSvgFromLayout(model, layout, options = {}) {
${labelLayer}
${busLabels}
${annotationPanel.svg}
- ${renderLegend(legendY)}
+ ${showLegend ? renderLegend(legendY) : ""}
`;
}