diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index f414f17ba..a9e9ff880 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -26,16 +26,23 @@ jobs: node-version: '22' - name: JavaScript syntax - run: node --check apps/smv_injector_gui/app.js + run: | + node --check apps/smv_injector_gui/app.js + node --check apps/smv_injector_gui/premium-v3.js - name: Required UI files shell: bash run: | test -s apps/smv_injector_gui/index.html test -s apps/smv_injector_gui/styles.css + test -s apps/smv_injector_gui/responsive.css + test -s apps/smv_injector_gui/premium-v3.css test -s apps/smv_injector_gui/app.js + test -s apps/smv_injector_gui/premium-v3.js test -s apps/smv_injector_gui/run.cmd grep -q 'SMV Injector' apps/smv_injector_gui/index.html + grep -q 'premium-v3.css' apps/smv_injector_gui/index.html + grep -q 'premium-v3.js' apps/smv_injector_gui/index.html grep -q 'navigator.serial' apps/smv_injector_gui/app.js - name: Local-server smoke @@ -51,3 +58,4 @@ jobs: sleep 0.25 done grep -q 'ARStack61850' /tmp/smv-gui-index.html + grep -q 'premium-v3.css' /tmp/smv-gui-index.html diff --git a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md new file mode 100644 index 000000000..3a32b40f5 --- /dev/null +++ b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md @@ -0,0 +1,67 @@ +# Premium UX review — ESP32-P4 SMV Injector + +This branch is intentionally an evaluation surface. Do not merge it into the production GUI until the injection workflow and visual direction are approved. + +## V3 visual direction + +The second review confirmed the keyboard workflow is moving in the right direction, but the screen still felt visually noisy and the generated phasor/waveform did not read strongly enough during normal bench use. + +V3 keeps the V2 matrix interaction intact and focuses on calm premium instrument art direction: + +- one UI type stack: Inter -> Plus Jakarta Sans -> native system fallback, with monospace reserved for engineering numbers; +- larger and more consistent breathing space between working groups; +- borders reduced to secondary separators instead of outlining every object; +- idle numeric fields use low-contrast tonal surfaces and only become strongly framed on focus; +- source/runtime information is intentionally quieter than the injection workspace; +- Current and Voltage remain separate side-by-side matrices; +- the command strip becomes a tonal toolbar instead of another bordered card; +- the preview stays beside the injection matrix down to a narrower viewport before stacking; +- phasor vectors and waveform traces use stronger strokes, round line caps, and higher non-active contrast; +- no blur, glassmorphism, decorative glow, chart dependency, or high-rate animation is introduced. + +The repository does not currently contain a local Inter or Plus Jakarta font asset, so this review branch does not add a network font dependency. The stack prefers either font when available and remains fully offline. A vendored font binary can be added separately only after the project decides its font packaging/license policy. + +## Keyboard-first matrix navigation + +Numeric cells behave as an engineering matrix rather than independent HTML fields: + +- `Arrow Up / Down` — previous / next channel in the same numeric column. +- `Arrow Left / Right` — move between Magnitude and Phase, wrapping to the adjacent row. +- `Enter` — commit when Live Apply is off, then move to the next channel. +- `Ctrl + Arrow Up / Down` — increment / decrement by the field step. +- `Ctrl + Shift + Arrow Up / Down` — coarse step (10×). +- `Tab / Shift+Tab` — normal sequential field navigation. +- Focusing a numeric cell selects its whole value so typing immediately replaces the old value. + +The navigation wraps through `Ia → Ib → Ic → In → Ua → Ub → Uc → Un`, so repeated arrow-key operation never requires reaching for the mouse. + +## Primary workflow to evaluate + +1. Open the GUI. +2. Prepare values while the device is offline if desired. +3. Use the arrow keys to move through the injection matrix and type new values directly. +4. Optionally enable 3-phase link for balanced magnitude/angle editing. +5. Confirm the generated phasor and waveform without leaving the workspace. +6. Connect the ESP32-P4. +7. Use Live Apply for immediate setpoint updates, or pause Live Apply and use Enter / Apply All. +8. Start live injection from the persistent bottom action bar. + +## Visualization truthfulness + +Phasor and waveform are generated setpoint previews. They visualize what the configured signal generator is intended to produce. They are not Ethernet on-wire proof. Future observed/captured data must be clearly separated from generated data. + +## Review checklist + +Please judge this V3 branch on these questions: + +- Can an engineer change many Ia/Ib/Ic/Ua/Ub/Uc values without touching the mouse? +- Does the screen feel calmer, more expensive, and less like a browser form? +- Do Current and Voltage read as the central task instead of being surrounded by competing metadata? +- Are the phasor vectors and waveform traces immediately visible without squinting? +- Does Signal Preview remain beside the injector at the actual browser zoom used on the bench? +- Is Start/Stop always obvious without dominating the entire screen? +- Is secondary protocol information available without slowing the primary workflow? + +## Scope + +GUI only. No firmware timing, SV packet layout, or serial command protocol is changed by this evaluation branch. The PR remains draft and unmerged until the visual direction is approved. diff --git a/apps/smv_injector_gui/app.js b/apps/smv_injector_gui/app.js index eeedfe5dd..cbec54012 100644 --- a/apps/smv_injector_gui/app.js +++ b/apps/smv_injector_gui/app.js @@ -1,14 +1,20 @@ "use strict"; const CHANNELS = [ - { id: "IA", kind: "current", label: "Current A", magnitude: 1.0, phase: 0, color: "#ff5f6d" }, - { id: "IB", kind: "current", label: "Current B", magnitude: 1.0, phase: -120, color: "#f2bb4c" }, - { id: "IC", kind: "current", label: "Current C", magnitude: 1.0, phase: 120, color: "#42a5f5" }, - { id: "IN", kind: "current", label: "Current N", magnitude: 0.0, phase: 0, color: "#a88cff" }, - { id: "UA", kind: "voltage", label: "Voltage A", magnitude: 57.74, phase: 0, color: "#ff5f6d" }, - { id: "UB", kind: "voltage", label: "Voltage B", magnitude: 57.74, phase: -120, color: "#f2bb4c" }, - { id: "UC", kind: "voltage", label: "Voltage C", magnitude: 57.74, phase: 120, color: "#42a5f5" }, - { id: "UN", kind: "voltage", label: "Voltage N", magnitude: 0.0, phase: 0, color: "#a88cff" }, + { id: "IA", kind: "current", label: "Current A", magnitude: 1.0, phase: 0, color: "#ff6b74" }, + { id: "IB", kind: "current", label: "Current B", magnitude: 1.0, phase: -120, color: "#e7b652" }, + { id: "IC", kind: "current", label: "Current C", magnitude: 1.0, phase: 120, color: "#5aa9ff" }, + { id: "IN", kind: "current", label: "Current N", magnitude: 0.0, phase: 0, color: "#9a86df" }, + { id: "UA", kind: "voltage", label: "Voltage A", magnitude: 57.74, phase: 0, color: "#ff6b74" }, + { id: "UB", kind: "voltage", label: "Voltage B", magnitude: 57.74, phase: -120, color: "#e7b652" }, + { id: "UC", kind: "voltage", label: "Voltage C", magnitude: 57.74, phase: 120, color: "#5aa9ff" }, + { id: "UN", kind: "voltage", label: "Voltage N", magnitude: 0.0, phase: 0, color: "#9a86df" }, +]; + +const CHANNEL_ORDER = CHANNELS.map((channel) => channel.id); +const GROUPS = [ + { kind: "current", title: "Current", symbol: "I", unit: "A RMS", ids: ["IA", "IB", "IC", "IN"] }, + { kind: "voltage", title: "Voltage", symbol: "U", unit: "V RMS", ids: ["UA", "UB", "UC", "UN"] }, ]; const state = { @@ -18,183 +24,334 @@ const state = { connected: false, running: false, liveApply: true, + link3p: false, frequencyHz: 50, + activeChannel: "UA", + activeField: "magnitude", channels: new Map(CHANNELS.map((channel) => [channel.id, { ...channel, enabled: true, quality: 0 }])), sendChain: Promise.resolve(), }; const els = {}; let toastTimer = null; +let visualFramePending = false; +let applyTimer = null; +const pendingApplyIds = new Set(); function $(id) { return document.getElementById(id); } +function displayId(id) { return `${id[0]}${id[1].toLowerCase()}`; } +function unitFor(channel) { return channel.kind === "current" ? "A" : "V"; } +function wireCountFor(channel) { return Math.round(channel.magnitude * (channel.kind === "current" ? 1000 : 100)); } +function phaseMdeg(channel) { return Math.round(channel.phase * 1000); } +function frequencyMhz() { return Math.round(state.frequencyHz * 1000); } +function formatMagnitude(value) { return Number(value).toFixed(3); } +function formatPhase(value) { return Number(value).toFixed(2); } function initElements() { [ "deviceStatus", "deviceStatusText", "connectButton", "runState", "runStateText", "startButton", "stopButton", "fpsMetric", "missedMetric", "failMetric", - "generationMetric", "frequencyInput", "frequencySlider", "liveApplyToggle", - "balancedButton", "zeroButton", "applyAllButton", "channelGrid", "runtimeLog", - "clearLogButton", "phasorCanvas", "toast", "sclFile", "sclState" + "generationMetric", "frequencyInput", "liveApplyToggle", "balancedButton", "zeroButton", + "applyAllButton", "channelGrid", "runtimeLog", "clearLogButton", "phasorCanvas", + "waveformCanvas", "toast", "sclFile", "sclState", "systemReadiness", "footerReadiness", + "footerReadinessDetail", "footerRate", "waveformFrequencyLabel", "phaseLinkButton", + "freq50Button", "freq60Button", "activeValueDetail", "phasorSelectedLabel", "footerSelection" ].forEach((id) => { els[id] = $(id); }); } -function wireCountFor(channel) { - if (channel.kind === "current") return Math.round(channel.magnitude * 1000); - return Math.round(channel.magnitude * 100); -} - -function phaseMdeg(channel) { return Math.round(channel.phase * 1000); } -function frequencyMhz() { return Math.round(state.frequencyHz * 1000); } - function showToast(message, error = false) { clearTimeout(toastTimer); els.toast.textContent = message; els.toast.classList.toggle("error", error); els.toast.classList.add("show"); - toastTimer = setTimeout(() => els.toast.classList.remove("show"), 2400); + toastTimer = setTimeout(() => els.toast.classList.remove("show"), 2100); } function logLine(text, direction = "rx") { - const now = new Date(); - const stamp = now.toLocaleTimeString([], { hour12: false }); + const stamp = new Date().toLocaleTimeString([], { hour12: false }); const prefix = direction === "tx" ? "→" : direction === "ui" ? "•" : "←"; if (els.runtimeLog.textContent === "Waiting for device connection…") els.runtimeLog.textContent = ""; els.runtimeLog.textContent += `[${stamp}] ${prefix} ${text}\n`; - if (els.runtimeLog.textContent.length > 60000) { - els.runtimeLog.textContent = els.runtimeLog.textContent.slice(-48000); - } + if (els.runtimeLog.textContent.length > 60000) els.runtimeLog.textContent = els.runtimeLog.textContent.slice(-48000); els.runtimeLog.scrollTop = els.runtimeLog.scrollHeight; } +function updateReadiness() { + document.body.dataset.connected = String(state.connected); + document.body.dataset.running = String(state.running); + + if (state.running) { + els.systemReadiness.dataset.state = "running"; + els.systemReadiness.textContent = "INJECTING"; + els.footerReadiness.textContent = "INJECTING"; + els.footerReadinessDetail.textContent = "ESP32-P4 realtime publisher active"; + return; + } + + if (state.connected) { + els.systemReadiness.dataset.state = "ready"; + els.systemReadiness.textContent = "READY"; + els.footerReadiness.textContent = "READY"; + els.footerReadinessDetail.textContent = "Profile armed · edit values or start live injection"; + return; + } + + els.systemReadiness.dataset.state = "offline"; + els.systemReadiness.textContent = "OFFLINE"; + els.footerReadiness.textContent = "DEVICE OFFLINE"; + els.footerReadinessDetail.textContent = "Setpoints are editable · connect ESP32-P4 to arm output"; +} + function setConnected(connected) { state.connected = connected; els.deviceStatus.dataset.state = connected ? "online" : "offline"; - els.deviceStatusText.textContent = connected ? "Device connected" : "Device offline"; - els.connectButton.textContent = connected ? "Disconnect" : "Connect device"; + els.deviceStatusText.textContent = connected ? "ESP32-P4 connected" : "Device offline"; + els.connectButton.textContent = connected ? "Disconnect" : "Connect"; els.startButton.disabled = !connected || state.running; els.stopButton.disabled = !connected || !state.running; - els.zeroButton.disabled = !connected; els.applyAllButton.disabled = !connected; - document.querySelectorAll(".channel-apply").forEach((button) => { button.disabled = !connected; }); + updateReadiness(); } function setRunning(running) { state.running = running; els.runState.dataset.state = running ? "running" : "stopped"; - els.runStateText.textContent = running ? "RUNNING" : "STOPPED"; + els.runStateText.textContent = running ? "INJECTING" : "STOPPED"; els.startButton.disabled = !state.connected || running; els.stopButton.disabled = !state.connected || !running; + updateReadiness(); } -function buildChannels() { +function buildMatrices() { els.channelGrid.innerHTML = ""; - for (const source of CHANNELS) { - const channel = state.channels.get(source.id); - const unit = channel.kind === "current" ? "A RMS" : "V RMS"; - const magStep = channel.kind === "current" ? "0.001" : "0.01"; - const magMax = channel.kind === "current" ? "10000" : "1000000"; - - const card = document.createElement("article"); - card.className = "channel-card"; - card.dataset.channel = channel.id; - card.dataset.enabled = String(channel.enabled); - card.style.setProperty("--phase-color", channel.color); - card.innerHTML = ` -
-
- -
${channel.id}${channel.label}
-
- + + for (const group of GROUPS) { + const section = document.createElement("section"); + section.className = `signal-group ${group.kind}-group`; + section.innerHTML = ` +
+
${group.symbol}${group.title}
+ ${group.unit}
-
-
- -
- - ${unit} -
-
-
- -
- - deg -
-
+
+ OnChMagnitudePhase
- - `; - - els.channelGrid.appendChild(card); - bindChannelCard(card, channel.id); - updateWirePreview(card, channel); +
`; + + const list = section.querySelector(".channel-list"); + for (const id of group.ids) list.appendChild(buildChannelRow(id)); + els.channelGrid.appendChild(section); } + + setActiveChannel(state.activeChannel, state.activeField); } -function readCardValues(card, id) { +function buildChannelRow(id) { const channel = state.channels.get(id); - const magnitude = Number(card.querySelector(".magnitude-input").value); - const phase = Number(card.querySelector(".phase-input").value); - const enabled = card.querySelector(".channel-enable").checked; - if (!Number.isFinite(magnitude) || magnitude < 0 || !Number.isFinite(phase)) return false; - channel.magnitude = magnitude; - channel.phase = phase; - channel.enabled = enabled; - card.dataset.enabled = String(enabled); - updateWirePreview(card, channel); - drawPhasors(); - return true; -} + const rowIndex = CHANNEL_ORDER.indexOf(id); + const row = document.createElement("div"); + row.className = "channel-row"; + row.dataset.channel = id; + row.dataset.enabled = String(channel.enabled); + row.dataset.active = String(id === state.activeChannel); + row.style.setProperty("--phase-color", channel.color); + row.setAttribute("role", "row"); + row.innerHTML = ` + +
${displayId(id)}
+
+
+ + ${unitFor(channel)} +
+
+
+
+ + ° +
+
`; -function updateWirePreview(card, channel) { - card.querySelector(".wire-preview").textContent = `${wireCountFor(channel)} counts · ${phaseMdeg(channel)} mdeg`; + bindChannelRow(row, id); + return row; } -function bindChannelCard(card, id) { - const magnitude = card.querySelector(".magnitude-input"); - const phase = card.querySelector(".phase-input"); - const slider = card.querySelector(".phase-slider"); - const enable = card.querySelector(".channel-enable"); - const apply = card.querySelector(".channel-apply"); +function bindChannelRow(row, id) { + const enable = row.querySelector(".channel-enable"); + const inputs = [...row.querySelectorAll(".nav-input")]; - const schedule = debounce(async () => { - if (!readCardValues(card, id)) return; - if (state.liveApply && state.connected) await applyChannel(id); - }, 130); + row.addEventListener("pointerdown", () => setActiveChannel(id, state.activeField)); + + for (const input of inputs) { + const field = input.dataset.field; + + input.addEventListener("focus", () => { + setActiveChannel(id, field); + requestAnimationFrame(() => input.select()); + }); + + input.addEventListener("pointerdown", () => setActiveChannel(id, field)); + + input.addEventListener("input", () => { + const value = Number(input.value); + if (!Number.isFinite(value)) return; + const channel = state.channels.get(id); + if (field === "magnitude" && value < 0) return; + channel[field] = value; + const changedIds = state.link3p ? applyThreePhaseLink(id, field) : [id]; + setActiveChannel(id, field); + scheduleVisualUpdate(); + if (state.liveApply && state.connected) queueChannelApply(changedIds); + }); + + input.addEventListener("keydown", (event) => handleMatrixKeydown(event, input, id)); + } - magnitude.addEventListener("input", schedule); - phase.addEventListener("input", () => { - const value = Number(phase.value); - if (Number.isFinite(value) && value >= -180 && value <= 180) slider.value = String(value); - schedule(); - }); - slider.addEventListener("input", () => { - phase.value = Number(slider.value).toFixed(1).replace(/\.0$/, ""); - schedule(); - }); enable.addEventListener("change", async () => { - if (!readCardValues(card, id)) return; - if (state.connected) await sendCommand(`ENABLE ${id} ${state.channels.get(id).enabled ? 1 : 0}`); + const channel = state.channels.get(id); + channel.enabled = enable.checked; + row.dataset.enabled = String(channel.enabled); + setActiveChannel(id, state.activeField); + scheduleVisualUpdate(); + if (state.connected) await sendCommand(`ENABLE ${id} ${channel.enabled ? 1 : 0}`); }); - apply.addEventListener("click", async () => { - if (!readCardValues(card, id)) return; - await applyChannel(id); +} + +function handleMatrixKeydown(event, input, id) { + const rowIndex = Number(input.dataset.rowIndex); + const colIndex = Number(input.dataset.colIndex); + const rowCount = CHANNEL_ORDER.length; + + if (event.ctrlKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) { + event.preventDefault(); + const baseStep = Number(input.step) || (colIndex === 0 ? 0.001 : 0.01); + const multiplier = event.shiftKey ? 10 : 1; + const delta = baseStep * multiplier * (event.key === "ArrowUp" ? 1 : -1); + const next = Number(input.value || 0) + delta; + const decimals = colIndex === 0 ? 3 : 2; + input.value = next.toFixed(decimals); + input.dispatchEvent(new Event("input", { bubbles: true })); + return; + } + + if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) { + event.preventDefault(); + let nextRow = rowIndex; + let nextCol = colIndex; + + if (event.key === "ArrowUp") nextRow = (rowIndex - 1 + rowCount) % rowCount; + if (event.key === "ArrowDown") nextRow = (rowIndex + 1) % rowCount; + if (event.key === "ArrowLeft") { + if (colIndex > 0) nextCol -= 1; + else { nextRow = (rowIndex - 1 + rowCount) % rowCount; nextCol = 1; } + } + if (event.key === "ArrowRight") { + if (colIndex < 1) nextCol += 1; + else { nextRow = (rowIndex + 1) % rowCount; nextCol = 0; } + } + + focusCell(nextRow, nextCol); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + if (state.connected && !state.liveApply) applyChannel(id); + focusCell((rowIndex + 1) % rowCount, colIndex); + return; + } + + if (event.key === "Escape") input.blur(); +} + +function focusCell(rowIndex, colIndex) { + const target = document.querySelector(`.nav-input[data-row-index="${rowIndex}"][data-col-index="${colIndex}"]`); + if (!target) return; + target.focus(); + target.select(); +} + +function normalizeAngle(angle) { + let value = angle % 360; + if (value > 180) value -= 360; + if (value <= -180) value += 360; + return value; +} + +function applyThreePhaseLink(sourceId, field) { + const prefix = sourceId[0]; + const suffix = sourceId[1]; + if (!['A', 'B', 'C'].includes(suffix)) return [sourceId]; + + const ids = [`${prefix}A`, `${prefix}B`, `${prefix}C`]; + const source = state.channels.get(sourceId); + + if (field === "magnitude") { + for (const id of ids) state.channels.get(id).magnitude = source.magnitude; + } else { + let base = source.phase; + if (suffix === "B") base += 120; + if (suffix === "C") base -= 120; + state.channels.get(`${prefix}A`).phase = normalizeAngle(base); + state.channels.get(`${prefix}B`).phase = normalizeAngle(base - 120); + state.channels.get(`${prefix}C`).phase = normalizeAngle(base + 120); + } + + for (const id of ids) syncChannelInputs(id); + return ids; +} + +function syncChannelInputs(id) { + const channel = state.channels.get(id); + const row = document.querySelector(`.channel-row[data-channel="${id}"]`); + if (!row) return; + const magnitude = row.querySelector(".magnitude-input"); + const phase = row.querySelector(".phase-input"); + if (document.activeElement !== magnitude) magnitude.value = formatMagnitude(channel.magnitude); + if (document.activeElement !== phase) phase.value = formatPhase(channel.phase); +} + +function setActiveChannel(id, field = state.activeField) { + state.activeChannel = id; + state.activeField = field; + const channel = state.channels.get(id); + + document.querySelectorAll(".channel-row").forEach((row) => { + row.dataset.active = String(row.dataset.channel === id); }); + document.querySelectorAll(".nav-input").forEach((input) => { + input.dataset.navActive = String(input.closest(".channel-row")?.dataset.channel === id && input.dataset.field === field); + }); + + updateActiveDetails(channel); + scheduleVisualUpdate(); } -function debounce(fn, delay) { - let timer = null; - return (...args) => { - clearTimeout(timer); - timer = setTimeout(() => fn(...args), delay); - }; +function updateActiveDetails(channel) { + if (!channel) return; + const id = displayId(channel.id); + const unit = unitFor(channel); + const summary = `${formatMagnitude(channel.magnitude)} ${unit} RMS · ${formatPhase(channel.phase)}°`; + const wire = `${wireCountFor(channel)} counts · ${phaseMdeg(channel)} mdeg`; + + els.activeValueDetail.innerHTML = `${id}${summary} · ${wire}`; + els.phasorSelectedLabel.textContent = `${id} · ${formatMagnitude(channel.magnitude)} ${unit} ∠ ${formatPhase(channel.phase)}°`; + els.footerSelection.textContent = `${id} ${formatMagnitude(channel.magnitude)} ${unit} ∠${formatPhase(channel.phase)}°`; +} + +function queueChannelApply(ids) { + for (const id of ids) pendingApplyIds.add(id); + clearTimeout(applyTimer); + applyTimer = setTimeout(async () => { + const queue = CHANNEL_ORDER.filter((id) => pendingApplyIds.has(id)); + pendingApplyIds.clear(); + for (const id of queue) await applyChannel(id); + }, 90); } async function sendCommand(command) { @@ -202,6 +359,7 @@ async function sendCommand(command) { showToast("Connect the device first", true); return false; } + state.sendChain = state.sendChain.then(async () => { const writer = state.port.writable.getWriter(); try { @@ -214,6 +372,7 @@ async function sendCommand(command) { logLine(`Serial write failed: ${error.message}`, "ui"); showToast(`Serial write failed: ${error.message}`, true); }); + await state.sendChain; return true; } @@ -224,15 +383,15 @@ async function applyChannel(id) { } async function applyAll() { - for (const source of CHANNELS) await applyChannel(source.id); + for (const id of CHANNEL_ORDER) await applyChannel(id); await sendCommand(`FREQ ${frequencyMhz()}`); - showToast("All live signal values applied"); + showToast("All injection values applied"); } async function connectSerial() { if (!("serial" in navigator)) { - showToast("This browser does not expose the Web Serial API", true); - logLine("Web Serial API unavailable. Use a compatible browser on localhost.", "ui"); + showToast("Web Serial API is unavailable in this browser", true); + logLine("Web Serial API unavailable. Open the localhost GUI in a compatible browser.", "ui"); return; } @@ -268,6 +427,8 @@ async function disconnectSerial() { els.fpsMetric.textContent = "—"; els.missedMetric.textContent = "—"; els.failMetric.textContent = "—"; + els.generationMetric.textContent = "—"; + els.footerRate.textContent = "4000 sample/s"; showToast("Device disconnected"); } } @@ -317,10 +478,11 @@ function processDeviceLine(raw) { const timing = line.match(/samples=(\d+)\s+\(~(\d+)\s+fps\).*?MC ok=(\d+) fail=(\d+).*?missed=(\d+).*?signal_gen=(\d+)/i); if (timing) { - els.fpsMetric.textContent = `${timing[2]} fps`; + els.fpsMetric.textContent = timing[2]; els.failMetric.textContent = timing[4]; els.missedMetric.textContent = timing[5]; els.generationMetric.textContent = timing[6]; + els.footerRate.textContent = `${timing[2]} fps observed`; } const committed = line.match(/Live signal generation\s+(\d+)\s+committed/i); @@ -328,12 +490,12 @@ function processDeviceLine(raw) { } function setFrequencyUi(hz, transmit = true) { + if (!Number.isFinite(hz) || hz < 1 || hz > 1000) return; state.frequencyHz = hz; els.frequencyInput.value = hz.toFixed(3); - if (hz >= 45 && hz <= 65) els.frequencySlider.value = String(hz); - if (transmit && state.liveApply && state.connected) { - sendCommand(`FREQ ${frequencyMhz()}`); - } + els.waveformFrequencyLabel.textContent = `${hz.toFixed(3)} Hz`; + scheduleVisualUpdate(); + if (transmit && state.liveApply && state.connected) sendCommand(`FREQ ${frequencyMhz()}`); } function setBalanced() { @@ -341,89 +503,223 @@ function setBalanced() { IA: [1.0, 0], IB: [1.0, -120], IC: [1.0, 120], IN: [0, 0], UA: [57.74, 0], UB: [57.74, -120], UC: [57.74, 120], UN: [0, 0], }; + for (const [id, [magnitude, phase]] of Object.entries(values)) { const channel = state.channels.get(id); channel.magnitude = magnitude; channel.phase = phase; channel.enabled = true; } - buildChannels(); - drawPhasors(); + + buildMatrices(); + scheduleVisualUpdate(); if (state.connected) applyAll(); + showToast("Balanced 3-phase setpoint loaded"); } async function zeroOutputs() { for (const channel of state.channels.values()) channel.magnitude = 0; - buildChannels(); - drawPhasors(); + buildMatrices(); + scheduleVisualUpdate(); if (state.connected) await sendCommand("ZERO"); + showToast(state.connected ? "Outputs zeroed" : "Local setpoints zeroed"); } -function drawPhasors() { - const canvas = els.phasorCanvas; +function toggleThreePhaseLink() { + state.link3p = !state.link3p; + els.phaseLinkButton.setAttribute("aria-pressed", String(state.link3p)); + const small = els.phaseLinkButton.querySelector("small"); + if (small) small.textContent = state.link3p ? "Magnitude + angle linked" : "Independent"; + showToast(state.link3p ? "3-phase link enabled" : "Independent phase editing"); +} + +function scheduleVisualUpdate() { + if (visualFramePending) return; + visualFramePending = true; + requestAnimationFrame(() => { + visualFramePending = false; + drawPhasors(); + drawWaveforms(); + updateActiveDetails(state.channels.get(state.activeChannel)); + }); +} + +function prepareCanvas(canvas, minWidth = 260, minHeight = 150) { const ctx = canvas.getContext("2d"); const rect = canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - const width = Math.max(300, Math.floor(rect.width * dpr)); - const height = Math.max(220, Math.floor(rect.height * dpr)); + const dpr = Math.min(2, window.devicePixelRatio || 1); + const width = Math.max(minWidth, Math.floor(rect.width * dpr)); + const height = Math.max(minHeight, Math.floor(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } + + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, width, height); - ctx.save(); ctx.scale(dpr, dpr); - const w = width / dpr; - const h = height / dpr; - const cx = w / 2; - const cy = h / 2; - const radius = Math.min(w, h) * 0.36; + return { ctx, w: width / dpr, h: height / dpr }; +} + +function drawPhasors() { + const { ctx, w, h } = prepareCanvas(els.phasorCanvas, 280, 210); + const cx = w * 0.5; + const cy = h * 0.52; + const radius = Math.max(58, Math.min(w * 0.36, h * 0.36)); - ctx.strokeStyle = "rgba(143,166,183,.16)"; + ctx.save(); + ctx.strokeStyle = "rgba(139,154,170,.11)"; ctx.lineWidth = 1; - for (const factor of [0.33, 0.66, 1]) { - ctx.beginPath(); ctx.arc(cx, cy, radius * factor, 0, Math.PI * 2); ctx.stroke(); + for (const factor of [.33, .66, 1]) { + ctx.beginPath(); + ctx.arc(cx, cy, radius * factor, 0, Math.PI * 2); + ctx.stroke(); + } + + for (let deg = 0; deg < 180; deg += 30) { + const a = deg * Math.PI / 180; + const dx = Math.cos(a) * (radius + 8); + const dy = Math.sin(a) * (radius + 8); + ctx.strokeStyle = "rgba(139,154,170,.075)"; + ctx.beginPath(); + ctx.moveTo(cx - dx, cy - dy); + ctx.lineTo(cx + dx, cy + dy); + ctx.stroke(); } - ctx.beginPath(); ctx.moveTo(cx - radius - 20, cy); ctx.lineTo(cx + radius + 20, cy); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(cx, cy - radius - 20); ctx.lineTo(cx, cy + radius + 20); ctx.stroke(); const groups = [ - ["IA", "IB", "IC"], - ["UA", "UB", "UC"], + { ids: ["UA", "UB", "UC", "UN"], scale: 1, width: 2.0, dash: [] }, + { ids: ["IA", "IB", "IC", "IN"], scale: .78, width: 1.2, dash: [4, 3] }, ]; - groups.forEach((ids, groupIndex) => { - const maxMagnitude = Math.max(0.0001, ...ids.map((id) => state.channels.get(id).magnitude)); - ids.forEach((id) => { + + for (const group of groups) { + const maxMagnitude = Math.max(.0001, ...group.ids.map((id) => state.channels.get(id).magnitude)); + for (const id of group.ids) { const channel = state.channels.get(id); - if (!channel.enabled || channel.magnitude <= 0) return; + if (!channel.enabled || channel.magnitude <= 0) continue; + const active = id === state.activeChannel; + const ratio = Math.min(1, channel.magnitude / maxMagnitude); + const length = radius * group.scale * (.32 + .68 * ratio); const angle = channel.phase * Math.PI / 180; - const visualScale = 0.38 + 0.62 * Math.min(1, channel.magnitude / maxMagnitude); - const length = radius * visualScale * (groupIndex === 0 ? 0.86 : 1); const x = cx + Math.cos(angle) * length; const y = cy - Math.sin(angle) * length; + + ctx.save(); + ctx.globalAlpha = active ? 1 : .48; ctx.strokeStyle = channel.color; ctx.fillStyle = channel.color; - ctx.globalAlpha = groupIndex === 0 ? 0.75 : 1; - ctx.lineWidth = groupIndex === 0 ? 2 : 3; - ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(x, y); ctx.stroke(); - const head = 8; + ctx.lineWidth = active ? group.width + 1 : group.width; + ctx.setLineDash(group.dash); + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(x, y); + ctx.stroke(); + ctx.setLineDash([]); + + const head = active ? 7 : 5; ctx.beginPath(); ctx.moveTo(x, y); - ctx.lineTo(x - Math.cos(angle - 0.45) * head, y + Math.sin(angle - 0.45) * head); - ctx.lineTo(x - Math.cos(angle + 0.45) * head, y + Math.sin(angle + 0.45) * head); - ctx.closePath(); ctx.fill(); - ctx.font = "600 11px system-ui"; - ctx.fillText(id, x + 7, y - 6); - ctx.globalAlpha = 1; - }); - }); + ctx.lineTo(x - Math.cos(angle - .45) * head, y + Math.sin(angle - .45) * head); + ctx.lineTo(x - Math.cos(angle + .45) * head, y + Math.sin(angle + .45) * head); + ctx.closePath(); + ctx.fill(); + + if (active) { + ctx.font = "650 9px system-ui"; + ctx.fillText(displayId(id), x + 7, y - 6); + } + ctx.restore(); + } + } + + ctx.fillStyle = "rgba(180,192,204,.42)"; + ctx.font = "500 8px system-ui"; + ctx.fillText("0°", cx + radius + 9, cy + 3); + ctx.fillText("90°", cx + 4, cy - radius - 8); + ctx.fillText("180°", cx - radius - 29, cy + 3); + ctx.fillText("270°", cx + 4, cy + radius + 12); + ctx.fillStyle = "rgba(238,242,246,.24)"; + ctx.beginPath(); + ctx.arc(cx, cy, 2, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +function drawWaveforms() { + const { ctx, w, h } = prepareCanvas(els.waveformCanvas, 280, 170); + const pad = { left: 27, right: 8, top: 12, bottom: 15 }; + const plotW = Math.max(1, w - pad.left - pad.right); + const plotH = Math.max(1, h - pad.top - pad.bottom); + const laneGap = 14; + const laneH = (plotH - laneGap) / 2; + const voltageY = pad.top + laneH / 2; + const currentY = pad.top + laneH + laneGap + laneH / 2; + const windowSeconds = .04; + + ctx.save(); + ctx.lineWidth = 1; + for (let i = 0; i <= 8; i += 1) { + const x = pad.left + plotW * (i / 8); + ctx.strokeStyle = "rgba(139,154,170,.07)"; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, pad.top + plotH); + ctx.stroke(); + } + + for (const y of [voltageY, currentY]) { + ctx.strokeStyle = "rgba(139,154,170,.14)"; + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(pad.left + plotW, y); + ctx.stroke(); + } + + ctx.fillStyle = "rgba(127,139,153,.62)"; + ctx.font = "650 7px system-ui"; + ctx.fillText("V", 10, voltageY + 2); + ctx.fillText("I", 10, currentY + 2); + + const drawGroup = (ids, centerY, amplitude, dashed) => { + const maxMagnitude = Math.max(.0001, ...ids.map((id) => state.channels.get(id).magnitude)); + for (const id of ids) { + const channel = state.channels.get(id); + if (!channel.enabled || channel.magnitude <= 0) continue; + const active = id === state.activeChannel; + const amp = amplitude * Math.min(1, channel.magnitude / maxMagnitude); + const phaseRad = channel.phase * Math.PI / 180; + + ctx.save(); + ctx.strokeStyle = channel.color; + ctx.globalAlpha = active ? .96 : .38; + ctx.lineWidth = active ? 1.7 : 1.05; + if (dashed) ctx.setLineDash([4, 3]); + ctx.beginPath(); + const points = Math.max(150, Math.floor(plotW)); + for (let i = 0; i <= points; i += 1) { + const ratio = i / points; + const t = windowSeconds * ratio; + const value = Math.sin(2 * Math.PI * state.frequencyHz * t + phaseRad); + const x = pad.left + plotW * ratio; + const y = centerY - value * amp; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.restore(); + } + }; - ctx.fillStyle = "rgba(238,246,251,.72)"; - ctx.font = "600 10px system-ui"; - ctx.fillText("0°", cx + radius + 24, cy + 3); - ctx.fillText("+90°", cx + 5, cy - radius - 18); - ctx.fillText("±180°", cx - radius - 52, cy + 3); - ctx.fillText("−90°", cx + 5, cy + radius + 22); + drawGroup(["UA", "UB", "UC", "UN"], voltageY, laneH * .39, false); + drawGroup(["IA", "IB", "IC", "IN"], currentY, laneH * .39, true); + + ctx.fillStyle = "rgba(127,139,153,.45)"; + ctx.font = "500 7px system-ui"; + for (const ms of [0, 10, 20, 30, 40]) { + const x = pad.left + plotW * (ms / 40); + ctx.fillText(String(ms), x - (ms === 0 ? 0 : 4), h - 4); + } ctx.restore(); } @@ -434,23 +730,22 @@ function bindUi() { els.zeroButton.addEventListener("click", zeroOutputs); els.applyAllButton.addEventListener("click", applyAll); els.balancedButton.addEventListener("click", setBalanced); + els.phaseLinkButton.addEventListener("click", toggleThreePhaseLink); + els.freq50Button.addEventListener("click", () => setFrequencyUi(50, true)); + els.freq60Button.addEventListener("click", () => setFrequencyUi(60, true)); els.clearLogButton.addEventListener("click", () => { els.runtimeLog.textContent = ""; }); els.liveApplyToggle.addEventListener("change", () => { state.liveApply = els.liveApplyToggle.checked; - showToast(state.liveApply ? "Live apply enabled" : "Live apply paused"); + showToast(state.liveApply ? "Live apply enabled" : "Live apply paused · Enter commits a channel"); }); const frequencyDebounced = debounce(() => { const hz = Number(els.frequencyInput.value); - if (!Number.isFinite(hz) || hz < 1 || hz > 1000) return; - setFrequencyUi(hz, true); - }, 140); + if (Number.isFinite(hz)) setFrequencyUi(hz, true); + }, 110); + els.frequencyInput.addEventListener("focus", () => requestAnimationFrame(() => els.frequencyInput.select())); els.frequencyInput.addEventListener("input", frequencyDebounced); - els.frequencySlider.addEventListener("input", () => { - const hz = Number(els.frequencySlider.value); - setFrequencyUi(hz, true); - }); els.sclFile.addEventListener("change", async () => { const file = els.sclFile.files?.[0]; @@ -463,7 +758,7 @@ function bindUi() { throw new Error("File is not a well-formed SCL document"); } const streamCount = xml.getElementsByTagNameNS("*", "SampledValueControl").length; - els.sclState.textContent = `${file.name} · ${streamCount} SV stream${streamCount === 1 ? "" : "s"} detected`; + els.sclState.textContent = `${file.name} · ${streamCount} SV stream${streamCount === 1 ? "" : "s"}`; showToast("Engineering file loaded locally"); } catch (error) { els.sclState.textContent = "Engineering file rejected"; @@ -471,20 +766,27 @@ function bindUi() { } }); - window.addEventListener("resize", debounce(drawPhasors, 80)); - window.addEventListener("beforeunload", () => { - if (state.port) disconnectSerial(); - }); + window.addEventListener("resize", debounce(scheduleVisualUpdate, 60)); + window.addEventListener("beforeunload", () => { if (state.port) disconnectSerial(); }); +} + +function debounce(fn, delay) { + let timer = null; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; } function init() { initElements(); - buildChannels(); + buildMatrices(); bindUi(); setConnected(false); setRunning(false); setFrequencyUi(50, false); - requestAnimationFrame(drawPhasors); + setActiveChannel("UA", "magnitude"); + scheduleVisualUpdate(); if (!("serial" in navigator)) { els.deviceStatusText.textContent = "Web Serial unavailable"; diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index 9754aa900..c99523385 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -7,144 +7,206 @@ ARStack61850 · SMV Injector + - +
-
- -
-
ARStack61850
-

SMV Injector

+
+ +
+ ARStack61850 + SMV Injector
+
+ MU01_SV1 + APPID 0x4000 + 4000 sample/s + + FAST INJECT +
+
Device offline
- +
-
-
-
-
-
-
Process bus stream
-

Active publisher profile

-
- Development profile +
+
+ +
Built-in profile · ready for SCL import
-
-
- -
-
Publisher state
- STOPPED -
+
+ +
+ +
+
-
-
Observed rate—
-
Missed slots—
-
TX failures—
-
Signal generation—
+
+
+ + OFFLINE
+
+
Rate—
+
Missed—
+
TX fail—
+
Generation—
+
+
-
- - +
+ Device diagnostics +
+ Serial evidence +
-
-
+
Waiting for device connection…
+ + -
-
+
+
-
Signal frequency
-
Hz
+ Injection workspace +

Manual Injection

+
+
+ + STOPPED
-
-
-
-
-
+
-
-
-
-
-
Vector preview
-

Phase relationships

-
+
+
+ Phasor + Ua · 57.740 V ∠ 0.00°
- -
- -
-
-
-
Device evidence
-

Runtime log

-
- + +
+ +
+
+ Waveform + 50.000 Hz · 40 ms
-
Waiting for device connection…
- -
+ +
+ + +
+ +
+
+ +
+ DEVICE OFFLINE + Connect ESP32-P4 to arm live injection +
+
+ +
+ Ua 57.740 V ∠0.00° + + 4000 sample/s +
+ +
+ + + +
+
+ diff --git a/apps/smv_injector_gui/premium-v3.css b/apps/smv_injector_gui/premium-v3.css new file mode 100644 index 000000000..6e587e795 --- /dev/null +++ b/apps/smv_injector_gui/premium-v3.css @@ -0,0 +1,329 @@ +/* V3 visual-art-direction pass: calm premium instrument surfaces. + Loaded after the base + responsive sheets so the review can be reverted independently. */ + +:root { + --bg: #0a0f14; + --chrome: #0c1117; + --surface: #11171e; + --surface-2: #0d141b; + --surface-3: #141c25; + --input: #0b1117; + --line: rgba(145, 163, 182, .10); + --line-2: rgba(145, 163, 182, .17); + --text: #f2f5f8; + --text-soft: #cbd4dd; + --muted: #8794a2; + --muted-2: #647180; + --accent: #72adf7; + --accent-soft: rgba(114, 173, 247, .11); + --green: #5bd49d; + --green-soft: rgba(91, 212, 157, .10); + --radius: 10px; + --radius-sm: 7px; + --ui: Inter, "Plus Jakarta Sans", "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, sans-serif; + --mono: "Cascadia Mono", "SFMono-Regular", Consolas, monospace; + --topbar: 56px; + --actionbar: 58px; +} + +html, body, button, input, summary { font-family: var(--ui); } +body { font-size: 12.5px; letter-spacing: -.005em; } +button { letter-spacing: -.005em; } + +.topbar { + padding: 0 18px; + gap: 22px; + border-bottom-color: var(--line); + background: #0b1016; +} +.brand-block { gap: 11px; } +.brand-mark { width: 30px; height: 30px; border-color: rgba(114,173,247,.28); border-radius: 8px; background: #0e1924; } +.brand-copy { gap: 1px; } +.product-family { font-size: 8px; font-weight: 650; letter-spacing: .14em; color: #738191; } +.brand-copy strong { font-size: 13.5px; font-weight: 640; letter-spacing: -.02em; } +.top-context { gap: 14px; font-size: 10px; color: #738191; } +.top-context strong { font: 650 11px/1 var(--mono); color: #c7d1db; } +.top-context b { color: #aebbc8; } +.top-mode { color: #7899bb; font-weight: 650; } +.status-pill { height: 30px; padding-inline: 11px; border-color: var(--line); background: #0d1319; } +.btn.compact { min-height: 31px; } + +.cockpit { + padding: 12px; + gap: 12px; + grid-template-columns: 182px minmax(430px, 1fr) minmax(330px, 382px); +} +.surface { + border-color: var(--line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: inset 0 1px 0 rgba(255,255,255,.018), 0 1px 2px rgba(0,0,0,.08); +} + +.eyebrow, .section-label, .command-label { + color: #718091; + font-size: 8px; + font-weight: 650; + letter-spacing: .105em; +} + +.source-pane { padding: 16px 14px; } +.pane-heading { margin-bottom: 15px; } +.pane-heading h2 { margin-top: 4px; font: 650 14px/1.15 var(--mono); } +.stream-state, .visual-badge { + border-color: var(--line); + background: rgba(255,255,255,.018); + color: #748293; +} +.stream-summary { + gap: 7px; + overflow: visible; + border: 0; + background: transparent; +} +.stream-summary div { + padding: 9px 8px; + gap: 4px; + border: 0 !important; + border-radius: 6px; + background: #0e151c; +} +.stream-summary span { color: #667484; font-size: 7px; font-weight: 620; } +.stream-summary b { color: #c9d2db; font: 600 9px/1.15 var(--mono); } +.file-button { + height: 34px; + margin-top: 13px; + border-color: var(--line-2); + background: rgba(255,255,255,.025); + font-size: 10px; + font-weight: 590; +} +.file-button:hover { background: rgba(114,173,247,.06); border-color: rgba(114,173,247,.30); } +.file-state { margin-top: 7px; color: #687585; line-height: 1.45; } +.source-section { margin-top: 16px; padding-top: 15px; border-top-color: var(--line); } +.preset-stack { margin-top: 8px; gap: 5px; } +.preset-button { min-height: 43px; padding: 7px; border-radius: 7px; } +.preset-button:hover { background: rgba(255,255,255,.025); border-color: transparent; } +.preset-icon { width: 28px; height: 28px; border-radius: 6px; } +.preset-button b { font-size: 10.5px; font-weight: 590; } +.preset-button small { margin-top: 2px; color: #6c7988; } +.health-list { margin-top: 8px; gap: 0; } +.health-list div { + min-height: 29px; + padding-inline: 4px; + border: 0; + border-bottom: 1px solid rgba(145,163,182,.075); + background: transparent; + border-radius: 0 !important; +} +.health-list div:last-child { border-bottom: 0; } +.health-list span { color: #697686; } +.health-list strong { color: #c4ced8; } +.diagnostics-panel { margin-top: 15px; padding-top: 12px; border-top-color: var(--line); } + +.injector-pane { padding: 17px 16px 14px; } +.injector-heading { margin-bottom: 14px; } +.injector-heading h1 { margin-top: 3px; font-size: 20px; font-weight: 620; letter-spacing: -.035em; } +.run-state { height: 27px; padding-inline: 9px; border-color: var(--line); background: rgba(255,255,255,.018); } + +.command-strip { + min-height: 54px; + padding: 8px 10px; + gap: 10px; + border: 0; + border-radius: 8px; + background: #0d141b; + box-shadow: inset 0 0 0 1px rgba(145,163,182,.065); +} +.frequency-control { gap: 2px; } +.frequency-value { margin-top: 1px; gap: 6px; } +.frequency-value input { width: 75px; font: 630 16px/1.18 var(--mono); } +.frequency-value span { color: #788695; } +.frequency-presets { gap: 5px; } +.micro-button { + width: 30px; + height: 28px; + border-color: rgba(145,163,182,.11); + background: rgba(255,255,255,.02); + color: #8593a2; +} +.micro-button:hover { background: rgba(114,173,247,.055); border-color: rgba(114,173,247,.24); } +.command-divider { background: rgba(145,163,182,.13); } +.mode-button { height: 35px; padding-inline: 9px; gap: 8px; border-radius: 6px; } +.mode-button:hover { border-color: transparent; background: rgba(255,255,255,.025); } +.mode-button[aria-pressed="true"] { border-color: transparent; background: rgba(114,173,247,.09); } +.link-icon { width: 26px; height: 26px; border-radius: 6px; } +.mode-button b, .switch-row b { font-size: 9.5px; font-weight: 590; } +.mode-button small, .switch-row small { margin-top: 1px; color: #6b7887; } +.switch-row { padding-inline: 5px; gap: 7px; } + +.signal-matrices { + margin-top: 13px; + gap: 12px; +} +.signal-group { + border-color: rgba(145,163,182,.085); + border-radius: 8px; + background: #0e151c; +} +.group-heading { + min-height: 39px; + padding-inline: 10px; + border-bottom-color: rgba(145,163,182,.08); + background: #0d141b; +} +.group-title { gap: 8px; } +.group-symbol { width: 22px; height: 22px; border-radius: 5px; background: rgba(114,173,247,.08); } +.group-title strong { font-size: 10.5px; font-weight: 610; } +.group-heading > span:last-child { color: #677585; } +.matrix-head { + min-height: 29px; + padding-inline: 8px; + border-bottom-color: rgba(145,163,182,.07); + color: #647282; + font-size: 7px; + font-weight: 620; +} +.channel-row { + height: 49px; + min-height: 49px; + padding-inline: 8px; + border-bottom-color: rgba(145,163,182,.065); +} +.channel-row:hover { background: rgba(255,255,255,.018); } +.channel-row[data-active="true"] { + background: rgba(114,173,247,.045); + box-shadow: 2px 0 0 var(--phase-color) inset; +} +.channel-name { gap: 7px; } +.channel-name strong { font: 650 11px/1 var(--mono); } +.phase-dot { width: 7px; height: 7px; } +.value-cell { padding-right: 7px; } +.input-unit input { + height: 33px; + padding-left: 9px; + border-color: transparent; + border-radius: 6px; + background: rgba(4,9,14,.34); + color: #edf2f7; + font: 620 12px/1 var(--mono); + box-shadow: inset 0 0 0 1px rgba(145,163,182,.055); +} +.input-unit input:hover { + border-color: transparent; + background: rgba(255,255,255,.025); + box-shadow: inset 0 0 0 1px rgba(145,163,182,.12); +} +.input-unit input:focus, .input-unit input[data-nav-active="true"] { + border-color: rgba(114,173,247,.58); + background: #0c151e; + box-shadow: 0 0 0 2px rgba(114,173,247,.075); +} +.input-unit span { right: 8px; color: #677585; } +.enable-toggle span { border-color: rgba(145,163,182,.14); background: rgba(255,255,255,.045); } + +.editor-status { + min-height: 40px; + padding-top: 10px; + color: #687585; +} +.active-value { gap: 7px; } +.active-value strong { color: #d4dce4; } +.keyboard-hint { gap: 12px; } +kbd { border-color: rgba(145,163,182,.15); border-bottom-color: rgba(145,163,182,.23); background: rgba(255,255,255,.025); color: #8c99a7; } + +.monitor-pane { + padding: 16px 14px 12px; + gap: 11px; + grid-template-rows: auto minmax(0,1.06fr) minmax(0,.94fr) auto; +} +.monitor-heading h2 { margin-top: 3px; font-size: 16px; font-weight: 620; letter-spacing: -.03em; } +.visual-section-heading { min-height: 27px; padding-inline: 1px; } +.visual-section-heading strong { color: #d9e0e7; font-size: 10.5px; font-weight: 600; } +.visual-section-heading span { color: #8492a0; font-size: 8px; } +.visual-section-heading b { color: #c9d4de; } +.visual-section canvas { + margin-top: 4px; + border-color: rgba(145,163,182,.09); + border-radius: 7px; + background: #080e14; + box-shadow: inset 0 1px 0 rgba(255,255,255,.012); + filter: brightness(1.12) saturate(1.08); +} +.monitor-footer { min-height: 24px; gap: 12px; color: #6b7887; font-size: 7.5px; } +.legend-line { opacity: .9; } +.legend-line.current { opacity: .75; } + +.action-bar { + padding: 0 14px 0 18px; + gap: 18px; + border-top-color: var(--line); + background: #0b1016; +} +.readiness-strip { gap: 10px; } +.readiness-strip strong { font-weight: 650; } +.readiness-strip span:last-child { color: #748292; } +.action-context { color: #758292; } +.primary-actions { gap: 8px; } +.btn { + min-height: 32px; + padding-inline: 12px; + border-color: rgba(145,163,182,.14); + border-radius: 7px; + background: rgba(255,255,255,.028); + font-size: 9.5px; + font-weight: 600; +} +.btn:hover:not(:disabled) { background: rgba(255,255,255,.055); border-color: rgba(145,163,182,.25); } +.btn.action { min-height: 36px; min-width: 80px; } +.btn.start { min-width: 116px; border-color: rgba(91,212,157,.28); background: #174735; } +.btn.stop { border-color: rgba(255,112,124,.16); background: rgba(255,112,124,.055); } + +/* Keep preview beside the matrix at the viewport/zoom seen in bench screenshots. */ +@media (max-width: 1240px) { + .cockpit { + padding: 10px; + gap: 10px; + grid-template-columns: 164px minmax(395px,1fr) minmax(292px,335px); + } + .source-pane { padding: 13px 11px; } + .injector-pane { padding: 14px 12px 12px; } + .monitor-pane { padding: 13px 11px 10px; } + .command-strip { grid-template-columns: 102px auto 1px minmax(105px,1fr) auto; gap: 7px; } + .signal-matrices { gap: 9px; } + .matrix-head, .channel-row { grid-template-columns: 25px 42px minmax(76px,1fr) minmax(68px,.82fr); } +} + +@media (max-width: 880px) { + body { overflow: auto; } + .app-shell { height: auto; min-height: 100vh; grid-template-rows: var(--topbar) auto var(--actionbar); } + .cockpit { + min-height: 760px; + grid-template-columns: 164px minmax(0,1fr); + grid-template-rows: minmax(500px,1fr) 330px; + overflow: visible; + } + .source-pane { grid-column: 1; grid-row: 1; } + .injector-pane { grid-column: 2; grid-row: 1; } + .monitor-pane { + grid-column: 1 / -1; + grid-row: 2; + grid-template-columns: 1fr 1.2fr; + grid-template-rows: auto minmax(0,1fr) auto; + } + .monitor-heading { grid-column: 1 / -1; } + .phasor-section { grid-column: 1; grid-row: 2; } + .waveform-section { grid-column: 2; grid-row: 2; } + .monitor-footer { grid-column: 1 / -1; } + .action-bar { position: sticky; bottom: 0; } +} + +@media (max-height: 760px) and (min-width: 881px) { + .cockpit { padding-top: 8px; padding-bottom: 8px; } + .source-pane, .injector-pane, .monitor-pane { padding-top: 11px; padding-bottom: 9px; } + .source-section { margin-top: 11px; padding-top: 10px; } + .channel-row { height: 43px; min-height: 43px; } + .group-heading { min-height: 34px; } + .matrix-head { min-height: 25px; } + .editor-status { min-height: 31px; padding-top: 6px; } +} diff --git a/apps/smv_injector_gui/premium-v3.js b/apps/smv_injector_gui/premium-v3.js new file mode 100644 index 000000000..e7517580d --- /dev/null +++ b/apps/smv_injector_gui/premium-v3.js @@ -0,0 +1,190 @@ +"use strict"; + +/* V3 preview renderer override. + Keeps the existing state/serial workflow untouched and only improves visual legibility. */ +(() => { + const uiFont = () => getComputedStyle(document.body).fontFamily || 'Inter, "Plus Jakarta Sans", system-ui, sans-serif'; + + drawPhasors = function drawPremiumPhasors() { + const { ctx, w, h } = prepareCanvas(els.phasorCanvas, 300, 220); + const cx = w * 0.5; + const cy = h * 0.51; + const radius = Math.max(66, Math.min(w * 0.39, h * 0.39)); + const font = uiFont(); + + ctx.save(); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + for (const factor of [.25, .5, .75, 1]) { + ctx.strokeStyle = factor === 1 ? "rgba(155,174,193,.19)" : "rgba(155,174,193,.12)"; + ctx.lineWidth = factor === 1 ? 1.1 : 1; + ctx.beginPath(); + ctx.arc(cx, cy, radius * factor, 0, Math.PI * 2); + ctx.stroke(); + } + + for (let deg = 0; deg < 180; deg += 30) { + const a = deg * Math.PI / 180; + const dx = Math.cos(a) * radius; + const dy = Math.sin(a) * radius; + ctx.strokeStyle = deg % 90 === 0 ? "rgba(155,174,193,.16)" : "rgba(155,174,193,.09)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx - dx, cy - dy); + ctx.lineTo(cx + dx, cy + dy); + ctx.stroke(); + } + + const groups = [ + { ids: ["UA", "UB", "UC", "UN"], scale: 1, width: 2.5, activeWidth: 3.5, dash: [] }, + { ids: ["IA", "IB", "IC", "IN"], scale: .80, width: 1.9, activeWidth: 2.9, dash: [7, 4] }, + ]; + + for (const group of groups) { + const maxMagnitude = Math.max(.0001, ...group.ids.map((id) => state.channels.get(id).magnitude)); + for (const id of group.ids) { + const channel = state.channels.get(id); + if (!channel.enabled || channel.magnitude <= 0) continue; + + const active = id === state.activeChannel; + const ratio = Math.min(1, channel.magnitude / maxMagnitude); + const length = radius * group.scale * (.34 + .66 * ratio); + const angle = channel.phase * Math.PI / 180; + const x = cx + Math.cos(angle) * length; + const y = cy - Math.sin(angle) * length; + + ctx.save(); + ctx.globalAlpha = active ? 1 : .68; + ctx.strokeStyle = channel.color; + ctx.fillStyle = channel.color; + ctx.lineWidth = active ? group.activeWidth : group.width; + ctx.setLineDash(group.dash); + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(x, y); + ctx.stroke(); + ctx.setLineDash([]); + + const head = active ? 9 : 7; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x - Math.cos(angle - .43) * head, y + Math.sin(angle - .43) * head); + ctx.lineTo(x - Math.cos(angle + .43) * head, y + Math.sin(angle + .43) * head); + ctx.closePath(); + ctx.fill(); + + ctx.font = `${active ? 650 : 560} ${active ? 11 : 9}px ${font}`; + ctx.globalAlpha = active ? 1 : .76; + const tx = x + (Math.cos(angle) >= 0 ? 8 : -22); + const ty = y + (Math.sin(angle) > .55 ? 13 : -7); + ctx.fillText(displayId(id), tx, ty); + ctx.restore(); + } + } + + ctx.fillStyle = "rgba(189,202,215,.58)"; + ctx.font = `560 9px ${font}`; + ctx.fillText("0°", cx + radius + 10, cy + 3); + ctx.fillText("90°", cx + 5, cy - radius - 10); + ctx.fillText("180°", cx - radius - 34, cy + 3); + ctx.fillText("270°", cx + 5, cy + radius + 15); + + ctx.fillStyle = "rgba(238,244,249,.50)"; + ctx.beginPath(); + ctx.arc(cx, cy, 2.6, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }; + + drawWaveforms = function drawPremiumWaveforms() { + const { ctx, w, h } = prepareCanvas(els.waveformCanvas, 320, 190); + const pad = { left: 34, right: 10, top: 15, bottom: 20 }; + const plotW = Math.max(1, w - pad.left - pad.right); + const plotH = Math.max(1, h - pad.top - pad.bottom); + const laneGap = 18; + const laneH = (plotH - laneGap) / 2; + const voltageY = pad.top + laneH / 2; + const currentY = pad.top + laneH + laneGap + laneH / 2; + const windowSeconds = .04; + const font = uiFont(); + + ctx.save(); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + for (let i = 0; i <= 8; i += 1) { + const x = pad.left + plotW * (i / 8); + ctx.strokeStyle = i % 2 === 0 ? "rgba(155,174,193,.105)" : "rgba(155,174,193,.065)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, pad.top + plotH); + ctx.stroke(); + } + + for (const y of [voltageY, currentY]) { + ctx.strokeStyle = "rgba(155,174,193,.18)"; + ctx.lineWidth = 1.1; + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(pad.left + plotW, y); + ctx.stroke(); + } + + ctx.fillStyle = "rgba(174,188,202,.58)"; + ctx.font = `650 9px ${font}`; + ctx.fillText("V", 13, voltageY + 3); + ctx.fillText("I", 13, currentY + 3); + + const drawGroup = (ids, centerY, amplitude, dashed) => { + const maxMagnitude = Math.max(.0001, ...ids.map((id) => state.channels.get(id).magnitude)); + for (const id of ids) { + const channel = state.channels.get(id); + if (!channel.enabled || channel.magnitude <= 0) continue; + + const active = id === state.activeChannel; + const normalized = Math.min(1, channel.magnitude / maxMagnitude); + const amp = amplitude * normalized; + const phaseRad = channel.phase * Math.PI / 180; + + ctx.save(); + ctx.strokeStyle = channel.color; + ctx.globalAlpha = active ? .98 : .66; + ctx.lineWidth = active ? 2.55 : (dashed ? 1.75 : 1.9); + if (dashed) ctx.setLineDash([7, 5]); + ctx.beginPath(); + + const points = Math.max(220, Math.floor(plotW * 1.1)); + for (let i = 0; i <= points; i += 1) { + const ratio = i / points; + const t = windowSeconds * ratio; + const value = Math.sin(2 * Math.PI * state.frequencyHz * t + phaseRad); + const x = pad.left + plotW * ratio; + const y = centerY - value * amp; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.restore(); + } + }; + + drawGroup(["UA", "UB", "UC", "UN"], voltageY, laneH * .40, false); + drawGroup(["IA", "IB", "IC", "IN"], currentY, laneH * .40, true); + + ctx.fillStyle = "rgba(155,174,193,.52)"; + ctx.font = `520 8px ${font}`; + for (const ms of [0, 10, 20, 30, 40]) { + const x = pad.left + plotW * (ms / 40); + ctx.fillText(String(ms), x - (ms === 0 ? 0 : 5), h - 5); + } + ctx.fillText("ms", w - 18, h - 5); + ctx.restore(); + }; + + requestAnimationFrame(() => { + drawPhasors(); + drawWaveforms(); + }); +})(); diff --git a/apps/smv_injector_gui/responsive.css b/apps/smv_injector_gui/responsive.css index 20758e88a..766a9496b 100644 --- a/apps/smv_injector_gui/responsive.css +++ b/apps/smv_injector_gui/responsive.css @@ -1,42 +1,81 @@ -/* Responsive operator layout. Keep all primary controls visible on laptops and split-screen benches. */ -html { min-width: 0 !important; } -body { overflow-x: hidden; } -.app-shell { min-width: 0; } -.workspace { width: 100%; min-width: 0; } -.hero-grid, .control-toolbar, .bottom-grid, .channel-grid { min-width: 0; } +/* Keep the injector + signal preview side-by-side for normal engineering laptop widths. + Stack only when the viewport is genuinely narrow. */ -@media (max-width: 1180px) { - .topbar { padding: 14px 18px; } - .workspace { padding: 18px 18px 32px; } - .hero-grid { grid-template-columns: 1fr; } - .run-panel { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto; column-gap: 18px; } - .run-metrics { grid-column: 1 / -1; grid-row: 2; margin: 14px 0 0; grid-template-columns: repeat(4, minmax(0, 1fr)); } - .run-actions { align-self: center; } - .run-actions .btn { min-width: 120px; } - .control-toolbar { grid-template-columns: 1fr; } - .frequency-control { grid-template-columns: 150px minmax(0, 1fr); } - .toolbar-actions { justify-content: flex-start; } - .channel-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .bottom-grid { grid-template-columns: 1fr; } - .section-heading { align-items: flex-start; } - .section-heading p { text-align: left; max-width: 520px; } +@media (max-width: 1240px) { + .cockpit { grid-template-columns: 178px minmax(400px,1fr) minmax(300px,330px); } + .source-pane { padding: 10px; } + .injector-pane { padding: 10px; } + .monitor-pane { padding: 9px; } + .top-context { justify-content: flex-start; } + .top-context .top-mode { display: none; } + .command-strip { grid-template-columns: 102px auto 1px minmax(108px,1fr) auto; gap: 5px; } + .keyboard-hint { gap: 6px; } + .keyboard-hint span:nth-child(3) { display: none; } + .matrix-head, .channel-row { grid-template-columns: 25px 43px minmax(78px,1fr) minmax(70px,.8fr); } +} + +@media (max-width: 1010px) { + body { overflow: auto; } + .app-shell { height: auto; min-height: 100vh; grid-template-rows: var(--topbar) auto var(--actionbar); } + .top-context span:nth-of-type(2), .top-context .context-separator { display: none; } + .cockpit { + min-height: 760px; + grid-template-columns: 176px minmax(0,1fr); + grid-template-rows: minmax(500px,1fr) 300px; + overflow: visible; + } + .source-pane { grid-column: 1; grid-row: 1; } + .injector-pane { grid-column: 2; grid-row: 1; } + .monitor-pane { + grid-column: 1 / -1; grid-row: 2; + grid-template-columns: 1fr 1fr; grid-template-rows: auto minmax(0,1fr) auto; + } + .monitor-heading { grid-column: 1 / -1; } + .phasor-section { grid-column: 1; grid-row: 2; } + .waveform-section { grid-column: 2; grid-row: 2; } + .monitor-footer { grid-column: 1 / -1; } + .action-bar { position: sticky; bottom: 0; } } @media (max-width: 760px) { - .topbar { position: static; align-items: flex-start; gap: 12px; flex-wrap: wrap; } - .connection-strip { width: 100%; justify-content: space-between; } - .workspace { padding: 14px 12px 28px; } - .stream-panel, .run-panel, .phase-panel, .console-panel { padding: 16px; } - .panel-header { align-items: flex-start; } - .identity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .profile-actions { align-items: flex-start; flex-direction: column; } - .file-state { max-width: 100%; white-space: normal; } - .run-panel { display: block; } - .run-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .run-actions { margin-top: 14px; } - .frequency-control { grid-template-columns: 1fr; } - .channel-grid { grid-template-columns: 1fr; } - .section-heading { display: block; } - .section-heading p { margin-top: 8px; } - .channel-fields { grid-template-columns: 1fr 1fr; } + :root { --topbar: auto; --actionbar: auto; } + .app-shell { display: block; min-height: 100vh; } + .topbar { + min-height: 54px; padding: 8px 10px; display: grid; + grid-template-columns: 1fr auto; grid-template-rows: auto auto; + } + .brand-block { grid-column: 1; grid-row: 1; } + .connection-strip { grid-column: 2; grid-row: 1; } + .top-context { grid-column: 1 / -1; grid-row: 2; min-height: 22px; justify-content: flex-start; border-top: 1px solid var(--line); padding-top: 6px; } + .cockpit { min-height: 0; padding: 6px; display: grid; grid-template-columns: 1fr; grid-template-rows: auto auto auto; gap: 6px; } + .source-pane, .injector-pane, .monitor-pane { grid-column: 1; grid-row: auto; } + .source-pane { overflow: visible; } + .signal-matrices { grid-template-columns: 1fr; } + .signal-group { min-height: 240px; } + .monitor-pane { display: grid; grid-template-columns: 1fr; grid-template-rows: auto 300px 270px auto; } + .monitor-heading, .phasor-section, .waveform-section, .monitor-footer { grid-column: 1; grid-row: auto; } + .editor-status { align-items: flex-start; flex-direction: column; } + .keyboard-hint { display: none; } + .action-bar { + position: sticky; bottom: 0; min-height: 64px; padding: 7px 8px; + grid-template-columns: minmax(0,1fr) auto; gap: 8px; + } + .action-context { display: none; } + .primary-actions { gap: 4px; } + .btn.action { min-width: 64px; } + .btn.start { min-width: 92px; } +} + +@media (max-width: 520px) { + .connection-strip .status-pill { display: none; } + .top-context { overflow-x: auto; } + .command-strip { grid-template-columns: 1fr auto; gap: 6px; } + .command-divider { display: none; } + .mode-button { grid-column: 1; grid-row: 2; } + .switch-row { grid-column: 2; grid-row: 2; } + .signal-matrices { grid-template-columns: 1fr; } + .action-bar { grid-template-columns: 1fr; } + .readiness-strip { display: none; } + .primary-actions { width: 100%; } + .primary-actions .btn { flex: 1; } } diff --git a/apps/smv_injector_gui/styles.css b/apps/smv_injector_gui/styles.css index 9b0cd2e37..75f6e6b2e 100644 --- a/apps/smv_injector_gui/styles.css +++ b/apps/smv_injector_gui/styles.css @@ -1,212 +1,345 @@ :root { - --bg: #071018; - --bg-soft: #0b1621; - --panel: rgba(14, 27, 39, 0.92); - --panel-2: rgba(19, 35, 49, 0.88); - --line: rgba(131, 161, 183, 0.16); - --line-strong: rgba(131, 161, 183, 0.28); - --text: #eef6fb; - --muted: #8fa6b7; - --cyan: #4fc7ff; - --blue: #5b8cff; - --green: #3ddc97; - --amber: #f2bb4c; - --red: #ff6b76; - --shadow: 0 24px 60px rgba(0, 0, 0, 0.24); - --radius: 18px; + --bg: #090d12; + --chrome: #0c1117; + --surface: #10161d; + --surface-2: #0d131a; + --surface-3: #121922; + --input: #0a0f15; + --line: #202a35; + --line-2: #2b3744; + --text: #eef3f8; + --text-soft: #c7d1dc; + --muted: #7f8b99; + --muted-2: #586473; + --accent: #69a9ff; + --accent-soft: rgba(105,169,255,.12); + --green: #57d59c; + --green-soft: rgba(87,213,156,.11); + --amber: #e4b75e; + --red: #ff707c; + --red-soft: rgba(255,112,124,.09); + --radius: 7px; + --radius-sm: 5px; + --topbar: 52px; + --actionbar: 54px; + --ui: Inter, "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, sans-serif; + --mono: "Cascadia Mono", "SFMono-Regular", Consolas, monospace; } * { box-sizing: border-box; } -html { min-width: 1120px; background: var(--bg); } +html, body { margin: 0; min-width: 0; min-height: 100%; background: var(--bg); } body { - margin: 0; min-height: 100vh; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + overflow: hidden; color: var(--text); - background: - radial-gradient(circle at 10% 0%, rgba(79, 199, 255, 0.08), transparent 28rem), - radial-gradient(circle at 92% 8%, rgba(91, 140, 255, 0.08), transparent 30rem), - linear-gradient(180deg, #09131c 0%, #061018 100%); + font-family: var(--ui); + font-size: 12px; + -webkit-font-smoothing: antialiased; + text-rendering: geometricPrecision; } - button, input { font: inherit; } button { color: inherit; } +button, label, summary { -webkit-tap-highlight-color: transparent; } +::selection { background: rgba(105,169,255,.22); } + +.app-shell { + height: 100vh; + min-height: 640px; + display: grid; + grid-template-rows: var(--topbar) minmax(0,1fr) var(--actionbar); + background: var(--bg); +} -.app-shell { min-height: 100vh; } .topbar { - min-height: 84px; - padding: 18px 30px; - display: flex; + padding: 0 13px; + display: grid; + grid-template-columns: minmax(185px,auto) minmax(0,1fr) auto; align-items: center; - justify-content: space-between; - position: sticky; - top: 0; - z-index: 20; + gap: 16px; border-bottom: 1px solid var(--line); - background: rgba(7, 16, 24, 0.88); - backdrop-filter: blur(18px); + background: var(--chrome); } -.brand, .connection-strip, .panel-header, .run-state, .run-actions, -.toolbar-actions, .switch-row, .section-heading { display: flex; align-items: center; } -.brand { gap: 14px; } + +.brand-block, .connection-strip, .top-context, .pane-heading, .injector-heading, +.monitor-heading, .health-heading, .readiness-strip, .primary-actions, .action-context, +.frequency-value, .switch-row, .active-value, .keyboard-hint, .monitor-footer, +.visual-section-heading { display: flex; align-items: center; } + +.brand-block { gap: 9px; min-width: 0; } .brand-mark { - width: 42px; height: 42px; border-radius: 12px; position: relative; - background: linear-gradient(145deg, rgba(79,199,255,.16), rgba(91,140,255,.10)); - border: 1px solid rgba(79,199,255,.28); - box-shadow: inset 0 0 24px rgba(79,199,255,.08); -} -.brand-mark span { - position: absolute; left: 10px; right: 10px; height: 2px; border-radius: 999px; - background: linear-gradient(90deg, transparent, var(--cyan), transparent); -} -.brand-mark span:nth-child(1){ top: 12px; transform: rotate(-24deg); } -.brand-mark span:nth-child(2){ top: 20px; } -.brand-mark span:nth-child(3){ top: 28px; transform: rotate(24deg); } -.brand h1, .panel h2, .section-heading h2 { margin: 2px 0 0; font-size: 20px; line-height: 1.15; } -.eyebrow { color: var(--muted); text-transform: uppercase; letter-spacing: .12em; font-size: 10px; font-weight: 750; } -.connection-strip { gap: 12px; } -.status-pill, .badge { - display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; - border-radius: 999px; border: 1px solid var(--line); background: rgba(255,255,255,.025); - color: var(--muted); font-size: 12px; font-weight: 650; -} -.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #687985; box-shadow: 0 0 0 4px rgba(104,121,133,.10); } -.status-pill[data-state="online"] { color: #c8f7e5; border-color: rgba(61,220,151,.25); background: rgba(61,220,151,.08); } -.status-pill[data-state="online"] .status-dot { background: var(--green); box-shadow: 0 0 0 4px rgba(61,220,151,.12); } - -.workspace { padding: 24px 30px 40px; max-width: 1720px; margin: 0 auto; } -.hero-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(370px, .85fr); gap: 18px; } -.panel { + width: 28px; height: 28px; position: relative; overflow: hidden; + border: 1px solid #294767; border-radius: 6px; background: #0e1924; +} +.brand-mark span { position: absolute; left: 6px; right: 6px; height: 1px; background: #79b6ff; } +.brand-mark span:nth-child(1) { top: 8px; transform: rotate(-24deg); } +.brand-mark span:nth-child(2) { top: 13px; } +.brand-mark span:nth-child(3) { top: 18px; transform: rotate(24deg); } +.brand-copy { min-width: 0; display: grid; gap: 0; } +.product-family { color: var(--muted); font-size: 8px; font-weight: 720; letter-spacing: .13em; text-transform: uppercase; } +.brand-copy strong { font-size: 13px; font-weight: 670; letter-spacing: -.015em; } + +.top-context { min-width: 0; justify-content: center; gap: 11px; color: var(--muted); font-size: 10px; white-space: nowrap; overflow: hidden; } +.top-context strong { color: var(--text-soft); font: 680 11px/1 var(--mono); } +.top-context b { color: var(--text-soft); font-weight: 650; } +.top-mode { color: #92bfee; font-size: 8px; font-weight: 750; letter-spacing: .1em; } +.context-separator, .action-separator, .command-divider { width: 1px; height: 15px; background: var(--line-2); flex: 0 0 auto; } +.connection-strip { gap: 8px; justify-content: flex-end; } + +.status-pill { + height: 28px; padding: 0 9px; display: inline-flex; align-items: center; gap: 6px; + border: 1px solid var(--line); border-radius: 999px; background: #0d1319; + color: var(--muted); font-size: 10px; font-weight: 620; white-space: nowrap; +} +.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #66717d; } +.status-pill[data-state="online"] { color: #bce8d3; border-color: rgba(87,213,156,.27); background: var(--green-soft); } +.status-pill[data-state="online"] .status-dot { background: var(--green); box-shadow: 0 0 0 3px rgba(87,213,156,.08); } + +.cockpit { + min-width: 0; min-height: 0; padding: 8px; + display: grid; + grid-template-columns: 190px minmax(420px,1fr) minmax(320px,355px); + gap: 8px; + overflow: hidden; +} + +.surface { + min-width: 0; min-height: 0; border: 1px solid var(--line); border-radius: var(--radius); - background: linear-gradient(180deg, rgba(17,31,44,.94), rgba(11,23,33,.94)); - box-shadow: var(--shadow); -} -.stream-panel, .run-panel, .phase-panel, .console-panel { padding: 20px; } -.panel-header { justify-content: space-between; gap: 16px; } -.panel-header.compact { min-height: 48px; } -.badge.neutral { color: #c5d6e2; } - -.stream-identity { - margin-top: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; - background: rgba(4, 12, 18, .35); -} -.identity-main { display: flex; align-items: baseline; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--line); } -.identity-label, .identity-grid span, .run-metrics span { color: var(--muted); font-size: 11px; } -.identity-main strong { font-size: 22px; letter-spacing: .02em; } -.identity-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding-top: 14px; } -.identity-grid div, .run-metrics div { display: grid; gap: 5px; } -.identity-grid strong, .run-metrics strong { font-size: 13px; font-weight: 700; } -.profile-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; } + background: var(--surface); + box-shadow: inset 0 1px 0 rgba(255,255,255,.014); +} + +.eyebrow, .section-label, .command-label { + color: var(--muted); font-size: 8px; font-weight: 730; letter-spacing: .11em; text-transform: uppercase; +} +h1, h2 { margin: 0; letter-spacing: -.025em; } + +.source-pane { padding: 12px; overflow: auto; scrollbar-width: thin; scrollbar-color: #293543 transparent; } +.pane-heading { justify-content: space-between; gap: 8px; margin-bottom: 10px; } +.pane-heading h2 { margin-top: 2px; font: 660 14px/1.1 var(--mono); } +.stream-state, .visual-badge { + height: 21px; padding: 0 6px; display: inline-flex; align-items: center; + border: 1px solid var(--line); border-radius: 4px; background: var(--surface-2); + color: var(--muted); font: 670 8px/1 var(--mono); letter-spacing: .06em; +} + +.stream-summary { + display: grid; grid-template-columns: 1fr 1fr; gap: 0; + overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); +} +.stream-summary div { min-width: 0; padding: 8px; display: grid; gap: 3px; } +.stream-summary div:nth-child(odd) { border-right: 1px solid var(--line); } +.stream-summary div:nth-child(-n+2) { border-bottom: 1px solid var(--line); } +.stream-summary span { color: var(--muted-2); font-size: 7px; font-weight: 700; letter-spacing: .08em; } +.stream-summary b { color: var(--text-soft); font: 600 9px/1.1 var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .file-button { - cursor: pointer; min-height: 38px; display: inline-flex; align-items: center; padding: 0 13px; - border-radius: 10px; border: 1px solid rgba(79,199,255,.25); background: rgba(79,199,255,.08); - color: #cfefff; font-size: 12px; font-weight: 700; -} -.file-button input { display:none; } -.file-state { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.microcopy { margin: 12px 0 0; color: #7890a2; font-size: 11px; line-height: 1.55; } - -.run-panel { display: grid; grid-template-rows: auto 1fr auto; } -.run-state { gap: 12px; } -.run-icon { width: 12px; height: 12px; border-radius: 50%; background: #7f919e; box-shadow: 0 0 0 6px rgba(127,145,158,.08); } -.run-state[data-state="running"] .run-icon { background: var(--green); box-shadow: 0 0 0 6px rgba(61,220,151,.12), 0 0 24px rgba(61,220,151,.18); } -.run-state strong { display:block; margin-top: 3px; font-size: 28px; letter-spacing: .04em; } -.run-state[data-state="running"] strong { color: #bdf4dd; } -.run-metrics { align-self:center; display:grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 24px 0; } -.run-metrics div { min-height: 62px; padding: 12px; border:1px solid var(--line); border-radius: 12px; background: rgba(4,12,18,.28); } -.run-metrics strong { font-size: 18px; } -.run-actions { gap: 10px; } -.run-actions .btn { flex: 1; height: 44px; } + height: 31px; margin-top: 10px; padding: 0 9px; display: flex; align-items: center; justify-content: center; + cursor: pointer; border: 1px solid var(--line-2); border-radius: var(--radius-sm); + background: #121922; color: var(--text-soft); font-size: 10px; font-weight: 630; + transition: background .1s ease, border-color .1s ease; +} +.file-button:hover { background: #16202a; border-color: #3a536d; } +.file-button input { display: none; } +.file-state { margin-top: 5px; color: var(--muted-2); font-size: 8px; line-height: 1.35; overflow-wrap: anywhere; } -.btn { - border: 1px solid transparent; border-radius: 10px; min-height: 38px; padding: 0 14px; - cursor: pointer; font-size: 12px; font-weight: 750; transition: transform .12s ease, opacity .12s ease, border-color .12s ease, background .12s ease; -} -.btn:hover:not(:disabled){ transform: translateY(-1px); } -.btn:active:not(:disabled){ transform: translateY(0); } -.btn:disabled{ opacity:.38; cursor:not-allowed; } -.btn.primary { background: linear-gradient(135deg, #237fb7, #315ed1); border-color: rgba(91,140,255,.34); } -.btn.secondary { background: rgba(255,255,255,.04); border-color: var(--line-strong); color: #d5e2eb; } -.btn.start { background: linear-gradient(135deg, #1c9e67, #157a66); border-color: rgba(61,220,151,.28); } -.btn.stop { background: rgba(255,107,118,.08); color: #ffc4c9; border-color: rgba(255,107,118,.26); } -.btn.ghost { background: transparent; border-color: var(--line); color: var(--muted); } -.btn.small { min-height:30px; padding:0 10px; } - -.control-toolbar { - margin-top: 18px; padding: 16px 18px; display:grid; grid-template-columns: minmax(440px,1fr) auto; gap: 22px; align-items:center; -} -.frequency-control { display:grid; grid-template-columns: 170px 1fr; gap:18px; align-items:center; } -.frequency-value { display:flex; align-items:baseline; gap:7px; margin-top:2px; } -.frequency-value input { - width:105px; border:0; outline:0; background:transparent; color:var(--text); font-size:24px; font-weight:760; padding:0; -} -.frequency-value span { color:var(--muted); font-size:12px; } -input[type="range"] { width:100%; accent-color: var(--cyan); cursor:pointer; } -.toolbar-actions { justify-content:flex-end; gap:9px; flex-wrap:wrap; } -.switch-row { gap:8px; color:#c9d8e2; font-size:12px; margin-right:4px; cursor:pointer; } -.switch-row input { display:none; } -.switch { width:36px; height:20px; border-radius:999px; background:#263845; border:1px solid var(--line-strong); position:relative; transition:.15s; } -.switch::after { content:""; position:absolute; top:3px; left:3px; width:12px; height:12px; border-radius:50%; background:#8193a0; transition:.15s; } -.switch-row input:checked + .switch { background:rgba(61,220,151,.20); border-color:rgba(61,220,151,.35); } -.switch-row input:checked + .switch::after { left:19px; background:var(--green); } - -.channels-section { margin-top: 26px; } -.section-heading { justify-content:space-between; gap:20px; margin-bottom:14px; } -.section-heading p { max-width:650px; margin:0; color:var(--muted); font-size:12px; line-height:1.55; text-align:right; } -.channel-grid { display:grid; grid-template-columns: repeat(4, minmax(240px,1fr)); gap:14px; } -.channel-card { - border:1px solid var(--line); border-radius:16px; padding:15px; - background:linear-gradient(180deg,rgba(17,31,44,.88),rgba(9,20,29,.88)); - box-shadow:0 14px 36px rgba(0,0,0,.14); transition:border-color .15s, transform .15s, opacity .15s; -} -.channel-card:hover { border-color:rgba(79,199,255,.22); transform:translateY(-1px); } -.channel-card[data-enabled="false"] { opacity:.55; } -.channel-head { display:flex; align-items:center; justify-content:space-between; gap:10px; } -.channel-title { display:flex; align-items:center; gap:10px; } -.phase-dot { width:10px; height:10px; border-radius:50%; background:var(--phase-color); box-shadow:0 0 0 5px color-mix(in srgb, var(--phase-color) 14%, transparent); } -.channel-title strong { font-size:17px; } -.channel-title span { display:block; margin-top:2px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.1em; } -.enable-toggle { position:relative; width:34px; height:20px; } -.enable-toggle input { display:none; } -.enable-toggle span { position:absolute; inset:0; border-radius:999px; background:#243642; border:1px solid var(--line-strong); cursor:pointer; } -.enable-toggle span::after { content:""; position:absolute; top:3px; left:3px; width:12px; height:12px; border-radius:50%; background:#748895; transition:.15s; } -.enable-toggle input:checked + span { background:rgba(61,220,151,.16); border-color:rgba(61,220,151,.28); } -.enable-toggle input:checked + span::after { left:17px; background:var(--green); } -.channel-fields { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:15px; } -.field { display:grid; gap:6px; } -.field label { color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.1em; } -.input-unit { position:relative; } +.source-section { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--line); } +.preset-stack { margin-top: 5px; display: grid; gap: 3px; } +.preset-button { + width: 100%; min-height: 39px; padding: 5px 6px; display: grid; grid-template-columns: 26px minmax(0,1fr); gap: 7px; align-items: center; + cursor: pointer; text-align: left; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; + transition: background .1s ease, border-color .1s ease; +} +.preset-button:hover { background: var(--surface-3); border-color: var(--line); } +.preset-icon { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 5px; background: var(--accent-soft); color: #a3ceff; font: 700 9px/1 var(--mono); } +.preset-icon.neutral { background: rgba(255,255,255,.04); color: var(--muted); } +.preset-button span:last-child { min-width: 0; display: grid; gap: 1px; } +.preset-button b { color: var(--text-soft); font-size: 10px; font-weight: 620; } +.preset-button small { color: var(--muted-2); font-size: 8px; } + +.health-heading { justify-content: space-between; gap: 6px; } +.health-state { color: var(--muted); font: 650 8px/1 var(--mono); } +.health-state[data-state="ready"] { color: var(--green); } +.health-state[data-state="running"] { color: #8ce4ba; } +.health-list { margin-top: 6px; display: grid; gap: 1px; } +.health-list div { min-height: 25px; padding: 0 7px; display: flex; align-items: center; justify-content: space-between; gap: 8px; background: var(--surface-2); border: 1px solid var(--line); } +.health-list div:first-child { border-radius: 4px 4px 0 0; } +.health-list div:last-child { border-radius: 0 0 4px 4px; } +.health-list span { color: var(--muted-2); font-size: 8px; } +.health-list strong { color: var(--text-soft); font: 620 9px/1 var(--mono); font-variant-numeric: tabular-nums; } + +.diagnostics-panel { margin-top: 11px; padding-top: 9px; border-top: 1px solid var(--line); } +.diagnostics-panel summary { cursor: pointer; color: var(--muted); font-size: 9px; user-select: none; } +.diagnostics-head { margin: 8px 0 5px; display: flex; align-items: center; justify-content: space-between; color: var(--muted-2); font-size: 8px; } +.text-button { padding: 0; border: 0; background: none; color: var(--accent); cursor: pointer; font-size: 8px; } +#runtimeLog { height: 118px; margin: 0; padding: 7px; overflow: auto; border: 1px solid var(--line); border-radius: 4px; background: #080c11; color: #88a3b9; font: 8px/1.45 var(--mono); white-space: pre-wrap; word-break: break-word; } + +.injector-pane { padding: 12px; overflow: hidden; display: flex; flex-direction: column; } +.injector-heading { flex: 0 0 auto; justify-content: space-between; gap: 10px; margin-bottom: 9px; } +.injector-heading h1 { margin-top: 2px; font-size: 18px; font-weight: 650; } +.run-state { + height: 25px; padding: 0 8px; display: inline-flex; align-items: center; gap: 6px; + border: 1px solid var(--line); border-radius: 5px; background: var(--surface-2); + color: var(--muted); font: 680 8px/1 var(--mono); letter-spacing: .07em; +} +.run-dot { width: 6px; height: 6px; border-radius: 50%; background: #68737f; } +.run-state[data-state="running"] { border-color: rgba(87,213,156,.28); background: var(--green-soft); color: #a8e8cb; } +.run-state[data-state="running"] .run-dot { background: var(--green); } + +.command-strip { + flex: 0 0 auto; min-height: 44px; padding: 6px 7px; + display: grid; grid-template-columns: 108px auto 1px minmax(118px,1fr) auto; align-items: center; gap: 7px; + border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); +} +.frequency-control { min-width: 0; display: grid; gap: 1px; } +.frequency-value { gap: 4px; } +.frequency-value input { width: 70px; padding: 0; border: 0; outline: 0; background: transparent; color: var(--text); font: 650 15px/1.15 var(--mono); font-variant-numeric: tabular-nums; } +.frequency-value input:focus { color: #fff; } +.frequency-value span { color: var(--muted); font-size: 8px; } +.frequency-presets { display: flex; gap: 3px; } +.micro-button { + width: 27px; height: 25px; padding: 0; cursor: pointer; border: 1px solid var(--line); border-radius: 4px; + background: #111820; color: var(--muted); font: 650 9px/1 var(--mono); +} +.micro-button:hover { border-color: #3c5169; color: var(--text-soft); } +.mode-button { + min-width: 0; height: 31px; padding: 0 7px; display: flex; align-items: center; gap: 7px; + cursor: pointer; border: 1px solid transparent; border-radius: 4px; background: transparent; text-align: left; +} +.mode-button:hover { background: var(--surface-3); border-color: var(--line); } +.mode-button[aria-pressed="true"] { background: var(--accent-soft); border-color: rgba(105,169,255,.28); } +.link-icon { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 4px; background: rgba(105,169,255,.09); color: #9ec9ff; font: 700 8px/1 var(--mono); } +.mode-button > span:last-child, .switch-row > span:last-child { min-width: 0; display: grid; gap: 0; } +.mode-button b, .switch-row b { color: var(--text-soft); font-size: 9px; font-weight: 630; white-space: nowrap; } +.mode-button small, .switch-row small { color: var(--muted-2); font-size: 7px; white-space: nowrap; } +.switch-row { gap: 6px; cursor: pointer; padding: 0 3px; } +.switch-row input { display: none; } +.switch { width: 28px; height: 16px; position: relative; border-radius: 999px; background: #202934; border: 1px solid #33404e; } +.switch::after { content: ""; position: absolute; width: 8px; height: 8px; top: 3px; left: 3px; border-radius: 50%; background: #75808d; transition: .1s ease; } +.switch-row input:checked + .switch { background: rgba(87,213,156,.14); border-color: rgba(87,213,156,.3); } +.switch-row input:checked + .switch::after { left: 15px; background: var(--green); } + +.signal-matrices { + flex: 1 1 auto; min-height: 0; margin-top: 8px; + display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-items: start; +} +.signal-group { + min-width: 0; overflow: hidden; align-self: start; + border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); +} +.group-heading { + min-height: 34px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px; + border-bottom: 1px solid var(--line); background: #0b1118; +} +.group-title { display: flex; align-items: center; gap: 7px; } +.group-symbol { width: 20px; height: 20px; display: grid; place-items: center; border-radius: 4px; background: rgba(105,169,255,.09); color: #9fc9ff; font: 700 9px/1 var(--mono); } +.group-title strong { color: var(--text-soft); font-size: 10px; font-weight: 650; } +.group-heading > span:last-child { color: var(--muted-2); font-size: 7px; letter-spacing: .04em; } +.matrix-head, .channel-row { + display: grid; grid-template-columns: 27px 48px minmax(88px,1fr) minmax(76px,.82fr); align-items: center; +} +.matrix-head { + min-height: 26px; padding: 0 6px; border-bottom: 1px solid var(--line); + color: var(--muted-2); font-size: 7px; font-weight: 720; letter-spacing: .09em; text-transform: uppercase; +} +.channel-list { min-height: 0; } +.channel-row { + height: 45px; min-height: 45px; padding: 0 6px; border-bottom: 1px solid #1b242e; + transition: background .08s ease, opacity .08s ease; +} +.channel-row:last-child { border-bottom: 0; } +.channel-row:hover { background: #111923; } +.channel-row[data-active="true"] { background: #131c26; box-shadow: 2px 0 0 var(--phase-color) inset; } +.channel-row[data-enabled="false"] { opacity: .46; } +.enable-toggle { width: 25px; height: 18px; display: grid; place-items: center; cursor: pointer; } +.enable-toggle input { display: none; } +.enable-toggle span { width: 22px; height: 13px; position: relative; border-radius: 999px; background: #202934; border: 1px solid #33404e; } +.enable-toggle span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 7px; height: 7px; border-radius: 50%; background: #737f8c; transition: .09s; } +.enable-toggle input:checked + span { background: rgba(105,169,255,.10); border-color: rgba(105,169,255,.28); } +.enable-toggle input:checked + span::after { left: 11px; background: #8dbfff; } +.channel-name { min-width: 0; display: flex; align-items: center; gap: 6px; } +.phase-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--phase-color); flex: 0 0 auto; } +.channel-name strong { color: var(--text); font: 680 11px/1 var(--mono); } +.value-cell { min-width: 0; padding-right: 5px; } +.input-unit { position: relative; min-width: 0; } .input-unit input { - width:100%; height:42px; border-radius:10px; border:1px solid var(--line); outline:0; - background:rgba(3,10,15,.45); color:var(--text); padding:0 44px 0 11px; font-size:15px; font-weight:680; + width: 100%; height: 30px; padding: 0 24px 0 7px; outline: 0; + border: 1px solid #27333f; border-radius: 4px; background: var(--input); color: var(--text); + font: 640 12px/1 var(--mono); font-variant-numeric: tabular-nums; + transition: border-color .08s ease, background .08s ease, box-shadow .08s ease; } -.input-unit input:focus { border-color:rgba(79,199,255,.45); box-shadow:0 0 0 3px rgba(79,199,255,.07); } -.input-unit span { position:absolute; right:10px; top:50%; transform:translateY(-50%); color:#71899a; font-size:10px; } -.phase-slider { margin-top:10px; } -.channel-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:12px; } -.wire-preview { color:#6f8798; font-size:10px; } -.channel-apply { min-height:32px; padding:0 10px; } +.input-unit input:hover { border-color: #354556; } +.input-unit input:focus, .input-unit input[data-nav-active="true"] { + border-color: #5a8fc8; background: #0d151e; box-shadow: 0 0 0 1px rgba(105,169,255,.15) inset, 0 0 0 2px rgba(105,169,255,.06); +} +.input-unit span { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); color: var(--muted-2); font-size: 7px; pointer-events: none; } -.bottom-grid { margin-top:18px; display:grid; grid-template-columns:1.05fr .95fr; gap:18px; } -#phasorCanvas { width:100%; height:300px; display:block; margin-top:8px; border-radius:12px; background:rgba(3,10,15,.34); border:1px solid var(--line); } -#runtimeLog { - height:300px; overflow:auto; margin:8px 0 0; padding:14px; border:1px solid var(--line); border-radius:12px; - background:#050c12; color:#9ec3d6; font:11px/1.55 "Cascadia Mono", "SFMono-Regular", Consolas, monospace; white-space:pre-wrap; word-break:break-word; +.editor-status { + flex: 0 0 auto; min-height: 33px; padding-top: 7px; display: flex; align-items: center; justify-content: space-between; gap: 10px; + color: var(--muted-2); font-size: 8px; overflow: hidden; } +.active-value { min-width: 0; gap: 6px; white-space: nowrap; overflow: hidden; } +.active-value strong { color: var(--text-soft); font: 650 9px/1 var(--mono); } +.active-value > span:last-child { overflow: hidden; text-overflow: ellipsis; } +.active-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; } +.keyboard-hint { flex: 0 0 auto; gap: 9px; white-space: nowrap; } +.keyboard-hint > span { display: flex; align-items: center; gap: 3px; } +kbd { min-width: 18px; padding: 2px 4px; border: 1px solid #303b47; border-bottom-color: #3d4956; border-radius: 3px; background: #131a22; color: #9ba7b4; font: 620 7px/1 var(--mono); text-align: center; } -.toast { - position:fixed; right:24px; bottom:24px; z-index:40; min-width:240px; max-width:420px; - padding:12px 14px; border-radius:12px; border:1px solid rgba(79,199,255,.25); - background:rgba(12,27,39,.96); box-shadow:var(--shadow); color:#dff4ff; font-size:12px; - opacity:0; transform:translateY(10px); pointer-events:none; transition:.18s; +.monitor-pane { padding: 11px; overflow: hidden; display: grid; grid-template-rows: auto minmax(0,1.03fr) minmax(0,.88fr) auto; gap: 8px; } +.monitor-heading { justify-content: space-between; gap: 8px; } +.monitor-heading h2 { margin-top: 2px; font-size: 15px; font-weight: 650; } +.visual-section { min-width: 0; min-height: 0; display: flex; flex-direction: column; } +.visual-section-heading { flex: 0 0 auto; min-height: 23px; justify-content: space-between; gap: 8px; color: var(--muted); } +.visual-section-heading strong { color: var(--text-soft); font-size: 9px; font-weight: 640; } +.visual-section-heading span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 8px/1 var(--mono); } +.visual-section-heading b { color: var(--text-soft); font-weight: 600; } +.visual-section canvas { width: 100%; flex: 1 1 auto; min-height: 0; display: block; border: 1px solid #1d2731; border-radius: 5px; background: #090f15; } +.monitor-footer { min-height: 21px; gap: 10px; color: var(--muted-2); font-size: 7px; white-space: nowrap; overflow: hidden; } +.monitor-footer > span:last-child { margin-left: auto; overflow: hidden; text-overflow: ellipsis; } +.legend-line { width: 13px; height: 2px; margin-right: 3px; display: inline-block; vertical-align: middle; background: #8ea9c1; } +.legend-line.current { height: 1px; opacity: .58; } + +.action-bar { + min-width: 0; padding: 0 10px 0 13px; display: grid; + grid-template-columns: minmax(220px,1fr) auto minmax(286px,1fr); align-items: center; gap: 14px; + border-top: 1px solid var(--line); background: var(--chrome); z-index: 12; } -.toast.show { opacity:1; transform:translateY(0); } -.toast.error { border-color:rgba(255,107,118,.34); color:#ffd0d4; } +.readiness-strip { min-width: 0; gap: 8px; } +.ready-dot { width: 7px; height: 7px; border-radius: 50%; background: #596573; box-shadow: 0 0 0 3px rgba(89,101,115,.07); } +.readiness-strip > div { min-width: 0; display: grid; gap: 1px; } +.readiness-strip strong { color: var(--text-soft); font: 690 8px/1 var(--mono); letter-spacing: .06em; } +.readiness-strip span:last-child { overflow: hidden; color: var(--muted); font-size: 8px; white-space: nowrap; text-overflow: ellipsis; } +body[data-connected="true"] .ready-dot { background: var(--green); box-shadow: 0 0 0 3px rgba(87,213,156,.08); } +body[data-running="true"] .ready-dot { background: #78dfb0; } +.action-context { justify-content: center; gap: 9px; color: var(--muted); font: 520 8px/1 var(--mono); white-space: nowrap; } +.primary-actions { justify-content: flex-end; gap: 6px; } -@media (max-width: 1320px) { - .workspace { padding-left:20px; padding-right:20px; } - .channel-grid { grid-template-columns:repeat(2,1fr); } - .control-toolbar { grid-template-columns:1fr; } - .toolbar-actions { justify-content:flex-start; } +.btn { + min-height: 30px; padding: 0 10px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; + cursor: pointer; border: 1px solid var(--line-2); border-radius: 5px; background: #131a22; + color: var(--text-soft); font-size: 9px; font-weight: 650; + transition: background .08s ease, border-color .08s ease, opacity .08s ease, transform .06s ease; } +.btn:hover:not(:disabled) { background: #18212b; border-color: #41505f; } +.btn:active:not(:disabled) { transform: translateY(1px); } +.btn:disabled { opacity: .32; cursor: not-allowed; } +.btn.compact { min-height: 28px; } +.btn.action { min-width: 76px; min-height: 34px; } +.btn.start { min-width: 108px; border-color: #347a5a; background: #174a36; color: #d3f3e4; } +.btn.start:hover:not(:disabled) { background: #1b563f; border-color: #45936d; } +.btn.stop { border-color: rgba(255,112,124,.24); background: var(--red-soft); color: #ffc4c9; } + +.toast { + position: fixed; right: 14px; bottom: 66px; z-index: 40; max-width: 360px; padding: 9px 11px; + transform: translateY(7px); opacity: 0; pointer-events: none; + border: 1px solid #365878; border-radius: 5px; background: #111a23; color: #dceaf8; + box-shadow: 0 10px 28px rgba(0,0,0,.3); font-size: 9px; transition: opacity .12s ease, transform .12s ease; +} +.toast.show { opacity: 1; transform: translateY(0); } +.toast.error { border-color: rgba(255,112,124,.36); color: #ffd2d6; background: #211419; } + +::-webkit-scrollbar { width: 7px; height: 7px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #27323e; border: 2px solid transparent; background-clip: padding-box; border-radius: 999px; }