From 88371d19f02d162cd18ad2f1911be295a6ceac9c Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 25 Aug 2026 00:39:07 -0400 Subject: [PATCH] The safety layer: two presses for anything you cannot take back Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8 --- README.md | 2 +- .../modules/powermenu/PowerButton.qml | 18 +++- .../modules/settings/ConfirmAction.qml | 81 ++++++++++++++++ .../modules/settings/DisplayPanelHeader.qml | 27 ++++-- .../quickshell/modules/settings/ErrorRow.qml | 28 ++++++ .../modules/settings/FirewallPage.qml | 63 ++++++++++++- .../modules/settings/NotMeasuredRow.qml | 26 ++++++ .../modules/settings/PrintersPage.qml | 22 ++++- .../modules/settings/SettingRow.qml | 5 +- .../modules/settings/SettingsButton.qml | 25 +++++ .../modules/settings/SharingPage.qml | 27 +++++- .../modules/settings/SnapshotsPage.qml | 10 +- .../modules/settings/SshKeysPage.qml | 22 ++++- .../quickshell/modules/settings/UsersPage.qml | 24 ++++- config/dot/quickshell/modules/settings/qmldir | 3 + config/dot/quickshell/services/ShellState.qml | 7 ++ tests/quickshell/settings-idiom-contract | 93 +++++++++++++++++++ 17 files changed, 447 insertions(+), 36 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/ConfirmAction.qml create mode 100644 config/dot/quickshell/modules/settings/ErrorRow.qml create mode 100644 config/dot/quickshell/modules/settings/NotMeasuredRow.qml create mode 100755 tests/quickshell/settings-idiom-contract diff --git a/README.md b/README.md index 3e58e74..7761af3 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -175 of them, under `tests/`. Run the lot, or a subset by pattern: +176 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/modules/powermenu/PowerButton.qml b/config/dot/quickshell/modules/powermenu/PowerButton.qml index 884df6e..bb93710 100644 --- a/config/dot/quickshell/modules/powermenu/PowerButton.qml +++ b/config/dot/quickshell/modules/powermenu/PowerButton.qml @@ -1,8 +1,13 @@ // One tile in the power menu: big glyph, label underneath. // // Anything that ends the session arms on the first press and only fires on the -// second, with the label swapping to "Confirm" — an accidental Ctrl+Alt+Delete -// should never be one click away from losing everything that is open. +// second — an accidental Ctrl+Alt+Delete should never be one click away from +// losing everything that is open. +// +// Armed, the tile turns red and names the thing it is about to do ("Power off") +// rather than saying "Confirm". Six tiles could all say "Confirm"; only one of +// them is about to take the machine down, and the press that does it should say +// which one it is. import QtQuick import qs.config @@ -21,6 +26,13 @@ Rectangle { readonly property bool armed: confirmTimer.running + // The tile's own label, said as a sentence rather than as a title: "Power + // Off" is the name of a menu entry, "Power off" is the thing the next press + // does. Derived, so an entry added to the menu cannot forget to name itself. + readonly property string armedLabel: root.label === "" + ? "Confirm" + : root.label.charAt(0) + root.label.slice(1).toLowerCase() + implicitWidth: 136 implicitHeight: 136 radius: Theme.cardRadius + 6 @@ -68,7 +80,7 @@ Rectangle { Text { anchors.horizontalCenter: parent.horizontalCenter - text: root.armed ? "Confirm" : root.label + text: root.armed ? root.armedLabel : root.label color: root.armed ? Theme.danger : Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize diff --git a/config/dot/quickshell/modules/settings/ConfirmAction.qml b/config/dot/quickshell/modules/settings/ConfirmAction.qml new file mode 100644 index 0000000..a38dca7 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ConfirmAction.qml @@ -0,0 +1,81 @@ +// The two-stage destructive confirm, as one component instead of twenty +// hand-rolled copies. The first press arms ("Verb…", normal tone -- danger +// never initiates, per SettingsButton's own contract); armed, the row shows +// Cancel ("Keep") beside the confirming press ("Verb it", danger tone). +// +// One armed confirm exists app-wide: the token lives on ShellState, so arming +// this one disarms whichever other row was armed, on any page. Consumers give +// a unique actionId, the verb pair, and handle onConfirmed; the component owns +// the state, the copy shape, and the keyboard path (via SettingsButton). +// +// ConfirmAction { +// actionId: "forget-network:" + ssid +// armText: "Forget…" // default: verb + ellipsis +// confirmText: "Forget it" +// cancelText: "Keep" // the house cancel word +// enabled: !Service.busy +// onConfirmed: Service.forget(ssid) +// } +// +// The armed state is readable (`armed`) so a row can swap its detail line for +// the consequence-naming sentence while armed -- naming what is lost is the +// caller's half of the contract; this component only guarantees the two +// presses happen and the wrong button cannot be the easy one. + +import QtQuick +import qs.config +import qs.services + +Row { + id: root + + property string actionId: "" + property string armText: "Remove…" + property string confirmText: "Remove it" + property string cancelText: "Keep" + property bool enabled: true + signal confirmed + signal armedChanged2 + + readonly property bool armed: root.actionId !== "" + && ShellState.armedConfirm === root.actionId + + spacing: 7 + + function disarm(): void { + if (root.armed) + ShellState.armedConfirm = ""; + } + + // Leaving the page, or the row vanishing under the armed state, must not + // leave a stale token claiming some other future row's identity. Rows more + // often hide than unload (an override pill, a conditional card), so the + // visibility guard matters as much as the destruction one. + Component.onDestruction: root.disarm() + onVisibleChanged: if (!root.visible) root.disarm() + + SettingsButton { + visible: !root.armed + enabled: root.enabled + text: root.armText + onClicked: ShellState.armedConfirm = root.actionId + } + + SettingsButton { + visible: root.armed + enabled: root.enabled + text: root.cancelText + onClicked: root.disarm() + } + + SettingsButton { + visible: root.armed + enabled: root.enabled + tone: "danger" + text: root.confirmText + onClicked: { + root.disarm(); + root.confirmed(); + } + } +} diff --git a/config/dot/quickshell/modules/settings/DisplayPanelHeader.qml b/config/dot/quickshell/modules/settings/DisplayPanelHeader.qml index 66f7e64..45ef608 100644 --- a/config/dot/quickshell/modules/settings/DisplayPanelHeader.qml +++ b/config/dot/quickshell/modules/settings/DisplayPanelHeader.qml @@ -104,15 +104,22 @@ Item { } } + // Armed, this line stops reporting the current mode and names what + // forgetting costs. The consequence used to live in a 400ms tooltip on + // the pill, which is not where someone about to press Forget is + // looking. Text { width: parent.width - visible: root.meta !== "" - text: root.meta + visible: root.meta !== "" || forget.armed + text: forget.armed + ? "Resolution, refresh rate, scale, rotation and color go back to what Panama picks automatically." + : root.meta color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall - elide: Text.ElideRight + wrapMode: forget.armed ? Text.WordWrap : Text.NoWrap + elide: forget.armed ? Text.ElideNone : Text.ElideRight } } @@ -136,9 +143,11 @@ Item { border.width: 1 border.color: Theme.alpha(Theme.accent, 0.25) + // The pill says what the state IS. What Forget costs is said by + // the header line while Forget is armed, not hidden in here. ToolTip.visible: customHover.hovered ToolTip.delay: 400 - ToolTip.text: "This display uses a setting you chose. Forget returns it to the one Panama ships." + ToolTip.text: "This display uses a setting you chose, not the one Panama picks automatically." Text { id: customLabel @@ -153,13 +162,17 @@ Item { HoverHandler { id: customHover } } - SettingsButton { + ConfirmAction { + id: forget + anchors.verticalCenter: parent.verticalCenter visible: root.overridden width: visible ? implicitWidth : 0 - text: "Forget" + actionId: "forget-display:" + root.connector + armText: "Forget…" + confirmText: "Forget it" enabled: root.enabled - onClicked: root.forgetRequested() + onConfirmed: root.forgetRequested() } } } diff --git a/config/dot/quickshell/modules/settings/ErrorRow.qml b/config/dot/quickshell/modules/settings/ErrorRow.qml new file mode 100644 index 0000000..527a539 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ErrorRow.qml @@ -0,0 +1,28 @@ +// A failure, reported where it happened. Before this component every page +// hand-rolled the same stanza with a different headline -- "Problem", +// "Updates need attention", "The network needs attention" -- and no visual +// mark distinguishing a failure from any other read-only fact. One shape now: +// +// ErrorRow { message: Updates.lastError } +// ErrorRow { message: Vpn.lastError; label: "The VPN needs attention" } +// +// The row hides itself while the message is empty, so consumers bind the +// service's lastError directly and write no visible: line. The message is the +// service's own sentence -- this component never rewords a failure, only +// frames it. + +import QtQuick +import qs.config + +SettingRow { + id: root + + property string message: "" + label: "Needs attention" + + visible: root.message !== "" + detail: root.message + labelColor: Theme.danger + controlWidth: 210 + divider: false +} diff --git a/config/dot/quickshell/modules/settings/FirewallPage.qml b/config/dot/quickshell/modules/settings/FirewallPage.qml index 50bef03..bc79991 100644 --- a/config/dot/quickshell/modules/settings/FirewallPage.qml +++ b/config/dot/quickshell/modules/settings/FirewallPage.qml @@ -38,6 +38,24 @@ SettingsPage { property string pendingInterface: "" property string pendingZone: "" + // The default is the same change with a wider blast radius: it decides for + // every connection that does not ask for a zone by name. It used to apply + // on the pick, one dropdown below a picker that made you confirm. + property string pendingDefaultZone: "" + + // Two armed changes on screen at once is how the wrong one gets pressed. + function armInterfaceMove(iface: string, zoneName: string): void { + root.pendingDefaultZone = ""; + root.pendingInterface = iface; + root.pendingZone = zoneName; + } + + function armDefaultZone(zoneName: string): void { + root.pendingInterface = ""; + root.pendingZone = ""; + root.pendingDefaultZone = zoneName; + } + // The zone being read in the browser. Read-only: this looks at a zone // without applying it to anything. property string browsingZone: "" @@ -416,10 +434,7 @@ SettingsPage { options: root.zoneOptions current: placement.zone divider: !placement.pending - onPicked: value => { - root.pendingInterface = placement.iface; - root.pendingZone = String(value); - } + onPicked: value => root.armInterfaceMove(placement.iface, String(value)) } SettingRow { @@ -482,7 +497,45 @@ SettingsPage { enabled: !Firewall.busy options: root.zoneOptions current: Firewall.defaultZone - onPicked: value => Firewall.setDefaultZone(String(value)) + divider: root.pendingDefaultZone === "" + onPicked: value => root.armDefaultZone(String(value)) + } + + SettingRow { + width: parent.width + visible: root.pendingDefaultZone !== "" + label: "Make " + root.pendingDefaultZone + " the default?" + // What moves is named the way the per-interface confirm names its + // interface: not "the default changes", but which machines end up + // deciding differently because of it. + detail: "Every connection firewalld has not placed in a zone of its " + + "own follows the default — each one leaves " + Firewall.defaultZone + + " for " + root.pendingDefaultZone + + ", and so does every network joined from now on." + controlWidth: 240 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: "Keep " + Firewall.defaultZone + enabled: !Firewall.busy + onClicked: root.pendingDefaultZone = "" + } + + SettingsButton { + text: "Change it" + tone: "danger" + enabled: !Firewall.busy + onClicked: { + const target = root.pendingDefaultZone; + root.pendingDefaultZone = ""; + Firewall.setDefaultZone(target); + } + } + } } // ── The zone browser ───────────────────────────────────────────────── diff --git a/config/dot/quickshell/modules/settings/NotMeasuredRow.qml b/config/dot/quickshell/modules/settings/NotMeasuredRow.qml new file mode 100644 index 0000000..4cbb942 --- /dev/null +++ b/config/dot/quickshell/modules/settings/NotMeasuredRow.qml @@ -0,0 +1,26 @@ +// The honest empty state: a fact the page cannot read right now, with the +// reason it cannot. The house rule is that "not measured" is always followed +// by "because" -- a bare dash invites the reader to assume zero, and zero is +// a measurement. +// +// NotMeasuredRow { because: "The desktop portal's permission store is not answering, so what has asked for this cannot be read." } +// NotMeasuredRow { label: "Not read yet"; because: "Reading what the battery firmware has recorded." } +// +// `because` is required in spirit: the row renders without one, but a consumer +// leaving it empty is exactly the dishonesty this component exists to prevent, +// and the idiom contract greps for it. + +import QtQuick +import qs.config + +SettingRow { + id: root + + property string because: "" + label: "Not measured" + + detail: root.because + labelColor: Theme.fgDim + controlWidth: 210 + divider: false +} diff --git a/config/dot/quickshell/modules/settings/PrintersPage.qml b/config/dot/quickshell/modules/settings/PrintersPage.qml index 7aec98a..63e0235 100644 --- a/config/dot/quickshell/modules/settings/PrintersPage.qml +++ b/config/dot/quickshell/modules/settings/PrintersPage.qml @@ -251,11 +251,16 @@ SettingsPage { width: parent.width label: String(jobRow.modelData.name ?? "Untitled") - detail: String(jobRow.modelData.printer ?? "") + " · " + // Armed, the row makes the Hold/Cancel distinction the service + // was built around: holding keeps the job, cancelling throws it + // away and the document has to be sent from the app again. + detail: cancelJob.armed + ? "The job is thrown away — printing it means sending it from the app again. Hold keeps it in the queue instead." + : String(jobRow.modelData.printer ?? "") + " · " + String(jobRow.modelData.state ?? "") + (Number(jobRow.modelData.pages ?? 0) > 0 ? " · " + jobRow.modelData.pages + " pages" : "") - controlWidth: 175 + controlWidth: 265 divider: jobRow.index < Printers.jobs.length - 1 Row { @@ -275,11 +280,18 @@ SettingsPage { : Printers.hold(Number(jobRow.modelData.id)) } - SettingsButton { + ConfirmAction { + id: cancelJob + anchors.verticalCenter: parent.verticalCenter - text: "Cancel" + actionId: "cancel-job:" + String(jobRow.modelData.id ?? "") + armText: "Cancel…" + confirmText: "Cancel it" + // Not "Keep": the job is not being kept in a drawer, it + // carries on out of the printer. + cancelText: "Keep printing" enabled: !Printers.busy - onClicked: Printers.cancel(Number(jobRow.modelData.id)) + onConfirmed: Printers.cancel(Number(jobRow.modelData.id)) } } } diff --git a/config/dot/quickshell/modules/settings/SettingRow.qml b/config/dot/quickshell/modules/settings/SettingRow.qml index fcf8ac1..56bd610 100644 --- a/config/dot/quickshell/modules/settings/SettingRow.qml +++ b/config/dot/quickshell/modules/settings/SettingRow.qml @@ -7,6 +7,9 @@ Item { default property alias trailingData: trailing.data property string icon: "" property string label: "" + // The headline's colour. Foreground for every ordinary row; ErrorRow sets + // it to Theme.danger so a failure reads as one at a glance. + property color labelColor: Theme.fg property string detail: "" property string value: "" property bool divider: true @@ -70,7 +73,7 @@ Item { Text { width: parent.width text: root.label - color: Theme.fg + color: root.labelColor font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.Medium diff --git a/config/dot/quickshell/modules/settings/SettingsButton.qml b/config/dot/quickshell/modules/settings/SettingsButton.qml index 0ca31f3..618b708 100644 --- a/config/dot/quickshell/modules/settings/SettingsButton.qml +++ b/config/dot/quickshell/modules/settings/SettingsButton.qml @@ -17,6 +17,31 @@ Rectangle { implicitHeight: 31 radius: 9 opacity: enabled ? 1 : 0.45 + + // The keyboard half of this component's own contract. Eleven files used + // to bolt these on by hand while the other hundred-odd instantiations -- + // including every confirming button in every destructive flow -- were + // pointer-only. The button carries them itself now, so a consumer cannot + // forget them. + activeFocusOnTab: root.enabled + Accessible.role: Accessible.Button + Accessible.name: root.text + Accessible.focusable: root.enabled + Accessible.focused: root.activeFocus + Accessible.onPressAction: root.clicked() + Keys.onReturnPressed: root.clicked() + Keys.onEnterPressed: root.clicked() + Keys.onSpacePressed: root.clicked() + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: parent.radius + 3 + visible: root.activeFocus + color: "transparent" + border.width: 2 + border.color: Theme.accentSecondary + } color: { if (tone === "accent") return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent; diff --git a/config/dot/quickshell/modules/settings/SharingPage.qml b/config/dot/quickshell/modules/settings/SharingPage.qml index 118cdd4..3b90741 100644 --- a/config/dot/quickshell/modules/settings/SharingPage.qml +++ b/config/dot/quickshell/modules/settings/SharingPage.qml @@ -157,15 +157,32 @@ SettingsPage { onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "") } - ActionRow { + SettingRow { + id: forgetCredentials + + width: parent.width visible: Sharing.remoteDesktop?.available === true && Sharing.remoteDesktop?.hasCredentials === true label: "Forget the stored credentials" - detail: "Remote desktop cannot be turned on again until new ones are set" - action: "Clear" - enabled: !Sharing.busy + // Armed, the row names the loss rather than the state: the keyring + // entry goes, and the only way back is the terminal flow above. + detail: forgetCredentialsConfirm.armed + ? "The user name and password are deleted from the login keyring. Remote desktop stays off until you set new ones in a terminal." + : "Remote desktop cannot be turned on again until new ones are set" + controlWidth: 200 divider: false - onTriggered: Sharing.clearRdpCredentials() + + ConfirmAction { + id: forgetCredentialsConfirm + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + actionId: "forget-rdp-credentials" + armText: "Forget…" + confirmText: "Forget them" + enabled: !Sharing.busy + onConfirmed: Sharing.clearRdpCredentials() + } } } diff --git a/config/dot/quickshell/modules/settings/SnapshotsPage.qml b/config/dot/quickshell/modules/settings/SnapshotsPage.qml index ba198d1..a29fa64 100644 --- a/config/dot/quickshell/modules/settings/SnapshotsPage.qml +++ b/config/dot/quickshell/modules/settings/SnapshotsPage.qml @@ -257,7 +257,15 @@ SettingsPage { // what to rely on later. icon: pointRow.modelData.kept ? "\u{F0A22}" : "\u{F0954}" label: String(pointRow.modelData.date ?? "") - detail: String(pointRow.modelData.description ?? "") + // Armed, the row names the loss instead of describing + // the snapshot: this is the only record of what these + // files looked like then, and deleting it is the one + // thing on this page nothing else can undo. + detail: pointRow.confirming + ? "The only copy of these files as of " + + String(pointRow.modelData.date ?? "this point in time") + + " is deleted." + : String(pointRow.modelData.description ?? "") + " · #" + pointRow.modelData.number + (pointRow.modelData.kept ? " · kept" : "") + (pointRow.browsingThis ? " · open below" : "") diff --git a/config/dot/quickshell/modules/settings/SshKeysPage.qml b/config/dot/quickshell/modules/settings/SshKeysPage.qml index f40e1ae..60f5be3 100644 --- a/config/dot/quickshell/modules/settings/SshKeysPage.qml +++ b/config/dot/quickshell/modules/settings/SshKeysPage.qml @@ -347,15 +347,27 @@ SettingsPage { width: parent.width label: String(heldRow.modelData.name ?? "") - detail: String(heldRow.modelData.fingerprint ?? "") - controlWidth: 110 + // Armed, the row stops showing the fingerprint and says what + // the confirming press will actually do -- including the case + // where it will do nothing, which is owed BEFORE the press + // rather than as an error afterwards. + detail: removeHeld.armed + ? (SshKeys.durableRemoval + ? "The agent stops offering this key until it is added again. The key file in ~/.ssh is untouched." + : "This agent lists every key in ~/.ssh, so it will refuse: removal here does not stick. Move the key out of ~/.ssh instead.") + : String(heldRow.modelData.fingerprint ?? "") + controlWidth: 190 + + ConfirmAction { + id: removeHeld - SettingsButton { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - text: "Remove" + actionId: "agent-remove:" + String(heldRow.modelData.path ?? "") + armText: "Remove…" + confirmText: "Remove it" enabled: !SshKeys.busy - onClicked: SshKeys.removeFromAgent(String(heldRow.modelData.path ?? "")) + onConfirmed: SshKeys.removeFromAgent(String(heldRow.modelData.path ?? "")) } } } diff --git a/config/dot/quickshell/modules/settings/UsersPage.qml b/config/dot/quickshell/modules/settings/UsersPage.qml index 2d646e5..c1c347b 100644 --- a/config/dot/quickshell/modules/settings/UsersPage.qml +++ b/config/dot/quickshell/modules/settings/UsersPage.qml @@ -276,16 +276,34 @@ SettingsPage { } } - SettingsButton { + ConfirmAction { + id: removePicture + visible: UserAccounts.avatarUrl !== "" - text: "Remove" + actionId: "remove-avatar" + armText: "Remove…" + confirmText: "Remove it" enabled: !UserAccounts.busy - onClicked: { + onConfirmed: { root.pictureOpen = false; UserAccounts.removeIcon(); } } } + + // The consequence, said before the confirming press: this + // deletes the file accountsservice keeps, so the picture is + // gone from the lock screen and every other account surface, + // not merely from this page. + Text { + width: parent.width + visible: removePicture.armed + text: "The picture is deleted from your account — the lock screen and everywhere else showing it fall back to your initial." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } } } diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index a48fdf9..e830b2d 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -37,6 +37,9 @@ SettingRow 1.0 SettingRow.qml SettingsCard 1.0 SettingsCard.qml SettingsNote 1.0 SettingsNote.qml SettingsButton 1.0 SettingsButton.qml +ConfirmAction 1.0 ConfirmAction.qml +ErrorRow 1.0 ErrorRow.qml +NotMeasuredRow 1.0 NotMeasuredRow.qml SettingsShell 1.0 SettingsShell.qml SettingsSidebar 1.0 SettingsSidebar.qml SettingsToggle 1.0 SettingsToggle.qml diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 878c7d4..29095c6 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -142,4 +142,11 @@ Singleton { // Set by Dock.qml so the bar can avoid fighting it for pointer grabs, and // read by the capture overlay so the dock isn't in the screenshot. property bool dockRevealed: false + + // Exactly one destructive confirmation may be armed at a time, app-wide -- + // "two armed destructive actions on screen at once is how the wrong one + // gets pressed" (SyncPage wrote that down for its own card; ConfirmAction + // enforces it for everyone). The token is owned by whichever ConfirmAction + // armed it; arming another disarms the first. + property string armedConfirm: "" } diff --git a/tests/quickshell/settings-idiom-contract b/tests/quickshell/settings-idiom-contract new file mode 100755 index 0000000..eab6fb2 --- /dev/null +++ b/tests/quickshell/settings-idiom-contract @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +# The safety idioms are only worth having if a refactor cannot quietly drop +# them. This pins the Tier-1 layer: +# +# 1. SettingsButton carries its own keyboard contract (Tab stop, Return / +# Enter / Space, Accessible role, focus ring) -- the reason no consumer +# hand-rolls those any more. +# 2. ConfirmAction exists, is registered, and every instantiation names an +# actionId; the one-armed-at-a-time token it arbitrates through lives on +# ShellState. An anonymous ConfirmAction shares "" with every other +# anonymous one, which arms them all at once. +# 3. ErrorRow instantiations bind a message, and NotMeasuredRow +# instantiations say `because:` -- a bare "Not measured" invites the +# reader to assume zero, and zero is a measurement. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +qs="$repo_dir/config/dot/quickshell" + +fail() { printf 'settings idiom contract: %s\n' "$1" >&2; exit 1; } +[[ -r "$qs/modules/settings/SettingsButton.qml" ]] || fail "missing SettingsButton.qml" + +python3 - "$qs" <<'PY' +import re, sys, pathlib +qs = pathlib.Path(sys.argv[1]) +settings = qs / 'modules' / 'settings' +problems = [] + +# 1. The button's own keyboard contract. +button = (settings / 'SettingsButton.qml').read_text() +for needle, why in [ + ('activeFocusOnTab', 'no Tab stop'), + ('Keys.onReturnPressed', 'Return does nothing'), + ('Keys.onSpacePressed', 'Space does nothing'), + ('Accessible.role', 'invisible to screen readers'), + ('Theme.accentSecondary', 'no focus ring'), +]: + if needle not in button: + problems.append(f'SettingsButton.qml: {needle} gone -- {why}') + +# 2. ConfirmAction registered, arbitrated through ShellState, always named. +qmldir = (settings / 'qmldir').read_text() +for component in ('ConfirmAction', 'ErrorRow', 'NotMeasuredRow'): + if not re.search(rf'^{component} ', qmldir, re.M): + problems.append(f'qmldir: {component} unregistered -- pages referencing it fail to load') + +confirm = (settings / 'ConfirmAction.qml').read_text() +if 'ShellState.armedConfirm' not in confirm: + problems.append('ConfirmAction.qml: not arbitrated through ShellState.armedConfirm -- two rows can be armed at once') +shellstate = (qs / 'services' / 'ShellState.qml').read_text() +if 'armedConfirm' not in shellstate: + problems.append('ShellState.qml: armedConfirm token gone -- ConfirmAction has nothing to arbitrate through') + +# 3. Instantiation-shape checks. A component use spans the braces that follow +# it; requiring the property inside that span is a cheap parse that has caught +# every real miss so far. +def block_after(text, start): + depth, i = 0, text.index('{', start) + for j in range(i, len(text)): + if text[j] == '{': depth += 1 + elif text[j] == '}': + depth -= 1 + if depth == 0: return text[i:j] + return text[i:] + +REQUIRED = {'ConfirmAction': 'actionId', 'ErrorRow': 'message', 'NotMeasuredRow': 'because'} +for page in sorted(qs.rglob('*.qml')): + if page.name in ('ConfirmAction.qml', 'ErrorRow.qml', 'NotMeasuredRow.qml'): + continue + text = page.read_text() + for component, prop in REQUIRED.items(): + for m in re.finditer(rf'\b{component}\s*\{{', text): + if not re.search(rf'\b{prop}\s*:', block_after(text, m.start())): + problems.append(f'{page.name}: {component} without {prop}:') + +if problems: + print(f"settings idiom contract: {len(problems)} problem(s)") + for p in problems: print(" -", p) + sys.exit(1) + +uses = {c: 0 for c in REQUIRED} +for page in qs.rglob('*.qml'): + if page.name in ('ConfirmAction.qml', 'ErrorRow.qml', 'NotMeasuredRow.qml'): + continue + text = page.read_text() + for c in uses: + uses[c] += len(re.findall(rf'\b{c}\s*\{{', text)) +print("settings idiom contract: ok (" + + ", ".join(f"{n} {c}" for c, n in uses.items()) + + ", every one carries its required property)") +PY