diff --git a/config/dot/quickshell/modules/bar/Bar.qml b/config/dot/quickshell/modules/bar/Bar.qml index e61e527..1f4e5ef 100644 --- a/config/dot/quickshell/modules/bar/Bar.qml +++ b/config/dot/quickshell/modules/bar/Bar.qml @@ -108,6 +108,10 @@ PanelWindow { anchors.verticalCenter: parent.verticalCenter } + HealthIndicator { + anchors.verticalCenter: parent.verticalCenter + } + ActivityIndicator { anchors.verticalCenter: parent.verticalCenter } diff --git a/config/dot/quickshell/modules/bar/HealthIndicator.qml b/config/dot/quickshell/modules/bar/HealthIndicator.qml new file mode 100644 index 0000000..9106f9b --- /dev/null +++ b/config/dot/quickshell/modules/bar/HealthIndicator.qml @@ -0,0 +1,89 @@ +// A deliberately absent-until-needed health affordance. Healthy and merely +// unconfigured systems leave no ornament or layout residue in the bar. + +import QtQuick +import QtQuick.Controls +import qs.config +import qs.services + +Rectangle { + id: root + + signal activated + + readonly property int issueCount: Health.summary.warnings + Health.summary.errors + readonly property color tone: Health.status === "error" + ? Theme.danger + : (Health.status === "warning" ? Theme.warn : "transparent") + readonly property string statusText: root.issueCount === 1 + ? "1 system health issue" + : root.issueCount + " system health issues" + readonly property string accessibleLabel: Health.status === "error" + ? "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue requires action" : " issues require action") + : "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue needs attention" : " issues need attention") + readonly property string tooltipText: root.accessibleLabel + + visible: Health.actionable + implicitWidth: visible ? content.implicitWidth + 16 : 0 + implicitHeight: visible ? 24 : 0 + width: implicitWidth + height: implicitHeight + radius: 9 + color: visible ? Theme.alpha(root.tone, 0.09) : "transparent" + border.width: activeFocus ? 2 : 1 + border.color: visible ? Theme.alpha(root.tone, activeFocus ? 0.72 : 0.20) : "transparent" + + activeFocusOnTab: visible + Accessible.role: Accessible.Button + Accessible.name: root.accessibleLabel + Accessible.description: "Open System Health" + Accessible.onPressAction: root.activated() + + onActivated: { + ShellState.openSettings("services"); + Health.refresh(); + } + + Keys.onReturnPressed: root.activated() + Keys.onEnterPressed: root.activated() + Keys.onSpacePressed: root.activated() + + Row { + id: content + + anchors.centerIn: parent + spacing: 6 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "\u{F0ECD}" // md-shield-alert-outline + color: root.tone + font.family: Theme.fontMono + font.pixelSize: 14 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: String(root.issueCount) + color: root.tone + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + } + + MouseArea { + id: pointer + + anchors.fill: parent + enabled: root.visible + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activated() + } + + ToolTip.visible: pointer.containsMouse && root.visible + ToolTip.delay: 500 + ToolTip.text: root.tooltipText +} diff --git a/config/dot/quickshell/scripts/panama-action b/config/dot/quickshell/scripts/panama-action index a43b1b4..9f755d0 100755 --- a/config/dot/quickshell/scripts/panama-action +++ b/config/dot/quickshell/scripts/panama-action @@ -109,6 +109,7 @@ case "$action" in clipboard) qs ipc call clipboard open ;; overview) qs ipc call overview open ;; settings) qs ipc call settings open ;; + health) qs ipc call health open ;; dnd) state="$(qs ipc call notifications dnd)" @@ -148,7 +149,7 @@ case "$action" in ;; *) printf 'Usage: panama-action {%s}\n' \ - 'control-center|notifications|calendar|clipboard|overview|settings|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2 + 'control-center|notifications|calendar|clipboard|overview|settings|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2 exit 2 ;; esac diff --git a/config/local/share/vicinae/scripts/check-system-health.sh b/config/local/share/vicinae/scripts/check-system-health.sh new file mode 100755 index 0000000..9e2c834 --- /dev/null +++ b/config/local/share/vicinae/scripts/check-system-health.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Panama: Check System Health +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Review Panama services, integrations, and recovery actions. +# @vicinae.keywords ["health", "doctor", "repair", "services"] + +exec "$HOME/.config/quickshell/scripts/panama-action" health diff --git a/tests/quickshell/health-ui-contract.sh b/tests/quickshell/health-ui-contract.sh index faf5406..526453d 100755 --- a/tests/quickshell/health-ui-contract.sh +++ b/tests/quickshell/health-ui-contract.sh @@ -7,6 +7,7 @@ set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" settings_dir="$repo_dir/config/dot/quickshell/modules/settings" +bar_dir="$repo_dir/config/dot/quickshell/modules/bar" fail() { printf 'health UI contract: %s\n' "$1" >&2 @@ -16,6 +17,21 @@ fail() { for file in HealthPage.qml HealthSummary.qml HealthCheckRow.qml; do [[ -f "$settings_dir/$file" ]] || fail "$file is missing" done +[[ -f "$bar_dir/HealthIndicator.qml" ]] || fail 'HealthIndicator.qml is missing' + +python3 - "$bar_dir/Bar.qml" <<'PY' || fail 'health indicator is not immediately before activity in the bar' +import re +import sys + +source = open(sys.argv[1], encoding="utf-8").read() +match = re.search(r'HealthIndicator\s*\{.*?\}\s*ActivityIndicator\s*\{', source, re.S) +assert match is not None +PY + +! rg -n '#[0-9a-fA-F]{3,8}' "$bar_dir/HealthIndicator.qml" >/dev/null \ + || fail 'health indicator introduced colors outside Theme' +! rg -n 'Behavior|Animation|Transition|pulse|shimmer' "$bar_dir/HealthIndicator.qml" >/dev/null \ + || fail 'health indicator introduced motion' [[ ! -e "$settings_dir/ServicesPage.qml" ]] || fail 'ServicesPage.qml still exists' rg -Fq 'HealthPage 1.0 HealthPage.qml' "$settings_dir/qmldir" \ || fail 'HealthPage is not registered in the Settings QML module' @@ -108,6 +124,8 @@ import Quickshell import Quickshell.Io import QtQuick +import "modules/bar" as BarModule +import qs.config import qs.modules.settings import qs.services @@ -130,6 +148,11 @@ ShellRoot { id: settingsShell anchors.fill: parent } + + BarModule.HealthIndicator { + id: healthIndicator + objectName: "health-indicator" + } } IpcHandler { @@ -142,6 +165,58 @@ ShellRoot { function request(id: string): bool { return settingsShell.requestHealthAction(id); } + + function indicatorMode(mode: string): bool { + const checkStatus = mode === "healthy" ? "ok" : (mode === "unconfigured" ? "unconfigured" : mode); + const overallStatus = mode === "warning" || mode === "error" ? mode : "healthy"; + const snapshot = { + schemaVersion: 1, + generatedAt: "2026-08-18T12:00:00Z", + summary: { + status: overallStatus, + healthy: mode === "healthy" ? 1 : 0, + warnings: mode === "warning" ? 1 : 0, + errors: mode === "error" ? 1 : 0, + unconfigured: mode === "unconfigured" ? 1 : 0 + }, + context: { session: "hyprland", versions: [] }, + checks: [{ + id: "desktop.vicinae", + group: "desktop-foundation", + title: "Vicinae", + status: checkStatus, + detail: "Fixture observation." + }] + }; + return Health.consumeSnapshot(JSON.stringify(snapshot), Health.acceptedGeneration + 1); + } + + function indicatorState(): string { + return JSON.stringify({ + visible: healthIndicator.visible, + width: healthIndicator.width, + implicitWidth: healthIndicator.implicitWidth, + issueCount: healthIndicator.issueCount, + statusText: healthIndicator.statusText, + accessibleLabel: healthIndicator.accessibleLabel, + tooltipText: healthIndicator.tooltipText, + warningTone: healthIndicator.tone === Theme.warn, + errorTone: healthIndicator.tone === Theme.danger, + activeFocusOnTab: healthIndicator.activeFocusOnTab + }); + } + + function activateIndicator(): string { + ShellState.settingsPage = "home"; + ShellState.settingsOpen = false; + const generation = Health.generation; + healthIndicator.activated(); + return JSON.stringify({ + page: ShellState.settingsPage, + settingsOpen: ShellState.settingsOpen, + refreshRequested: Health.generation > generation || Health.queuedRefresh + }); + } } } QML @@ -231,6 +306,54 @@ jq -e ' ' \ >/dev/null <<<"$confirmation_state" || fail 'Quickshell restart did not open confirmation sheet' +for hidden_mode in healthy unconfigured; do + [[ "$(run ipc call health-ui-test indicatorMode "$hidden_mode")" == "true" ]] \ + || fail "$hidden_mode indicator fixture was rejected" + hidden_state="$(run ipc call health-ui-test indicatorState)" + jq -e ' + .visible == false + and .width == 0 + and .implicitWidth == 0 + and .issueCount == 0 + ' >/dev/null <<<"$hidden_state" \ + || fail "$hidden_mode state reserved bar space: $hidden_state" +done + +[[ "$(run ipc call health-ui-test indicatorMode warning)" == "true" ]] \ + || fail 'warning indicator fixture was rejected' +warning_state="$(run ipc call health-ui-test indicatorState)" +jq -e ' + .visible == true + and .width > 0 + and .implicitWidth > 0 + and .issueCount == 1 + and .statusText == "1 system health issue" + and .accessibleLabel == "System Health: 1 issue needs attention" + and .tooltipText == "System Health: 1 issue needs attention" + and .warningTone == true + and .errorTone == false + and .activeFocusOnTab == true +' >/dev/null <<<"$warning_state" || fail "warning indicator is not the approved amber accessible capsule: $warning_state" + +[[ "$(run ipc call health-ui-test indicatorMode error)" == "true" ]] \ + || fail 'error indicator fixture was rejected' +error_state="$(run ipc call health-ui-test indicatorState)" +jq -e ' + .visible == true + and .issueCount == 1 + and .accessibleLabel == "System Health: 1 issue requires action" + and .tooltipText == "System Health: 1 issue requires action" + and .warningTone == false + and .errorTone == true +' >/dev/null <<<"$error_state" || fail "error indicator is not the approved red accessible capsule: $error_state" + +activation_state="$(run ipc call health-ui-test activateIndicator)" +jq -e ' + .page == "services" + and .settingsOpen == true + and .refreshRequested == true +' >/dev/null <<<"$activation_state" || fail "indicator activation did not open and refresh System Health: $activation_state" + if rg -i 'QQml|ReferenceError|TypeError|binding loop|failed to load component' "$shell_log"; then fail 'isolated fixture emitted QML errors or warnings' fi diff --git a/tests/quickshell/panama-action-contract.sh b/tests/quickshell/panama-action-contract.sh index 5543f74..1af8623 100755 --- a/tests/quickshell/panama-action-contract.sh +++ b/tests/quickshell/panama-action-contract.sh @@ -148,6 +148,9 @@ assert_commands 'qs ' run_action settings assert_commands 'qs ' +run_action health +assert_commands 'qs ' + run_action dnd assert_commands $'qs \npanama-osd ' @@ -191,11 +194,13 @@ assert_commands $'qs \nquickshell <--daemonize>\nqs \npanama-o PANAMA_ACTION_TEST_FAIL_KILL=true run_action restart-shell assert_commands $'qs \nquickshell <--daemonize>\nqs \npanama-osd ' +usage_output="$work/usage.txt" if HOME="$work/home" PATH="$fake_bin:$PATH" PANAMA_ACTION_HELPER_DIR="$fake_bin" \ PANAMA_ACTION_CONFIRM_ATTEMPTS=1 PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \ - "$dispatcher" definitely-not-an-action >/dev/null 2>&1; then + "$dispatcher" definitely-not-an-action > /dev/null 2>"$usage_output"; then fail 'unknown action was accepted' fi +rg -q '\bhealth\b' "$usage_output" || fail 'health is missing from dispatcher usage' : >"$command_log" if PANAMA_ACTION_TEST_FAIL_QS=true HOME="$work/home" PATH="$fake_bin:$PATH" \ diff --git a/tests/quickshell/panama-commands-contract.sh b/tests/quickshell/panama-commands-contract.sh index bc02e50..459af61 100755 --- a/tests/quickshell/panama-commands-contract.sh +++ b/tests/quickshell/panama-commands-contract.sh @@ -24,6 +24,7 @@ declare -A expected=( [open-clipboard.sh]=clipboard [open-mission-control.sh]=overview [open-settings.sh]=settings + [check-system-health.sh]=health [toggle-dnd.sh]=dnd [toggle-caffeine.sh]=caffeine [toggle-night-light.sh]=night-light @@ -72,6 +73,17 @@ for script_name in "${!expected[@]}"; do grep -Fxq '# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg' "$script" \ || fail "$script_name does not use the Panama application identity" + if [[ $script_name == check-system-health.sh ]]; then + [[ $title == 'Panama: Check System Health' ]] \ + || fail "health command has the wrong title: $title" + grep -Fxq '# @vicinae.schemaVersion 1' "$script" \ + || fail 'health command does not use schema version 1' + grep -Fxq '# @vicinae.keywords ["health", "doctor", "repair", "services"]' "$script" \ + || fail 'health command has the wrong search vocabulary' + grep -Fxq 'exec "$HOME/.config/quickshell/scripts/panama-action" health' "$script" \ + || fail 'health command bypasses the stable dispatcher path' + fi + : >"$dispatch_log" HOME="$work/home" PANAMA_COMMAND_TEST_LOG="$dispatch_log" "$script" dispatched="$(cat "$dispatch_log")"