From 544f091aa3ecd1e77b8b81d63dadc633d9fab592 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:52:15 +0700 Subject: [PATCH 01/24] Prototype premium SMV cockpit layout --- apps/smv_injector_gui/index.html | 243 ++++++++++++++++++++----------- 1 file changed, 154 insertions(+), 89 deletions(-) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index 9754aa900..c3ed0b67b 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -8,140 +8,205 @@ - +
-
- -
-
ARStack61850
-

SMV Injector

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

Active publisher profile

-
- Development profile +
+
+
+ Engineering source + +
Development profile active
+
-
-
- -
-
Publisher state
- STOPPED -
-
+
-
-
Observed rate—
-
Missed slots—
-
TX failures—
-
Signal generation—
+
+ Quick setup + + +
+ +
+ +
+
+ Runtime health + OFFLINE
+
+
Rate—
+
Missed—
+
TX fail—
+
Generation—
+
+
-
- - +
+ Device diagnostics +
+ Serial evidence +
-
-
+
Waiting for device connection…
+ + -
-
+
+
-
Signal frequency
-
Hz
+ Fast injection +

4I + 4V

+
+
+ + STOPPED
-
-
-
-
-
-
-
Live signal control
-

Current & voltage channels

+
+
+ On + Channel + Magnitude + Phase + Wire +
-

Magnitude uses engineering units. Phase uses ordinary degrees. Conversion to wire counts and milli-degrees happens inside the GUI.

+
+
+ +
+ Tab next value + Enter apply channel + Values remain editable while disconnected for setup and preview.
-
-
-
-
+
+
-
-
+
+
-
Device evidence
-

Runtime log

+ Generated +

Output waveform

