Compare commits

...

4 Commits

32 changed files with 823 additions and 163 deletions
+1 -1
View File
@@ -404,7 +404,7 @@ Rectangle {
id: badgeLabel id: badgeLabel
anchors.centerIn: parent anchors.centerIn: parent
text: deviceController.connectionStatus || "Disconnected" text: deviceController.connectionStatus || "Disconnected"
color: "#111111" color: Theme.statusBadgeText
font.pixelSize: Theme.font2xs font.pixelSize: Theme.font2xs
font.bold: true font.bold: true
} }
+58 -39
View File
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Wafer import ISC.Wafer
@@ -124,7 +125,7 @@ Item {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 38 implicitHeight: 38
radius: 4 radius: 4
color: Qt.rgba(1, 1, 1, 0.02) color: Theme.cardWash
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: 1 border.width: 1
@@ -232,7 +233,7 @@ Item {
Label { Label {
text: slotBox.filePath !== "" text: slotBox.filePath !== ""
? root.shortName(slotBox.filePath) ? root.shortName(slotBox.filePath)
: (root.activeBox === slotBox.boxIndex ? " Select file from left panel" : "Click to select") : (root.activeBox === slotBox.boxIndex ? "<- Select file from left panel..." : "Click to select")
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontXs
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
opacity: slotBox.filePath !== "" ? 1.0 : 0.6 opacity: slotBox.filePath !== "" ? 1.0 : 0.6
@@ -248,14 +249,15 @@ Item {
onClicked: slotBox.cleared() onClicked: slotBox.cleared()
background: Rectangle { background: Rectangle {
radius: Theme.radiusSm radius: Theme.radiusSm
color: parent.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent" color: parent.hovered ? Theme.flatButtonHover : "transparent"
} }
contentItem: Label { contentItem: IconImage {
text: "✕" source: "icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontSm anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
} }
} }
@@ -291,44 +293,54 @@ Item {
background: Rectangle { background: Rectangle {
radius: Theme.radiusSm radius: Theme.radiusSm
color: resetBtn.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent" color: resetBtn.hovered ? Theme.flatButtonHover : "transparent"
} }
contentItem: Label { contentItem: IconImage {
text: "↺" source: "icons/rotate-ccw.svg"
font.pixelSize: Theme.fontLg width: 18; height: 18
sourceSize.width: 18
sourceSize.height: 18
color: Theme.bodyColor color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter anchors.centerIn: parent
verticalAlignment: Text.AlignVCenter
} }
ToolTip.visible: resetBtn.hovered ToolTip.visible: resetBtn.hovered
ToolTip.text: "Reset comparison" ToolTip.text: "Reset comparison"
} }
Label {
text: "Dynamic Time Warping Profile Matching"
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
}
} }
// ── Error Banner ─────────────────────────────────────────── // ── Error Banner ───────────────────────────────────────────
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: errorLabel.implicitHeight + 16 Layout.preferredHeight: errorRow.implicitHeight + 16
visible: root.errorMessage !== "" visible: root.errorMessage !== ""
color: Qt.rgba(0.93, 0.27, 0.27, 0.15) color: Theme.errorSurface
border.color: "#ef4444" border.color: Theme.statusErrorColor
border.width: 1 border.width: 1
radius: Theme.radiusSm radius: Theme.radiusSm
Label { RowLayout {
id: errorLabel id: errorRow
anchors.fill: parent anchors.fill: parent
anchors.margins: 8 anchors.margins: 8
text: "⚠ " + root.errorMessage spacing: 8
color: "#fca5a5"
font.pixelSize: Theme.fontSm IconImage {
wrapMode: Text.WordWrap source: "icons/triangle-alert.svg"
width: 16; height: 16
sourceSize.width: 16
sourceSize.height: 16
color: Theme.errorTextSoft
Layout.alignment: Qt.AlignTop
}
Label {
id: errorLabel
text: root.errorMessage
color: Theme.errorTextSoft
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
} }
} }
@@ -402,9 +414,9 @@ Item {
var plotH = height - padTop - padBottom var plotH = height - padTop - padBottom
var range = (maxV - minV) || 1 var range = (maxV - minV) || 1
ctx.strokeStyle = Qt.rgba(1, 1, 1, 0.08) ctx.strokeStyle = Theme.chartGridLine
ctx.lineWidth = 1 ctx.lineWidth = 1
ctx.fillStyle = Qt.rgba(1, 1, 1, 0.35) ctx.fillStyle = Theme.chartAxisText
ctx.font = "10px sans-serif" ctx.font = "10px sans-serif"
ctx.textAlign = "right" ctx.textAlign = "right"
@@ -507,10 +519,14 @@ Item {
visible: !root.hasSeries visible: !root.hasSeries
spacing: 8 spacing: 8
Label { IconImage {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
text: root.comparing ? "" : "📊" visible: !root.comparing
font.pixelSize: 32 source: "icons/bar-chart.svg"
width: 32; height: 32
sourceSize.width: 32
sourceSize.height: 32
color: Theme.sideMutedText
} }
Label { Label {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
@@ -655,10 +671,13 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked
spacing: 4 spacing: 4
Label { IconImage {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
text: "🗺" source: "icons/map.svg"
font.pixelSize: 24 width: 24; height: 24
sourceSize.width: 24
sourceSize.height: 24
color: Theme.sideMutedText
} }
Label { Label {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
@@ -864,7 +883,7 @@ Item {
label: "DTW ALIGNMENT DISTANCE" label: "DTW ALIGNMENT DISTANCE"
value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--" value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
valueColor: root.warpingDistance >= 0 valueColor: root.warpingDistance >= 0
? (root.warpingDistance < 50 ? "#6ee7b7" : root.warpingDistance < 100 ? "#fde047" : "#ef4444") ? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
: Theme.sideMutedText : Theme.sideMutedText
} }
ReadoutCard { ReadoutCard {
@@ -878,7 +897,7 @@ Item {
label: "MAX SENSOR DEVIATION" label: "MAX SENSOR DEVIATION"
value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--" value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--"
valueColor: root.maxSensorDeviation >= 0 valueColor: root.maxSensorDeviation >= 0
? (root.maxSensorDeviation < 1.0 ? "#6ee7b7" : root.maxSensorDeviation < 3.0 ? "#fde047" : "#ef4444") ? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
: Theme.sideMutedText : Theme.sideMutedText
} }
} }
+9 -1
View File
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import Qt.labs.qmlmodels import Qt.labs.qmlmodels
import ISC import ISC
@@ -181,10 +182,17 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 12 anchors.rightMargin: 12
text: "✕"
implicitWidth: 32 implicitWidth: 32
implicitHeight: 32 implicitHeight: 32
onClicked: root.close() onClicked: root.close()
contentItem: IconImage {
source: "icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.buttonNeutralText
anchors.centerIn: parent
}
} }
} }
+25 -13
View File
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Dialogs import QtQuick.Dialogs
import ISC import ISC
@@ -123,19 +124,17 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
Text { IconImage {
anchors.centerIn: parent anchors.centerIn: parent
text: "✓" source: "icons/check.svg"
font.pixelSize: Theme.fontMd width: 12; height: 12
font.bold: true sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0 opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity { Behavior on opacity {
NumberAnimation { duration: Theme.durationFast } NumberAnimation { duration: Theme.durationFast }
} }
// Small nudge to center the checkmark perfectly
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
} }
} }
@@ -366,7 +365,7 @@ Item {
width: 22 width: 22
height: 22 height: 22
radius: height / 2 radius: height / 2
color: "white" color: Theme.toggleThumb
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
x: Theme.isDarkMode ? parent.width - width - 3 : 3 x: Theme.isDarkMode ? parent.width - width - 3 : 3
@@ -452,12 +451,9 @@ Item {
} }
} }
Label { Row {
anchors.centerIn: parent anchors.centerIn: parent
text: "↓ Scroll for more" spacing: 4
font.pixelSize: Theme.fontSm
font.bold: true
color: Theme.bodyColor
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0 opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
Behavior on opacity { Behavior on opacity {
@@ -465,6 +461,22 @@ Item {
duration: 400 duration: 400
} }
} }
IconImage {
anchors.verticalCenter: parent.verticalCenter
source: "icons/chevron-down.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "Scroll for more"
font.pixelSize: Theme.fontSm
font.bold: true
color: Theme.bodyColor
}
} }
} }
} }
+8 -4
View File
@@ -1,6 +1,7 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs import QtQuick.Dialogs
import ISC import ISC
@@ -211,7 +212,7 @@ ColumnLayout {
id: statusPillText id: statusPillText
anchors.centerIn: parent anchors.centerIn: parent
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE") text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
color: "#111111" color: Theme.statusBadgeText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontXs
font.weight: Font.Bold font.weight: Font.Bold
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
@@ -483,9 +484,12 @@ ColumnLayout {
anchors.rightMargin: Theme.panelPadding anchors.rightMargin: Theme.panelPadding
spacing: 8 spacing: 8
Text { IconImage {
text: "📄" source: "icons/file-text.svg"
font.pixelSize: Theme.fontSm width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
visible: root.csvPath !== "" visible: root.csvPath !== ""
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
} }
+53 -4
View File
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs import QtQuick.Dialogs
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
@@ -30,11 +31,23 @@ Item {
// into the trend chart's data property. The slot parseJsonToData() is defined // into the trend chart's data property. The slot parseJsonToData() is defined
// on the Python TrendChartItem; we use it so malformed payloads are logged // on the Python TrendChartItem; we use it so malformed payloads are logged
// there instead of crashing the QML binding. // there instead of crashing the QML binding.
property string loadErrorMessage: ""
Connections { Connections {
target: streamController target: streamController
function onTrendData(avgsJson) { function onTrendData(avgsJson) {
trendChart.setDataFromJson(avgsJson); trendChart.setDataFromJson(avgsJson);
} }
// A file was picked from the rail (any tab) — loadFile() switches the
// backend to review mode; mirror that in the toolbar toggle so a
// successful load is never hidden behind a stale "Live" selection.
function onLoadedFileChanged() {
root.loadErrorMessage = "";
if (streamController.mode === "review")
modeBar.currentIndex = 0;
}
function onLoadFileError(message) {
root.loadErrorMessage = message;
}
} }
ColumnLayout { ColumnLayout {
@@ -118,12 +131,13 @@ Item {
RowLayout { RowLayout {
visible: streamController.mode === "live" visible: streamController.mode === "live"
spacing: 5 spacing: 5
// WiFi icon (Unicode approximation; swap for SVG if available) // Live status dot (matches the connection-flow indicator dot elsewhere)
Label { Rectangle {
id: liveIndicator id: liveIndicator
text: "◉" width: 8; height: 8
radius: 4
Layout.alignment: Qt.AlignVCenter
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
font.pixelSize: Theme.fontSm
SequentialAnimation on opacity { SequentialAnimation on opacity {
running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle" running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle"
@@ -250,6 +264,41 @@ Item {
} }
} }
// ── Load error banner ───────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: errorRow.implicitHeight + 16
visible: root.loadErrorMessage !== ""
color: Theme.errorSurface
border.color: Theme.statusErrorColor
border.width: 1
radius: Theme.radiusSm
RowLayout {
id: errorRow
anchors.fill: parent
anchors.margins: 8
spacing: 8
IconImage {
source: "icons/triangle-alert.svg"
width: 16; height: 16
sourceSize.width: 16
sourceSize.height: 16
color: Theme.errorTextSoft
Layout.alignment: Qt.AlignTop
}
Label {
text: root.loadErrorMessage
color: Theme.errorTextSoft
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}
// ── Body: 2 columns (wafer + trend + transport | readout) ────────── // ── Body: 2 columns (wafer + trend + transport | readout) ──────────
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
@@ -32,18 +33,17 @@ ColumnLayout {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
Text { IconImage {
anchors.centerIn: parent anchors.centerIn: parent
text: "✓" source: "../icons/check.svg"
font.pixelSize: Theme.fontSm width: 12; height: 12
font.bold: true sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0 opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity { Behavior on opacity {
NumberAnimation { duration: Theme.durationFast } NumberAnimation { duration: Theme.durationFast }
} }
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
} }
} }
@@ -205,7 +205,7 @@ ColumnLayout {
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
Label { Label {
text: s.diff !== undefined ? s.diff : "—" text: s.diff !== undefined ? s.diff : "—"
color: Theme.isDarkMode ? "#A78BFA" : "#8B5CF6" color: Theme.diffAccent
font.family: Theme.codeFontFamily font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.bold: true font.bold: true
@@ -10,6 +10,10 @@ Dialog {
standardButtons: Dialog.NoButton standardButtons: Dialog.NoButton
width: 300 width: 300
// Anchor to the top-left of the window instead of the default centered position.
x: 24
y: 24
// called by WaferMapView's TapHandler // called by WaferMapView's TapHandler
property int sensorIndex: -1 property int sensorIndex: -1
property string sensorLabel: "" property string sensorLabel: ""
@@ -60,7 +64,7 @@ Dialog {
id: overrideTag id: overrideTag
anchors.centerIn: parent anchors.centerIn: parent
text: "OVERRIDE" text: "OVERRIDE"
color: "white" color: Theme.statusBadgeText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontXs
font.bold: true font.bold: true
font.letterSpacing: 1 font.letterSpacing: 1
+62 -19
View File
@@ -122,10 +122,12 @@ ColumnLayout {
} }
contentItem: RowLayout { contentItem: RowLayout {
spacing: 6 spacing: 6
Label { IconImage {
text: "▤" source: "../icons/folder.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontSm
} }
Label { Label {
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
@@ -204,7 +206,7 @@ ColumnLayout {
onClicked: streamController.loadFile(modelData.fileName) onClicked: streamController.loadFile(modelData.fileName)
background: Rectangle { background: Rectangle {
color: fileItem.isActive ? Qt.rgba(1, 1, 1, 0.06) : fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent" color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
radius: Theme.radiusSm radius: Theme.radiusSm
Behavior on color { Behavior on color {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
@@ -225,11 +227,11 @@ ColumnLayout {
color: { color: {
var t = modelData.waferType; var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P") if (t === "A" || t === "E" || t === "P")
return "#3B82F6"; return Theme.familyBlueAccent;
if (t === "B" || t === "C" || t === "D") if (t === "B" || t === "C" || t === "D")
return "#10B981"; return Theme.familyGreenAccent;
if (t === "Z") if (t === "Z")
return "#8B5CF6"; return Theme.familyVioletAccent;
return Theme.headingColor; return Theme.headingColor;
} }
} }
@@ -249,19 +251,36 @@ ColumnLayout {
color: { color: {
var t = modelData.waferType; var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P") if (t === "A" || t === "E" || t === "P")
return "#1D4ED8"; return Theme.familyBlueFill;
if (t === "B" || t === "C" || t === "D") if (t === "B" || t === "C" || t === "D")
return "#065F46"; return Theme.familyGreenFill;
if (t === "Z") if (t === "Z")
return "#7C3AED"; return Theme.familyVioletFill;
return "#374151"; return Theme.familyNeutralFill;
} }
Label { Label {
anchors.centerIn: parent anchors.centerIn: parent
text: modelData.waferType || "?" text: modelData.waferType || "?"
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.weight: Font.Bold font.weight: Font.Bold
color: "#FFFFFF" color: Theme.textOnColor
}
// Recording indicator dot — distinguishes a live-recorded CSV
// (SessionController.startRecording) from a read-memory dump
// (DeviceController.parseAndSaveData).
Rectangle {
visible: modelData.isRecording === true
width: 10
height: 10
radius: 5
color: Theme.recordColor
border.color: Theme.subtleSectionBackground
border.width: 1.5
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: -2
anchors.rightMargin: -2
} }
} }
@@ -270,14 +289,38 @@ ColumnLayout {
spacing: 1 spacing: 1
Layout.fillWidth: true Layout.fillWidth: true
// Primary: wafer type + serial number // Primary: wafer type + serial number, plus a REC badge for recordings
Label { RowLayout {
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
elide: Text.ElideRight
Layout.fillWidth: true Layout.fillWidth: true
spacing: 6
Label {
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
elide: Text.ElideRight
Layout.fillWidth: true
}
Rectangle {
visible: modelData.isRecording === true
radius: 4
color: Qt.alpha(Theme.recordColor, 0.18)
implicitWidth: recLabel.implicitWidth + 10
implicitHeight: recLabel.implicitHeight + 4
Layout.alignment: Qt.AlignVCenter
Label {
id: recLabel
anchors.centerIn: parent
text: "REC"
color: Theme.recordColor
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.6
}
}
} }
// Secondary: date · time // Secondary: date · time
@@ -1,6 +1,7 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import ISC import ISC
// ===== Stream Stats Dialog ===== // ===== Stream Stats Dialog =====
@@ -72,7 +73,6 @@ Popup {
} }
Button { Button {
text: "✕"
flat: true flat: true
Layout.preferredWidth: 32 Layout.preferredWidth: 32
Layout.preferredHeight: 32 Layout.preferredHeight: 32
@@ -82,11 +82,13 @@ Popup {
radius: Theme.radiusXs radius: Theme.radiusXs
color: parent.hovered ? Theme.buttonNeutralHover : "transparent" color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
} }
contentItem: Label { contentItem: IconImage {
text: parent.text source: "../icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter anchors.centerIn: parent
verticalAlignment: Text.AlignVCenter
} }
} }
} }
+25 -29
View File
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
@@ -177,8 +178,6 @@ Item {
implicitWidth: 56 implicitWidth: 56
implicitHeight: 56 implicitHeight: 56
hoverEnabled: true hoverEnabled: true
text: "⏮"
font.pixelSize: 24
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
color: skipStartBtn.pressed ? Theme.transportButtonHover color: skipStartBtn.pressed ? Theme.transportButtonHover
@@ -188,12 +187,13 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: "../icons/prev.svg"
width: 22; height: 22
sourceSize.width: 22
sourceSize.height: 22
color: Theme.headingColor color: Theme.headingColor
font: parent.font anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
onClicked: streamController.step(-1) onClicked: streamController.step(-1)
} }
@@ -205,8 +205,6 @@ Item {
implicitWidth: 56 implicitWidth: 56
implicitHeight: 56 implicitHeight: 56
hoverEnabled: true hoverEnabled: true
text: "■"
font.pixelSize: 22
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
color: reviewStopBtn.pressed ? Theme.transportButtonHover color: reviewStopBtn.pressed ? Theme.transportButtonHover
@@ -216,12 +214,13 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: "../icons/stop.svg"
width: 20; height: 20
sourceSize.width: 20
sourceSize.height: 20
color: Theme.headingColor color: Theme.headingColor
font: parent.font anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
onClicked: streamController.stop() onClicked: streamController.stop()
} }
@@ -233,8 +232,6 @@ Item {
implicitWidth: 64 implicitWidth: 64
implicitHeight: 56 implicitHeight: 56
hoverEnabled: true hoverEnabled: true
text: streamController.playing ? "⏸" : "▶"
font.pixelSize: 24
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
color: playPauseBtn.pressed ? Theme.transportButtonHover color: playPauseBtn.pressed ? Theme.transportButtonHover
@@ -244,13 +241,13 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: streamController.playing ? "../icons/pause.svg" : "../icons/play.svg"
width: 22; height: 22
sourceSize.width: 22
sourceSize.height: 22
color: Theme.headingColor color: Theme.headingColor
font: parent.font anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
leftPadding: parent.text === "▶" ? 4 : 0
} }
onClicked: { onClicked: {
if (streamController.playing) { if (streamController.playing) {
@@ -268,8 +265,6 @@ Item {
implicitWidth: 56 implicitWidth: 56
implicitHeight: 56 implicitHeight: 56
hoverEnabled: true hoverEnabled: true
text: "⏭"
font.pixelSize: 24
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
color: skipEndBtn.pressed ? Theme.transportButtonHover color: skipEndBtn.pressed ? Theme.transportButtonHover
@@ -279,12 +274,13 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: "../icons/next.svg"
width: 22; height: 22
sourceSize.width: 22
sourceSize.height: 22
color: Theme.headingColor color: Theme.headingColor
font: parent.font anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
onClicked: streamController.step(1) onClicked: streamController.step(1)
} }
@@ -29,6 +29,22 @@ Item {
highColor: Theme.sensorHigh highColor: Theme.sensorHigh
textColor: Theme.headingColor textColor: Theme.headingColor
// Grows the hovered marker smoothly; hoverScale setter triggers a repaint.
hoverScale: 1.0 + 0.35 * hoverPulse
property real hoverPulse: map.hoveredIndex >= 0 ? 1.0 : 0.0
Behavior on hoverPulse {
NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
}
HoverHandler {
id: hoverHandler
onPointChanged: {
map.hoveredIndex = map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
}
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
}
TapHandler { TapHandler {
onTapped: ev => { onTapped: ev => {
var idx = map.which_marker(ev.position.x, ev.position.y); var idx = map.which_marker(ev.position.x, ev.position.y);
+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>

After

Width:  |  Height:  |  Size: 262 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" x2="18" y1="20" y2="10" />
<line x1="12" x2="12" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 239 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 236 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>

After

Width:  |  Height:  |  Size: 392 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>

After

Width:  |  Height:  |  Size: 342 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="14" y="3" width="5" height="18" rx="1" />
<rect x="6" y="3" width="5" height="18" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 313 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="6 3 20 12 6 21 6 3" />
</svg>

After

Width:  |  Height:  |  Size: 250 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 238 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 297 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>

After

Width:  |  Height:  |  Size: 260 B

+73 -27
View File
@@ -7,18 +7,23 @@ import QtQuick
// ============================================================================= // =============================================================================
// Sections: // Sections:
// 1. Mode flag // 1. Mode flag
// 2. Typography // 2. Typography (families, pixel sizes, weights)
// 3. Motion // 3. Motion
// 4. Tone palette (raw tones; everything else derives from these) // 4. Tone palette (raw tones; everything else derives from these)
// 5. Surfaces (page / card / panel / workspace backgrounds) // 5. Surfaces (page / card / panel / workspace backgrounds)
// 6. Borders // 6. Borders
// 7. Text (headings, body, panel titles) // 7. Text (headings, body, panel titles)
// 8. Controls (fields, buttons, checkbox) // 8. Controls (fields, buttons, checkbox, toggle)
// 9. Tracks (pill toggle + scrollbar) // 9. Interaction overlays (hover / active washes — mode-aware)
// 10. Tabs (footer tab bar) // 10. Tracks (pill toggle + scrollbar)
// 11. Side rail // 11. Tabs (mode toggle pills)
// 12. Status (success / warning / error) // 12. Side rail
// 13. Geometry (radius / border width / spacing / sizing) // 13. Transport / toolbar
// 14. Status & badges (success / warning / error, badge text)
// 15. Sensor bands (wafer map dots)
// 16. Wafer family badges (source list avatars + accent bars)
// 17. Comparison & charts (metric grading, diff accent, canvas grid)
// 18. Geometry (radius / border width / spacing / sizing)
// ============================================================================= // =============================================================================
// Based on Codex themes: // Based on Codex themes:
// Dark (oscurange): surface #0B0B0F, ink #E6E6E6 // Dark (oscurange): surface #0B0B0F, ink #E6E6E6
@@ -29,7 +34,10 @@ QtObject {
// ── 1. Mode ────────────────────────────────────────────────────────────── // ── 1. Mode ──────────────────────────────────────────────────────────────
property bool isDarkMode: true property bool isDarkMode: true
// ── 2. Typography ────────────────────────────────────────────────────── // ── 2. Typography ────────────────────────────────────────────────────────
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
// pixelSize scale — map to closest token, tune globally // pixelSize scale — map to closest token, tune globally
readonly property int font2xs: 9 // tiny metadata readonly property int font2xs: 9 // tiny metadata
readonly property int fontXs: 11 // captions, micro labels readonly property int fontXs: 11 // captions, micro labels
@@ -44,22 +52,20 @@ QtObject {
readonly property int fontWeightMedium: 500 readonly property int fontWeightMedium: 500
readonly property int fontWeightBold: 700 readonly property int fontWeightBold: 700
// ── 3. Motion ────────────────────────────────────────────────────────── // ── 3. Motion ────────────────────────────────────────────────────────────
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash) readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
readonly property int durationBase: 180 // standard transitions (pill switch, row select) readonly property int durationBase: 180 // standard transitions (pill switch, row select)
readonly property int durationSlow: 260 // larger transitions (panel open/close) readonly property int durationSlow: 260 // larger transitions (panel open/close)
readonly property var easeStandard: Easing.OutCubic readonly property var easeStandard: Easing.OutCubic
// ── 4. Tone palette (Codex theme v1 inspired) ─────────────────────────── // ── 4. Tone palette (Codex theme v1 inspired) ───────────────────────────
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7" readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7"
readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B" readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B"
readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333" readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333"
readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes
readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853" readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853"
readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38" readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38"
readonly property color themeViolet: isDarkMode ? "#A78BFA" : "#8B5CF6"
readonly property color tone100: themeSurface readonly property color tone100: themeSurface
readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1" readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1"
@@ -71,7 +77,7 @@ QtObject {
readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E" readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E"
readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE" readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE"
// ── 3. Surfaces ────────────────────────────────────────────────────────── // ── 5. Surfaces ──────────────────────────────────────────────────────────
readonly property color pageBackground: tone100 readonly property color pageBackground: tone100
readonly property color cardBackground: tone150 readonly property color cardBackground: tone150
readonly property color panelBackground: tone150 readonly property color panelBackground: tone150
@@ -79,7 +85,7 @@ QtObject {
readonly property color responseBackground: tone150 readonly property color responseBackground: tone150
readonly property color subtleSectionBackground: tone250 readonly property color subtleSectionBackground: tone250
// ── 4. Borders ─────────────────────────────────────────────────────────── // ── 6. Borders ───────────────────────────────────────────────────────────
readonly property color cardBorder: toneBorder readonly property color cardBorder: toneBorder
readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2" readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
readonly property color responseBorder: toneBorder readonly property color responseBorder: toneBorder
@@ -88,14 +94,14 @@ QtObject {
readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2" readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1" readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1"
// ── 5. Text ────────────────────────────────────────────────────────────── // ── 7. Text ──────────────────────────────────────────────────────────────
readonly property color headingColor: toneText readonly property color headingColor: toneText
readonly property color bodyColor: toneMute readonly property color bodyColor: toneMute
readonly property color subheadingColor: toneMute readonly property color subheadingColor: toneMute
readonly property color panelTitleText: toneMute readonly property color panelTitleText: toneMute
readonly property color disabledText: toneMute readonly property color disabledText: toneMute
// ── 6. Controls ────────────────────────────────────────────────────────── // ── 8. Controls ──────────────────────────────────────────────────────────
// Fields // Fields
readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF" readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
readonly property color fieldText: toneText readonly property color fieldText: toneText
@@ -111,32 +117,48 @@ QtObject {
readonly property color primaryAccent: themeAccent readonly property color primaryAccent: themeAccent
// Checkbox // Checkbox
readonly property color checkboxText: toneText readonly property color checkboxText: toneText
// Toggle switch thumb (white knob reads on both track colors)
readonly property color toggleThumb: "#FFFFFF"
// ── 7. Tracks (pill toggle, scrollbar thumb) ───────────────────────────── // ── 9. Interaction overlays ──────────────────────────────────────────────
// Mode-aware washes. Never use raw Qt.rgba(1,1,1,x) in views: a white
// overlay is invisible on light-mode surfaces.
readonly property color listHoverBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.04) : Qt.rgba(0, 0, 0, 0.03)
readonly property color listActiveBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.06) : Qt.rgba(0, 0, 0, 0.05)
readonly property color flatButtonHover: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.06)
readonly property color cardWash: isDarkMode ? Qt.rgba(1, 1, 1, 0.02) : Qt.rgba(0, 0, 0, 0.02)
// ── 10. Tracks (pill toggle, scrollbar thumb) ────────────────────────────
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE" readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
// ── 8. Tabs (mode toggle pills) ───────────────────────────────────────── // ── 11. Tabs (mode toggle pills) ─────────────────────────────────────────
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF" readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
// ── 9. Side rail ──────────────────────────────────────────────────────── // ── 12. Side rail ────────────────────────────────────────────────────────
readonly property color sideRailBackground: tone150 readonly property color sideRailBackground: tone150
readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1" readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF" readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
readonly property color sideBorder: toneBorder readonly property color sideBorder: toneBorder
readonly property color sideMutedText: toneMute readonly property color sideMutedText: toneMute
// ── 10a. Transport / toolbar surfaces ──────────────────────────────────── // ── 13. Transport / toolbar surfaces ────────────────────────────────────
readonly property color transportButtonBg: tone250 readonly property color transportButtonBg: tone250
readonly property color transportButtonHover: tone300 readonly property color transportButtonHover: tone300
readonly property color liveColor: themeAdded readonly property color liveColor: themeAdded
readonly property color recordColor: themeRemoved readonly property color recordColor: themeRemoved
// ── 10. Status ─────────────────────────────────────────────────────────── // ── 14. Status & badges ──────────────────────────────────────────────────
readonly property color statusSuccessColor: themeAdded readonly property color statusSuccessColor: themeAdded
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
readonly property color statusErrorColor: themeRemoved readonly property color statusErrorColor: themeRemoved
// Text placed ON a filled status pill / saturated chip:
readonly property color statusBadgeText: "#111111" // dark ink on bright status fills, both modes
readonly property color textOnColor: "#FFFFFF" // white text on deep saturated fills (avatars etc.)
// Error banner (soft red surface + readable red text)
readonly property color errorSurface: Qt.alpha(statusErrorColor, 0.15)
readonly property color errorTextSoft: isDarkMode ? "#FCA5A5" : "#B91C1C"
// -- 10b. Sensor bands (wafer map dots) // ── 15. Sensor bands (wafer map dots) ────────────────────────────────────
readonly property color sensorInRange: statusSuccessColor readonly property color sensorInRange: statusSuccessColor
// ponytail: high-temp shares error red — semantically correct (dangerous value), // ponytail: high-temp shares error red — semantically correct (dangerous value),
// re-hue if colorblind testing demands a different channel. // re-hue if colorblind testing demands a different channel.
@@ -145,7 +167,31 @@ QtObject {
readonly property color waferRingColor: toneBorder readonly property color waferRingColor: toneBorder
readonly property color waferAxisColor: softBorder readonly property color waferAxisColor: softBorder
// ── 11. Geometry ───────────────────────────────────────────────────────── // ── 16. Wafer family badges (SourcePanel list) ───────────────────────────
// Accent = left bar / outline shade; Fill = avatar circle behind textOnColor.
// Family grouping mirrors FileBrowser's waferType first-letter convention.
readonly property color familyBlueAccent: "#3B82F6" // A / E / P
readonly property color familyBlueFill: "#1D4ED8"
readonly property color familyGreenAccent: "#10B981" // B / C / D
readonly property color familyGreenFill: "#065F46"
readonly property color familyVioletAccent: "#8B5CF6" // Z
readonly property color familyVioletFill: "#7C3AED"
readonly property color familyNeutralFill: "#374151" // unknown family
// ── 17. Comparison & charts ──────────────────────────────────────────────
// Metric grading for DTW readout cards (good → caution → bad).
// Dark values are pastel (readable on tone150); light values are deepened
// so they still pass on near-white cards.
readonly property color metricGood: isDarkMode ? "#6EE7B7" : "#059669"
readonly property color metricWarn: isDarkMode ? "#FDE047" : "#B45309"
readonly property color metricBad: isDarkMode ? "#EF4444" : "#DC2626"
// DIFF column accent in ReadoutPanel
readonly property color diffAccent: themeViolet
// Canvas chart primitives (DTW plot grid + axis labels)
readonly property color chartGridLine: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.10)
readonly property color chartAxisText: isDarkMode ? Qt.rgba(1, 1, 1, 0.35) : Qt.rgba(0, 0, 0, 0.45)
// ── 18. Geometry ─────────────────────────────────────────────────────────
// Radius // Radius
readonly property int radiusXs: 6 // fields, tight elements readonly property int radiusXs: 6 // fields, tight elements
readonly property int radiusSm: 8 // buttons readonly property int radiusSm: 8 // buttons
@@ -35,6 +35,7 @@ class SessionController(QObject):
recordingChanged = Signal() recordingChanged = Signal()
sensorsChanged = Signal() sensorsChanged = Signal()
loadedFileChanged = Signal() loadedFileChanged = Signal()
loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why
clusterAveragingEnabledChanged = Signal() clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
@@ -275,17 +276,30 @@ class SessionController(QObject):
try: try:
data, _ = ZWaferParser().parse(file_path) data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc: except (ValueError, KeyError) as exc:
log.warning("Could not parse %s: %s", file_path, exc) msg = f"Could not parse {Path(file_path).name}: {exc}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return return
if data is None or not data.sensors: if data is None or not data.sensors:
log.warning("Could not parse %s", file_path) msg = f"Could not parse {Path(file_path).name}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return return
records = read_data_records(file_path) records = read_data_records(file_path)
sensors = data.sensors sensors = data.sensors
frames = frames_from_wafer_data(data, records) frames = frames_from_wafer_data(data, records)
if not sensors or not frames: if not sensors:
log.warning("No sensors or data in %s", file_path) msg = f"No sensor layout found in {Path(file_path).name}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return
if not frames:
# A well-formed header with zero data rows below it — most commonly
# a live recording that was stopped before any frames arrived.
msg = f"{Path(file_path).name} has no recorded frames"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return return
wafer_id = "" wafer_id = ""
@@ -304,6 +318,10 @@ class SessionController(QObject):
self._model.reset() self._model.reset()
self._stats_tracker.reset() self._stats_tracker.reset()
self._loaded_file = file_path self._loaded_file = file_path
# A file was explicitly picked for review — show it regardless of
# whichever mode (e.g. Live) was previously active, otherwise a
# successful load can be invisible to the user.
self.setMode(MODE_REVIEW)
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
self._emit_current() self._emit_current()
@@ -330,10 +348,48 @@ class SessionController(QObject):
@slot_error_boundary @slot_error_boundary
def compareFiles(self, file_a: str, file_b: str) -> None: def compareFiles(self, file_a: str, file_b: str) -> None:
"""Run DTW comparison between two CSV files and emit result.""" """Run DTW comparison between two CSV files and emit result."""
from pathlib import Path
from pygui.backend.comparison import compare_runs from pygui.backend.comparison import compare_runs
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
try: try:
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
# startRecording (see FileBrowser.isRecording), distinct from the
# "<serial>-<timestamp>.csv" convention used for read-memory dumps.
# Comparing across those categories doesn't make sense (a live
# capture vs. a full memory readout aren't the same kind of run),
# so gate on that before even looking at wafer type.
stem_a, stem_b = Path(file_a).stem, Path(file_b).stem
is_recording_a = stem_a.lower().startswith("live_")
is_recording_b = stem_b.lower().startswith("live_")
if is_recording_a != is_recording_b:
self.comparisonResult.emit({
"success": False,
"error": "Cannot compare a recording file with a read-memory file"
})
return
# Gate on wafer type (family): comparing a P-wafer against an X-wafer
# (different sensor counts/layouts) would silently truncate the
# smaller sensor set and pair up unrelated physical sensors in the
# overlap view. Same convention as FileBrowser's waferType badge —
# first character of the filename stem, minus the "live_" prefix
# so the wafer letter is read from the serial, not the "L".
def _wafer_type(stem: str, is_recording: bool) -> str:
if is_recording:
stem = stem[len("live_"):]
return stem[0].upper() if stem else ""
type_a = _wafer_type(stem_a, is_recording_a)
type_b = _wafer_type(stem_b, is_recording_b)
if type_a and type_b and type_a != type_b:
self.comparisonResult.emit({
"success": False,
"error": f"Cannot compare different wafer types: {type_a} vs {type_b}"
})
return
if is_official_csv(file_a): if is_official_csv(file_a):
_, recs_a = read_official_csv(file_a) _, recs_a = read_official_csv(file_a)
else: else:
@@ -350,8 +406,10 @@ class SessionController(QObject):
}) })
return return
# Use first 100 frames of each for comparison (avoid O(n²) blowup) # Compare the full overlapping length of both runs. DTW's O(n*m) cost
max_frames = min(100, len(recs_a), len(recs_b)) # matrix is cheap even at a few thousand frames (~26MB at 1800x1800),
# so only cap against pathologically long files, not typical runs.
max_frames = min(5000, len(recs_a), len(recs_b))
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
for i in range(max_frames)] for i in range(max_frames)]
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
@@ -380,10 +438,8 @@ class SessionController(QObject):
max_sensor_deviation = diff max_sensor_deviation = diff
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the # Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
# same sensor-parsing branch as loadFile() (file_a is assumed representative # same sensor-parsing branch as loadFile(); file_a and file_b are now
# of both files' wafer type). # guaranteed to share a wafer type by the gate above.
from pathlib import Path
from pygui.backend.data.data_records import is_official_csv, read_official_csv from pygui.backend.data.data_records import is_official_csv, read_official_csv
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
+7
View File
@@ -119,6 +119,11 @@ class FileBrowser(QObject):
return return
for csv_path in sorted(self._current_directory.glob("*.csv")): for csv_path in sorted(self._current_directory.glob("*.csv")):
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
# SessionController.startRecording (see WaferMapTab's Record button),
# distinct from the "<serial>-<timestamp>.csv" convention used by
# DeviceController.parseAndSaveData for read-memory dumps.
is_recording = csv_path.stem.lower().startswith("live_")
try: try:
metadata = self._load_metadata(csv_path) metadata = self._load_metadata(csv_path)
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "") wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
@@ -146,6 +151,7 @@ class FileBrowser(QObject):
"masterType": master_state.get(str(csv_path), "") or metadata.master_type, "masterType": master_state.get(str(csv_path), "") or metadata.master_type,
"fileName": str(csv_path), "fileName": str(csv_path),
"highlight": wafer_type in {"A", "B", "C"}, "highlight": wafer_type in {"A", "B", "C"},
"isRecording": is_recording,
} }
) )
except Exception: except Exception:
@@ -169,6 +175,7 @@ class FileBrowser(QObject):
"masterType": master_state.get(str(csv_path), ""), "masterType": master_state.get(str(csv_path), ""),
"fileName": str(csv_path), "fileName": str(csv_path),
"highlight": False, "highlight": False,
"isRecording": is_recording,
} }
) )
@@ -52,6 +52,8 @@ class WaferMapItem(QQuickPaintedItem):
sizeChanged = Signal() sizeChanged = Signal()
thicknessChanged = Signal() thicknessChanged = Signal()
showThicknessChanged = Signal() showThicknessChanged = Signal()
hoveredIndexChanged = Signal()
hoverScaleChanged = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -68,6 +70,10 @@ class WaferMapItem(QQuickPaintedItem):
self._thickness_data: list[float] = [] self._thickness_data: list[float] = []
self._show_thickness: bool = False self._show_thickness: bool = False
self._thickness_heatmap:QImage | None = None self._thickness_heatmap:QImage | None = None
# Hover highlight: index of the marker under the pointer (-1 = none)
# and a 1.0->~1.5 grow factor driven by a QML Behavior for a smooth animation.
self._hovered_index: int = -1
self._hover_scale: float = 1.0
# Dark-theme color defaults (match Theme.qml tokens) # Dark-theme color defaults (match Theme.qml tokens)
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder) self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
@@ -81,6 +87,12 @@ class WaferMapItem(QQuickPaintedItem):
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py) self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
self._marker_r: int = 4 self._marker_r: int = 4
self._heatmap: QImage | None = None self._heatmap: QImage | None = None
# Size the markers/heatmap were last computed against. On first activation
# (e.g. a freshly-created Loader item) width()/height() can still be 0/stale
# when `sensors` is first set, computing degenerate marker positions that
# never get retried. paint() re-checks this every frame so geometry that
# settles after property assignment still gets picked up.
self._last_draw_size: int = -1
self.widthChanged.connect(self._on_resize) self.widthChanged.connect(self._on_resize)
self.heightChanged.connect(self._on_resize) self.heightChanged.connect(self._on_resize)
@@ -213,6 +225,29 @@ class WaferMapItem(QQuickPaintedItem):
self.showThicknessChanged.emit() self.showThicknessChanged.emit()
self.update() self.update()
@Property(int, notify=hoveredIndexChanged)
def hoveredIndex(self) -> int:
return self._hovered_index
@hoveredIndex.setter
def hoveredIndex(self, val: int) -> None:
val = int(val)
if val == self._hovered_index:
return
self._hovered_index = val
self.hoveredIndexChanged.emit()
self.update()
@Property(float, notify=hoverScaleChanged)
def hoverScale(self) -> float:
return self._hover_scale
@hoverScale.setter
def hoverScale(self, val: float) -> None:
self._hover_scale = float(val)
self.hoverScaleChanged.emit()
self.update()
# Colour properties — QML can bind these to Theme tokens # Colour properties — QML can bind these to Theme tokens
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def ringColor(self) -> QColor: return self._ring_color def ringColor(self) -> QColor: return self._ring_color
@@ -440,7 +475,15 @@ class WaferMapItem(QQuickPaintedItem):
# ── paint ───────────────────────────────────────────────────────────── # ── paint ─────────────────────────────────────────────────────────────
def paint(self, painter: QPainter) -> None: def paint(self, painter: QPainter) -> None:
ds = self._draw_size() ds = self._draw_size()
if ds != self._last_draw_size:
# Geometry settled (or changed) since markers/heatmap were last built
# against a stale size — recompute now, at actual paint time.
self._last_draw_size = ds
self._compute_markers()
self._rebuild_heatmap()
self._rebuild_thickness()
r_px = int(ds / 2 - 4) r_px = int(ds / 2 - 4)
cx, cy = self._center() cx, cy = self._center()
@@ -544,10 +587,22 @@ class WaferMapItem(QQuickPaintedItem):
self._bands[i] if i < len(self._bands) else "in_range", self._bands[i] if i < len(self._bands) else "in_range",
self._in_range_color, self._in_range_color,
) )
is_hovered = i == self._hovered_index
marker_r = int(round(r * self._hover_scale)) if is_hovered else r
if is_hovered:
# Soft glow ring behind the marker, growing with hoverScale.
glow_r = int(round(marker_r * 1.9))
glow_color = QColor(color)
glow_color.setAlpha(70)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(glow_color))
painter.drawEllipse(px - glow_r, py - glow_r, 2 * glow_r, 2 * glow_r)
# Filled circle with thin dark outline for contrast over heatmap # Filled circle with thin dark outline for contrast over heatmap
painter.setPen(QPen(QColor(0, 0, 0, 100), 1)) painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
painter.setBrush(QBrush(color)) painter.setBrush(QBrush(color))
painter.drawEllipse(px - r, py - r, 2 * r, 2 * r) painter.drawEllipse(px - marker_r, py - marker_r, 2 * marker_r, 2 * marker_r)
if self._show_labels: if self._show_labels:
has_temp = i < len(self._values) has_temp = i < len(self._values)
@@ -557,7 +612,6 @@ class WaferMapItem(QQuickPaintedItem):
ox = getattr(s, "offset_x", 0.0) * r ox = getattr(s, "offset_x", 0.0) * r
oy = getattr(s, "offset_y", 0.0) * r oy = getattr(s, "offset_y", 0.0) * r
# Pre-compute metrics using current scaled fonts
painter.setFont(id_font) painter.setFont(id_font)
id_fm = painter.fontMetrics() id_fm = painter.fontMetrics()
id_line_h = id_fm.height() id_line_h = id_fm.height()
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from pygui.backend.data.file_browser import FileBrowser
def _write_csv(path, wafer_id="P00001"):
path.write_text(
f"Wafer ID={wafer_id}\n"
"Acquisition Date=03/26/2025\n"
"Label,1,2,3\n"
"X (mm),0,10,-10\n"
"Y (mm),10,-10,-10\n"
"data\n"
"0.0,149.0,148.5,150.6\n"
)
def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
"""Files named live_<serial>_<timestamp>.csv (SessionController.startRecording)
must be distinguishable in the browser from read-memory dumps."""
_write_csv(tmp_path / "live_P00001_20260706_143022.csv")
_write_csv(tmp_path / "P00002-20260706_143500.csv")
browser = FileBrowser()
browser._set_current_directory(tmp_path)
browser.refreshFiles()
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
+120
View File
@@ -47,6 +47,36 @@ def test_pause_stop_timer(controller):
controller.stop() controller.stop()
assert controller.frameIndex == 0 assert controller.frameIndex == 0
def test_load_file_emits_error_for_empty_recording(controller, tmp_path):
"""A recording with a valid header but zero data rows (e.g. Record was
started/stopped without any live frames arriving) must surface a clear
loadFileError instead of silently doing nothing."""
empty_recording = tmp_path / "live_A00001_20260706_120427.csv"
empty_recording.write_text(
"Wafer ID=A00001\n"
"Acquisition Date=07/06/2026\n"
"Label,1,2\n"
"X (mm),0.0,10.0\n"
"Y (mm),10.0,-10.0\n"
"data\n"
)
mock_slot = MagicMock()
controller.loadFileError.connect(mock_slot)
controller.loadFile(str(empty_recording))
assert mock_slot.call_count == 1
assert "no recorded frames" in mock_slot.call_args[0][0]
assert controller.loadedFile == ""
def test_load_file_switches_to_review_mode(controller):
"""Loading a file for playback must always land in review mode, even if
the controller was left in live mode from a previous session — otherwise
a successful load can be invisible behind a stale Live toggle."""
controller._mode = "live"
controller.loadFile("tests/fixtures/sample_stream.csv")
assert controller.mode == "review"
def test_compare_files_integration(controller): def test_compare_files_integration(controller):
# Mock the comparisonResult signal # Mock the comparisonResult signal
mock_slot = MagicMock() mock_slot = MagicMock()
@@ -65,6 +95,96 @@ def test_compare_files_integration(controller):
assert "series_b" in args assert "series_b" in args
assert args["frame_offset"] == 0 assert args["frame_offset"] == 0
def test_compare_files_uses_full_run_not_first_100_frames(controller, tmp_path):
"""compareFiles must score the whole run, not just an initial 100-frame window.
Regression test: max_frames used to be hard-capped at 100, so a >100-frame
run's soak/cool phases were silently excluded from the DTW comparison.
"""
num_frames = 150
csv_path = tmp_path / "long_run.csv"
lines = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data"]
for i in range(num_frames):
t = i * 0.5
lines.append(f"{t},{149.0 + i * 0.01},{148.5 + i * 0.01},{150.6 + i * 0.01}")
csv_path.write_text("\n".join(lines) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(csv_path), str(csv_path))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is True
assert len(result["series_a"]) == num_frames
assert len(result["series_b"]) == num_frames
def test_compare_files_rejects_mismatched_wafer_types(controller, tmp_path):
"""compareFiles must refuse to compare two different wafer families.
Otherwise the overlap view silently truncates to min(sensor_count) and
pairs up unrelated physical sensors between the two wafer types.
"""
header = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
file_a = tmp_path / "A00055-20250326.csv"
file_b = tmp_path / "X00108-20250326.csv"
file_a.write_text("\n".join(header) + "\n")
file_b.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(file_a), str(file_b))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is False
assert "A" in result["error"] and "X" in result["error"]
def test_compare_files_rejects_recording_vs_read_mem(controller, tmp_path):
"""A live_-prefixed recording must not be compared against a read-memory
dump. Regression: stem[0] on "live_P0001_..." is "L", which used to be
misreported as a wafer-type mismatch ("L vs P") instead of this clearer
recording-vs-read-mem error.
"""
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
recording = tmp_path / "live_P0001_20260706_143022.csv"
read_mem = tmp_path / "P0001-20260706_143500.csv"
recording.write_text("\n".join(header) + "\n")
read_mem.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(recording), str(read_mem))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is False
assert result["error"] == "Cannot compare a recording file with a read-memory file"
def test_compare_files_allows_two_recordings_of_same_wafer_type(controller, tmp_path):
"""Two live recordings of the same wafer family should compare fine —
the "live_" prefix must not be mistaken for the wafer type letter."""
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
file_a = tmp_path / "live_P0001_20260706_143022.csv"
file_b = tmp_path / "live_P0002_20260706_150000.csv"
file_a.write_text("\n".join(header) + "\n")
file_b.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(file_a), str(file_b))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is True
def test_comparison_result_reaches_qml(qapp, controller): def test_comparison_result_reaches_qml(qapp, controller):
"""The result dict must arrive in QML as a real JS object with fields. """The result dict must arrive in QML as a real JS object with fields.