diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index ddddca61c..79e188fb6 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -21,17 +21,18 @@ Button { scale: down ? 0.988 : 1.0 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") + readonly property color activeFill: !enabled ? "#10161d" : hovered ? "#e9f2fd" : + tone === "success" ? "#194d38" : + tone === "danger" ? theme.redSoft : + tone === "accent" ? theme.accentSoft : "#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 diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index fecaa51a3..a2c44dadf 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -29,18 +29,21 @@ Item { component ExpertTab: TabButton { implicitHeight: 42 + hoverEnabled: true font.family: hub.uiFont font.pixelSize: 11 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 ? "#172536" + : 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 diff --git a/apps/arstack_studio/qml/DarkToolButton.qml b/apps/arstack_studio/qml/DarkToolButton.qml index f21242095..6b5526990 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 ? theme.accentSoft : (control.hovered ? "#e9f2fd" : "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 66a0daf89..7bf2dbec8 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -118,7 +118,8 @@ ApplicationWindow { // Session-only operator intent. Never persist across application launches: // 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 ptpDesiredForSession: true + property bool defaultPtpStartAttempted: false property string ptpDesiredRole: "SOURCE" property bool ptpRestoreAfterFirmware: false property int ptpRestoreChecks: 0 @@ -239,6 +240,26 @@ ApplicationWindow { } } + // Operator default: PTP SOURCE ON once a verified board is ready. + // Keep the toggle tied to actual board telemetry, not this intent. + // Manual OFF cancels default ON for the rest of this application session. + Timer { + interval: 400 + repeat: true + running: !root.defaultPtpStartAttempted + onTriggered: { + const session = workflowBar.session + if (!session || !device.deviceVerified || !device.ptpAvailable || + !session.startReady || session.updatingFirmware || + device.ptpStarting) + return + root.defaultPtpStartAttempted = true + if (root.ptpDesiredForSession && !device.ptpRunning && + !root.startPtpRole("SOURCE")) + root.showMessage("PTP SOURCE could not start automatically. Use PTP On to retry.", true) + } + } + Timer { id: messageTimer interval: 2600 @@ -323,6 +344,7 @@ ApplicationWindow { } function requestStopPtp() { + root.defaultPtpStartAttempted = true root.ptpDesiredForSession = false root.ptpRestoreAfterFirmware = false return workflowBar.session && workflowBar.session.requestStopPtp() @@ -930,10 +952,27 @@ ApplicationWindow { SplitView.preferredWidth: root.compactLayout ? 700 : 860 theme: studioTheme - ColumnLayout { + Flickable { + id: injectionScroll + objectName: "studioInjectionScroll" anchors.fill: parent - anchors.margins: root.compactLayout ? 11 : 13 - spacing: root.compactLayout ? 6 : 8 + property real innerMargin: root.compactLayout ? 11 : 13 + clip: true + contentWidth: width + contentHeight: injectionContent.height + 2 * innerMargin + flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + ColumnLayout { + id: injectionContent + x: injectionScroll.innerMargin + y: injectionScroll.innerMargin + width: Math.max(0, injectionScroll.width - 2 * injectionScroll.innerMargin) + height: Math.max(injectionScroll.height - 2 * injectionScroll.innerMargin, + implicitHeight) + spacing: root.compactLayout ? 6 : 8 RowLayout { Layout.fillWidth: true @@ -1049,17 +1088,19 @@ ApplicationWindow { } } - RowLayout { + ColumnLayout { Layout.fillWidth: true - Layout.preferredHeight: root.compactLayout ? 214 : 224 - Layout.maximumHeight: root.compactLayout ? 214 : 224 - spacing: 9 + Layout.fillHeight: true + Layout.minimumHeight: 418 + spacing: 8 SignalMatrix { id: voltageMatrix enabled: !workflowBar.startQueued objectName: "studioVoltageMatrix" Layout.fillWidth: true Layout.fillHeight: true + Layout.preferredHeight: 205 + Layout.minimumHeight: 205 theme: studioTheme controller: root device: device @@ -1079,6 +1120,8 @@ ApplicationWindow { objectName: "studioCurrentMatrix" Layout.fillWidth: true Layout.fillHeight: true + Layout.preferredHeight: 205 + Layout.minimumHeight: 205 theme: studioTheme controller: root device: device @@ -1136,7 +1179,10 @@ ApplicationWindow { compact: root.compactLayout } } - Item { Layout.fillHeight: true } + // Stacked voltage/current matrices fill the full + // desktop panel; Flickable preserves all controls + // on smaller Windows viewports without clipping. + } } } diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index aa995b10d..b46d8ed9b 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 ? 148 : 174 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - width: 28 - height: 28 - radius: 7 + width: 38 + height: 38 + radius: 9 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 15 - height: 15 + width: 25 + height: 25 source: Qt.resolvedUrl("../assets/material/smv_injector.svg") - sourceSize.width: 30 - sourceSize.height: 30 + sourceSize.width: 50 + sourceSize.height: 50 } } @@ -73,7 +73,7 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: 11 + font.pixelSize: bar.compact ? 12 : 14 font.weight: Font.DemiBold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index a882bb495..93c3fbcd0 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -39,7 +39,8 @@ Button { : tone === "danger" ? "#ffe3e6" : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft contentItem: ColumnLayout { - spacing: 3 + anchors.centerIn: parent + spacing: 4 Image { visible: control.iconSource.toString().length > 0 source: control.hovered && control.enabled ? control.hoverPath : control.iconSource @@ -56,7 +57,7 @@ Button { text: control.text color: control.textColor font: control.font - Layout.alignment: Qt.AlignHCenter + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 1c84e2b43..0190dde10 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -196,7 +196,7 @@ Rectangle { } Layout.fillWidth: true - Layout.preferredHeight: matrix.compact ? 40 : 42 + Layout.preferredHeight: matrix.compact ? 34 : 36 color: signalRow.selected ? "#101c27" : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#0f1922" : rowHover.hovered ? "#0d161e" : "transparent" diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index eedfce586..b08f858b4 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -9,15 +9,20 @@ CheckBox { property string uiFont: "Inter" implicitHeight: 30 spacing: 9 + hoverEnabled: true + padding: 0 font.family: uiFont font.pixelSize: 11 indicator: Rectangle { - implicitWidth: 18 - implicitHeight: 18 + implicitWidth: 19 + implicitHeight: 19 + width: 19 + height: 19 x: control.leftPadding y: (control.height - height) / 2 radius: 5 - color: control.checked ? control.theme.accent : control.theme.surface2 + color: control.checked ? control.theme.accent + : control.hovered ? control.theme.raisedHover : control.theme.surface2 border.width: 1 border.color: control.checked ? control.theme.accent : control.activeFocus ? control.theme.textSoft : control.theme.line diff --git a/apps/arstack_studio/qml/StudioDarkComboBox.qml b/apps/arstack_studio/qml/StudioDarkComboBox.qml index 60f7afb00..8c9308758 100644 --- a/apps/arstack_studio/qml/StudioDarkComboBox.qml +++ b/apps/arstack_studio/qml/StudioDarkComboBox.qml @@ -54,7 +54,7 @@ ComboBox { highlighted: combo.highlightedIndex === index contentItem: Text { text: combo.textAt(choice.index) - color: combo.theme.text + color: choice.hovered || 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: choice.hovered || choice.highlighted ? "#e9f2fd" : combo.theme.raised } } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index e49593114..52f427952 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() + // Always send the live table as one bounded FIFO scenario before + // START. Dirty flags and persisted draft state are not wire readback. + // This prevents previous board phases (e.g. Ia=0) surviving when + // the table visibly specifies Ia=-30 degrees. + 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/main.cpp b/apps/arstack_studio/src/main.cpp index 13518af84..4fc5d7cb5 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -67,27 +67,50 @@ struct UiBounds { const auto current = findUiBounds(window, "studioCurrentMatrix"); const auto tray = findUiBounds(window, "studioTimingTray"); const auto quality = findUiBounds(window, "studioQualityRow"); - const std::array, 6> areas{{ + const auto scroll = findUiBounds(window, "studioInjectionScroll"); + const auto* scrollItem = window->findChild( + QStringLiteral("studioInjectionScroll")); + const qreal scrollContentHeight = scrollItem != nullptr + ? scrollItem->property("contentHeight").toDouble() : 0.0; + const bool needsScroll = scrollContentHeight > scroll.area.height() + tolerance; + const std::array, 7> areas{{ {"toolbar", bar}, {"injection workspace", surface}, - {"voltage matrix", voltage}, {"current matrix", current}, - {"PTP timing tray", tray}, {"quality editor", quality} + {"injection scroller", scroll}, {"voltage matrix", voltage}, + {"current matrix", current}, {"PTP timing tray", tray}, + {"quality editor", quality} }}; for (const auto& [label, item] : areas) { + const bool mustFitViewport = !needsScroll || + (item.area == bar.area || item.area == surface.area || + item.area == scroll.area); + const bool withinViewport = viewport.adjusted( + -tolerance, -tolerance, tolerance, tolerance) + .contains(item.area.adjusted(tolerance, tolerance, + -tolerance, -tolerance)); + // On minimum-height Windows screens the lower controls are reachable + // by vertical scrolling, not clipped or silently removed. + const bool insideScrollableContent = + item.area.left() >= scroll.area.left() - tolerance && + item.area.right() <= scroll.area.right() + tolerance && + item.area.top() >= scroll.area.top() - tolerance && + item.area.bottom() <= scroll.area.top() + + scrollContentHeight + tolerance; 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))) { - qCritical().noquote() << "Studio UI viewport violation" << size << label - << "bounds=" << item.area << "viewport=" << viewport; + (mustFitViewport ? !withinViewport : !insideScrollableContent)) { + qCritical().noquote() << "Studio UI viewport/scroll violation" + << size << label << "bounds=" << item.area + << "viewport=" << viewport + << "scroll content height=" << scrollContentHeight; return false; } } - if (!(voltage.area.right() <= current.area.left() + tolerance - && tray.area.top() >= voltage.area.bottom() - tolerance + if (!(voltage.area.bottom() <= current.area.top() + tolerance + && tray.area.top() >= current.area.bottom() - tolerance && quality.area.top() >= tray.area.bottom() - tolerance - && quality.area.bottom() <= surface.area.bottom() + tolerance)) { - qCritical().noquote() << "Studio UI panel order/overlap violation" << size - << voltage.area << current.area << tray.area << quality.area; + && quality.area.bottom() <= scroll.area.top() + scrollContentHeight + tolerance)) { + qCritical().noquote() << "Studio stacked panel order/overlap violation" + << size << voltage.area << current.area + << tray.area << quality.area; return false; } const std::array controls{{ diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index ecca26e80..4c3fe556a 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -48,6 +48,44 @@ 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") + workflow = source("WorkflowBar.qml") + # P0: software timing, SV egress/smpCnt and phase-angle staging must + # stay untouched by a UI-only reflow. Every START sends the actual + # eight visible channel phases, not a stale pre-existing board state. + require("const accepted = smartSession.requestStartWithScenario(" in workflow + and "controller.startScenarioSnapshot()" in workflow + and "smartSession.requestStart()" not in workflow.split( + "function requestStart() {")[1].split("function requestStop() {")[0], + "SV START must always stage the full displayed 4I+4V scenario") + ui_geometry = (STUDIO / "src/main.cpp").read_text(encoding="utf-8") + require("Flickable {" in main_qml + and 'objectName: "studioInjectionScroll"' in main_qml + and "contentHeight: injectionContent.height" in main_qml + and "Layout.fillHeight: true" in main_qml + and "id: voltageMatrix" in main_qml + and "id: currentMatrix" in main_qml + and "Layout.minimumHeight: 418" in main_qml + and "voltage.area.bottom() <= current.area.top()" in ui_geometry + and "insideScrollableContent" in ui_geometry, + "stacked 4V/4I tables must remain fully reachable at minimum Windows height") + require('property bool ptpDesiredForSession: true' in main_qml + and "defaultPtpStartAttempted" in main_qml + and "root.ptpDesiredForSession = false" in main_qml, + "PTP SOURCE default ON must preserve immediate user OFF") + ribbon = source("ModernRibbon.qml") + ribbon_action = source("RibbonAction.qml") + calm = source("CalmButton.qml") + checks = source("StudioDarkCheckBox.qml") + combo = source("StudioDarkComboBox.qml") + require("width: 38" in ribbon and "width: 25" in ribbon + and "font.pixelSize: bar.compact ? 12 : 14" in ribbon, + "SMV Injector logo/branding must remain readable at desktop scale") + require('hovered ? "#142334"' in calm + and 'hovered ? "#142334"' in ribbon_action + and '"#142334"' in combo + and "hoverEnabled: true" in checks + and "width: 19" in checks and "height: 19" in checks, + "Advanced hover captions must remain dark on white, checkbox size stable") matrix = source("SignalMatrix.qml") menu = source("StudioActionMenu.qml") cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") @@ -116,7 +154,8 @@ def main() -> None: and 'enabled: !workflowBar.startQueued; onActivated: root.zeroAll()' in main_qml, "scenario editors and shortcuts must be frozen during a pending START snapshot") require("controller.pendingApply = true" in workflow - and "requestStartWithScenario(controller.startScenarioSnapshot())" in workflow + and "smartSession.requestStartWithScenario(" in workflow + and "controller.startScenarioSnapshot()" in workflow 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 +184,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 workflow + and "controller.startScenarioSnapshot()" in workflow 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