diff --git a/README.md b/README.md
index 4b241d0..bc07acc 100644
--- a/README.md
+++ b/README.md
@@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
-153 of them, under `tests/`. Run the lot, or a subset by pattern:
+156 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml
index 2d5db5e..d25275b 100644
--- a/config/dot/quickshell/config/PreferenceSchema.qml
+++ b/config/dot/quickshell/config/PreferenceSchema.qml
@@ -92,6 +92,14 @@ Singleton {
label: "Battery",
detail: "Show the charge level in the bar, on machines that have a battery"
},
+ // The number beside the icon, GNOME's "Show Battery Percentage".
+ // Off by default for the same reason GNOME ships it off: the icon
+ // already says what matters, and the number is for people who want it.
+ {
+ key: "showBatteryPercent", type: "bool", def: false, group: "vitals",
+ label: "Battery percentage",
+ detail: "Show the exact number beside the battery icon"
+ },
// Off by default: this is a coding-tool readout, not something a
// general-purpose desktop should show without being asked.
diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml
index 27c9f59..8a3f001 100644
--- a/config/dot/quickshell/config/Settings.qml
+++ b/config/dot/quickshell/config/Settings.qml
@@ -39,6 +39,7 @@ Singleton {
readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu")
readonly property bool showBattery: DesktopPreferences.get("showBattery")
+ readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent")
readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage")
// ── Battery ─────────────────────────────────────────────────────────────
diff --git a/config/dot/quickshell/modules/bar/StatusCluster.qml b/config/dot/quickshell/modules/bar/StatusCluster.qml
index 7b1273b..b95ed7a 100644
--- a/config/dot/quickshell/modules/bar/StatusCluster.qml
+++ b/config/dot/quickshell/modules/bar/StatusCluster.qml
@@ -130,6 +130,16 @@ Pill {
// battery has actually been read, so this is not a zero that looks like a
// flat cell. Same shape as the graphics field in VitalsWidget, which gates
// on both the preference and the hardware.
+ //
+ // One color for the icon and the number beside it: two different colors
+ // would read as two indicators.
+ readonly property color batteryColor: {
+ if (Battery.critical) return Theme.danger;
+ if (Battery.low) return Theme.warn;
+ if (Battery.charging) return Theme.ok;
+ return Theme.fg;
+ }
+
StatusGlyph {
visible: Settings.showBattery && Battery.available
glyph: {
@@ -141,11 +151,24 @@ Pill {
// md-battery_10 .. md-battery_90 are consecutive from F007A.
return String.fromCodePoint(0xF007A + (level / 10) - 1);
}
- color: {
- if (Battery.critical) return Theme.danger;
- if (Battery.low) return Theme.warn;
- if (Battery.charging) return Theme.ok;
- return Theme.fg;
+ color: root.batteryColor
+ }
+
+ // The exact number, for the people who ask the icon to be more specific --
+ // GNOME's "Show Battery Percentage", living under the same gates as the
+ // icon it annotates.
+ Text {
+ visible: Settings.showBattery && Settings.showBatteryPercent && Battery.available
+ anchors.verticalCenter: parent.verticalCenter
+ text: Math.round(Battery.percent) + "%"
+ color: root.batteryColor
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.durFast
+ }
}
}
}
diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml
index 880656e..4be6aba 100644
--- a/config/dot/quickshell/modules/settings/AppearancePage.qml
+++ b/config/dot/quickshell/modules/settings/AppearancePage.qml
@@ -381,6 +381,7 @@ SettingsPage {
// Only where there is a battery to report on. A desktop should not be
// offered a switch for a readout it can never show.
ToggleRow { setting: "showBattery"; visible: Battery.available }
+ ToggleRow { setting: "showBatteryPercent"; visible: Battery.available && Settings.showBattery }
ToggleRow { setting: "showAgentUsage"; divider: true }
// Refresh interval was on the Home page, which split one concept across
// two pages -- what the vitals show here, how often they update there.
diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml
index 5320687..eaa935d 100644
--- a/config/dot/quickshell/modules/settings/PowerPage.qml
+++ b/config/dot/quickshell/modules/settings/PowerPage.qml
@@ -121,6 +121,25 @@ SettingsPage {
SliderRow { setting: "suspendMinutesBattery"; zeroLabel: "Never"; divider: false }
}
+ // What the lid does. Informational by design: the decision follows what
+ // is connected (see services/LidPolicy.qml), and the deliberate absence
+ // of an override is part of the design -- a lid switch set to "never
+ // suspend" is a laptop that cooks in a bag. Saying so here beats leaving
+ // the behavior undiscoverable.
+ SettingsCard {
+ visible: Battery.available
+ title: "When the lid closes"
+ subtitle: "Decided by what is connected rather than by a setting: with an external display attached the machine is docked and keeps running; on its own it suspends, locking on the way down."
+
+ TextRow {
+ label: "Right now"
+ value: LidPolicy.inhibited
+ ? "Stays awake — an external display is connected"
+ : "Suspends"
+ divider: false
+ }
+ }
+
// Only shown when the numbers are actually contradictory, rather than as a
// permanent warning nobody reads.
SettingsCard {
diff --git a/config/dot/quickshell/modules/settings/UsersPage.qml b/config/dot/quickshell/modules/settings/UsersPage.qml
index 8e6fedb..c18ba75 100644
--- a/config/dot/quickshell/modules/settings/UsersPage.qml
+++ b/config/dot/quickshell/modules/settings/UsersPage.qml
@@ -55,7 +55,12 @@ SettingsPage {
root.newUserIsAdministrator = false;
}
- Component.onCompleted: UserAccounts.refresh()
+ Component.onCompleted: {
+ UserAccounts.refresh();
+ // Probing fprintd bus-activates it, so it waits for the page rather
+ // than costing every shell launch.
+ Fingerprint.refresh();
+ }
TextRow {
visible: UserAccounts.lastError !== ""
@@ -260,6 +265,58 @@ SettingsPage {
}
}
+ // ── Fingerprint ──────────────────────────────────────────────────────────
+ //
+ // Hidden in full on a machine with no reader. Two systems make this work
+ // and the card keeps them honest with each other: fprintd holds the
+ // enrolled prints (GNOME's Users panel owns that dialog, so enrollment
+ // hands off the same way password-adjacent panels do), and authselect
+ // decides whether PAM asks the reader at all -- a print enrolled while
+ // that is off does nothing, which reads as "fingerprint is broken".
+
+ SettingsCard {
+ visible: Fingerprint.readerPresent
+ title: "Fingerprint"
+ subtitle: Fingerprint.readerName !== ""
+ ? Fingerprint.readerName
+ : "A fingerprint reader is present."
+
+ SwitchRow {
+ label: "Unlock with a fingerprint"
+ detail: {
+ if (Fingerprint.enrolled.length === 0)
+ return "Enroll a finger below first; until then the password is the only way in";
+ return Fingerprint.pamEnabled
+ ? "The lock screen and sudo accept an enrolled finger, with the password as fallback"
+ : "Enrolled fingers are ignored until this is on";
+ }
+ checked: Fingerprint.pamEnabled
+ enabled: !Fingerprint.busy
+ onToggled: value => Fingerprint.setUnlockEnabled(value)
+ }
+
+ ActionRow {
+ label: "Enrolled fingers"
+ detail: Fingerprint.enrolled.length === 0
+ ? "None yet"
+ : Fingerprint.enrolled.map(finger => Fingerprint.fingerLabel(finger)).join(", ")
+ action: "Manage…"
+ divider: Fingerprint.lastError !== ""
+ // GNOME's Users panel owns the enrollment dialog; growing our own
+ // means reimplementing a guided capture flow fprintd already has
+ // a good one of.
+ onTriggered: SystemSettings.openGnomePanel("system", "users")
+ }
+
+ TextRow {
+ visible: Fingerprint.lastError !== ""
+ label: "Fingerprint needs attention"
+ detail: Fingerprint.lastError
+ value: ""
+ divider: false
+ }
+ }
+
// ── Everyone else ────────────────────────────────────────────────────────
SettingsCard {
diff --git a/config/dot/quickshell/scripts/panama-fingerprint b/config/dot/quickshell/scripts/panama-fingerprint
new file mode 100755
index 0000000..760c045
--- /dev/null
+++ b/config/dot/quickshell/scripts/panama-fingerprint
@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+
+# Fingerprint state and the one privileged switch, for the Users page.
+#
+# Two independent facts make a working fingerprint login, and conflating them
+# is how the feature usually confuses people: fprintd must hold at least one
+# enrolled print (GNOME's Users panel owns that dialog, and Panama hands off
+# to it), and PAM must be told to ask the reader at all, which on Fedora is
+# authselect's `with-fingerprint` feature. This helper reports both and can
+# flip the second.
+#
+# Usage:
+# panama-fingerprint status -> {"reader":bool,"readerName":"","enrolled":[],"pamEnabled":bool,"error":""}
+# panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit)
+#
+# authselect is baseline Fedora (it manages PAM for the whole install), and
+# fprintd ships with Workstation; a machine with neither simply reports no
+# reader, which hides the card.
+
+set -uo pipefail
+
+PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
+
+emit() {
+ jq -cn \
+ --argjson reader "$1" \
+ --arg readerName "$2" \
+ --argjson enrolled "$3" \
+ --argjson pamEnabled "$4" \
+ --arg error "$5" \
+ '{reader: $reader, readerName: $readerName, enrolled: $enrolled,
+ pamEnabled: $pamEnabled, error: $error}'
+}
+
+cmd_status() {
+ command -v fprintd-list >/dev/null 2>&1 || { emit false "" '[]' false ""; return; }
+
+ # fprintd-list both answers "is there a reader" (fprintd is bus-activated,
+ # so this also copes with the daemon not running yet) and names the
+ # enrolled fingers in one call.
+ local listing
+ if ! listing="$(timeout 10 fprintd-list "$USER" 2>&1)"; then
+ # "No devices available" is the normal no-reader machine; anything
+ # else is a real problem worth surfacing.
+ if grep -qi 'no devices' <<<"$listing"; then
+ emit false "" '[]' false ""
+ else
+ emit false "" '[]' false "fprintd did not answer: $(head -1 <<<"$listing")"
+ fi
+ return
+ fi
+
+ # "Fingerprints for user gib on FocalTech ... (press):" carries the reader
+ # product name; " - #0: right-index-finger" lines carry the enrollment.
+ local name enrolled pam
+ name="$(sed -n 's/^Fingerprints for user [^ ]* on \(.*\) (\w*):$/\1/p' <<<"$listing" | head -1)"
+ enrolled="$(sed -n 's/^ *- #[0-9]*: //p' <<<"$listing" | jq -Rn '[inputs]')"
+ pam=false
+ authselect current 2>/dev/null | grep -q 'with-fingerprint' && pam=true
+
+ emit true "$name" "$enrolled" "$pam" ""
+}
+
+cmd_set_unlock() {
+ local verb reason
+ case "$1" in
+ on) verb=enable-feature
+ reason="Turning on fingerprint login: telling PAM (via authselect) to ask the fingerprint reader when unlocking" ;;
+ off) verb=disable-feature
+ reason="Turning off fingerprint login: telling PAM (via authselect) to stop asking the fingerprint reader" ;;
+ *) echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1 ;;
+ esac
+
+ local sudo_cmd=(sudo)
+ [[ -x "$PANAMA_PATH/bin/panama-sudo" ]] && sudo_cmd=(
+ "$PANAMA_PATH/bin/panama-sudo" --reason "$reason" --
+ )
+ "${sudo_cmd[@]}" authselect "$verb" with-fingerprint
+}
+
+case "${1:-}" in
+ status) cmd_status ;;
+ set-unlock) [[ -n "${2:-}" ]] || { echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1; }
+ cmd_set_unlock "$2" ;;
+ *) echo 'usage: panama-fingerprint status | set-unlock on|off' >&2; exit 1 ;;
+esac
diff --git a/config/dot/quickshell/services/Fingerprint.qml b/config/dot/quickshell/services/Fingerprint.qml
new file mode 100644
index 0000000..b3ef675
--- /dev/null
+++ b/config/dot/quickshell/services/Fingerprint.qml
@@ -0,0 +1,95 @@
+pragma Singleton
+
+// The fingerprint reader, for the Users page.
+//
+// Two facts, owned by two different systems: fprintd holds the enrolled
+// prints (GNOME's Users panel owns the enrollment dialog and Panama hands off
+// to it), and authselect decides whether PAM asks the reader at unlock. Both
+// come through scripts/panama-fingerprint, and the one privileged change --
+// flipping authselect's with-fingerprint feature -- prompts through polkit
+// with a stated reason, like everything else on that page.
+//
+// Read when the page opens rather than at shell startup: probing fprintd
+// bus-activates the daemon, and most sessions never open this page.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-fingerprint"
+
+ property bool readerPresent: false
+ property string readerName: ""
+ property var enrolled: []
+ property bool pamEnabled: false
+ property bool scanned: false
+ property bool busy: false
+ property string lastError: ""
+
+ // "right-index-finger" -> "Right index finger". Presentation lives here
+ // rather than in the page, the way PowerProfiles.label does, so nothing
+ // can disagree about what a finger is called.
+ function fingerLabel(finger: string): string {
+ const words = String(finger).split("-").join(" ");
+ return words.slice(0, 1).toUpperCase() + words.slice(1);
+ }
+
+ function refresh(): void {
+ if (!query.running)
+ query.running = true;
+ }
+
+ function setUnlockEnabled(on: bool): void {
+ if (root.busy)
+ return;
+ root.busy = true;
+ root.lastError = "";
+ apply.command = [root.helperPath, "set-unlock", on ? "on" : "off"];
+ apply.running = true;
+ }
+
+ Process {
+ id: query
+ command: [root.helperPath, "status"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const parsed = JSON.parse(this.text);
+ root.readerPresent = parsed.reader === true;
+ root.readerName = String(parsed.readerName ?? "");
+ root.enrolled = Array.isArray(parsed.enrolled) ? parsed.enrolled : [];
+ root.pamEnabled = parsed.pamEnabled === true;
+ if (String(parsed.error ?? "") !== "")
+ root.lastError = String(parsed.error);
+ } catch (error) {
+ root.readerPresent = false;
+ root.lastError = "Could not read the fingerprint helper's output.";
+ console.warn("Fingerprint: could not parse helper output:", error);
+ }
+ root.scanned = true;
+ }
+ }
+ }
+
+ Process {
+ id: apply
+ stderr: StdioCollector {
+ // A dismissed polkit prompt is a normal outcome on this page, not
+ // a failure to report.
+ onStreamFinished: {
+ const text = this.text.trim();
+ if (text !== "" && !/dismissed|not authorized/i.test(text))
+ root.lastError = text;
+ }
+ }
+ // Re-read rather than assuming: authselect may refuse, and the
+ // prompt may have been dismissed.
+ onExited: {
+ root.busy = false;
+ root.refresh();
+ }
+ }
+}
diff --git a/config/local/share/vicinae/scripts/settings-appearance b/config/local/share/vicinae/scripts/settings-appearance
index 200a0f8..e8eeb1f 100755
--- a/config/local/share/vicinae/scripts/settings-appearance
+++ b/config/local/share/vicinae/scripts/settings-appearance
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Appearance in Settings.
-# @vicinae.keywords ["settings", "24-hour time", "show seconds", "show weekday", "processor", "memory", "graphics", "battery", "claude usage", "inner gaps", "outer gaps", "border width", "corner radius"]
+# @vicinae.keywords ["settings", "24-hour time", "show seconds", "show weekday", "processor", "memory", "graphics", "battery", "battery percentage", "claude usage", "inner gaps", "outer gaps", "border width"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page appearance
diff --git a/docs/settings.md b/docs/settings.md
index 0a7c2c2..92c214d 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
-147 settings across 29 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
+148 settings across 29 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -317,6 +317,7 @@ Found on **Appearance**.
| **Memory**
`showMemory` | true | Show memory usage beside the workspace indicator |
| **Graphics**
`showGpu` | true | Show graphics usage beside the workspace indicator |
| **Battery**
`showBattery` | true | Show the charge level in the bar, on machines that have a battery |
+| **Battery percentage**
`showBatteryPercent` | false | Show the exact number beside the battery icon |
| **Claude usage**
`showAgentUsage` | false | Show how much of the Claude subscription has been used, beside the other vitals |
| **Vitals refresh**
`vitalsIntervalMs` | 2000 ms | How often processor, memory, and graphics usage update. Range 500–10000. |
diff --git a/setup/packages/hyprland-packages b/setup/packages/hyprland-packages
index 41050e0..32fd5d5 100644
--- a/setup/packages/hyprland-packages
+++ b/setup/packages/hyprland-packages
@@ -4,6 +4,10 @@ adwaita-icon-theme
adwaita-sans-fonts
brightnessctl
ddcutil
+# The Users page's fingerprint card reads enrollment through fprintd-list.
+# Workstation ships fprintd; declaring it keeps the card from silently
+# missing on an install that started from less.
+fprintd
gpu-screen-recorder
grim
grimblast
diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract
index 865c163..bcb3afa 100755
--- a/tests/quickshell/declared-dependencies-contract
+++ b/tests/quickshell/declared-dependencies-contract
@@ -70,6 +70,7 @@ package_for() {
case "$1" in
zbarimg) printf 'zbar' ;;
fc-list|fc-match) printf 'fontconfig' ;;
+ fprintd-list) printf 'fprintd' ;;
lspci) printf 'pciutils' ;;
getenforce) printf 'libselinux-utils' ;;
nmcli) printf 'NetworkManager' ;;
diff --git a/tests/quickshell/fingerprint-contract b/tests/quickshell/fingerprint-contract
new file mode 100755
index 0000000..539d1ba
--- /dev/null
+++ b/tests/quickshell/fingerprint-contract
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+
+# Fingerprint login, which is two systems that must be kept honest with each
+# other: fprintd holds the enrolled prints, authselect decides whether PAM
+# asks the reader. A print enrolled while with-fingerprint is off does
+# nothing, and that silence -- "I enrolled a finger and nothing happened" --
+# is the failure this card exists to name.
+#
+# The helper is the parse surface, so it runs for real against stub fprintd
+# and authselect; the page and service checks are structural.
+
+set -uo pipefail
+
+repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint"
+service="$repo_dir/config/dot/quickshell/services/Fingerprint.qml"
+page="$repo_dir/config/dot/quickshell/modules/settings/UsersPage.qml"
+
+fail() {
+ printf 'fingerprint contract: %s\n' "$1" >&2
+ exit 1
+}
+
+# ── Wiring ───────────────────────────────────────────────────────────────────
+
+rg -Fq 'visible: Fingerprint.readerPresent' "$page" \
+ || fail 'the card is not hidden on machines with no reader'
+rg -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \
+ || fail 'the unlock switch does not drive authselect'
+rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$page" \
+ || fail 'enrollment does not hand off to the GNOME Users panel'
+rg -Fq 'Fingerprint.refresh()' "$page" \
+ || fail 'the page never reads the fingerprint state'
+rg -Fq 'authselect' "$helper" && rg -Fq 'with-fingerprint' "$helper" \
+ || fail 'the helper does not manage the authselect feature'
+rg -Fq -- '--reason' "$helper" \
+ || fail 'the privileged change carries no stated reason'
+rg -Fq 'function fingerLabel' "$service" \
+ || fail 'finger names have no single place to be presented from'
+
+# ── The helper against stub fprintd and authselect ───────────────────────────
+
+stub_dir="$(mktemp -d)"
+state_dir="$(mktemp -d)"
+trap 'rm -rf "$stub_dir" "$state_dir"' EXIT
+
+cat >"$stub_dir/fprintd-list" <"$stub_dir/authselect" <>"$state_dir/authselect-log"
+if [[ "\$1" == "current" ]]; then
+ echo 'Profile ID: local'
+ [[ -e "$state_dir/pam-on" ]] && echo '- with-fingerprint'
+ exit 0
+fi
+STUB
+
+# PANAMA_PATH pointed at an empty directory forces the plain-sudo fallback,
+# which the stub records instead of escalating.
+cat >"$stub_dir/sudo" <>"$state_dir/sudo-log"
+exec "\$@"
+STUB
+chmod +x "$stub_dir"/fprintd-list "$stub_dir"/authselect "$stub_dir"/sudo
+
+run() { PANAMA_PATH="$state_dir" PATH="$stub_dir:$PATH" "$helper" "$@"; }
+
+status="$(run status)"
+jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \
+ || fail "the reader name did not parse: $status"
+jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \
+ || fail "enrolled fingers did not parse: $status"
+jq -e '.pamEnabled == false and .error == ""' <<<"$status" >/dev/null \
+ || fail "authselect state misread as enabled: $status"
+
+touch "$state_dir/pam-on"
+jq -e '.pamEnabled == true' <<<"$(run status)" >/dev/null \
+ || fail 'with-fingerprint enabled was not detected'
+
+# No reader is a normal machine, not an error.
+touch "$state_dir/no-reader"
+jq -e '.reader == false and .error == ""' <<<"$(run status)" >/dev/null \
+ || fail "a readerless machine was reported as a problem: $(run status)"
+rm -f "$state_dir/no-reader"
+
+# The privileged change goes through, with the right feature name.
+run set-unlock on >/dev/null
+grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/sudo-log" \
+ || fail 'set-unlock on did not enable the authselect feature'
+run set-unlock off >/dev/null
+grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/sudo-log" \
+ || fail 'set-unlock off did not disable the authselect feature'
+
+printf 'fingerprint contract: ok\n'
diff --git a/tests/quickshell/gnome-handoff-contract b/tests/quickshell/gnome-handoff-contract
index 819fbaf..b592db4 100755
--- a/tests/quickshell/gnome-handoff-contract
+++ b/tests/quickshell/gnome-handoff-contract
@@ -49,6 +49,7 @@ declare -A OWNED=(
# Anything here must be justified, not merely tolerated.
declare -A ALLOWED=(
["OnlineAccountsPage.qml:online-accounts"]="adding an account requires GOA's own dialog"
+ ["UsersPage.qml:system-users"]="fingerprint enrollment requires fprintd's guided capture flow, and GNOME's Users panel carries the only good dialog for it"
)
pages="$(grep -oE '\{ *page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/' | sort -u)"