diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt
index 993df310e..79db977ea 100644
--- a/apps/arstack_studio/CMakeLists.txt
+++ b/apps/arstack_studio/CMakeLists.txt
@@ -170,6 +170,8 @@ qt_add_qml_module(arstack_studio
assets/material/device_hub.svg
assets/material/help_outline.svg
assets/material/smv_injector.svg
+ assets/material/electric_bolt_run.svg
+ assets/material/electric_bolt_stop.svg
)
qt_add_resources(arstack_studio "reference_templates"
diff --git a/apps/arstack_studio/assets/material/electric_bolt.svg b/apps/arstack_studio/assets/material/electric_bolt.svg
new file mode 100644
index 000000000..a1c3d2793
--- /dev/null
+++ b/apps/arstack_studio/assets/material/electric_bolt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/arstack_studio/assets/material/electric_bolt_run.svg b/apps/arstack_studio/assets/material/electric_bolt_run.svg
new file mode 100644
index 000000000..81cf2396b
--- /dev/null
+++ b/apps/arstack_studio/assets/material/electric_bolt_run.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/arstack_studio/assets/material/electric_bolt_stop.svg b/apps/arstack_studio/assets/material/electric_bolt_stop.svg
new file mode 100644
index 000000000..daa281f8a
--- /dev/null
+++ b/apps/arstack_studio/assets/material/electric_bolt_stop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml
index e0ee2e98c..c8a46b6e8 100644
--- a/apps/arstack_studio/qml/Main.qml
+++ b/apps/arstack_studio/qml/Main.qml
@@ -7,8 +7,8 @@ import ARStack.Studio 1.0
ApplicationWindow {
id: root
- width: 1240
- height: 760
+ width: 1150
+ height: 790
minimumWidth: 960
minimumHeight: 620
visible: true
@@ -192,7 +192,7 @@ ApplicationWindow {
source: Qt.resolvedUrl("../assets/InterVariable.ttf")
}
- readonly property bool compactLayout: width < 1120
+ readonly property bool compactLayout: width < 1220
readonly property bool canDeploy: workflowBar.session ? workflowBar.session.canDeployProfile : false
readonly property bool canStart: workflowBar.session ? workflowBar.session.startReady : false
readonly property string toastMessage: transientMessage
@@ -1065,8 +1065,9 @@ ApplicationWindow {
objectName: "studioInjectionSurface"
SplitView.fillWidth: true
SplitView.fillHeight: true
- SplitView.minimumWidth: root.compactLayout ? 500 : 550
- SplitView.preferredWidth: root.compactLayout ? 700 : 860
+ SplitView.minimumWidth: root.compactLayout ? 500 : 540
+ SplitView.preferredWidth: root.compactLayout ? 600 : 690
+ SplitView.maximumWidth: 750
theme: studioTheme
ColumnLayout {
@@ -1295,8 +1296,8 @@ ApplicationWindow {
SplitView {
visible: (root.phasorDockVisible && !root.phasorDetached) ||
(root.waveformDockVisible && !root.waveformDetached)
- SplitView.preferredWidth: root.compactLayout ? 300 : 380
- SplitView.minimumWidth: root.compactLayout ? 270 : 300
+ SplitView.preferredWidth: root.compactLayout ? 450 : 470
+ SplitView.minimumWidth: root.compactLayout ? 300 : 340
SplitView.maximumWidth: 680
SplitView.fillHeight: true
orientation: Qt.Vertical
diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml
index 193af1705..bce90ffec 100644
--- a/apps/arstack_studio/qml/ModernRibbon.qml
+++ b/apps/arstack_studio/qml/ModernRibbon.qml
@@ -220,36 +220,42 @@ Rectangle {
}
Rectangle {
- Layout.preferredWidth: bar.compact ? 84 : 108
- Layout.preferredHeight: 36
+ Layout.preferredWidth: bar.compact ? 84 : 102
+ Layout.preferredHeight: 40
Layout.alignment: Qt.AlignVCenter
radius: 15
color: "#0a1016"
border.width: 1
- border.color: Qt.rgba(bar.workflow.smartStateColor.r,
- bar.workflow.smartStateColor.g,
- bar.workflow.smartStateColor.b, 0.48)
+ border.color: bar.device.deviceVerified && bar.device.running
+ ? bar.theme.green : "#663942"
+ readonly property bool physicalRunning: bar.device.deviceVerified &&
+ bar.device.running
+ readonly property color runColor: physicalRunning
+ ? bar.theme.green : "#b66069"
RowLayout {
anchors.fill: parent
- anchors.leftMargin: 9
+ anchors.leftMargin: 10
anchors.rightMargin: 9
spacing: 6
-
- Rectangle {
- width: 6
- height: 6
- radius: 3
- color: bar.workflow.smartStateColor
- }
-
+ Image {
+ Layout.preferredWidth: 19
+ Layout.preferredHeight: 19
+ source: Qt.resolvedUrl(parent.parent.physicalRunning
+ ? "../assets/material/electric_bolt_run.svg"
+ : "../assets/material/electric_bolt_stop.svg")
+ sourceSize.width: 24
+ sourceSize.height: 24
+ opacity: parent.parent.physicalRunning ? 1.0 : 0.48
+ }
Label {
Layout.fillWidth: true
- text: bar.conciseState()
- color: bar.workflow.smartStateColor
+ text: bar.device.deviceVerified
+ ? (bar.device.running ? "RUN" : "STOP") : "OFF"
+ color: parent.parent.runColor
font.family: bar.uiFont
- font.pixelSize: 9
- font.weight: Font.DemiBold
+ font.pixelSize: 11
+ font.weight: Font.Bold
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml
index 06f5d88c6..3295cbe9d 100644
--- a/apps/arstack_studio/qml/WorkflowBar.qml
+++ b/apps/arstack_studio/qml/WorkflowBar.qml
@@ -17,14 +17,78 @@ SurfacePanel {
property bool compact: false
// Firmware transaction is the single source of truth. UI timers and
// unrelated PTP/serial errors must not bounce the injection START button.
- readonly property bool startQueued: Boolean(ribbon.device && ribbon.device.scenarioApplying)
+ // Operator intent may arrive before first profile synchronization ends.
+ // One bounded, boot-bound pending click is consumed as soon as the SAME
+ // verified board enters READY. Never persist/replay across reconnect.
+ property bool startIntentPending: false
+ property string startIntentBoot: ""
+ property double startIntentDeadline: 0
+ readonly property bool startQueued: Boolean(
+ startIntentPending || (ribbon.device && ribbon.device.scenarioApplying))
+ Timer {
+ id: startIntentExpiry
+ interval: 6500
+ repeat: false
+ onTriggered: {
+ if (!ribbon.startIntentPending) return
+ ribbon.cancelStartIntent()
+ controller.showMessage(
+ "START not sent: device preparation timed out. Check board/profile state and retry.", true)
+ }
+ }
+ function cancelStartIntent() {
+ startIntentPending = false
+ startIntentBoot = ""
+ startIntentDeadline = 0
+ startIntentExpiry.stop()
+ }
+ function queueFirstStart() {
+ // Only a verified, physically connected board may receive deferred
+ // START. Never arm for offline, firmware update or reconnect.
+ if (!device.deviceVerified || !device.connected ||
+ smartSession.updatingFirmware || smartSession.firmwareUpdateRequired ||
+ smartSession.firmwareInstallRequired) return false
+ const boot = String(device.bootId)
+ if (!boot || boot === "undefined") return false
+ startIntentBoot = boot
+ startIntentPending = true
+ startIntentDeadline = Date.now() + 6500
+ startIntentExpiry.restart()
+ controller.showMessage("START queued — waiting for 4I+4V profile readiness on this board.", false)
+ return true
+ }
+ function tryConsumeFirstStart() {
+ if (!startIntentPending || !smartSession.startReady) return
+ const boot = String(device.bootId)
+ if (!device.deviceVerified || !device.connected || boot !== startIntentBoot ||
+ Date.now() > startIntentDeadline) {
+ cancelStartIntent()
+ return
+ }
+ const requestedBoot = startIntentBoot
+ cancelStartIntent()
+ Qt.callLater(function() {
+ // Prevent a reconnect between readiness notification and dispatch.
+ if (device.deviceVerified && device.connected &&
+ String(device.bootId) === requestedBoot && smartSession.startReady &&
+ !device.running && !device.scenarioApplying)
+ ribbon.requestStart()
+ })
+ }
+ Connections {
+ target: smartSession
+ function onStateChanged() { ribbon.tryConsumeFirstStart() }
+ }
Connections {
target: ribbon.device
function onRunningChanged() {
if (ribbon.device.running) controller.pendingApply = false
}
function onDeviceVerifiedChanged() {
- if (!ribbon.device.deviceVerified) controller.pendingApply = true
+ if (!ribbon.device.deviceVerified) {
+ controller.pendingApply = true
+ ribbon.cancelStartIntent()
+ }
}
}
property bool updatePromptDeferred: false
@@ -271,8 +335,13 @@ SurfacePanel {
return
}
if (!smartSession.startReady) {
- // One click must explain why a physical START is not queued.
- // Never make the operator click twice to discover readiness.
+ // A first click during same-device profile preparation is retained
+ // briefly and dispatched once READY. Other errors are explained.
+ if (smartSession.state === "PREPARING 4I+4V" ||
+ smartSession.state === "RECOVERING PROFILE" ||
+ smartSession.state === "DEVICE FOUND") {
+ if (ribbon.queueFirstStart()) return
+ }
controller.showMessage("START waiting: " + ribbon.startReason(), true)
return
}
@@ -291,6 +360,11 @@ SurfacePanel {
}
function requestStop() {
+ if (ribbon.startIntentPending) {
+ ribbon.cancelStartIntent()
+ controller.showMessage("Pending START cancelled.", false)
+ return
+ }
// A pending CFG transaction can progress to START: Stop must remain
// actionable before RUNNING telemetry, not only after it.
if (!ribbon.injectionRunning && !ribbon.startQueued) return
diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py
index fe344ee67..aa459e8ae 100644
--- a/apps/arstack_studio/tests/check_operator_workspace.py
+++ b/apps/arstack_studio/tests/check_operator_workspace.py
@@ -191,7 +191,8 @@ def main() -> None:
require('bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning'
in ribbon_qml
and '!ribbon.injectionRunning && !ribbon.startQueued' in source("WorkflowBar.qml")
- and 'readonly property bool startQueued: Boolean(ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml")
+ and 'readonly property bool startQueued: Boolean(' in source("WorkflowBar.qml")
+ and 'startIntentPending || (ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml")
and 'startWatchdog' not in source("WorkflowBar.qml"),
"SV STOP must remain actionable while the acknowledged scenario transaction is pending")
require("function flushPendingEdits()" in matrix
@@ -537,6 +538,33 @@ def main() -> None:
"SV output must fail-closed on measured heartbeat loss, preserve board STOP cause, "
"and never show misleading green READY after an unrequested stop")
+ # Default workbench should never stretch the 4I+4V setpoint matrix
+ # beyond a useful editing width while starving Phasor/Waveform charts.
+ responsive_main = source("Main.qml")
+ ribbon_layout = source("ModernRibbon.qml")
+ start_flow = source("WorkflowBar.qml")
+ require("width: 1150" in responsive_main
+ and "SplitView.maximumWidth: 750" in responsive_main
+ and "SplitView.preferredWidth: root.compactLayout ? 450 : 470" in responsive_main
+ and "electric_bolt_run.svg" in ribbon_layout
+ and "electric_bolt_stop.svg" in ribbon_layout
+ and "assets/material/electric_bolt_run.svg" in
+ (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
+ and "assets/material/electric_bolt_stop.svg" in
+ (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
+ and "bar.device.running ? \"RUN\" : \"STOP\"" in ribbon_layout,
+ "Studio default workbench must balance channels and charts; physical RUN/STOP uses supplied bolt")
+ require("property bool startIntentPending: false" in start_flow
+ and "String(device.bootId)" in start_flow
+ and "startIntentDeadline = Date.now() + 6500" in start_flow
+ and "onStateChanged() { ribbon.tryConsumeFirstStart() }" in start_flow
+ and "function onDeviceVerifiedChanged()" in start_flow
+ and "ribbon.cancelStartIntent()" in start_flow
+ and "if (ribbon.startIntentPending)" in start_flow
+ and "String(device.bootId) === requestedBoot" in start_flow
+ and "smartSession.requestStartWithScenario(scenario)" in start_flow,
+ "First START click must be bounded, single-device, cancelable, and use verified scenario START")
+
toast_layout = source("Main.qml")
toast_view = source("StatusToast.qml")
require("function explainFailure(message)" in toast_layout