diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index 882788bfe..924570197 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -10,6 +10,7 @@ Button { property url iconSource property real iconSize: 16 property string toolTipText: "" + readonly property string hoverPath: iconSource.toString().replace("/assets/material/", "/assets/material_dark/") implicitHeight: theme ? theme.controlHeight : 36 implicitWidth: Math.max(64, contentItem.implicitWidth + 20) @@ -28,6 +29,7 @@ Button { tone === "accent" ? (hovered ? "#1d3c5e" : theme.accentSoft) : (down ? "#1a2530" : hovered ? theme.raisedHover : "#141c25") readonly property color activeEdge: !enabled ? theme.lineSoft : + hovered ? "#aac9e9" : tone === "success" ? "#347a59" : tone === "danger" ? "#86434b" : tone === "accent" ? "#315f8d" : @@ -39,15 +41,15 @@ Button { tone === "accent" ? "#dcebff" : theme.textSoft contentItem: Row { - // Both caption and icon belong to the same centered Control item. - spacing: control.iconSource.toString().length > 0 ? 7 : 0 + // Center the complete icon+caption pair in both axes in Qt Controls. anchors.centerIn: parent + spacing: control.iconSource.toString().length > 0 ? 7 : 0 Image { visible: control.iconSource.toString().length > 0 width: control.iconSize height: control.iconSize anchors.verticalCenter: parent.verticalCenter - source: control.iconSource + source: control.hovered && control.enabled ? control.hoverPath : control.iconSource sourceSize.width: control.iconSize * 2 sourceSize.height: control.iconSize * 2 opacity: control.enabled ? 0.94 : 0.30 diff --git a/apps/arstack_studio/qml/DarkToolButton.qml b/apps/arstack_studio/qml/DarkToolButton.qml index f21242095..0b00d1357 100644 --- a/apps/arstack_studio/qml/DarkToolButton.qml +++ b/apps/arstack_studio/qml/DarkToolButton.qml @@ -29,7 +29,7 @@ ToolButton { visible: control.iconSource.toString().length === 0 anchors.fill: parent text: control.text - color: control.enabled ? (control.hovered ? "#ffffff" : theme.textSoft) : theme.muted2 + color: control.enabled ? (control.hovered ? "#142334" : theme.textSoft) : theme.muted2 font: control.font horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -37,7 +37,7 @@ ToolButton { } background: Rectangle { radius: 6 - color: control.down ? theme.accentSoft : (control.hovered ? theme.raisedHover : "transparent") + color: control.down ? "#d6e6f9" : (control.hovered ? "#edf5ff" : "transparent") border.width: control.activeFocus ? 1 : 0 border.color: theme.accent } diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 4ac8d2779..05a29a3c6 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1080,10 +1080,10 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true - // Use all available vertical workspace rather than - // a fixed 384px island with empty space below. + // Stack Voltage and Current vertically; allocate + // the available height between the two matrices. Layout.fillHeight: true - Layout.preferredHeight: root.compactLayout ? 350 : 380 + Layout.preferredHeight: root.compactLayout ? 350 : 450 // Allow 960x620 minimum viewport to shrink without // clipping timing and quality rows; on larger // viewports this column expands into spare space. @@ -1171,7 +1171,8 @@ ApplicationWindow { compact: root.compactLayout } } - Item { Layout.fillHeight: true } + // No trailing filler: vertically stacked matrices + // expand to use the otherwise blank injection space. } } diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 27fdd8139..bf9a737ab 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -47,23 +47,23 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 190 : 224 + Layout.preferredWidth: bar.compact ? 220 : 270 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - Layout.preferredWidth: 48 - Layout.preferredHeight: 48 - width: 48 - height: 48 + Layout.preferredWidth: 56 + Layout.preferredHeight: 56 + width: 56 + height: 56 radius: 10 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 34 - height: 34 + width: 44 + height: 44 source: Qt.resolvedUrl("../assets/material/smv_injector.svg") sourceSize.width: 68 sourceSize.height: 68 @@ -75,7 +75,7 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: bar.compact ? 17 : 19 + font.pixelSize: bar.compact ? 20 : 22 font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index 8e5bf1ff3..2855e1436 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -40,8 +40,8 @@ Button { : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft contentItem: ColumnLayout { spacing: 4 - // Controls own the content rectangle; centering is declarative - // on the layout itself, rather than anchoring Qt Controls content. + // Qt Quick Controls sizes the content rectangle; equal stretchers + // center the icon and caption vertically without anchor conflicts. Item { Layout.fillHeight: true } Image { visible: control.iconSource.toString().length > 0 diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 101e9c2d3..ac323221d 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -227,6 +227,10 @@ Rectangle { } Layout.fillWidth: true + // Distribute spare workspace across editable channel rows; + // never leave a large dead zone under four fixed-height rows. + Layout.fillHeight: true + Layout.minimumHeight: 34 Layout.preferredHeight: matrix.compact ? 40 : 42 color: signalRow.selected ? "#101c27" : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#0f1922" @@ -438,6 +442,6 @@ Rectangle { } } - Item { Layout.fillHeight: true } + // Channel delegates stretch into available height. } } diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index a50529053..8fd0d320a 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -7,7 +7,10 @@ CheckBox { id: control property var theme property string uiFont: "Inter" - implicitHeight: 30 + implicitHeight: 32 + // Keep one fixed indicator geometry at rest, during hover and focus. + // Native style decorations must not replace this explicit indicator. + background: Item { implicitWidth: 1; implicitHeight: 1 } implicitWidth: Math.max(28, contentItem.implicitWidth + 30) leftPadding: 1 rightPadding: 2 @@ -25,8 +28,8 @@ CheckBox { radius: 5 color: control.checked ? control.theme.accent : control.theme.surface2 border.width: 1 - border.color: control.checked || control.hovered ? control.theme.accent - : control.activeFocus ? control.theme.textSoft : control.theme.line + border.color: control.checked || control.hovered || control.activeFocus + ? control.theme.accent : control.theme.line Text { anchors.centerIn: parent visible: control.checked diff --git a/apps/arstack_studio/qml/StudioDarkComboBox.qml b/apps/arstack_studio/qml/StudioDarkComboBox.qml index 60f7afb00..3e84add7c 100644 --- a/apps/arstack_studio/qml/StudioDarkComboBox.qml +++ b/apps/arstack_studio/qml/StudioDarkComboBox.qml @@ -52,9 +52,10 @@ ComboBox { width: combo.width - 8 implicitHeight: 36 highlighted: combo.highlightedIndex === index + hoverEnabled: true contentItem: Text { text: combo.textAt(choice.index) - color: combo.theme.text + color: choice.highlighted || choice.hovered ? "#142334" : combo.theme.text font: combo.font verticalAlignment: Text.AlignVCenter elide: Text.ElideRight @@ -63,7 +64,7 @@ ComboBox { } background: Rectangle { radius: 5 - color: parent.highlighted ? combo.theme.accentSoft : combo.theme.raised + color: choice.highlighted || choice.hovered ? "#edf5ff" : combo.theme.raised } } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 184b54d46..11825422f 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -25,6 +25,22 @@ def ordered(data: str, first: str, second: str, reason: str) -> None: def main() -> None: + # Lock the successful 120-second PTP/SV baseline in the operator CI. + # A GUI-only change must not silently restore the old free-running bundle. + project_root = STUDIO.parent.parent + firmware_profile = (project_root / ".github/workflows/esp32p4-smv-injector.yml").read_text(encoding="utf-8") + preview_profile = (project_root / ".github/workflows/arstack-studio-native-preview.yml").read_text(encoding="utf-8") + require("CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y" in firmware_profile + and "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y" in firmware_profile + and "# CONFIG_AR_SMV_BROADCAST_MIRROR is not set" in firmware_profile + and "phase_sync_preview: true" in preview_profile, + "Studio native preview must ship measured PTP-SV AUTO firmware with no diagnostic mirror") + sv_gate = (project_root / "include/ariec61850/time_sync/sv_tx_egress_evidence.hpp").read_text(encoding="utf-8") + sv_tests = (project_root / "tests/test_sv_tx_egress_evidence.cpp").read_text(encoding="utf-8") + require("kMaxSoftwarePhaseBoundUs = 4U" in sv_gate + and "kMaxCanonicalTxOffsetNs = 250000LL" in sv_gate + and "check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac" in sv_tests, + "retain independently bounded PTP sample-phase and nonzero MAC publication latency") main_qml = source("Main.qml") preview = source("SignalPreview.qml") tray = source("StudioTimingTray.qml") @@ -61,6 +77,10 @@ def main() -> None: and "root.ptpDesiredForSession = false" in main_qml, "PTP user intent restoration must be bounded, explicit and cancellable") matrix = source("SignalMatrix.qml") + require("Layout.fillHeight: true" in matrix + and "Layout.minimumHeight: 34" in matrix + and "Item { Layout.fillHeight: true }" not in matrix, + "editable channel rows must consume vertical space without a blank filler") require("function flushPendingEdits()" in matrix and "magnitudeApplyTimer.stop()" in matrix and "phaseApplyTimer.stop()" in matrix @@ -73,15 +93,43 @@ def main() -> None: and main_qml.index("id: voltageMatrix") < main_qml.index("id: currentMatrix"), "Voltage and Current need a vertical, flexible-height editor workspace") ribbon_qml = source("ModernRibbon.qml") - require("Layout.preferredWidth: bar.compact ? 190 : 224" in ribbon_qml - and "font.pixelSize: bar.compact ? 17 : 19" in ribbon_qml, - "SMV Injector logo/caption must be instrument-sized, not micro branding") + require("Layout.preferredWidth: bar.compact ? 220 : 270" in ribbon_qml + and "font.pixelSize: bar.compact ? 20 : 22" in ribbon_qml + and "width: 44" in ribbon_qml, + "SMV Injector branding must be readable at compact and normal widths") + device_controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + workflow = source("WorkflowBar.qml") + require("const scenario = controller.startScenarioSnapshot()" in workflow + and "smartSession.requestStartWithScenario(scenario)" in workflow + and "smartSession.requestStart()" not in workflow, + "every START must transmit all 8 visible setpoints, never reuse stale board angles") + require('QStringLiteral("IA"), QStringLiteral("IB")' in device_controller + and 'QStringLiteral("IC"), QStringLiteral("IN")' in device_controller + and 'QStringLiteral("UA"), QStringLiteral("UB")' in device_controller + and 'QStringLiteral("UC"), QStringLiteral("UN")' in device_controller + and 'commands << QStringLiteral("START");' in device_controller + and 'const auto phaseMdeg = std::llround(angleMdeg);' in device_controller, + "START transaction must retain every channel and signed millidegree") + require("anchors.centerIn: parent" in source("CalmButton.qml") + and "Text.AlignHCenter" in source("RibbonAction.qml"), + "control captions must center on both axes") checkbox_qml = source("StudioDarkCheckBox.qml") require("width: 20" in checkbox_qml and "height: 20" in checkbox_qml, "checkbox geometry must remain unchanged by hover") require("hovered ? \"#edf5ff\"" in source("CalmButton.qml") - and "hovered ? \"#142334\"" in source("CalmButton.qml"), - "light hover background must always pair with dark caption") + and "hovered ? \"#142334\"" in source("CalmButton.qml") + and "/assets/material_dark/" in source("CalmButton.qml"), + "light hover background must pair with dark caption and icon") + combo = source("StudioDarkComboBox.qml") + require('choice.highlighted || choice.hovered ? "#142334"' in combo + and 'choice.highlighted || choice.hovered ? "#edf5ff"' in combo, + "Advanced dropdown options must be readable on white hover") + dark_tool = source("DarkToolButton.qml") + require('control.hovered ? "#142334"' in dark_tool + and 'control.hovered ? "#edf5ff"' in dark_tool, + "Advanced text/icon tool buttons must use dark captions on white hover") + require("background: Item { implicitWidth: 1; implicitHeight: 1 }" in checkbox_qml, + "checkbox hover must not render a second platform-native indicator") menu = source("StudioActionMenu.qml") cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") diff --git a/docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md b/docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md new file mode 100644 index 000000000..2b9a5108e --- /dev/null +++ b/docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md @@ -0,0 +1,20 @@ +# ARStack Studio — SV/PTP non-regression baseline (2026-10-18) + +## Frozen engineering evidence +Operator's latest 120s ASIX PCAP contained 480,294 canonical 4I+4V SV frames on APPID 0x4000, svID ARSTACK_SV01, 4,000 fps, VLAN 0 PCP 4; **zero** smpCnt discontinuities (120 modulo-4000 wraps), every frame smpSynch=1 LOCAL, and no broadcast diagnostic mirror. There were 120 each of PTP Announce, Sync and matched Follow_Up on domain 0, all from ARStack's own local SOURCE. Correlation between host-received smpCnt=0 frames and PTP timestamp epochs yielded ~+48.9 us median / ~+67.2 us p95 across 120 matched observations; these are **Windows/ASIX host RX offsets**, not actual hardware sampling phase error. + +This is strong wire-level evidence for stable local-epoch alignment, **not** a certificate of external GM synchronization, cross-vendor acceptance, or microsecond hardware phase accuracy. + +## CI release guardrails +- Native Windows Preview must embed firmware from its own exact commit with phase servo ON, canonical MAC TX timestamp evidence ON, and diagnostic mirror OFF. Never quietly fall back to the old free-running firmware. +- Preserve fail-closed AUTO=0 when clock/sample-phase evidence is absent; qualified SOURCE-local AUTO=1 is allowed; never imply AUTO=2 global time traceability without evidence. +- Independently measure GPTimer sample-epoch phase (4us implementation threshold) and MAC SV transmission publication latency (nonzero is expected, admission range currently [-4us,+250us]); do not conflate the two. +- Never modify canonical EtherType, APPID 0x4000, svID, VLAN, confRev, ASDU 4I+4V payload, sample-counter continuity or PTP profile as collateral of UI work. +- Every START must use a fresh snapshot of all eight displayed waveform setpoints: IA IB IC IN UA UB UC UN, preserving signed angle millidegrees, magnitude scaling, enable flags and quality. A bare START that can reuse old board setpoints is forbidden. +- GUI angles are user setpoints, not independent firmware readback. **Do not claim actual phase injection matches until a same-run SET transaction, board SHOW phase_mdeg, and decoded SV waveform agree**. +- Default PTP Source/AUTO, Phasor ON and Wave ON; the operator should not encounter additional dialog gates for normal PTP ON and SMV Start. + +## Follow-up acceptance +With GUI Ia=-30°, Ib=-150°, Ic=+90°, capture a new immediately adjacent SV waveform *after* starting with that exact table. Verify the board's per-channel phase_mdeg (e.g. IA=-30000), then compare LS-fitted raw SV instantaneous waveform phase to the requested values. Repeat after STOP/START and reconnect. Verify PTP AUTO local acquisition and smpCnt continuity remain intact. + +This baseline intentionally does not claim SIPROTEC 5 lock or IED compatibility without actual subscriber evidence.