aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-16-user-details.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-10-16-user-details.md')
-rw-r--r--docs/superpowers/plans/2026-10-16-user-details.md1054
1 files changed, 1054 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-16-user-details.md b/docs/superpowers/plans/2026-10-16-user-details.md
new file mode 100644
index 0000000..5b13b90
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-16-user-details.md
@@ -0,0 +1,1054 @@
+# User Details Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A drawer row greeting the login user, opening a page that edits name, email, avatar, language, location and git signing key, written where other programs read them.
+
+**Architecture:** `userctl` (bash) is the only reader and writer of the system stores: AccountsService over `busctl`, `~/.config/emaildefaults` via `kwriteconfig6`, git via `git config --global`. The QML side (`desktop/modules/user/`) calls it through one-shot `Process`es. `User.js` holds the pure helpers (parse, locale list, git name preview), node-tested. The drawer gains a header row and a `Module.tiled` flag so the module has a page and no tile.
+
+**Tech Stack:** bash, jq, busctl (elogind), kwriteconfig6, gpg, Quickshell 0.3.1 QML, node for the JS test.
+
+Spec: `docs/superpowers/specs/2026-10-16-user-details-design.md`.
+
+---
+
+## Before starting
+
+- The working tree may hold uncommitted work from other tasks (the perf page). Stage only the files each task names.
+- No real personal data in any committed file, test fixture or message: use `Jane Doèr`, `jane@example.org`. A gitleaks hook blocks the real surname.
+- Never run `qs` from a tool call; the user's running shell hot-reloads on save. Read its log with `timeout 5 qs log -p desktop | tail`.
+
+## File map
+
+| File | Role |
+| --- | --- |
+| `desktop/modules/user/userctl` | create: get, keys, set |
+| `desktop/modules/user/test-userctl.sh` | create: stubbed check for userctl |
+| `desktop/modules/user/User.js` | create: parse, locales, gitName |
+| `desktop/modules/user/test_user.js` | create: node check for User.js |
+| `desktop/modules/user/UserRow.qml` | create: the drawer greeting |
+| `desktop/modules/user/UserPage.qml` | create: the form |
+| `desktop/modules/user/UserModule.qml` | create: registers the page |
+| `desktop/modules/user/README.md` | create |
+| `desktop/Module.qml` | modify: `tiled` |
+| `desktop/Drawer.qml` | modify: tile filter, header row |
+| `desktop/shell.qml` | modify: import and list the module |
+| `desktop/README.md`, `AGENTS.md` | modify: module list, busctl note |
+
+---
+
+### Task 1: userctl
+
+**Files:**
+- Create: `desktop/modules/user/test-userctl.sh`
+- Create: `desktop/modules/user/userctl`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/modules/user/test-userctl.sh`:
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The one runnable check for userctl. busctl, kwriteconfig6, git and gpg are
+# stubs on PATH that log their arguments, so nothing here reaches the real
+# AccountsService, emaildefaults or git config. jq is the real one.
+#
+# Usage: ./test-userctl.sh (exit 0 = all passed)
+
+set -u
+
+here="$(cd "$(dirname "$0")" && pwd)"
+ctl="$here/userctl"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+mkdir "$tmp/bin"
+cat > "$tmp/bin/stub" <<'EOF'
+#!/bin/bash
+n="${0##*/}"
+{ printf '%s' "$n"; printf ' [%s]' "$@"; echo; } >> "$LOG"
+[[ "${FAIL:-}" == "$n" ]] && { echo boom >&2; exit 1; }
+case "$n" in
+ busctl) [[ " $* " == *" get-property "* ]] && printf '{"type":"s","data":"%s"}\n' "${@: -1}" ;;
+ git) [[ " $* " == *" --get "* ]] && { [[ -n "${GIT_VALUE:-}" ]] && echo "$GIT_VALUE" || exit 1; } ;;
+ gpg) cat "$GPG_FIXTURE" ;;
+esac
+exit 0
+EOF
+chmod +x "$tmp/bin/stub"
+for n in busctl kwriteconfig6 git gpg; do ln -s stub "$tmp/bin/$n"; done
+export PATH="$tmp/bin:$PATH" HOME="$tmp" LOG="$tmp/log"
+
+obj="/org/freedesktop/Accounts/User$(id -u)"
+acc="busctl [--system] [call] [org.freedesktop.Accounts] [$obj] [org.freedesktop.Accounts.User]"
+
+pass=0
+fail=0
+check() {
+ local label="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ printf 'ok %s\n' "$label"; pass=$((pass + 1))
+ else
+ printf 'FAIL %s:\n want %q\n got %q\n' "$label" "$want" "$got"; fail=$((fail + 1))
+ fi
+}
+reset() { : > "$LOG"; unset FAIL GIT_VALUE; }
+
+# get: one line per field, AccountsService through --json, the key from git.
+reset
+export GIT_VALUE=ABCDEF
+check "get" "name=RealName
+email=Email
+avatar=IconFile
+language=Language
+location=Location
+signingkey=ABCDEF" "$("$ctl" get)"
+
+# keys: the primary fingerprint and first uid of each secret key; subkey
+# fingerprints and later uids are skipped.
+reset
+cat > "$tmp/gpg.txt" <<'EOF'
+sec:u:255:22:AAAA1111:1700000000:::u:::scESC:::+:::23::0:
+fpr:::::::::1111222233334444555566667777AAAA1111:
+grp:::::::::0000:
+uid:u::::1700000000::HASH::Jane Doèr <jane@example.org>::::::::::0:
+uid:u::::1700000000::HASH::Jane D. <jd@example.org>::::::::::0:
+ssb:u:255:18:SUB1:1700000000::::::e:::+:::23:
+fpr:::::::::9999888877776666555544443333SUB1:
+sec:u:255:22:BBBB2222:1700000000:::u:::scESC:::#:::23::0:
+fpr:::::::::AAAABBBBCCCCDDDDEEEEFFFF0000BBBB2222:
+uid:u::::1700000000::HASH::Work Key <work@example.org>::::::::::0:
+EOF
+export GPG_FIXTURE="$tmp/gpg.txt"
+check "keys" "1111222233334444555566667777AAAA1111 Jane Doèr <jane@example.org>
+AAAABBBBCCCCDDDDEEEEFFFF0000BBBB2222 Work Key <work@example.org>" "$("$ctl" keys)"
+
+# set name: AccountsService, emaildefaults, git with the surname shortened.
+reset
+out="$("$ctl" set name "Jane Doèr")"
+check "set name prints nothing" "" "$out"
+check "set name writes" "$acc [SetRealName] [s] [Jane Doèr]
+kwriteconfig6 [--file] [emaildefaults] [--group] [PROFILE_Default] [--key] [FullName] [Jane Doèr]
+git [config] [--global] [user.name] [Jane D.]" "$(cat "$LOG")"
+
+gitname() { reset; "$ctl" set name "$1" >/dev/null; grep '^git' "$LOG"; }
+check "three words" "git [config] [--global] [user.name] [Anna Maria R.]" "$(gitname "Anna Maria Rossi")"
+check "one word" "git [config] [--global] [user.name] [Cher]" "$(gitname "Cher")"
+check "accented initial" "git [config] [--global] [user.name] [Ève À.]" "$(gitname "Ève Àlba")"
+check "extra spaces" "git [config] [--global] [user.name] [Jane D.]" "$(gitname " Jane Doèr ")"
+
+# set email: the same three stores.
+reset
+"$ctl" set email jane@example.org >/dev/null
+check "set email writes" "$acc [SetEmail] [s] [jane@example.org]
+kwriteconfig6 [--file] [emaildefaults] [--group] [PROFILE_Default] [--key] [EmailAddress] [jane@example.org]
+git [config] [--global] [user.email] [jane@example.org]" "$(cat "$LOG")"
+
+# AccountsService only.
+for pair in avatar:SetIconFile language:SetLanguage location:SetLocation; do
+ reset
+ "$ctl" set "${pair%%:*}" v >/dev/null
+ check "set ${pair%%:*} writes" "$acc [${pair#*:}] [s] [v]" "$(cat "$LOG")"
+done
+
+# signingkey: a fingerprint is written; empty unsets, but only if one is set.
+reset
+"$ctl" set signingkey ABCD >/dev/null
+check "set signingkey" "git [config] [--global] [user.signingkey] [ABCD]" "$(cat "$LOG")"
+reset
+export GIT_VALUE=OLD
+"$ctl" set signingkey "" >/dev/null
+check "unset signingkey" "git [config] [--global] [--get] [user.signingkey]
+git [config] [--global] [--unset] [user.signingkey]" "$(cat "$LOG")"
+reset
+"$ctl" set signingkey "" >/dev/null
+check "unset when none is set" "git [config] [--global] [--get] [user.signingkey]" "$(cat "$LOG")"
+
+# A failing write stops there, says why on stdout, and exits 1.
+reset
+export FAIL=busctl
+out="$("$ctl" set name "Jane Doèr")"; rc=$?
+check "failure exit" "1" "$rc"
+check "failure message" "busctl: boom" "$out"
+check "failure stops" "1" "$(wc -l < "$LOG" | tr -d ' ')"
+
+# An unknown field is refused.
+reset
+"$ctl" set shoesize 42 >/dev/null 2>&1; rc=$?
+check "unknown field" "2" "$rc"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ $fail -eq 0 ]]
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `chmod +x desktop/modules/user/test-userctl.sh && desktop/modules/user/test-userctl.sh`
+Expected: FAIL lines (userctl does not exist), last line `0 passed, N failed`, exit 1.
+
+- [ ] **Step 3: Write userctl**
+
+`desktop/modules/user/userctl`:
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The user's details, read and written where other programs look for them.
+#
+# userctl get key=value: name email avatar language location signingkey
+# userctl keys fingerprint<TAB>uid per gpg secret key
+# userctl set <key> <val> write one field to every store holding it
+#
+# Stores: AccountsService (and through SetRealName the passwd GECOS that
+# LibreOffice reads), KDE's ~/.config/emaildefaults, the global git config.
+# Polkit lets a user change their own AccountsService entry without a prompt.
+#
+# set prints nothing on success. On a failing write it prints the command and
+# its error on stdout, for the drawer page, and exits 1 without trying the
+# remaining stores. A new field is one case below and one entry in the
+# page's field list.
+
+set -u
+# Character-wise ${x:0:1}, so an accented initial is not cut in half.
+export LC_ALL=C.UTF-8
+
+ACC=org.freedesktop.Accounts
+OBJ="/org/freedesktop/Accounts/User$(id -u)"
+IFACE=org.freedesktop.Accounts.User
+
+usage() {
+ printf 'usage: %s get | keys | set <name|email|avatar|language|location|signingkey> <value>\n' \
+ "${0##*/}" >&2
+ exit 2
+}
+
+run() {
+ local err
+ if ! err="$("$@" 2>&1 >/dev/null)"; then
+ printf '%s: %s\n' "$1" "${err:-failed}"
+ exit 1
+ fi
+}
+
+# --json, because plain get-property prints non-ASCII as octal escapes.
+prop() {
+ busctl --system --json=short get-property "$ACC" "$OBJ" "$IFACE" "$1" | jq -r .data
+}
+
+acc() { run busctl --system call "$ACC" "$OBJ" "$IFACE" "$1" s "$2"; }
+kde() { run kwriteconfig6 --file emaildefaults --group PROFILE_Default --key "$1" "$2"; }
+
+# The surname as an initial, for commits: "Jane Doèr" -> "Jane D.".
+gitname() {
+ local -a w
+ read -ra w <<< "$1"
+ local n=${#w[@]}
+ if (( n < 2 )); then printf '%s' "${w[*]}"; return; fi
+ printf '%s %s.' "${w[*]:0:n-1}" "${w[n-1]:0:1}"
+}
+
+[[ $# -ge 1 ]] || usage
+
+case "$1" in
+ get)
+ printf 'name=%s\n' "$(prop RealName)"
+ printf 'email=%s\n' "$(prop Email)"
+ printf 'avatar=%s\n' "$(prop IconFile)"
+ printf 'language=%s\n' "$(prop Language)"
+ printf 'location=%s\n' "$(prop Location)"
+ printf 'signingkey=%s\n' "$(git config --global --get user.signingkey)"
+ ;;
+ keys)
+ # The first fpr after a sec is the primary key's; the first uid
+ # after that names it. ssb fingerprints come later and are skipped.
+ gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '
+ $1 == "sec" { want = 1; next }
+ $1 == "fpr" && want == 1 { fpr = $10; want = 2; next }
+ $1 == "uid" && want == 2 { print fpr "\t" $10; want = 0 }'
+ ;;
+ set)
+ [[ $# -eq 3 ]] || usage
+ v="$3"
+ case "$2" in
+ name)
+ acc SetRealName "$v"
+ kde FullName "$v"
+ run git config --global user.name "$(gitname "$v")"
+ ;;
+ email)
+ acc SetEmail "$v"
+ kde EmailAddress "$v"
+ run git config --global user.email "$v"
+ ;;
+ avatar) acc SetIconFile "$v" ;;
+ language) acc SetLanguage "$v" ;;
+ location) acc SetLocation "$v" ;;
+ signingkey)
+ if [[ -n "$v" ]]; then
+ run git config --global user.signingkey "$v"
+ elif git config --global --get user.signingkey >/dev/null; then
+ run git config --global --unset user.signingkey
+ fi
+ ;;
+ *) usage ;;
+ esac
+ ;;
+ *) usage ;;
+esac
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `chmod +x desktop/modules/user/userctl && desktop/modules/user/test-userctl.sh`
+Expected: every line `ok`, last line `19 passed, 0 failed`, exit 0.
+
+- [ ] **Step 5: Read-only check against the real stores**
+
+Run: `desktop/modules/user/userctl get && desktop/modules/user/userctl keys | cut -c1-50`
+Expected: six `key=value` lines with the real values (accented letters intact, not `\303`), and one line per secret key. Do not run `set` here; the user tries writes from the page.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/modules/user/userctl desktop/modules/user/test-userctl.sh
+git commit -m "feat(user): userctl reads and writes the user's details
+
+One writer for the stores other programs read: AccountsService (and with
+it the passwd GECOS LibreOffice prefills from), KDE's emaildefaults and
+the global git config, where the surname goes in as an initial. busctl
+is asked for JSON because its plain output escapes non-ASCII as octal.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: User.js
+
+**Files:**
+- Create: `desktop/modules/user/test_user.js`
+- Create: `desktop/modules/user/User.js`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/modules/user/test_user.js`:
+
+```js
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Run: node desktop/modules/user/test_user.js
+// User.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/User.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const U = new Function(src + "\nreturn { parse, keys, locales, gitName };")();
+
+// parse: key=value lines; a value may itself hold "=".
+assert.deepStrictEqual(U.parse("name=Jane Doèr\nlocation=a=b\n\n"),
+ { name: "Jane Doèr", location: "a=b" });
+assert.deepStrictEqual(U.parse(""), {});
+
+// keys: userctl keys lines into {fpr, uid}.
+assert.deepStrictEqual(U.keys("AAAA\tJane <j@example.org>\nBBBB\tWork\n"),
+ [{ fpr: "AAAA", uid: "Jane <j@example.org>" }, { fpr: "BBBB", uid: "Work" }]);
+
+// locales: UTF-8 entries of locale -a, spelled as AccountsService stores
+// them; C and POSIX left out; sorted.
+assert.deepStrictEqual(U.locales("C\nC.utf8\nPOSIX\nit_IT.utf8\nen_US.UTF-8\nde_DE\nfr_FR.utf8\n"),
+ ["en_US.UTF-8", "fr_FR.UTF-8", "it_IT.UTF-8"]);
+
+// gitName: the same rule as userctl's gitname.
+assert.strictEqual(U.gitName("Jane Doèr"), "Jane D.");
+assert.strictEqual(U.gitName("Anna Maria Rossi"), "Anna Maria R.");
+assert.strictEqual(U.gitName("Cher"), "Cher");
+assert.strictEqual(U.gitName("Ève Àlba"), "Ève À.");
+assert.strictEqual(U.gitName(" Jane Doèr "), "Jane D.");
+assert.strictEqual(U.gitName(""), "");
+
+console.log("test_user: ok");
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `node desktop/modules/user/test_user.js`
+Expected: FAIL with `ENOENT: no such file or directory` for `User.js`.
+
+- [ ] **Step 3: Write User.js**
+
+`desktop/modules/user/User.js`:
+
+```js
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+.pragma library
+
+// Pure helpers for the user page, tested by test_user.js under node.
+
+// `userctl get` output into an object.
+function parse(text) {
+ const out = {};
+ for (const line of text.split("\n")) {
+ const i = line.indexOf("=");
+ if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
+ }
+ return out;
+}
+
+// `userctl keys` output into [{fpr, uid}].
+function keys(text) {
+ return text.split("\n").filter(l => l.includes("\t"))
+ .map(l => ({ fpr: l.slice(0, l.indexOf("\t")), uid: l.slice(l.indexOf("\t") + 1) }));
+}
+
+// `locale -a` prints it_IT.utf8; AccountsService stores it_IT.UTF-8.
+function locales(text) {
+ return text.split("\n")
+ .filter(l => /\.utf-?8$/i.test(l) && !/^C\./.test(l))
+ .map(l => l.replace(/\.utf-?8$/i, ".UTF-8"))
+ .sort();
+}
+
+// The name git gets, for the page's preview. ponytail: duplicates userctl's
+// gitname; both tests pin the same cases, keep them in step.
+function gitName(name) {
+ const w = name.trim().split(/\s+/).filter(s => s !== "");
+ if (w.length < 2) return w.join(" ");
+ return w.slice(0, -1).join(" ") + " " + Array.from(w[w.length - 1])[0] + ".";
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `node desktop/modules/user/test_user.js`
+Expected: `test_user: ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/user/User.js desktop/modules/user/test_user.js
+git commit -m "feat(user): helpers for the user page
+
+Parsing userctl's output and locale -a, and the git name preview, kept
+out of QML so node can test them.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Module.tiled and the drawer row
+
+**Files:**
+- Modify: `desktop/Module.qml` (after `property bool active`)
+- Modify: `desktop/Drawer.qml` (imports; grid view `NotificationList` and tile `Repeater`)
+- Create: `desktop/modules/user/UserRow.qml`
+
+- [ ] **Step 1: Check the glyph exists in the icon font**
+
+Run: `fc-list ':charset=f007' family | grep -i inconsolata`
+Expected: at least one `Inconsolata Nerd Font` line (nf-fa-user). If none, pick another user glyph that does match and use it in Steps 3 and Task 4.
+
+- [ ] **Step 2: Add `tiled` to Module.qml**
+
+In `desktop/Module.qml`, after the `active` property:
+
+```qml
+ // Whether the drawer's grid shows a tile. False for a module reached
+ // some other way: the user page opens from the drawer's header row.
+ property bool tiled: true
+```
+
+- [ ] **Step 3: Write UserRow.qml**
+
+`desktop/modules/user/UserRow.qml`:
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Widgets
+import QtQuick
+import "../.."
+import "User.js" as U
+
+// The drawer's greeting: avatar, login name, real name. It re-reads each time
+// it becomes visible, which is every drawer open and every return from a
+// page, so a save on the user page shows here on the way back.
+Rectangle {
+ id: row
+
+ signal clicked
+ property var info: ({})
+
+ implicitHeight: 64
+ radius: 12
+ color: area.containsMouse ? Qt.alpha(Theme.accent, 0.22) : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.08)
+
+ function read() {
+ reader.running = false;
+ reader.running = true;
+ }
+ onVisibleChanged: if (visible) read()
+ Component.onCompleted: read()
+
+ Process {
+ id: reader
+ command: [Qt.resolvedUrl("userctl").toString().replace(/^file:\/\//, ""), "get"]
+ // Cleared first: the icon path never changes, so only a source going
+ // through "" makes an uncached Image load the new picture.
+ stdout: StdioCollector {
+ onStreamFinished: { row.info = ({}); row.info = U.parse(text); }
+ }
+ }
+
+ ClippingRectangle {
+ id: face
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ width: 44
+ height: 44
+ radius: 22
+ color: Theme.surfaceAlt
+
+ Image {
+ id: pic
+ anchors.fill: parent
+ source: row.info.avatar ? "file://" + row.info.avatar : ""
+ fillMode: Image.PreserveAspectCrop
+ sourceSize: Qt.size(88, 88)
+ cache: false
+ }
+ Text {
+ anchors.centerIn: parent
+ visible: pic.status !== Image.Ready
+ text: "\uf007"
+ font { family: Theme.iconFamily; pixelSize: 20 }
+ color: Theme.subtext
+ }
+ }
+
+ Column {
+ anchors { left: face.right; leftMargin: 12; right: parent.right; rightMargin: 12
+ verticalCenter: parent.verticalCenter }
+ spacing: 2
+ Text {
+ text: "Hello " + Quickshell.env("USER")
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2; bold: true }
+ color: Theme.text
+ }
+ Text {
+ width: parent.width
+ visible: text !== ""
+ text: row.info.name ?? ""
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+ }
+
+ MouseArea {
+ id: area
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: row.clicked()
+ }
+}
+```
+
+- [ ] **Step 4: Place the row and filter the tiles in Drawer.qml**
+
+Add `import "modules/user"` after the existing imports at the top of `desktop/Drawer.qml`.
+
+In the grid view `Item` (the one with `visible: root.page === "" && !root.history`), insert before `NotificationList`:
+
+```qml
+ UserRow {
+ id: user
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ onClicked: root.show("user")
+ }
+```
+
+and change the `NotificationList` top anchor so it starts under the row:
+
+```qml
+ NotificationList {
+ id: notifications
+ anchors { top: user.bottom; topMargin: 12; left: parent.left; right: parent.right }
+```
+
+(keep its existing `anchors.bottom`, `anchors.bottomMargin` and handlers). In the tile `Repeater`, change `model: root.modules` to:
+
+```qml
+ model: root.modules.filter(m => m.tiled)
+```
+
+- [ ] **Step 5: Check the shell reloaded**
+
+Run: `sleep 2; timeout 5 qs log -p desktop 2>&1 | grep -E "ERROR|Configuration Loaded" | tail -3`
+Expected: last line `Configuration Loaded`, no `ERROR` after it. `show("user")` resolves to the grid until Task 4 adds the module; that is expected.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/Module.qml desktop/Drawer.qml desktop/modules/user/UserRow.qml
+git commit -m "feat(desktop): a greeting row above the notifications
+
+Avatar, Hello <login> and the real name, re-read whenever the grid view
+shows again so an edit is visible on the way back from the page. Module
+gains tiled, so a module can own a page without a tile.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: The user page and module
+
+**Files:**
+- Create: `desktop/modules/user/UserPage.qml`
+- Create: `desktop/modules/user/UserModule.qml`
+- Modify: `desktop/shell.qml` (imports, `modules` list)
+
+- [ ] **Step 1: Write UserPage.qml**
+
+`desktop/modules/user/UserPage.qml`:
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Dialogs
+import "../.."
+import "User.js" as U
+
+// The form. `saved` is what userctl read, `draft` the edits; Save sends each
+// changed key to `userctl set` in turn. A failed key keeps its edit and shows
+// the error under the field, so the next Save retries it.
+Column {
+ id: form
+
+ spacing: 14
+
+ // A new detail is one entry here and one case in userctl. Avatar is not
+ // listed: it is the picture at the top, not a row.
+ readonly property var fields: [
+ { key: "name", label: "Name", kind: "text" },
+ { key: "email", label: "Email", kind: "text" },
+ { key: "location", label: "Location", kind: "text" },
+ { key: "language", label: "Language", kind: "locale" },
+ { key: "signingkey", label: "Signing key", kind: "key" },
+ ]
+
+ readonly property string ctl: Qt.resolvedUrl("userctl").toString().replace(/^file:\/\//, "")
+ property var saved: ({})
+ property var draft: ({})
+ property var errors: ({})
+ property var keys: []
+ property var locales: []
+ property bool busy: false
+ readonly property var changed: Object.keys(draft).filter(k => draft[k] !== saved[k])
+
+ function set(key, value) {
+ const d = Object.assign({}, form.draft);
+ d[key] = value;
+ form.draft = d;
+ }
+
+ // ComboBox entries per kind. The current language is kept even if
+ // locale -a no longer lists it, so the combo never shows blank.
+ function options(kind) {
+ if (kind === "locale") {
+ const l = form.locales.slice();
+ if (form.saved.language && !l.includes(form.saved.language)) l.unshift(form.saved.language);
+ return l.map(v => ({ text: v, value: v }));
+ }
+ return [{ text: "None", value: "" }]
+ .concat(form.keys.map(k => ({ text: k.uid + " · " + k.fpr.slice(-16), value: k.fpr })));
+ }
+
+ // One Process per call, so reads and writes never share one (a running
+ // Process ignores a second start, AGENTS.md). cb gets stdout.
+ function run(cmd, cb) {
+ op.createObject(form, { command: cmd, cb: cb }).running = true;
+ }
+ Component {
+ id: op
+ Process {
+ id: proc
+ property var cb: null
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try { proc.cb(text); }
+ finally { proc.destroy(); }
+ }
+ }
+ }
+ }
+
+ // keep: draft values to carry over the reload (the keys that failed).
+ function load(keep) {
+ form.run([form.ctl, "get"], out => {
+ const s = U.parse(out);
+ form.saved = ({});
+ form.saved = s;
+ form.draft = Object.assign({}, s, keep);
+ });
+ }
+
+ function save() {
+ if (form.busy || form.changed.length === 0) return;
+ form.busy = true;
+ form.errors = ({});
+ form.step(form.changed.slice(), {});
+ }
+ function step(queue, failed) {
+ if (queue.length === 0) {
+ form.busy = false;
+ form.load(failed);
+ return;
+ }
+ const k = queue.shift();
+ form.run([form.ctl, "set", k, form.draft[k]], out => {
+ if (out.trim() !== "") {
+ const e = Object.assign({}, form.errors);
+ e[k] = out.trim();
+ form.errors = e;
+ failed[k] = form.draft[k];
+ }
+ form.step(queue, failed);
+ });
+ }
+
+ Component.onCompleted: {
+ form.load({});
+ form.run([form.ctl, "keys"], out => form.keys = U.keys(out));
+ form.run(["locale", "-a"], out => form.locales = U.locales(out));
+ }
+
+ // A FileDialog hands back a file:// URL; AccountsService wants a path.
+ FileDialog {
+ id: picker
+ title: "Choose a picture"
+ fileMode: FileDialog.OpenFile
+ nameFilters: ["Images (*.png *.jpg *.jpeg)"]
+ onAccepted: form.set("avatar", decodeURIComponent(selectedFile.toString().replace(/^file:\/\//, "")))
+ }
+
+ ClippingRectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 96
+ height: 96
+ radius: 48
+ color: Theme.surfaceAlt
+
+ Image {
+ id: pic
+ anchors.fill: parent
+ source: form.draft.avatar ? "file://" + form.draft.avatar : ""
+ fillMode: Image.PreserveAspectCrop
+ sourceSize: Qt.size(192, 192)
+ cache: false
+ }
+ Text {
+ anchors.centerIn: parent
+ visible: pic.status !== Image.Ready
+ text: "\uf007"
+ font { family: Theme.iconFamily; pixelSize: 40 }
+ color: Theme.subtext
+ }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: picker.open()
+ }
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: !!form.errors.avatar
+ text: form.errors.avatar ?? ""
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ Repeater {
+ model: form.fields
+
+ delegate: Column {
+ id: field
+ required property var modelData
+ width: form.width
+ spacing: 4
+
+ Text {
+ text: field.modelData.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+
+ Rectangle {
+ visible: field.modelData.kind === "text"
+ width: parent.width
+ height: 34
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ text: form.draft[field.modelData.key] ?? ""
+ onTextEdited: form.set(field.modelData.key, text)
+ }
+ }
+
+ ComboBox {
+ visible: field.modelData.kind !== "text"
+ width: parent.width
+ textRole: "text"
+ valueRole: "value"
+ model: field.modelData.kind === "text" ? [] : form.options(field.modelData.kind)
+ // count is read so the index is recomputed once the list
+ // arrives, which is after the draft.
+ currentIndex: count >= 0 ? indexOfValue(form.draft[field.modelData.key] ?? "") : -1
+ onActivated: form.set(field.modelData.key, currentValue)
+ }
+
+ Text {
+ width: parent.width
+ visible: !!form.errors[field.modelData.key]
+ text: form.errors[field.modelData.key] ?? ""
+ wrapMode: Text.Wrap
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+ }
+
+ Text {
+ width: parent.width
+ text: "git: " + U.gitName(form.draft.name ?? "") + " <" + (form.draft.email ?? "") + ">"
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+
+ Button {
+ anchors.right: parent.right
+ text: form.busy ? "Saving…" : "Save"
+ enabled: !form.busy && form.changed.length > 0
+ onClicked: form.save()
+ }
+}
+```
+
+- [ ] **Step 2: Write UserModule.qml**
+
+`desktop/modules/user/UserModule.qml`:
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// The user's details. No tile: the drawer's greeting row opens the page.
+Module {
+ name: "user"
+ label: "User"
+ // nf-fa-user, present in Inconsolata Nerd Font.
+ icon: "\uf007"
+ tiled: false
+
+ page: Component {
+ Page {
+ title: "User"
+ UserPage { width: parent.width }
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Register the module in shell.qml**
+
+In `desktop/shell.qml`, add `import "modules/user"` after `import "modules/updates"`, and add `UserModule {},` as the last entry of the `modules: [...]` list in `Drawer`.
+
+- [ ] **Step 4: Check the shell reloaded**
+
+Run: `sleep 2; timeout 5 qs log -p desktop 2>&1 | grep -E "ERROR|Configuration Loaded" | tail -3`
+Expected: last line `Configuration Loaded`, no `ERROR` after it. If `UserModule` is reported unknown, the directory index was not rescanned (AGENTS.md, Reloading): ask the user to run `./reload.sh desktop`.
+
+- [ ] **Step 5: Ask the user to check it by hand**
+
+Ask the user to: open the drawer and see the row (avatar, `Hello <login>`, real name); click it; change the location to something harmless and Save; go back and reopen the page to see it persisted; pick a new avatar and check the row shows it on return; check the git preview line; set the location back. Anything visual is the user's call (AGENTS.md).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/modules/user/UserPage.qml desktop/modules/user/UserModule.qml desktop/shell.qml
+git commit -m "feat(user): a drawer page for the user's details
+
+Name, email, avatar, language, location and the git signing key, each
+saved through userctl to every store that holds it. Only changed fields
+are sent; a field whose write failed keeps its edit and its error, so
+the next Save retries it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Documentation
+
+**Files:**
+- Create: `desktop/modules/user/README.md`
+- Modify: `desktop/README.md` (module list near `modules/perf/`)
+- Modify: `AGENTS.md` (the `desktop/` line in "What this is"; a note in "Per-component notes")
+
+- [ ] **Step 1: Write the module README**
+
+`desktop/modules/user/README.md`:
+
+```markdown
+# user
+
+The drawer's greeting row (avatar, `Hello <login>`, real name) and the page
+it opens, which edits the user's details where other programs read them.
+
+| Field | Written to |
+| --- | --- |
+| name | AccountsService `RealName` (which also rewrites the passwd GECOS that LibreOffice prefills from), `~/.config/emaildefaults` `FullName`, git `user.name` with the surname as an initial |
+| email | AccountsService `Email`, emaildefaults `EmailAddress`, git `user.email` |
+| avatar | AccountsService `IconFile`; the daemon copies the file in and SDDM shows it |
+| language, location | AccountsService |
+| signing key | git `user.signingkey`; None unsets it |
+
+`userctl` does all reading and writing; the page only calls it. Polkit lets a
+user edit their own AccountsService entry without a prompt. A new detail is
+one entry in `UserPage.qml`'s `fields` and one `case` in `userctl`.
+
+Checks: `./test-userctl.sh` (stubs every store) and
+`node desktop/modules/user/test_user.js`.
+
+Not here: the password (`passwd` in a terminal; changing it also leaves the
+login keyring on the old one), `~/.face.icon` (SDDM reads the
+AccountsService icon), social links (nothing reads them). Values are read
+when the page opens, not watched.
+```
+
+- [ ] **Step 2: Update the module lists**
+
+In `desktop/README.md`, after the `modules/perf/` line add:
+
+```
+ modules/user/ the greeting row and the user's details page
+```
+
+In `AGENTS.md`, change the `desktop/` line of the tree to:
+
+```
+ desktop/ the drawer (SUPER alone): user details, sound, mail, VMs, status,
+ performance, clipboard, updates, appearance
+```
+
+- [ ] **Step 3: Add the busctl note to AGENTS.md**
+
+At the end of "Per-component notes" in `AGENTS.md`:
+
+```markdown
+- **`busctl get-property` escapes non-ASCII as octal.** A name with an
+ accented letter reads `s "Do\303\250r"`-style, which no shell builtin
+ decodes. `busctl --json=short` returns real UTF-8; `userctl` pipes it
+ through `jq -r .data`.
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/modules/user/README.md desktop/README.md AGENTS.md
+git commit -m "docs(user): the module's README and the busctl escaping note
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```