Polish legacy UI: dark theme, note hints, lock-safe drag, and cleaner symbol editor
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-20 02:06:07 -05:00
parent 46175efe1b
commit 9a21e111d0
4 changed files with 227 additions and 52 deletions

View File

@ -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) => `<option value="${s}" ${pin.side === s ? "selected" : ""}>${s}</option>`).join("");
const typeOptions = PIN_TYPES.map((t) => `<option value="${t}" ${pin.type === t ? "selected" : ""}>${t}</option>`).join("");
return `<div class="miniRow symbolPinRow" data-old-pin="${escHtml(pin.name)}">
<input class="pinCol pinName" type="text" value="${escHtml(pin.name)}" placeholder="name" />
<input class="pinCol pinNumber" type="text" value="${escHtml(pin.number)}" placeholder="number" />
<select class="pinCol pinSide">${sideOptions}</select>
<input class="pinCol pinOffset" type="number" min="0" step="1" value="${Number(pin.offset ?? 0)}" />
<select class="pinCol pinType">${typeOptions}</select>
<button type="button" data-move-symbol-pin="up" title="Move pin up">Up</button>
<button type="button" data-move-symbol-pin="down" title="Move pin down">Down</button>
<button type="button" data-remove-symbol-pin="${escHtml(pin.name)}">Remove</button>
<div class="symbolPinMain">
<input class="pinCol pinName" type="text" value="${escHtml(pin.name)}" placeholder="name" />
<input class="pinCol pinNumber" type="text" value="${escHtml(pin.number)}" placeholder="num" />
<select class="pinCol pinSide">${sideOptions}</select>
<input class="pinCol pinOffset" type="number" min="0" step="1" value="${Number(pin.offset ?? 0)}" />
<select class="pinCol pinType">${typeOptions}</select>
</div>
<div class="symbolPinActions">
<button type="button" data-move-symbol-pin="up" title="Move pin up">Up</button>
<button type="button" data-move-symbol-pin="down" title="Move pin down">Down</button>
<button type="button" data-remove-symbol-pin="${escHtml(pin.name)}">Remove</button>
</div>
</div>`;
}
@ -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();

View File

@ -20,14 +20,15 @@
<button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button>
<button id="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button>
<button id="autoTidyBtn" aria-label="Run automatic tidy layout">Auto Tidy</button>
<button id="themeToggleBtn" aria-label="Toggle dark mode">Dark</button>
<button id="shortcutsBtn" aria-label="Show keyboard shortcuts">Shortcuts</button>
<button id="undoBtn" title="Undo (Ctrl/Cmd+Z)">Undo</button>
<button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button>
<label class="inlineSelect">
Mode
<select id="renderModeSelect">
<option value="schematic_stub">Schematic Stub</option>
<option value="explicit">Explicit Wires</option>
<option value="schematic_stub">Schematic Stub</option>
</select>
</label>
<input id="fileInput" type="file" accept="application/json,.json" hidden />

View File

@ -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;

View File

@ -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 = {}) {
<g data-layer="net-labels">${labelLayer}</g>
<g data-layer="bus-groups">${busLabels}</g>
${annotationPanel.svg}
${renderLegend(legendY)}
${showLegend ? renderLegend(legendY) : ""}
</svg>`;
}