diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt
index 83ca7bb7e..5b81b75c6 100644
--- a/apps/arstack_studio/CMakeLists.txt
+++ b/apps/arstack_studio/CMakeLists.txt
@@ -113,6 +113,13 @@ qt_add_qml_module(arstack_studio
RESOURCES
assets/InterVariable.ttf
assets/INTER_LICENSE.txt
+ assets/material_dark/play_arrow.svg
+ assets/material_dark/stop.svg
+ assets/material_dark/folder_open.svg
+ assets/material_dark/dashboard.svg
+ assets/material_dark/tune.svg
+ assets/material_dark/monitor_heart.svg
+ assets/material_dark/settings.svg
assets/MATERIAL_ICONS_LICENSE.txt
assets/material/close.svg
assets/material/open_in_new.svg
diff --git a/apps/arstack_studio/assets/material_dark/dashboard.svg b/apps/arstack_studio/assets/material_dark/dashboard.svg
new file mode 100644
index 000000000..d71fc1014
--- /dev/null
+++ b/apps/arstack_studio/assets/material_dark/dashboard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/arstack_studio/assets/material_dark/folder_open.svg b/apps/arstack_studio/assets/material_dark/folder_open.svg
new file mode 100644
index 000000000..8c460009e
--- /dev/null
+++ b/apps/arstack_studio/assets/material_dark/folder_open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/arstack_studio/assets/material_dark/monitor_heart.svg b/apps/arstack_studio/assets/material_dark/monitor_heart.svg
new file mode 100644
index 000000000..d76823214
--- /dev/null
+++ b/apps/arstack_studio/assets/material_dark/monitor_heart.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/arstack_studio/assets/material_dark/play_arrow.svg b/apps/arstack_studio/assets/material_dark/play_arrow.svg
new file mode 100644
index 000000000..295bece93
--- /dev/null
+++ b/apps/arstack_studio/assets/material_dark/play_arrow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/arstack_studio/assets/material_dark/settings.svg b/apps/arstack_studio/assets/material_dark/settings.svg
new file mode 100644
index 000000000..2fffc5ac9
--- /dev/null
+++ b/apps/arstack_studio/assets/material_dark/settings.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/arstack_studio/assets/material_dark/stop.svg b/apps/arstack_studio/assets/material_dark/stop.svg
new file mode 100644
index 000000000..c3f97a40e
--- /dev/null
+++ b/apps/arstack_studio/assets/material_dark/stop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/arstack_studio/assets/material_dark/tune.svg b/apps/arstack_studio/assets/material_dark/tune.svg
new file mode 100644
index 000000000..944fa0eca
--- /dev/null
+++ b/apps/arstack_studio/assets/material_dark/tune.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 9c4e3b919..ec67055e2 100644
--- a/apps/arstack_studio/qml/Main.qml
+++ b/apps/arstack_studio/qml/Main.qml
@@ -52,7 +52,9 @@ ApplicationWindow {
function markScenarioDirty() {
if (restoringScenario) return
saveScenarioTimer.restart()
- if (!workflowBar.session || !workflowBar.session.liveControlReady)
+ // 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
}
function restoreLocalScenario() {
@@ -132,6 +134,7 @@ ApplicationWindow {
property real activePhase: 0.0
property real activeQuality: 0
property string transientMessage: ""
+ property string statusMessage: ""
property bool transientError: false
property bool phasorDockVisible: true
property bool phasorDetached: false
@@ -197,6 +200,7 @@ ApplicationWindow {
onTriggered: {
root.transientMessage = ""
root.transientError = false
+ root.statusMessage = ""
}
}
@@ -222,7 +226,8 @@ ApplicationWindow {
}
function showMessage(message, error) {
- transientMessage = message
+ statusMessage = message
+ transientMessage = error === true ? message : ""
transientError = error === true
statusHistoryModel.append({
timeText: new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss"),
@@ -425,16 +430,13 @@ ApplicationWindow {
: "One or more draft commands could not be queued; retry after checking the board.", !ok)
return ok
}
- function requestApplyDraft() {
- if (!workflowBar.session || !workflowBar.session.liveControlReady) {
- showMessage("Local draft saved; connect a verified ESP32-P4 to apply it.", false)
- return false
- }
- if (workflowBar.injectionRunning) {
- showMessage("Stop the publisher before applying an offline draft.", true)
- return false
- }
- return applyAllSignals()
+ function startScenarioSnapshot() {
+ var draft = scenarioSnapshot()
+ draft.currentScale = currentScale
+ draft.voltageScale = voltageScale
+ draft.ct = { enabled: ctSaturationEnabled, dc: ctDcOffsetPercent,
+ harmonic: ctHarmonicPercent, order: ctHarmonicOrder, clip: ctClipPercent }
+ return draft
}
function setFrequencyValue(value) {
@@ -565,8 +567,8 @@ ApplicationWindow {
workflowBar.session.requestSetQuality(activeSignal, unsignedValue)
}
- Shortcut { sequence: "Ctrl+B"; onActivated: root.balanced() }
- Shortcut { sequence: "Ctrl+0"; onActivated: root.zeroAll() }
+ Shortcut { sequence: "Ctrl+B"; enabled: !workflowBar.startQueued; onActivated: root.balanced() }
+ Shortcut { sequence: "Ctrl+0"; enabled: !workflowBar.startQueued; onActivated: root.zeroAll() }
Shortcut { sequence: "F5"; enabled: workflowBar.session && workflowBar.session.state !== "RUNNING"; onActivated: workflowBar.requestStart() }
Shortcut { sequence: "F6"; enabled: workflowBar.session && workflowBar.session.state === "RUNNING"; onActivated: workflowBar.requestStop() }
@@ -630,7 +632,9 @@ ApplicationWindow {
spacing: 8
Label {
- text: device.deviceVerified ? device.portName + " · ESP32-P4" : "No device"
+ text: !device.deviceVerified
+ ? "No ESP32 board detected · Preview mode only"
+ : device.portName + " · ESP32-P4" + (workflowBar.injectionRunning ? " · Injecting" : " · Ready")
color: device.deviceVerified ? studioTheme.textSoft : studioTheme.muted
font.family: root.uiFont
font.pixelSize: 9
@@ -644,6 +648,22 @@ ApplicationWindow {
font.pixelSize: 9
}
Item { Layout.fillWidth: true }
+ Label {
+ visible: root.statusMessage.length > 0
+ Layout.maximumWidth: root.compactLayout ? 165 : 300
+ text: root.statusMessage
+ elide: Text.ElideRight
+ color: root.transientError ? studioTheme.amber : studioTheme.textSoft
+ font.family: root.uiFont
+ font.pixelSize: 9
+ }
+ Label {
+ visible: device.deviceVerified
+ text: "PTP " + (device.ptpRunning ? device.ptpRole : "OFF") + " · smpSynch " + device.smpSynchValue
+ color: device.ptpRunning ? studioTheme.green : studioTheme.muted
+ font.family: root.uiFont
+ font.pixelSize: 9
+ }
Label {
visible: device.deviceVerified
text: "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures
@@ -795,8 +815,8 @@ ApplicationWindow {
id: workflowBar
objectName: "studioWorkflowBar"
Layout.fillWidth: true
- Layout.preferredHeight: 54
- Layout.minimumHeight: 54
+ Layout.preferredHeight: 78
+ Layout.minimumHeight: 78
theme: studioTheme
controller: root
device: device
@@ -854,7 +874,7 @@ ApplicationWindow {
ColumnLayout {
anchors.fill: parent
anchors.margins: root.compactLayout ? 11 : 13
- spacing: 8
+ spacing: root.compactLayout ? 6 : 8
RowLayout {
Layout.fillWidth: true
@@ -863,22 +883,14 @@ ApplicationWindow {
Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold }
}
Item { Layout.fillWidth: true }
- CalmButton {
- theme: studioTheme
- uiFont: root.uiFont
- iconSource: Qt.resolvedUrl("../assets/material/file_upload.svg")
- text: !device.deviceVerified ? "Local draft"
- : root.pendingApply ? "Apply to board" : "SET queued"
- implicitWidth: 106
- tone: device.deviceVerified && root.pendingApply ? "accent" : "normal"
- toolTipText: "Local setpoints are separate from physical output. Board last values are not read back."
- onClicked: root.requestApplyDraft()
- }
+ // START owns the single ordered scenario transaction.
+ // No redundant Apply button in the operator workflow.
}
Rectangle {
Layout.fillWidth: true
height: 48
+ enabled: !workflowBar.startQueued
radius: 8
color: "#0b1219"
border.width: 0
@@ -891,6 +903,7 @@ ApplicationWindow {
x: 80
y: 45
title: "Frequency · shared across all channels"
+ commandRows: 4
Action { text: "DC · 0 Hz"; icon.source: Qt.resolvedUrl("../assets/material/block.svg"); onTriggered: root.setWaveformMode("DC") }
Action { text: "50 Hz · nominal"; icon.source: Qt.resolvedUrl("../assets/material/schedule.svg"); onTriggered: root.setFrequencyValue(50) }
Action { text: "60 Hz · nominal"; icon.source: Qt.resolvedUrl("../assets/material/schedule.svg"); onTriggered: root.setFrequencyValue(60) }
@@ -920,7 +933,7 @@ ApplicationWindow {
id: frequencyField
ToolTip.visible: hovered && !activeFocus
ToolTip.delay: 650
- ToolTip.text: "Right-click for DC / 50 Hz / 60 Hz and AC restoration"
+ ToolTip.text: "Right-click · Shift+F10"
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
@@ -979,10 +992,12 @@ ApplicationWindow {
RowLayout {
Layout.fillWidth: true
- Layout.fillHeight: true
+ Layout.preferredHeight: root.compactLayout ? 214 : 224
+ Layout.maximumHeight: root.compactLayout ? 214 : 224
spacing: 9
SignalMatrix {
id: voltageMatrix
+ enabled: !workflowBar.startQueued
objectName: "studioVoltageMatrix"
Layout.fillWidth: true
Layout.fillHeight: true
@@ -1001,6 +1016,7 @@ ApplicationWindow {
}
SignalMatrix {
id: currentMatrix
+ enabled: !workflowBar.startQueued
objectName: "studioCurrentMatrix"
Layout.fillWidth: true
Layout.fillHeight: true
@@ -1023,8 +1039,8 @@ ApplicationWindow {
StudioTimingTray {
objectName: "studioTimingTray"
Layout.fillWidth: true
- Layout.preferredHeight: root.height < 700 ? 112 : 132
- Layout.minimumHeight: root.height < 700 ? 112 : 132
+ Layout.preferredHeight: 84
+ Layout.minimumHeight: 84
theme: studioTheme
controller: root
device: device
@@ -1050,6 +1066,7 @@ ApplicationWindow {
}
Rectangle { width: 1; height: 20; color: studioTheme.lineSoft }
QualityEditor {
+ enabled: !workflowBar.startQueued
Layout.fillWidth: true
theme: studioTheme
controller: root
@@ -1060,6 +1077,7 @@ ApplicationWindow {
compact: root.compactLayout
}
}
+ Item { Layout.fillHeight: true }
}
}
@@ -1089,7 +1107,7 @@ ApplicationWindow {
SplitView.fillWidth: true
SplitView.fillHeight: true
SplitView.minimumHeight: 170
- SplitView.preferredHeight: 300
+ SplitView.preferredHeight: 238
theme: studioTheme
titleText: "Phasor"
statusText: ""
@@ -1128,7 +1146,7 @@ ApplicationWindow {
SplitView.fillWidth: true
SplitView.fillHeight: true
SplitView.minimumHeight: 170
- SplitView.preferredHeight: 300
+ SplitView.preferredHeight: 328
theme: studioTheme
titleText: "Waveform"
statusText: root.signalFrequency.toFixed(3) + " Hz"
diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml
index 0ed98d7f5..0c8034d14 100644
--- a/apps/arstack_studio/qml/ModernRibbon.qml
+++ b/apps/arstack_studio/qml/ModernRibbon.qml
@@ -14,7 +14,7 @@ Rectangle {
property string uiFont: "Inter"
property bool compact: false
- implicitHeight: 52
+ implicitHeight: 76
radius: 0
color: "transparent"
border.width: 0
@@ -47,7 +47,7 @@ Rectangle {
spacing: 6
RowLayout {
- Layout.preferredWidth: bar.compact ? 112 : 132
+ Layout.preferredWidth: bar.compact ? 105 : 124
Layout.alignment: Qt.AlignVCenter
spacing: 8
@@ -80,20 +80,18 @@ Rectangle {
}
}
- Rectangle { width: 1; height: 24; color: bar.theme.lineSoft }
+ Rectangle { width: 1; height: 42; color: bar.theme.lineSoft }
RibbonAction {
theme: bar.theme
uiFont: bar.uiFont
- text: !bar.device.deviceVerified ? "Preview" : "Start"
+ text: bar.workflow.startQueued ? "Starting" : "Start"
tone: !bar.device.deviceVerified ? "accent"
: bar.session.startReady ? "success" : "neutral"
iconSource: Qt.resolvedUrl("../assets/material/play_arrow.svg")
- enabled: !bar.workflow.injectionRunning && !bar.controller.offlinePreviewRunning
+ enabled: !bar.workflow.injectionRunning && !bar.workflow.startQueued && !bar.controller.offlinePreviewRunning
&& !bar.session.updatingFirmware && !bar.session.updateNeedsBootloaderHelp
- toolTipText: !bar.device.deviceVerified
- ? "Animate local setpoint preview only; no SV frames are transmitted."
- : bar.workflow.startReason()
+ toolTipText: "Start"
onClicked: {
if (!bar.device.deviceVerified) {
bar.controller.offlinePreviewRunning = true
@@ -110,21 +108,21 @@ Rectangle {
iconSource: Qt.resolvedUrl("../assets/material/stop.svg")
enabled: (bar.workflow.injectionRunning || bar.controller.offlinePreviewRunning)
&& !bar.session.updatingFirmware
- toolTipText: bar.device.deviceVerified ? "Stop Sampled Values output" : "Stop local preview only"
+ toolTipText: "Stop"
onClicked: {
if (!bar.device.deviceVerified) bar.controller.offlinePreviewRunning = false
else bar.workflow.requestStop()
}
}
- Rectangle { width: 1; height: 24; color: bar.theme.lineSoft }
+ Rectangle { width: 1; height: 42; color: bar.theme.lineSoft }
RibbonAction {
theme: bar.theme
uiFont: bar.uiFont
text: bar.compact ? "SCL" : "Open SCL"
iconSource: Qt.resolvedUrl("../assets/material/folder_open.svg")
- enabled: bar.session.engineeringEditable
+ enabled: bar.session.engineeringEditable && !bar.workflow.startQueued
toolTipText: "Open IEC 61850 engineering configuration"
onClicked: bar.workflow.openEngineeringDialog()
}
@@ -135,7 +133,7 @@ Rectangle {
uiFont: bar.uiFont
text: "4I+4V"
iconSource: Qt.resolvedUrl("../assets/material/dashboard.svg")
- enabled: bar.session.engineeringEditable
+ enabled: bar.session.engineeringEditable && !bar.workflow.startQueued
toolTipText: "Use the built-in 4I + 4V / 4000 samples/s profile"
onClicked: bar.workflow.useBuiltInProfile()
}
@@ -194,19 +192,17 @@ Rectangle {
RibbonAction {
theme: bar.theme
uiFont: bar.uiFont
- text: bar.compact ? "" : "Advanced"
- iconOnly: bar.compact
+ text: "Advanced"
+ iconOnly: false
iconSource: Qt.resolvedUrl("../assets/material/settings.svg")
tone: "neutral"
- toolTipText: bar.session.firmwareUpdateAvailable
- ? "Advanced controls · optional firmware update available"
- : "Advanced device, firmware, waveform and timing controls"
+ toolTipText: "Advanced"
onClicked: bar.controller.openConfiguration()
}
Rectangle {
- Layout.preferredWidth: bar.compact ? 92 : 112
- Layout.preferredHeight: 30
+ Layout.preferredWidth: bar.compact ? 84 : 108
+ Layout.preferredHeight: 36
Layout.alignment: Qt.AlignVCenter
radius: 15
color: "#0a1016"
diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml
index 5826cd4f7..8093a829f 100644
--- a/apps/arstack_studio/qml/RibbonAction.qml
+++ b/apps/arstack_studio/qml/RibbonAction.qml
@@ -5,87 +5,71 @@ import QtQuick.Layouts
Button {
id: control
-
property var theme
property string uiFont: "Inter"
property string tone: "neutral"
property url iconSource
- property bool large: false
+ property bool large: true
property bool iconOnly: false
property string toolTipText: ""
-
- implicitWidth: iconOnly ? 32 : large ? Math.max(74, label.implicitWidth + 40) : Math.max(54, label.implicitWidth + (icon.visible ? 32 : 20))
- implicitHeight: large ? 38 : 32
+ readonly property string hoverPath: iconSource.toString().replace("/assets/material/", "/assets/material_dark/")
+ implicitWidth: iconOnly ? 60 : Math.max(67, caption.implicitWidth + 20)
+ implicitHeight: 65
hoverEnabled: true
activeFocusOnTab: true
font.family: uiFont
- font.pixelSize: large ? 11 : 9
+ font.pixelSize: 10
font.weight: Font.DemiBold
-
readonly property color fillColor: !enabled ? "transparent"
- : tone === "success" ? (hovered ? "#194433" : "#14372a")
- : tone === "danger" ? (hovered ? "#46252d" : "#351e24")
- : tone === "accent" ? (hovered ? "#15324d" : "#11283d")
- : tone === "warning" ? (hovered ? "#41351f" : "#312919")
- : checked ? "#132b42"
- : hovered ? theme.raisedHover : "transparent"
+ : hovered ? "#e9f2fd"
+ : tone === "success" ? "#173d30"
+ : tone === "danger" ? "#3b2027"
+ : tone === "accent" || checked ? "#163451"
+ : tone === "warning" ? "#45351b" : "transparent"
readonly property color edgeColor: !enabled ? "transparent"
- : tone === "success" ? "#2b684e"
- : tone === "danger" ? "#713843"
- : tone === "accent" ? "#2b567f"
- : tone === "warning" ? "#715a2f"
- : checked ? "#294f74"
- : activeFocus ? theme.accent : "transparent"
+ : hovered ? "#a6c9f2"
+ : tone === "success" ? "#367e5e"
+ : tone === "danger" ? "#8b4b56"
+ : tone === "accent" || checked ? "#34638a"
+ : tone === "warning" ? "#806536" : "transparent"
readonly property color textColor: !enabled ? theme.muted2
- : tone === "success" ? "#d9f7e9"
- : tone === "danger" ? "#ffdce1"
- : tone === "accent" ? "#ddebff"
- : tone === "warning" ? "#f0d49a"
- : checked ? theme.text : theme.textSoft
-
- contentItem: Item {
- implicitWidth: row.implicitWidth
- implicitHeight: row.implicitHeight
-
- RowLayout {
- id: row
- anchors.centerIn: parent
- spacing: control.iconOnly ? 0 : 6
-
- Image {
- id: icon
- visible: control.iconSource.toString().length > 0
- source: control.iconSource
- Layout.preferredWidth: control.large ? 17 : 14
- Layout.preferredHeight: Layout.preferredWidth
- sourceSize.width: Layout.preferredWidth * 2
- sourceSize.height: Layout.preferredHeight * 2
- opacity: control.enabled ? 0.92 : 0.28
- }
-
- Text {
- id: label
- visible: !control.iconOnly
- text: control.text
- color: control.textColor
- font: control.font
- horizontalAlignment: Text.AlignHCenter
- verticalAlignment: Text.AlignVCenter
- elide: Text.ElideRight
- }
+ : hovered ? "#142334"
+ : tone === "success" ? "#dbfaec"
+ : tone === "warning" ? "#f5daaa"
+ : tone === "danger" ? "#ffe3e6"
+ : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft
+ contentItem: ColumnLayout {
+ spacing: 3
+ Image {
+ visible: control.iconSource.toString().length > 0
+ source: control.hovered && control.enabled ? control.hoverPath : control.iconSource
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: 24
+ Layout.preferredHeight: 24
+ sourceSize.width: 48
+ sourceSize.height: 48
+ opacity: control.enabled ? 1 : 0.3
+ }
+ Text {
+ id: caption
+ visible: !control.iconOnly
+ text: control.text
+ color: control.textColor
+ font: control.font
+ Layout.alignment: Qt.AlignHCenter
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
}
}
-
background: Rectangle {
- radius: 6
+ radius: 9
color: control.fillColor
border.width: control.edgeColor === "transparent" ? 0 : 1
border.color: control.edgeColor
Behavior on color { ColorAnimation { duration: 90 } }
- Behavior on border.color { ColorAnimation { duration: 90 } }
}
-
ToolTip.visible: hovered && toolTipText.length > 0
ToolTip.text: toolTipText
- ToolTip.delay: 420
+ ToolTip.delay: 600
}
diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml
index 8b49bd621..2e6d9d242 100644
--- a/apps/arstack_studio/qml/SignalMatrix.qml
+++ b/apps/arstack_studio/qml/SignalMatrix.qml
@@ -140,6 +140,7 @@ Rectangle {
theme: matrix.theme
uiFont: matrix.uiFont
title: "Magnitude · " + signalRow.sid
+ commandRows: 4
Action {
text: "Zero · 0"
icon.source: Qt.resolvedUrl("../assets/material/block.svg")
@@ -170,6 +171,7 @@ Rectangle {
theme: matrix.theme
uiFont: matrix.uiFont
title: "Angle · " + signalRow.sid
+ commandRows: 3
Action {
text: "Zero angle · 0°"
icon.source: Qt.resolvedUrl("../assets/material/block.svg")
@@ -238,8 +240,7 @@ Rectangle {
Layout.preferredWidth: 25
Layout.alignment: Qt.AlignVCenter
checked: signalRow.channelEnabled
- ToolTip.visible: hovered
- ToolTip.text: checked ? "Channel enabled" : "Channel disabled"
+ ToolTip.visible: false
onToggled: {
matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked)
matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex)
@@ -267,9 +268,9 @@ Rectangle {
NumericField {
id: magnitudeField
- ToolTip.visible: hovered && !activeFocus
+ ToolTip.visible: hovered && !activeFocus && !magnitudeMenu.visible && !phaseMenu.visible
ToolTip.delay: 650
- ToolTip.text: "Right-click or Shift+F10: magnitude presets and Equal ABC"
+ ToolTip.text: "Right-click · Shift+F10"
MouseArea {
anchors.fill: parent
z: 5
@@ -329,9 +330,9 @@ Rectangle {
NumericField {
id: phaseField
- ToolTip.visible: hovered && !activeFocus
+ ToolTip.visible: hovered && !activeFocus && !magnitudeMenu.visible && !phaseMenu.visible
ToolTip.delay: 650
- ToolTip.text: "Right-click or Shift+F10: Equal/Balance ABC angles"
+ ToolTip.text: "Right-click · Shift+F10"
MouseArea {
anchors.fill: parent
z: 5
diff --git a/apps/arstack_studio/qml/SignalPreview.qml b/apps/arstack_studio/qml/SignalPreview.qml
index 342998400..61b85e65c 100644
--- a/apps/arstack_studio/qml/SignalPreview.qml
+++ b/apps/arstack_studio/qml/SignalPreview.qml
@@ -532,13 +532,6 @@ SurfacePanel {
}
}
- Label {
- Layout.fillWidth: true
- text: "Generated setpoint · not independent on-wire proof"
- horizontalAlignment: Text.AlignRight
- color: preview.theme.muted
- font.family: preview.uiFont
- font.pixelSize: 8
- }
+
}
}
diff --git a/apps/arstack_studio/qml/StudioActionMenu.qml b/apps/arstack_studio/qml/StudioActionMenu.qml
index 62c4dc0df..a026db163 100644
--- a/apps/arstack_studio/qml/StudioActionMenu.qml
+++ b/apps/arstack_studio/qml/StudioActionMenu.qml
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// Compact Material-action popup shared by magnitude, angle and frequency.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
@@ -8,46 +7,48 @@ Menu {
id: actionMenu
property var theme
property string uiFont: "Inter"
+ property int commandRows: 4
+ property int dividerRows: 1
popupType: Popup.Item
- implicitWidth: 232
- topPadding: 5
- bottomPadding: 5
- leftPadding: 5
- rightPadding: 5
+ width: 212
+ height: commandRows * 29 + dividerRows * 7 + 8
+ topPadding: 4
+ bottomPadding: 4
+ leftPadding: 4
+ rightPadding: 4
spacing: 0
font.family: uiFont
font.pixelSize: 11
delegate: MenuItem {
id: actionItem
width: actionMenu.width - actionMenu.leftPadding - actionMenu.rightPadding
- implicitHeight: actionMenu.theme.menuItemHeight
- leftPadding: 9
- rightPadding: 10
+ implicitHeight: 29
+ leftPadding: 8
+ rightPadding: 8
font.family: actionMenu.uiFont
font.pixelSize: 11
contentItem: RowLayout {
- spacing: 9
+ spacing: 7
Image {
visible: actionItem.icon.source.toString().length > 0
source: actionItem.icon.source
- sourceSize.width: 28
- sourceSize.height: 28
- Layout.preferredWidth: visible ? 17 : 0
- Layout.preferredHeight: 17
- opacity: actionItem.enabled ? 0.95 : 0.35
+ sourceSize.width: 24
+ sourceSize.height: 24
+ Layout.preferredWidth: visible ? 15 : 0
+ Layout.preferredHeight: 15
+ opacity: actionItem.enabled ? 0.95 : 0.33
}
Text {
text: actionItem.text
font: actionItem.font
- color: !actionItem.enabled ? actionMenu.theme.muted2
- : actionItem.highlighted ? actionMenu.theme.text : actionMenu.theme.textSoft
+ color: actionItem.enabled ? actionMenu.theme.textSoft : actionMenu.theme.muted2
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
Layout.fillWidth: true
}
}
background: Rectangle {
- radius: actionMenu.theme.controlRadius
+ radius: 4
color: actionItem.highlighted ? actionMenu.theme.menuHover : "transparent"
border.width: actionItem.activeFocus ? 1 : 0
border.color: actionMenu.theme.accent
@@ -55,7 +56,7 @@ Menu {
}
background: Rectangle {
color: actionMenu.theme.menuSurface
- radius: actionMenu.theme.menuRadius
+ radius: 7
border.width: 1
border.color: actionMenu.theme.line
}
diff --git a/apps/arstack_studio/qml/StudioMenuDivider.qml b/apps/arstack_studio/qml/StudioMenuDivider.qml
index c673ae18c..18a96ea93 100644
--- a/apps/arstack_studio/qml/StudioMenuDivider.qml
+++ b/apps/arstack_studio/qml/StudioMenuDivider.qml
@@ -5,8 +5,8 @@ import QtQuick.Controls
MenuSeparator {
id: separator
property var theme
- topPadding: 3
- bottomPadding: 3
+ topPadding: 2
+ bottomPadding: 2
leftPadding: 10
rightPadding: 10
contentItem: Rectangle {
diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml
index 1cb937802..e673f1f71 100644
--- a/apps/arstack_studio/qml/StudioTheme.qml
+++ b/apps/arstack_studio/qml/StudioTheme.qml
@@ -47,8 +47,8 @@ QtObject {
readonly property int controlRadius: 7
// Shared Material-inspired desktop surface/interaction tokens.
readonly property color menuSurface: "#18222e"
- readonly property color menuHover: "#253b51"
- readonly property int menuRadius: 11
+ readonly property color menuHover: "#314a63"
+ readonly property int menuRadius: 7
readonly property int menuItemHeight: 33
readonly property int spaceXs: 4
readonly property int spaceSm: 8
diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml
index 0da9537dc..08708d47d 100644
--- a/apps/arstack_studio/qml/StudioTimingTray.qml
+++ b/apps/arstack_studio/qml/StudioTimingTray.qml
@@ -96,7 +96,7 @@ Rectangle {
checked: tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp
enabled: !tray.busy && (!tray.boardOnline || tray.ptpReady)
ToolTip.visible: hovered
- ToolTip.text: "Start/stop PTP with verified board feedback. Does not establish SV sample-phase lock."
+ ToolTip.text: "PTP"
onClicked: {
// Switch flips locally before asynchronous firmware evidence.
// Restore binding immediately, including on rejected requests.
@@ -136,7 +136,7 @@ Rectangle {
})
}
ToolTip.visible: hovered
- ToolTip.text: "Source transmits PTP; Follower discovers an external GM; Monitor receives without disciplining."
+ ToolTip.text: "PTP role"
}
Label {
text: "smpSynch"
@@ -156,7 +156,7 @@ Rectangle {
? tray.syncIndex(tray.device.smpSynchMode) : offlineSettings.lastSynch
enabled: !tray.boardOnline || (tray.session && !tray.session.updatingFirmware)
ToolTip.visible: hovered
- ToolTip.text: "1/2 change SV metadata only; they do not synchronize the sample clock."
+ ToolTip.text: "smpSynch mode"
onActivated: {
const requested = currentIndex
offlineSettings.lastSynch = requested
@@ -184,26 +184,10 @@ Rectangle {
iconSource: Qt.resolvedUrl("../assets/material/settings.svg")
objectName: "studioTimingDetails"
implicitWidth: 66
- toolTipText: "PTP settings, timing evidence and diagnostics"
+ toolTipText: "Timing settings"
onClicked: if (tray.controller) tray.controller.openTimingSettings()
}
}
- Label {
- Layout.fillWidth: true
- text: !tray.boardOnline
- ? "OFFLINE PLAN ONLY · PTP and smpSynch selections are local; no PTP/SV packets sent. Board state wins at reconnect."
- : tray.device.smpSynchSimulated
- ? "SV smpSynch=" + tray.device.smpSynchValue + " · SIMULATED metadata — sample clock not proven locked."
- : tray.device.smpSynchMeasured
- ? "SV smpSynch=" + tray.device.smpSynchValue + " · board-reported evidence; sample phase needs independent verification."
- : "SV smpSynch=" + tray.device.smpSynchValue + " · unqualified. PTP traffic alone does not prove SV synchronization."
- color: tray.device && tray.device.smpSynchSimulated ? tray.theme.amber : tray.theme.muted
- font.family: tray.uiFont
- font.pixelSize: tray.compact ? 9 : 10
- elide: Text.ElideRight
- ToolTip.visible: evidenceHover.hovered
- ToolTip.text: text
- HoverHandler { id: evidenceHover }
- }
+
}
}
diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml
index 9865562ad..3059987a3 100644
--- a/apps/arstack_studio/qml/WorkflowBar.qml
+++ b/apps/arstack_studio/qml/WorkflowBar.qml
@@ -15,6 +15,40 @@ SurfacePanel {
property string uiFont: "Inter"
property string monoFont: "Inter"
property bool compact: false
+ property bool startQueued: false
+ Timer {
+ id: startWatchdog
+ interval: 8000
+ repeat: false
+ onTriggered: {
+ if (!ribbon.startQueued) return
+ ribbon.startQueued = false
+ controller.pendingApply = true
+ controller.showMessage("START not confirmed by ESP32-P4. Check board status.", true)
+ }
+ }
+ Connections {
+ target: ribbon.device
+ function onRunningChanged() {
+ if (ribbon.device.running) {
+ ribbon.startQueued = false
+ startWatchdog.stop()
+ controller.pendingApply = false
+ }
+ }
+ function onLastErrorChanged() {
+ if (!ribbon.startQueued) return
+ ribbon.startQueued = false
+ startWatchdog.stop()
+ controller.pendingApply = true
+ }
+ function onDeviceVerifiedChanged() {
+ if (ribbon.device.deviceVerified) return
+ ribbon.startQueued = false
+ startWatchdog.stop()
+ controller.pendingApply = true
+ }
+ }
property bool updatePromptDeferred: false
property bool installPromptDeferred: false
property bool firmwareResultSuccess: false
@@ -117,7 +151,8 @@ SurfacePanel {
// overwrite a board with a restored/offline local draft.
controller.profileDirty = false
controller.pendingApply = true
- controller.showMessage("Board ready. Review your local values, then Apply to board.", false)
+ // Fast path: the next START uploads the local setpoints first.
+ // No auto-transmit on device discovery/reconnection.
}
function onStateChanged() {
if (ribbon.firmwareGateActive)
@@ -222,7 +257,7 @@ SurfacePanel {
}
function requestStart() {
- if (ribbon.injectionRunning) return
+ if (ribbon.injectionRunning || ribbon.startQueued) return
if (smartSession.firmwareInstallRequired) {
ribbon.installPromptDeferred = false
installDialog.open()
@@ -248,12 +283,18 @@ SurfacePanel {
controller.showMessage(ribbon.startReason(), false)
return
}
- if (controller.pendingApply) {
- controller.showMessage("Apply the local scenario before starting physical SMV output.", true)
- return
+ const accepted = controller.pendingApply
+ ? smartSession.requestStartWithScenario(controller.startScenarioSnapshot())
+ : smartSession.requestStart()
+ if (!accepted) {
+ controller.pendingApply = true
+ controller.showMessage("Start rejected. Check device status.", true)
+ } else {
+ // A successful queue call is NOT the board START ACK.
+ // Keep the draft dirty until RUNNING evidence arrives.
+ ribbon.startQueued = true
+ startWatchdog.restart()
}
- if (!smartSession.requestStart())
- controller.showMessage(smartSession.statusText, true)
}
function requestStop() {
diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp
index 978dd0025..265da7431 100644
--- a/apps/arstack_studio/src/DeviceController.cpp
+++ b/apps/arstack_studio/src/DeviceController.cpp
@@ -627,6 +627,79 @@ void DeviceController::handlePortClosed(const QString& portName) {
bool DeviceController::sendShow() { return sendCommand(QStringLiteral("SHOW")); }
bool DeviceController::start() { return sendCommand(QStringLiteral("START")); }
+
+bool DeviceController::startWithScenario(const QVariantMap& scenario) {
+ if (!deviceVerified_ || running_) return false;
+ bool hzOk = false, currentScaleOk = false, voltageScaleOk = false;
+ const double hz = scenario.value(QStringLiteral("hz")).toDouble(&hzOk);
+ const double currentScale = scenario.value(QStringLiteral("currentScale")).toDouble(¤tScaleOk);
+ const double voltageScale = scenario.value(QStringLiteral("voltageScale")).toDouble(&voltageScaleOk);
+ if (!hzOk || !std::isfinite(hz) || hz < 0.0 || hz > 1000.0 ||
+ !currentScaleOk || !voltageScaleOk || !finitePositive(currentScale) ||
+ !finitePositive(voltageScale)) {
+ setError(QStringLiteral("Invalid frequency or engineering scale; START refused."));
+ return false;
+ }
+ const auto current = scenario.value(QStringLiteral("current")).toList();
+ const auto voltage = scenario.value(QStringLiteral("voltage")).toList();
+ if (current.size() != 4 || voltage.size() != 4) {
+ setError(QStringLiteral("START requires exactly four current and four voltage channels."));
+ return false;
+ }
+ QStringList commands;
+ commands.reserve(20);
+ commands << QStringLiteral("FREQ %1").arg(std::llround(hz * 1000.0));
+ static const QStringList ids{QStringLiteral("IA"), QStringLiteral("IB"),
+ QStringLiteral("IC"), QStringLiteral("IN"), QStringLiteral("UA"),
+ QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")};
+ for (int i = 0; i < 8; ++i) {
+ const QVariantList row = (i < 4 ? current.at(i) : voltage.at(i - 4)).toList();
+ if (row.size() != 4 || row.at(2).metaType().id() != QMetaType::Bool) {
+ setError(QStringLiteral("Invalid channel state; START refused."));
+ return false;
+ }
+ bool magOk = false, phaseOk = false, qualityOk = false;
+ const double magnitude = row.at(0).toDouble(&magOk);
+ const double phase = row.at(1).toDouble(&phaseOk);
+ const qulonglong quality = row.at(3).toULongLong(&qualityOk);
+ if (!magOk || !phaseOk || !qualityOk || quality > std::numeric_limits::max()) {
+ setError(QStringLiteral("Invalid channel data; START refused."));
+ return false;
+ }
+ const QString command = formatSignalCommand(ids.at(i), magnitude, phase,
+ static_cast(quality), currentScale, voltageScale, hz);
+ if (command.isEmpty()) {
+ setError(QStringLiteral("Out-of-range setpoint; START refused."));
+ return false;
+ }
+ commands << command;
+ commands << QStringLiteral("ENABLE %1 %2").arg(ids.at(i)).arg(row.at(2).toBool() ? 1 : 0);
+ }
+ const QVariantMap shape = scenario.value(QStringLiteral("ct")).toMap();
+ bool dcOk = false, harmonicOk = false, clipOk = false, orderOk = false;
+ const double dc = shape.value(QStringLiteral("dc")).toDouble(&dcOk);
+ const double harmonic = shape.value(QStringLiteral("harmonic")).toDouble(&harmonicOk);
+ const double clip = shape.value(QStringLiteral("clip")).toDouble(&clipOk);
+ const int order = shape.value(QStringLiteral("order")).toInt(&orderOk);
+ const bool shaping = shape.value(QStringLiteral("enabled")).toBool();
+ if (!dcOk || !harmonicOk || !clipOk || !orderOk ||
+ (shaping && hz == 0.0) || !std::isfinite(dc) || dc < -300.0 || dc > 300.0 ||
+ !std::isfinite(harmonic) || harmonic < 0.0 || harmonic > 300.0 ||
+ !std::isfinite(clip) || clip < 1.0 || clip > 1000.0 || order < 2 || order > 63) {
+ setError(QStringLiteral("Invalid CT shaping; START refused."));
+ return false;
+ }
+ commands << QStringLiteral("SHAPE CT %1 %2 %3 %4 %5")
+ .arg(shaping ? 1 : 0).arg(std::llround(dc * 10.0))
+ .arg(std::llround(harmonic * 10.0)).arg(order).arg(std::llround(clip * 10.0));
+ commands << QStringLiteral("START");
+ // Worker admission is all-or-none (bounded queue), and exclusive FIFO
+ // prevents concurrent UI edits interleaving with this START transaction.
+ // Acceptance is not firmware ACK; RUNNING still requires board telemetry.
+ if (!sendCommandBatch(commands, false, true)) return false;
+ signalFrequencyHz_ = hz;
+ return true;
+}
bool DeviceController::stop() { return sendCommand(QStringLiteral("STOP")); }
bool DeviceController::zero() { return sendCommand(QStringLiteral("ZERO")); }
@@ -1282,20 +1355,21 @@ bool DeviceController::sendQuietCommand(const QString& command) {
return sendCommandBatch({command}, true);
}
-bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet) {
+bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) {
if (!connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) {
setError(QStringLiteral("Device is not connected."));
return false;
}
const QStringList copy = commands;
const quint64 generation = sessionGeneration_;
- QMetaObject::invokeMethod(
+ const bool queued = QMetaObject::invokeMethod(
ioWorker_,
- [worker = ioWorker_, copy, quiet, generation] {
- worker->enqueueCommands(copy, quiet, generation);
+ [worker = ioWorker_, copy, quiet, exclusive, generation] {
+ worker->enqueueCommands(copy, quiet, exclusive, generation);
},
Qt::QueuedConnection);
- return true;
+ if (!queued) setError(QStringLiteral("Device queue dispatcher unavailable."));
+ return queued;
}
void DeviceController::setSessionHeartbeatEnabled(const bool enabled) {
diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp
index de712e2c8..80bc1377f 100644
--- a/apps/arstack_studio/src/DeviceController.hpp
+++ b/apps/arstack_studio/src/DeviceController.hpp
@@ -211,6 +211,8 @@ class DeviceController : public QObject {
void disconnectPort();
bool sendShow();
virtual bool start();
+ // Single validated, bounded FIFO transaction. START is always last.
+ bool startWithScenario(const QVariantMap& scenario);
bool stop();
virtual bool zero();
virtual bool setFrequency(double hz);
@@ -288,7 +290,7 @@ class DeviceController : public QObject {
protected:
bool sendQuietCommand(const QString& command);
- bool sendCommandBatch(const QStringList& commands, bool quiet = false);
+ bool sendCommandBatch(const QStringList& commands, bool quiet = false, bool exclusive = false);
// One canonical SET encoder reused by single edits and the coalesced
// multi-channel flush. Empty result = invalid; never send partial batch.
static QString formatSignalCommand(const QString& signalId, double magnitude,
diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp
index 8c052266e..93d8b747a 100644
--- a/apps/arstack_studio/src/SmartSessionController.cpp
+++ b/apps/arstack_studio/src/SmartSessionController.cpp
@@ -302,6 +302,11 @@ bool SmartSessionController::requestStart() {
return device_->start();
}
+bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenario) {
+ if (!startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false;
+ return device_->startWithScenario(scenario);
+}
+
bool SmartSessionController::requestStop() {
if (device_ == nullptr) return false;
if (!device_->running()) return true;
diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp
index b31d2a673..bd7efd3f6 100644
--- a/apps/arstack_studio/src/SmartSessionController.hpp
+++ b/apps/arstack_studio/src/SmartSessionController.hpp
@@ -165,6 +165,7 @@ class SmartSessionController : public QObject {
// S7: QML submits operator intent only. The supervisor owns every gate that
// can mutate device connection, output, live values, profile state or PTP.
Q_INVOKABLE bool requestStart();
+ Q_INVOKABLE bool requestStartWithScenario(const QVariantMap& scenario);
Q_INVOKABLE bool requestStop();
Q_INVOKABLE bool requestConnect();
Q_INVOKABLE void requestRefreshPorts();
diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py
index 68fe8e9b6..e04a09245 100644
--- a/apps/arstack_studio/tests/check_operator_workspace.py
+++ b/apps/arstack_studio/tests/check_operator_workspace.py
@@ -54,8 +54,9 @@ def main() -> None:
and "tray.session.requestSmpSynch([\"AUTO\", \"0\", \"1\", \"2\"][requested])" in tray
and "if (!tray.boardOnline)" in tray,
"main PTP/smpSynch must restore device-owned state after user intent")
- require("SIMULATED metadata" in tray and "sample phase needs independent verification" in tray,
- "SV simulation must never masquerade as independently proven lock")
+ require("SIMULATED FLAG" in expert and "MEASURED SYNC FLAG" in expert
+ and "sample-phase evidence independently" in expert,
+ "Advanced must retain truthful SV timing evidence without noisy Main disclaimers")
require(matrix.count("Qt.Key_Menu") == 2 and matrix.count("Qt.Key_F10") == 2
and matrix.count("signalRow.openFieldMenu(") == 4,
"both magnitude and angle menus must support mouse and keyboard")
@@ -65,8 +66,10 @@ def main() -> None:
ribbon = source("ModernRibbon.qml")
cmake_icons = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
require("scenarioSnapshot()" in main_qml and "restoreLocalScenario()" in main_qml
- and "requestApplyDraft()" in main_qml and "offlinePreviewRunning" in main_qml,
- "offline-first scenario needs persistence, explicit Apply and non-wire preview")
+ and "startScenarioSnapshot()" in main_qml and "offlinePreviewRunning" in main_qml,
+ "offline-first scenario must persist and support one-click Start")
+ require("Apply to board" not in main_qml and "requestApplyDraft()" not in main_qml,
+ "operator UI must not expose a redundant Apply-to-board action")
require('saveScenarioTimer.stop()' in main_qml
and 'scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot())' in main_qml,
"closing immediately after an edit must flush the local draft before teardown")
@@ -75,12 +78,19 @@ def main() -> None:
and 'typeof row[1] !== "number"' in main_qml
and 'typeof row[3] !== "number"' in main_qml,
"saved scenario restoration must reject JSON null/string coercion and nonfinite values")
+ require('id: voltageMatrix\n enabled: !workflowBar.startQueued' in main_qml
+ and 'id: currentMatrix\n enabled: !workflowBar.startQueued' in main_qml
+ and 'enabled: !workflowBar.startQueued; onActivated: root.balanced()' in main_qml
+ 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 "Apply the local scenario before starting" in workflow
- and "controller.applyAllSignals()" not in workflow,
- "reconnect must not silently transmit an offline draft")
- require("offline-ptp-lab" in tray and "no PTP/SV packets sent" in tray,
- "PTP/smpSynch offline controls must be plans only")
+ and "requestStartWithScenario(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")
+ require("offline-ptp-lab" in tray and "if (!tray.boardOnline)" in tray
+ and "no network packets or clock discipline" in tray,
+ "PTP/smpSynch offline controls must be plans only, not device writes")
require("assets/material/smv_injector.svg" in cmake_icons
and "assets/MATERIAL_ICONS_LICENSE.txt" in cmake_icons
and "../assets/material/smv_injector.svg" in ribbon,
@@ -89,6 +99,21 @@ def main() -> None:
and "Equal magnitudes A/B/C" in matrix
and "Balance angles · 120°" in matrix,
"context actions must stay compact, descriptive and grouped")
+ assert "implicitHeight: 65" in source("RibbonAction.qml")
+ assert 'hovered ? "#142334"' in source("RibbonAction.qml")
+ assert "width: 212" in menu and "commandRows * 29" in menu
+ assert "text: \"Generated setpoint · not independent on-wire proof\"" not in preview
+ assert "No ESP32 board detected · Preview mode only" in main_qml
+ assert "Layout.preferredHeight: 78" in main_qml
+ device_src = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8")
+ session_src = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8")
+ require('commands << QStringLiteral("START")' in device_src
+ and "sendCommandBatch(commands, false, true)" in device_src
+ and "formatSignalCommand(ids.at(i)" in device_src
+ and "requestStartWithScenario(const QVariantMap& scenario)" in session_src,
+ "one-click physical Start must validate and exclusively enqueue all SETs before START")
+ require("assets/material_dark/play_arrow.svg" in cmake,
+ "light-hover Material icons must have bundled dark variants")
require(matrix.count("StudioActionMenu {") == 2
and "id: magnitudeMenu" in matrix and "id: phaseMenu" in matrix
and "popupType: Popup.Item" in menu,