diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index ddddca61c..98f74608f 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -22,39 +22,47 @@ Button { Behavior on scale { NumberAnimation { duration: 70; easing.type: Easing.OutCubic } } readonly property color activeFill: !enabled ? "#10161d" : - tone === "success" ? (hovered ? "#1e5c43" : "#194d38") : - tone === "danger" ? (hovered ? "#523039" : theme.redSoft) : - tone === "accent" ? (hovered ? "#1d3c5e" : theme.accentSoft) : - (down ? "#1a2530" : hovered ? theme.raisedHover : "#141c25") + hovered ? "#e9f2fd" : + tone === "success" ? "#194d38" : + tone === "danger" ? theme.redSoft : + tone === "accent" ? theme.accentSoft : + down ? "#1a2530" : "#141c25" readonly property color activeEdge: !enabled ? theme.lineSoft : + hovered ? "#a6c9f2" : tone === "success" ? "#347a59" : tone === "danger" ? "#86434b" : tone === "accent" ? "#315f8d" : (activeFocus ? theme.accent : hovered ? "#3a4a5b" : theme.line) readonly property color activeText: !enabled ? theme.muted2 : + hovered ? "#142334" : tone === "success" ? "#d6f4e6" : tone === "danger" ? "#ffdce0" : tone === "accent" ? "#dcebff" : theme.textSoft - contentItem: Row { - spacing: control.iconSource.toString().length > 0 ? 7 : 0 - anchors.centerIn: parent - Image { - visible: control.iconSource.toString().length > 0 - width: control.iconSize - height: control.iconSize - anchors.verticalCenter: parent.verticalCenter - source: control.iconSource - sourceSize.width: control.iconSize * 2 - sourceSize.height: control.iconSize * 2 - opacity: control.enabled ? 0.94 : 0.30 - } - Text { - text: control.text - color: control.activeText - font: control.font - anchors.verticalCenter: parent.verticalCenter - Behavior on color { ColorAnimation { duration: 90 } } + contentItem: Item { + Row { + spacing: control.iconSource.toString().length > 0 ? 7 : 0 + anchors.centerIn: parent + Image { + visible: control.iconSource.toString().length > 0 + width: control.iconSize + height: control.iconSize + anchors.verticalCenter: parent.verticalCenter + source: control.hovered && control.enabled + ? control.iconSource.toString().replace("/assets/material/", "/assets/material_dark/") + : control.iconSource + sourceSize.width: control.iconSize * 2 + sourceSize.height: control.iconSize * 2 + opacity: control.enabled ? 0.94 : 0.30 + } + Text { + text: control.text + color: control.activeText + font: control.font + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } } } diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index fecaa51a3..708e23d28 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -34,16 +34,18 @@ Item { font.weight: checked ? Font.DemiBold : Font.Medium contentItem: Label { text: parent.text - color: parent.checked ? hub.theme.text : hub.theme.muted + color: parent.hovered && !parent.checked ? "#142334" + : parent.checked ? hub.theme.text : hub.theme.muted font: parent.font horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.checked ? hub.theme.raised : "transparent" + color: parent.hovered && !parent.checked ? "#e9f2fd" + : parent.checked ? hub.theme.raised : "transparent" radius: 6 - border.width: parent.checked ? 1 : 0 - border.color: hub.theme.line + border.width: parent.checked || parent.hovered ? 1 : 0 + border.color: parent.hovered && !parent.checked ? "#a6c9f2" : hub.theme.line Rectangle { visible: parent.parent.checked anchors.left: parent.left @@ -59,7 +61,7 @@ Item { ColumnLayout { anchors.fill: parent - spacing: 16 + spacing: 11 RowLayout { Layout.fillWidth: true diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 66a0daf89..098ad49d6 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -54,7 +54,8 @@ ApplicationWindow { saveScenarioTimer.restart() // While STOPPED, a new edit belongs to the next supervised Start batch. // Only a genuinely RUNNING board receives incremental live SETs. - if (!workflowBar.injectionRunning) + if (!workflowBar.injectionRunning || + !workflowBar.session || !workflowBar.session.liveControlReady) pendingApply = true } function restoreLocalScenario() { @@ -119,6 +120,8 @@ ApplicationWindow { // an explicit PTP ON can be restored after a firmware reflash/reconnect // without introducing an uncontrolled power-on publisher. property bool ptpDesiredForSession: false + property bool ptpDefaultStartPending: true + property bool ptpExplicitOff: false property string ptpDesiredRole: "SOURCE" property bool ptpRestoreAfterFirmware: false property int ptpRestoreChecks: 0 @@ -198,6 +201,8 @@ ApplicationWindow { function onDeviceVerifiedChanged() { root.pendingApply = true root.offlinePreviewRunning = false + if (device.deviceVerified && !root.ptpExplicitOff) + root.ptpDefaultStartPending = true } } @@ -215,6 +220,28 @@ ApplicationWindow { } } + // One automatic SOURCE start on verified connection. An explicit OFF + // cancels this for the remainder of the Studio session; never send PTP + // commands without a verified board or while firmware is updating. + Timer { + interval: 400 + repeat: true + running: root.ptpDefaultStartPending && !root.ptpExplicitOff && device.deviceVerified + onTriggered: { + if (device.ptpRunning) { + root.ptpDefaultStartPending = false + return + } + const session = workflowBar.session + if (!session || !session.startReady || session.updatingFirmware || + device.ptpStarting || !device.ptpAvailable) + return + root.ptpDefaultStartPending = false + if (!root.startPtpRole("SOURCE")) + root.showMessage("PTP auto-start was not accepted. Use PTP On to retry.", true) + } + } + Timer { interval: 300 repeat: true @@ -317,6 +344,8 @@ ApplicationWindow { const accepted = profilePanel.startPtpRole(role) if (accepted) { root.ptpDesiredForSession = true + root.ptpExplicitOff = false + root.ptpDefaultStartPending = false root.ptpDesiredRole = role } return accepted @@ -324,6 +353,8 @@ ApplicationWindow { function requestStopPtp() { root.ptpDesiredForSession = false + root.ptpDefaultStartPending = false + root.ptpExplicitOff = true root.ptpRestoreAfterFirmware = false return workflowBar.session && workflowBar.session.requestStopPtp() } @@ -468,8 +499,11 @@ ApplicationWindow { selectSignal(group, row) markScenarioDirty() if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) { - if (phaseLink && row < 3) sendLinkedGroup(group) - else sendSignal(group, row) + const sent = phaseLink && row < 3 ? sendLinkedGroup(group) : sendSignal(group, row) + if (!sent) { + pendingApply = true + showMessage("Setpoint not queued. STOP and START to apply all displayed 4I+4V values.", true) + } } return true } @@ -503,8 +537,9 @@ ApplicationWindow { frequencyField.invalidInput = false refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) - workflowBar.session.requestSetFrequency(value) + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && + !workflowBar.session.requestSetFrequency(value)) + pendingApply = true return true } @@ -610,8 +645,9 @@ ApplicationWindow { selectSignal(activeGroup, activeRow) refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) - workflowBar.session.requestZero() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && + !workflowBar.session.requestZero()) + pendingApply = true } function setActiveQuality(value) { @@ -619,8 +655,9 @@ ApplicationWindow { groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) - workflowBar.session.requestSetQuality(activeSignal, unsignedValue) + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && + !workflowBar.session.requestSetQuality(activeSignal, unsignedValue)) + pendingApply = true } Shortcut { sequence: "Ctrl+B"; enabled: !workflowBar.startQueued; onActivated: root.balanced() } @@ -698,11 +735,12 @@ ApplicationWindow { } Rectangle { width: 1; height: 12; color: studioTheme.lineSoft } Label { - text: root.pendingApply && !workflowBar.injectionRunning - ? "4I + 4V · Changes sent on Start" + text: root.pendingApply + ? (workflowBar.injectionRunning + ? "4I + 4V · Changes pending · restart to apply" + : "4I + 4V · Changes sent on Start") : "4I + 4V · 4000 samples/s" - color: root.pendingApply && !workflowBar.injectionRunning - ? studioTheme.amber : studioTheme.muted + color: root.pendingApply ? studioTheme.amber : studioTheme.muted font.family: root.uiFont font.pixelSize: 9 } @@ -930,10 +968,31 @@ ApplicationWindow { SplitView.preferredWidth: root.compactLayout ? 700 : 860 theme: studioTheme - ColumnLayout { + // A narrow Windows viewport cannot display two full + // engineering tables plus timing/quality at once. Scroll + // the injection editor as ONE surface at small heights; + // normal desktop sizes fully expand the two matrices. + ScrollView { + id: injectionScroll + objectName: "studioInjectionScroll" anchors.fill: parent - anchors.margins: root.compactLayout ? 11 : 13 - spacing: root.compactLayout ? 6 : 8 + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded + contentWidth: availableWidth + contentHeight: injectionEditor.y + injectionEditor.height + + (root.compactLayout ? 8 : 9) + + ColumnLayout { + id: injectionEditor + x: root.compactLayout ? 8 : 9 + y: root.compactLayout ? 8 : 9 + width: Math.max(1, injectionScroll.availableWidth - + 2 * (root.compactLayout ? 8 : 9)) + height: Math.max(implicitHeight, + injectionScroll.availableHeight - + 2 * (root.compactLayout ? 8 : 9)) + spacing: root.compactLayout ? 4 : 5 RowLayout { Layout.fillWidth: true @@ -1049,11 +1108,15 @@ ApplicationWindow { } } - RowLayout { + // Stack voltage above current. Both tables share the + // available height instead of leaving an empty lower + // half; the inner rows stretch with the desktop. + ColumnLayout { Layout.fillWidth: true - Layout.preferredHeight: root.compactLayout ? 214 : 224 - Layout.maximumHeight: root.compactLayout ? 214 : 224 - spacing: 9 + Layout.fillHeight: true + Layout.minimumHeight: 408 + Layout.preferredHeight: 422 + spacing: 7 SignalMatrix { id: voltageMatrix enabled: !workflowBar.startQueued @@ -1136,7 +1199,7 @@ ApplicationWindow { compact: root.compactLayout } } - Item { Layout.fillHeight: true } + } } } diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index aa995b10d..8c218bf92 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -47,24 +47,24 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 105 : 124 + Layout.preferredWidth: bar.compact ? 150 : 176 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - width: 28 - height: 28 - radius: 7 + width: 40 + height: 40 + radius: 9 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 15 - height: 15 + width: 28 + height: 28 source: Qt.resolvedUrl("../assets/material/smv_injector.svg") - sourceSize.width: 30 - sourceSize.height: 30 + sourceSize.width: 56 + sourceSize.height: 56 } } @@ -73,7 +73,7 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: 11 + font.pixelSize: bar.compact ? 14 : 15 font.weight: Font.DemiBold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 14fb81763..8060e4176 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -49,6 +49,13 @@ SurfacePanel { || panel.device.ptpRunning || panel.startPending || panel.session.updatingFirmware) return false const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile()) + if (accepted && panel.controller) { + // A start from Advanced has exactly the same owner as Main. + panel.controller.ptpDesiredForSession = true + panel.controller.ptpExplicitOff = false + panel.controller.ptpDefaultStartPending = false + panel.controller.ptpDesiredRole = role + } if (!accepted && panel.controller) panel.controller.showMessage("PTP start blocked. Check timing diagnostics.", true) return Boolean(accepted) @@ -134,7 +141,7 @@ SurfacePanel { const wantsRunning = checked checked = Qt.binding(function() { return Boolean(panel.device.ptpRunning) }) if (wantsRunning) panel.startSelectedMode() - else if (panel.session && !panel.session.requestStopPtp() && panel.controller) + else if (panel.controller && !panel.controller.requestStopPtp()) panel.controller.showMessage("PTP stop was not accepted. Copy diagnostics.", true) } } @@ -287,7 +294,7 @@ SurfacePanel { text: panel.device.ptpStatus.indexOf("PTP STOP unverified") === 0 ? "Retry Stop PTP" : "Stop PTP" enabled: panel.session && (panel.device.ptpRunning || panel.startPending) && !panel.session.updatingFirmware onClicked: { - panel.session.requestStopPtp() + if (panel.controller) panel.controller.requestStopPtp() } } CalmButton { diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index a882bb495..c55a83ea0 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -38,28 +38,33 @@ Button { : tone === "warning" ? "#f5daaa" : tone === "danger" ? "#ffe3e6" : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft - contentItem: ColumnLayout { - spacing: 3 - Image { - visible: control.iconSource.toString().length > 0 - source: control.hovered && control.enabled ? control.hoverPath : control.iconSource - Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: 24 - Layout.preferredHeight: 24 - sourceSize.width: 48 - sourceSize.height: 48 - opacity: control.enabled ? 1 : 0.3 - } - Text { - id: caption - visible: !control.iconOnly - text: control.text - color: control.textColor - font: control.font - Layout.alignment: Qt.AlignHCenter - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight + // The content box owns full button geometry; its visual group is + // centered in both axes (Windows / 100–175% DPI). + contentItem: Item { + Column { + anchors.centerIn: parent + spacing: 4 + Image { + visible: control.iconSource.toString().length > 0 + source: control.hovered && control.enabled ? control.hoverPath : control.iconSource + anchors.horizontalCenter: parent.horizontalCenter + width: 24 + height: 24 + sourceSize.width: 48 + sourceSize.height: 48 + opacity: control.enabled ? 1 : 0.3 + } + Text { + id: caption + visible: !control.iconOnly + text: control.text + color: control.textColor + font: control.font + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } } } background: Rectangle { diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 1c84e2b43..7eb1f375f 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -196,7 +196,9 @@ Rectangle { } Layout.fillWidth: true - Layout.preferredHeight: matrix.compact ? 40 : 42 + Layout.fillHeight: true + Layout.minimumHeight: 36 + Layout.preferredHeight: matrix.compact ? 39 : 40 color: signalRow.selected ? "#101c27" : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#0f1922" : rowHover.hovered ? "#0d161e" : "transparent" @@ -407,6 +409,5 @@ Rectangle { } } - Item { Layout.fillHeight: true } } } diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index eedfce586..5d0730d12 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -8,7 +8,11 @@ CheckBox { property var theme property string uiFont: "Inter" implicitHeight: 30 + hoverEnabled: true spacing: 9 + padding: 0 + // Disable native Windows hover glyph: one consistent 18px indicator. + background: Item {} font.family: uiFont font.pixelSize: 11 indicator: Rectangle { @@ -20,7 +24,8 @@ CheckBox { color: control.checked ? control.theme.accent : control.theme.surface2 border.width: 1 border.color: control.checked ? control.theme.accent - : control.activeFocus ? control.theme.textSoft : control.theme.line + : control.hovered || control.activeFocus + ? control.theme.textSoft : 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..6ce33ad21 100644 --- a/apps/arstack_studio/qml/StudioDarkComboBox.qml +++ b/apps/arstack_studio/qml/StudioDarkComboBox.qml @@ -21,7 +21,7 @@ ComboBox { background: Rectangle { radius: 7 - color: combo.enabled ? (combo.hovered ? combo.theme.raisedHover : combo.theme.surface2) + color: combo.enabled ? (combo.hovered ? "#e9f2fd" : combo.theme.surface2) : combo.theme.surface2 border.width: 1 border.color: combo.activeFocus ? combo.theme.accent : combo.theme.line @@ -29,7 +29,7 @@ ComboBox { contentItem: Text { text: combo.displayText - color: combo.enabled ? combo.theme.text : combo.theme.muted + color: combo.enabled ? (combo.hovered ? "#142334" : combo.theme.text) : combo.theme.muted font: combo.font verticalAlignment: Text.AlignVCenter leftPadding: 12 @@ -39,7 +39,7 @@ ComboBox { indicator: Text { text: "\u25be" - color: combo.enabled ? combo.theme.textSoft : combo.theme.muted + color: combo.enabled ? (combo.hovered ? "#142334" : combo.theme.textSoft) : combo.theme.muted font.pixelSize: 14 anchors.right: parent.right anchors.rightMargin: 11 @@ -54,7 +54,7 @@ ComboBox { highlighted: combo.highlightedIndex === index contentItem: Text { text: combo.textAt(choice.index) - color: combo.theme.text + color: choice.highlighted ? "#142334" : combo.theme.text font: combo.font verticalAlignment: Text.AlignVCenter elide: Text.ElideRight @@ -63,7 +63,7 @@ ComboBox { } background: Rectangle { radius: 5 - color: parent.highlighted ? combo.theme.accentSoft : combo.theme.raised + color: parent.highlighted ? "#e9f2fd" : combo.theme.raised } } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index e49593114..64f990e71 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -281,9 +281,12 @@ SurfacePanel { controller.showMessage(ribbon.startReason(), false) return } - const accepted = controller.pendingApply - ? smartSession.requestStartWithScenario(controller.startScenarioSnapshot()) - : smartSession.requestStart() + // Every START uploads the current visible 4I+4V model. The board may + // still hold an older scenario after STOP, reconnect, PTP role change, + // or another session. Never take a bare START shortcut from a GUI + // that displays different phasor angles. + const accepted = smartSession.requestStartWithScenario( + controller.startScenarioSnapshot()) if (!accepted) { controller.pendingApply = true controller.showMessage("Start rejected. Check device status.", true) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index bd5fe2233..299e770a9 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -644,6 +644,14 @@ class DeterministicSessionHarness final { QStringLiteral("Ib"), 1.0, -120.0, 0U, 1000.0, 100.0, 50.0); const QString ic = DeviceController::formatSignalCommand( QStringLiteral("Ic"), 1.0, 120.0, 0U, 1000.0, 100.0, 50.0); + // The operator's non-zero phasor settings must survive milli-degree + // wire encoding; never substitute the balanced 0/-120/+120 preset. + const QString iaShifted = DeviceController::formatSignalCommand( + QStringLiteral("Ia"), 1.0, -30.0, 0U, 1000.0, 100.0, 50.0); + const QString ibShifted = DeviceController::formatSignalCommand( + QStringLiteral("Ib"), 1.0, -150.0, 0U, 1000.0, 100.0, 50.0); + const QString icShifted = DeviceController::formatSignalCommand( + QStringLiteral("Ic"), 1.0, 90.0, 0U, 1000.0, 100.0, 50.0); const QString badId = DeviceController::formatSignalCommand( QStringLiteral("IX"), 1.0, 0.0, 0U, 1000.0, 100.0, 50.0); const QString overflow = DeviceController::formatSignalCommand( @@ -653,6 +661,9 @@ class DeterministicSessionHarness final { return ia == QStringLiteral("SET IA 1000 0 0") && ib == QStringLiteral("SET IB 1000 -120000 0") && ic == QStringLiteral("SET IC 1000 120000 0") && + iaShifted == QStringLiteral("SET IA 1000 -30000 0") && + ibShifted == QStringLiteral("SET IB 1000 -150000 0") && + icShifted == QStringLiteral("SET IC 1000 90000 0") && badId.isEmpty() && overflow.isEmpty() && dcNegative == QStringLiteral("SET IA -1000 0 0"); } diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 13518af84..a605c19cd 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -30,6 +30,7 @@ #include #include +#include #include #include @@ -72,20 +73,44 @@ struct UiBounds { {"voltage matrix", voltage}, {"current matrix", current}, {"PTP timing tray", tray}, {"quality editor", quality} }}; + // At the supported 960x620 minimum viewport the editor uses a + // vertical ScrollView; offscreen lower controls must remain laid out + // correctly and reachable by scrolling, not be smashed to <30px rows. + // Full desktop views MUST still show all controls without clipping. + const bool compactScroll = size.height() < 700; for (const auto& [label, item] : areas) { + const bool mustBeInViewport = !compactScroll || + std::string_view{label} == "toolbar" || + std::string_view{label} == "injection workspace" || + std::string_view{label} == "voltage matrix"; if (!item.visible || item.area.width() < 25 || item.area.height() < 15 || - !viewport.adjusted(-tolerance, -tolerance, tolerance, tolerance) - .contains(item.area.adjusted(tolerance, tolerance, - -tolerance, -tolerance))) { + (mustBeInViewport && + !viewport.adjusted(-tolerance, -tolerance, tolerance, tolerance) + .contains(item.area.adjusted(tolerance, tolerance, + -tolerance, -tolerance)))) { qCritical().noquote() << "Studio UI viewport violation" << size << label << "bounds=" << item.area << "viewport=" << viewport; return false; } } - if (!(voltage.area.right() <= current.area.left() + tolerance - && tray.area.top() >= voltage.area.bottom() - tolerance + // Voltage and Current intentionally stack vertically. The native + // geometry test must detect clipping/overlap in the NEW layout, rather + // than assert the obsolete side-by-side relationship. + const auto scroll = findUiBounds(window, "studioInjectionScroll"); + if (!scroll.visible || + !surface.area.adjusted(-tolerance, -tolerance, tolerance, tolerance) + .contains(scroll.area.adjusted(tolerance, tolerance, -tolerance, -tolerance))) { + qCritical().noquote() << "Studio operator scrolling surface missing" << size + << scroll.area << surface.area; + return false; + } + if (!(voltage.area.bottom() <= current.area.top() + tolerance + && std::abs(voltage.area.left() - current.area.left()) <= tolerance + && std::abs(voltage.area.right() - current.area.right()) <= tolerance + && tray.area.top() >= current.area.bottom() - tolerance && quality.area.top() >= tray.area.bottom() - tolerance - && quality.area.bottom() <= surface.area.bottom() + tolerance)) { + && (compactScroll || + quality.area.bottom() <= surface.area.bottom() + tolerance))) { qCritical().noquote() << "Studio UI panel order/overlap violation" << size << voltage.area << current.area << tray.area << quality.area; return false; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index ecca26e80..49e9d235c 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -34,8 +34,9 @@ def main() -> None: and "Signals, firmware, PTP and device settings" in config, "Advanced must not imply this is only a simulated lab tool") require("Changes sent on Start" in main_qml - and "root.pendingApply && !workflowBar.injectionRunning" in main_qml, - "STOPPED waveform edits must distinguish draft from sent setpoints") + and "Changes pending · restart to apply" in main_qml + and "root.pendingApply ? studioTheme.amber" in main_qml, + "show draft edits distinctly from sent setpoints, even while injecting") controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") require("logUpdateTimer_.setInterval(100)" in controller and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller, @@ -115,8 +116,12 @@ def main() -> None: and 'enabled: !workflowBar.startQueued; onActivated: root.balanced()' in main_qml and 'enabled: !workflowBar.startQueued; onActivated: root.zeroAll()' in main_qml, "scenario editors and shortcuts must be frozen during a pending START snapshot") + start_path = workflow.split("function requestStart() {", 1)[1].split( + "function requestStop()", 1)[0] require("controller.pendingApply = true" in workflow - and "requestStartWithScenario(controller.startScenarioSnapshot())" in workflow + and "smartSession.requestStartWithScenario(" in start_path + and "controller.startScenarioSnapshot()" in start_path + and "smartSession.requestStart()" not in start_path and "controller.applyAllSignals()" not in workflow and "startWatchdog" in workflow and "onRunningChanged()" in workflow, "reconnect must not auto-transmit, but one-click Start must stage a bounded scenario") @@ -145,7 +150,8 @@ def main() -> None: and 'toolTipText: "Advanced"' not in ribbon and 'ToolTip.text: "smpSynch mode"' not in timing_ui, "captioned operator controls must not duplicate labels as tooltips") - require('requestStartWithScenario(controller.startScenarioSnapshot())' in workflow + require("smartSession.requestStartWithScenario(" in start_path + and "controller.startScenarioSnapshot()" in start_path and "Apply to board" not in main_qml, "Edit-to-Start must not regress to a manual Apply-to-board action") require("assets/material/smv_injector.svg" in cmake_icons @@ -156,6 +162,50 @@ def main() -> None: and "Equal magnitudes A/B/C" in matrix and "Balance angles · 120°" in matrix, "context actions must stay compact, descriptive and grouped") + # Freeze operator control geometry, hover contrast and table layout. + ribbon_action = source("RibbonAction.qml") + calm = source("CalmButton.qml") + check_box = source("StudioDarkCheckBox.qml") + combo = source("StudioDarkComboBox.qml") + require("Layout.minimumHeight: 408" in main_qml + and 'objectName: "studioInjectionScroll"' in main_qml + and "id: injectionEditor" in main_qml + and "contentHeight: injectionEditor.y + injectionEditor.height" in main_qml + and "Layout.fillHeight: true" in matrix + and "Item { Layout.fillHeight: true }" not in matrix, + "4I and 4V must stack, expand, and scroll rather than clip at minimum size") + require("width: 40" in ribbon and "height: 40" in ribbon + and 'font.pixelSize: bar.compact ? 14 : 15' in ribbon + and "anchors.centerIn: parent" in ribbon_action, + "Studio identity and ribbon content must be legible and centered") + require('hovered ? "#e9f2fd"' in calm + and 'hovered ? "#142334"' in calm + and 'hovered && !parent.checked ? "#142334"' in config + and 'choice.highlighted ? "#142334"' in combo, + "Advanced hover must use light background with dark text") + require("background: Item {}" in check_box + and "implicitWidth: 18" in check_box + and "implicitHeight: 18" in check_box, + "checkbox must not draw a second native hover glyph") + require("property bool ptpDefaultStartPending: true" in main_qml + and 'root.startPtpRole("SOURCE")' in main_qml + and "root.ptpExplicitOff = true" in main_qml + and "root.ptpDefaultStartPending = false" in main_qml + and "panel.controller.requestStopPtp()" in expert, + "PTP defaults ON after verified connection while explicit OFF cancels restarts") + require("property bool phasorDockVisible: true" in main_qml + and "property bool waveformDockVisible: true" in main_qml + and "tray.session.requestSmpSynch(" in tray, + "phasor and waveform must default visible and keep AUTO sync selection") + require("!workflowBar.session.liveControlReady" in main_qml + and "!workflowBar.session.requestSetFrequency(value)" in main_qml + and "!workflowBar.session.requestZero()" in main_qml + and "!workflowBar.session.requestSetQuality(activeSignal, unsignedValue)" in main_qml, + "unavailable or rejected live updates must be marked pending") + require("const sent = phaseLink && row < 3" in main_qml + and "if (!sent) {" in main_qml + and "pendingApply = true" in main_qml, + "live SET rejection must never silently claim displayed setpoints were sent") assert "implicitHeight: 65" in source("RibbonAction.qml") assert 'hovered ? "#142334"' in source("RibbonAction.qml") require('title: ""' in menu and 'property string contextLabel: ""' in menu diff --git a/docs/SMV_PTP_OPERATOR_BASELINE_NO_REGRESSION.md b/docs/SMV_PTP_OPERATOR_BASELINE_NO_REGRESSION.md new file mode 100644 index 000000000..19875fab6 --- /dev/null +++ b/docs/SMV_PTP_OPERATOR_BASELINE_NO_REGRESSION.md @@ -0,0 +1,46 @@ +# ARStack Studio — SMV/PTP non-regression baseline + +Physical checkpoint (2026-10-17, 120s ASIX/Npcap): 480,294 canonical VLAN +priority-tagged SV frames, APPID 0x4000, svID ARSTACK_SV01, +4000 nominal FPS with 0 smpCnt gaps, duplicates or reordering, 120 +counter wraps; 8 quality words/channel GOOD and AUTO smpSynch=1 LOCAL in +all captured SV frames. Diagnostic mirror OFF. Domain-0 two-step PTP +had 120 Announce, 120 Sync, 120 matched Follow_Up; SOURCE local clock +class 248. Host-arrival correlation placed smpCnt=0 near integer PTP +seconds with median +49 microseconds and stable relation for 120 epochs. + +That +49us is *not a measurement of hardware sample phase*; Npcap, +Windows and USB Ethernet add variable latency. A real PTP subscription, +peer-delay exchange, independent PPS and IED acceptance have not been +certified. Never replace measured AUTO=1 with an unconditional forced +flag; never automatically advertise globally traceable smpSynch=2. + +Mandatory anti-regression gates for every Studio release: + +1. Preserve tested M3C.6 SOURCE local AUTO qualification, phase tracking, + timestamp evidence, failure revocation and canonical 4000-FPS stream. + GUI-only fixes must not change the firmware protocol engine or PTP. +2. Every operator START must atomically enqueue the *current displayed* + FREQ, eight channel SET/ENABLE commands, CT shape and then START. + Do not use a bare START on the basis of a cached pendingApply flag. + Fail on queue exhaustion; never report a rejected live edit as applied. +3. End-to-end angle encoding at 1 A, scale 1000: + Ia -30 degrees => SET IA 1000 -30000 0 + Ib -150 degrees => SET IB 1000 -150000 0 + Ic +90 degrees => SET IC 1000 90000 0. + Do not silently replace with default 0/-120/+120 degree pattern. +4. Default PTP SOURCE ON only once a verified ESP32-P4 is ready; + smpSynch AUTO; user explicit OFF cancels any automatic restart. + Phasor and Wave docks visible by default. +5. Voltage above Current, with both tables occupying the editor height. + Shared buttons have centered content, light hover with dark captions, + and a single unchanged-size checkbox indicator when hovered. +6. Windows Native Preview must package firmware from the **same commit** + with phase servo ON, canonical hardware MAC TX timestamp evidence ON, + and diagnostic mirror OFF. Reject outdated firmware bundles. +7. CI: PTP Core GCC/Clang/MSVC, ESP32-P4 SV+PTP SOURCE/RECEIVER, + Studio Qt/operator workspace contracts, Windows Lifecycle and Native + Preview must all pass before any public merge/release candidate. + +This baseline documents the best known functioning stream, not third-party +subscriber certification or independent microsecond hardware accuracy.