diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-17 12:41:55 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-17 12:41:55 +0200 |
| commit | 4e363eed8340ab6b1395d6f5e259600c6808c02f (patch) | |
| tree | b42faf1a7529aed8125a32bb9e40ffc7f10e065e | |
| parent | 729c7483ed9a71446bb3c8176c29e405109744a3 (diff) | |
| download | sddm-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.md | 1117 |
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. |
