From f52f67707684f28b95b7dab0834b0041eca4dde0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:01:19 +0700 Subject: [PATCH 01/13] feat(ux): add operator-provided lightning icon for physical RUN/STOP indicator --- apps/arstack_studio/assets/material/electric_bolt.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 apps/arstack_studio/assets/material/electric_bolt.svg 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 From f724e2080d5c66dc8274f025add64400352e2044 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:01:37 +0700 Subject: [PATCH 02/13] feat(ribbon): physical RUN/STOP lightning CTA bound to board RUNNING state --- apps/arstack_studio/qml/ModernRibbon.qml | 40 ++++++++++++++---------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 193af1705..1745f6080 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("../assets/material/electric_bolt.svg") + sourceSize.width: 24 + sourceSize.height: 24 + opacity: parent.parent.physicalRunning ? 1.0 : 0.48 + layer.enabled: true + layer.effect: null } - 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 } From 2de8fd2e9e6c4667eab00149b881fedbad76d6b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:01:51 +0700 Subject: [PATCH 03/13] fix(layout): balanced professional default 1150px studio with bounded injection matrix and wider charts --- apps/arstack_studio/qml/Main.qml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 From 0dd29af453a4ee9f8a0a591fd685cfc066c0de17 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:02:24 +0700 Subject: [PATCH 04/13] fix(start): bounded same-boot first-click intent through profile preparation, cancel on STOP/disconnect --- apps/arstack_studio/qml/WorkflowBar.qml | 81 +++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 06f5d88c6..2bcf99b24 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -17,14 +17,77 @@ 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.deviceIdentity ? device.deviceIdentity.bootId : 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.deviceIdentity ? device.deviceIdentity.bootId : device.bootId) + if (!device.deviceVerified || !device.connected || boot !== startIntentBoot || + Date.now() > startIntentDeadline) { + cancelStartIntent() + return + } + cancelStartIntent() + Qt.callLater(function() { + // Verify the device again AFTER scheduling, never replay a stale + // intent across a new session, and snapshot the actual GUI now. + if (device.deviceVerified && device.connected && 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 +334,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 +359,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 From bbd036cda6ed64351e1e04936557dacdee42a741 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:02:44 +0700 Subject: [PATCH 05/13] fix(start): bind deferred first-click identity to actual exposed bootId Q_PROPERTY --- apps/arstack_studio/qml/WorkflowBar.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 2bcf99b24..b09d7f733 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -48,7 +48,7 @@ SurfacePanel { if (!device.deviceVerified || !device.connected || smartSession.updatingFirmware || smartSession.firmwareUpdateRequired || smartSession.firmwareInstallRequired) return false - const boot = String(device.deviceIdentity ? device.deviceIdentity.bootId : device.bootId) + const boot = String(device.bootId) if (!boot || boot === "undefined") return false startIntentBoot = boot startIntentPending = true @@ -59,7 +59,7 @@ SurfacePanel { } function tryConsumeFirstStart() { if (!startIntentPending || !smartSession.startReady) return - const boot = String(device.deviceIdentity ? device.deviceIdentity.bootId : device.bootId) + const boot = String(device.bootId) if (!device.deviceVerified || !device.connected || boot !== startIntentBoot || Date.now() > startIntentDeadline) { cancelStartIntent() From 61bc8995d6fd1fc8ca29cda88ab392e2942a1028 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:03:02 +0700 Subject: [PATCH 06/13] feat(ux): dedicated board RUN and muted STOP lightning asset --- apps/arstack_studio/assets/material/electric_bolt_run.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 apps/arstack_studio/assets/material/electric_bolt_run.svg 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 From 1fb40d96458ed4e0fd909b8506700de3134e0469 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:03:05 +0700 Subject: [PATCH 07/13] feat(ux): dedicated board RUN and muted STOP lightning asset --- apps/arstack_studio/assets/material/electric_bolt_stop.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 apps/arstack_studio/assets/material/electric_bolt_stop.svg 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 From 8ada9f53853a58f0a75d1f6e80e851a5c20f8e87 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:03:18 +0700 Subject: [PATCH 08/13] style(ribbon): green live lightning and muted red stopped lightning assets --- apps/arstack_studio/qml/ModernRibbon.qml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 1745f6080..bce90ffec 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -241,13 +241,13 @@ Rectangle { Image { Layout.preferredWidth: 19 Layout.preferredHeight: 19 - source: Qt.resolvedUrl("../assets/material/electric_bolt.svg") + 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 - layer.enabled: true - layer.effect: null - } + } Label { Layout.fillWidth: true text: bar.device.deviceVerified From 8c7460290c544bdf79ba7d2fcba552a9f50525e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:03:35 +0700 Subject: [PATCH 09/13] fix(start): double-check exact board boot identity across deferred Qt event dispatch --- apps/arstack_studio/qml/WorkflowBar.qml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index b09d7f733..3295cbe9d 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -65,11 +65,12 @@ SurfacePanel { cancelStartIntent() return } + const requestedBoot = startIntentBoot cancelStartIntent() Qt.callLater(function() { - // Verify the device again AFTER scheduling, never replay a stale - // intent across a new session, and snapshot the actual GUI now. - if (device.deviceVerified && device.connected && smartSession.startReady && + // 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() }) From 4068d4d0127eeda22e2897fbc479a29386f4a654 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:03:53 +0700 Subject: [PATCH 10/13] test(ui): guard balanced layout, physical lightning status and bounded same-boot first-click START --- .../tests/check_operator_workspace.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index fe344ee67..863fa6143 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -537,6 +537,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") + # 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 "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 From 5d20a093714908d28be5081facd36f0889e19a6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:06:00 +0700 Subject: [PATCH 11/13] test(ui): require STOP while native ACK pending or same-boot deferred first-click intent --- apps/arstack_studio/tests/check_operator_workspace.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 863fa6143..912e6ce3f 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 From 2c482999df8d8afe1be499952c168b6c8b056e51 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:06:53 +0700 Subject: [PATCH 12/13] fix(package): bundle both physical RUN/STOP bolt SVGs in Qt resource manifest --- apps/arstack_studio/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) 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" From b628f2a22ad887d878f84774ce2f2bcbb661fc27 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:07:04 +0700 Subject: [PATCH 13/13] test(package): enforce embedded Qt bolt assets, not missing icons in portable Windows EXE --- apps/arstack_studio/tests/check_operator_workspace.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 912e6ce3f..aa459e8ae 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -548,6 +548,10 @@ def main() -> None: 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