aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 12:41:55 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 12:41:55 +0200
commit4e363eed8340ab6b1395d6f5e259600c6808c02f (patch)
treeb42faf1a7529aed8125a32bb9e40ffc7f10e065e
parent729c7483ed9a71446bb3c8176c29e405109744a3 (diff)
downloadsddm-theme-udt-4e363eed8340ab6b1395d6f5e259600c6808c02f.tar.gz
sddm-theme-udt-4e363eed8340ab6b1395d6f5e259600c6808c02f.zip
docs: add sddm-theme-udt implementation plan
-rw-r--r--docs/superpowers/plans/2026-09-17-sddm-theme-udt.md1117
1 files changed, 1117 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-17-sddm-theme-udt.md b/docs/superpowers/plans/2026-09-17-sddm-theme-udt.md
new file mode 100644
index 0000000..1da3d20
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-17-sddm-theme-udt.md
@@ -0,0 +1,1117 @@
+# sddm-theme-udt 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 UDT-family SDDM login theme that draws the wallpaper on every screen, pins the login card to one screen, tracks the wallpaper accent live through `theme.conf.user`, and is wired into UDT's `udt-accent` and `install.sh`.
+
+**Architecture:** The theme code is root-installed and immutable. `udt-accent` writes `~/.local/share/udt/sddm-theme.conf` (world-readable, data only) on every wallpaper change. Root installs a symlink `/usr/share/sddm/themes/udt/theme.conf.user` to that file, which SDDM merges into the theme's `config` object. No user-writable code reaches the greeter.
+
+**Tech Stack:** SDDM 0.21.0, Qt 6.11.2 QML, QtQuick.Controls.Basic, QtQuick.Effects (`MultiEffect`), QtSvg icons, Python 3 (`udt-accent`).
+
+**Spec:** `docs/superpowers/specs/2026-09-17-sddm-theme-udt-design.md`
+
+## Global Constraints
+
+- License: GPLv2 only. Every source file carries `SPDX-License-Identifier: GPL-2.0-only` and `Copyright (C) 2026 Danilo M. <danix@danix.xyz>`.
+- Fonts: Noto Sans only. Never reference Inconsolata Nerd Font (user-only, invisible to the `sddm` user).
+- Palette keys are the Catppuccin-compat names UDT already emits: `base`, `mantle`, `crust`, `surface0`, `surface1`, `surface2`, `text`, `subtext0`, `subtext1`, `overlay0`, `overlay1`, `overlay2`, `red`, `yellow`, `green`, `teal`, `blue`, `lavender`, `accent`.
+- Target is SDDM 0.21 / Qt 6 / theme API 2.0. `config.stringValue(key)` and `config.boolValue(key)` only, no implicit `config.key` typing.
+- No personal paths or usernames in committed files. Root block expands `$HOME` at print time.
+- No em dashes in committed prose.
+- Commits are GPG-signed (repo global config). Do not disable signing.
+- `HANDOFF.md` is local-only and never committed.
+
+---
+
+### Task 1: Scaffolding and a wallpaper-only greeter
+
+**Files:**
+- Create: `LICENSE`
+- Create: `README.md`
+- Create: `.gitignore`
+- Create: `theme/metadata.desktop`
+- Create: `theme/theme.conf`
+- Create: `theme/Main.qml`
+- Test: `sddm-greeter-qt6 --test-mode --theme <abs>/theme`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `theme/Main.qml` root that later tasks edit; `theme/theme.conf` keys `base`, `mantle`, `accent`, `background`, `uiScreen`, `fadeoutMs`, `blur`. `cardGeometry` and `uiVisible` are the two properties later tasks use.
+
+- [ ] **Step 1: Add the license**
+
+```bash
+cp ../unified-desktop-theme/LICENSE LICENSE
+head -3 LICENSE # expect: GNU GENERAL PUBLIC LICENSE / Version 2, June 1991
+```
+
+- [ ] **Step 2: Add `.gitignore`**
+
+```
+HANDOFF.md
+*.tmp
+```
+
+- [ ] **Step 3: Add `theme/metadata.desktop`**
+
+```desktop
+[SddmGreeterTheme]
+Name=UDT
+Description=Unified Desktop Theme login screen
+Author=Danilo M.
+Copyright=(c) 2026 Danilo M.
+License=GPL-2.0-only
+Type=sddm-theme
+Version=0.1
+MainScript=Main.qml
+ConfigFile=theme.conf
+Theme-API=2.0
+QtVersion=6
+```
+
+- [ ] **Step 4: Add `theme/theme.conf` with Macchiato fallbacks**
+
+```conf
+# Static defaults and fallbacks. udt-accent writes the live values into
+# theme.conf.user, which SDDM merges over this file. Every key here is the
+# Catppuccin Macchiato value of the matching UDT role, so the theme parses and
+# renders before a wallpaper has ever been set.
+[General]
+type=image
+background=
+
+base=#24273a
+mantle=#1e2030
+crust=#181926
+surface0=#363a4f
+surface1=#494d64
+surface2=#5b6078
+text=#cad3f5
+subtext0=#a5adcb
+subtext1=#b8c0e0
+overlay0=#6e738d
+overlay1=#8087a2
+overlay2=#939ab7
+red=#ed8796
+yellow=#eed49f
+green=#a6da95
+teal=#8bd5ca
+blue=#8aadf4
+lavender=#b7bdf8
+accent=#b7bdf8
+
+# Empty uiScreen pins the card to the primary screen.
+uiScreen=
+# 60000 matches the stock theme's idle timeout.
+fadeoutMs=60000
+blur=true
+```
+
+- [ ] **Step 5: Add `theme/Main.qml`**
+
+```qml
+// SPDX-License-Identifier: GPL-2.0-only
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// UDT SDDM greeter. The root item spans every screen. The wallpaper is drawn
+// once per screen; the login card is pinned to one screen (config uiScreen,
+// else the primary screen); input wakes the card after it fades.
+
+import QtQuick
+
+Item {
+ id: root
+
+ // SDDM sizes the greeter to the union of every screen.
+ readonly property var union: screenModel.geometry(-1)
+ width: union.width
+ height: union.height
+
+ // Palette. Values come from theme.conf, overridden live by theme.conf.user.
+ readonly property string baseColor: config.stringValue("base")
+ readonly property int cardRadius: 10
+ readonly property string wallpaperUrl:
+ config.stringValue("background") === ""
+ ? ""
+ : "file://" + config.stringValue("background")
+
+ readonly property string wantedScreen: config.stringValue("uiScreen")
+ property var primaryGeometry: screenModel.geometry(screenModel.primary >= 0 ? screenModel.primary : 0)
+ property var namedGeometry: primaryGeometry
+ readonly property var cardGeometry: wantedScreen === "" ? primaryGeometry : namedGeometry
+
+ // The Image delegate of the screen the card sits on. The frosted card
+ // samples it. Null until the model has populated.
+ property Item cardScreenImage: null
+
+ Repeater {
+ id: screens
+ model: screenModel
+ delegate: Item {
+ x: geometry.x
+ y: geometry.y
+ width: geometry.width
+ height: geometry.height
+
+ Image {
+ id: screenImage
+ anchors.fill: parent
+ source: root.wallpaperUrl
+ fillMode: Image.PreserveAspectCrop
+ sourceSize.width: parent.width
+ sourceSize.height: parent.height
+ smooth: true
+ asynchronous: true
+ }
+
+ // Scrim so the card and text read on any wallpaper.
+ Rectangle {
+ anchors.fill: parent
+ color: root.baseColor
+ opacity: 0.45
+ }
+
+ readonly property string screenName: name
+
+ Component.onCompleted: root.registerScreen(index, screenName, screenImage, geometry)
+ }
+ }
+
+ // Filled as the model populates: the primary screen's name, and the
+ // wallpaper image plus geometry of whichever screen owns the card.
+ property string primaryName: ""
+
+ function registerScreen(index, screenName, image, geom) {
+ if (index === screenModel.primary)
+ root.primaryName = screenName
+ if (screenName === root.cardScreenName()) {
+ root.cardScreenImage = image
+ root.namedGeometry = geom
+ }
+ }
+
+ function cardScreenName() {
+ return root.wantedScreen !== "" ? root.wantedScreen : root.primaryName
+ }
+
+ // Idle: fade the card after fadeoutMs, restore on any input.
+ property bool uiVisible: true
+
+ Timer {
+ id: idle
+ interval: config.intValue("fadeoutMs") > 0 ? config.intValue("fadeoutMs") : 60000
+ running: root.uiVisible
+ repeat: false
+ onTriggered: root.uiVisible = false
+ }
+
+ function wake() {
+ root.uiVisible = true
+ idle.restart()
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onPressed: root.wake()
+ onPositionChanged: root.wake()
+ }
+
+ Keys.onPressed: event => {
+ root.wake()
+ event.accepted = false
+ }
+
+ // Placeholder card. Task 2 replaces this with components/LoginCard.qml.
+ Rectangle {
+ id: card
+ x: root.cardGeometry.x + (root.cardGeometry.width - width) / 2
+ y: root.cardGeometry.y + (root.cardGeometry.height - height) / 2
+ width: 380
+ height: 220
+ radius: root.cardRadius
+ color: config.stringValue("mantle")
+ opacity: root.uiVisible ? 0.9 : 0
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
+ }
+}
+```
+
+- [ ] **Step 6: Lint**
+
+Run: `/usr/lib64/qt6/bin/qmllint -I /usr/lib64/qt6/qml theme/Main.qml`
+Expected: parses with no syntax error. Warnings about unknown context properties (`screenModel`, `config`) are expected and fine.
+
+- [ ] **Step 7: Run the greeter**
+
+Run: `sddm-greeter-qt6 --test-mode --theme "$PWD/theme"`
+Expected: a window spanning the screens showing the current wallpaper, dimmed, with a rounded rectangle centered on the primary screen. Close the window.
+
+- [ ] **Step 8: Add `README.md`**
+
+```markdown
+# sddm-theme-udt
+
+An SDDM login theme for the unified-desktop-theme (UDT) family. It draws the
+current wallpaper on every screen, pins the login card to one screen, and takes
+its colours from the UDT palette, including the wallpaper-snapped accent.
+
+## How it is wired
+
+`udt-accent`, in the UDT repo, writes the live palette to
+`~/.local/share/udt/sddm-theme.conf` on every wallpaper change. Root installs
+`/usr/share/sddm/themes/udt/theme.conf.user` as a symlink to that file, which
+SDDM merges into the theme config at every greeter start. Wallpaper and accent
+need no root re-run; only theme code changes do.
+
+## Install
+
+The theme code is copied by root. UDT's `install.sh` prints the block; run it in
+a root shell:
+
+ install -d /usr/share/sddm/themes/udt
+ cp -r <this repo>/theme/. /usr/share/sddm/themes/udt/
+ ln -sfn ~/.local/share/udt/sddm-theme.conf /usr/share/sddm/themes/udt/theme.conf.user
+ printf '[Theme]\nCurrent=udt\n' > /etc/sddm.conf.d/udt.conf
+
+## Test
+
+ sddm-greeter-qt6 --test-mode --theme "$PWD/theme"
+
+## License
+
+GPLv2 only. See `LICENSE`.
+
+## Development Approach
+
+This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback.
+
+All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions.
+
+The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability.
+```
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add LICENSE README.md .gitignore theme/
+git commit -m "feat: wallpaper-only greeter skeleton"
+```
+
+---
+
+### Task 2: Login card, inputs, and idle fade
+
+**Files:**
+- Create: `theme/components/LoginCard.qml`
+- Modify: `theme/Main.qml` (replace the placeholder card with `LoginCard`)
+
+**Interfaces:**
+- Consumes: `root.cardGeometry`, `root.uiVisible`, `root.cardScreenImage` from Task 1.
+- Produces: `LoginCard` with properties `backdropSource` (Item), `typing` (bool), signal `wake()`; reads `config`, `sddm`, `userModel`, `sessionModel`, `keyboard`.
+
+- [ ] **Step 1: Add `theme/components/LoginCard.qml`**
+
+```qml
+// SPDX-License-Identifier: GPL-2.0-only
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// The login card: avatar, username, password, session choice, feedback and the
+// power bar. Reads the palette straight from `config`; SDDM merges theme.conf
+// and the live theme.conf.user into it.
+
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Effects
+
+Item {
+ id: card
+
+ // The wallpaper image to frost behind the card. Null falls back to a flat
+ // translucent surface.
+ property Item backdropSource: null
+ readonly property int radius: 10
+ signal wake()
+
+ // Shake on a failed login without touching the card's own x binding, which
+ // Main owns: animating x directly would destroy that binding.
+ property real shakeOffset: 0
+
+ implicitWidth: 380
+ implicitHeight: column.implicitHeight + 2 * 28
+
+ // Flat translucent surface. Task 4 layers the blur behind it.
+ Rectangle {
+ anchors.fill: parent
+ radius: card.radius
+ color: config.stringValue("mantle")
+ opacity: 0.82
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: card.radius
+ color: "transparent"
+ border.width: 2
+ border.color: config.stringValue("accent")
+ }
+
+ // Avatar lookup. userModel is a list model; Repeater delegates expose its
+ // roles by name. The matching face is copied out on demand because the
+ // username can change.
+ property string avatarPath: ""
+ readonly property string initials:
+ usernameField.text.length > 0 ? usernameField.text.charAt(0).toUpperCase() : "?"
+
+ Repeater {
+ id: faces
+ model: userModel
+ delegate: Item {
+ readonly property string faceName: name
+ readonly property string faceIcon: icon
+ visible: false
+ }
+ }
+
+ function refreshAvatar() {
+ for (var i = 0; i < faces.count; ++i) {
+ var it = faces.itemAt(i)
+ if (it && it.faceName === usernameField.text) {
+ avatarPath = it.faceIcon || ""
+ return
+ }
+ }
+ avatarPath = ""
+ }
+
+ Column {
+ id: column
+ anchors.centerIn: parent
+ width: parent.width - 2 * 28
+ spacing: 16
+
+ 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: card.initials
+ color: config.stringValue("text")
+ font.family: "Noto Sans"
+ font.pixelSize: 34
+ }
+ }
+
+ Image {
+ id: avatarImage
+ anchors.fill: parent
+ source: card.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: config.stringValue("accent")
+ }
+ }
+
+ TextField {
+ id: usernameField
+ width: parent.width
+ 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")
+ }
+ onTextChanged: card.refreshAvatar()
+ onAccepted: card.login()
+ Keys.onPressed: event => {
+ card.wake()
+ event.accepted = false
+ }
+ }
+
+ TextField {
+ id: passwordField
+ width: parent.width
+ 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")
+ }
+ onAccepted: card.login()
+ Keys.onPressed: event => {
+ card.wake()
+ event.accepted = false
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: 8
+
+ ComboBox {
+ id: sessionBox
+ width: parent.width - loginButton.width - parent.spacing
+ 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")
+ }
+ }
+
+ Button {
+ id: loginButton
+ text: "Log in"
+ font.family: "Noto Sans"
+ palette.button: config.stringValue("accent")
+ palette.buttonText: config.stringValue("base")
+ background: Rectangle {
+ radius: 6
+ color: config.stringValue("accent")
+ }
+ contentItem: Text {
+ text: loginButton.text
+ color: config.stringValue("base")
+ font: loginButton.font
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ padding: 8
+ }
+ onClicked: card.login()
+ }
+ }
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ font.family: "Noto Sans"
+ font.pixelSize: 12
+ color: card.loginError !== ""
+ ? config.stringValue("red")
+ : config.stringValue("yellow")
+ text: card.message
+ }
+ }
+
+ function login() {
+ if (usernameField.text.length === 0)
+ return
+ sddm.login(usernameField.text, passwordField.text, sessionBox.currentIndex)
+ }
+
+ // Login failure wins the notice line; otherwise Caps Lock, if on.
+ 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() {
+ loginError = "Login failed"
+ passwordField.text = ""
+ shake.restart()
+ }
+ function onLoginSucceeded() {
+ loginError = ""
+ }
+ }
+
+ SequentialAnimation {
+ id: shake
+ NumberAnimation { target: card; property: "shakeOffset"; to: -10; duration: 40 }
+ NumberAnimation { target: card; property: "shakeOffset"; to: 10; duration: 40 }
+ NumberAnimation { target: card; property: "shakeOffset"; to: 0; duration: 40 }
+ }
+
+ Component.onCompleted: refreshAvatar()
+}
+```
+
+- [ ] **Step 2: Replace the placeholder card in `theme/Main.qml`**
+
+Replace the `// Placeholder card...` block at the end of `Main.qml` with:
+
+```qml
+ LoginCard {
+ id: card
+ x: root.cardGeometry.x + (root.cardGeometry.width - width) / 2 + card.shakeOffset
+ y: root.cardGeometry.y + (root.cardGeometry.height - height) / 2
+ backdropSource: root.cardScreenImage
+ visible: opacity > 0
+ opacity: root.uiVisible ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
+ onWake: root.wake()
+ }
+```
+
+- [ ] **Step 3: Lint**
+
+Run: `/usr/lib64/qt6/bin/qmllint -I /usr/lib64/qt6/qml theme/Main.qml theme/components/LoginCard.qml`
+Expected: no syntax errors; unknown context property warnings only.
+
+- [ ] **Step 4: Run the greeter**
+
+Run: `sddm-greeter-qt6 --test-mode --theme "$PWD/theme"`
+Expected: card on the primary screen with the accent ring, a circular avatar (the last user's face, or their initial), username prefilled, a password field, the session combo, and a Log in button. Do nothing for `fadeoutMs` and the card fades out; move the mouse and it returns. Move it over the card specifically to confirm the full-screen mouse area still wakes it.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add theme/
+git commit -m "feat: login card with avatar, inputs and idle fade"
+```
+
+---
+
+### Task 3: Power bar
+
+**Files:**
+- Create: `theme/icons/power.svg`
+- Create: `theme/icons/reboot.svg`
+- Create: `theme/icons/suspend.svg`
+- Create: `theme/icons/hibernate.svg`
+- Create: `theme/components/PowerBar.qml`
+- Modify: `theme/components/LoginCard.qml` (add the bar below the buttons row)
+
+**Interfaces:**
+- Consumes: `config` palette, `sddm` power methods.
+- Produces: `PowerBar` used by `LoginCard`.
+
+- [ ] **Step 1: Add the four icons**
+
+Each is a 24x24 line icon drawn in white. The bar tints them with `MultiEffect`, so the fill colour in the file does not matter as long as it is white.
+
+`theme/icons/power.svg`:
+
+```svg
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round">
+ <path d="M12 3v9"/>
+ <path d="M6.6 6.6a8 8 0 1 0 10.8 0"/>
+</svg>
+```
+
+`theme/icons/reboot.svg`:
+
+```svg
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M20 11a8 8 0 1 0-2.3 5.7"/>
+ <polyline points="20 5 20 11 14 11"/>
+</svg>
+```
+
+`theme/icons/suspend.svg`:
+
+```svg
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round">
+ <path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.5 6.5 0 0 0 9.8 9.8z"/>
+</svg>
+```
+
+`theme/icons/hibernate.svg`:
+
+```svg
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M12 3v18"/>
+ <path d="M5 10l7 7 7-7"/>
+</svg>
+```
+
+- [ ] **Step 2: Add `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 button is disabled when SDDM
+// reports the action is unavailable.
+
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Effects
+
+Row {
+ id: bar
+ spacing: 8
+
+ readonly property var actions: [
+ { icon: "power.svg", label: "Shut down", enabled: sddm.canPowerOff, run: function() { sddm.powerOff() } },
+ { icon: "reboot.svg", label: "Restart", enabled: sddm.canReboot, run: function() { sddm.reboot() } },
+ { icon: "suspend.svg", label: "Sleep", enabled: sddm.canSuspend, run: function() { sddm.suspend() } },
+ { icon: "hibernate.svg", label: "Hibernate", enabled: sddm.canHibernate, run: function() { sddm.hibernate() } }
+ ]
+
+ Repeater {
+ model: bar.actions
+ delegate: Item {
+ width: 44
+ height: 44
+ opacity: modelData.enabled ? 1 : 0.35
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 8
+ color: config.stringValue("surface0")
+ opacity: mouse.containsMouse && modelData.enabled ? 1 : 0
+ }
+
+ Image {
+ id: icon
+ anchors.centerIn: parent
+ width: 22
+ height: 22
+ source: modelData.icon
+ visible: false
+ }
+
+ MultiEffect {
+ anchors.fill: icon
+ source: icon
+ colorizationEnabled: true
+ 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 3: Insert the bar in `LoginCard.qml`**
+
+Add after the closing brace of the `Row` containing the session box and login button, still inside `column`:
+
+```qml
+ PowerBar {
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+```
+
+- [ ] **Step 4: Lint**
+
+Run: `/usr/lib64/qt6/bin/qmllint -I /usr/lib64/qt6/qml theme/components/LoginCard.qml theme/components/PowerBar.qml`
+Expected: no syntax errors.
+
+- [ ] **Step 5: Run the greeter**
+
+Run: `sddm-greeter-qt6 --test-mode --theme "$PWD/theme"`
+Expected: four icons below the login row. Hovering one shows an accent-tinted icon, a `surface0` pill and a tooltip. Unavailable actions are dimmed. Power actions are no-ops in test mode.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add theme/
+git commit -m "feat: add power bar to login card"
+```
+
+---
+
+### Task 4: Frosted backdrop
+
+**Files:**
+- Create: `theme/components/Backdrop.qml`
+- Modify: `theme/components/LoginCard.qml` (replace the flat surface with `Backdrop`)
+
+**Interfaces:**
+- Consumes: `LoginCard.backdropSource` (the wallpaper `Image` delegate from Task 1).
+- Produces: `Backdrop` with properties `sourceItem` (Item), `radius` (int), `tint` (color), `tintOpacity` (real), `blurEnabled` (bool).
+
+- [ ] **Step 1: Add `theme/components/Backdrop.qml`**
+
+```qml
+// SPDX-License-Identifier: GPL-2.0-only
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// A frosted panel. It snapshots the wallpaper image the card sits on, blurs it
+// and masks it to the card's rounded rect, then tints it for text contrast.
+// When there is no source, only the tint draws, so the card still reads.
+
+import QtQuick
+import QtQuick.Effects
+
+Item {
+ id: backdrop
+
+ property Item sourceItem: null
+ property int radius: 10
+ property color tint: "#24273a"
+ property real tintOpacity: 0.72
+ property bool blurEnabled: true
+
+ ShaderEffectSource {
+ id: snapshot
+ sourceItem: backdrop.sourceItem
+ live: true
+ sourceRect: {
+ if (backdrop.sourceItem === null)
+ return Qt.rect(0, 0, 0, 0)
+ var p = backdrop.mapToItem(backdrop.sourceItem, 0, 0)
+ return Qt.rect(p.x, p.y, backdrop.width, backdrop.height)
+ }
+ }
+
+ MultiEffect {
+ anchors.fill: parent
+ source: snapshot
+ visible: backdrop.sourceItem !== null && backdrop.blurEnabled
+ blurEnabled: true
+ blur: 0.8
+ maskEnabled: true
+ maskSource: mask
+ }
+
+ Rectangle {
+ id: mask
+ anchors.fill: parent
+ radius: backdrop.radius
+ layer.enabled: true
+ visible: false
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: backdrop.radius
+ color: backdrop.tint
+ opacity: backdrop.tintOpacity
+ }
+}
+```
+
+- [ ] **Step 2: Use it in `LoginCard.qml`**
+
+Replace the flat surface rectangle at the top of the card with:
+
+```qml
+ Backdrop {
+ anchors.fill: parent
+ radius: card.radius
+ sourceItem: card.backdropSource
+ tint: config.stringValue("mantle")
+ blurEnabled: config.boolValue("blur")
+ }
+```
+
+- [ ] **Step 3: Lint**
+
+Run: `/usr/lib64/qt6/bin/qmllint -I /usr/lib64/qt6/qml theme/components/Backdrop.qml theme/components/LoginCard.qml`
+Expected: no syntax errors.
+
+- [ ] **Step 4: Run the greeter**
+
+Run: `sddm-greeter-qt6 --test-mode --theme "$PWD/theme"`
+Expected: the card shows a blurred, dimmed copy of the wallpaper behind it, matching the visible wallpaper position, clipped to the rounded rect. Set `blur=false` in `theme.conf` and confirm it falls back to a flat translucent card with no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add theme/
+git commit -m "feat: frosted backdrop behind the login card"
+```
+
+---
+
+### Task 5: Live palette output in udt-accent
+
+**Files:**
+- Modify: `../unified-desktop-theme/bin/udt-accent`
+- Test: `../unified-desktop-theme/bin/udt-accent --selftest`
+
+**Interfaces:**
+- Consumes: `read_palette()`, `write_atomic()`, `ACCENTS`, `FALLBACK` already in the file.
+- Produces: `SDDM_KEYS` (list), `sddm_conf(palette, name, image) -> str`, `write_sddm(name, image) -> None`, and the output file `~/.local/share/udt/sddm-theme.conf`.
+
+- [ ] **Step 1: Write the failing test**
+
+In `selftest()`, before the final `print("selftest OK")`, add:
+
+```python
+ # The SDDM theme reads this file through a symlink, so it has to carry every
+ # key the theme asks for, and a second run has to overwrite cleanly rather
+ # than append.
+ palette = {k: "#1e2030" for k in SDDM_KEYS}
+ probe_image = Path(tempfile.mkdtemp()) / "wall.png"
+ probe_image.write_bytes(b"")
+ text = sddm_conf(palette, FALLBACK, str(probe_image))
+ for key in ["type", "background", "accent", *SDDM_KEYS]:
+ assert f"\n{key}=" in "\n" + text, f"sddm conf missing {key}"
+ assert text.count("accent=") == 1
+ assert "background=" + str(probe_image) in text
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `cd ../unified-desktop-theme && ./bin/udt-accent --selftest`
+Expected: FAIL with `NameError: name 'SDDM_KEYS' is not defined`.
+
+- [ ] **Step 3: Implement**
+
+Add near the other output paths in `bin/udt-accent`:
+
+```python
+# The SDDM theme's live palette. ~/.cache is 0700 and unreachable to the sddm
+# user, ~/.local/share is 0755, so the file has to live here. Root symlinks
+# /usr/share/sddm/themes/udt/theme.conf.user to it. See the sddm-theme-udt spec.
+LIBEXEC = Path.home() / ".local" / "share" / "udt"
+SDDM_OUTPUT = LIBEXEC / "sddm-theme.conf"
+
+# The keys the theme asks for. The names are the same Catppuccin-compat names
+# palette.rasi and udt-palette.qml use, so one palette has one vocabulary.
+SDDM_KEYS = ["base", "mantle", "crust", "surface0", "surface1", "surface2",
+ "text", "subtext0", "subtext1", "overlay0", "overlay1", "overlay2",
+ "red", "yellow", "green", "teal", "blue", "lavender"]
+```
+
+Add the writer next to `write_sublime`:
+
+```python
+def sddm_conf(palette, name, image):
+ """The SDDM theme config, as an INI fragment.
+
+ theme.conf.user overrides theme.conf and is exposed to the theme as the
+ `config` object. It is data parsed by QSettings, never executed, so nothing
+ the login user can write reaches the greeter as code.
+ """
+ lines = [
+ "# Generated by udt-accent. Do not edit.",
+ "[General]",
+ "type=image",
+ f"background={os.path.realpath(image)}",
+ f"accent={ACCENTS[name]}",
+ ]
+ lines += [f"{key}={palette.get(key, '#000000')}" for key in SDDM_KEYS]
+ return "\n".join(lines) + "\n"
+
+
+def write_sddm(name, image):
+ """Write the live SDDM palette, atomically, world-readable.
+
+ World-readable because the greeter runs as the sddm user. The atomic writer
+ creates the file 0600, so the mode is fixed up here.
+ """
+ palette = read_palette()
+ if not palette:
+ print("udt-accent: palette.rasi unreadable, skipping SDDM theme",
+ file=sys.stderr)
+ return
+ write_atomic(SDDM_OUTPUT, sddm_conf(palette, name, image))
+ os.chmod(SDDM_OUTPUT, 0o644)
+```
+
+Call it from `main()` after `write_sublime(name)`:
+
+```python
+ write_sddm(name, image)
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `cd ../unified-desktop-theme && ./bin/udt-accent --selftest`
+Expected: `selftest OK`.
+
+- [ ] **Step 5: Verify against the real palette**
+
+Run: `./bin/install.sh >/dev/null && ./bin/udt-accent "$HOME/.cache/udt/wpaper" && cat "$HOME/.local/share/udt/sddm-theme.conf" && ls -l "$HOME/.local/share/udt/sddm-theme.conf"`
+Expected: an INI with `accent=`, `background=` and all 18 keys; mode `-rw-r--r--`.
+
+- [ ] **Step 6: Commit** (in the UDT repo)
+
+```bash
+cd ../unified-desktop-theme
+git add bin/udt-accent
+git commit -m "feat: write the SDDM theme palette for sddm-theme-udt"
+```
+
+---
+
+### Task 6: Print the root block from UDT install.sh
+
+**Files:**
+- Modify: `../unified-desktop-theme/install.sh`
+
+**Interfaces:**
+- Consumes: `$repo`, `$HOME`, the sibling `sddm-theme-udt/theme/` directory.
+- Produces: a printed root block on stdout.
+
+- [ ] **Step 1: Add the block**
+
+In `../unified-desktop-theme/install.sh`, after the conky restart block and before the final `[ -n "$reloaded" ]` line, add:
+
+```bash
+# SDDM. The theme code is root-installed, but the palette the greeter reads has
+# to stay live, so the root block below symlinks the generated file in. Printed,
+# never run: /usr/share/sddm is root-owned and this script is not.
+sddm_repo="$repo/../sddm-theme-udt"
+if [ -d "$sddm_repo/theme" ]; then
+ echo
+ echo "sddm theme: run these as root to install or refresh it"
+ echo " install -d /usr/share/sddm/themes/udt"
+ echo " cp -r \"$sddm_repo/theme/.\" /usr/share/sddm/themes/udt/"
+ echo " ln -sfn \"$HOME/.local/share/udt/sddm-theme.conf\" /usr/share/sddm/themes/udt/theme.conf.user"
+ echo " printf '[Theme]\\nCurrent=udt\\n' > /etc/sddm.conf.d/udt.conf"
+fi
+```
+
+- [ ] **Step 2: Verify the block prints**
+
+Run: `cd ../unified-desktop-theme && ./install.sh | tail -8`
+Expected: the four root commands, with the absolute repo path and `$HOME` expanded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add install.sh
+git commit -m "feat: print the sddm-theme-udt root install block"
+```
+
+---
+
+### Task 7: Root install and end-to-end verification
+
+**Files:**
+- Modify: `README.md` (final wording if the block changed)
+
+**Interfaces:**
+- Consumes: everything above.
+- Produces: an installed, selected theme.
+
+- [ ] **Step 1: Confirm the greeter can read the live files**
+
+Run (as root):
+
+```bash
+ls -l /home/*/.local/share/udt/sddm-theme.conf 2>/dev/null
+ls -ld /usr/share/sddm /usr/share/sddm/themes
+```
+
+Then, once installed, confirm the greeter user can read the config and the wallpaper:
+
+```bash
+sudo -u sddm cat /usr/share/sddm/themes/udt/theme.conf.user >/dev/null && echo conf-ok
+sudo -u sddm test -r "$(sed -n 's/^background=//p' /home/*/.local/share/udt/sddm-theme.conf)" && echo wallpaper-ok
+```
+
+Expected: `conf-ok` and `wallpaper-ok`.
+
+- [ ] **Step 2: Install as root**
+
+Run the four commands printed by `install.sh`, in a root shell.
+
+- [ ] **Step 3: Verify selection**
+
+Run:
+
+```bash
+cat /etc/sddm.conf.d/udt.conf
+grep -R Current /etc/sddm.conf.d/
+```
+
+Expected: `[Theme]` / `Current=udt`, and `udt` is the last `Current` in sorted order.
+
+- [ ] **Step 4: Log out and inspect the real greeter**
+
+Log out. Expected: the wallpaper on every screen, the card on the primary (or the `uiScreen` output), the palette and accent matching the desktop.
+
+- [ ] **Step 5: Change the wallpaper and re-check**
+
+From the desktop, set a different wallpaper through the `appearance` quickshell, then log out again. Expected: the login card accent and wallpaper follow with no root re-run.
+
+- [ ] **Step 6: Commit any README correction**
+
+```bash
+git add README.md
+git commit -m "docs: finalise install instructions"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage**
+
+- Live tracking via `theme.conf.user` symlink: Tasks 5 and 6 (generation and install), Task 1 (config keys).
+- One writer, one palette source: Task 5 only touches `udt-accent`; `udt-palette` untouched.
+- File in `~/.local/share/udt/`: Task 5 constant `LIBEXEC`.
+- Wallpaper real path: Task 5 `os.path.realpath`.
+- Copy not symlink for theme code: Task 7 uses `cp -r`.
+- GUI on one screen, wallpaper on all: Task 1 `cardGeometry` and per-screen Repeater.
+- Idle fade: Task 1 timer, Task 2 card opacity.
+- Frosted card with fallback: Task 4.
+- Avatar: Task 2.
+- Session selector: Task 2.
+- Power bar: Task 3.
+- Feedback (login failure, Caps Lock): Task 2.
+- Noto Sans only: every font reference.
+- Palette names: Task 1 `theme.conf`, Task 5 `SDDM_KEYS`.
+- Security note: enforced by Task 1 root-owned code + Task 5 data-only INI.
+- Testing: per-task greeter run, Task 5 selftest, Task 7 greeter-user reachability.
+
+**Placeholder scan:** none. Every step carries its code or exact command.
+
+**Type consistency:** `cardGeometry`, `uiVisible`, `cardScreenImage` (Main) match their uses in Tasks 2 and 4. `backdropSource` is defined on `LoginCard` in Task 2 and consumed unchanged in Task 4. `sddm_conf` and `write_sddm` are defined and called with the same signatures in Task 5. `SDDM_KEYS` is used by the Task 5 selftest before its definition step, which is intentional TDD ordering within the same task.