aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-16 19:17:49 +0200
committerDanilo M. <danix@danix.xyz>2026-09-16 19:17:49 +0200
commit913104adfb31aa96407ea7f02ca2871eebda2064 (patch)
tree82a5da70b982487bb41c67f6d73367a4a3de383f /docs
parent85807e963e8483dae83dbf59112eabc1a7964387 (diff)
downloadconky-theme-udt-913104adfb31aa96407ea7f02ca2871eebda2064.tar.gz
conky-theme-udt-913104adfb31aa96407ea7f02ca2871eebda2064.zip
docs: add implementation plan for the Lua dashboard
Ten tasks, TDD where there is logic to test. Ordered so nothing half-built touches the working desktop: pure Lua and its tests first, then pixels, then the palette and the widget, then window placement, and only last does UDT switch over to this repo's template. Leads with the platform facts verified during design, because each one invalidates the approach an implementer would otherwise reach for: conky_surface() instead of the Xlib idiom on Wayland, own_window_type normal rather than desktop, a Lua fault presenting as a blank screen, grim capturing screen coordinates so it needs the dashboard's workspace active, and Cairo having no Black font weight. The pcall error overlay is built before any widget and verified by injecting a deliberate fault, since it is the only channel that makes a later mistake visible. Fixture arithmetic in the parser tests is checked rather than asserted from memory: a wrong expected value sends the implementer chasing a bug that is not there. Paths are ~-relative throughout, so the plan does not pin to one home directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-09-16-conky-lua-dashboard.md1531
1 files changed, 1531 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-16-conky-lua-dashboard.md b/docs/superpowers/plans/2026-09-16-conky-lua-dashboard.md
new file mode 100644
index 0000000..3445d6a
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-16-conky-lua-dashboard.md
@@ -0,0 +1,1531 @@
+# Conky Lua Dashboard 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 fullscreen Conky dashboard for Hyprland, drawn with Cairo from Lua, on a toggleable special workspace, with a grid layout the user reorders by editing one table, and one clock widget end to end.
+
+**Architecture:** `conky.conf.in` is a UDT-templated config that loads `dashboard.lua` in `lua_draw_hook_post`. `dashboard.lua` owns a layout table of grid cells, converts cells to pixel rects, and calls each widget's `draw(cr, rect, colors)`. Cairo primitives live in `lib/card.lua`, `/proc` and `/sys` parsers in `lib/data.lua`, one file per widget in `widgets/`. `conky.text` stays empty because Cairo output covers it.
+
+**Tech Stack:** Conky 1.24.2-pre (Lua + Cairo + Imlib2, Wayland output), Lua 5.4.9, Cairo via Conky's bindings, Hyprland windowrules, UDT palette templating (Python, `bin/udt-palette`).
+
+---
+
+## Critical platform facts
+
+These were verified experimentally on this machine. Each one invalidates an approach you will otherwise reach for.
+
+**1. Use `conky_surface()`, never `cairo_xlib_surface_create`.** Under `out_to_wayland = true`, `conky_window.drawable` and `conky_window.visual` are both `nil`. Every Conky-Lua tutorial online uses the Xlib idiom and all of them are wrong here. `conky_window.width` and `.height` DO work and return floats.
+
+**2. `own_window_type` must be `'normal'`, not `'desktop'`.** Desktop type is a layer-surface at level 0; Hyprland cannot assign it to a workspace. Normal type appears in `hyprctl clients` and a windowrule can place it.
+
+**3. A Lua error produces a blank screen and no message.** No stderr, no log. This is why Task 4 wraps the draw in `pcall` before any widget exists.
+
+**4. `grim` cannot capture a window on an inactive workspace.** It captures whatever occupies those screen coordinates. To screenshot the dashboard you must switch to its workspace first, and wait after switching. Do not trust a capture taken without switching.
+
+**5. Cairo has no Black font weight.** `CAIRO_FONT_WEIGHT_BOLD` is the maximum. To reach Noto Sans Black, pass the family name `"Noto Sans Black"` with `CAIRO_FONT_WEIGHT_NORMAL`. Verified: `"13"` at size 90 measures 100.0px wide as Black versus 94.0px as Noto Sans Bold, so the heavier face is genuinely selected.
+
+**6. `print()` from Lua reaches Conky's stdout.** Run Conky in the foreground to see it. This is the only debugging channel.
+
+---
+
+## File structure
+
+**This repo (`~/Programming/GIT/conky-theme-udt`):**
+
+| Path | Responsibility |
+|---|---|
+| `conky.conf.in` | UDT template: Conky settings, `@ROLE@` colour placeholders, `lua_load`. Empty `conky.text`. |
+| `dashboard.lua` | Entry point. Layout table, grid maths, palette parsing, `pcall` error overlay, widget dispatch. |
+| `lib/card.lua` | Cairo primitives: rounded-rect card, text helpers, font selection. |
+| `lib/data.lua` | `/proc` and `/sys` parsers. Pure functions over strings, so they are testable. |
+| `widgets/clock.lua` | The only v1 widget. `draw(cr, rect, colors)`. |
+| `test/fixtures/` | Committed sample `/proc/stat`, `/proc/meminfo`, `temp1_input`. |
+| `test/test_data.lua` | `assert`-based parser check, run with `lua`. |
+| `hypr/dashboard.conf` | Hyprland windowrules and keybind, sourced by the user's config. |
+| `README.md` | What it is, install, layout editing, the Wayland Cairo gotcha. |
+
+**The UDT repo (`~/Programming/GIT/unified-desktop-theme`), modified in Task 9:**
+
+| Path | Change |
+|---|---|
+| `bin/udt-palette:545-555` | `gen_conky` reads its template from this repo and adds the `critical` role. |
+| `install.sh:178-207` | Link this repo's rendered config; keep the existing restart logic. |
+| `templates/conky.conf.in` | Deleted, replaced by this repo. |
+
+**Ordering rationale:** Tasks 1-3 build and test pure Lua with no Conky involved, so failures are readable. Task 4 gets pixels on screen with a hardcoded colour. Tasks 5-7 add the palette, the grid, and the widget. Task 8 places the window. Task 9 switches UDT over last, so a broken intermediate state never touches the working desktop.
+
+---
+
+### Task 1: Repo scaffolding and the data parsers' first test
+
+**Files:**
+- Create: `test/fixtures/proc_stat`
+- Create: `test/fixtures/proc_meminfo`
+- Create: `test/fixtures/temp1_input`
+- Create: `test/test_data.lua`
+- Create: `lib/data.lua`
+
+- [ ] **Step 1: Write the fixtures**
+
+These are real samples from this machine, trimmed. Create `test/fixtures/proc_stat`:
+
+```
+cpu 209094 2199 135469 11604898 24408 0 946 0 0 0
+cpu0 5294 11 2928 740049 460 0 592 0 0 0
+cpu1 5210 18 2801 740512 431 0 12 0 0 0
+intr 12345678
+ctxt 987654321
+```
+
+Create `test/fixtures/proc_meminfo`:
+
+```
+MemTotal: 31943076 kB
+MemFree: 4291244 kB
+MemAvailable: 25627088 kB
+Buffers: 123456 kB
+Cached: 6789012 kB
+SwapTotal: 8388604 kB
+SwapFree: 8388604 kB
+```
+
+Create `test/fixtures/temp1_input`:
+
+```
+53125
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `test/test_data.lua`. Note `package.path` must be set so `require 'lib.data'` resolves when run from the repo root.
+
+```lua
+-- Parser checks for lib/data.lua.
+-- Run from the repo root: lua test/test_data.lua
+-- Parsers are what break silently on a kernel or hardware change, so they are
+-- what gets a test. Everything else in this project is verified by screenshot.
+
+package.path = './?.lua;' .. package.path
+local data = require 'lib.data'
+
+local function read(path)
+ local f = assert(io.open(path, 'r'))
+ local s = f:read('*a')
+ f:close()
+ return s
+end
+
+-- cpu_times: total and idle jiffies from the aggregate "cpu " line.
+local total, idle = data.cpu_times(read('test/fixtures/proc_stat'))
+-- 209094+2199+135469+11604898+24408+0+946 = 11977014
+assert(total == 11977014, 'cpu total, got ' .. tostring(total))
+-- idle field is the 4th value, 11604898; iowait (24408) counts as idle too
+assert(idle == 11629306, 'cpu idle, got ' .. tostring(idle))
+
+-- mem_info: values in kB, as the file gives them.
+local mem = data.mem_info(read('test/fixtures/proc_meminfo'))
+assert(mem.total == 31943076, 'mem total, got ' .. tostring(mem.total))
+assert(mem.available == 25627088, 'mem available, got ' .. tostring(mem.available))
+-- used is total minus available, which is what a user means by "used"
+assert(mem.used == 6315988, 'mem used, got ' .. tostring(mem.used))
+
+-- millidegrees: hwmon temp*_input is millidegrees C, rounded to whole degrees.
+assert(data.millidegrees(read('test/fixtures/temp1_input')) == 53,
+ 'temp, got ' .. tostring(data.millidegrees(read('test/fixtures/temp1_input'))))
+-- A missing or unreadable sensor must yield nil, not an error and not 0:
+-- 0 degrees is a plausible reading and would be indistinguishable from failure.
+assert(data.millidegrees(nil) == nil, 'nil input must give nil')
+assert(data.millidegrees('') == nil, 'empty input must give nil')
+assert(data.millidegrees('garbage') == nil, 'unparseable input must give nil')
+
+print('test_data: all assertions passed')
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_data.lua`
+
+Expected: failure, `module 'lib.data' not found`.
+
+- [ ] **Step 4: Write the minimal implementation**
+
+Create `lib/data.lua`:
+
+```lua
+-- Readers for /proc and /sys.
+--
+-- Every function takes the file *contents* as a string rather than reading the
+-- file itself, so the parsers are testable against fixtures without mocking the
+-- filesystem. The thin read-and-parse wrappers live at the bottom.
+
+local M = {}
+
+-- Aggregate CPU jiffies from /proc/stat. Returns total, idle.
+-- iowait counts as idle: a core waiting on disk is not doing work, and
+-- treating it as busy makes a disk-bound system look CPU-bound.
+function M.cpu_times(stat)
+ local line = stat:match('^cpu%s+([^\n]+)')
+ if not line then return nil, nil end
+ local v = {}
+ for n in line:gmatch('%d+') do v[#v + 1] = tonumber(n) end
+ if #v < 5 then return nil, nil end
+ local total = 0
+ for _, n in ipairs(v) do total = total + n end
+ return total, v[4] + v[5] -- idle + iowait
+end
+
+-- /proc/meminfo, values in kB as the file states them.
+function M.mem_info(meminfo)
+ local function field(name)
+ return tonumber(meminfo:match(name .. ':%s+(%d+)'))
+ end
+ local total = field('MemTotal')
+ local available = field('MemAvailable')
+ if not (total and available) then return nil end
+ return {
+ total = total,
+ free = field('MemFree'),
+ available = available,
+ -- MemAvailable already excludes reclaimable cache, so this is the figure a
+ -- user recognises as "used", unlike total-free which counts cache.
+ used = total - available,
+ }
+end
+
+-- hwmon temp*_input is millidegrees C. Returns whole degrees, or nil.
+-- nil rather than 0 on failure: 0 C is a legitimate reading.
+function M.millidegrees(s)
+ if not s then return nil end
+ local n = tonumber(s:match('^%s*(-?%d+)'))
+ if not n then return nil end
+ return math.floor(n / 1000 + 0.5)
+end
+
+-- Read a whole file, returning nil if it cannot be read. Used by the wrappers
+-- so a vanished sysfs path degrades to nil instead of raising.
+function M.slurp(path)
+ local f = io.open(path, 'r')
+ if not f then return nil end
+ local s = f:read('*a')
+ f:close()
+ return s
+end
+
+-- Find a hwmon directory by the exact contents of its `name` file.
+--
+-- Globbing by name, never by a fixed hwmon index: indices drift across kernel
+-- and hardware reorders, and a stale index silently reports a different chip.
+-- Carried over from the previous conky config, where this was the hard-won bit.
+function M.hwmon_dir(name)
+ local p = io.popen('grep -lx ' .. ("%q"):format(name)
+ .. ' /sys/class/hwmon/hwmon*/name 2>/dev/null')
+ if not p then return nil end
+ local hit = p:read('*l')
+ p:close()
+ if not hit then return nil end
+ return hit:match('^(.*)/name$')
+end
+
+return M
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_data.lua`
+
+Expected: `test_data: all assertions passed`
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+git add lib/data.lua test/
+git commit -m "feat: add /proc and /sys parsers with fixture tests
+
+Parsers take file contents as a string, not a path, so they test
+against committed fixtures with no filesystem mocking.
+
+hwmon is globbed by its name file rather than a fixed index, carried
+over from the old config: indices drift across kernel reorders and a
+stale one silently reads a different chip.
+
+Failure returns nil, never 0. A sensor reading 0 C is legitimate, so 0
+cannot double as an error value."
+```
+
+---
+
+### Task 2: CPU percentage from two samples
+
+CPU load is a delta between two readings, so it needs state. This is separate from Task 1 because it is the one parser with memory, and getting the first-call case wrong yields a bogus 100% spike on startup.
+
+**Files:**
+- Modify: `test/test_data.lua`
+- Modify: `lib/data.lua`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `test/test_data.lua`, before the final `print`:
+
+```lua
+-- cpu_percent is a delta between two samples, so it holds state.
+-- The first call has no previous sample and must report nil, not a number:
+-- any number it invented would be wrong, and 100% on startup looks like a
+-- real spike.
+local c = data.new_cpu_counter()
+assert(c:sample(1000, 900) == nil, 'first sample must give nil')
+
+-- Second sample: 100 more total jiffies, 50 of them idle, so 50% busy.
+-- Held in a local first: calling sample() again inside the assert message would
+-- advance the counter a third time.
+local busy = c:sample(1100, 950)
+assert(busy == 50.0, 'second sample, got ' .. tostring(busy))
+
+-- A counter that did not advance means no elapsed time, not 0% load.
+local c2 = data.new_cpu_counter()
+c2:sample(1000, 900)
+assert(c2:sample(1000, 900) == nil, 'zero delta must give nil, not a division by zero')
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_data.lua`
+
+Expected: failure, `attempt to call a nil value (field 'new_cpu_counter')`.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Add to `lib/data.lua`, before the final `return M`:
+
+```lua
+-- A stateful CPU-load counter.
+--
+-- Load is work done between two samples, so a single reading cannot produce a
+-- percentage. Each counter keeps its own previous sample, which also means a
+-- per-core counter is just another instance.
+function M.new_cpu_counter()
+ return {
+ prev_total = nil,
+ prev_idle = nil,
+ -- Returns busy percent since the previous sample, or nil when there is no
+ -- usable delta (first call, or the counters did not advance).
+ sample = function(self, total, idle)
+ if not (total and idle) then return nil end
+ local pt, pi = self.prev_total, self.prev_idle
+ self.prev_total, self.prev_idle = total, idle
+ if not pt then return nil end
+ local dt = total - pt
+ if dt <= 0 then return nil end
+ local busy = (dt - (idle - pi)) / dt * 100
+ -- Clamp: a counter reset or a suspend/resume can produce a nonsense
+ -- delta, and a bar drawn at -12% or 340% is worse than a clamped one.
+ if busy < 0 then busy = 0 elseif busy > 100 then busy = 100 end
+ return busy
+ end,
+ }
+end
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_data.lua`
+
+Expected: `test_data: all assertions passed`
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+git add lib/data.lua test/test_data.lua
+git commit -m "feat: add stateful CPU percentage counter
+
+Load is a delta, so one reading cannot yield a percentage. The first
+call returns nil rather than a fabricated number: 100% on startup reads
+as a real spike.
+
+A non-advancing counter also returns nil instead of dividing by zero,
+and the result is clamped because a suspend/resume can produce a
+nonsense delta."
+```
+
+---
+
+### Task 3: Cairo card primitives
+
+Pure geometry, no Conky. Verified by asserting the path-building helpers do not raise and by the screenshot in Task 4.
+
+**Files:**
+- Create: `lib/card.lua`
+
+- [ ] **Step 1: Write the implementation**
+
+Create `lib/card.lua`:
+
+```lua
+-- Cairo drawing primitives shared by every widget.
+--
+-- A widget is handed a rect and these helpers; it never computes its own
+-- position and never touches the palette directly.
+
+local M = {}
+
+-- Cairo's font API tops out at CAIRO_FONT_WEIGHT_BOLD, so a Black face cannot
+-- be requested by weight. Passing the family name that fontconfig registers for
+-- it ("Noto Sans Black") with NORMAL weight does select it: measured at size
+-- 90, "13" is 100.0px wide as Black against 94.0px as Noto Sans Bold.
+M.FONT_MONO = 'Inconsolata Nerd Font'
+M.FONT_UI = 'Noto Sans'
+M.FONT_HEAVY = 'Noto Sans Black'
+
+function M.font(cr, family, size, bold)
+ cairo_select_font_face(cr, family, CAIRO_FONT_SLANT_NORMAL,
+ bold and CAIRO_FONT_WEIGHT_BOLD or CAIRO_FONT_WEIGHT_NORMAL)
+ cairo_set_font_size(cr, size)
+end
+
+function M.rgba(cr, c, alpha)
+ cairo_set_source_rgba(cr, c[1], c[2], c[3], alpha or c[4] or 1)
+end
+
+-- Text at (x, y), where y is the BASELINE, not the top of the glyphs.
+function M.text(cr, x, y, s)
+ cairo_move_to(cr, x, y)
+ cairo_show_text(cr, s)
+end
+
+-- Measured width and height of a string under the current font.
+function M.measure(cr, s)
+ local e = cairo_text_extents_t:create()
+ cairo_text_extents(cr, s, e)
+ return e.width, e.height
+end
+
+-- Right-aligned text: x is the RIGHT edge.
+function M.text_right(cr, x, y, s)
+ local w = M.measure(cr, s)
+ M.text(cr, x - w, y, s)
+end
+
+-- A rounded-rectangle path. Does not paint; the caller fills or strokes, so one
+-- path can serve both a fill and its border.
+function M.rounded_path(cr, x, y, w, h, r)
+ -- Clamp the radius: a radius over half the shorter side makes the arcs
+ -- overlap and Cairo draws a pinched, bowtie-looking shape.
+ local m = math.min(w, h) / 2
+ if r > m then r = m end
+ cairo_new_path(cr)
+ cairo_arc(cr, x + w - r, y + r, r, -math.pi / 2, 0)
+ cairo_arc(cr, x + w - r, y + h - r, r, 0, math.pi / 2)
+ cairo_arc(cr, x + r, y + h - r, r, math.pi / 2, math.pi)
+ cairo_arc(cr, x + r, y + r, r, math.pi, math.pi * 1.5)
+ cairo_close_path(cr)
+end
+
+-- A card: filled rounded rect with a hairline border, matching the mockups.
+-- Returns the inner rect so a widget can lay out against padded bounds.
+function M.card(cr, rect, colors, pad)
+ pad = pad or 18
+ M.rounded_path(cr, rect.x, rect.y, rect.w, rect.h, 14)
+ M.rgba(cr, colors.surface, 0.55)
+ cairo_fill_preserve(cr)
+ -- 1px hairline. Cairo strokes astride the path, so a width of 1 on an integer
+ -- coordinate straddles two pixel rows and renders as a soft 2px line; the
+ -- cards read as thin outlines in the mockup, so keep it sub-pixel-crisp by
+ -- stroking at 1 and accepting the AA rather than offsetting by 0.5, which
+ -- would misalign the fill.
+ M.rgba(cr, colors.border, 0.9)
+ cairo_set_line_width(cr, 1)
+ cairo_stroke(cr)
+ return { x = rect.x + pad, y = rect.y + pad,
+ w = rect.w - pad * 2, h = rect.h - pad * 2 }
+end
+
+-- A small-caps section label, as used across both mockups.
+function M.label(cr, x, y, s, colors)
+ M.font(cr, M.FONT_MONO, 13, false)
+ M.rgba(cr, colors.label)
+ M.text(cr, x, y, s:upper())
+end
+
+return M
+```
+
+- [ ] **Step 2: Verify the file parses**
+
+Cairo functions are only defined inside Conky, so this cannot be unit-tested
+here; it can be checked for syntax errors.
+
+Run: `cd ~/Programming/GIT/conky-theme-udt && luac -p lib/card.lua && echo "SYNTAX OK"`
+
+Expected: `SYNTAX OK`
+
+If `luac` is not installed, use:
+`lua -e "assert(loadfile('lib/card.lua')); print('SYNTAX OK')"`
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+git add lib/card.lua
+git commit -m "feat: add Cairo card primitives
+
+Rounded-rect card with a hairline border, text helpers with measured
+alignment, and font selection.
+
+Cairo's font API stops at BOLD, so the Black face is reached by family
+name instead of weight: at size 90, '13' measures 100px as Noto Sans
+Black against 94px as Bold, confirming the heavier face is selected.
+
+The card radius is clamped to half the shorter side, because a larger
+radius makes the corner arcs overlap into a bowtie."
+```
+
+---
+
+### Task 4: Pixels on screen, with the error overlay first
+
+The `pcall` overlay comes before any widget, because from here on a Lua typo would otherwise present as an unexplained black screen.
+
+**Files:**
+- Create: `dashboard.lua`
+- Create: `conky.conf` (a hand-written dev config; the templated one arrives in Task 5)
+
+- [ ] **Step 1: Write the dev config**
+
+Create `conky.conf`. This is a temporary development config with literal
+colours, replaced by the rendered template in Task 5.
+
+```lua
+-- Development config with literal colours. Task 5 replaces this with the
+-- UDT-rendered conky.conf.in. Not the file the installed dashboard uses.
+conky.config = {
+ out_to_x = false,
+ out_to_wayland = true,
+ own_window = true,
+ own_window_type = 'normal',
+ own_window_class = 'conky-dash',
+ own_window_argb_visual = true,
+ own_window_argb_value = 200,
+ minimum_width = 1200,
+ minimum_height = 700,
+ double_buffer = true,
+ update_interval = 2,
+ total_run_times = 0,
+ draw_borders = false,
+ draw_shades = false,
+ override_utf8_locale = true,
+ lua_load = './dashboard.lua',
+ lua_draw_hook_post = 'main',
+ -- Literal dev palette, Catppuccin Macchiato values.
+ color1 = '#8aadf4', -- heading
+ color2 = '#a5adcb', -- label
+ color3 = '#494d64', -- rule / border
+ color4 = '#8bd5ca', -- value
+ color5 = '#c6a0f6', -- highlight
+ color6 = '#a6da95', -- ok
+ color7 = '#ed8796', -- critical
+ default_color = '#cad3f5', -- body
+}
+
+-- Empty: Cairo output covers conky.text entirely, verified by experiment.
+conky.text = [[]]
+```
+
+- [ ] **Step 2: Write dashboard.lua with the error overlay and a placeholder draw**
+
+Create `dashboard.lua`:
+
+```lua
+-- Conky Lua dashboard: entry point.
+--
+-- Drawn entirely with Cairo in lua_draw_hook_post; conky.text is empty because
+-- Cairo output covers it.
+
+-- Resolve requires relative to this file's directory, since Conky's working
+-- directory is wherever it was launched from, not the config's location.
+local here = debug.getinfo(1, 'S').source:match('^@(.*/)') or './'
+package.path = here .. '?.lua;' .. package.path
+
+require 'cairo'
+
+local card = require 'lib.card'
+
+-- === Layout ===============================================================
+-- Order and position are yours: edit this table, nothing else.
+-- col/row are grid cells, w/h span cells. Cell size is derived from the
+-- surface, so the same table works on a 2560x1080 and a 1920x1080 screen.
+local COLS, ROWS = 4, 2
+
+local layout = {
+ { widget = 'clock', col = 1, row = 1, w = 1, h = 2 },
+}
+-- ==========================================================================
+
+local GAP = 16 -- gap between cards, px
+local MARGIN = 28 -- outer margin, px
+
+-- #rrggbb -> {r, g, b} as 0-1 floats, which is what Cairo wants.
+-- UDT's gen_conky emits hex6, so the conversion lives here rather than in the
+-- other repo's renderer.
+local function hex(s, fallback)
+ local r, g, b = tostring(s or ''):match('^#?(%x%x)(%x%x)(%x%x)$')
+ if not r then return fallback or { 1, 0, 1 } end -- magenta: visibly wrong
+ return { tonumber(r, 16) / 255, tonumber(g, 16) / 255, tonumber(b, 16) / 255 }
+end
+
+-- Palette, read once per draw from the Conky colour slots the template filled.
+local function palette()
+ return {
+ heading = hex(conky_parse('${color1}')),
+ label = hex(conky_parse('${color2}')),
+ border = hex(conky_parse('${color3}')),
+ rule = hex(conky_parse('${color3}')),
+ value = hex(conky_parse('${color4}')),
+ highlight = hex(conky_parse('${color5}')),
+ ok = hex(conky_parse('${color6}')),
+ critical = hex(conky_parse('${color7}')),
+ body = hex(conky_parse('${color}')),
+ -- The card fill. Derived from the body background rather than given its own
+ -- role, so a scheme switch cannot leave the cards mismatched.
+ surface = hex(conky_parse('${color3}')),
+ }
+end
+
+-- Cell rect for a layout entry. 1-indexed cols and rows, as the table reads.
+local function rect_for(entry, sw, sh)
+ local cw = (sw - MARGIN * 2 - GAP * (COLS - 1)) / COLS
+ local ch = (sh - MARGIN * 2 - GAP * (ROWS - 1)) / ROWS
+ return {
+ x = MARGIN + (entry.col - 1) * (cw + GAP),
+ y = MARGIN + (entry.row - 1) * (ch + GAP),
+ w = cw * (entry.w or 1) + GAP * ((entry.w or 1) - 1),
+ h = ch * (entry.h or 1) + GAP * ((entry.h or 1) - 1),
+ }
+end
+
+-- Widget modules, loaded once and cached. A widget that fails to load must not
+-- take the frame down with it, so the require is wrapped.
+local widgets = {}
+local function widget(name)
+ if widgets[name] == nil then
+ local ok, mod = pcall(require, 'widgets.' .. name)
+ widgets[name] = ok and mod or false
+ if not ok then print('dashboard: cannot load widget ' .. name .. ': ' .. tostring(mod)) end
+ end
+ return widgets[name] or nil
+end
+
+-- Draw an error where the dashboard should be.
+--
+-- Conky reports a Lua fault as a blank screen with no message on any stream, so
+-- without this every mistake looks identical to "nothing ran".
+local function draw_error(cr, msg, sw)
+ cairo_select_font_face(cr, 'Inconsolata Nerd Font', CAIRO_FONT_SLANT_NORMAL,
+ CAIRO_FONT_WEIGHT_BOLD)
+ cairo_set_font_size(cr, 16)
+ cairo_set_source_rgba(cr, 0.93, 0.53, 0.59, 1) -- literal: the palette may be what failed
+ local y = 40
+ for line in tostring(msg):gmatch('[^\n]+') do
+ cairo_move_to(cr, 24, y)
+ cairo_show_text(cr, line)
+ y = y + 20
+ if y > 400 then break end
+ end
+ print('dashboard error: ' .. tostring(msg))
+end
+
+local function draw(cr, sw, sh, colors)
+ for _, entry in ipairs(layout) do
+ local w = widget(entry.widget)
+ if w then
+ w.draw(cr, rect_for(entry, sw, sh), colors)
+ else
+ -- Name the missing widget in place, rather than leaving a blank cell.
+ local r = rect_for(entry, sw, sh)
+ local inner = card.card(cr, r, colors)
+ card.label(cr, inner.x, inner.y + 16, 'missing: ' .. entry.widget, colors)
+ end
+ end
+end
+
+function conky_main()
+ if conky_window == nil then return end
+ local s = conky_surface()
+ if s == nil then return end
+ local cr = cairo_create(s)
+ local sw, sh = conky_window.width, conky_window.height
+ local ok, err = pcall(function()
+ draw(cr, sw, sh, palette())
+ end)
+ if not ok then draw_error(cr, err, sw) end
+ cairo_destroy(cr)
+end
+```
+
+- [ ] **Step 3: Run Conky in the foreground and confirm the placeholder card draws**
+
+The clock widget does not exist yet, so the expected result is one card reading
+`MISSING: CLOCK`. That proves the grid, the card primitive, the palette parse
+and the missing-widget path all work.
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+timeout 8 conky -c ./conky.conf
+```
+
+Expected: no Lua errors on stdout. Conky's own info lines about the Wayland
+session are normal.
+
+- [ ] **Step 4: Screenshot to verify it reached the screen**
+
+Remember fact 4: `grim` captures screen coordinates, so you must be on the
+dashboard's workspace. This script switches, captures, and switches back.
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+orig=$(hyprctl activeworkspace -j | jq -r .id)
+(conky -c ./conky.conf >/tmp/conky-dash.log 2>&1 &)
+sleep 3
+ws=$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|.workspace.id' | head -1)
+hyprctl dispatch workspace "$ws" >/dev/null; sleep 2
+g=$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' | head -1)
+grim -g "$g" /tmp/dash-task4.png && echo "captured $g"
+hyprctl dispatch workspace "$orig" >/dev/null
+pkill -f 'conky -c ./conky.conf'
+cat /tmp/conky-dash.log
+```
+
+Expected: `captured <geometry>`, and `/tmp/dash-task4.png` shows a single
+rounded card on a translucent dark background with the text `MISSING: CLOCK`.
+View it to confirm; a capture of the wallpaper means the workspace switch did
+not settle, so raise the `sleep` and retry.
+
+- [ ] **Step 5: Verify the error overlay actually works**
+
+This is the safety net for every later task, so prove it fires rather than
+assuming it does.
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+cp dashboard.lua /tmp/dashboard.lua.bak
+# Introduce a deliberate fault inside the draw path.
+sed -i 's|^ for _, entry in ipairs(layout) do| error("deliberate test fault")\n for _, entry in ipairs(layout) do|' dashboard.lua
+timeout 8 conky -c ./conky.conf 2>&1 | grep -m1 'dashboard error'
+cp /tmp/dashboard.lua.bak dashboard.lua
+```
+
+Expected: a line containing `dashboard error: ... deliberate test fault`. That
+confirms `pcall` catches the fault and reports it instead of blanking.
+
+Then confirm the restore worked: `grep -c 'deliberate test fault' dashboard.lua`
+must print `0`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+git add dashboard.lua conky.conf
+git commit -m "feat: draw to a Wayland Cairo surface with an error overlay
+
+Entry point, grid maths, palette parsing and widget dispatch.
+
+Uses conky_surface(), not cairo_xlib_surface_create: under
+out_to_wayland the Xlib drawable and visual are both nil, so the idiom
+every Conky-Lua tutorial uses cannot work here.
+
+The pcall overlay lands before any widget exists, because Conky reports
+a Lua fault as a blank screen with no message on any stream. Verified
+by injecting a fault and seeing it reported. A missing widget names
+itself in its own cell rather than leaving the cell empty.
+
+conky.conf here is a development config with literal colours; the
+templated one arrives with the UDT wiring."
+```
+
+---
+
+### Task 5: The UDT-templated config
+
+**Files:**
+- Create: `conky.conf.in`
+- Create: `.gitignore` (modify: add the rendered output)
+
+- [ ] **Step 1: Write the template**
+
+Create `conky.conf.in`. It is Task 4's dev config with the literal colours
+replaced by UDT placeholders. `@SCHEME@` is substituted by `gen_conky` too and
+serves as a marker of which scheme rendered the file.
+
+```lua
+-- Conky Lua dashboard, rendered by unified-desktop-theme.
+--
+-- Generated from conky.conf.in for scheme @SCHEME@. Do not edit the rendered
+-- conky.conf: the next install.sh overwrites it. Edit this template.
+conky.config = {
+ out_to_x = false,
+ out_to_wayland = true,
+ own_window = true,
+ -- 'normal', not 'desktop': a desktop-type window is a layer-surface at
+ -- level 0 and Hyprland cannot assign it to a workspace, which is the whole
+ -- point of this dashboard.
+ own_window_type = 'normal',
+ -- Distinct from the plain 'Conky' class used by any desktop-layer instance,
+ -- so the windowrules for one cannot match the other.
+ own_window_class = 'conky-dash',
+ own_window_argb_visual = true,
+ own_window_argb_value = 200,
+ minimum_width = 1200,
+ minimum_height = 700,
+ double_buffer = true,
+ update_interval = 2,
+ total_run_times = 0,
+ draw_borders = false,
+ draw_shades = false,
+ override_utf8_locale = true,
+ lua_load = '~/.config/conky/dashboard.lua',
+ lua_draw_hook_post = 'main',
+ color1 = '@HEADING@',
+ color2 = '@LABEL@',
+ color3 = '@RULE@',
+ color4 = '@VALUE@',
+ color5 = '@HIGHLIGHT@',
+ color6 = '@OK@',
+ color7 = '@CRITICAL@',
+ default_color = '@BODY@',
+ default_outline_color = '@BODY_OUTLINE@',
+ default_shade_color = '@BODY_SHADE@',
+}
+
+-- Empty by design: Cairo output covers conky.text.
+conky.text = [[]]
+```
+
+- [ ] **Step 2: Add the rendered output to .gitignore**
+
+The repo already ignores `HANDOFF.md` and `weather.env`. Append the rendered
+config, for the same reason UDT ignores its generated files: tracking it would
+turn every scheme switch into a diff.
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+printf 'conky.conf\n' >> .gitignore
+```
+
+Note this also un-tracks the Task 4 dev config, which is intended: it was
+scaffolding. Remove it from the index:
+
+```bash
+git rm --cached conky.conf
+```
+
+- [ ] **Step 3: Verify the template renders**
+
+`gen_conky` does not know about `@CRITICAL@` yet, so a render now must leave it
+unsubstituted. Confirm that, so Task 9's change is demonstrably necessary rather
+than assumed.
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+grep -c '@CRITICAL@' conky.conf.in
+```
+
+Expected: `1`.
+
+- [ ] **Step 4: Verify the template is valid Lua once substituted**
+
+A placeholder is not valid Lua, so check a substituted copy rather than the
+template itself.
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+sed -e 's/@SCHEME@/macchiato/' -e 's/@HEADING@/#8aadf4/' -e 's/@LABEL@/#a5adcb/' \
+ -e 's/@RULE@/#494d64/' -e 's/@VALUE@/#8bd5ca/' -e 's/@HIGHLIGHT@/#c6a0f6/' \
+ -e 's/@OK@/#a6da95/' -e 's/@CRITICAL@/#ed8796/' -e 's/@BODY@/#cad3f5/' \
+ -e 's/@BODY_OUTLINE@/#494d64/' -e 's/@BODY_SHADE@/#1e2030/' \
+ conky.conf.in > /tmp/conky-render-test.conf
+lua -e "assert(loadfile('/tmp/conky-render-test.conf')); print('TEMPLATE OK')"
+```
+
+Expected: `TEMPLATE OK`
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+git add conky.conf.in .gitignore
+git commit -m "feat: add the UDT-templated conky config
+
+Same settings as the dev config with the colours as @ROLE@
+placeholders, plus @CRITICAL@ for the error overlay, which gen_conky
+does not substitute yet.
+
+The rendered conky.conf is gitignored for the reason UDT ignores its
+generated files: tracking it turns every scheme switch into a diff."
+```
+
+---
+
+### Task 6: The clock widget
+
+**Files:**
+- Create: `widgets/clock.lua`
+
+- [ ] **Step 1: Write the widget**
+
+Shaped after `idea2.png`: hour stacked over minute in a very heavy face,
+left-aligned and tightly leaded, then the weekday in small caps, then the date
+with slash separators.
+
+Create `widgets/clock.lua`:
+
+```lua
+-- Clock: hour stacked over minute, weekday, slashed date.
+--
+-- Shape follows idea2.png; every colour comes from the palette.
+
+local card = require 'lib.card'
+
+local M = {}
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+
+ -- Numeral size is derived from the cell, not fixed, so the same widget fills
+ -- a 1x2 cell on either monitor instead of needing a per-screen constant.
+ -- Two stacked numerals plus the date block: allow 40% of the height each.
+ local size = math.min(inner.h * 0.40, inner.w * 0.95)
+
+ card.font(cr, card.FONT_HEAVY, size, false)
+ card.rgba(cr, colors.body)
+
+ -- Baselines. Cairo's y is the baseline, so the first sits one cap-height
+ -- down; 0.78 of the font size approximates cap height for Noto Sans and
+ -- avoids measuring every frame.
+ local x = inner.x
+ local y1 = inner.y + size * 0.78
+ -- Tight leading, as in the mockup: the numerals nearly touch.
+ local y2 = y1 + size * 0.92
+
+ card.text(cr, x, y1, os.date('%H'))
+ card.text(cr, x, y2, os.date('%M'))
+
+ -- Weekday, small caps.
+ card.font(cr, card.FONT_UI, 15, true)
+ card.rgba(cr, colors.body)
+ local wy = y2 + 34
+ card.text(cr, x, wy, os.date('%A'):upper())
+
+ -- Date as "16 / SEP / 2026". The slashes take the dimmer label colour so the
+ -- numerals read first, which is what gives the mockup's date line its rhythm.
+ card.font(cr, card.FONT_UI, 14, true)
+ local dy = wy + 24
+ local parts = {
+ { os.date('%d'), colors.value },
+ { ' / ', colors.label },
+ { os.date('%b'):upper(), colors.value },
+ { ' / ', colors.label },
+ { os.date('%Y'), colors.value },
+ }
+ local dx = x
+ for _, p in ipairs(parts) do
+ card.rgba(cr, p[2])
+ card.text(cr, dx, dy, p[1])
+ dx = dx + card.measure(cr, p[1])
+ end
+end
+
+return M
+```
+
+- [ ] **Step 2: Verify syntax**
+
+Run: `cd ~/Programming/GIT/conky-theme-udt && lua -e "assert(loadfile('widgets/clock.lua')); print('SYNTAX OK')"`
+
+Expected: `SYNTAX OK`
+
+- [ ] **Step 3: Render and screenshot**
+
+Regenerate the dev config, since Task 5 removed it from tracking but it is
+still the fastest way to run this standalone:
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+sed -e 's/@SCHEME@/macchiato/' -e 's/@HEADING@/#8aadf4/' -e 's/@LABEL@/#a5adcb/' \
+ -e 's/@RULE@/#494d64/' -e 's/@VALUE@/#8bd5ca/' -e 's/@HIGHLIGHT@/#c6a0f6/' \
+ -e 's/@OK@/#a6da95/' -e 's/@CRITICAL@/#ed8796/' -e 's/@BODY@/#cad3f5/' \
+ -e 's/@BODY_OUTLINE@/#494d64/' -e 's/@BODY_SHADE@/#1e2030/' \
+ -e "s|~/.config/conky/dashboard.lua|./dashboard.lua|" \
+ conky.conf.in > conky.conf
+
+orig=$(hyprctl activeworkspace -j | jq -r .id)
+(conky -c ./conky.conf >/tmp/conky-dash.log 2>&1 &)
+sleep 3
+ws=$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|.workspace.id' | head -1)
+hyprctl dispatch workspace "$ws" >/dev/null; sleep 2
+g=$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' | head -1)
+grim -g "$g" /tmp/dash-clock.png && echo "captured $g"
+hyprctl dispatch workspace "$orig" >/dev/null
+pkill -f 'conky -c ./conky.conf'
+cat /tmp/conky-dash.log
+```
+
+Expected: no `dashboard error` in the log, and `/tmp/dash-clock.png` shows the
+current hour above the current minute in a heavy face, the weekday in caps, and
+the slashed date. View the image and compare against `idea2.png` for shape.
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+git add widgets/clock.lua
+git commit -m "feat: add the clock widget
+
+Hour stacked over minute in Noto Sans Black, weekday in caps, date
+slashed, following idea2.png. Colours are the palette's.
+
+Numeral size derives from the cell rather than a constant, so the same
+widget fills its cell on either monitor. The slashes take the dimmer
+label colour so the numerals read first."
+```
+
+---
+
+### Task 7: Layout sanity check across both screen sizes
+
+The grid's entire justification is that one table works on both monitors. Verify that rather than trusting it.
+
+**Files:**
+- Create: `test/test_layout.lua`
+- Modify: `dashboard.lua` (expose `rect_for` for testing)
+
+- [ ] **Step 1: Expose the grid maths**
+
+`rect_for` is a local, so the test cannot reach it. Export it on a table without
+changing how `conky_main` uses it. In `dashboard.lua`, immediately after the
+`rect_for` function definition, add:
+
+```lua
+-- Exported for test/test_layout.lua. The grid's whole claim is that one layout
+-- table works on differently shaped screens, which is worth checking.
+conky_dashboard_internal = { rect_for = rect_for, COLS = COLS, ROWS = ROWS,
+ GAP = GAP, MARGIN = MARGIN }
+```
+
+- [ ] **Step 2: Write the test**
+
+Create `test/test_layout.lua`:
+
+```lua
+-- Grid maths check.
+-- Run from the repo root: lua test/test_layout.lua
+--
+-- dashboard.lua requires cairo, which only exists inside Conky, so stub the
+-- pieces it touches at load time before requiring it.
+package.path = './?.lua;' .. package.path
+package.preload['cairo'] = function() return {} end
+package.preload['lib.card'] = function() return {} end
+conky_window = nil
+function conky_parse(s) return '#000000' end
+
+dofile('dashboard.lua')
+local G = conky_dashboard_internal
+assert(G, 'dashboard.lua must export conky_dashboard_internal')
+
+-- Every cell must land inside the surface, on both real monitors.
+for _, screen in ipairs({ { 2560, 1080 }, { 1920, 1080 } }) do
+ local sw, sh = screen[1], screen[2]
+ for col = 1, G.COLS do
+ for row = 1, G.ROWS do
+ local r = G.rect_for({ col = col, row = row, w = 1, h = 1 }, sw, sh)
+ assert(r.x >= G.MARGIN - 0.01,
+ ('x underflows margin at %dx%d col %d'):format(sw, sh, col))
+ assert(r.y >= G.MARGIN - 0.01,
+ ('y underflows margin at %dx%d row %d'):format(sw, sh, row))
+ assert(r.x + r.w <= sw - G.MARGIN + 0.01,
+ ('cell overflows width at %dx%d col %d: x=%f w=%f'):format(sw, sh, col, r.x, r.w))
+ assert(r.y + r.h <= sh - G.MARGIN + 0.01,
+ ('cell overflows height at %dx%d row %d'):format(sw, sh, row))
+ assert(r.w > 0 and r.h > 0, 'cell must have positive size')
+ end
+ end
+end
+
+-- A spanning cell must cover its cells plus the gap between them, so two
+-- side-by-side 1-wide cards and one 2-wide card occupy the same pixels.
+local a = G.rect_for({ col = 1, row = 1, w = 1, h = 1 }, 2560, 1080)
+local b = G.rect_for({ col = 2, row = 1, w = 1, h = 1 }, 2560, 1080)
+local span = G.rect_for({ col = 1, row = 1, w = 2, h = 1 }, 2560, 1080)
+assert(math.abs((b.x + b.w) - (span.x + span.w)) < 0.01,
+ ('a 2-wide card must end where the second 1-wide card ends: %f vs %f')
+ :format(b.x + b.w, span.x + span.w))
+assert(math.abs(span.w - (a.w * 2 + G.GAP)) < 0.01, 'span must absorb the gap')
+
+-- The shipped layout must not place anything outside the declared grid, which
+-- is the mistake a user editing the table will actually make.
+for _, e in ipairs(G.layout or {}) do
+ assert(e.col >= 1 and e.col + (e.w or 1) - 1 <= G.COLS,
+ 'layout entry out of columns: ' .. tostring(e.widget))
+ assert(e.row >= 1 and e.row + (e.h or 1) - 1 <= G.ROWS,
+ 'layout entry out of rows: ' .. tostring(e.widget))
+end
+
+print('test_layout: all assertions passed')
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_layout.lua`
+
+Expected: failure. The last block reads `G.layout`, which Step 1 did not export.
+
+- [ ] **Step 4: Export the layout too**
+
+In `dashboard.lua`, change the exported table to include the layout:
+
+```lua
+conky_dashboard_internal = { rect_for = rect_for, COLS = COLS, ROWS = ROWS,
+ GAP = GAP, MARGIN = MARGIN, layout = layout }
+```
+
+- [ ] **Step 5: Run both tests to verify they pass**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+lua test/test_data.lua && lua test/test_layout.lua
+```
+
+Expected:
+```
+test_data: all assertions passed
+test_layout: all assertions passed
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+git add dashboard.lua test/test_layout.lua
+git commit -m "test: check grid maths on both real screen sizes
+
+The grid's justification is that one layout table works on differently
+shaped screens, so assert it: every cell lands inside the margins at
+2560x1080 and 1920x1080, a spanning cell absorbs the gap it covers, and
+no shipped layout entry falls outside the declared grid.
+
+dashboard.lua exports its internals for this; cairo and lib.card are
+stubbed via package.preload since they only exist inside Conky."
+```
+
+---
+
+### Task 8: Hyprland placement and the toggle
+
+**Files:**
+- Create: `hypr/dashboard.conf`
+
+- [ ] **Step 1: Write the Hyprland config fragment**
+
+Create `hypr/dashboard.conf`:
+
+```bash
+# Conky Lua dashboard: window placement and toggle.
+#
+# Source this from hyprland.conf:
+# source = ~/Programming/GIT/conky-theme-udt/hypr/dashboard.conf
+#
+# The dashboard is a normal toplevel, not a desktop-layer surface, which is what
+# lets these rules put it on a workspace at all.
+
+# Pin it to its own special workspace. `silent` keeps launching it from stealing
+# focus, since it starts with the session rather than on request.
+windowrulev2 = workspace special:dash silent, class:^(conky-dash)$
+windowrulev2 = float, class:^(conky-dash)$
+windowrulev2 = fullscreen, class:^(conky-dash)$
+windowrulev2 = noborder, class:^(conky-dash)$
+windowrulev2 = noshadow, class:^(conky-dash)$
+# It is a dashboard, not a window: never let it take focus or be tabbed to.
+windowrulev2 = nofocus, class:^(conky-dash)$
+
+# Toggle. Super+D shows and hides the special workspace.
+bind = SUPER, D, togglespecialworkspace, dash
+
+# Always running, started with the session: hiding is a workspace switch, so
+# showing is instant and any graph history survives. Draws are not skipped while
+# hidden; that optimisation was rejected as unmeasured.
+exec-once = conky -c ~/.config/conky/conky.conf
+```
+
+- [ ] **Step 2: Verify the rules match the real window**
+
+Do not assume the class matches; check it against a running instance.
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+(conky -c ./conky.conf >/tmp/conky-dash.log 2>&1 &)
+sleep 3
+hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"class=\(.class) ws=\(.workspace.name) floating=\(.floating) size=\(.size)"'
+pkill -f 'conky -c ./conky.conf'
+```
+
+Expected: one line with `class=conky-dash`. If it prints nothing, the class in
+the config and the rules disagree and the rules will never fire.
+
+- [ ] **Step 3: Verify the waybar launcher command**
+
+The waybar module lives in the UDT repo, so this task only confirms the command
+the user will bind. Run it and check the workspace toggles:
+
+```bash
+hyprctl dispatch togglespecialworkspace dash
+sleep 1
+hyprctl activeworkspace -j | jq -r .name
+hyprctl dispatch togglespecialworkspace dash
+```
+
+Expected: the middle command prints `special:dash`, then the state returns.
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+git add hypr/dashboard.conf
+git commit -m "feat: add Hyprland placement rules and toggle
+
+Pins conky-dash to a special workspace, fullscreen, no border, no
+focus, and binds Super+D to toggle it. Sourced from hyprland.conf
+rather than edited into it.
+
+nofocus and silent matter because the dashboard starts with the session:
+without them it steals focus at login and can be tabbed to like an app."
+```
+
+---
+
+### Task 9: Switch UDT over
+
+Last, so no intermediate state touches the working desktop. This modifies the other repo.
+
+**Files:**
+- Modify: `~/Programming/GIT/unified-desktop-theme/bin/udt-palette:545-555` and its `TARGETS` entry at line 654
+- Modify: `~/Programming/GIT/unified-desktop-theme/install.sh:178-207`
+- Delete: `~/Programming/GIT/unified-desktop-theme/templates/conky.conf.in`
+
+- [ ] **Step 1: Read the current state of both files**
+
+Do not edit from this plan's quoted line numbers alone; confirm them first.
+
+```bash
+cd ~/Programming/GIT/unified-desktop-theme
+sed -n '540,560p' bin/udt-palette
+sed -n '645,670p' bin/udt-palette
+sed -n '175,210p' install.sh
+```
+
+- [ ] **Step 2: Add the critical role to gen_conky**
+
+In `bin/udt-palette`, replace the `gen_conky` function (around line 545) with:
+
+```python
+def gen_conky(res, scheme, palette, template):
+ """conky: substitute into the dashboard template.
+
+ The template lives in the conky-theme-udt repo, which owns the Lua
+ dashboard; this only fills in the colours. `critical` is included for the
+ dashboard's error overlay, which draws a caught Lua error on screen because
+ conky otherwise reports one as a blank window.
+ """
+ out = template.replace("@SCHEME@", scheme)
+ for role in ("heading", "label", "rule", "value", "highlight", "ok",
+ "body", "body_outline", "body_shade", "critical"):
+ out = out.replace(f"@{role.upper()}@", hex6(res[role]))
+ return out
+```
+
+- [ ] **Step 3: Point the target at this repo**
+
+In `bin/udt-palette`, the `TARGETS` list entry at line 654 currently reads:
+
+```python
+ ("templates/conky.conf", gen_conky, "templates/conky.conf.in"),
+```
+
+The template and its output now live in the other repo. Paths in `TARGETS` are
+relative to the UDT repo root, so this needs the dashboard repo's location.
+Add near the top of the file, after the other module-level constants:
+
+```python
+# The Lua dashboard lives in its own repo; UDT only renders its colours.
+CONKY_REPO = Path("~/Programming/GIT/conky-theme-udt").expanduser()
+```
+
+and change the `TARGETS` entry to:
+
+```python
+ (str(CONKY_REPO / "conky.conf"), gen_conky, str(CONKY_REPO / "conky.conf.in")),
+```
+
+Confirm `Path` is already imported; if not, add `from pathlib import Path` to
+the imports.
+
+- [ ] **Step 4: Run the palette selftest**
+
+This is the check that a scheme can satisfy every role, so it catches a missing
+`critical` in any of the nine schemes.
+
+```bash
+cd ~/Programming/GIT/unified-desktop-theme
+./bin/udt-palette --selftest
+```
+
+Expected: a pass for every scheme. A failure naming `critical` means that
+scheme's `roles-*.conf` lacks the role under `[state]`; add it there.
+
+- [ ] **Step 5: Render and confirm the placeholders are gone**
+
+```bash
+cd ~/Programming/GIT/unified-desktop-theme
+./bin/udt-palette
+grep -c '@[A-Z_]*@' ~/Programming/GIT/conky-theme-udt/conky.conf
+```
+
+Expected: `0`. Any remaining placeholder is a role `gen_conky` does not
+substitute.
+
+- [ ] **Step 6: Update install.sh**
+
+In `install.sh`, the conky block around line 182 currently reads:
+
+```bash
+mkdir -p "$HOME/.config/conky"
+ln -sfn "$repo/templates/conky.conf" "$HOME/.config/conky/conky.conf"
+```
+
+Replace with:
+
+```bash
+# The Lua dashboard lives in its own repo; udt-palette renders its colours into
+# conky.conf there. Both the config and the Lua it loads are linked, since
+# lua_load points at ~/.config/conky/dashboard.lua.
+conky_repo="$HOME/Programming/GIT/conky-theme-udt"
+mkdir -p "$HOME/.config/conky"
+ln -sfn "$conky_repo/conky.conf" "$HOME/.config/conky/conky.conf"
+ln -sfn "$conky_repo/dashboard.lua" "$HOME/.config/conky/dashboard.lua"
+ln -sfn "$conky_repo/lib" "$HOME/.config/conky/lib"
+ln -sfn "$conky_repo/widgets" "$HOME/.config/conky/widgets"
+```
+
+- [ ] **Step 7: Delete the old template**
+
+```bash
+cd ~/Programming/GIT/unified-desktop-theme
+git rm templates/conky.conf.in
+# The rendered output is gitignored, so remove it from disk only.
+rm -f templates/conky.conf
+```
+
+- [ ] **Step 8: Run the full install and confirm the dashboard comes up**
+
+```bash
+cd ~/Programming/GIT/unified-desktop-theme
+./install.sh
+sleep 3
+hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"class=\(.class) ws=\(.workspace.name)"'
+```
+
+Expected: the install reports conky among what it reloaded, and the window
+appears with `class=conky-dash`.
+
+If nothing appears, run it in the foreground to see the Lua error, which is the
+only way it will surface:
+`timeout 8 conky -c ~/.config/conky/conky.conf`
+
+- [ ] **Step 9: Verify a scheme switch still recolours it**
+
+This is what the whole templating exists for, so prove it end to end.
+
+```bash
+grep '^scheme' ~/.config/udt/roles.conf # note the current value
+sed -i 's/^scheme = .*/scheme = nord/' ~/.config/udt/roles.conf
+cd ~/Programming/GIT/unified-desktop-theme && ./install.sh >/dev/null
+grep -E 'color1|default_color' ~/Programming/GIT/conky-theme-udt/conky.conf
+```
+
+Expected: Nord hex values, different from the Macchiato ones. Then restore the
+original scheme and re-run `./install.sh`.
+
+- [ ] **Step 10: Commit both repos**
+
+```bash
+cd ~/Programming/GIT/unified-desktop-theme
+git add bin/udt-palette install.sh
+git commit -m "refactor: render the conky template from the dashboard repo
+
+The Lua dashboard lives in conky-theme-udt now, so udt-palette renders
+that repo's conky.conf.in instead of a local template and install.sh
+links the Lua alongside the config.
+
+gen_conky also substitutes critical, for the dashboard's error overlay:
+conky reports a Lua fault as a blank window, so the dashboard catches
+it and draws the message instead.
+
+The old variables-and-execi template is deleted. Its hardware discovery
+was not lost; the hwmon glob-by-name approach is carried into
+lib/data.lua deliberately, since fixed indices drift across kernel
+reorders."
+```
+
+---
+
+### Task 10: README
+
+**Files:**
+- Create: `README.md`
+
+- [ ] **Step 1: Write it**
+
+Create `README.md`:
+
+```markdown
+# conky-theme-udt
+
+A fullscreen Conky dashboard for Hyprland, drawn with Cairo from Lua, living on
+a special workspace that `SUPER+D` toggles.
+
+Colours come from [unified-desktop-theme](../unified-desktop-theme): this repo
+holds `conky.conf.in`, and UDT's `bin/udt-palette` renders it into `conky.conf`
+using the current scheme's `[conky]` roles. Switching scheme recolours the
+dashboard.
+
+## Install
+
+ cd ../unified-desktop-theme && ./install.sh
+
+That renders `conky.conf` and links it, `dashboard.lua`, `lib/` and `widgets/`
+into `~/.config/conky/`. Then source the Hyprland rules once, from
+`hyprland.conf`:
+
+ source = ~/Programming/GIT/conky-theme-udt/hypr/dashboard.conf
+
+## Changing the layout
+
+Edit the `layout` table at the top of `dashboard.lua`. `col`/`row` are grid
+cells and `w`/`h` span them; cell size is derived from the screen, so the same
+table works on differently shaped monitors.
+
+ local layout = {
+ { widget = 'clock', col = 1, row = 1, w = 1, h = 2 },
+ }
+
+Reordering widgets is an edit to that table and nothing else: a widget is handed
+a rectangle and draws inside it, so it cannot care where it is. Adding one is a
+file in `widgets/` exporting `draw(cr, rect, colors)` plus a row in the table.
+
+`COLS` and `ROWS` above the table set the grid. Cards snap to it; there is no
+absolute pixel placement, deliberately, because cells keep aligning when the
+grid or the screen changes.
+
+## Development
+
+Run it in the foreground to see Lua output, which is the only debugging channel:
+
+ conky -c ~/.config/conky/conky.conf
+
+Run the parser and layout checks:
+
+ lua test/test_data.lua && lua test/test_layout.lua
+
+Screenshot it, remembering that `grim` captures screen coordinates and so needs
+the dashboard's workspace to be the active one:
+
+ hyprctl dispatch togglespecialworkspace dash
+ sleep 1
+ grim -g "$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')" /tmp/dash.png
+
+## Gotchas worth knowing
+
+**Cairo on Wayland needs `conky_surface()`.** Under `out_to_wayland`,
+`conky_window.drawable` and `.visual` are both `nil`, so the
+`cairo_xlib_surface_create` idiom used by essentially every Conky-Lua tutorial
+cannot work here.
+
+**A Lua error is a blank screen and nothing else.** No stderr, no log. The draw
+runs inside `pcall` and paints the caught error on the surface, which is the
+only reason a mistake is visible at all.
+
+**`own_window_type` must be `normal`.** A `desktop`-type window is a
+layer-surface at level 0 and Hyprland cannot assign it to a workspace.
+
+**Cairo has no Black font weight.** `CAIRO_FONT_WEIGHT_BOLD` is the maximum, so
+the heavy clock numerals ask for the family `Noto Sans Black` at normal weight.
+
+**Sensors are globbed by hwmon `name`, never by index.** Indices drift across
+kernel and hardware reorders, and a stale one silently reports a different chip.
+
+## 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 2: Verify the commands in it actually work**
+
+A README whose commands fail is worse than none. Run the two test commands and
+the foreground command from it.
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+lua test/test_data.lua && lua test/test_layout.lua
+timeout 5 conky -c ~/.config/conky/conky.conf 2>&1 | grep -c 'dashboard error' || true
+```
+
+Expected: both tests pass, and the `grep -c` prints `0`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd ~/Programming/GIT/conky-theme-udt
+git add README.md
+git commit -m "docs: add README
+
+Install, how to edit the layout table, how to run the checks, and the
+four gotchas that cost real time: conky_surface() on Wayland, a Lua
+error presenting as a blank screen, own_window_type needing to be
+normal, and Cairo having no Black font weight."
+```
+
+---
+
+## Verification checklist
+
+Run after Task 10. Every item is a command with an expected result, not a judgement.
+
+- [ ] Parsers pass: `lua test/test_data.lua` prints `test_data: all assertions passed`
+- [ ] Layout passes: `lua test/test_layout.lua` prints `test_layout: all assertions passed`
+- [ ] No unsubstituted placeholders: `grep -c '@[A-Z_]*@' conky.conf` prints `0`
+- [ ] No Lua faults: `timeout 8 conky -c ~/.config/conky/conky.conf 2>&1 | grep 'dashboard error'` prints nothing
+- [ ] Window places correctly: `hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|.workspace.name'` prints `special:dash`
+- [ ] Toggle works: `hyprctl dispatch togglespecialworkspace dash` makes it visible, again hides it
+- [ ] Clock is legible in a screenshot taken on its own workspace, and matches `idea2.png` in shape
+- [ ] Scheme switch recolours it: change `scheme` in `~/.config/udt/roles.conf`, run UDT's `install.sh`, see different hex in `conky.conf`
+- [ ] Both repos have signed commits: `git log --format='%h %G? %s' -5` shows `G` in each
+
+## What this plan does not build
+
+From the spec, deliberately deferred. None of it blocks the v1 slice.
+
+- The system, weather, network and media widgets. Each is one file in `widgets/`
+ plus one row in the layout table, which is what Tasks 4-7 exist to make true.
+- The weather fetch script and `~/.config/udt/weather.env`. The spec settles the
+ source (OpenWeatherMap) and the condition mapping to port.
+- Album art via Imlib2, which still needs a check that the bindings work on a
+ Wayland `conky_surface()`.
+- The waybar launcher module, which belongs in the UDT repo next to the rest of
+ the waybar config.
+- Per-monitor layouts, absolute placement, click interaction.