aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 14:19:48 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 14:19:48 +0200
commitbac97cd59f2f9149254b2a22227214e8efbd7e07 (patch)
tree0dc24a58ae551b59843159b83eafabdb2bd74411
parent81973e758a5bc543d2d38a16b0251bb0786b840d (diff)
downloadsddm-theme-udt-bac97cd59f2f9149254b2a22227214e8efbd7e07.tar.gz
sddm-theme-udt-bac97cd59f2f9149254b2a22227214e8efbd7e07.zip
docs: plan the left panel greeter implementation
Six tasks across sddm-theme-udt, unified-desktop-theme and quickshell: resolve the power icons in udt-accent, add the panel config keys, draw the power bar from them, add the staged credential slot, build the panel and swap Main, and track the icon-theme trigger follow-up.
-rw-r--r--docs/superpowers/plans/2026-09-17-sddm-theme-udt-panel.md1005
1 files changed, 1005 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-17-sddm-theme-udt-panel.md b/docs/superpowers/plans/2026-09-17-sddm-theme-udt-panel.md
new file mode 100644
index 0000000..ea0d778
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-17-sddm-theme-udt-panel.md
@@ -0,0 +1,1005 @@
+# Left Panel Greeter 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:** Replace the centered login card with a full-height left panel that shows a clock, greeting, avatar, a staged username-then-password field, and power/session controls, with power icons resolved from the desktop's selected icon theme.
+
+**Architecture:** `udt-accent` keeps writing the palette INI, and additionally resolves the four FreeDesktop power icons from the gsettings icon theme into absolute paths. The QML theme becomes a `Panel` with a `Credentials` child that animates one field slot between a username stage and a password stage. The greeter still reads only INI data; no new file reads happen inside QML.
+
+**Tech Stack:** QML (Qt 6.11, SDDM theme API 2.0), Python 3 stdlib (`unified-desktop-theme/bin/udt-accent`).
+
+**Spec:** `docs/superpowers/specs/2026-09-17-sddm-theme-udt-panel-design.md`
+
+## Global Constraints
+
+- Target is SDDM 0.21.0, Qt 6.11.2, theme API 2.0.
+- The greeter runs as the unprivileged `sddm` user. QML must not read files; all live input arrives through the INI `config` object.
+- Python code is stdlib only. No new dependency.
+- Every QML and Python file carries the SPDX line `// SPDX-License-Identifier: GPL-2.0-only` (or `#` for Python) and `Copyright (C) 2026 Danilo M. <danix@danix.xyz>`.
+- Only `Noto Sans` is used in the greeter; no bundled fonts.
+- The panel has square corners (no radius).
+- Power icons are drawn as flat silhouettes by `MultiEffect`; the icon theme supplies the shape, the UDT palette supplies the colour.
+
+---
+
+### Task 1: Resolve the power icons in udt-accent
+
+**Repository:** `~/Programming/GIT/unified-desktop-theme`
+
+**Files:**
+- Modify: `bin/udt-accent` (constants near line 50, `sddm_conf`/`write_sddm` around lines 311-342, `selftest` around line 415)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `icon_theme() -> str`, `find_icon(theme: str, names: list[str], dirs: list[Path] | None = None) -> str`, `power_icons() -> dict[str, str]`, and `sddm_conf(palette, name, image, icons: dict | None = None) -> str`. The emitted INI gains the keys `powerIcon`, `rebootIcon`, `suspendIcon`, `hibernateIcon`.
+
+- [ ] **Step 1: Write the failing selftest additions**
+
+In `bin/udt-accent`, insert this block immediately before the `print("selftest OK")` line at the end of `selftest()`:
+
+```python
+ # Power icons: an unknown or empty theme resolves to empty, so the greeter
+ # falls back to its bundled SVGs; a real theme prefers the symbolic variant
+ # and a name list is honoured in order.
+ assert find_icon("", ["system-shutdown"]) == ""
+ assert find_icon("no-such-theme-udt", ["system-shutdown"]) == ""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = Path(tmpdir) / "icons"
+ base = root / "probe"
+ (base / "actions" / "16").mkdir(parents=True)
+ (base / "actions" / "symbolic").mkdir(parents=True)
+ (base / "actions" / "16" / "system-shutdown.svg").write_text("<svg/>")
+ (base / "actions" / "symbolic" / "system-shutdown-symbolic.svg").write_text("<svg/>")
+ got = find_icon("probe", ["system-shutdown"], dirs=[root])
+ assert got.endswith("actions/symbolic/system-shutdown-symbolic.svg"), got
+ (base / "actions" / "symbolic" / "system-shutdown-symbolic.svg").unlink()
+ got = find_icon("probe", ["system-shutdown"], dirs=[root])
+ assert got.endswith("actions/16/system-shutdown.svg"), got
+ assert find_icon("probe", ["system-hibernate", "system-suspend-hibernate"],
+ dirs=[root]) == ""
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ probe_image = Path(tmpdir) / "wall.png"
+ probe_image.write_bytes(b"")
+ palette = {k: "#1e2030" for k in SDDM_KEYS}
+ icons = {"powerIcon": "/tmp/power.svg", "rebootIcon": "",
+ "suspendIcon": "", "hibernateIcon": ""}
+ text = sddm_conf(palette, FALLBACK, str(probe_image), icons)
+ for key in ("powerIcon", "rebootIcon", "suspendIcon", "hibernateIcon"):
+ assert f"\n{key}=" in "\n" + text, f"sddm conf missing {key}"
+ assert "\npowerIcon=/tmp/power.svg" in "\n" + text
+
+ got = power_icons()
+ assert set(got) == {"powerIcon", "rebootIcon", "suspendIcon", "hibernateIcon"}
+```
+
+- [ ] **Step 2: Run the selftest to verify it fails**
+
+Run: `python3 bin/udt-accent --selftest`
+Expected: FAIL with `NameError: name 'find_icon' is not defined`.
+
+- [ ] **Step 3: Add the constants**
+
+In `bin/udt-accent`, directly below the `SDDM_KEYS` list (around line 60), add:
+
+```python
+# The SDDM panel's power icons. The greeter runs as the sddm user and cannot
+# read the login user's dconf, so udt-accent resolves the selected icon theme
+# to absolute paths and bakes them into the INI. An unresolved key stays empty
+# and the theme draws its bundled SVG instead.
+ICON_SCHEMA = "org.gnome.desktop.interface"
+ICON_KEY = "icon-theme"
+ICON_DIRS = [Path.home() / ".local" / "share" / "icons",
+ Path("/usr/share/icons")]
+POWER_ICONS = {
+ "powerIcon": ["system-shutdown"],
+ "rebootIcon": ["system-reboot"],
+ "suspendIcon": ["system-suspend"],
+ "hibernateIcon": ["system-hibernate", "system-suspend-hibernate"],
+}
+```
+
+- [ ] **Step 4: Add the resolver functions**
+
+In `bin/udt-accent`, directly above `def sddm_conf(...)`, add:
+
+```python
+def icon_theme():
+ """The icon theme the appearance drawer selected, or "" if unreadable.
+
+ gsettings runs as the login user, so it reads the user's dconf. Empty on
+ any failure: the theme then keeps its bundled power icons.
+ """
+ try:
+ got = subprocess.run(["gsettings", "get", ICON_SCHEMA, ICON_KEY],
+ capture_output=True, text=True, check=True)
+ except (OSError, subprocess.CalledProcessError):
+ return ""
+ return got.stdout.strip().strip("'")
+
+
+def find_icon(theme, names, dirs=None):
+ """Absolute path to the first of `names` found in `theme`, else "".
+
+ A theme may ship an icon flat or as a symbolic variant at several sizes.
+ Prefer the symbolic variant, then the largest size directory, so the panel
+ gets a tintable silhouette. `dirs` is injectable for the selftest.
+ """
+ if not theme:
+ return ""
+ dirs = ICON_DIRS if dirs is None else dirs
+ wanted = set(names) | {f"{name}-symbolic" for name in names}
+ matches = []
+ for base in dirs:
+ root = base / theme
+ if not root.is_dir():
+ continue
+ matches = [p for p in root.rglob("*.svg") if p.stem in wanted]
+ if matches:
+ break
+
+ def rank(path):
+ symbolic = path.stem.endswith("-symbolic")
+ sizes = [int(part) for part in path.parts if part.isdigit()]
+ return (symbolic, max(sizes, default=0))
+
+ return str(max(matches, key=rank)) if matches else ""
+
+
+def power_icons():
+ """The four power icon paths, resolved from the selected icon theme."""
+ theme = icon_theme()
+ return {key: find_icon(theme, names) for key, names in POWER_ICONS.items()}
+```
+
+- [ ] **Step 5: Emit the icon keys**
+
+Change the `sddm_conf` signature and body:
+
+```python
+def sddm_conf(palette, name, image, icons=None):
+```
+
+Inside the function, after the `lines` list is created and before the final `return`, add:
+
+```python
+ icons = icons or {}
+```
+
+and after the existing palette-keys line add:
+
+```python
+ lines += [f"{key}={icons.get(key, '')}" for key in POWER_ICONS]
+```
+
+Then update the `write_sddm` call so it passes the resolved icons:
+
+```python
+ write_atomic(SDDM_OUTPUT, sddm_conf(palette, name, image, power_icons()),
+ mode=0o644, dir_mode=0o755)
+```
+
+- [ ] **Step 6: Run the selftest to verify it passes**
+
+Run: `python3 bin/udt-accent --selftest`
+Expected: `selftest OK`
+
+- [ ] **Step 7: Run it against the live wallpaper and inspect the output**
+
+Run: `./bin/udt-accent "$(python3 -c 'import os;print(os.path.realpath(os.path.expanduser("~/.cache/udt/wpaper")))')" && cat ~/.local/share/udt/sddm-theme.conf`
+Expected: the four `powerIcon`/`rebootIcon`/`suspendIcon`/`hibernateIcon` keys are present; the ones the selected theme provides are absolute paths, the rest are empty.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add bin/udt-accent
+git commit -m "feat: resolve SDDM power icons from the selected icon theme
+
+The greeter runs as the sddm user and cannot read the login user's
+dconf, so udt-accent resolves the four FreeDesktop power icon names in
+the icon theme gsettings reports and bakes absolute paths into the
+generated sddm-theme.conf. An unresolved key stays empty and the
+theme falls back to its bundled SVGs."
+```
+
+---
+
+### Task 2: Add the panel config keys
+
+**Repository:** `~/Programming/GIT/sddm-theme-udt`
+
+**Files:**
+- Modify: `theme/theme.conf`
+
+**Interfaces:**
+- Produces: the config keys `panelWidth`, `timeFormat`, `dateFormat`, `greeting`, and the four icon keys, read by `Panel.qml` and `PowerBar.qml` in Tasks 3 and 5.
+
+- [ ] **Step 1: Append the keys**
+
+Append to `theme/theme.conf`:
+
+```ini
+
+# Left panel width, as a fraction of the greeter window. 0.30 is roughly a
+# third of the screen.
+panelWidth=0.30
+
+# Clock and greeting. Qt date/time format strings. The date follows the
+# greeter's locale, which may be C.
+timeFormat=HH:mm
+dateFormat=dddd, d MMMM
+greeting=hello there
+
+# Power button icons. udt-accent writes absolute paths here from the icon theme
+# the appearance drawer selected; empty falls back to the bundled
+# theme/icons/*.svg. Regenerated on every wallpaper change, so set these only
+# through the appearance selection.
+powerIcon=
+rebootIcon=
+suspendIcon=
+hibernateIcon=
+```
+
+- [ ] **Step 2: Verify the file still parses as INI**
+
+Run: `python3 -c "import configparser;c=configparser.ConfigParser();c.read('theme/theme.conf');print(dict(c['General'])['panelWidth'], dict(c['General'])['greeting'])"`
+Expected: `0.30 hello there`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add theme/theme.conf
+git commit -m "feat: add panel, clock and power-icon config keys"
+```
+
+---
+
+### Task 3: Power bar reads the resolved icons
+
+**Repository:** `~/Programming/GIT/sddm-theme-udt`
+
+**Files:**
+- Modify: `theme/components/PowerBar.qml` (replace the whole file)
+
+**Interfaces:**
+- Consumes: `config.stringValue("powerIcon")`, `config.stringValue("rebootIcon")`, `config.stringValue("suspendIcon")`, `config.stringValue("hibernateIcon")`.
+- Produces: unchanged `Row` of four action buttons; consumed by `Panel.qml` in Task 5.
+
+- [ ] **Step 1: Replace the file**
+
+Write `theme/components/PowerBar.qml`:
+
+```qml
+// SPDX-License-Identifier: GPL-2.0-only
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// Shutdown, restart, sleep and hibernate. Each icon is the path udt-accent
+// resolved from the selected icon theme, falling back to the bundled line art
+// when the key is empty or the file fails to load. Each button is disabled when
+// SDDM reports the action is unavailable.
+
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Effects
+
+Row {
+ id: bar
+ spacing: 12
+
+ readonly property var actions: [
+ { icon: config.stringValue("powerIcon"), fallback: "../icons/power.svg",
+ label: "Shut down", enabled: sddm.canPowerOff, run: function() { sddm.powerOff() } },
+ { icon: config.stringValue("rebootIcon"), fallback: "../icons/reboot.svg",
+ label: "Restart", enabled: sddm.canReboot, run: function() { sddm.reboot() } },
+ { icon: config.stringValue("suspendIcon"), fallback: "../icons/suspend.svg",
+ label: "Sleep", enabled: sddm.canSuspend, run: function() { sddm.suspend() } },
+ { icon: config.stringValue("hibernateIcon"), fallback: "../icons/hibernate.svg",
+ label: "Hibernate", enabled: sddm.canHibernate, run: function() { sddm.hibernate() } }
+ ]
+
+ Repeater {
+ model: bar.actions
+ delegate: Item {
+ width: 56
+ height: 56
+ opacity: modelData.enabled ? 1 : 0.35
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 10
+ color: config.stringValue("surface0")
+ opacity: mouse.containsMouse && modelData.enabled ? 1 : 0
+ }
+
+ Image {
+ id: icon
+ anchors.centerIn: parent
+ width: 34
+ height: 34
+ sourceSize.width: width
+ sourceSize.height: height
+ source: modelData.icon !== "" ? modelData.icon : modelData.fallback
+ visible: false
+ onStatusChanged: {
+ if (status === Image.Error && source !== modelData.fallback)
+ source = modelData.fallback
+ }
+ }
+
+ MultiEffect {
+ anchors.fill: icon
+ source: icon
+ colorization: 1.0
+ colorizationColor: mouse.containsMouse && modelData.enabled
+ ? config.stringValue("accent")
+ : config.stringValue("subtext0")
+ }
+
+ MouseArea {
+ id: mouse
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: modelData.enabled
+ onClicked: modelData.run()
+ }
+
+ ToolTip.visible: mouse.containsMouse && modelData.enabled
+ ToolTip.text: modelData.label
+ ToolTip.delay: 400
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Syntax check**
+
+Run: `/usr/lib64/qt6/bin/qmllint theme/components/PowerBar.qml; echo "exit=$?"`
+Expected: exit 0. Only `Unqualified access` warnings for `config`/`sddm` are expected, which are SDDM context properties.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add theme/components/PowerBar.qml
+git commit -m "feat: draw the power bar from the resolved icon theme paths"
+```
+
+---
+
+### Task 4: The staged credential slot
+
+**Repository:** `~/Programming/GIT/sddm-theme-udt`
+
+**Files:**
+- Create: `theme/components/Credentials.qml`
+
+**Interfaces:**
+- Produces: an `Item` with `property string stage` (`"user"`/`"pass"`), read-only `username`, `password`, `passwordStage`; functions `confirm()`, `clearPassword()`, `focusStage()`; signals `submitted()` and `wake()`. Consumed by `Panel.qml` in Task 5.
+
+- [ ] **Step 1: Write the component**
+
+Write `theme/components/Credentials.qml`:
+
+```qml
+// SPDX-License-Identifier: GPL-2.0-only
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// One credential slot, two stages. The username shows first; confirming it
+// slides the username out and the password in. Confirming the password emits
+// submitted(). A back chevron, or Esc, returns to the username.
+
+import QtQuick
+import QtQuick.Controls.Basic
+
+Item {
+ id: creds
+
+ // Emitted when the password stage is confirmed, and on input so the panel
+ // can restart its idle timer.
+ signal submitted()
+ signal wake()
+
+ property string stage: "user"
+ readonly property string username: usernameField.text
+ readonly property string password: passwordField.text
+ readonly property bool passwordStage: stage === "pass"
+
+ implicitHeight: 44
+
+ function confirm() {
+ if (!creds.passwordStage) {
+ if (usernameField.text.length > 0)
+ creds.stage = "pass"
+ } else {
+ creds.submitted()
+ }
+ }
+
+ function clearPassword() {
+ passwordField.text = ""
+ }
+
+ function focusStage() {
+ if (creds.passwordStage)
+ passwordField.forceActiveFocus()
+ else
+ usernameField.forceActiveFocus()
+ }
+
+ onStageChanged: focusStage()
+ Component.onCompleted: focusStage()
+
+ // Back chevron, password stage only. The width animates so the slot grows
+ // and shrinks with it.
+ Item {
+ id: back
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: creds.passwordStage ? 44 : 0
+ height: 44
+ clip: true
+ visible: width > 0
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 6
+ color: config.stringValue("surface0")
+ opacity: backMouse.containsMouse ? 1 : 0
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: "\u2190"
+ color: config.stringValue("subtext0")
+ font.family: "Noto Sans"
+ font.pixelSize: 22
+ }
+
+ MouseArea {
+ id: backMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: {
+ creds.clearPassword()
+ creds.stage = "user"
+ }
+ }
+ }
+
+ Button {
+ id: confirmButton
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: 44
+ height: 44
+ padding: 0
+ background: Rectangle {
+ radius: 6
+ color: config.stringValue("accent")
+ }
+ contentItem: Text {
+ text: "\u2192"
+ color: config.stringValue("base")
+ font.family: "Noto Sans"
+ font.pixelSize: 22
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+ onClicked: creds.confirm()
+ }
+
+ // The sliding slot. Both fields live here, clipped, and slide across.
+ Item {
+ id: slot
+ anchors.left: back.right
+ anchors.leftMargin: 8
+ anchors.right: confirmButton.left
+ anchors.rightMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ height: 44
+ clip: true
+
+ TextField {
+ id: usernameField
+ width: slot.width
+ height: slot.height
+ text: userModel.lastUser !== undefined ? userModel.lastUser : ""
+ placeholderText: "Username"
+ font.family: "Noto Sans"
+ color: config.stringValue("text")
+ palette.base: config.stringValue("surface0")
+ palette.text: config.stringValue("text")
+ palette.highlight: config.stringValue("accent")
+ palette.highlightedText: config.stringValue("base")
+ background: Rectangle {
+ radius: 6
+ color: config.stringValue("surface0")
+ }
+ x: creds.passwordStage ? -slot.width : 0
+ opacity: creds.passwordStage ? 0 : 1
+ Behavior on x { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
+ Behavior on opacity { NumberAnimation { duration: 220 } }
+ onAccepted: creds.confirm()
+ onTextChanged: creds.wake()
+ Keys.onPressed: event => {
+ creds.wake()
+ event.accepted = false
+ }
+ }
+
+ TextField {
+ id: passwordField
+ width: slot.width
+ height: slot.height
+ echoMode: TextInput.Password
+ placeholderText: "Password"
+ font.family: "Noto Sans"
+ color: config.stringValue("text")
+ palette.base: config.stringValue("surface0")
+ palette.text: config.stringValue("text")
+ palette.highlight: config.stringValue("accent")
+ palette.highlightedText: config.stringValue("base")
+ background: Rectangle {
+ radius: 6
+ color: config.stringValue("surface0")
+ }
+ x: creds.passwordStage ? 0 : slot.width
+ opacity: creds.passwordStage ? 1 : 0
+ Behavior on x { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
+ Behavior on opacity { NumberAnimation { duration: 220 } }
+ onAccepted: creds.confirm()
+ Keys.onPressed: event => {
+ creds.wake()
+ if (event.key === Qt.Key_Escape) {
+ creds.clearPassword()
+ creds.stage = "user"
+ event.accepted = true
+ } else {
+ event.accepted = false
+ }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Syntax check**
+
+Run: `/usr/lib64/qt6/bin/qmllint theme/components/Credentials.qml; echo "exit=$?"`
+Expected: exit 0. Only `Unqualified access` warnings for `config`/`userModel` are expected.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add theme/components/Credentials.qml
+git commit -m "feat: add the staged username and password credential slot"
+```
+
+---
+
+### Task 5: The left panel and the Main swap
+
+**Repository:** `~/Programming/GIT/sddm-theme-udt`
+
+**Files:**
+- Create: `theme/components/Panel.qml`
+- Modify: `theme/Main.qml` (lines 86-92 `wake()`, lines 106-116 the card block)
+- Delete: `theme/components/LoginCard.qml`
+- Modify: `README.md` (the description and the config-key list)
+
+**Interfaces:**
+- Consumes: `Backdrop`, `PowerBar` (Task 3), `Credentials` (Task 4), and the config keys from Task 2.
+- Produces: a `Panel` root with `property Item backdropSource`, `property real shakeOffset`, `signal wake()`, and `function restoreFocus()`, consumed by `Main.qml`.
+
+- [ ] **Step 1: Create the panel**
+
+Write `theme/components/Panel.qml`:
+
+```qml
+// SPDX-License-Identifier: GPL-2.0-only
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// The left login panel: clock and date, greeting, avatar, the staged credential
+// slot, the power row and the session selector. Main shows it on the window
+// whose screen matches uiScreen, else on the primary one.
+
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Effects
+
+Item {
+ id: panel
+
+ signal wake()
+
+ // The wallpaper image to frost behind the panel, and the failed-login shake
+ // applied to the content so the frost stays put.
+ property Item backdropSource: null
+ property real shakeOffset: 0
+
+ readonly property string accentColor: config.stringValue("accent")
+ readonly property string textColor: config.stringValue("text")
+ readonly property string subtextColor: config.stringValue("subtext0")
+ readonly property string greeting: config.stringValue("greeting") !== ""
+ ? config.stringValue("greeting")
+ : "hello there"
+ readonly property string timeFormat: config.stringValue("timeFormat") !== ""
+ ? config.stringValue("timeFormat")
+ : "HH:mm"
+ readonly property string dateFormat: config.stringValue("dateFormat") !== ""
+ ? config.stringValue("dateFormat")
+ : "dddd, d MMMM"
+
+ // Live clock, ticked once a second.
+ property date now: new Date()
+ Timer {
+ interval: 1000
+ running: true
+ repeat: true
+ onTriggered: panel.now = new Date()
+ }
+
+ // Frosted ground, square corners.
+ Backdrop {
+ anchors.fill: parent
+ radius: 0
+ sourceItem: panel.backdropSource
+ tint: config.stringValue("mantle")
+ blurEnabled: config.boolValue("blur")
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ border.width: 2
+ border.color: panel.accentColor
+ }
+
+ // Non-visual: userModel faces, looked up by the entered username.
+ Repeater {
+ id: faces
+ model: userModel
+ delegate: Item {
+ readonly property string faceName: name
+ readonly property string faceIcon: icon
+ visible: false
+ }
+ }
+
+ property string avatarPath: ""
+ readonly property string initials: creds.username.length > 0
+ ? creds.username.charAt(0).toUpperCase()
+ : "?"
+
+ function refreshAvatar() {
+ for (var i = 0; i < faces.count; ++i) {
+ var it = faces.itemAt(i)
+ if (it && it.faceName === creds.username) {
+ avatarPath = it.faceIcon || ""
+ return
+ }
+ }
+ avatarPath = ""
+ }
+
+ // Everything below shifts on a failed login.
+ Item {
+ id: content
+ anchors.fill: parent
+ transform: Translate { x: panel.shakeOffset }
+
+ Column {
+ id: top
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: 32
+ spacing: 14
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ text: Qt.formatDateTime(panel.now, panel.timeFormat)
+ color: panel.textColor
+ font.family: "Noto Sans"
+ font.pixelSize: 56
+ font.weight: Font.Light
+ }
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ text: Qt.formatDateTime(panel.now, panel.dateFormat)
+ color: panel.subtextColor
+ font.family: "Noto Sans"
+ font.pixelSize: 18
+ }
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ text: panel.greeting
+ color: panel.textColor
+ font.family: "Noto Sans"
+ font.pixelSize: 18
+ topPadding: 8
+ }
+
+ Item {
+ width: 96
+ height: 96
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: config.stringValue("surface0")
+ visible: avatarImage.status !== Image.Ready
+ Text {
+ anchors.centerIn: parent
+ text: panel.initials
+ color: panel.textColor
+ font.family: "Noto Sans"
+ font.pixelSize: 34
+ }
+ }
+
+ Image {
+ id: avatarImage
+ anchors.fill: parent
+ source: panel.avatarPath
+ fillMode: Image.PreserveAspectCrop
+ visible: false
+ }
+
+ MultiEffect {
+ anchors.fill: parent
+ source: avatarImage
+ visible: avatarImage.status === Image.Ready
+ maskEnabled: true
+ maskSource: avatarMask
+ }
+
+ Rectangle {
+ id: avatarMask
+ anchors.fill: parent
+ radius: width / 2
+ layer.enabled: true
+ visible: false
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "transparent"
+ border.width: 2
+ border.color: panel.accentColor
+ }
+ }
+
+ Credentials {
+ id: creds
+ width: parent.width
+ onSubmitted: panel.submit()
+ onWake: panel.wake()
+ onUsernameChanged: panel.refreshAvatar()
+ }
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ font.family: "Noto Sans"
+ font.pixelSize: 12
+ color: panel.loginError !== ""
+ ? config.stringValue("red")
+ : config.stringValue("yellow")
+ text: panel.message
+ }
+ }
+
+ PowerBar {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: sessionBox.top
+ anchors.bottomMargin: 20
+ }
+
+ ComboBox {
+ id: sessionBox
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: 32
+ model: sessionModel
+ textRole: "name"
+ currentIndex: sessionModel.lastIndex
+ font.family: "Noto Sans"
+ palette.base: config.stringValue("surface0")
+ palette.text: config.stringValue("text")
+ palette.highlight: config.stringValue("accent")
+ palette.highlightedText: config.stringValue("base")
+ background: Rectangle {
+ radius: 6
+ color: config.stringValue("surface0")
+ }
+ }
+ }
+
+ function submit() {
+ if (creds.username.length === 0)
+ return
+ sddm.login(creds.username, creds.password, sessionBox.currentIndex)
+ }
+
+ function restoreFocus() {
+ creds.focusStage()
+ }
+
+ // Login failure wins the notice line, otherwise Caps Lock.
+ property string loginError: ""
+ readonly property string message: {
+ if (loginError !== "")
+ return loginError
+ if (keyboard.capsLock)
+ return "Caps Lock is on"
+ return ""
+ }
+
+ Connections {
+ target: sddm
+ function onLoginFailed() {
+ panel.loginError = "Login failed"
+ creds.clearPassword()
+ shake.restart()
+ }
+ function onLoginSucceeded() {
+ panel.loginError = ""
+ }
+ }
+
+ SequentialAnimation {
+ id: shake
+ NumberAnimation { target: panel; property: "shakeOffset"; to: -10; duration: 40 }
+ NumberAnimation { target: panel; property: "shakeOffset"; to: 10; duration: 40 }
+ NumberAnimation { target: panel; property: "shakeOffset"; to: 0; duration: 40 }
+ }
+
+ Component.onCompleted: {
+ refreshAvatar()
+ creds.focusStage()
+ }
+}
+```
+
+- [ ] **Step 2: Swap the card for the panel in Main**
+
+In `theme/Main.qml`, change the `wake()` function (around lines 86-92) to call the panel:
+
+```qml
+ function wake() {
+ if (!root.uiVisible) {
+ root.uiVisible = true
+ panel.restoreFocus()
+ }
+ idle.restart()
+ }
+```
+
+Then replace the whole `LoginCard { ... }` block (lines 106-116) with:
+
+```qml
+ // Panel width as a fraction of the window, with a sane fallback when the
+ // key is missing or malformed.
+ readonly property real panelWidth: {
+ var v = parseFloat(config.stringValue("panelWidth"))
+ return v > 0 ? v : 0.30
+ }
+
+ Panel {
+ id: panel
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width * root.panelWidth
+ visible: root.cardScreen
+ enabled: root.uiVisible
+ opacity: root.uiVisible ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
+ backdropSource: wallpaperImage
+ onWake: root.wake()
+ }
+```
+
+- [ ] **Step 3: Delete the old card and lint all QML**
+
+Run:
+
+```bash
+rm theme/components/LoginCard.qml
+/usr/lib64/qt6/bin/qmllint theme/Main.qml theme/components/*.qml; echo "exit=$?"
+```
+
+Expected: exit 0. Only `Unqualified access` warnings for the SDDM context properties (`config`, `sddm`, `userModel`, `sessionModel`, `keyboard`, `screenModel`, `primaryScreen`) are expected.
+
+- [ ] **Step 4: Visual check in the greeter**
+
+Run: `sddm-greeter-qt6 --test-mode --theme "$PWD/theme"`
+Expected: a left panel, roughly a third of the window, square corners, showing the clock and date, the greeting, the avatar, one field, the power row above a full-width session selector at the bottom. Type a username, press Enter, confirm the field slides to the password and the back chevron appears. Login and power actions are no-ops in test mode. If the environment has no display, this step is done by the maintainer.
+
+- [ ] **Step 5: Update the README**
+
+In `README.md`, after the "How it is wired" section, add a "Panel" section describing the layout and the new keys:
+
+```markdown
+## Panel
+
+The greeter draws a full-height panel on the left edge, `panelWidth` of the
+screen wide (0.30 by default), frosted over the wallpaper with square corners.
+From top to bottom it shows the clock (`timeFormat`) and date (`dateFormat`),
+the `greeting`, the avatar, one credential slot, the power row, and the session
+selector at the bottom.
+
+The credential slot is staged: type the username, confirm, and it slides out to
+reveal the password; confirm again to log in. Esc or the back chevron returns
+to the username.
+
+Power icons come from the icon theme the appearance drawer selected. `udt-accent`
+resolves them and writes absolute paths into the generated `theme.conf.user`;
+when a theme has no match, the bundled `theme/icons/*.svg` draw instead.
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add theme/Main.qml theme/components/Panel.qml README.md
+git rm theme/components/LoginCard.qml
+git commit -m "feat: replace the login card with a left panel
+
+Draw the greeter as a full-height left panel with a clock, greeting,
+avatar, a staged username-then-password slot, a power row and the
+session selector at the bottom. Delete the centered login card."
+```
+
+---
+
+### Task 6: Record the quickshell follow-up
+
+**Repository:** `~/Programming/GIT/quickshell`
+
+**Files:**
+- Modify: `TODO.md`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: nothing executable; a tracked follow-up.
+
+- [ ] **Step 1: Append the follow-up**
+
+Append to `TODO.md`, above the `## Done` section:
+
+```markdown
+## Appearance follow-ups
+
+- [ ] When the icon theme changes, run `udt-accent` with the current wallpaper
+ so the SDDM theme's power icons follow without waiting for a wallpaper
+ change. `appearance/Icons.qml` already sets gsettings; it needs the same
+ `udt-accent <wpaper>` call `wallp` makes.
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add TODO.md
+git commit -m "docs: track running udt-accent on icon theme change
+
+The SDDM panel power icons are resolved by udt-accent, so an icon
+theme switch does not reach the greeter until the next wallpaper
+change. Record wiring the call into the appearance drawer."
+```
+
+---
+
+## Self-Review
+
+- **Spec coverage:** left panel geometry and blur (Task 5), square corners (Task 5), clock/date/greeting config (Tasks 2 and 5), avatar (Task 5), staged credential flow with confirm and back (Task 4), power icons from gsettings (Tasks 1 and 3), session selector at the bottom (Task 5), quickshell TODO (Task 6), README (Task 5), config keys (Task 2). All spec sections map to a task.
+- **Placeholder scan:** every step carries the actual code or command.
+- **Type consistency:** `sddm_conf(palette, name, image, icons=None)` matches its call in `write_sddm` and the selftest. `find_icon(theme, names, dirs=None)` matches `power_icons()` and the selftest. `Panel` exposes `restoreFocus()`, `shakeOffset` and `wake()`, which Main uses. `Credentials` exposes `username`, `password`, `passwordStage`, `confirm()`, `clearPassword()`, `focusStage()`, `submitted` and `wake`, which Panel uses.