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