aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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.