# 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` > **Correction (post-implementation):** Task 1's `Main.qml` assumed one greeter > window spanning a union of all screens (`screenModel.geometry(-1)`). SDDM > 0.21 creates one window per screen, each with a single-row `screenModel` and a > boolean `primaryScreen` context property. The spec's multi-screen decision was > corrected; the implementation now gates the card per window. Read the spec, > not Task 1's original code, for the multi-screen model. ## Global Constraints - License: GPLv2 only. Every source file carries `SPDX-License-Identifier: GPL-2.0-only` and `Copyright (C) 2026 Danilo M. `. - 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 /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. // // 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 /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. // // 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 ``` `theme/icons/reboot.svg`: ```svg ``` `theme/icons/suspend.svg`: ```svg ``` `theme/icons/hibernate.svg`: ```svg ``` - [ ] **Step 2: Add `theme/components/PowerBar.qml`** ```qml // SPDX-License-Identifier: GPL-2.0-only // Copyright (C) 2026 Danilo M. // // 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. // // 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.