The safety layer: two presses for anything you cannot take back

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 00:39:07 -04:00
parent 8b1205e4b8
commit 88371d19f0
17 changed files with 447 additions and 36 deletions
@@ -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();
}
}
}
@@ -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()
}
}
}
@@ -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
}
@@ -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 ─────────────────────────────────────────────────
@@ -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
}
@@ -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))
}
}
}
@@ -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
@@ -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;
@@ -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()
}
}
}
@@ -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" : "")
@@ -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 ?? ""))
}
}
}
@@ -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
}
}
}
@@ -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