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 = ` -