diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index ddddca61c..eb418851a 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -22,6 +22,7 @@ Button { Behavior on scale { NumberAnimation { duration: 70; easing.type: Easing.OutCubic } } readonly property color activeFill: !enabled ? "#10161d" : + hovered ? "#edf5ff" : tone === "success" ? (hovered ? "#1e5c43" : "#194d38") : tone === "danger" ? (hovered ? "#523039" : theme.redSoft) : tone === "accent" ? (hovered ? "#1d3c5e" : theme.accentSoft) : @@ -32,11 +33,14 @@ Button { 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 { + // The same item owns both the icon and text; no native Button + // hover glyph can appear independently. spacing: control.iconSource.toString().length > 0 ? 7 : 0 anchors.centerIn: parent Image { diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index fecaa51a3..4e8695895 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -34,13 +34,13 @@ 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 ? "#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 ? "#edf5ff" : 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/Main.qml b/apps/arstack_studio/qml/Main.qml index 66a0daf89..72ac77a79 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -52,10 +52,9 @@ ApplicationWindow { function markScenarioDirty() { if (restoringScenario) return 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) - pendingApply = true + // All edits are a draft until the matching command is queued. + // Never label a visible draft as proven transmitted waveform data. + pendingApply = true } function restoreLocalScenario() { const saved = scenarioSettings.lastScenario @@ -122,6 +121,7 @@ ApplicationWindow { property string ptpDesiredRole: "SOURCE" property bool ptpRestoreAfterFirmware: false property int ptpRestoreChecks: 0 + property bool ptpDefaultStartAttempted: false property bool restoringScenario: false property bool offlinePreviewRunning: false property real currentScale: 1000.0 @@ -215,6 +215,23 @@ ApplicationWindow { } } + // Operator-friendly default: start SOURCE with the verified profile + // once the connected board is READY, never in a blind reconnect loop. + // A deliberate OFF cancels this default for the rest of the session. + Timer { + interval: 500 + repeat: true + running: !root.ptpDefaultStartAttempted + onTriggered: { + const session = workflowBar.session + if (!session || !device.deviceVerified || !session.startReady || + session.updatingFirmware || device.ptpStarting) return + root.ptpDefaultStartAttempted = true + if (!device.ptpRunning && !root.startPtpRole("SOURCE")) + root.showMessage("PTP could not start automatically; use PTP On to retry.", true) + } + } + Timer { interval: 300 repeat: true @@ -323,6 +340,7 @@ ApplicationWindow { } function requestStopPtp() { + root.ptpDefaultStartAttempted = true root.ptpDesiredForSession = false root.ptpRestoreAfterFirmware = false return workflowBar.session && workflowBar.session.requestStopPtp() @@ -467,9 +485,12 @@ ApplicationWindow { selectSignal(group, row) markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) { - if (phaseLink && row < 3) sendLinkedGroup(group) - else sendSignal(group, row) + if (workflowBar.injectionRunning && workflowBar.session && + workflowBar.session.liveControlReady) { + const queued = phaseLink && row < 3 + ? sendLinkedGroup(group) : sendSignal(group, row) + if (!queued) showMessage("Signal update not queued; retry or Stop/Start.", true) + else pendingApply = false } return true } @@ -487,6 +508,8 @@ ApplicationWindow { return ok } function startScenarioSnapshot() { + // START always serializes the same ListModels that draw the table, + // including all eight current phase angles and enabled flags. var draft = scenarioSnapshot() draft.currentScale = currentScale draft.voltageScale = voltageScale @@ -698,10 +721,10 @@ ApplicationWindow { } Rectangle { width: 1; height: 12; color: studioTheme.lineSoft } Label { - text: root.pendingApply && !workflowBar.injectionRunning - ? "4I + 4V · Changes sent on Start" + text: root.pendingApply + ? "4I + 4V · Setpoints pending · Start to apply" : "4I + 4V · 4000 samples/s" - color: root.pendingApply && !workflowBar.injectionRunning + color: root.pendingApply ? studioTheme.amber : studioTheme.muted font.family: root.uiFont font.pixelSize: 9 @@ -1049,11 +1072,11 @@ ApplicationWindow { } } - RowLayout { + ColumnLayout { Layout.fillWidth: true - Layout.preferredHeight: root.compactLayout ? 214 : 224 - Layout.maximumHeight: root.compactLayout ? 214 : 224 - spacing: 9 + Layout.preferredHeight: root.compactLayout ? 352 : 384 + Layout.maximumHeight: root.compactLayout ? 352 : 384 + spacing: 7 SignalMatrix { id: voltageMatrix enabled: !workflowBar.startQueued diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index aa995b10d..6776d73df 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -47,21 +47,21 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 105 : 124 + Layout.preferredWidth: bar.compact ? 152 : 182 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 @@ -73,8 +73,8 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: 11 - font.weight: Font.DemiBold + font.pixelSize: 15 + 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 a882bb495..ae84160c4 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -40,6 +40,7 @@ Button { : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft contentItem: ColumnLayout { spacing: 3 + anchors.centerIn: parent Image { visible: control.iconSource.toString().length > 0 source: control.hovered && control.enabled ? control.hoverPath : control.iconSource @@ -56,7 +57,8 @@ Button { text: control.text color: control.textColor font: control.font - Layout.alignment: Qt.AlignHCenter + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + Layout.fillWidth: true horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index eedfce586..fd2794a78 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -9,6 +9,7 @@ CheckBox { property string uiFont: "Inter" implicitHeight: 30 spacing: 9 + hoverEnabled: true font.family: uiFont font.pixelSize: 11 indicator: Rectangle { @@ -19,7 +20,7 @@ CheckBox { radius: 5 color: control.checked ? control.theme.accent : control.theme.surface2 border.width: 1 - border.color: control.checked ? control.theme.accent + border.color: control.checked || control.hovered ? control.theme.accent : control.activeFocus ? control.theme.textSoft : control.theme.line Text { anchors.centerIn: parent @@ -31,6 +32,7 @@ CheckBox { } } contentItem: Text { + horizontalAlignment: Text.AlignLeft leftPadding: control.indicator.width + control.spacing text: control.text font: control.font diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index e49593114..bbc858ac4 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -281,9 +281,11 @@ SurfacePanel { controller.showMessage(ribbon.startReason(), false) return } - const accepted = controller.pendingApply - ? smartSession.requestStartWithScenario(controller.startScenarioSnapshot()) - : smartSession.requestStart() + // A START always transmits the current 4I+4V table snapshot. + // A previous run may have cleared pendingApply; a bare START could + // then repeat old board angles while Studio displays edited values. + 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..1d2c0b492 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -82,8 +82,10 @@ struct UiBounds { return false; } } - if (!(voltage.area.right() <= current.area.left() + tolerance - && tray.area.top() >= voltage.area.bottom() - tolerance + // Operator layout: voltage above current. Validate the new actual + // vertical hierarchy, not the obsolete side-by-side geometry. + 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 diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index ecca26e80..afea41801 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -33,9 +33,20 @@ def main() -> None: require("Lab simulation controls" not in config 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") + require("Setpoints pending · Start to apply" in main_qml + and "root.pendingApply" in main_qml + and "pendingApply = true" in main_qml, + "table edits must expose unsent setpoints without claiming wire readback") + require("Layout.preferredHeight: root.compactLayout ? 352 : 384" in main_qml + and "ColumnLayout {" in main_qml, + "voltage and current must stack vertically to use the workspace") + require('property bool ptpDefaultStartAttempted: false' in main_qml + and 'root.ptpDefaultStartAttempted = true' in main_qml + and '!root.startPtpRole("SOURCE")' in main_qml, + "default PTP start must be one-shot and deliberate OFF must cancel it") + require("phaseLink && row < 3" in main_qml + and "sendLinkedGroup(group) : sendSignal(group, row)" in main_qml, + "edited 3-phase angles must be transmitted from the same table model") controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") require("logUpdateTimer_.setInterval(100)" in controller and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller, @@ -81,6 +92,10 @@ def main() -> None: require("1.75" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), "reuse Windows executable for 100%, 150% and 175% DPI acceptance") workflow = source("WorkflowBar.qml") + require('const accepted = smartSession.requestStartWithScenario(' in workflow + and 'controller.startScenarioSnapshot()' in workflow + and 'smartSession.requestStart()' not in workflow, + "every Start must send the visible 4I+4V table, never reuse stale device angles") require("Qt.callLater(installDialog.open)" not in workflow and "Qt.callLater(updateDialog.open)" not in workflow and "if (!success) Qt.callLater(resultDialog.open)" in workflow @@ -116,7 +131,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 +161,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