diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index 924570197..66d0dd2b2 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -40,10 +40,13 @@ Button { tone === "danger" ? "#ffdce0" : tone === "accent" ? "#dcebff" : theme.textSoft - contentItem: Row { - // Center the complete icon+caption pair in both axes in Qt Controls. - anchors.centerIn: parent - spacing: control.iconSource.toString().length > 0 ? 7 : 0 + contentItem: Item { + implicitWidth: centeredContent.implicitWidth + implicitHeight: centeredContent.implicitHeight + Row { + id: centeredContent + anchors.centerIn: parent + spacing: control.iconSource.toString().length > 0 ? 7 : 0 Image { visible: control.iconSource.toString().length > 0 width: control.iconSize @@ -61,6 +64,7 @@ Button { anchors.verticalCenter: parent.verticalCenter Behavior on color { ColorAnimation { duration: 90 } } } + } } ToolTip.visible: hovered && toolTipText.length > 0 diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 05a29a3c6..30a7400ef 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1069,7 +1069,21 @@ ApplicationWindow { text: "3-phase link" onToggled: { root.phaseLink = checked + // Linking phases changes future edit behavior only; + // never silently rewrite existing setpoints. root.markScenarioDirty() + if (workflowBar.injectionRunning && workflowBar.session + && workflowBar.session.liveControlReady) { + // Persist *all eight current table values* on + // live link changes; the displayed draft + // must never silently diverge from TX. + var ok = true + for (var group = 0; group < 2; ++group) + for (var row = 0; row < 4; ++row) + ok = root.sendSignal(group, row) && ok + if (ok) root.pendingApply = false + else root.showMessage("Not all live phases were sent. Stop/Start to apply the table.", true) + } } font.family: root.uiFont font.pixelSize: 9 @@ -1080,14 +1094,16 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true - // Stack Voltage and Current vertically; allocate - // the available height between the two matrices. - Layout.fillHeight: true - 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. - Layout.minimumHeight: root.compactLayout ? 180 : 210 + // Two stacked tables consume their actual content + // heights, never stretch four tiny rows into an + // oversized empty card at tall window sizes. + Layout.fillHeight: false + // Reserve vertical room for frequency, timing, + // and quality controls even at 960x620. Larger + // windows can use full natural matrix height. + Layout.preferredHeight: Math.min(410, Math.max(215, root.height - 365)) + Layout.maximumHeight: 410 + Layout.minimumHeight: 195 spacing: 8 SignalMatrix { id: voltageMatrix @@ -1095,6 +1111,8 @@ ApplicationWindow { objectName: "studioVoltageMatrix" Layout.fillWidth: true Layout.fillHeight: true + Layout.preferredHeight: 196 + Layout.minimumHeight: 92 theme: studioTheme controller: root device: device @@ -1114,6 +1132,8 @@ ApplicationWindow { objectName: "studioCurrentMatrix" Layout.fillWidth: true Layout.fillHeight: true + Layout.preferredHeight: 196 + Layout.minimumHeight: 92 theme: studioTheme controller: root device: device diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index bf9a737ab..22f18779c 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 ? 220 : 270 + Layout.preferredWidth: bar.compact ? 232 : 286 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - Layout.preferredWidth: 56 - Layout.preferredHeight: 56 - width: 56 - height: 56 + Layout.preferredWidth: 64 + Layout.preferredHeight: 64 + width: 64 + height: 64 radius: 10 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 44 - height: 44 + width: 53 + height: 53 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 ? 20 : 22 + font.pixelSize: bar.compact ? 21 : 25 font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index 8fd0d320a..c47b6d679 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -8,6 +8,9 @@ CheckBox { property var theme property string uiFont: "Inter" implicitHeight: 32 + // The same single indicator is painted for normal, hover and focus. + // Do not overlay a native checkbox glyph on hover. + // Only the custom Rectangle paints the indicator; native style stays hidden. // 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 } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 11825422f..68087b5b6 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -53,10 +53,10 @@ def main() -> None: and "root.pendingApply" in main_qml and "pendingApply = true" in main_qml, "table edits must expose unsent setpoints without claiming wire readback") - require("Layout.minimumHeight: root.compactLayout ? 180 : 210" in main_qml - and "Layout.fillHeight: true" in main_qml + require("Layout.preferredHeight: Math.min(410, Math.max(215, root.height - 365))" in main_qml + and "Layout.maximumHeight: 410" in main_qml and "ColumnLayout {" in main_qml, - "voltage and current must stack vertically and fill the workspace") + "voltage and current must stack vertically with bounded content height") require('property bool ptpDefaultStartAttempted: false' in main_qml and 'root.ptpDefaultStartAttempted = true' in main_qml and '!root.startPtpRole("SOURCE")' in main_qml, @@ -76,6 +76,30 @@ def main() -> None: and "root.ptpRestoreChecks > 120" in main_qml and "root.ptpDesiredForSession = false" in main_qml, "PTP user intent restoration must be bounded, explicit and cancellable") + ribbon = source("ModernRibbon.qml") + checkbox = source("StudioDarkCheckBox.qml") + calm = source("CalmButton.qml") + require("Layout.preferredWidth: bar.compact ? 232 : 286" in ribbon + and "font.pixelSize: bar.compact ? 21 : 25" in ribbon, + "operator brand/logo must stay legible") + require("indicator: Rectangle {" in checkbox, + "checkbox must keep one stable custom indicator on hover") + require("anchors.centerIn: parent" in calm, + "button caption needs horizontal centering") + require("root.sendSignal(group, row)" in main_qml + and "Layout.fillHeight: false" in main_qml + and "Layout.preferredHeight: 196" in main_qml, + "tables and live sent phases must use explicit bounded layout") + require('property bool ptpDefaultStartAttempted: false' in main_qml + and 'root.startPtpRole("SOURCE")' in main_qml + and "device.deviceVerified" in main_qml, + "default PTP must reuse verified session ownership") + device_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + require('commands << QStringLiteral("FREQ %1")' in device_cpp + and "const double phase = row.at(1).toDouble(&phaseOk);" in device_cpp + and "const double angleMdeg = phaseDegrees * 1000.0;" in device_cpp + and "commands << QStringLiteral(\"START\");" in device_cpp, + "START must send actual eight model phases as millidegree SET commands before START") matrix = source("SignalMatrix.qml") require("Layout.fillHeight: true" in matrix and "Layout.minimumHeight: 34" in matrix @@ -93,9 +117,9 @@ 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 ? 220 : 270" in ribbon_qml - and "font.pixelSize: bar.compact ? 20 : 22" in ribbon_qml - and "width: 44" in ribbon_qml, + require("Layout.preferredWidth: bar.compact ? 232 : 286" in ribbon_qml + and "font.pixelSize: bar.compact ? 21 : 25" in ribbon_qml + and "width: 53" 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") diff --git a/docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md b/docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md new file mode 100644 index 000000000..dadb65f9a --- /dev/null +++ b/docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md @@ -0,0 +1,22 @@ +# ARStack Studio SV–PTP no-regression baseline — 18 Oct 2026 + +This document freezes observed evidence and operator-visible behavior without calling host-receiver timing a hardware accuracy certificate. + +## Known-good evidence + +The user's independent 120-second ASIX/Npcap capture demonstrated: 480,294 canonical SV frames (svID ARSTACK_SV01, APPID 0x4000), no diagnostic mirror, no smpCnt gaps/duplicates, 120 counter wraps, all smpSynch=1 under SOURCE + AUTO. PTP domain 0 carried 120 Announce, 120 Sync, and 120 matched Follow_Up. Host-relative smpCnt=0 to PTP epoch estimate median +48.9us, P95 +67.2us. This includes frame-publication and capture path delay; it is NOT a hardware sample-clock phase-error guarantee. + +## Non-regression gates + +1. Native Preview firmware must match the exact GUI commit and include PTP phase servo, MAC TX timing evidence, and broadcast mirror disabled. Never regress to a v0.1.1 bundle built from the old main firmware. +2. Local smpSynch=1 is qualified by actual sample timing; neither PTP visibility nor manual metadata suffices. Do not assert global smpSynch=2 for a local class-248 SOURCE. +3. At 50 Hz / 80 SPC, canonical SV rate is 4000 fps and smpCnt wraps 3999→0, with no healthy-stream discontinuities. +4. Every START flushes editor debounces and sends the eight current table setpoints as an ordered FREQ/SET/ENABLE/SHAPE/START batch; serial phase encoding is signed millidegrees (Ia −30.00° -> -30000). No silent reuse of stale ESP32-P4 setpoints. +5. Editing live phase-link behavior must use the same ListModels as the visible table and enqueue all affected phases. Do not claim on-wire equality without PCAP and timestamp-matched setpoints. +6. PTP SOURCE has exactly one owner for the verified-session default ON behavior; user OFF cancels it. Do not add duplicate startup timers, modal barriers, or write PTP/SMV clock state from presentation. +7. GUI: readable brand/logo, consistently centered buttons, dark text for pale hover, only one stable checkbox indicator, vertical voltage/current tables with bounded heights; preserve Windows DPI 100/150/175 acceptance. +8. GUI-only improvements must not alter Ethernet SV frames, PTP hardware epoch lease, timestamp/ISR behavior, wire quality, or smpCnt logic. + +## Next evidence for release + +Studio Qt CI, Windows Lifecycle and exact-head Native Preview must pass. After installation, inspect serial FREQ/SET phase commands and independent decoded waveforms for Ia −30°, Ib −150°, Ic +90° and the requested Ua/Ub/Uc. Capture >120 seconds after AUTO becomes local 1, include STOP/START and PTP SOURCE fault/recovery. Confirm no missing counters and stable epoch alignment. Physical synchronization accuracy and cross-vendor subscriber acceptance are separate from host timing screening.