aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-09-17-system-widgets.md1607
1 files changed, 1607 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-17-system-widgets.md b/docs/superpowers/plans/2026-09-17-system-widgets.md
new file mode 100644
index 0000000..2ba13f8
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-17-system-widgets.md
@@ -0,0 +1,1607 @@
+# System Widgets 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:** Four dashboard cards covering the machine: CPU load with a per-core equaliser, memory and every temperature; the GPU; filesystem capacity as rings; and the user cache.
+
+**Architecture:** Parsers extend `lib/data.lua` in its existing string-in/table-out shape. Two sampler scripts write cache files on conky's `${execi}` schedule so the draw hook never runs a subprocess. Shared drawing primitives (`bar`, `ring`, `threshold`) go in `lib/card.lua` so all four cards apply one colour language. A new `warning` palette role supplies the middle threshold colour.
+
+**Tech Stack:** Lua 5.4 + Cairo via conky's bindings; bash + coreutils for the samplers. No new dependency.
+
+**Spec:** `docs/superpowers/specs/2026-09-17-system-widgets-design.md`
+
+---
+
+## Critical platform facts
+
+Read these before touching anything. Each cost time to learn here.
+
+- **A Lua error is a blank screen.** Conky reports a Lua fault on no stream. The `pcall` overlay in `dashboard.lua` is the only reason failures are visible. Every parser returns nil or an empty table rather than raising.
+- **`lib/weather.lua` is raise-free and verified so.** Match that: guard every argument you do arithmetic on, including the ones "no caller can get wrong". Three such bugs were found and fixed there under review.
+- **`card.measure()` is ink width, `card.advance()` is cursor movement.** Not interchangeable. `measure` to centre or right-align, `advance` to step along a run of text.
+- **Never call `cairo_text_extents_t:create()`.** It leaks ~182KB per 5000 calls, unreclaimable. `lib/card.lua` owns one reused struct.
+- **`${execi}` fires even though `conky.text` renders nothing**, which is what schedules the samplers. Verified with a probe config.
+- **Widget modules are cached after first `require`.** Editing a widget needs `./restart.sh`; editing only `dashboard.lua` does not.
+- **`df` is aliased to `df -h` in the user's shell.** A sampler calling bare `df` gets human-readable sizes and a parser expecting bytes silently mis-reads every figure. Call `/usr/bin/df -P -B1` by absolute path.
+- **hwmon is globbed by its `name` file, never by index.** `lib/data.lua` has `hwmon_dir()` for this.
+- **Conky never rereads its config.** A `conky.conf.in` change needs UDT's `install.sh` to re-render.
+
+## Offscreen rendering
+
+Conky's Cairo bindings load in plain Lua, so a widget can be drawn to a PNG without conky. This turns a four-step verify loop into one command and is how the weather card's proportions were fixed. A harness exists at `test/render.lua` after Task 2.
+
+ lua test/render.lua <widget> <cols> <rows> <wspan> <hspan> <out.png>
+
+It crops to the card, so check the live dashboard before concluding anything about size relative to neighbouring cards.
+
+---
+
+## File structure
+
+| Path | Responsibility |
+|---|---|
+| `lib/data.lua` | + `per_cpu_times`, `sensor`, `df_parse`, `du_parse`. Parsing only; no drawing, no host bindings. |
+| `lib/card.lua` | + `bar`, `ring`, `threshold`. Drawing primitives; no data, no thresholds of its own. |
+| `test/render.lua` | Offscreen widget renderer. Test tooling, not shipped code. |
+| `bin/disks-sample.sh` | `df` to a cache file. |
+| `bin/cache-sample.sh` | `du` to a cache file. |
+| `widgets/system.lua` | CPU equaliser, RAM, all temperatures. Owns its hwmon bindings and ceilings. |
+| `widgets/gpu.lua` | Arc B580 temps and fan. Owns its bindings. |
+| `widgets/disks.lua` | Filesystem rings. |
+| `widgets/cache.lua` | Cache total and top four. |
+
+Modified: `conky.conf.in` (two `${execi}`, one `@WARNING@`), `dashboard.lua` (palette role, layout rows), `README.md`, and UDT's `bin/udt-palette` (one word).
+
+---
+
+## Task 1: The warning palette role
+
+The middle threshold colour, needed by every later task. Touches the UDT repo.
+
+**Files:**
+- Modify: `../unified-desktop-theme/bin/udt-palette`
+- Modify: `conky.conf.in`
+- Modify: `dashboard.lua`
+
+- [ ] **Step 1: Add the role to gen_conky**
+
+In `../unified-desktop-theme/bin/udt-palette`, find `def gen_conky` (around line 548). Its loop lists the roles it substitutes. Add `"warning"`:
+
+```python
+ for role in ("heading", "label", "rule", "value", "highlight", "ok",
+ "body", "body_outline", "body_shade", "critical", "warning"):
+```
+
+`res['warning']` already resolves in every scheme (udt-palette maps `yellow`, and Nord's `aurora_yellow`, to it globally) and other generators already read it, so no scheme file changes.
+
+- [ ] **Step 2: Add the placeholder to the template**
+
+In `conky.conf.in`, after the `color7` line:
+
+```lua
+ color8 = '@WARNING@',
+```
+
+- [ ] **Step 3: Expose it in the palette**
+
+In `dashboard.lua`'s `palette()` function, after the `critical` line:
+
+```lua
+ warning = hex(CFG.color8),
+```
+
+- [ ] **Step 4: Render and verify the colour arrives**
+
+```bash
+cd ../unified-desktop-theme && ./install.sh >/dev/null 2>&1
+grep color8 ~/.config/conky/conky.conf
+```
+
+Expected: `color8 = '#e0af68'` (tokyo-night's yellow) or the current scheme's equivalent. If it is `@WARNING@` unsubstituted, Step 1 did not take.
+
+- [ ] **Step 5: Verify it survives a scheme switch**
+
+```bash
+cd ../unified-desktop-theme
+for s in dracula nord mocha; do
+ python3 bin/udt-palette --scheme $s --print-role warning 2>/dev/null \
+ || echo "$s: check manually"
+done
+```
+
+If `--print-role` does not exist, instead confirm by eye that `palette/<scheme>.conf` defines a yellow (Nord calls it `aurora_yellow`). The point is that no scheme is missing the colour.
+
+- [ ] **Step 6: Commit**
+
+Two repos, two commits.
+
+```bash
+cd ../unified-desktop-theme
+git add bin/udt-palette
+git commit -m "feat: substitute the warning role into the conky template"
+cd -
+git add conky.conf.in dashboard.lua
+git commit -m "feat: expose the warning palette role"
+```
+
+---
+
+## Task 2: The offscreen render harness
+
+Test tooling, built before the widgets so every later task can check its work cheaply.
+
+**Files:**
+- Create: `test/render.lua`
+
+- [ ] **Step 1: Write the harness**
+
+Create `test/render.lua`:
+
+```lua
+-- Render a widget to a PNG without conky.
+--
+-- Conky ships its Cairo bindings as a loadable module, so a widget's draw()
+-- can be called against an image surface from plain Lua. That turns
+-- edit-install-restart-toggle-screenshot into one command, which is how the
+-- weather card's proportions were fixed.
+--
+-- lua test/render.lua weather 16 12 3 5 /tmp/w.png
+--
+-- Caveat: this crops to the single card. It says nothing about how the card
+-- looks NEXT to its neighbours, which is a real failure mode (the weather
+-- card's type once looked fine here and tiny beside the clock). Check the
+-- live dashboard before believing anything about relative size.
+
+package.cpath = '/usr/lib64/conky/lib?.so;' .. package.cpath
+package.path = './?.lua;' .. package.path
+require 'cairo'
+
+local widget_name = arg[1] or error('usage: render.lua <widget> <cols> <rows> <w> <h> <out.png>')
+local COLS = tonumber(arg[2]) or 8
+local ROWS = tonumber(arg[3]) or 6
+local wspan = tonumber(arg[4]) or 2
+local hspan = tonumber(arg[5]) or 3
+local out = arg[6] or '/tmp/widget.png'
+
+local GAP, MARGIN = 16, 28
+local sw, sh = 2560, 1080
+
+-- The palette, read from the rendered conky.conf exactly as dashboard.lua
+-- does, so the PNG uses the real scheme rather than invented colours.
+local function config_colors()
+ local path = os.getenv('HOME') .. '/.config/conky/conky.conf'
+ local f = io.open(path, 'r')
+ if not f then return {} end
+ local src = f:read('*a')
+ f:close()
+ local c = {}
+ for k, v in src:gmatch("([%w_]+)%s*=%s*'(#%x%x%x%x%x%x)'") do c[k] = v end
+ return c
+end
+
+local CFG = config_colors()
+
+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
+ return { tonumber(r, 16) / 255, tonumber(g, 16) / 255, tonumber(b, 16) / 255 }
+end
+
+local colors = {
+ heading = hex(CFG.color1), label = hex(CFG.color2),
+ border = hex(CFG.color3), rule = hex(CFG.color3),
+ value = hex(CFG.color4), highlight = hex(CFG.color5),
+ ok = hex(CFG.color6), critical = hex(CFG.color7),
+ warning = hex(CFG.color8, { 0.88, 0.69, 0.41 }),
+ body = hex(CFG.default_color),
+ surface = hex(CFG.default_shade_color),
+}
+
+local cw = (sw - MARGIN * 2 - GAP * (COLS - 1)) / COLS
+local ch = (sh - MARGIN * 2 - GAP * (ROWS - 1)) / ROWS
+local rect = {
+ x = MARGIN, y = MARGIN,
+ w = cw * wspan + GAP * (wspan - 1),
+ h = ch * hspan + GAP * (hspan - 1),
+}
+
+local surf = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, rect.w + 40, rect.h + 40)
+local cr = cairo_create(surf)
+-- A backdrop close to the dashboard's own, so contrast reads honestly.
+local bg = colors.surface
+cairo_set_source_rgb(cr, bg[1] * 0.6, bg[2] * 0.6, bg[3] * 0.7)
+cairo_paint(cr)
+cairo_translate(cr, 20 - rect.x, 20 - rect.y)
+
+local ok, mod = pcall(require, 'widgets.' .. widget_name)
+if not ok then
+ print('cannot load widget ' .. widget_name .. ': ' .. tostring(mod))
+ os.exit(1)
+end
+local drew, err = pcall(mod.draw, cr, rect, colors)
+if not drew then print('DRAW ERROR: ' .. tostring(err)) end
+
+cairo_destroy(cr)
+cairo_surface_write_to_png(surf, out)
+cairo_surface_destroy(surf)
+print(string.format('%s at %dx%d on a %dx%d grid -> %s', widget_name, wspan, hspan, COLS, ROWS, out))
+```
+
+- [ ] **Step 2: Verify against an existing widget**
+
+```bash
+lua test/render.lua weather 16 12 3 5 /tmp/harness-check.png
+```
+
+Expected: the line `weather at 3x5 on a 16x12 grid -> /tmp/harness-check.png`, and no `DRAW ERROR`.
+
+- [ ] **Step 3: Look at the PNG**
+
+Open `/tmp/harness-check.png`. Expected: the weather card as it appears on the dashboard, in the current scheme's colours. If the colours are magenta, `config_colors` did not find the rendered config, which means Task 1 Step 4 was skipped.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add test/render.lua
+git commit -m "test: add an offscreen widget render harness"
+```
+
+---
+
+## Task 3: Parsers for per-core CPU and sensors
+
+**Files:**
+- Modify: `lib/data.lua`
+- Modify: `test/test_data.lua`
+- Create: `test/fixtures/proc_stat_percore`
+
+- [ ] **Step 1: Create the fixture**
+
+Create `test/fixtures/proc_stat_percore`, a real `/proc/stat` shape trimmed to four cores:
+
+```
+cpu 293470 5201 186259 16513195 82679 0 2095 0 0 0
+cpu0 6441 24 3225 1056496 1932 0 760 0 0 0
+cpu1 23587 123 15762 1018523 8299 0 112 0 0 0
+cpu2 9859 24 6098 1049850 2386 0 81 0 0 0
+cpu3 11200 30 7000 1040000 2500 0 90 0 0 0
+intr 123456789 0 0 0
+ctxt 987654321
+btime 1789600000
+processes 54321
+procs_running 2
+procs_blocked 0
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Insert into `test/test_data.lua`, immediately before the final `print` line:
+
+```lua
+-- === Per-core CPU =========================================================
+-- The aggregate line answers "how busy is the machine"; the equaliser needs
+-- one entry per core. Both come from the same file, so they are parsed by the
+-- same rules and differ only in which lines they read.
+local percore = read('test/fixtures/proc_stat_percore')
+local cores = data.per_cpu_times(percore)
+assert(#cores == 4, 'one entry per cpuN line, got ' .. tostring(#cores))
+
+-- cpu0: 6441+24+3225+1056496+1932+0+760 = 1068878 total, idle+iowait = 1058428
+assert(cores[1].total == 1068878, 'core 0 total, got ' .. tostring(cores[1].total))
+assert(cores[1].idle == 1058428, 'core 0 idle, got ' .. tostring(cores[1].idle))
+
+-- Ordering matters: bar N must be core N, so the list follows the file.
+assert(cores[2].total == 1066406, 'core 1 total, got ' .. tostring(cores[2].total))
+
+-- The aggregate 'cpu ' line must NOT be counted as a core: it would draw a
+-- seventeenth bar showing the average, which looks like a real core.
+for i, c in ipairs(cores) do
+ assert(c.total < 2000000, 'entry ' .. i .. ' looks like the aggregate line')
+end
+
+-- A counter per core is just another instance, which is why new_cpu_counter
+-- holds its own previous sample rather than using a module-level one.
+local c0 = data.new_cpu_counter()
+assert(c0:sample(cores[1].total, cores[1].idle) == nil, 'first sample is nil')
+local busy = c0:sample(cores[1].total + 100, cores[1].idle + 25)
+assert(busy == 75.0, 'second sample 75%, got ' .. tostring(busy))
+
+-- Malformed input yields an empty list, never an error: a raise here is a
+-- blank dashboard.
+assert(#data.per_cpu_times('') == 0, 'empty input gives an empty list')
+assert(#data.per_cpu_times('garbage\nlines\n') == 0, 'garbage gives an empty list')
+assert(#data.per_cpu_times(nil) == 0, 'nil gives an empty list')
+-- A truncated line (fewer than the 5 fields the maths needs) is skipped
+-- rather than producing a nonsense total.
+assert(#data.per_cpu_times('cpu0 1 2\n') == 0, 'a short line is skipped')
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `lua test/test_data.lua`
+Expected: FAIL with `attempt to call a nil value (field 'per_cpu_times')`
+
+- [ ] **Step 4: Write the implementation**
+
+Add to `lib/data.lua`, before the final `return M`:
+
+```lua
+-- Per-core jiffies from /proc/stat, in file order, so entry N is core N.
+--
+-- Separate from cpu_times() rather than a flag on it: the aggregate is a
+-- single pair and this is a list, and a function returning one or the other
+-- depending on an argument is worse than two functions.
+--
+-- The leading 'cpu ' aggregate is excluded by requiring a digit after 'cpu',
+-- since counting it would draw an extra bar showing the average alongside the
+-- real cores.
+function M.per_cpu_times(stat)
+ local out = {}
+ if type(stat) ~= 'string' then return out end
+ for line in stat:gmatch('[^\n]+') do
+ local nums = line:match('^cpu%d+%s+(.+)$')
+ if nums then
+ local v = {}
+ for n in nums:gmatch('%d+') do v[#v + 1] = tonumber(n) end
+ -- Needs at least user..iowait to compute a busy fraction; a shorter line
+ -- is truncated or from a kernel that reports differently, and a partial
+ -- sum would look plausible while being wrong.
+ if #v >= 5 then
+ local total = 0
+ for _, n in ipairs(v) do total = total + n end
+ out[#out + 1] = { total = total, idle = v[4] + v[5] }
+ end
+ end
+ end
+ return out
+end
+
+-- A named hwmon sensor's value in whole degrees, or nil.
+--
+-- Wraps hwmon_dir + slurp + millidegrees so a widget names the chip and the
+-- file rather than building paths. nil, never 0: zero degrees is a plausible
+-- reading and must not be indistinguishable from a missing sensor.
+function M.sensor(chip, file)
+ if type(chip) ~= 'string' or type(file) ~= 'string' then return nil end
+ local dir = M.hwmon_dir(chip)
+ if not dir then return nil end
+ return M.millidegrees(M.slurp(dir .. '/' .. file))
+end
+
+-- A raw hwmon integer (fan RPM, power microwatts), or nil. Same lookup as
+-- sensor() without the millidegree conversion.
+function M.sensor_raw(chip, file)
+ if type(chip) ~= 'string' or type(file) ~= 'string' then return nil end
+ local dir = M.hwmon_dir(chip)
+ if not dir then return nil end
+ local s = M.slurp(dir .. '/' .. file)
+ if not s then return nil end
+ return tonumber(s:match('^%s*(-?%d+)'))
+end
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `lua test/test_data.lua`
+Expected: PASS
+
+- [ ] **Step 6: Verify against the live machine**
+
+The fixture proves the parse; this proves the binding.
+
+```bash
+lua -e '
+package.path="./?.lua;"..package.path
+local d = require "lib.data"
+local cores = d.per_cpu_times(d.slurp("/proc/stat"))
+print("cores found:", #cores, "(expect 16 on this host)")
+print("Tctl:", d.sensor("k10temp","temp1_input"))
+print("Tccd1:", d.sensor("k10temp","temp3_input"))
+print("NVMe:", d.sensor("nvme","temp1_input"))
+print("Arc pkg:", d.sensor("xe","temp2_input"))
+print("Arc vram:", d.sensor("xe","temp3_input"))
+print("Arc fan:", d.sensor_raw("xe","fan1_input"), "RPM")
+print("board2:", d.sensor("gigabyte_wmi","temp2_input"))
+print("board3:", d.sensor("gigabyte_wmi","temp3_input"))
+print("absent chip:", tostring(d.sensor("nosuchchip","temp1_input")))'
+```
+
+Expected: 16 cores, plausible temperatures (roughly 30-60C at idle), a fan RPM, and `nil` for the absent chip. A `nil` for a sensor that should exist means the chip name or file is wrong; fix the call, not the parser.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add lib/data.lua test/test_data.lua test/fixtures/proc_stat_percore
+git commit -m "feat: add per-core CPU times and named sensor reads"
+```
+
+---
+
+## Task 4: Parsers for the two cache files
+
+**Files:**
+- Modify: `lib/data.lua`
+- Modify: `test/test_data.lua`
+- Create: `test/fixtures/df_output`
+- Create: `test/fixtures/du_output`
+
+- [ ] **Step 1: Create the fixtures**
+
+Create `test/fixtures/df_output`, a real `df -P -B1` capture with placeholder servers:
+
+```
+Filesystem 1-blocks Used Available Capacity Mounted on
+/dev/mapper/myvg-root 263174213632 208111570944 41746907136 84% /
+/dev/mapper/myvg-home 719407267840 219043332096 463804289024 33% /home
+/dev/sda1 983350091776 547869650944 385453473792 59% /data
+server-a:/Volume1/Library 3958241259520 2966853025792 815851831296 79% /mnt/nfs/Library
+server-b:/mnt/HD/HD_a2/share 982889670656 480039763968 503676862464 49% /mnt/nfs/shared
+/dev/full 100000000000 100000000000 0 100% /mnt/full
+```
+
+Create `test/fixtures/du_output`, a `du -sh` capture, total first:
+
+```
+1.6G /home/you/.cache
+1.1G /home/you/.cache/mozilla
+245M /home/you/.cache/pip
+131M /home/you/.cache/go-build
+85M /home/you/.cache/opencode
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Insert into `test/test_data.lua`, immediately before the final `print` line:
+
+```lua
+-- === Cache files ==========================================================
+-- df -P guarantees one line per mount with the mountpoint LAST, which is why
+-- the parser takes the last field rather than the sixth: an NFS device is
+-- 'server:/export' and a device name can carry surprises, but the mountpoint
+-- is always at the end.
+local fs = data.df_parse(read('test/fixtures/df_output'))
+assert(#fs == 6, 'one entry per mount, got ' .. tostring(#fs))
+assert(fs[1].mount == '/', 'first mount, got ' .. tostring(fs[1].mount))
+assert(fs[1].pct == 84, 'root percent, got ' .. tostring(fs[1].pct))
+assert(fs[1].size == 263174213632, 'root size in bytes, got ' .. tostring(fs[1].size))
+assert(fs[1].used == 208111570944, 'root used, got ' .. tostring(fs[1].used))
+
+-- The NFS rows are the reason the last field matters: a colon in the device
+-- would break a parser splitting on punctuation.
+assert(fs[4].mount == '/mnt/nfs/Library', 'nfs mount, got ' .. tostring(fs[4].mount))
+assert(fs[4].pct == 79, 'nfs percent, got ' .. tostring(fs[4].pct))
+
+-- The device field carries the server, which is what lets the disks card
+-- label the two shares without an address hardcoded in the repo.
+assert(fs[4].host == 'server-a', 'nfs host, got ' .. tostring(fs[4].host))
+assert(fs[1].host == nil, 'a local device has no host, got ' .. tostring(fs[1].host))
+
+-- A full filesystem is a real state, not an error.
+assert(fs[6].pct == 100, 'a full mount reads 100, got ' .. tostring(fs[6].pct))
+
+-- The header line must not become a row.
+for _, e in ipairs(fs) do
+ assert(e.mount ~= 'on' and e.mount ~= 'Mounted', 'the header leaked in as a row')
+end
+
+assert(#data.df_parse('') == 0, 'empty input gives an empty list')
+assert(#data.df_parse(nil) == 0, 'nil gives an empty list')
+-- A header with no rows is what a failed df produces.
+assert(#data.df_parse('Filesystem 1-blocks Used Available Capacity Mounted on\n') == 0,
+ 'a header alone gives an empty list')
+
+-- du -sh: total first, then children largest-first. The suffixes must be
+-- converted, not string-sorted: '1.1G' outranks '245M' numerically and loses
+-- to it alphabetically.
+local cache = data.du_parse(read('test/fixtures/du_output'))
+assert(cache.total, 'a total is parsed')
+assert(cache.total.label == '1.6G', 'total label, got ' .. tostring(cache.total.label))
+assert(#cache.items == 4, 'four children, got ' .. tostring(#cache.items))
+assert(cache.items[1].name == 'mozilla', 'largest child, got ' .. tostring(cache.items[1].name))
+assert(cache.items[1].label == '1.1G', 'its label, got ' .. tostring(cache.items[1].label))
+assert(cache.items[2].name == 'pip', 'second child, got ' .. tostring(cache.items[2].name))
+
+-- Sizes are compared as bytes, which is what makes 'biggest' meaningful.
+assert(cache.items[1].bytes > cache.items[2].bytes, '1.1G must outrank 245M')
+assert(cache.items[1].bytes > 1e9, 'a G suffix is about a billion bytes')
+assert(cache.items[4].bytes < 1e8, 'an M suffix is far smaller')
+
+-- Share of the total drives the highlight colour.
+assert(cache.items[1].bytes / cache.total.bytes > 0.5,
+ 'mozilla dominates this fixture, which is what the highlight keys on')
+
+assert(data.du_parse('') == nil, 'empty input gives nil')
+assert(data.du_parse(nil) == nil, 'nil gives nil')
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `lua test/test_data.lua`
+Expected: FAIL with `attempt to call a nil value (field 'df_parse')`
+
+- [ ] **Step 4: Write the implementation**
+
+Add to `lib/data.lua`, before the final `return M`:
+
+```lua
+-- Parse `df -P -B1` output into a list of filesystems.
+--
+-- The mountpoint is taken as the LAST field, not the sixth. df -P guarantees
+-- one line per filesystem with the mountpoint last, and an NFS device reads
+-- 'server:/export', so counting fields from the left is fragile in a way
+-- counting from the right is not.
+--
+-- Sizes are bytes because the sampler passes -B1. Never parse `df -h` output
+-- here: the user's shell aliases df to df -h, so a sampler calling bare df
+-- would hand this function '1.6G' where it expects an integer.
+function M.df_parse(text)
+ local out = {}
+ if type(text) ~= 'string' then return out end
+ for line in text:gmatch('[^\n]+') do
+ -- A data row ends in 'NN% /some/path'. The header ends in 'Mounted on',
+ -- which fails the percent match, so it is skipped without a special case.
+ local size, used, pct, mount = line:match('(%d+)%s+(%d+)%s+%d+%s+(%d+)%%%s+(%S+)%s*$')
+ if size then
+ -- The device is kept so a caller can tell an NFS mount from a local one
+ -- and recover its server, which is how the disks card labels the two
+ -- shares without hardcoding an address in the repo.
+ local dev = line:match('^(%S+)')
+ out[#out + 1] = {
+ mount = mount,
+ dev = dev,
+ host = dev and dev:match('^([^/:]+):') or nil,
+ size = tonumber(size),
+ used = tonumber(used),
+ pct = tonumber(pct),
+ }
+ end
+ end
+ return out
+end
+
+-- Human-readable size to bytes: '1.1G' -> 1181116006.
+--
+-- Needed because du -sh is what the sampler runs (its output is also what the
+-- card displays), and 'biggest' has to be decided numerically: sorted as
+-- strings, '245M' beats '1.1G'.
+local SUFFIX = { K = 1024, M = 1024^2, G = 1024^3, T = 1024^4, P = 1024^5 }
+
+function M.human_bytes(s)
+ if type(s) ~= 'string' then return nil end
+ local n, suf = s:match('^%s*([%d%.]+)%s*([KMGTP]?)')
+ n = tonumber(n)
+ if not n then return nil end
+ return n * (SUFFIX[suf] or 1)
+end
+
+-- Parse `du -sh` output: the first line is the total, the rest are children
+-- already sorted largest-first by the sampler. Returns nil when there is
+-- nothing usable, so the widget can draw its "no data" state.
+function M.du_parse(text)
+ if type(text) ~= 'string' or text == '' then return nil end
+ local lines = {}
+ for line in text:gmatch('[^\n]+') do lines[#lines + 1] = line end
+ if #lines == 0 then return nil end
+
+ local function entry(line)
+ local label, path = line:match('^(%S+)%s+(.+)$')
+ if not label then return nil end
+ return { label = label, bytes = M.human_bytes(label) or 0,
+ name = path:match('([^/]+)/?$') or path, path = path }
+ end
+
+ local total = entry(lines[1])
+ if not total then return nil end
+ local items = {}
+ for i = 2, #lines do
+ local e = entry(lines[i])
+ if e then items[#items + 1] = e end
+ end
+ return { total = total, items = items }
+end
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `lua test/test_data.lua`
+Expected: PASS
+
+- [ ] **Step 6: Run the whole suite**
+
+Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua`
+Expected: three "all assertions passed" lines
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add lib/data.lua test/test_data.lua test/fixtures/df_output test/fixtures/du_output
+git commit -m "feat: parse df and du output for the disk and cache cards"
+```
+
+---
+
+## Task 5: The sampler scripts
+
+**Files:**
+- Create: `bin/disks-sample.sh`
+- Create: `bin/cache-sample.sh`
+- Modify: `conky.conf.in`
+
+- [ ] **Step 1: Write the disks sampler**
+
+Create `bin/disks-sample.sh`:
+
+```bash
+#!/bin/bash
+# Sample filesystem usage into a cache file, for widgets/disks.lua.
+#
+# This exists so the draw hook never calls statfs on an NFS path. An
+# unreachable server blocks that call, and blocking the Cairo draw freezes the
+# whole dashboard; here it costs a stale cache and nothing else.
+#
+# One NFS mount per server: Library and Slackware are the same export on one
+# server, shared and backup_danix the same on another, so showing all four
+# printed every number twice.
+
+set -u
+
+CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
+CACHE="$CACHE_DIR/disks.txt"
+
+MOUNTS=(/ /home /data /mnt/nfs/Library /mnt/nfs/shared)
+
+mkdir -p "$CACHE_DIR"
+
+TMP="$CACHE.tmp.$$"
+trap 'rm -f "$TMP"' EXIT
+umask 077
+
+# /usr/bin/df by absolute path with -P -B1, deliberately:
+# - the user's shell aliases df to `df -h`, and an alias or a function would
+# hand the parser '1.6G' where it expects an integer
+# - -P is the POSIX format, one line per filesystem, mountpoint last
+# - -B1 is bytes, so the widget does the formatting and the parser never
+# has to interpret a suffix
+if ! /usr/bin/df -P -B1 "${MOUNTS[@]}" > "$TMP" 2>/dev/null; then
+ # A single unreachable mount must not discard the others: df still reports
+ # the ones it could stat, so keep the output if it has any data rows.
+ if [ "$(wc -l < "$TMP")" -lt 2 ]; then
+ echo "disks-sample: df produced nothing usable" >&2
+ exit 1
+ fi
+fi
+
+mv -f "$TMP" "$CACHE"
+```
+
+- [ ] **Step 2: Write the cache sampler**
+
+Create `bin/cache-sample.sh`:
+
+```bash
+#!/bin/bash
+# Sample ~/.cache sizes into a cache file, for widgets/cache.lua.
+#
+# du walks the tree and costs about 100ms warm, which is fine every 15 minutes
+# and unthinkable on a 2-second draw. The old conky config used the same
+# interval for the same reason.
+
+set -u
+
+CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
+CACHE="$CACHE_DIR/cachesize.txt"
+TARGET="${XDG_CACHE_HOME:-$HOME/.cache}"
+
+mkdir -p "$CACHE_DIR"
+
+TMP="$CACHE.tmp.$$"
+trap 'rm -f "$TMP"' EXIT
+umask 077
+
+# Total first, then the four largest children. `sort -h` compares human sizes
+# numerically; the Lua side converts them again because it needs the ratio to
+# the total, not just the order.
+{
+ du -sh "$TARGET" 2>/dev/null
+ du -sh "$TARGET"/* 2>/dev/null | sort -hr | head -4
+} > "$TMP"
+
+if [ ! -s "$TMP" ]; then
+ echo "cache-sample: du produced nothing" >&2
+ exit 1
+fi
+
+mv -f "$TMP" "$CACHE"
+```
+
+- [ ] **Step 3: Make them executable and lint**
+
+```bash
+chmod +x bin/disks-sample.sh bin/cache-sample.sh
+shellcheck bin/disks-sample.sh bin/cache-sample.sh
+```
+
+Expected: no output.
+
+- [ ] **Step 4: Run both and check the output parses**
+
+```bash
+./bin/disks-sample.sh; echo "disks exit: $?"
+./bin/cache-sample.sh; echo "cache exit: $?"
+ls -l ~/.cache/udt/disks.txt ~/.cache/udt/cachesize.txt | awk '{print $1, $NF}'
+```
+
+Expected: both exit 0, both files mode `-rw-------`.
+
+```bash
+lua -e '
+package.path="./?.lua;"..package.path
+local d = require "lib.data"
+local fs = d.df_parse(d.slurp(os.getenv("HOME").."/.cache/udt/disks.txt"))
+print("filesystems:", #fs)
+for _, e in ipairs(fs) do print(string.format(" %-20s %3d%%", e.mount, e.pct)) end
+local c = d.du_parse(d.slurp(os.getenv("HOME").."/.cache/udt/cachesize.txt"))
+print("cache total:", c and c.total.label, "children:", c and #c.items)
+for _, e in ipairs(c and c.items or {}) do print(" " .. e.name, e.label) end'
+```
+
+Expected: five filesystems with plausible percentages, a cache total and four named children. **This is the step that catches the `df -h` alias trap**: if percentages are nil or sizes look like `1`, the sampler is not producing bytes.
+
+- [ ] **Step 5: Verify a failed sample keeps the old cache**
+
+```bash
+cp ~/.cache/udt/disks.txt /tmp/disks.good
+md5sum ~/.cache/udt/disks.txt
+# a sampler pointed at a nonexistent mount
+sed 's|/mnt/nfs/Library|/mnt/nfs/nonexistent|' bin/disks-sample.sh > /tmp/ds.sh
+chmod +x /tmp/ds.sh && /tmp/ds.sh; echo "exit: $?"
+md5sum ~/.cache/udt/disks.txt
+rm -f /tmp/ds.sh /tmp/disks.good
+```
+
+Expected: `df` still reports the reachable mounts, so the cache updates with those. The point is that it does not end up empty or truncated.
+
+- [ ] **Step 6: Schedule both**
+
+In `conky.conf.in`, replace the `conky.text` line:
+
+```lua
+conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}${execi 60 ~/.config/conky/bin/disks-sample.sh}${execi 900 ~/.config/conky/bin/cache-sample.sh}]]
+```
+
+All three render nothing and are covered by Cairo; the execi still fires, which was verified with a probe config before the weather widget relied on it.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add bin/disks-sample.sh bin/cache-sample.sh conky.conf.in
+git commit -m "feat: add the disks and cache samplers"
+```
+
+---
+
+## Task 6: Drawing primitives
+
+**Files:**
+- Modify: `lib/card.lua`
+
+- [ ] **Step 1: Add the three primitives**
+
+Add to `lib/card.lua`, before the final `return M`:
+
+```lua
+-- Colour for a value against two thresholds.
+--
+-- One function so all four system cards agree on the rule instead of each
+-- re-deriving it, and so "what counts as busy" is stated in a single place.
+-- `v`, `warn` and `crit` share whatever unit the caller is using: percent for
+-- a filesystem, degrees for a sensor.
+function M.threshold(v, warn, crit, colors)
+ if type(v) ~= 'number' then return colors.label end
+ if v >= crit then return colors.critical end
+ if v >= warn then return colors.warning end
+ return colors.ok
+end
+
+-- A horizontal bar: a dim full-width track with a filled portion over it.
+--
+-- frac is clamped rather than trusted: a filesystem at 100% and a load that
+-- briefly computes above 1.0 must not draw past the track.
+function M.bar(cr, x, y, w, h, frac, colour, colors)
+ frac = tonumber(frac) or 0
+ if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
+
+ M.rgba(cr, colors.rule, 0.5)
+ M.rounded_path(cr, x, y, w, h, h / 2)
+ cairo_fill(cr)
+
+ if frac > 0 then
+ M.rgba(cr, colour, 1)
+ M.rounded_path(cr, x, y, math.max(w * frac, h), h, h / 2)
+ cairo_fill(cr)
+ end
+end
+
+-- A vertical bar, rising from its baseline. The equaliser's element.
+function M.vbar(cr, x, base_y, w, max_h, frac, colour, colors)
+ frac = tonumber(frac) or 0
+ if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
+
+ M.rgba(cr, colors.rule, 0.5)
+ cairo_rectangle(cr, x, base_y - max_h, w, max_h)
+ cairo_fill(cr)
+
+ local h = max_h * frac
+ if h > 0 then
+ M.rgba(cr, colour, 1)
+ cairo_rectangle(cr, x, base_y - h, w, h)
+ cairo_fill(cr)
+ end
+end
+
+-- A ring gauge, after idea2.png: a dim full circle with an arc over it
+-- covering `frac`, leaving the centre free for a glyph.
+--
+-- The arc starts at twelve o'clock and sweeps clockwise, which is what reads
+-- as a gauge. Cairo's zero angle is at three o'clock and it sweeps clockwise
+-- already, so the start is offset by -pi/2 rather than the direction being
+-- reversed.
+function M.ring(cr, cx, cy, r, frac, colour, colors, width)
+ frac = tonumber(frac) or 0
+ if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
+ width = width or math.max(3, r * 0.18)
+
+ cairo_set_line_width(cr, width)
+ cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
+
+ M.rgba(cr, colors.rule, 0.5)
+ cairo_arc(cr, cx, cy, r, 0, math.pi * 2)
+ cairo_stroke(cr)
+
+ if frac > 0 then
+ M.rgba(cr, colour, 1)
+ cairo_arc(cr, cx, cy, r, -math.pi / 2, -math.pi / 2 + math.pi * 2 * frac)
+ cairo_stroke(cr)
+ end
+ -- Leave the cap as it was found: a later stroke inheriting ROUND would get
+ -- visibly rounded ends on the card's hairlines.
+ cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT)
+end
+
+-- Bytes to a short human string: 1181116006 -> '1.1G'.
+-- The card formats its own numbers because the samplers pass raw bytes.
+function M.human(bytes)
+ local n = tonumber(bytes)
+ if not n then return '--' end
+ local units = { 'B', 'K', 'M', 'G', 'T', 'P' }
+ local i = 1
+ while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end
+ if i == 1 then return string.format('%d%s', n, units[i]) end
+ if n >= 100 then return string.format('%.0f%s', n, units[i]) end
+ return string.format('%.1f%s', n, units[i])
+end
+```
+
+- [ ] **Step 2: Check it parses**
+
+Run: `luac -p lib/card.lua && echo "syntax ok"`
+Expected: `syntax ok`
+
+- [ ] **Step 3: Check the formatter and threshold by hand**
+
+These are pure functions, so they check without Cairo:
+
+```bash
+lua -e '
+package.path="./?.lua;"..package.path
+-- card.lua needs the cairo globals only inside the drawing functions, so a
+-- stub is enough to require it for the pure ones.
+cairo_text_extents_t = { create = function() return {} end }
+local card = require "lib.card"
+for _, b in ipairs{0, 999, 1024, 1181116006, 1024^3, 3958241259520} do
+ print(string.format("%16s -> %s", tostring(b), card.human(b)))
+end
+local colors = { ok="OK", warning="WARN", critical="CRIT", label="LBL" }
+for _, v in ipairs{0, 24, 25, 74, 75, 100} do
+ print(string.format(" %3d%% -> %s", v, card.threshold(v, 25, 75, colors)))
+end
+print(" nil ->", card.threshold(nil, 25, 75, colors))'
+```
+
+Expected: `1181116006 -> 1.1G`, `3958241259520 -> 3.6T`; thresholds giving OK below 25, WARN from 25 to 74, CRIT at 75 and above, and LBL for nil.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add lib/card.lua
+git commit -m "feat: add bar, vbar, ring and threshold primitives"
+```
+
+---
+
+## Task 7: The system card
+
+**Files:**
+- Create: `widgets/system.lua`
+- Modify: `dashboard.lua`
+
+- [ ] **Step 1: Write the widget**
+
+Create `widgets/system.lua`:
+
+```lua
+-- System: CPU load as a per-core equaliser, memory, and every temperature.
+--
+-- The hwmon bindings and temperature ceilings below are host-specific, which
+-- is why they live here rather than in lib/data.lua: that file is the part
+-- that ports to another machine, this is the part that does not.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local M = {}
+
+-- chip, file, label, warn, crit.
+--
+-- Ceilings are per sensor because a single pair cannot serve both: 70C is
+-- unremarkable on a CPU package and alarming on an NVMe. Starting values,
+-- easy to retune once real numbers under load are known.
+local TEMPS = {
+ { 'k10temp', 'temp1_input', 'CPU', 75, 90 },
+ { 'k10temp', 'temp3_input', 'CCD1', 75, 90 },
+ { 'nvme', 'temp1_input', 'NVME', 60, 70 },
+ { 'gigabyte_wmi', 'temp2_input', 'BOARD', 60, 70 },
+ { 'gigabyte_wmi', 'temp3_input', 'BOARD', 60, 70 },
+}
+
+-- One counter per core, created on first draw and kept for the process's life:
+-- load is a delta between samples, so the state has to outlive the frame.
+local counters = {}
+local total_counter = data.new_cpu_counter()
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
+
+ local label_size = clamp(inner.w * 0.030, 11, 18)
+ local big_size = clamp(inner.w * 0.085, 26, 56)
+ local row_size = clamp(inner.w * 0.030, 11, 17)
+
+ local stat = data.slurp('/proc/stat')
+ local agg_total, agg_idle = data.cpu_times(stat or '')
+ local load = total_counter:sample(agg_total, agg_idle)
+
+ local y = inner.y
+
+ -- Aggregate load, the headline number.
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, y + label_size, 'CPU')
+
+ card.font(cr, card.FONT_HEAVY, big_size, false)
+ card.rgba(cr, load and card.threshold(load, 25, 75, colors) or colors.label)
+ card.text(cr, inner.x, y + label_size + big_size,
+ load and string.format('%d%%', math.floor(load + 0.5)) or '--')
+
+ local ey = y + label_size + big_size + 14
+
+ -- The equaliser: one bar per core, rising from a common baseline.
+ local cores = data.per_cpu_times(stat or '')
+ local n = #cores
+ if n > 0 then
+ local gap = math.max(2, inner.w * 0.006)
+ local bw = (inner.w - gap * (n - 1)) / n
+ local eh = clamp(inner.h * 0.16, 24, 80)
+ -- Below about 3px a row of sixteen bars is an illegible smear; drop to the
+ -- aggregate bar instead of drawing one.
+ if bw >= 3 then
+ for i, c in ipairs(cores) do
+ counters[i] = counters[i] or data.new_cpu_counter()
+ local pct = counters[i]:sample(c.total, c.idle) or 0
+ card.vbar(cr, inner.x + (i - 1) * (bw + gap), ey + eh, bw, eh,
+ pct / 100, card.threshold(pct, 25, 75, colors), colors)
+ end
+ else
+ card.bar(cr, inner.x, ey + eh - 8, inner.w, 8, (load or 0) / 100,
+ card.threshold(load or 0, 25, 75, colors), colors)
+ end
+ ey = ey + eh + 16
+ end
+
+ -- Memory. MemAvailable, not free: it already excludes reclaimable cache, so
+ -- it is the figure a person recognises as "used".
+ local mem = data.mem_info(data.slurp('/proc/meminfo') or '')
+ if mem then
+ local frac = mem.used / mem.total
+ card.font(cr, card.FONT_MONO, row_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, 'RAM')
+ card.rgba(cr, colors.value)
+ card.text_right(cr, inner.x + inner.w, ey,
+ string.format('%s / %s', card.human(mem.used * 1024), card.human(mem.total * 1024)))
+ ey = ey + 8
+ card.bar(cr, inner.x, ey, inner.w, 8, frac,
+ card.threshold(frac * 100, 25, 75, colors), colors)
+ ey = ey + 22
+ end
+
+ -- Temperatures, each against its own ceiling.
+ card.font(cr, card.FONT_MONO, row_size, false)
+ local step = row_size * 1.8
+ for _, t in ipairs(TEMPS) do
+ if ey + step > inner.y + inner.h then break end -- out of room: stop
+ local v = data.sensor(t[1], t[2])
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, t[3])
+ card.rgba(cr, v and card.threshold(v, t[4], t[5], colors) or colors.label)
+ card.text_right(cr, inner.x + inner.w, ey,
+ v and string.format('%d\u{00B0}', v) or '--')
+ ey = ey + step
+ end
+end
+
+return M
+```
+
+- [ ] **Step 2: Check it parses and renders**
+
+```bash
+luac -p widgets/system.lua && echo "syntax ok"
+lua test/render.lua system 16 12 3 6 /tmp/system.png
+```
+
+Expected: `syntax ok`, then the render line with no `DRAW ERROR`.
+
+- [ ] **Step 3: Look at the PNG, twice**
+
+Open `/tmp/system.png`. Expected: a CPU percentage, a row of sixteen bars at varying heights, a RAM bar, and five temperature rows.
+
+**The equaliser needs two renders to judge**, because the first sample of every counter returns nil and draws every bar at zero. Run it again immediately:
+
+```bash
+lua test/render.lua system 16 12 3 6 /tmp/system2.png
+```
+
+`/tmp/system2.png` is a fresh process, so its bars will also be flat. That is expected and is not a bug: in conky the counters persist across frames and fill in from the second frame onward. Judge the bar layout here and the bar *heights* in Task 10 on the live dashboard.
+
+- [ ] **Step 4: Add it to the layout**
+
+In `dashboard.lua`, add to the `layout` table:
+
+```lua
+ { widget = 'system', col = 4, row = 1, w = 3, h = 6 },
+```
+
+- [ ] **Step 5: Run the suite**
+
+Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua`
+Expected: three "all assertions passed" lines.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add widgets/system.lua dashboard.lua
+git commit -m "feat: add the system card with a per-core equaliser"
+```
+
+---
+
+## Task 8: The GPU card
+
+**Files:**
+- Create: `widgets/gpu.lua`
+- Modify: `dashboard.lua`
+
+- [ ] **Step 1: Write the widget**
+
+Create `widgets/gpu.lua`:
+
+```lua
+-- GPU: Arc B580 temperatures and fan speed.
+--
+-- No utilisation figure, deliberately. The xe driver exposes no
+-- gpu_busy_percent, intel_gpu_top refuses the device outright ("Detected Xe
+-- device which is not supported"), and gputop prints per-process rows with
+-- ANSI escapes, which is not an interface to build a widget on. The card shows
+-- what the hardware actually reports rather than inventing a number.
+--
+-- The integrated AMD GPU does expose gpu_busy_percent and is deliberately not
+-- shown: the discrete card is the one in use.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local M = {}
+
+-- chip, file, label, warn, crit
+local TEMPS = {
+ { 'xe', 'temp2_input', 'PKG', 75, 85 },
+ { 'xe', 'temp3_input', 'VRAM', 80, 90 },
+}
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
+
+ local label_size = clamp(inner.w * 0.030, 11, 18)
+ local big_size = clamp(inner.w * 0.085, 26, 56)
+ local row_size = clamp(inner.w * 0.030, 11, 17)
+
+ local y = inner.y
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, y + label_size, 'GPU')
+ card.rgba(cr, colors.value)
+ card.text_right(cr, inner.x + inner.w, y + label_size, 'ARC B580')
+
+ -- The package temperature is the headline, since there is no load to show.
+ local pkg = data.sensor('xe', 'temp2_input')
+ card.font(cr, card.FONT_HEAVY, big_size, false)
+ card.rgba(cr, pkg and card.threshold(pkg, 75, 85, colors) or colors.label)
+ card.text(cr, inner.x, y + label_size + big_size,
+ pkg and string.format('%d\u{00B0}', pkg) or '--')
+
+ local ey = y + label_size + big_size + 18
+ local step = row_size * 1.9
+
+ card.font(cr, card.FONT_MONO, row_size, false)
+ for _, t in ipairs(TEMPS) do
+ if ey + step > inner.y + inner.h then break end
+ local v = data.sensor(t[1], t[2])
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, t[3])
+ card.rgba(cr, v and card.threshold(v, t[4], t[5], colors) or colors.label)
+ card.text_right(cr, inner.x + inner.w, ey,
+ v and string.format('%d\u{00B0}', v) or '--')
+ ey = ey + step
+ end
+
+ -- Fan RPM is not a temperature, so it carries no threshold colour: a fast
+ -- fan is the cooling working, not a fault.
+ if ey + step <= inner.y + inner.h then
+ local rpm = data.sensor_raw('xe', 'fan1_input')
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, 'FAN')
+ card.rgba(cr, colors.value)
+ card.text_right(cr, inner.x + inner.w, ey,
+ rpm and string.format('%d RPM', rpm) or '--')
+ end
+end
+
+return M
+```
+
+- [ ] **Step 2: Check and render**
+
+```bash
+luac -p widgets/gpu.lua && echo "syntax ok"
+lua test/render.lua gpu 16 12 3 3 /tmp/gpu.png
+```
+
+- [ ] **Step 3: Look at the PNG**
+
+Open `/tmp/gpu.png`. Expected: "GPU" and "ARC B580", a large package temperature, then PKG, VRAM and FAN rows. Compare the numbers against:
+
+```bash
+for f in temp2_input temp3_input fan1_input; do
+ d=$(grep -lx xe /sys/class/hwmon/hwmon*/name | head -1); d=${d%/name}
+ printf '%-12s %s\n' "$f" "$(cat $d/$f)"
+done
+```
+
+Temperatures are millidegrees there, so `51000` means the card should show `51`.
+
+- [ ] **Step 4: Add to the layout**
+
+In `dashboard.lua`:
+
+```lua
+ { widget = 'gpu', col = 7, row = 1, w = 3, h = 3 },
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add widgets/gpu.lua dashboard.lua
+git commit -m "feat: add the GPU card"
+```
+
+---
+
+## Task 9: The disks and cache cards
+
+**Files:**
+- Create: `widgets/disks.lua`
+- Create: `widgets/cache.lua`
+- Modify: `dashboard.lua`
+
+- [ ] **Step 1: Write the disks widget**
+
+Create `widgets/disks.lua`:
+
+```lua
+-- Disks: one ring per filesystem, after idea2.png.
+--
+-- Reads a cache file written by bin/disks-sample.sh and never calls statfs
+-- itself: an unreachable NFS server blocks that call, and blocking the draw
+-- freezes the whole dashboard.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local M = {}
+
+local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
+ .. '/udt/disks.txt'
+
+-- mount -> centre glyph and the small text under it.
+--
+-- The icon IS the label: no mount name is drawn, because a Slackware S or a
+-- house says which filesystem this is faster than the word does. Every
+-- codepoint here was taken from the old conky config and re-rendered to
+-- confirm it, rather than guessed: an earlier guess at the Slackware mark
+-- (F83C) turned out to draw a stomach, and a wrong-but-present codepoint
+-- fails plausibly rather than visibly.
+--
+-- The two NFS shares are told apart by their server's address, which is read
+-- from the mount's device field at runtime rather than written here. The
+-- addresses belong on the screen, not in a public repo, and deriving them also
+-- means the card keeps working if the LAN is renumbered. It fits: at a 50px
+-- radius the usable inner width is about 75px and an address measures 58px at
+-- 10px type.
+--
+-- A mount not listed still draws, with a generic disk glyph and the last path
+-- segment, so adding one to the sampler needs no change here.
+local GLYPH = {
+ ['/'] = '\u{F318}', -- Slackware
+ ['/home'] = '\u{F015}', -- house
+ ['/data'] = '\u{F02CA}', -- hard disk
+}
+local NFS_GLYPH = '\u{F06F3}' -- network share
+local FALLBACK_GLYPH = '\u{F02CA}'
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
+ local label_size = clamp(inner.w * 0.030, 10, 16)
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, inner.y + label_size, 'DISKS')
+
+ local fs = data.df_parse(data.slurp(CACHE) or '')
+ if #fs == 0 then
+ card.rgba(cr, colors.label)
+ card.font(cr, card.FONT_UI, label_size * 1.2, true)
+ card.text(cr, inner.x, inner.y + label_size * 3.4, 'no disk data')
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.text(cr, inner.x, inner.y + label_size * 5, 'bin/disks-sample.sh')
+ return
+ end
+
+ local top = inner.y + label_size * 2.2
+ local avail_h = (inner.y + inner.h) - top
+
+ -- Lay the rings out in a row, wrapping if the card is narrow. Below the
+ -- radius where a ring reads, fall back to labelled bars rather than drawing
+ -- a row of illegible dots.
+ local n = #fs
+ local per_row = n
+ local cell_w = inner.w / per_row
+ local r = math.min(cell_w * 0.34, avail_h * 0.30)
+
+ if r >= 18 then
+ for i, e in ipairs(fs) do
+ -- host is non-nil exactly for a network mount, since a local device is
+ -- a path and carries no 'server:' prefix.
+ local glyph = GLYPH[e.mount] or (e.host and NFS_GLYPH) or FALLBACK_GLYPH
+ local sub = e.host
+ if not sub and not GLYPH[e.mount] then
+ sub = (e.mount:match('([^/]+)/?$') or e.mount):upper()
+ end
+ local cx = inner.x + cell_w * (i - 0.5)
+ local cy = top + r + 6
+ local colour = card.threshold(e.pct, 25, 75, colors)
+
+ card.ring(cr, cx, cy, r, e.pct / 100, colour, colors)
+
+ -- Glyph at the centre. Drawn at 0.55 of the radius rather than 0.7: the
+ -- network-share glyph carries a wide horizontal connector that runs past
+ -- the ring at larger sizes, which reads as a line joining the rings
+ -- together.
+ local gsize = r * 0.55
+ card.font(cr, card.FONT_MONO, gsize, false)
+ card.rgba(cr, colors.label)
+ local gw = card.measure(cr, glyph)
+ -- Raised when there is text under it, centred when there is not.
+ card.text(cr, cx - gw / 2, cy + (sub and gsize * 0.10 or gsize * 0.38), glyph)
+
+ -- The address, inside the ring beneath the glyph. Only the NFS shares
+ -- have one; the local mounts are identified by their icon alone.
+ if sub then
+ card.font(cr, card.FONT_MONO, clamp(r * 0.20, 9, 12), false)
+ card.rgba(cr, colors.label)
+ local sw_ = card.measure(cr, sub)
+ card.text(cr, cx - sw_ / 2, cy + r * 0.55, sub)
+ end
+
+ -- The percentage sits under the ring, in the threshold colour.
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.rgba(cr, colour)
+ local pct = string.format('%d%%', e.pct)
+ local pw = card.measure(cr, pct)
+ card.text(cr, cx - pw / 2, cy + r + label_size * 1.7, pct)
+ end
+ else
+ local step = label_size * 2.6
+ local ey = top + label_size
+ for _, e in ipairs(fs) do
+ if ey + step > inner.y + inner.h then break end
+ -- The bar fallback has no room for a ring, so it shows the glyph inline
+ -- followed by the address or the mount name.
+ local glyph = GLYPH[e.mount] or (e.host and NFS_GLYPH) or FALLBACK_GLYPH
+ local name = e.host or (e.mount:match('([^/]+)/?$') or e.mount):upper()
+ local colour = card.threshold(e.pct, 25, 75, colors)
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, glyph .. ' ' .. name)
+ card.rgba(cr, colour)
+ card.text_right(cr, inner.x + inner.w, ey,
+ string.format('%s / %s', card.human(e.used), card.human(e.size)))
+ card.bar(cr, inner.x, ey + 6, inner.w, 6, e.pct / 100, colour, colors)
+ ey = ey + step
+ end
+ end
+end
+
+return M
+```
+
+- [ ] **Step 2: Write the cache widget**
+
+Create `widgets/cache.lua`:
+
+```lua
+-- Cache: ~/.cache total and its four largest subdirectories.
+--
+-- Reads a cache file written by bin/cache-sample.sh. du walks the tree and
+-- costs about 100ms, which cannot happen on a 2-second draw.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
+ .. '/udt/cachesize.txt'
+
+local M = {}
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
+ local label_size = clamp(inner.w * 0.030, 10, 16)
+ local big_size = clamp(inner.w * 0.075, 22, 46)
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, inner.y + label_size, 'CACHE')
+
+ local c = data.du_parse(data.slurp(CACHE) or '')
+ if not c then
+ card.rgba(cr, colors.label)
+ card.font(cr, card.FONT_UI, label_size * 1.2, true)
+ card.text(cr, inner.x, inner.y + label_size * 3.4, 'no cache data')
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.text(cr, inner.x, inner.y + label_size * 5, 'bin/cache-sample.sh')
+ return
+ end
+
+ card.font(cr, card.FONT_HEAVY, big_size, false)
+ card.rgba(cr, colors.body)
+ card.text(cr, inner.x, inner.y + label_size + big_size, c.total.label)
+
+ local ey = inner.y + label_size + big_size + label_size * 2
+ local step = label_size * 2.2
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ for i, e in ipairs(c.items) do
+ if ey + step > inner.y + inner.h then break end
+ -- The biggest offender is the point of the card, so it carries colour:
+ -- critical when it is over half the total, warning otherwise. The rest
+ -- stay in the ordinary value colour so the eye lands on the one to delete.
+ local colour = colors.value
+ if i == 1 then
+ local share = c.total.bytes > 0 and (e.bytes / c.total.bytes) or 0
+ colour = share > 0.5 and colors.critical or colors.warning
+ end
+ card.rgba(cr, colors.label)
+ -- Long names are truncated rather than allowed to collide with the size.
+ local name = e.name
+ if #name > 18 then name = name:sub(1, 17) .. '\u{2026}' end
+ card.text(cr, inner.x, ey, name)
+ card.rgba(cr, colour)
+ card.text_right(cr, inner.x + inner.w, ey, e.label)
+ ey = ey + step
+ end
+end
+
+return M
+```
+
+- [ ] **Step 3: Check and render both**
+
+```bash
+luac -p widgets/disks.lua widgets/cache.lua && echo "syntax ok"
+lua test/render.lua disks 16 12 5 4 /tmp/disks.png
+lua test/render.lua cache 16 12 3 4 /tmp/cache.png
+lua test/render.lua disks 16 12 2 3 /tmp/disks-small.png
+```
+
+- [ ] **Step 4: Look at all three PNGs**
+
+`/tmp/disks.png`: five rings with glyphs at their centres, names and percentages beneath, each coloured by usage. At the real figures, `/` at 84% should be `critical`, `/data` at 59% and Library at 79% should differ visibly.
+
+`/tmp/disks-small.png`: the same data as labelled bars, because the rings would be under 18px. This is the fallback working, not a bug.
+
+`/tmp/cache.png`: the total, then four rows with the largest coloured.
+
+**Check the glyphs rendered.** All five were taken from the old conky config
+and re-rendered while this plan was written, so they are known good, but a
+wrong-but-present codepoint draws a plausible neighbour rather than failing, so
+look:
+
+```bash
+f=$(fc-match -f '%{file}' 'Inconsolata Nerd Font')
+magick -background '#16161e' -fill '#c0caf5' -font "$f" -pointsize 80 \
+ label:$'\uF318 \uF015 \U000F02CA \U000F06F3' /tmp/diskglyphs.png
+```
+
+Open it: expected, in order, the Slackware S, a house, a hard disk platter,
+and a network-share icon. A blank means the codepoint is absent entirely.
+
+**Check the two NFS rings show their addresses** and that the network glyph's
+connector does not run outside the ring. If it does, reduce `gsize` below
+`r * 0.55`.
+
+- [ ] **Step 5: Add both to the layout**
+
+In `dashboard.lua`:
+
+```lua
+ { widget = 'disks', col = 7, row = 4, w = 5, h = 4 },
+ { widget = 'cache', col = 12, row = 4, w = 3, h = 4 },
+```
+
+- [ ] **Step 6: Run the suite and commit**
+
+```bash
+lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
+git add widgets/disks.lua widgets/cache.lua dashboard.lua
+git commit -m "feat: add the disks and cache cards"
+```
+
+---
+
+## Task 10: Install, look at it, iterate
+
+Nothing so far has been seen in the real dashboard.
+
+- [ ] **Step 1: Render and restart**
+
+```bash
+cd ../unified-desktop-theme && ./install.sh && cd -
+./restart.sh
+```
+
+`install.sh` is needed here, not just `restart.sh`, because `conky.conf.in` changed in Tasks 1 and 5.
+
+- [ ] **Step 2: Confirm the samplers ran**
+
+```bash
+sleep 70 # the disks sampler is on a 60s execi
+ls -l ~/.cache/udt/*.txt
+```
+
+Expected: both files, recently modified. If `disks.txt` is missing after 70s, the `${execi}` line in `conky.conf.in` did not take; check the rendered `~/.config/conky/conky.conf`.
+
+- [ ] **Step 3: Screenshot the dashboard**
+
+Poll for the workspace switch rather than sleeping through it, and capture the output rather than the window geometry (a window on a hidden special workspace reports a negative Y):
+
+```bash
+hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null
+for i in $(seq 40); do
+ [ "$(hyprctl monitors -j | jq -r '.[0].specialWorkspace.name')" = "special:dash" ] && break
+ sleep 0.15
+done
+sleep 3
+grim -o DP-1 /tmp/dash.png
+hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null
+```
+
+- [ ] **Step 4: Look at the PNG**
+
+Open `/tmp/dash.png` and read it. `grim` exits 0 whether the cards drew correctly, drew nothing, or drew an error.
+
+Check specifically:
+
+- **The equaliser has varying bar heights.** Flat bars mean the per-core counters are not persisting; they are module-level in `widgets/system.lua` precisely so they survive between frames.
+- **Colours differ between cards by state**, not everything one colour. A filesystem at 84% and one at 33% must look different.
+- **The `warning` colour appears at all.** If nothing is ever amber, `color8` did not render; check `grep color8 ~/.config/conky/conky.conf`.
+- **Nothing overlaps or runs past a card edge.**
+
+- [ ] **Step 5: Load the machine and look again**
+
+Thresholds are invisible at idle. Make something happen:
+
+```bash
+stress-ng --cpu 16 --timeout 30s 2>/dev/null &
+sleep 12
+# screenshot again as in Step 3, into /tmp/dash-load.png
+```
+
+If `stress-ng` is not installed, `for i in $(seq 16); do yes >/dev/null & done; sleep 12; kill %1 %2 %3 %4` and so on, or simply build something.
+
+Expected: the equaliser rises and turns amber then red, the CPU percentage follows, temperatures climb. This is the only way to see the colour language working.
+
+- [ ] **Step 6: Fix what the screenshots show**
+
+Likely first-pass problems, each fixed in the widget and re-rendered offscreen before restarting:
+
+- Text colliding with a ring or running past the card edge: reduce the font size or the ring radius.
+- Rings cramped: raise the fallback threshold above 18px so bars take over sooner.
+- Equaliser too short or too tall: adjust the `clamp(inner.h * 0.16, 24, 80)` band.
+- A temperature row missing: the card ran out of vertical room and stopped, which is by design; give the card more rows or drop a sensor.
+
+- [ ] **Step 7: Commit any adjustments**
+
+```bash
+git add widgets/ dashboard.lua
+git commit -m "fix: adjust the system cards against the screenshots"
+```
+
+---
+
+## Task 11: Documentation
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Document the cards and samplers**
+
+Add to `README.md`:
+
+- A **System widgets** section: what the four cards show, that the samplers are scheduled by `${execi}` in `conky.text`, and how to run them by hand (`./bin/disks-sample.sh`, `./bin/cache-sample.sh`).
+- The **host bindings** table from the spec (chip, file, label, ceilings), stating plainly that these are specific to this machine.
+- The **colour language**: `ok`/`warning`/`critical` at 25% and 75% for proportions, per-sensor ceilings for temperatures.
+
+Add to **Gotchas worth knowing**:
+
+- `df` is aliased to `df -h` in the user's shell, so the sampler calls `/usr/bin/df -P -B1` by absolute path. A sampler using bare `df` hands the parser human-readable sizes and every figure silently mis-reads.
+- The Arc B580 exposes no utilisation figure through any stable interface, which is why the GPU card has no load bar.
+- `/data` is on a spinning disk with no hwmon chip, so it has no temperature.
+- Per-core counters are module-level in `widgets/system.lua` because load is a delta between frames; a counter recreated each draw reports nil forever.
+
+- [ ] **Step 2: Verify every documented command**
+
+Run each command the README now claims works and confirm the output matches. A README documenting a command nobody ran is how the last one drifted.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: document the system widgets"
+```
+
+---
+
+## Self-review notes
+
+Checked against the spec: every section has a task. The spec's out-of-scope items (disk I/O rates, GPU utilisation, network) stay out.
+
+Two things this plan cannot settle in advance:
+
+- **The equaliser's bar heights cannot be judged offscreen.** Every counter's first sample is nil, so a one-shot render always draws flat bars. Task 7 says so explicitly and Task 10 Step 5 is where they are actually verified, under load.
+- **The exact band sizes in every widget are a first pass.** Task 10 Step 6 corrects them against screenshots, which is the only way to judge them.
+
+One risk worth naming: `widgets/disks.lua` and `widgets/system.lua` are the two largest widgets so far, and `system.lua` carries CPU, memory and temperatures. If it grows further it should split, but at four sections it is still one coherent card.