- + 40 ms
-
Waiting for device connection…
-
-
+ + + +
+ +
+
+ +
+ DEVICE OFFLINE + Connect ESP32-P4 to arm live injection +
+
+ +
+ MU01_SV1 + + 4000 sample/s +
+ +
+ + + +
+
From c9be099d546be727d2c6e522fd94fac1c771f1a2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:53:35 +0700 Subject: [PATCH 02/24] Apply precision graphite premium visual system --- apps/smv_injector_gui/styles.css | 488 +++++++++++++++++++------------ 1 file changed, 303 insertions(+), 185 deletions(-) diff --git a/apps/smv_injector_gui/styles.css b/apps/smv_injector_gui/styles.css index 9b0cd2e37..35eb3cd9c 100644 --- a/apps/smv_injector_gui/styles.css +++ b/apps/smv_injector_gui/styles.css @@ -1,212 +1,330 @@ :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: #0a0d12; + --chrome: #0d1117; + --surface: #11161d; + --surface-raised: #151b23; + --surface-input: #0d1218; + --line: #232b36; + --line-strong: #303a47; + --text: #eef2f6; + --text-soft: #c7d0da; + --muted: #7f8b99; + --muted-2: #5e6975; + --accent: #65a8ff; + --accent-soft: rgba(101, 168, 255, .12); + --green: #54d39a; + --green-soft: rgba(84, 211, 154, .12); + --amber: #e8b85d; + --red: #ff6f7c; + --red-soft: rgba(255, 111, 124, .10); + --radius: 8px; + --radius-small: 6px; + --topbar: 58px; + --actionbar: 58px; + --font-ui: Inter, "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, sans-serif; + --font-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(--font-ui); + font-size: 13px; + -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(101, 168, 255, .24); } + +.app-shell { + height: 100vh; + min-height: 680px; + display: grid; + grid-template-rows: var(--topbar) minmax(0, 1fr) var(--actionbar); + background: + linear-gradient(rgba(255,255,255,.012), rgba(255,255,255,0) 160px), + var(--bg); +} -.app-shell { min-height: 100vh; } .topbar { - min-height: 84px; - padding: 18px 30px; - display: flex; + min-width: 0; + padding: 0 18px; + display: grid; + grid-template-columns: minmax(220px, auto) minmax(0, 1fr) auto; align-items: center; - justify-content: space-between; - position: sticky; - top: 0; - z-index: 20; + gap: 22px; border-bottom: 1px solid var(--line); - background: rgba(7, 16, 24, 0.88); - backdrop-filter: blur(18px); + background: var(--chrome); + z-index: 10; } -.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, +.visual-heading, .visual-footer, .health-heading, .readiness-strip, .primary-actions, +.action-context, .frequency-strip, .switch-row, .frequency-value, .channel-name, +.row-actions { display: flex; align-items: center; } + +.brand-block { gap: 11px; 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: 31px; height: 31px; border-radius: 7px; position: relative; overflow: hidden; + border: 1px solid #315070; background: #101d2a; +} +.brand-mark span { position: absolute; left: 7px; right: 7px; height: 1px; background: var(--accent); opacity: .92; } +.brand-mark span:nth-child(1) { top: 9px; transform: rotate(-25deg); } +.brand-mark span:nth-child(2) { top: 15px; } +.brand-mark span:nth-child(3) { top: 21px; transform: rotate(25deg); } +.brand-copy { min-width: 0; display: grid; gap: 1px; } +.product-family { color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.brand-copy strong { font-size: 14px; font-weight: 670; letter-spacing: -.01em; } + +.top-context { min-width: 0; justify-content: center; gap: 12px; color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; } +.top-context strong { color: var(--text-soft); font-size: 12px; font-weight: 700; } +.top-context b { color: var(--text-soft); font-weight: 650; } +.context-kicker { color: var(--muted-2); text-transform: uppercase; letter-spacing: .1em; font-size: 9px; } +.context-separator, .action-separator { width: 1px; height: 16px; background: var(--line-strong); flex: 0 0 auto; } +.connection-strip { gap: 9px; justify-content: flex-end; } + +.status-pill { + height: 30px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; + border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: #0e1319; + font-size: 11px; font-weight: 620; white-space: nowrap; +} +.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #64707d; } +.status-pill[data-state="online"] { color: #bee9d6; border-color: rgba(84, 211, 154, .28); background: var(--green-soft); } +.status-pill[data-state="online"] .status-dot { background: var(--green); box-shadow: 0 0 0 3px rgba(84, 211, 154, .10); } + +.cockpit { + min-width: 0; min-height: 0; + padding: 12px; + display: grid; + grid-template-columns: 238px minmax(500px, 1.18fr) minmax(390px, .82fr); + gap: 10px; + 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: 0 1px 0 rgba(255,255,255,.018) inset; +} + +.stream-pane { padding: 15px; overflow: auto; scrollbar-width: thin; scrollbar-color: #2c3642 transparent; } +.pane-heading, .injector-heading, .visual-heading { justify-content: space-between; gap: 12px; } +.pane-heading { margin-bottom: 13px; } +.eyebrow, .field-label { + color: var(--muted); font-size: 9px; font-weight: 720; text-transform: uppercase; letter-spacing: .11em; +} +h1, h2 { margin: 0; letter-spacing: -.025em; } +.pane-heading h2, .visual-heading h2 { margin-top: 2px; font-size: 16px; font-weight: 650; } +.profile-chip, .visual-badge { + height: 23px; padding: 0 7px; display: inline-flex; align-items: center; + border: 1px solid var(--line); border-radius: 5px; background: #0d1218; color: var(--muted); + font: 650 9px/1 var(--font-mono); letter-spacing: .06em; +} + +.stream-card { + padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-small); background: var(--surface-input); +} +.stream-card > strong { display: block; margin-top: 5px; font: 650 15px/1.2 var(--font-mono); color: var(--text); } +.stream-meta-grid { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px 12px; } +.stream-meta-grid div { min-width: 0; display: grid; gap: 3px; } +.stream-meta-grid span { color: var(--muted-2); font-size: 9px; text-transform: uppercase; letter-spacing: .07em; } +.stream-meta-grid b { color: var(--text-soft); font: 600 10px/1.2 var(--font-mono); overflow: hidden; text-overflow: ellipsis; } + +.engineering-file { margin-top: 14px; display: grid; gap: 7px; } .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: 34px; padding: 0 10px; display: flex; align-items: center; justify-content: center; + cursor: pointer; border: 1px solid var(--line-strong); border-radius: var(--radius-small); + background: #141a22; color: var(--text-soft); font-size: 11px; font-weight: 640; + transition: border-color .12s ease, background .12s ease; +} +.file-button:hover { border-color: #43658b; background: #17202a; } +.file-button input { display: none; } +.file-state { color: var(--muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; } +.section-divider { height: 1px; margin: 14px 0; background: var(--line); } -.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; } +.quick-presets { display: grid; gap: 7px; } +.quick-presets > .field-label { margin-bottom: 1px; } +.preset-button { + width: 100%; min-height: 44px; padding: 7px 9px; display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; + cursor: pointer; text-align: left; border: 1px solid transparent; border-radius: var(--radius-small); background: transparent; + transition: background .12s ease, border-color .12s ease; +} +.preset-button:hover { background: #151b23; border-color: var(--line); } +.preset-icon { + width: 28px; height: 28px; display: grid; place-items: center; border-radius: 6px; + background: var(--accent-soft); color: #9bc6ff; font: 700 10px/1 var(--font-mono); +} +.preset-icon.zero { background: rgba(255,255,255,.045); color: var(--muted); } +.preset-button span:last-child { min-width: 0; display: grid; gap: 2px; } +.preset-button b { color: var(--text-soft); font-size: 11px; font-weight: 620; } +.preset-button small { color: var(--muted); font-size: 9px; } + +.health-block { display: grid; gap: 9px; } +.health-heading { justify-content: space-between; } +.health-state { color: var(--muted); font: 650 9px/1 var(--font-mono); } +.health-state[data-state="ready"] { color: var(--green); } +.health-state[data-state="running"] { color: #8ce5bd; } +.health-grid { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--line); border-radius: var(--radius-small); overflow: hidden; } +.health-grid div { min-width: 0; min-height: 51px; padding: 9px; display: grid; align-content: center; gap: 4px; background: var(--surface-input); } +.health-grid div:nth-child(odd) { border-right: 1px solid var(--line); } +.health-grid div:nth-child(-n+2) { border-bottom: 1px solid var(--line); } +.health-grid span { color: var(--muted-2); font-size: 9px; text-transform: uppercase; letter-spacing: .07em; } +.health-grid strong { color: var(--text-soft); font: 650 12px/1 var(--font-mono); font-variant-numeric: tabular-nums; } + +.diagnostics-panel { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; } +.diagnostics-panel summary { cursor: pointer; color: var(--muted); font-size: 10px; user-select: none; } +.diagnostics-head { margin: 10px 0 6px; display: flex; align-items: center; justify-content: space-between; color: var(--muted-2); font-size: 9px; } +.text-button { padding: 0; border: 0; background: none; color: var(--accent); cursor: pointer; font-size: 9px; } +#runtimeLog { + height: 140px; margin: 0; padding: 9px; overflow: auto; border: 1px solid var(--line); border-radius: 5px; + background: #090d12; color: #8da7ba; font: 9px/1.5 var(--font-mono); white-space: pre-wrap; word-break: break-word; +} + +.injector-pane { padding: 16px; overflow: hidden; display: flex; flex-direction: column; } +.injector-heading { flex: 0 0 auto; margin-bottom: 13px; } +.injector-heading h1 { margin-top: 2px; font-size: 22px; font-weight: 620; } +.run-state { + height: 28px; padding: 0 9px; display: inline-flex; align-items: center; gap: 7px; + border: 1px solid var(--line); border-radius: 6px; background: var(--surface-input); + color: var(--muted); font: 680 9px/1 var(--font-mono); letter-spacing: .08em; +} +.run-dot { width: 6px; height: 6px; border-radius: 50%; background: #65717d; } +.run-state[data-state="running"] { border-color: rgba(84,211,154,.28); background: var(--green-soft); color: #a8e9cd; } +.run-state[data-state="running"] .run-dot { background: var(--green); box-shadow: 0 0 0 3px rgba(84,211,154,.10); } + +.frequency-strip { + flex: 0 0 auto; min-height: 58px; padding: 8px 10px; display: grid; + grid-template-columns: 142px minmax(100px, 1fr) auto; gap: 16px; + border: 1px solid var(--line); border-radius: var(--radius-small); background: var(--surface-input); +} +.frequency-main { min-width: 0; display: grid; gap: 1px; } +.frequency-value { gap: 5px; } .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; } + width: 82px; padding: 0; border: 0; outline: 0; background: transparent; color: var(--text); + font: 640 18px/1.2 var(--font-mono); font-variant-numeric: tabular-nums; +} +.frequency-value span { color: var(--muted); font-size: 10px; } +.frequency-slider { width: 100%; accent-color: var(--accent); } +.switch-row { gap: 8px; color: var(--text-soft); font-size: 10px; cursor: pointer; white-space: nowrap; } +.switch-row input { display: none; } +.switch { width: 30px; height: 17px; position: relative; border-radius: 999px; background: #202832; border: 1px solid #34404d; transition: .12s ease; } +.switch::after { content: ""; position: absolute; width: 9px; height: 9px; top: 3px; left: 3px; border-radius: 50%; background: #788492; transition: .12s ease; } +.switch-row input:checked + .switch { background: rgba(84,211,154,.16); border-color: rgba(84,211,154,.34); } +.switch-row input:checked + .switch::after { left: 16px; background: var(--green); } + +.injection-table { flex: 1 1 auto; min-height: 0; margin-top: 12px; border: 1px solid var(--line); border-radius: var(--radius-small); overflow: hidden; background: #0e1319; } +.grid-head, .channel-row { + display: grid; + grid-template-columns: 40px minmax(84px, .85fr) minmax(135px, 1.2fr) minmax(122px, 1fr) minmax(96px, .85fr) 54px; + align-items: center; +} +.grid-head { + min-height: 32px; padding: 0 8px; background: #0c1117; border-bottom: 1px solid var(--line); + color: var(--muted-2); font-size: 8px; font-weight: 720; letter-spacing: .1em; text-transform: uppercase; +} +.channel-grid { min-height: 0; } +.channel-row { + min-height: 47px; padding: 0 8px; border-bottom: 1px solid #1d242d; + transition: background .09s ease, opacity .09s ease; +} +.channel-row:last-child { border-bottom: 0; } +.channel-row:hover { background: #121921; } +.channel-row[data-active="true"] { background: #141c25; box-shadow: 2px 0 0 var(--accent) inset; } +.channel-row[data-enabled="false"] { opacity: .48; } +.channel-name { min-width: 0; gap: 8px; } +.phase-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--phase-color); flex: 0 0 auto; } +.channel-name-copy { min-width: 0; display: grid; gap: 1px; } +.channel-name strong { color: var(--text); font: 650 12px/1 var(--font-mono); } +.channel-name small { overflow: hidden; color: var(--muted-2); font-size: 8px; white-space: nowrap; text-overflow: ellipsis; } +.enable-toggle { width: 28px; height: 18px; display: grid; place-items: center; cursor: pointer; } +.enable-toggle input { display: none; } +.enable-toggle span { width: 25px; height: 14px; position: relative; border-radius: 999px; background: #202832; border: 1px solid #34404d; } +.enable-toggle span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 8px; height: 8px; border-radius: 50%; background: #707c89; transition: .1s; } +.enable-toggle input:checked + span { background: rgba(101,168,255,.12); border-color: rgba(101,168,255,.32); } +.enable-toggle input:checked + span::after { left: 13px; background: #8fc0ff; } +.value-cell { min-width: 0; padding-right: 9px; } +.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; -} -.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; } - -.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; + width: 100%; height: 31px; padding: 0 36px 0 8px; outline: 0; border: 1px solid #29333f; + border-radius: 5px; background: #0b1016; color: var(--text); font: 620 12px/1 var(--font-mono); + font-variant-numeric: tabular-nums; transition: border-color .1s ease, background .1s ease, box-shadow .1s ease; +} +.input-unit input:hover { border-color: #364453; } +.input-unit input:focus { border-color: #4d79a8; background: #0d141c; box-shadow: 0 0 0 2px rgba(101,168,255,.08); } +.input-unit span { position: absolute; right: 7px; top: 50%; transform: translateY(-50%); color: var(--muted-2); font-size: 8px; pointer-events: none; } +.wire-preview { min-width: 0; overflow: hidden; color: var(--muted-2); font: 500 8px/1.35 var(--font-mono); white-space: nowrap; text-overflow: ellipsis; } +.channel-apply { + width: 44px; min-height: 27px !important; padding: 0 !important; justify-self: end; + color: var(--muted) !important; font-size: 9px !important; } +.channel-row[data-active="true"] .channel-apply:not(:disabled) { color: #a7ccfa !important; border-color: #355474 !important; } -.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; +.grid-hint { + flex: 0 0 auto; min-height: 32px; display: flex; align-items: center; gap: 13px; overflow: hidden; + color: var(--muted-2); font-size: 9px; white-space: nowrap; +} +kbd { min-width: 21px; padding: 2px 5px; border: 1px solid #303a45; border-bottom-color: #3e4a57; border-radius: 4px; background: #141a21; color: #9aa6b3; font: 600 8px/1 var(--font-mono); } + +.visual-pane { min-width: 0; min-height: 0; display: grid; grid-template-rows: 1fr .92fr; gap: 10px; } +.visual-card { padding: 13px; display: flex; flex-direction: column; overflow: hidden; } +.visual-heading { flex: 0 0 auto; margin-bottom: 8px; } +.visual-card canvas { + width: 100%; flex: 1 1 auto; min-height: 0; display: block; border: 1px solid #1d2630; border-radius: 6px; + background: #0b1016; +} +.visual-footer { flex: 0 0 auto; min-height: 24px; gap: 12px; overflow: hidden; color: var(--muted-2); font-size: 8px; white-space: nowrap; } +.visual-footer > span:last-child { margin-left: auto; overflow: hidden; text-overflow: ellipsis; } +.legend-line { width: 15px; height: 2px; margin-right: 4px; display: inline-block; vertical-align: middle; background: #8fa8c0; } +.legend-line.current { height: 1px; opacity: .62; } +.waveform-footer { justify-content: space-between; } +.waveform-footer > span:last-child { margin-left: 0; font-family: var(--font-mono); } + +.action-bar { + min-width: 0; padding: 0 12px 0 17px; display: grid; grid-template-columns: minmax(250px, 1fr) auto minmax(300px, 1fr); + align-items: center; gap: 18px; border-top: 1px solid var(--line); background: var(--chrome); z-index: 12; +} +.readiness-strip { min-width: 0; gap: 9px; } +.ready-dot { width: 8px; height: 8px; border-radius: 50%; background: #596471; box-shadow: 0 0 0 4px rgba(89,100,113,.08); } +.readiness-strip > div { min-width: 0; display: grid; gap: 2px; } +.readiness-strip strong { color: var(--text-soft); font: 690 9px/1 var(--font-mono); letter-spacing: .07em; } +.readiness-strip span:last-child { overflow: hidden; color: var(--muted); font-size: 9px; white-space: nowrap; text-overflow: ellipsis; } +body[data-connected="true"] .ready-dot { background: var(--green); box-shadow: 0 0 0 4px rgba(84,211,154,.08); } +body[data-running="true"] .ready-dot { background: #75e0ae; box-shadow: 0 0 0 4px rgba(84,211,154,.12); } +.action-context { justify-content: center; gap: 10px; color: var(--muted); font: 520 9px/1 var(--font-mono); white-space: nowrap; } +.primary-actions { justify-content: flex-end; gap: 7px; } + +.btn { + min-height: 32px; padding: 0 11px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; + cursor: pointer; border: 1px solid var(--line-strong); border-radius: 6px; background: #151b22; + color: var(--text-soft); font-size: 10px; font-weight: 650; transition: background .1s ease, border-color .1s ease, opacity .1s ease, transform .08s ease; } -.toast.show { opacity:1; transform:translateY(0); } -.toast.error { border-color:rgba(255,107,118,.34); color:#ffd0d4; } +.btn:hover:not(:disabled) { background: #19212a; border-color: #43505e; } +.btn:active:not(:disabled) { transform: translateY(1px); } +.btn:disabled { opacity: .34; cursor: not-allowed; } +.btn.compact { min-height: 30px; } +.btn.action { min-width: 82px; min-height: 36px; } +.btn.start { min-width: 116px; border-color: #34795b; background: #184b37; color: #d2f5e5; } +.btn.start:hover:not(:disabled) { background: #1b5940; border-color: #469972; } +.btn.stop { border-color: rgba(255,111,124,.28); background: var(--red-soft); color: #ffc2c7; } +body[data-running="true"] .btn.start { opacity: .45; } -@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; } +.toast { + position: fixed; right: 18px; bottom: 72px; z-index: 40; max-width: 380px; padding: 10px 12px; + transform: translateY(8px); opacity: 0; pointer-events: none; border: 1px solid #365778; border-radius: 6px; + background: #121a23; color: #dbe9f7; box-shadow: 0 12px 32px rgba(0,0,0,.32); font-size: 10px; + transition: opacity .14s ease, transform .14s ease; } +.toast.show { opacity: 1; transform: translateY(0); } +.toast.error { border-color: rgba(255,111,124,.38); color: #ffd1d5; background: #211419; } + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #27313d; border: 2px solid transparent; background-clip: padding-box; border-radius: 999px; } +::-webkit-scrollbar-thumb:hover { background: #34404d; border: 2px solid transparent; background-clip: padding-box; } From 93cb6e1b53d0749d9536b173de66a446e3692c37 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:53:59 +0700 Subject: [PATCH 03/24] Keep premium cockpit responsive on engineering laptops --- apps/smv_injector_gui/responsive.css | 108 +++++++++++++++++---------- 1 file changed, 70 insertions(+), 38 deletions(-) diff --git a/apps/smv_injector_gui/responsive.css b/apps/smv_injector_gui/responsive.css index 20758e88a..40af9cee3 100644 --- a/apps/smv_injector_gui/responsive.css +++ b/apps/smv_injector_gui/responsive.css @@ -1,42 +1,74 @@ -/* 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; } +/* Premium cockpit responsiveness: preserve the no-scroll primary workflow on engineering laptops, + then progressively stack visual verification without hiding controls. */ -@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: 1320px) { + .cockpit { grid-template-columns: 220px minmax(470px, 1.12fr) minmax(350px, .78fr); } + .top-context { justify-content: flex-start; } + .top-context .context-kicker { display: none; } + .grid-head, .channel-row { + grid-template-columns: 36px minmax(76px,.8fr) minmax(126px,1.18fr) minmax(116px,1fr) minmax(82px,.7fr) 50px; + } + .grid-hint span:last-child { display: none; } } -@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; } +@media (max-width: 1120px) { + body { overflow: auto; } + .app-shell { height: auto; min-height: 100vh; grid-template-rows: var(--topbar) auto var(--actionbar); } + .topbar { grid-template-columns: minmax(210px, auto) minmax(0, 1fr) auto; gap: 12px; } + .top-context span:not(.context-separator):not(:nth-last-child(1)), .top-context .context-separator { display: none; } + .cockpit { + min-height: 790px; + grid-template-columns: 220px minmax(0, 1fr); + grid-template-rows: minmax(500px, 1fr) 300px; + overflow: visible; + } + .stream-pane { grid-column: 1; grid-row: 1; } + .injector-pane { grid-column: 2; grid-row: 1; } + .visual-pane { grid-column: 1 / -1; grid-row: 2; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; } + .visual-card canvas { min-height: 205px; } + .action-bar { position: sticky; bottom: 0; } +} + +@media (max-width: 820px) { + :root { --topbar: auto; --actionbar: auto; } + .app-shell { display: block; min-height: 100vh; } + .topbar { + min-height: 58px; padding: 10px 12px; 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: 24px; justify-content: flex-start; border-top: 1px solid var(--line); padding-top: 7px; } + .cockpit { min-height: 0; padding: 8px; display: grid; grid-template-columns: 1fr; grid-template-rows: auto auto auto; gap: 8px; } + .stream-pane, .injector-pane, .visual-pane { grid-column: 1; grid-row: auto; } + .stream-pane { overflow: visible; } + .visual-pane { display: grid; grid-template-columns: 1fr; grid-template-rows: 320px 300px; } + .frequency-strip { grid-template-columns: 130px 1fr; } + .switch-row { grid-column: 1 / -1; } + .injection-table { overflow-x: auto; } + .grid-head, .channel-row { min-width: 610px; } + .grid-hint { display: none; } + .action-bar { + position: sticky; bottom: 0; min-height: 68px; padding: 8px 10px; + grid-template-columns: minmax(0, 1fr) auto; gap: 10px; + } + .action-context { display: none; } + .primary-actions { gap: 5px; } + .btn.action { min-width: 66px; } + .btn.start { min-width: 96px; } +} + +@media (max-width: 560px) { + .connection-strip .status-pill { display: none; } + .top-context { overflow-x: auto; } + .stream-meta-grid { grid-template-columns: 1fr 1fr; } + .injector-pane { padding: 12px; } + .frequency-strip { grid-template-columns: 1fr; gap: 8px; } + .frequency-slider { grid-row: 2; } + .switch-row { grid-row: 3; grid-column: 1; } + .action-bar { grid-template-columns: 1fr; } + .readiness-strip { display: none; } + .primary-actions { width: 100%; } + .primary-actions .btn { flex: 1; } + .visual-pane { grid-template-rows: 290px 270px; } } From 58a634dff47f3f18f1a1c93657b8a8e313051713 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:55:28 +0700 Subject: [PATCH 04/24] Wire fast injection grid and generated waveform preview --- apps/smv_injector_gui/app.js | 449 +++++++++++++++++++++++++---------- 1 file changed, 320 insertions(+), 129 deletions(-) diff --git a/apps/smv_injector_gui/app.js b/apps/smv_injector_gui/app.js index eeedfe5dd..fc6354ee3 100644 --- a/apps/smv_injector_gui/app.js +++ b/apps/smv_injector_gui/app.js @@ -1,14 +1,14 @@ "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 state = { @@ -19,12 +19,14 @@ const state = { running: false, liveApply: true, frequencyHz: 50, + activeChannel: "UA", channels: new Map(CHANNELS.map((channel) => [channel.id, { ...channel, enabled: true, quality: 0 }])), sendChain: Promise.resolve(), }; const els = {}; let toastTimer = null; +let visualFramePending = false; function $(id) { return document.getElementById(id); } @@ -34,7 +36,9 @@ function initElements() { "startButton", "stopButton", "fpsMetric", "missedMetric", "failMetric", "generationMetric", "frequencyInput", "frequencySlider", "liveApplyToggle", "balancedButton", "zeroButton", "applyAllButton", "channelGrid", "runtimeLog", - "clearLogButton", "phasorCanvas", "toast", "sclFile", "sclState" + "clearLogButton", "phasorCanvas", "waveformCanvas", "toast", "sclFile", "sclState", + "systemReadiness", "footerReadiness", "footerReadinessDetail", "footerRate", + "waveformFrequencyLabel" ].forEach((id) => { els[id] = $(id); }); } @@ -45,13 +49,22 @@ function wireCountFor(channel) { function phaseMdeg(channel) { return Math.round(channel.phase * 1000); } function frequencyMhz() { return Math.round(state.frequencyHz * 1000); } +function displayId(id) { return `${id[0]}${id[1].toLowerCase()}`; } + +function formatMagnitude(channel) { + return Number(channel.magnitude).toFixed(3); +} + +function formatPhase(channel) { + return Number(channel.phase).toFixed(2); +} 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"), 2200); } function logLine(text, direction = "rx") { @@ -66,126 +79,168 @@ function logLine(text, direction = "rx") { 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 = "Device connected · profile ready for live injection"; + return; + } + + els.systemReadiness.dataset.state = "offline"; + els.systemReadiness.textContent = "OFFLINE"; + els.footerReadiness.textContent = "DEVICE OFFLINE"; + els.footerReadinessDetail.textContent = "Connect ESP32-P4 to arm live injection"; +} + function setConnected(connected) { state.connected = connected; els.deviceStatus.dataset.state = connected ? "online" : "offline"; - els.deviceStatusText.textContent = connected ? "Device connected" : "Device offline"; + els.deviceStatusText.textContent = connected ? "ESP32-P4 connected" : "Device offline"; els.connectButton.textContent = connected ? "Disconnect" : "Connect device"; 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 setActiveChannel(id) { + state.activeChannel = id; + document.querySelectorAll(".channel-row").forEach((row) => { + row.dataset.active = String(row.dataset.channel === id); + }); + scheduleVisualUpdate(); } function buildChannels() { 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 unit = channel.kind === "current" ? "A" : "V"; + const magStep = channel.kind === "current" ? "0.001" : "0.001"; 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}
-
- + const row = document.createElement("div"); + row.className = "channel-row"; + row.setAttribute("role", "row"); + row.dataset.channel = channel.id; + row.dataset.enabled = String(channel.enabled); + row.dataset.active = String(channel.id === state.activeChannel); + row.style.setProperty("--phase-color", channel.color); + row.innerHTML = ` + +
+ + ${displayId(channel.id)}${channel.label}
-
-
- -
- - ${unit} -
+
+
+ + ${unit}
-
- -
- - deg -
+
+
+
+ + °
- - `; - - els.channelGrid.appendChild(card); - bindChannelCard(card, channel.id); - updateWirePreview(card, channel); + + `; + + els.channelGrid.appendChild(row); + bindChannelRow(row, channel.id); + updateWirePreview(row, channel); } } -function readCardValues(card, id) { +function readRowValues(row, 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; + const magnitude = Number(row.querySelector(".magnitude-input").value); + const phase = Number(row.querySelector(".phase-input").value); + const enabled = row.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(); + row.dataset.enabled = String(enabled); + updateWirePreview(row, channel); + scheduleVisualUpdate(); return true; } -function updateWirePreview(card, channel) { - card.querySelector(".wire-preview").textContent = `${wireCountFor(channel)} counts · ${phaseMdeg(channel)} mdeg`; +function updateWirePreview(row, channel) { + row.querySelector(".wire-preview").textContent = `${wireCountFor(channel)} · ${phaseMdeg(channel)}m°`; } -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 magnitude = row.querySelector(".magnitude-input"); + const phase = row.querySelector(".phase-input"); + const enable = row.querySelector(".channel-enable"); + const apply = row.querySelector(".channel-apply"); const schedule = debounce(async () => { - if (!readCardValues(card, id)) return; + if (!readRowValues(row, id)) return; if (state.liveApply && state.connected) await applyChannel(id); - }, 130); + }, 110); + const activate = () => setActiveChannel(id); + row.addEventListener("pointerdown", activate); + magnitude.addEventListener("focus", activate); + phase.addEventListener("focus", activate); 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(); - }); + phase.addEventListener("input", schedule); + + for (const input of [magnitude, phase]) { + input.addEventListener("dblclick", () => input.select()); + input.addEventListener("keydown", async (event) => { + if (event.key !== "Enter") return; + if (!readRowValues(row, id)) return; + if (state.connected) { + event.preventDefault(); + await applyChannel(id); + showToast(`${displayId(id)} applied`); + } + }); + } + enable.addEventListener("change", async () => { - if (!readCardValues(card, id)) return; + if (!readRowValues(row, id)) return; if (state.connected) await sendCommand(`ENABLE ${id} ${state.channels.get(id).enabled ? 1 : 0}`); }); + apply.addEventListener("click", async () => { - if (!readCardValues(card, id)) return; + if (!readRowValues(row, id)) return; await applyChannel(id); + showToast(`${displayId(id)} applied`); }); } @@ -202,6 +257,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 +270,7 @@ async function sendCommand(command) { logLine(`Serial write failed: ${error.message}`, "ui"); showToast(`Serial write failed: ${error.message}`, true); }); + await state.sendChain; return true; } @@ -226,7 +283,7 @@ async function applyChannel(id) { async function applyAll() { for (const source of CHANNELS) await applyChannel(source.id); await sendCommand(`FREQ ${frequencyMhz()}`); - showToast("All live signal values applied"); + showToast("All injection values applied"); } async function connectSerial() { @@ -268,6 +325,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 +376,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); @@ -330,7 +390,9 @@ function processDeviceLine(raw) { function setFrequencyUi(hz, transmit = true) { state.frequencyHz = hz; els.frequencyInput.value = hz.toFixed(3); + els.waveformFrequencyLabel.textContent = `${hz.toFixed(3)} Hz`; if (hz >= 45 && hz <= 65) els.frequencySlider.value = String(hz); + scheduleVisualUpdate(); if (transmit && state.liveApply && state.connected) { sendCommand(`FREQ ${frequencyMhz()}`); } @@ -341,89 +403,216 @@ 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(); + scheduleVisualUpdate(); if (state.connected) applyAll(); } async function zeroOutputs() { for (const channel of state.channels.values()) channel.magnitude = 0; buildChannels(); - drawPhasors(); + scheduleVisualUpdate(); if (state.connected) await sendCommand("ZERO"); + showToast(state.connected ? "Outputs zeroed" : "Local setpoints zeroed"); } -function drawPhasors() { - const canvas = els.phasorCanvas; +function scheduleVisualUpdate() { + if (visualFramePending) return; + visualFramePending = true; + requestAnimationFrame(() => { + visualFramePending = false; + drawPhasors(); + drawWaveforms(); + }); +} + +function prepareCanvas(canvas, minWidth = 280, minHeight = 180) { 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, 320, 220); + const cx = w * 0.5; + const cy = h * 0.52; + const radius = Math.max(62, Math.min(w * 0.38, h * 0.36)); - ctx.strokeStyle = "rgba(143,166,183,.16)"; + ctx.save(); ctx.lineWidth = 1; + ctx.strokeStyle = "rgba(139,154,170,.13)"; for (const factor of [0.33, 0.66, 1]) { - ctx.beginPath(); ctx.arc(cx, cy, radius * factor, 0, Math.PI * 2); ctx.stroke(); + ctx.beginPath(); + ctx.arc(cx, cy, radius * factor, 0, Math.PI * 2); + ctx.stroke(); + } + + ctx.strokeStyle = "rgba(139,154,170,.10)"; + for (const deg of [0, 30, 60, 90, 120, 150]) { + const a = deg * Math.PI / 180; + const dx = Math.cos(a) * (radius + 10); + const dy = Math.sin(a) * (radius + 10); + 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"], radius: 1, width: 2.2, dash: [] }, + { ids: ["IA", "IB", "IC"], radius: .82, width: 1.4, dash: [5, 4] }, ]; - 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(0.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 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 ratio = Math.min(1, channel.magnitude / maxMagnitude); + const length = radius * group.radius * (.35 + .65 * ratio); const x = cx + Math.cos(angle) * length; const y = cy - Math.sin(angle) * length; + const active = state.activeChannel === id; + + ctx.save(); + ctx.globalAlpha = active ? 1 : .62; 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 + .9 : group.width; + ctx.setLineDash(group.dash); + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(x, y); + ctx.stroke(); + ctx.setLineDash([]); + + const head = active ? 8 : 6; 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 - .44) * head, y + Math.sin(angle - .44) * head); + ctx.lineTo(x - Math.cos(angle + .44) * head, y + Math.sin(angle + .44) * head); + ctx.closePath(); + ctx.fill(); + + ctx.font = `${active ? 650 : 560} 10px ${getComputedStyle(document.body).fontFamily}`; + ctx.fillText(displayId(id), x + 7, y - 6); + ctx.restore(); + } + } + + ctx.fillStyle = "rgba(199,208,218,.52)"; + ctx.font = "500 9px system-ui"; + ctx.fillText("0°", cx + radius + 12, cy + 3); + ctx.fillText("90°", cx + 5, cy - radius - 10); + ctx.fillText("180°", cx - radius - 35, cy + 3); + ctx.fillText("270°", cx + 5, cy + radius + 15); + + ctx.fillStyle = "rgba(238,242,246,.28)"; + ctx.beginPath(); + ctx.arc(cx, cy, 2.2, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +function drawWaveforms() { + const { ctx, w, h } = prepareCanvas(els.waveformCanvas, 320, 190); + const pad = { left: 34, right: 10, top: 18, bottom: 17 }; + 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; - 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); + ctx.save(); + ctx.strokeStyle = "rgba(139,154,170,.10)"; + ctx.lineWidth = 1; + + for (let i = 0; i <= 8; i += 1) { + const x = pad.left + plotW * (i / 8); + 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,.18)"; + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(pad.left + plotW, y); + ctx.stroke(); + } + + ctx.fillStyle = "rgba(127,139,153,.70)"; + ctx.font = "600 8px system-ui"; + ctx.fillText("V", 14, voltageY + 3); + ctx.fillText("I", 14, currentY + 3); + + const drawGroup = (ids, centerY, amplitude, dashed) => { + const maxMagnitude = Math.max(0.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 = state.activeChannel === id; + 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 : .46; + ctx.lineWidth = active ? 1.8 : 1.15; + if (dashed) ctx.setLineDash([4, 3]); + ctx.beginPath(); + const points = Math.max(180, 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(); + } + }; + + drawGroup(["UA", "UB", "UC"], voltageY, laneH * .39, false); + drawGroup(["IA", "IB", "IC"], currentY, laneH * .39, true); + + ctx.fillStyle = "rgba(127,139,153,.52)"; + ctx.font = "500 8px system-ui"; + for (const ms of [0, 10, 20, 30, 40]) { + const x = pad.left + plotW * (ms / 40); + ctx.fillText(`${ms}`, x - (ms === 0 ? 0 : 5), h - 5); + } + ctx.fillText("ms", w - 18, h - 5); ctx.restore(); } @@ -445,8 +634,10 @@ function bindUi() { const hz = Number(els.frequencyInput.value); if (!Number.isFinite(hz) || hz < 1 || hz > 1000) return; setFrequencyUi(hz, true); - }, 140); + }, 120); + els.frequencyInput.addEventListener("input", frequencyDebounced); + els.frequencyInput.addEventListener("dblclick", () => els.frequencyInput.select()); els.frequencySlider.addEventListener("input", () => { const hz = Number(els.frequencySlider.value); setFrequencyUi(hz, true); @@ -463,7 +654,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,7 +662,7 @@ function bindUi() { } }); - window.addEventListener("resize", debounce(drawPhasors, 80)); + window.addEventListener("resize", debounce(scheduleVisualUpdate, 70)); window.addEventListener("beforeunload", () => { if (state.port) disconnectSerial(); }); @@ -484,7 +675,7 @@ function init() { setConnected(false); setRunning(false); setFrequencyUi(50, false); - requestAnimationFrame(drawPhasors); + scheduleVisualUpdate(); if (!("serial" in navigator)) { els.deviceStatusText.textContent = "Web Serial unavailable"; From 27a700be06125ab626fb4baf81a5e3cf3b4b14b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:56:18 +0700 Subject: [PATCH 05/24] Document premium UX evaluation criteria --- apps/smv_injector_gui/PREMIUM_UX_REVIEW.md | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 apps/smv_injector_gui/PREMIUM_UX_REVIEW.md 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..3456f50f0 --- /dev/null +++ b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md @@ -0,0 +1,66 @@ +# Premium UX review build + +This branch is intentionally an evaluation build for the ESP32-P4 SMV Injector GUI. It does not change the firmware command protocol and it is not intended to be merged until the operator workflow and visual direction are approved. + +## Design target + +- premium, clean engineering-instrument appearance rather than a dashboard +- no-scroll primary workflow on normal engineering laptops +- fast 4I + 4V value entry in a compact precision grid +- persistent generated phasor and waveform visualization +- clear separation between generated setpoint preview and future on-wire observation +- device/profile/runtime status always visible +- Start/Stop always in one fixed location +- minimal visual effects and no heavy chart dependencies + +## Primary workflow + +1. Connect the ESP32-P4. +2. Optionally load SCL/CID engineering data. +3. Edit magnitude, phase, and frequency directly in the injection grid. +4. Confirm the generated phasor and 40 ms waveform preview. +5. Keep Live apply enabled for immediate updates or disable it and use Apply / Apply all. +6. Press Start live. +7. Observe runtime rate, missed slots, TX failures, and generation counter. +8. Press Stop from the fixed action bar. + +Balanced and Zero can be used while disconnected so a test state can be prepared and visually reviewed before the device is armed. + +## Visualization truthfulness + +The phasor and waveform are labelled **Generated / SETPOINT**. They are calculated from the configured magnitude, phase, enable state, and frequency. They show what the injector is configured to generate; they are not presented as proof of frames observed on the Ethernet wire. + +A later monitor path can add an **Observed** trace and generated-vs-observed comparison without changing the main cockpit layout. + +## Keyboard / fast-entry behavior + +- `Tab` moves through value fields naturally. +- Double-click selects the complete numeric value. +- `Enter` applies the active channel when a device is connected. +- Number inputs retain native arrow-key fine adjustment. +- Clicking/focusing a row makes that channel visually dominant in the phasor and waveform previews. + +## Performance approach + +The prototype remains dependency-free HTML/CSS/JavaScript. Visualization uses two canvas surfaces and redraws only on state changes or resize through `requestAnimationFrame`. Device serial traffic and the firmware realtime publisher remain unchanged. + +## Review focus + +Please judge this PR primarily on: + +- visual quality / premium feel +- density and readability +- speed of the injection workflow +- usefulness of persistent phasor + waveform +- whether profile/status information is visible without competing with the injection task +- whether the layout is a good visual foundation for the later Qt/QML desktop implementation + +## Run + +Use the existing launcher from the repository root: + +```powershell +.\apps\smv_injector_gui\run.cmd +``` + +The same Web Serial requirements and firmware compatibility rules as the existing GUI apply. From 96894fffd4438ef962edaf236273293f27022941 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:16:29 +0700 Subject: [PATCH 06/24] Refine injector cockpit for keyboard-first workflow --- apps/smv_injector_gui/index.html | 175 +++++++++++++++---------------- 1 file changed, 85 insertions(+), 90 deletions(-) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index c3ed0b67b..023ac7039 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -8,7 +8,7 @@ - +
@@ -20,11 +20,11 @@
- Active stream - MU01_SV1 - + MU01_SV1 APPID 0x4000 4000 sample/s + + FAST INJECT
@@ -32,62 +32,53 @@ Device offline
- +
-
@@ -196,7 +191,7 @@

Output waveform

- MU01_SV1 + Ua 57.740 V ∠0.00° 4000 sample/s
From df83cc05602215e4a689bf9a4a6def09124c3a6c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:17:52 +0700 Subject: [PATCH 07/24] Polish injector layout into lean instrument UI --- apps/smv_injector_gui/styles.css | 476 ++++++++++++++++--------------- 1 file changed, 246 insertions(+), 230 deletions(-) diff --git a/apps/smv_injector_gui/styles.css b/apps/smv_injector_gui/styles.css index 35eb3cd9c..f225c3af3 100644 --- a/apps/smv_injector_gui/styles.css +++ b/apps/smv_injector_gui/styles.css @@ -1,28 +1,29 @@ :root { - --bg: #0a0d12; - --chrome: #0d1117; - --surface: #11161d; - --surface-raised: #151b23; - --surface-input: #0d1218; - --line: #232b36; - --line-strong: #303a47; - --text: #eef2f6; - --text-soft: #c7d0da; + --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: #5e6975; - --accent: #65a8ff; - --accent-soft: rgba(101, 168, 255, .12); - --green: #54d39a; - --green-soft: rgba(84, 211, 154, .12); - --amber: #e8b85d; - --red: #ff6f7c; - --red-soft: rgba(255, 111, 124, .10); - --radius: 8px; - --radius-small: 6px; - --topbar: 58px; - --actionbar: 58px; - --font-ui: Inter, "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, sans-serif; - --font-mono: "Cascadia Mono", "SFMono-Regular", Consolas, monospace; + --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; } @@ -31,78 +32,73 @@ body { min-height: 100vh; overflow: hidden; color: var(--text); - font-family: var(--font-ui); - font-size: 13px; + 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(101, 168, 255, .24); } +::selection { background: rgba(105,169,255,.22); } .app-shell { height: 100vh; - min-height: 680px; + min-height: 640px; display: grid; - grid-template-rows: var(--topbar) minmax(0, 1fr) var(--actionbar); - background: - linear-gradient(rgba(255,255,255,.012), rgba(255,255,255,0) 160px), - var(--bg); + grid-template-rows: var(--topbar) minmax(0,1fr) var(--actionbar); + background: var(--bg); } .topbar { - min-width: 0; - padding: 0 18px; + padding: 0 13px; display: grid; - grid-template-columns: minmax(220px, auto) minmax(0, 1fr) auto; + grid-template-columns: minmax(185px,auto) minmax(0,1fr) auto; align-items: center; - gap: 22px; + gap: 16px; border-bottom: 1px solid var(--line); background: var(--chrome); - z-index: 10; } .brand-block, .connection-strip, .top-context, .pane-heading, .injector-heading, -.visual-heading, .visual-footer, .health-heading, .readiness-strip, .primary-actions, -.action-context, .frequency-strip, .switch-row, .frequency-value, .channel-name, -.row-actions { display: flex; align-items: center; } +.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: 11px; min-width: 0; } +.brand-block { gap: 9px; min-width: 0; } .brand-mark { - width: 31px; height: 31px; border-radius: 7px; position: relative; overflow: hidden; - border: 1px solid #315070; background: #101d2a; + width: 28px; height: 28px; position: relative; overflow: hidden; + border: 1px solid #294767; border-radius: 6px; background: #0e1924; } -.brand-mark span { position: absolute; left: 7px; right: 7px; height: 1px; background: var(--accent); opacity: .92; } -.brand-mark span:nth-child(1) { top: 9px; transform: rotate(-25deg); } -.brand-mark span:nth-child(2) { top: 15px; } -.brand-mark span:nth-child(3) { top: 21px; transform: rotate(25deg); } -.brand-copy { min-width: 0; display: grid; gap: 1px; } -.product-family { color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } -.brand-copy strong { font-size: 14px; font-weight: 670; letter-spacing: -.01em; } +.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: 12px; color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; } -.top-context strong { color: var(--text-soft); font-size: 12px; font-weight: 700; } +.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; } -.context-kicker { color: var(--muted-2); text-transform: uppercase; letter-spacing: .1em; font-size: 9px; } -.context-separator, .action-separator { width: 1px; height: 16px; background: var(--line-strong); flex: 0 0 auto; } -.connection-strip { gap: 9px; justify-content: flex-end; } +.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: 30px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; - border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: #0e1319; - font-size: 11px; font-weight: 620; white-space: nowrap; + 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: #64707d; } -.status-pill[data-state="online"] { color: #bee9d6; border-color: rgba(84, 211, 154, .28); background: var(--green-soft); } -.status-pill[data-state="online"] .status-dot { background: var(--green); box-shadow: 0 0 0 3px rgba(84, 211, 154, .10); } +.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: 12px; + min-width: 0; min-height: 0; padding: 8px; display: grid; - grid-template-columns: 238px minmax(500px, 1.18fr) minmax(390px, .82fr); - gap: 10px; + grid-template-columns: 190px minmax(420px,1fr) minmax(320px,355px); + gap: 8px; overflow: hidden; } @@ -111,220 +107,240 @@ button, label, summary { -webkit-tap-highlight-color: transparent; } border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); - box-shadow: 0 1px 0 rgba(255,255,255,.018) inset; + box-shadow: inset 0 1px 0 rgba(255,255,255,.014); } -.stream-pane { padding: 15px; overflow: auto; scrollbar-width: thin; scrollbar-color: #2c3642 transparent; } -.pane-heading, .injector-heading, .visual-heading { justify-content: space-between; gap: 12px; } -.pane-heading { margin-bottom: 13px; } -.eyebrow, .field-label { - color: var(--muted); font-size: 9px; font-weight: 720; text-transform: uppercase; letter-spacing: .11em; +.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; } -.pane-heading h2, .visual-heading h2 { margin-top: 2px; font-size: 16px; font-weight: 650; } -.profile-chip, .visual-badge { - height: 23px; padding: 0 7px; display: inline-flex; align-items: center; - border: 1px solid var(--line); border-radius: 5px; background: #0d1218; color: var(--muted); - font: 650 9px/1 var(--font-mono); letter-spacing: .06em; + +.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-card { - padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-small); background: var(--surface-input); +.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-card > strong { display: block; margin-top: 5px; font: 650 15px/1.2 var(--font-mono); color: var(--text); } -.stream-meta-grid { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px 12px; } -.stream-meta-grid div { min-width: 0; display: grid; gap: 3px; } -.stream-meta-grid span { color: var(--muted-2); font-size: 9px; text-transform: uppercase; letter-spacing: .07em; } -.stream-meta-grid b { color: var(--text-soft); font: 600 10px/1.2 var(--font-mono); overflow: hidden; text-overflow: ellipsis; } +.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; } -.engineering-file { margin-top: 14px; display: grid; gap: 7px; } .file-button { - height: 34px; padding: 0 10px; display: flex; align-items: center; justify-content: center; - cursor: pointer; border: 1px solid var(--line-strong); border-radius: var(--radius-small); - background: #141a22; color: var(--text-soft); font-size: 11px; font-weight: 640; - transition: border-color .12s ease, background .12s ease; + 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 { border-color: #43658b; background: #17202a; } +.file-button:hover { background: #16202a; border-color: #3a536d; } .file-button input { display: none; } -.file-state { color: var(--muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; } -.section-divider { height: 1px; margin: 14px 0; background: var(--line); } +.file-state { margin-top: 5px; color: var(--muted-2); font-size: 8px; line-height: 1.35; overflow-wrap: anywhere; } -.quick-presets { display: grid; gap: 7px; } -.quick-presets > .field-label { margin-bottom: 1px; } +.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: 44px; padding: 7px 9px; display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; - cursor: pointer; text-align: left; border: 1px solid transparent; border-radius: var(--radius-small); background: transparent; - transition: background .12s ease, border-color .12s ease; -} -.preset-button:hover { background: #151b23; border-color: var(--line); } -.preset-icon { - width: 28px; height: 28px; display: grid; place-items: center; border-radius: 6px; - background: var(--accent-soft); color: #9bc6ff; font: 700 10px/1 var(--font-mono); + 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-icon.zero { background: rgba(255,255,255,.045); color: var(--muted); } -.preset-button span:last-child { min-width: 0; display: grid; gap: 2px; } -.preset-button b { color: var(--text-soft); font-size: 11px; font-weight: 620; } -.preset-button small { color: var(--muted); font-size: 9px; } +.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-block { display: grid; gap: 9px; } -.health-heading { justify-content: space-between; } -.health-state { color: var(--muted); font: 650 9px/1 var(--font-mono); } +.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: #8ce5bd; } -.health-grid { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--line); border-radius: var(--radius-small); overflow: hidden; } -.health-grid div { min-width: 0; min-height: 51px; padding: 9px; display: grid; align-content: center; gap: 4px; background: var(--surface-input); } -.health-grid div:nth-child(odd) { border-right: 1px solid var(--line); } -.health-grid div:nth-child(-n+2) { border-bottom: 1px solid var(--line); } -.health-grid span { color: var(--muted-2); font-size: 9px; text-transform: uppercase; letter-spacing: .07em; } -.health-grid strong { color: var(--text-soft); font: 650 12px/1 var(--font-mono); font-variant-numeric: tabular-nums; } +.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: 12px; border-top: 1px solid var(--line); padding-top: 10px; } -.diagnostics-panel summary { cursor: pointer; color: var(--muted); font-size: 10px; user-select: none; } -.diagnostics-head { margin: 10px 0 6px; display: flex; align-items: center; justify-content: space-between; color: var(--muted-2); font-size: 9px; } -.text-button { padding: 0; border: 0; background: none; color: var(--accent); cursor: pointer; font-size: 9px; } -#runtimeLog { - height: 140px; margin: 0; padding: 9px; overflow: auto; border: 1px solid var(--line); border-radius: 5px; - background: #090d12; color: #8da7ba; font: 9px/1.5 var(--font-mono); white-space: pre-wrap; word-break: break-word; -} +.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: 16px; overflow: hidden; display: flex; flex-direction: column; } -.injector-heading { flex: 0 0 auto; margin-bottom: 13px; } -.injector-heading h1 { margin-top: 2px; font-size: 22px; font-weight: 620; } +.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: 28px; padding: 0 9px; display: inline-flex; align-items: center; gap: 7px; - border: 1px solid var(--line); border-radius: 6px; background: var(--surface-input); - color: var(--muted); font: 680 9px/1 var(--font-mono); letter-spacing: .08em; + 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: #65717d; } -.run-state[data-state="running"] { border-color: rgba(84,211,154,.28); background: var(--green-soft); color: #a8e9cd; } -.run-state[data-state="running"] .run-dot { background: var(--green); box-shadow: 0 0 0 3px rgba(84,211,154,.10); } +.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); } -.frequency-strip { - flex: 0 0 auto; min-height: 58px; padding: 8px 10px; display: grid; - grid-template-columns: 142px minmax(100px, 1fr) auto; gap: 16px; - border: 1px solid var(--line); border-radius: var(--radius-small); background: var(--surface-input); +.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); } -.frequency-main { min-width: 0; display: grid; gap: 1px; } -.frequency-value { gap: 5px; } -.frequency-value input { - width: 82px; padding: 0; border: 0; outline: 0; background: transparent; color: var(--text); - font: 640 18px/1.2 var(--font-mono); font-variant-numeric: tabular-nums; +.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; } -.frequency-value span { color: var(--muted); font-size: 10px; } -.frequency-slider { width: 100%; accent-color: var(--accent); } -.switch-row { gap: 8px; color: var(--text-soft); font-size: 10px; cursor: pointer; white-space: nowrap; } +.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: 30px; height: 17px; position: relative; border-radius: 999px; background: #202832; border: 1px solid #34404d; transition: .12s ease; } -.switch::after { content: ""; position: absolute; width: 9px; height: 9px; top: 3px; left: 3px; border-radius: 50%; background: #788492; transition: .12s ease; } -.switch-row input:checked + .switch { background: rgba(84,211,154,.16); border-color: rgba(84,211,154,.34); } -.switch-row input:checked + .switch::after { left: 16px; background: var(--green); } +.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); } -.injection-table { flex: 1 1 auto; min-height: 0; margin-top: 12px; border: 1px solid var(--line); border-radius: var(--radius-small); overflow: hidden; background: #0e1319; } -.grid-head, .channel-row { - display: grid; - grid-template-columns: 40px minmax(84px, .85fr) minmax(135px, 1.2fr) minmax(122px, 1fr) minmax(96px, .85fr) 54px; - align-items: center; +.signal-matrices { + flex: 1 1 auto; min-height: 0; margin-top: 8px; + display: grid; grid-template-columns: 1fr 1fr; gap: 8px; +} +.signal-group { + min-width: 0; min-height: 0; overflow: hidden; + border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); + display: flex; flex-direction: column; +} +.group-heading { + flex: 0 0 auto; 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; } -.grid-head { - min-height: 32px; padding: 0 8px; background: #0c1117; border-bottom: 1px solid var(--line); - color: var(--muted-2); font-size: 8px; font-weight: 720; letter-spacing: .1em; text-transform: uppercase; +.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; } -.channel-grid { min-height: 0; } +.matrix-head { + flex: 0 0 auto; 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 { flex: 1 1 auto; min-height: 0; } .channel-row { - min-height: 47px; padding: 0 8px; border-bottom: 1px solid #1d242d; - transition: background .09s ease, opacity .09s ease; + min-height: 51px; height: 25%; 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: #121921; } -.channel-row[data-active="true"] { background: #141c25; box-shadow: 2px 0 0 var(--accent) inset; } -.channel-row[data-enabled="false"] { opacity: .48; } -.channel-name { min-width: 0; gap: 8px; } -.phase-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--phase-color); flex: 0 0 auto; } -.channel-name-copy { min-width: 0; display: grid; gap: 1px; } -.channel-name strong { color: var(--text); font: 650 12px/1 var(--font-mono); } -.channel-name small { overflow: hidden; color: var(--muted-2); font-size: 8px; white-space: nowrap; text-overflow: ellipsis; } -.enable-toggle { width: 28px; height: 18px; display: grid; place-items: center; cursor: pointer; } +.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: 25px; height: 14px; position: relative; border-radius: 999px; background: #202832; border: 1px solid #34404d; } -.enable-toggle span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 8px; height: 8px; border-radius: 50%; background: #707c89; transition: .1s; } -.enable-toggle input:checked + span { background: rgba(101,168,255,.12); border-color: rgba(101,168,255,.32); } -.enable-toggle input:checked + span::after { left: 13px; background: #8fc0ff; } -.value-cell { min-width: 0; padding-right: 9px; } +.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: 31px; padding: 0 36px 0 8px; outline: 0; border: 1px solid #29333f; - border-radius: 5px; background: #0b1016; color: var(--text); font: 620 12px/1 var(--font-mono); - font-variant-numeric: tabular-nums; transition: border-color .1s ease, background .1s ease, box-shadow .1s ease; + width: 100%; height: 31px; 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:hover { border-color: #364453; } -.input-unit input:focus { border-color: #4d79a8; background: #0d141c; box-shadow: 0 0 0 2px rgba(101,168,255,.08); } -.input-unit span { position: absolute; right: 7px; top: 50%; transform: translateY(-50%); color: var(--muted-2); font-size: 8px; pointer-events: none; } -.wire-preview { min-width: 0; overflow: hidden; color: var(--muted-2); font: 500 8px/1.35 var(--font-mono); white-space: nowrap; text-overflow: ellipsis; } -.channel-apply { - width: 44px; min-height: 27px !important; padding: 0 !important; justify-self: end; - color: var(--muted) !important; font-size: 9px !important; +.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); } -.channel-row[data-active="true"] .channel-apply:not(:disabled) { color: #a7ccfa !important; border-color: #355474 !important; } +.input-unit span { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); color: var(--muted-2); font-size: 7px; pointer-events: none; } -.grid-hint { - flex: 0 0 auto; min-height: 32px; display: flex; align-items: center; gap: 13px; overflow: hidden; - color: var(--muted-2); font-size: 9px; white-space: nowrap; +.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; } -kbd { min-width: 21px; padding: 2px 5px; border: 1px solid #303a45; border-bottom-color: #3e4a57; border-radius: 4px; background: #141a21; color: #9aa6b3; font: 600 8px/1 var(--font-mono); } +.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; } -.visual-pane { min-width: 0; min-height: 0; display: grid; grid-template-rows: 1fr .92fr; gap: 10px; } -.visual-card { padding: 13px; display: flex; flex-direction: column; overflow: hidden; } -.visual-heading { flex: 0 0 auto; margin-bottom: 8px; } -.visual-card canvas { - width: 100%; flex: 1 1 auto; min-height: 0; display: block; border: 1px solid #1d2630; border-radius: 6px; - background: #0b1016; -} -.visual-footer { flex: 0 0 auto; min-height: 24px; gap: 12px; overflow: hidden; color: var(--muted-2); font-size: 8px; white-space: nowrap; } -.visual-footer > span:last-child { margin-left: auto; overflow: hidden; text-overflow: ellipsis; } -.legend-line { width: 15px; height: 2px; margin-right: 4px; display: inline-block; vertical-align: middle; background: #8fa8c0; } -.legend-line.current { height: 1px; opacity: .62; } -.waveform-footer { justify-content: space-between; } -.waveform-footer > span:last-child { margin-left: 0; font-family: var(--font-mono); } +.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 12px 0 17px; display: grid; grid-template-columns: minmax(250px, 1fr) auto minmax(300px, 1fr); - align-items: center; gap: 18px; border-top: 1px solid var(--line); background: var(--chrome); z-index: 12; + 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; } -.readiness-strip { min-width: 0; gap: 9px; } -.ready-dot { width: 8px; height: 8px; border-radius: 50%; background: #596471; box-shadow: 0 0 0 4px rgba(89,100,113,.08); } -.readiness-strip > div { min-width: 0; display: grid; gap: 2px; } -.readiness-strip strong { color: var(--text-soft); font: 690 9px/1 var(--font-mono); letter-spacing: .07em; } -.readiness-strip span:last-child { overflow: hidden; color: var(--muted); font-size: 9px; white-space: nowrap; text-overflow: ellipsis; } -body[data-connected="true"] .ready-dot { background: var(--green); box-shadow: 0 0 0 4px rgba(84,211,154,.08); } -body[data-running="true"] .ready-dot { background: #75e0ae; box-shadow: 0 0 0 4px rgba(84,211,154,.12); } -.action-context { justify-content: center; gap: 10px; color: var(--muted); font: 520 9px/1 var(--font-mono); white-space: nowrap; } -.primary-actions { justify-content: flex-end; gap: 7px; } +.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; } .btn { - min-height: 32px; padding: 0 11px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; - cursor: pointer; border: 1px solid var(--line-strong); border-radius: 6px; background: #151b22; - color: var(--text-soft); font-size: 10px; font-weight: 650; transition: background .1s ease, border-color .1s ease, opacity .1s ease, transform .08s ease; + 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: #19212a; border-color: #43505e; } +.btn:hover:not(:disabled) { background: #18212b; border-color: #41505f; } .btn:active:not(:disabled) { transform: translateY(1px); } -.btn:disabled { opacity: .34; cursor: not-allowed; } -.btn.compact { min-height: 30px; } -.btn.action { min-width: 82px; min-height: 36px; } -.btn.start { min-width: 116px; border-color: #34795b; background: #184b37; color: #d2f5e5; } -.btn.start:hover:not(:disabled) { background: #1b5940; border-color: #469972; } -.btn.stop { border-color: rgba(255,111,124,.28); background: var(--red-soft); color: #ffc2c7; } -body[data-running="true"] .btn.start { opacity: .45; } +.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: 18px; bottom: 72px; z-index: 40; max-width: 380px; padding: 10px 12px; - transform: translateY(8px); opacity: 0; pointer-events: none; border: 1px solid #365778; border-radius: 6px; - background: #121a23; color: #dbe9f7; box-shadow: 0 12px 32px rgba(0,0,0,.32); font-size: 10px; - transition: opacity .14s ease, transform .14s ease; + 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,111,124,.38); color: #ffd1d5; background: #211419; } +.toast.error { border-color: rgba(255,112,124,.36); color: #ffd2d6; background: #211419; } -::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar { width: 7px; height: 7px; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: #27313d; border: 2px solid transparent; background-clip: padding-box; border-radius: 999px; } -::-webkit-scrollbar-thumb:hover { background: #34404d; border: 2px solid transparent; background-clip: padding-box; } +::-webkit-scrollbar-thumb { background: #27323e; border: 2px solid transparent; background-clip: padding-box; border-radius: 999px; } From debcd9556ef040dbd2406cd2a54b5d822832f979 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:18:15 +0700 Subject: [PATCH 08/24] Keep signal preview beside injector on laptop widths --- apps/smv_injector_gui/responsive.css | 91 +++++++++++++++------------- 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/apps/smv_injector_gui/responsive.css b/apps/smv_injector_gui/responsive.css index 40af9cee3..75d367b12 100644 --- a/apps/smv_injector_gui/responsive.css +++ b/apps/smv_injector_gui/responsive.css @@ -1,74 +1,81 @@ -/* Premium cockpit responsiveness: preserve the no-scroll primary workflow on engineering laptops, - then progressively stack visual verification without hiding controls. */ +/* Keep the injector + signal preview side-by-side for normal engineering laptop widths. + Stack only when the viewport is genuinely narrow. */ -@media (max-width: 1320px) { - .cockpit { grid-template-columns: 220px minmax(470px, 1.12fr) minmax(350px, .78fr); } +@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 .context-kicker { display: none; } - .grid-head, .channel-row { - grid-template-columns: 36px minmax(76px,.8fr) minmax(126px,1.18fr) minmax(116px,1fr) minmax(82px,.7fr) 50px; - } - .grid-hint span:last-child { display: none; } + .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: 1120px) { +@media (max-width: 1010px) { body { overflow: auto; } .app-shell { height: auto; min-height: 100vh; grid-template-rows: var(--topbar) auto var(--actionbar); } - .topbar { grid-template-columns: minmax(210px, auto) minmax(0, 1fr) auto; gap: 12px; } - .top-context span:not(.context-separator):not(:nth-last-child(1)), .top-context .context-separator { display: none; } + .top-context span:nth-of-type(2), .top-context .context-separator { display: none; } .cockpit { - min-height: 790px; - grid-template-columns: 220px minmax(0, 1fr); - grid-template-rows: minmax(500px, 1fr) 300px; + min-height: 760px; + grid-template-columns: 176px minmax(0,1fr); + grid-template-rows: minmax(500px,1fr) 300px; overflow: visible; } - .stream-pane { grid-column: 1; grid-row: 1; } + .source-pane { grid-column: 1; grid-row: 1; } .injector-pane { grid-column: 2; grid-row: 1; } - .visual-pane { grid-column: 1 / -1; grid-row: 2; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; } - .visual-card canvas { min-height: 205px; } + .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: 820px) { +@media (max-width: 760px) { :root { --topbar: auto; --actionbar: auto; } .app-shell { display: block; min-height: 100vh; } .topbar { - min-height: 58px; padding: 10px 12px; display: grid; + 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: 24px; justify-content: flex-start; border-top: 1px solid var(--line); padding-top: 7px; } - .cockpit { min-height: 0; padding: 8px; display: grid; grid-template-columns: 1fr; grid-template-rows: auto auto auto; gap: 8px; } - .stream-pane, .injector-pane, .visual-pane { grid-column: 1; grid-row: auto; } - .stream-pane { overflow: visible; } - .visual-pane { display: grid; grid-template-columns: 1fr; grid-template-rows: 320px 300px; } - .frequency-strip { grid-template-columns: 130px 1fr; } - .switch-row { grid-column: 1 / -1; } - .injection-table { overflow-x: auto; } - .grid-head, .channel-row { min-width: 610px; } - .grid-hint { display: none; } + .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: 255px; } + .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: 68px; padding: 8px 10px; - grid-template-columns: minmax(0, 1fr) auto; gap: 10px; + 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: 5px; } - .btn.action { min-width: 66px; } - .btn.start { min-width: 96px; } + .primary-actions { gap: 4px; } + .btn.action { min-width: 64px; } + .btn.start { min-width: 92px; } } -@media (max-width: 560px) { +@media (max-width: 520px) { .connection-strip .status-pill { display: none; } .top-context { overflow-x: auto; } - .stream-meta-grid { grid-template-columns: 1fr 1fr; } - .injector-pane { padding: 12px; } - .frequency-strip { grid-template-columns: 1fr; gap: 8px; } - .frequency-slider { grid-row: 2; } - .switch-row { grid-row: 3; grid-column: 1; } + .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; } - .visual-pane { grid-template-rows: 290px 270px; } } From f759635c2bb780e134945a166c7d95747cf8f09d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:20:31 +0700 Subject: [PATCH 09/24] Add arrow-key matrix navigation and linked three-phase editing --- apps/smv_injector_gui/app.js | 533 +++++++++++++++++++++-------------- 1 file changed, 322 insertions(+), 211 deletions(-) diff --git a/apps/smv_injector_gui/app.js b/apps/smv_injector_gui/app.js index fc6354ee3..cbec54012 100644 --- a/apps/smv_injector_gui/app.js +++ b/apps/smv_injector_gui/app.js @@ -11,6 +11,12 @@ const CHANNELS = [ { 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 = { port: null, reader: null, @@ -18,8 +24,10 @@ 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(), }; @@ -27,55 +35,44 @@ const state = { 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", "waveformCanvas", "toast", "sclFile", "sclState", - "systemReadiness", "footerReadiness", "footerReadinessDetail", "footerRate", - "waveformFrequencyLabel" + "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 displayId(id) { return `${id[0]}${id[1].toLowerCase()}`; } - -function formatMagnitude(channel) { - return Number(channel.magnitude).toFixed(3); -} - -function formatPhase(channel) { - return Number(channel.phase).toFixed(2); -} - 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"), 2200); + 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; } @@ -95,25 +92,24 @@ function updateReadiness() { els.systemReadiness.dataset.state = "ready"; els.systemReadiness.textContent = "READY"; els.footerReadiness.textContent = "READY"; - els.footerReadinessDetail.textContent = "Device connected · profile ready for live injection"; + 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 = "Connect ESP32-P4 to arm live injection"; + 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 ? "ESP32-P4 connected" : "Device offline"; - els.connectButton.textContent = connected ? "Disconnect" : "Connect device"; + els.connectButton.textContent = connected ? "Disconnect" : "Connect"; els.startButton.disabled = !connected || state.running; els.stopButton.disabled = !connected || !state.running; els.applyAllButton.disabled = !connected; - document.querySelectorAll(".channel-apply").forEach((button) => { button.disabled = !connected; }); updateReadiness(); } @@ -126,130 +122,236 @@ function setRunning(running) { updateReadiness(); } -function setActiveChannel(id) { - state.activeChannel = id; - document.querySelectorAll(".channel-row").forEach((row) => { - row.dataset.active = String(row.dataset.channel === id); - }); - scheduleVisualUpdate(); -} - -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" : "V"; - const magStep = channel.kind === "current" ? "0.001" : "0.001"; - const magMax = channel.kind === "current" ? "10000" : "1000000"; - - const row = document.createElement("div"); - row.className = "channel-row"; - row.setAttribute("role", "row"); - row.dataset.channel = channel.id; - row.dataset.enabled = String(channel.enabled); - row.dataset.active = String(channel.id === state.activeChannel); - row.style.setProperty("--phase-color", channel.color); - row.innerHTML = ` - -
- - ${displayId(channel.id)}${channel.label} -
-
-
- - ${unit} -
+ 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}
-
-
- - ° -
+
+ OnChMagnitudePhase
- - `; +
`; - els.channelGrid.appendChild(row); - bindChannelRow(row, channel.id); - updateWirePreview(row, 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 readRowValues(row, id) { +function buildChannelRow(id) { const channel = state.channels.get(id); - const magnitude = Number(row.querySelector(".magnitude-input").value); - const phase = Number(row.querySelector(".phase-input").value); - const enabled = row.querySelector(".channel-enable").checked; - - if (!Number.isFinite(magnitude) || magnitude < 0 || !Number.isFinite(phase)) return false; - - channel.magnitude = magnitude; - channel.phase = phase; - channel.enabled = enabled; - row.dataset.enabled = String(enabled); - updateWirePreview(row, channel); - scheduleVisualUpdate(); - 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(row, channel) { - row.querySelector(".wire-preview").textContent = `${wireCountFor(channel)} · ${phaseMdeg(channel)}m°`; + bindChannelRow(row, id); + return row; } function bindChannelRow(row, id) { - const magnitude = row.querySelector(".magnitude-input"); - const phase = row.querySelector(".phase-input"); const enable = row.querySelector(".channel-enable"); - const apply = row.querySelector(".channel-apply"); + const inputs = [...row.querySelectorAll(".nav-input")]; - const schedule = debounce(async () => { - if (!readRowValues(row, id)) return; - if (state.liveApply && state.connected) await applyChannel(id); - }, 110); + row.addEventListener("pointerdown", () => setActiveChannel(id, state.activeField)); - const activate = () => setActiveChannel(id); - row.addEventListener("pointerdown", activate); - magnitude.addEventListener("focus", activate); - phase.addEventListener("focus", activate); - magnitude.addEventListener("input", schedule); - phase.addEventListener("input", schedule); - - for (const input of [magnitude, phase]) { - input.addEventListener("dblclick", () => input.select()); - input.addEventListener("keydown", async (event) => { - if (event.key !== "Enter") return; - if (!readRowValues(row, id)) return; - if (state.connected) { - event.preventDefault(); - await applyChannel(id); - showToast(`${displayId(id)} applied`); - } + 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)); } enable.addEventListener("change", async () => { - if (!readRowValues(row, 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}`); }); +} + +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; - apply.addEventListener("click", async () => { - if (!readRowValues(row, id)) return; - await applyChannel(id); - showToast(`${displayId(id)} applied`); + 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) { @@ -281,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 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; } @@ -376,7 +478,7 @@ 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]}`; + els.fpsMetric.textContent = timing[2]; els.failMetric.textContent = timing[4]; els.missedMetric.textContent = timing[5]; els.generationMetric.textContent = timing[6]; @@ -388,14 +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); els.waveformFrequencyLabel.textContent = `${hz.toFixed(3)} Hz`; - if (hz >= 45 && hz <= 65) els.frequencySlider.value = String(hz); scheduleVisualUpdate(); - if (transmit && state.liveApply && state.connected) { - sendCommand(`FREQ ${frequencyMhz()}`); - } + if (transmit && state.liveApply && state.connected) sendCommand(`FREQ ${frequencyMhz()}`); } function setBalanced() { @@ -411,19 +511,28 @@ function setBalanced() { channel.enabled = true; } - buildChannels(); + 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(); + buildMatrices(); scheduleVisualUpdate(); if (state.connected) await sendCommand("ZERO"); showToast(state.connected ? "Outputs zeroed" : "Local setpoints zeroed"); } +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; @@ -431,10 +540,11 @@ function scheduleVisualUpdate() { visualFramePending = false; drawPhasors(); drawWaveforms(); + updateActiveDetails(state.channels.get(state.activeChannel)); }); } -function prepareCanvas(canvas, minWidth = 280, minHeight = 180) { +function prepareCanvas(canvas, minWidth = 260, minHeight = 150) { const ctx = canvas.getContext("2d"); const rect = canvas.getBoundingClientRect(); const dpr = Math.min(2, window.devicePixelRatio || 1); @@ -453,25 +563,25 @@ function prepareCanvas(canvas, minWidth = 280, minHeight = 180) { } function drawPhasors() { - const { ctx, w, h } = prepareCanvas(els.phasorCanvas, 320, 220); + const { ctx, w, h } = prepareCanvas(els.phasorCanvas, 280, 210); const cx = w * 0.5; const cy = h * 0.52; - const radius = Math.max(62, Math.min(w * 0.38, h * 0.36)); + const radius = Math.max(58, Math.min(w * 0.36, h * 0.36)); ctx.save(); + ctx.strokeStyle = "rgba(139,154,170,.11)"; ctx.lineWidth = 1; - ctx.strokeStyle = "rgba(139,154,170,.13)"; - for (const factor of [0.33, 0.66, 1]) { + for (const factor of [.33, .66, 1]) { ctx.beginPath(); ctx.arc(cx, cy, radius * factor, 0, Math.PI * 2); ctx.stroke(); } - ctx.strokeStyle = "rgba(139,154,170,.10)"; - for (const deg of [0, 30, 60, 90, 120, 150]) { + for (let deg = 0; deg < 180; deg += 30) { const a = deg * Math.PI / 180; - const dx = Math.cos(a) * (radius + 10); - const dy = Math.sin(a) * (radius + 10); + 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); @@ -479,28 +589,27 @@ function drawPhasors() { } const groups = [ - { ids: ["UA", "UB", "UC"], radius: 1, width: 2.2, dash: [] }, - { ids: ["IA", "IB", "IC"], radius: .82, width: 1.4, dash: [5, 4] }, + { ids: ["UA", "UB", "UC", "UN"], scale: 1, width: 2.0, dash: [] }, + { ids: ["IA", "IB", "IC", "IN"], scale: .78, width: 1.2, dash: [4, 3] }, ]; for (const group of groups) { - const maxMagnitude = Math.max(0.0001, ...group.ids.map((id) => state.channels.get(id).magnitude)); + 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 angle = channel.phase * Math.PI / 180; + const active = id === state.activeChannel; const ratio = Math.min(1, channel.magnitude / maxMagnitude); - const length = radius * group.radius * (.35 + .65 * ratio); + const length = radius * group.scale * (.32 + .68 * ratio); + const angle = channel.phase * Math.PI / 180; const x = cx + Math.cos(angle) * length; const y = cy - Math.sin(angle) * length; - const active = state.activeChannel === id; ctx.save(); - ctx.globalAlpha = active ? 1 : .62; + ctx.globalAlpha = active ? 1 : .48; ctx.strokeStyle = channel.color; ctx.fillStyle = channel.color; - ctx.lineWidth = active ? group.width + .9 : group.width; + ctx.lineWidth = active ? group.width + 1 : group.width; ctx.setLineDash(group.dash); ctx.beginPath(); ctx.moveTo(cx, cy); @@ -508,51 +617,51 @@ function drawPhasors() { ctx.stroke(); ctx.setLineDash([]); - const head = active ? 8 : 6; + const head = active ? 7 : 5; ctx.beginPath(); ctx.moveTo(x, y); - ctx.lineTo(x - Math.cos(angle - .44) * head, y + Math.sin(angle - .44) * head); - ctx.lineTo(x - Math.cos(angle + .44) * head, y + Math.sin(angle + .44) * head); + 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(); - ctx.font = `${active ? 650 : 560} 10px ${getComputedStyle(document.body).fontFamily}`; - ctx.fillText(displayId(id), x + 7, y - 6); + if (active) { + ctx.font = "650 9px system-ui"; + ctx.fillText(displayId(id), x + 7, y - 6); + } ctx.restore(); } } - ctx.fillStyle = "rgba(199,208,218,.52)"; - ctx.font = "500 9px system-ui"; - ctx.fillText("0°", cx + radius + 12, cy + 3); - ctx.fillText("90°", cx + 5, cy - radius - 10); - ctx.fillText("180°", cx - radius - 35, cy + 3); - ctx.fillText("270°", cx + 5, cy + radius + 15); - - ctx.fillStyle = "rgba(238,242,246,.28)"; + 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.2, 0, Math.PI * 2); + ctx.arc(cx, cy, 2, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function drawWaveforms() { - const { ctx, w, h } = prepareCanvas(els.waveformCanvas, 320, 190); - const pad = { left: 34, right: 10, top: 18, bottom: 17 }; + 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 = 18; + 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.strokeStyle = "rgba(139,154,170,.10)"; 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); @@ -560,35 +669,34 @@ function drawWaveforms() { } for (const y of [voltageY, currentY]) { - ctx.strokeStyle = "rgba(139,154,170,.18)"; + 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,.70)"; - ctx.font = "600 8px system-ui"; - ctx.fillText("V", 14, voltageY + 3); - ctx.fillText("I", 14, currentY + 3); + 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(0.0001, ...ids.map((id) => state.channels.get(id).magnitude)); + 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 = state.activeChannel === id; - const normalized = Math.min(1, channel.magnitude / maxMagnitude); - const amp = amplitude * normalized; + 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 ? .98 : .46; - ctx.lineWidth = active ? 1.8 : 1.15; + ctx.globalAlpha = active ? .96 : .38; + ctx.lineWidth = active ? 1.7 : 1.05; if (dashed) ctx.setLineDash([4, 3]); ctx.beginPath(); - const points = Math.max(180, Math.floor(plotW)); + const points = Math.max(150, Math.floor(plotW)); for (let i = 0; i <= points; i += 1) { const ratio = i / points; const t = windowSeconds * ratio; @@ -603,16 +711,15 @@ function drawWaveforms() { } }; - drawGroup(["UA", "UB", "UC"], voltageY, laneH * .39, false); - drawGroup(["IA", "IB", "IC"], currentY, laneH * .39, true); + drawGroup(["UA", "UB", "UC", "UN"], voltageY, laneH * .39, false); + drawGroup(["IA", "IB", "IC", "IN"], currentY, laneH * .39, true); - ctx.fillStyle = "rgba(127,139,153,.52)"; - ctx.font = "500 8px system-ui"; + 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(`${ms}`, x - (ms === 0 ? 0 : 5), h - 5); + ctx.fillText(String(ms), x - (ms === 0 ? 0 : 4), h - 4); } - ctx.fillText("ms", w - 18, h - 5); ctx.restore(); } @@ -623,25 +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); - }, 120); - + if (Number.isFinite(hz)) setFrequencyUi(hz, true); + }, 110); + els.frequencyInput.addEventListener("focus", () => requestAnimationFrame(() => els.frequencyInput.select())); els.frequencyInput.addEventListener("input", frequencyDebounced); - els.frequencyInput.addEventListener("dblclick", () => els.frequencyInput.select()); - els.frequencySlider.addEventListener("input", () => { - const hz = Number(els.frequencySlider.value); - setFrequencyUi(hz, true); - }); els.sclFile.addEventListener("change", async () => { const file = els.sclFile.files?.[0]; @@ -662,19 +766,26 @@ function bindUi() { } }); - window.addEventListener("resize", debounce(scheduleVisualUpdate, 70)); - 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); + setActiveChannel("UA", "magnitude"); scheduleVisualUpdate(); if (!("serial" in navigator)) { From 649417a3d501f681f6cc21616eafa8886211ba43 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:22:13 +0700 Subject: [PATCH 10/24] Keep injection rows compact at tall viewport sizes --- apps/smv_injector_gui/styles.css | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/smv_injector_gui/styles.css b/apps/smv_injector_gui/styles.css index f225c3af3..75f6e6b2e 100644 --- a/apps/smv_injector_gui/styles.css +++ b/apps/smv_injector_gui/styles.css @@ -222,15 +222,14 @@ h1, h2 { margin: 0; letter-spacing: -.025em; } .signal-matrices { flex: 1 1 auto; min-height: 0; margin-top: 8px; - display: grid; grid-template-columns: 1fr 1fr; gap: 8px; + display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-items: start; } .signal-group { - min-width: 0; min-height: 0; overflow: hidden; + min-width: 0; overflow: hidden; align-self: start; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); - display: flex; flex-direction: column; } .group-heading { - flex: 0 0 auto; min-height: 34px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px; + 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; } @@ -241,12 +240,12 @@ h1, h2 { margin: 0; letter-spacing: -.025em; } display: grid; grid-template-columns: 27px 48px minmax(88px,1fr) minmax(76px,.82fr); align-items: center; } .matrix-head { - flex: 0 0 auto; min-height: 26px; padding: 0 6px; border-bottom: 1px solid var(--line); + 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 { flex: 1 1 auto; min-height: 0; } +.channel-list { min-height: 0; } .channel-row { - min-height: 51px; height: 25%; padding: 0 6px; border-bottom: 1px solid #1b242e; + 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; } @@ -265,7 +264,7 @@ h1, h2 { margin: 0; letter-spacing: -.025em; } .value-cell { min-width: 0; padding-right: 5px; } .input-unit { position: relative; min-width: 0; } .input-unit input { - width: 100%; height: 31px; padding: 0 24px 0 7px; outline: 0; + 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; From 5d66d5b686df0303336a1a5bf3526492c47fc788 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:23:47 +0700 Subject: [PATCH 11/24] Document v2 keyboard-first injector review points --- apps/smv_injector_gui/PREMIUM_UX_REVIEW.md | 102 +++++++++++---------- 1 file changed, 56 insertions(+), 46 deletions(-) diff --git a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md index 3456f50f0..a607ebae5 100644 --- a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md +++ b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md @@ -1,66 +1,76 @@ -# Premium UX review build +# Premium UX review — ESP32-P4 SMV Injector -This branch is intentionally an evaluation build for the ESP32-P4 SMV Injector GUI. It does not change the firmware command protocol and it is not intended to be merged until the operator workflow and visual direction are approved. +This branch is intentionally an evaluation surface. Do not merge it into the production GUI until the injection workflow and visual direction are approved. -## Design target +## V2 direction after first visual review -- premium, clean engineering-instrument appearance rather than a dashboard -- no-scroll primary workflow on normal engineering laptops -- fast 4I + 4V value entry in a compact precision grid -- persistent generated phasor and waveform visualization -- clear separation between generated setpoint preview and future on-wire observation -- device/profile/runtime status always visible -- Start/Stop always in one fixed location -- minimal visual effects and no heavy chart dependencies +The first cockpit prototype still behaved too much like a web form: one long 8-channel table, per-row Apply buttons, wire-value clutter, a large frequency slider, and responsive stacking that moved the phasor/waveform below the injector on normal laptop widths. -## Primary workflow +V2 changes the working model rather than only polishing colors: -1. Connect the ESP32-P4. -2. Optionally load SCL/CID engineering data. -3. Edit magnitude, phase, and frequency directly in the injection grid. -4. Confirm the generated phasor and 40 ms waveform preview. -5. Keep Live apply enabled for immediate updates or disable it and use Apply / Apply all. -6. Press Start live. -7. Observe runtime rate, missed slots, TX failures, and generation counter. -8. Press Stop from the fixed action bar. +- Current and Voltage are separate 4-row matrices shown side-by-side. +- Magnitude and Phase are the only primary numeric columns. +- Wire counts/mdeg remain available in the selected-value detail instead of occupying a permanent column. +- Per-row Apply buttons are removed; Live Apply is the normal fast path and `Enter` commits when Live Apply is paused. +- Phasor and waveform remain beside the injector down to approximately 1010 px viewport width. +- Frequency is a compact direct-entry value with 50/60 Hz presets; the large slider is removed. +- A 3-phase link mode can couple A/B/C magnitude and preserve 120-degree phase relationships while editing. -Balanced and Zero can be used while disconnected so a test state can be prepared and visually reviewed before the device is armed. +## Keyboard-first matrix navigation -## Visualization truthfulness +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 phasor and waveform are labelled **Generated / SETPOINT**. They are calculated from the configured magnitude, phase, enable state, and frequency. They show what the injector is configured to generate; they are not presented as proof of frames observed on the Ethernet wire. +The navigation wraps through `Ia → Ib → Ic → In → Ua → Ub → Uc → Un`, so repeated arrow-key operation never requires reaching for the mouse. -A later monitor path can add an **Observed** trace and generated-vs-observed comparison without changing the main cockpit layout. +## Primary workflow to evaluate -## Keyboard / fast-entry behavior +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. -- `Tab` moves through value fields naturally. -- Double-click selects the complete numeric value. -- `Enter` applies the active channel when a device is connected. -- Number inputs retain native arrow-key fine adjustment. -- Clicking/focusing a row makes that channel visually dominant in the phasor and waveform previews. +## Visual intent -## Performance approach +The target is a precision engineering instrument, not a dashboard and not a gaming UI: -The prototype remains dependency-free HTML/CSS/JavaScript. Visualization uses two canvas surfaces and redraws only on state changes or resize through `requestAnimationFrame`. Device serial traffic and the firmware realtime publisher remain unchanged. +- compact hierarchy +- graphite surfaces +- restrained accent color +- small tabular numeric typography +- thin separators +- minimal radius and shadow +- no glass blur or decorative glow +- selected signal color is used for data identity, not decoration +- phasor and waveform are part of the working surface, not afterthought cards -## Review focus +## Visualization truthfulness -Please judge this PR primarily on: +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. -- visual quality / premium feel -- density and readability -- speed of the injection workflow -- usefulness of persistent phasor + waveform -- whether profile/status information is visible without competing with the injection task -- whether the layout is a good visual foundation for the later Qt/QML desktop implementation +## Review checklist -## Run +Please judge the branch on these questions: -Use the existing launcher from the repository root: +- Can an engineer change many Ia/Ib/Ic/Ua/Ub/Uc values without touching the mouse? +- Can the operator understand the active injection in one glance? +- Are Current and Voltage easy to compare independently? +- Do phasor and waveform remain visible at the laptop viewport actually used on the bench? +- Is the UI calm and premium rather than card-heavy or browser-like? +- Is Start/Stop always obvious without dominating the entire screen? +- Is secondary protocol information available without slowing the primary workflow? -```powershell -.\apps\smv_injector_gui\run.cmd -``` +## Scope -The same Web Serial requirements and firmware compatibility rules as the existing GUI apply. +GUI only. No firmware timing, SV packet layout, or serial command protocol is changed by this evaluation branch. From 3d9c61ccc9dcdce0e8fb6351927160af2b149b62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:24:18 +0700 Subject: [PATCH 12/24] Align keyboard hint with matrix controls --- apps/smv_injector_gui/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index 023ac7039..07df61c11 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -57,7 +57,7 @@

MU01_SV1

Open SCL / CID -
Development profile
+
Built-in profile · ready for SCL import
@@ -142,8 +142,8 @@

Manual Injection

←↑↓→ move - Enter next / apply - +− step + Enter next / commit + Ctrl+↑↓ step
From c5aa82421d0a01138741e4e08e09514855211cd1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:25:21 +0700 Subject: [PATCH 13/24] Clarify no-merge evaluation status --- apps/smv_injector_gui/PREMIUM_UX_REVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md index a607ebae5..23a9162ca 100644 --- a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md +++ b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md @@ -73,4 +73,4 @@ Please judge the branch on these questions: ## Scope -GUI only. No firmware timing, SV packet layout, or serial command protocol is changed by this evaluation branch. +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. From 11661be8f94d732f50bbec91c958fd1f29ca3960 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:25:38 +0700 Subject: [PATCH 14/24] Tune narrow-layout matrix sizing --- apps/smv_injector_gui/responsive.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/smv_injector_gui/responsive.css b/apps/smv_injector_gui/responsive.css index 75d367b12..766a9496b 100644 --- a/apps/smv_injector_gui/responsive.css +++ b/apps/smv_injector_gui/responsive.css @@ -51,7 +51,7 @@ .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: 255px; } + .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; } From 323134b7ba241162f86922fdd1f2f0d1a7bf791a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:25:56 +0700 Subject: [PATCH 15/24] Add explicit visual review goals --- apps/smv_injector_gui/PREMIUM_UX_REVIEW.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md index 23a9162ca..c7a69fe96 100644 --- a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md +++ b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md @@ -55,6 +55,8 @@ The target is a precision engineering instrument, not a dashboard and not a gami - selected signal color is used for data identity, not decoration - phasor and waveform are part of the working surface, not afterthought cards +The screen should feel comfortable during repeated bench work: the eye should land first on Current/Voltage values, then immediately on the phasor/waveform result, while source metadata and diagnostics remain quiet in the periphery. + ## 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. From 84b7f36a442cda61536a0878e6f918e64ca7f025 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:26:28 +0700 Subject: [PATCH 16/24] Note latest v2 review status --- apps/smv_injector_gui/PREMIUM_UX_REVIEW.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md index c7a69fe96..11f872722 100644 --- a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md +++ b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md @@ -76,3 +76,5 @@ Please judge the branch on these questions: ## 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. + +Latest review iteration: V2 keyboard-first matrix + side-by-side signal preview. Please evaluate this version before any merge decision. From b8e5fa8ddb7e7abe96181bcb0a8c2b9f9c3c8a89 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:26:47 +0700 Subject: [PATCH 17/24] Finalize v2 review notes --- apps/smv_injector_gui/PREMIUM_UX_REVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md index 11f872722..fcefd202d 100644 --- a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md +++ b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md @@ -77,4 +77,4 @@ Please judge the branch on these questions: 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. -Latest review iteration: V2 keyboard-first matrix + side-by-side signal preview. Please evaluate this version before any merge decision. +Latest review iteration: **V2 keyboard-first matrix + side-by-side signal preview**. Evaluate this version before any merge decision. From 9d201ae8b98650844387c7db7e5dd1553f6b3c42 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:27:03 +0700 Subject: [PATCH 18/24] Keep review notes synced with v2 From addbd6f947263497174f94f1b6de99eebfd60847 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:27:21 +0700 Subject: [PATCH 19/24] No-op sync review note From aeb0c2aebf3da39ba823becce19710250c20852a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:59:40 +0700 Subject: [PATCH 20/24] Polish premium calm V3 visual system --- apps/smv_injector_gui/premium-v3.css | 329 +++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 apps/smv_injector_gui/premium-v3.css 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; } +} From fb797cc59a67e78199b46af676c687326941b6da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:00:18 +0700 Subject: [PATCH 21/24] Improve phasor and waveform clarity for premium V3 --- apps/smv_injector_gui/premium-v3.js | 190 ++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 apps/smv_injector_gui/premium-v3.js 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(); + }); +})(); From 72fee1f0efc455e65a0d32a9e0e35d28d988248e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:00:49 +0700 Subject: [PATCH 22/24] Load premium calm V3 review layer --- apps/smv_injector_gui/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index 07df61c11..c99523385 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -7,6 +7,7 @@ ARStack61850 · SMV Injector +
@@ -206,5 +207,6 @@

Signal Preview

+ From 49b5b18f9d73bf88bb59056f46f4e7eedb6fae89 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:01:37 +0700 Subject: [PATCH 23/24] Validate premium V3 GUI assets in CI --- .github/workflows/smv-injector-gui.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 From d55abc5af7d64ae0d188a09201be429de9d9158f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:02:42 +0700 Subject: [PATCH 24/24] Document V3 calm premium review criteria --- apps/smv_injector_gui/PREMIUM_UX_REVIEW.md | 53 ++++++++-------------- 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md index fcefd202d..3a32b40f5 100644 --- a/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md +++ b/apps/smv_injector_gui/PREMIUM_UX_REVIEW.md @@ -2,19 +2,24 @@ This branch is intentionally an evaluation surface. Do not merge it into the production GUI until the injection workflow and visual direction are approved. -## V2 direction after first visual review +## V3 visual direction -The first cockpit prototype still behaved too much like a web form: one long 8-channel table, per-row Apply buttons, wire-value clutter, a large frequency slider, and responsive stacking that moved the phasor/waveform below the injector on normal laptop widths. +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. -V2 changes the working model rather than only polishing colors: +V3 keeps the V2 matrix interaction intact and focuses on calm premium instrument art direction: -- Current and Voltage are separate 4-row matrices shown side-by-side. -- Magnitude and Phase are the only primary numeric columns. -- Wire counts/mdeg remain available in the selected-value detail instead of occupying a permanent column. -- Per-row Apply buttons are removed; Live Apply is the normal fast path and `Enter` commits when Live Apply is paused. -- Phasor and waveform remain beside the injector down to approximately 1010 px viewport width. -- Frequency is a compact direct-entry value with 50/60 Hz presets; the large slider is removed. -- A 3-phase link mode can couple A/B/C magnitude and preserve 120-degree phase relationships while editing. +- 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 @@ -41,40 +46,22 @@ The navigation wraps through `Ia → Ib → Ic → In → Ua → Ub → Uc → U 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. -## Visual intent - -The target is a precision engineering instrument, not a dashboard and not a gaming UI: - -- compact hierarchy -- graphite surfaces -- restrained accent color -- small tabular numeric typography -- thin separators -- minimal radius and shadow -- no glass blur or decorative glow -- selected signal color is used for data identity, not decoration -- phasor and waveform are part of the working surface, not afterthought cards - -The screen should feel comfortable during repeated bench work: the eye should land first on Current/Voltage values, then immediately on the phasor/waveform result, while source metadata and diagnostics remain quiet in the periphery. - ## 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 the branch on these questions: +Please judge this V3 branch on these questions: - Can an engineer change many Ia/Ib/Ic/Ua/Ub/Uc values without touching the mouse? -- Can the operator understand the active injection in one glance? -- Are Current and Voltage easy to compare independently? -- Do phasor and waveform remain visible at the laptop viewport actually used on the bench? -- Is the UI calm and premium rather than card-heavy or browser-like? +- 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. - -Latest review iteration: **V2 keyboard-first matrix + side-by-side signal preview**. Evaluate this version before any merge decision.