diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt
index 993df310e..47bb3f659 100644
--- a/apps/arstack_studio/CMakeLists.txt
+++ b/apps/arstack_studio/CMakeLists.txt
@@ -158,6 +158,8 @@ qt_add_qml_module(arstack_studio
assets/material/file_upload.svg
assets/material/stop.svg
assets/material/play_arrow.svg
+ assets/material/run-bolt.svg
+ assets/material/stop-bolt.svg
assets/material/dashboard.svg
assets/material/folder_open.svg
assets/material/search.svg
diff --git a/apps/arstack_studio/assets/material/run-bolt.svg b/apps/arstack_studio/assets/material/run-bolt.svg
new file mode 100644
index 000000000..6df727593
--- /dev/null
+++ b/apps/arstack_studio/assets/material/run-bolt.svg
@@ -0,0 +1 @@
+
diff --git a/apps/arstack_studio/assets/material/stop-bolt.svg b/apps/arstack_studio/assets/material/stop-bolt.svg
new file mode 100644
index 000000000..71035e290
--- /dev/null
+++ b/apps/arstack_studio/assets/material/stop-bolt.svg
@@ -0,0 +1 @@
+
diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml
index e0ee2e98c..126652474 100644
--- a/apps/arstack_studio/qml/Main.qml
+++ b/apps/arstack_studio/qml/Main.qml
@@ -1065,8 +1065,12 @@ ApplicationWindow {
objectName: "studioInjectionSurface"
SplitView.fillWidth: true
SplitView.fillHeight: true
- SplitView.minimumWidth: root.compactLayout ? 500 : 550
- SplitView.preferredWidth: root.compactLayout ? 700 : 860
+ // Form fields must not expand across the whole screen.
+ // Keep the injector compact; give spare width to plots.
+ // Users can still resize the split manually.
+ SplitView.minimumWidth: root.compactLayout ? 480 : 520
+ SplitView.preferredWidth: root.compactLayout ? 505 : 610
+ SplitView.maximumWidth: 660
theme: studioTheme
ColumnLayout {
@@ -1295,9 +1299,9 @@ ApplicationWindow {
SplitView {
visible: (root.phasorDockVisible && !root.phasorDetached) ||
(root.waveformDockVisible && !root.waveformDetached)
- SplitView.preferredWidth: root.compactLayout ? 300 : 380
- SplitView.minimumWidth: root.compactLayout ? 270 : 300
- SplitView.maximumWidth: 680
+ SplitView.preferredWidth: root.compactLayout ? 420 : 560
+ SplitView.minimumWidth: root.compactLayout ? 330 : 380
+ SplitView.maximumWidth: 1200
SplitView.fillHeight: true
orientation: Qt.Vertical
handle: Rectangle {
diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml
index 193af1705..1f913ab45 100644
--- a/apps/arstack_studio/qml/ModernRibbon.qml
+++ b/apps/arstack_studio/qml/ModernRibbon.qml
@@ -220,11 +220,13 @@ Rectangle {
}
Rectangle {
- Layout.preferredWidth: bar.compact ? 84 : 108
- Layout.preferredHeight: 36
+ Layout.preferredWidth: bar.compact ? 92 : 120
+ Layout.preferredHeight: 39
Layout.alignment: Qt.AlignVCenter
- radius: 15
- color: "#0a1016"
+ radius: 12
+ // A noninteractive status, not another START/STOP authority.
+ // Green only after verified board RUNNING; muted red otherwise.
+ color: bar.device.running ? "#112a23" : "#271a20"
border.width: 1
border.color: Qt.rgba(bar.workflow.smartStateColor.r,
bar.workflow.smartStateColor.g,
@@ -236,21 +238,25 @@ Rectangle {
anchors.rightMargin: 9
spacing: 6
- Rectangle {
- width: 6
- height: 6
- radius: 3
- color: bar.workflow.smartStateColor
+ Image {
+ source: bar.device.running
+ ? Qt.resolvedUrl("../assets/material/run-bolt.svg")
+ : Qt.resolvedUrl("../assets/material/stop-bolt.svg")
+ sourceSize.width: 21
+ sourceSize.height: 21
+ Layout.preferredWidth: 21
+ Layout.preferredHeight: 21
+ fillMode: Image.PreserveAspectFit
+ opacity: bar.device.running ? 1.0 : 0.72
}
-
Label {
Layout.fillWidth: true
- text: bar.conciseState()
- color: bar.workflow.smartStateColor
+ text: bar.device.running ? "RUN" : "STOP"
+ color: bar.device.running ? bar.theme.green : "#b45d67"
font.family: bar.uiFont
- font.pixelSize: 9
- font.weight: Font.DemiBold
- elide: Text.ElideRight
+ font.pixelSize: 12
+ font.weight: Font.Bold
+ font.letterSpacing: 0.7
verticalAlignment: Text.AlignVCenter
}
}
diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml
index 06f5d88c6..f28210e89 100644
--- a/apps/arstack_studio/qml/WorkflowBar.qml
+++ b/apps/arstack_studio/qml/WorkflowBar.qml
@@ -17,14 +17,64 @@ 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)
+ readonly property bool startQueued: Boolean(
+ (ribbon.device && ribbon.device.scenarioApplying) || ribbon.startIntentWaiting)
+ // One deliberate click while 4I+4V is preparing remains armed only for
+ // this verified board boot. No automatic START after USB reconnect/flash.
+ property bool startIntentWaiting: false
+ property string startIntentBootId: ""
+ function cancelStartIntent() {
+ startIntentWaiting = false
+ startIntentBootId = ""
+ startIntentDeadline.stop()
+ }
+ Timer {
+ id: startIntentDeadline
+ interval: 8000
+ repeat: false
+ onTriggered: {
+ if (!ribbon.startIntentWaiting) return
+ ribbon.cancelStartIntent()
+ controller.showMessage(
+ "START belum dikirim: persiapan profil 4I+4V melebihi 8 detik. Periksa status board dan coba lagi.", true)
+ }
+ }
+ function deliverReadyStart() {
+ if (!startIntentWaiting || !ribbon.device || !ribbon.device.deviceVerified) return
+ if (ribbon.device.bootId !== startIntentBootId ||
+ smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp) {
+ cancelStartIntent()
+ return
+ }
+ if (ribbon.device.running || ribbon.device.scenarioApplying) {
+ cancelStartIntent()
+ return
+ }
+ if (!smartSession.startReady) return
+ cancelStartIntent()
+ // Snapshot is taken at the actual verified start boundary, not from
+ // stale startup defaults. No hidden retries after a rejected command.
+ const snapshot = controller.startScenarioSnapshot()
+ if (!snapshot) return
+ if (!smartSession.requestStartWithScenario(snapshot)) {
+ controller.pendingApply = true
+ controller.showMessage("START ditolak board. Periksa status koneksi dan diagnostics.", true)
+ }
+ }
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()
+ }
+ }
+ function onDeviceIdentityChanged() {
+ if (ribbon.startIntentWaiting && ribbon.device.bootId !== ribbon.startIntentBootId)
+ ribbon.cancelStartIntent()
}
}
property bool updatePromptDeferred: false
@@ -123,6 +173,7 @@ SurfacePanel {
Connections {
target: smartSession
function onReadyForLiveApply() {
+ if (ribbon.startIntentWaiting) Qt.callLater(ribbon.deliverReadyStart)
// The current board SHOW response does not contain per-channel
// amplitude/phase/quality. Never silently claim readback or
// overwrite a board with a restored/offline local draft.
@@ -132,6 +183,7 @@ SurfacePanel {
// No auto-transmit on device discovery/reconnection.
}
function onStateChanged() {
+ if (ribbon.startIntentWaiting) Qt.callLater(ribbon.deliverReadyStart)
if (ribbon.firmwareGateActive)
controller.profileDirty = true
@@ -271,9 +323,17 @@ 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.
- controller.showMessage("START waiting: " + ribbon.startReason(), true)
+ if (ribbon.device.deviceVerified &&
+ smartSession.state === "PREPARING 4I+4V" &&
+ ribbon.device.bootId.length > 0) {
+ ribbon.startIntentBootId = ribbon.device.bootId
+ ribbon.startIntentWaiting = true
+ startIntentDeadline.restart()
+ controller.showMessage(
+ "START diterima. Menunggu profil 4I+4V siap; injeksi dimulai setelah board terverifikasi.", false)
+ } else {
+ controller.showMessage("START belum siap: " + ribbon.startReason(), true)
+ }
return
}
// A START always transmits the current 4I+4V table snapshot.
@@ -291,6 +351,13 @@ SurfacePanel {
}
function requestStop() {
+ // An operator STOP cancels a queued one-click intent before any
+ // firmware command can be dispatched.
+ if (startIntentWaiting) {
+ cancelStartIntent()
+ controller.showMessage("START dibatalkan. Board tetap STOP.", 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..a80c31b31 100644
--- a/apps/arstack_studio/tests/check_operator_workspace.py
+++ b/apps/arstack_studio/tests/check_operator_workspace.py
@@ -191,7 +191,10 @@ 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 'ribbon.device.scenarioApplying) || ribbon.startIntentWaiting' in source("WorkflowBar.qml")
+ and 'if (startIntentWaiting)' in source("WorkflowBar.qml")
+ and 'cancelStartIntent()' 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 +540,29 @@ 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")
+ # Operator desktop layout: bounded injector column, space for plots,
+ # no extra QML START authority and no abandoned first-click intent.
+ layout = source("Main.qml")
+ ribbon = source("ModernRibbon.qml")
+ workflow = source("WorkflowBar.qml")
+ require("SplitView.maximumWidth: 660" in layout
+ and "SplitView.preferredWidth: root.compactLayout ? 505 : 610" in layout
+ and "SplitView.preferredWidth: root.compactLayout ? 420 : 560" in layout
+ and "run-bolt.svg" in ribbon and "stop-bolt.svg" in ribbon
+ and "assets/material/run-bolt.svg" in (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
+ and "assets/material/stop-bolt.svg" in (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
+ and 'text: bar.device.running ? "RUN" : "STOP"' in ribbon
+ and "property bool startIntentWaiting: false" in workflow
+ and "interval: 8000" in workflow
+ and "startIntentBootId" in workflow
+ and "function cancelStartIntent()" in workflow
+ and "function deliverReadyStart()" in workflow
+ and "smartSession.state === \"PREPARING 4I+4V\"" in workflow
+ and "if (startIntentWaiting)" in workflow
+ and "smartSession.requestStartWithScenario(snapshot)" in workflow,
+ "Studio must offer proportional engineering panels, verified bolt RUN/STOP "
+ "and first-click START through a bounded same-boot cancellable intent")
+
toast_layout = source("Main.qml")
toast_view = source("StatusToast.qml")
require("function explainFailure(message)" in toast_layout