aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 18:58:49 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 18:58:49 +0200
commitdb50ae8b65f6be6b2c6d9e2a9a019c8b21adfaab (patch)
tree1e29a56f9c7ecbe811d485d7eb099358880b7558
parent4b6c6b4856c8aa39375542aeb912b56168533a3a (diff)
downloadconky-theme-udt-db50ae8b65f6be6b2c6d9e2a9a019c8b21adfaab.tar.gz
conky-theme-udt-db50ae8b65f6be6b2c6d9e2a9a019c8b21adfaab.zip
docs: plan the network and slackware widgets
Fifteen tasks, TDD throughout: parsers and primitives first, then the samplers, then the cards, then the edits to existing widgets. Verified two assumptions the plan rests on rather than assuming them: Lua 5.4 provides utf8, so card.truncate can cut on a character boundary, and conky's Cairo bindings export cairo_get_line_width, without which the ring line-state fix has nothing to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-09-17-network-slackware-widgets.md2046
1 files changed, 2046 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-17-network-slackware-widgets.md b/docs/superpowers/plans/2026-09-17-network-slackware-widgets.md
new file mode 100644
index 0000000..aadc5a4
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-17-network-slackware-widgets.md
@@ -0,0 +1,2046 @@
+# Network and Slackware 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:** Add a network card (LAN and public address, dual-line throughput chart) and a Slackware card (version, packages, kernel, time since the last ChangeLog change), plus CPU/board identification on the system card and free/total figures under each disk ring.
+
+**Architecture:** Pure parsers in `lib/data.lua` tested against fixtures, shared Cairo primitives in `lib/card.lua`, one file per card in `widgets/`, and anything slow or blocking pushed into a sampler script under `bin/` that writes a cache file. The draw hook reads files and never spawns a subprocess.
+
+**Tech Stack:** Lua 5.4 (conky's embedded interpreter), Cairo via conky's bindings, bash for samplers. No third-party Lua modules: conky's Lua has no `lfs` and no socket library, which is why timestamps come from samplers rather than from `stat` in-process.
+
+**Spec:** `docs/superpowers/specs/2026-09-17-network-slackware-widgets-design.md`
+
+---
+
+## Before You Start
+
+Read `DESIGN.md`. Every card here must obey it: the big value sits at the card's
+top-right, rows run label-left and value-right, type is fluid via
+`card.fit_unit`, and measured quantities take the `ok`/`warning`/`critical`
+colour from `card.threshold`. A card that ignores it is a defect.
+
+Read `AGENTS.md`. The repository is public: **no LAN addresses, hostnames,
+usernames or real locations in committed files.** Fixtures use `192.0.2.x`
+(TEST-NET-1) and generic hardware strings. Derive per-host values at runtime.
+
+Two environment facts that will otherwise waste your time:
+
+- **A Lua error in conky is a blank screen with no message.** Parsers and
+ widgets return nil or a safe default rather than raising.
+- **Conky caches widget modules.** After editing anything under `widgets/` or
+ `lib/`, run `./restart.sh`. Editing `dashboard.lua` alone does not need it.
+
+The test suite is plain `assert`, no framework:
+
+```bash
+lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
+```
+
+Each test file is a script that exits non-zero on the first failed assert. Run
+it directly; there is no test runner and no per-test selection.
+
+---
+
+## File Structure
+
+**Create:**
+
+| File | Responsibility |
+|---|---|
+| `bin/pubip-sample.sh` | Fetch the public address, validate it, write `~/.cache/udt/pubip.txt` |
+| `bin/slackware-sample.sh` | Read five Slackware facts, write `~/.cache/udt/slackware.txt` |
+| `widgets/network.lua` | Draw the network card; owns the history ring buffers |
+| `widgets/slackware.lua` | Draw the Slackware card |
+| `test/fixtures/proc_cpuinfo` | CPU model fixture (AMD form) |
+| `test/fixtures/proc_cpuinfo_intel` | CPU model fixture (Intel form) |
+| `test/fixtures/slackware_cache` | `key value` sampler output |
+| `test/fixtures/ip_addr` | `ip -4 addr` output, TEST-NET address |
+
+**Modify:**
+
+| File | Change |
+|---|---|
+| `lib/data.lua` | Add `new_rate_counter`, `kv_parse`, `cpu_model`, `board_name`, `iface_addr`; add `avail` to `df_parse` |
+| `lib/card.lua` | Add `plot` and `truncate`; fix `ring` to restore line width |
+| `widgets/system.lua` | Two identification rows under the header |
+| `widgets/disks.lua` | Free and total under each ring, replacing the percentage |
+| `widgets/cache.lua` | Use `card.truncate` instead of byte slicing |
+| `test/test_data.lua` | Cases for every new parser, plus the `fs[3]` gap |
+| `conky.conf.in` | Two new `execi` samplers |
+| `dashboard.lua` | Two new layout entries |
+| `README.md` | Document both cards and both samplers |
+| `TODO.md` | Remove the completed item |
+
+**Task order rationale:** primitives and parsers first (Tasks 1-6), because both
+cards depend on them; then the samplers (7, 9); then the cards (8, 10); then the
+edits to existing cards (11, 12); then wiring and documentation (13, 14). Every
+task ends at a commit and leaves the suite green.
+
+---
+
+## Task 1: Fix `card.ring` line-width leak, add `card.truncate`
+
+`card.ring` sets a line width and restores only the cap, so a later stroke
+inherits the ring's width. `card.plot` (Task 2) sets both, so fix the pattern
+once here. `card.truncate` replaces `cache.lua`'s byte slicing, which can split
+a multi-byte UTF-8 name in half and emit an invalid sequence.
+
+**Files:**
+- Modify: `lib/card.lua:182-206` (`M.ring`), and append `M.truncate`
+- Test: `test/test_data.lua` (append a truncate section)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `test/test_data.lua`, before any final summary line:
+
+```lua
+-- === card.truncate ========================================================
+-- Truncation is by CHARACTER, not byte: slicing a UTF-8 string mid-sequence
+-- emits an invalid byte that Cairo draws as a replacement box, and the name
+-- that needed shortening is exactly the kind that carries accents.
+local card = require 'lib.card'
+
+assert(card.truncate('short', 10) == 'short', 'a short string is unchanged')
+assert(card.truncate('exactlyten', 10) == 'exactlyten', 'a string at the limit is unchanged')
+assert(card.truncate('abcdefghijkl', 10) == 'abcdefghi\u{2026}',
+ 'a long string is cut to limit-1 plus an ellipsis, got ' .. tostring(card.truncate('abcdefghijkl', 10)))
+
+-- The multi-byte case: ten accented characters are 20 bytes, so a byte-based
+-- slice would cut one in half and produce invalid UTF-8.
+local accented = string.rep('\u{00E9}', 12)
+local cut = card.truncate(accented, 10)
+assert(cut == string.rep('\u{00E9}', 9) .. '\u{2026}',
+ 'accented input must cut on a character boundary, got ' .. tostring(cut))
+
+assert(card.truncate(nil, 10) == '', 'nil truncates to empty')
+assert(card.truncate('abc', 0) == '', 'a zero limit gives empty')
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: FAIL with `attempt to call a nil value (field 'truncate')`.
+
+- [ ] **Step 3: Implement**
+
+In `lib/card.lua`, change `M.ring` to save and restore the line width. Replace
+the two lines that set width and cap:
+
+```lua
+ width = width or math.max(3, r * 0.18)
+
+ -- Start a fresh path. A preceding card.text() leaves a current point behind,
+ -- and cairo_arc() joins to it with a straight line, so without this every
+ -- ring after a label is drawn with a stray chord to the text baseline.
+ cairo_new_path(cr)
+ local prev_width = cairo_get_line_width(cr)
+ cairo_set_line_width(cr, width)
+ cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
+```
+
+and replace the closing cap restore with both:
+
+```lua
+ -- Leave the line state as it was found. A later stroke inheriting ROUND gets
+ -- visibly rounded ends on the card's hairlines, and one inheriting this
+ -- width gets a hairline several pixels thick.
+ cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT)
+ cairo_set_line_width(cr, prev_width)
+```
+
+Append `M.truncate` to `lib/card.lua`, before the final `return M`:
+
+```lua
+-- Shorten a string to `limit` characters, appending an ellipsis when it cuts.
+--
+-- By character, never by byte: Lua's string.sub counts bytes, so slicing a
+-- UTF-8 name mid-sequence emits an invalid byte, which Cairo draws as a
+-- replacement box. A name long enough to need shortening is exactly the kind
+-- likely to carry an accent.
+--
+-- This counts codepoints, not rendered width, so it does not account for a
+-- double-width CJK glyph. That is the right trade here: the strings it cuts
+-- are cache directory names and hardware model strings. Measure with
+-- M.measure when true rendered width matters.
+function M.truncate(s, limit)
+ s = tostring(s or '')
+ limit = tonumber(limit) or 0
+ if limit <= 0 then return '' end
+ if utf8.len(s) == nil then return s:sub(1, limit) end -- not valid UTF-8: byte-slice
+ if utf8.len(s) <= limit then return s end
+ local cut = utf8.offset(s, limit) -- byte index of the limit'th character
+ return s:sub(1, cut - 1) .. '\u{2026}'
+end
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: PASS, no output, exit 0.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add lib/card.lua test/test_data.lua
+git commit -m "fix: restore the line width card.ring sets, add card.truncate
+
+A ring left its line width behind, so the next stroke on the card inherited
+a hairline several pixels thick. It already restored the cap for the same
+reason.
+
+card.truncate replaces byte slicing, which cuts a multi-byte character in
+half and emits an invalid sequence Cairo draws as a box.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 2: `card.plot`, the two-line chart primitive
+
+**Files:**
+- Modify: `lib/card.lua` (append `M.plot`)
+- Test: verified visually in Task 8; the arithmetic that matters is tested in Task 3
+
+No unit test here: this draws to a Cairo context and produces pixels, which the
+suite has no way to assert against. The project's stated convention is that
+parsers get tests and drawing is verified by screenshot. The scaling arithmetic
+lives in the widget and is tested there.
+
+- [ ] **Step 1: Implement**
+
+Append to `lib/card.lua`, before `return M`:
+
+```lua
+-- A line chart: several series sharing one baseline and one vertical scale.
+--
+-- `series` is a list of { values = <array>, colour = <rgb> }. `max` is the
+-- shared ceiling; passing one rather than computing per series is the whole
+-- point, because two series scaled independently lie about their relative
+-- size: a 200kB/s upload would draw the same height as a 40MB/s download.
+--
+-- Values are drawn oldest-left, one per array entry, so a caller sizing its
+-- history to the pixel width gets one sample per column and no interpolation.
+-- A series shorter than its buffer draws only what it has, which is what a
+-- freshly started dashboard shows while the window fills.
+function M.plot(cr, x, y, w, h, series, max, colors)
+ if not (w > 0 and h > 0) then return end
+ max = tonumber(max) or 0
+ if max <= 0 then max = 1 end -- an idle link is a flat line, not a division by zero
+
+ -- The baseline, so an empty chart still reads as a chart rather than a gap.
+ M.rgba(cr, colors.rule, 0.5)
+ cairo_new_path(cr)
+ cairo_set_line_width(cr, 1)
+ cairo_move_to(cr, x, y + h)
+ cairo_line_to(cr, x + w, y + h)
+ cairo_stroke(cr)
+
+ local prev_width = cairo_get_line_width(cr)
+ local prev_cap = cairo_get_line_cap(cr)
+ cairo_set_line_width(cr, math.max(1.5, h * 0.02))
+ cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
+
+ for _, s in ipairs(series) do
+ local v = s.values
+ local n = #v
+ if n >= 2 then
+ -- One sample per column when the caller sized its buffer to the width.
+ local step = w / math.max(n - 1, 1)
+ M.rgba(cr, s.colour, 1)
+ cairo_new_path(cr)
+ for i = 1, n do
+ local frac = v[i] / max
+ if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
+ local px = x + (i - 1) * step
+ local py = y + h - frac * h
+ if i == 1 then cairo_move_to(cr, px, py) else cairo_line_to(cr, px, py) end
+ end
+ cairo_stroke(cr)
+ end
+ end
+
+ -- Leave the line state as it was found, for the same reason card.ring does.
+ cairo_set_line_width(cr, prev_width)
+ cairo_set_line_cap(cr, prev_cap)
+end
+```
+
+- [ ] **Step 2: Verify it loads**
+
+```bash
+lua -e "package.path='./?.lua;'..package.path; local c=require 'lib.card'; assert(type(c.plot)=='function'); print('ok')"
+```
+
+Expected: `ok`.
+
+- [ ] **Step 3: Run the suite**
+
+```bash
+lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
+```
+
+Expected: no output, exit 0. (`lib/card.lua` is required by the truncate test.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add lib/card.lua
+git commit -m "feat: add card.plot, a shared-scale line chart
+
+Several series on one baseline and one ceiling. The shared ceiling is the
+point: scaled independently, a 200kB/s upload draws the same height as a
+40MB/s download, which is a lie about a link's shape.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 3: `data.new_rate_counter`
+
+A byte counter is cumulative, so a rate needs two samples. This mirrors
+`new_cpu_counter`, which is the established pattern for stateful sampling.
+
+**Files:**
+- Modify: `lib/data.lua` (append after `new_cpu_counter`)
+- Test: `test/test_data.lua`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `test/test_data.lua`:
+
+```lua
+-- === Network rate =========================================================
+-- Interface byte counters are cumulative, so a rate is a delta over elapsed
+-- time. The first call has no previous sample and must report nil: any number
+-- it invented would be wrong, and a spike at startup looks real.
+local r = data.new_rate_counter()
+assert(r:sample(1000, 100) == nil, 'first sample must give nil')
+
+-- 2048 bytes over 2 seconds is 1024 B/s.
+local rate = r:sample(3048, 102)
+assert(rate == 1024, 'second sample, got ' .. tostring(rate))
+
+-- A counter that went backwards means a wrap or an interface reset. It must
+-- clamp to zero, never produce the huge positive an unsigned wrap implies:
+-- one bogus sample poisons the shared autoscale for the whole window.
+local r2 = data.new_rate_counter()
+r2:sample(5000, 100)
+assert(r2:sample(10, 102) == 0, 'a counter reset must give 0, got ' .. tostring(r2:sample(20, 104)))
+
+-- Two samples inside the same clock second. os.time() has whole-second
+-- resolution against a 2s draw interval, so this happens in normal operation
+-- and must not divide by zero.
+local r3 = data.new_rate_counter()
+r3:sample(1000, 500)
+local same_second = r3:sample(2000, 500)
+assert(same_second ~= nil and same_second >= 0,
+ 'a zero time delta must fall back to the draw interval, got ' .. tostring(same_second))
+
+-- A missing interface reads nil, which must propagate rather than raise.
+local r4 = data.new_rate_counter()
+assert(r4:sample(nil, 100) == nil, 'a nil byte count gives nil')
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: FAIL with `attempt to call a nil value (field 'new_rate_counter')`.
+
+- [ ] **Step 3: Implement**
+
+Append to `lib/data.lua`, after `new_cpu_counter`:
+
+```lua
+-- A stateful byte-rate counter, for an interface's rx/tx totals.
+--
+-- Same shape as new_cpu_counter and for the same reason: the counters are
+-- cumulative, so one reading cannot produce a rate, and each direction needs
+-- its own previous sample.
+--
+-- `fallback_dt` is the draw interval, used when two samples land in the same
+-- clock second. os.time() resolves to whole seconds and the dashboard draws
+-- every two, so equal timestamps are ordinary, not exceptional.
+function M.new_rate_counter(fallback_dt)
+ return {
+ prev_bytes = nil,
+ prev_time = nil,
+ -- Returns bytes per second since the previous sample, or nil when there is
+ -- no usable delta (first call, or a nil reading from a vanished interface).
+ sample = function(self, bytes, now)
+ if type(bytes) ~= 'number' then return nil end
+ now = now or os.time()
+ local pb, pt = self.prev_bytes, self.prev_time
+ self.prev_bytes, self.prev_time = bytes, now
+ if not pb then return nil end
+
+ local dt = now - pt
+ -- Same second, or a clock that went backwards over an NTP step.
+ if dt <= 0 then dt = fallback_dt or 2 end
+
+ local db = bytes - pb
+ -- A negative delta is a 32-bit wrap or an interface reset. Zero, never
+ -- the huge positive the wrap arithmetic would imply: one bogus sample
+ -- sets the shared autoscale and flattens the whole window.
+ if db < 0 then db = 0 end
+
+ return db / dt
+ end,
+ }
+end
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: PASS, exit 0.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add lib/data.lua test/test_data.lua
+git commit -m "feat: add a byte-rate counter for interface statistics
+
+Mirrors new_cpu_counter: cumulative counters need two samples to yield a
+rate, and each direction carries its own previous reading.
+
+Clamps a negative delta to zero rather than letting a 32-bit wrap or an
+interface reset produce a spike. One bogus sample sets the shared autoscale
+and flattens the entire window.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 4: `data.kv_parse` and the Slackware fixture
+
+**Files:**
+- Create: `test/fixtures/slackware_cache`
+- Modify: `lib/data.lua`
+- Test: `test/test_data.lua`
+
+- [ ] **Step 1: Write the fixture**
+
+Create `test/fixtures/slackware_cache`. Generic values, not this host's:
+
+```
+version Slackware 15.0+
+packages 1234
+changelog 1758000000
+birth 1700000000
+kernel 6.12.1
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Append to `test/test_data.lua`:
+
+```lua
+-- === kv_parse =============================================================
+-- The slackware sampler writes 'key value' lines. The format is deliberately
+-- dumber than the data: epochs stay integers rather than becoming formatted
+-- dates, so the widget decides how to render an age and the shell never
+-- reconstructs a date string.
+local kv = data.kv_parse(read('test/fixtures/slackware_cache'))
+assert(kv.version == 'Slackware 15.0+', 'version, got ' .. tostring(kv.version))
+-- The value keeps its internal spaces: only the FIRST space is the separator.
+assert(kv.packages == '1234', 'packages, got ' .. tostring(kv.packages))
+assert(kv.changelog == '1758000000', 'changelog, got ' .. tostring(kv.changelog))
+assert(kv.kernel == '6.12.1', 'kernel, got ' .. tostring(kv.kernel))
+
+-- A key the sampler did not write reads nil, which is how the widget tells
+-- "the sampler ran but this fact was unavailable" from "no sampler".
+assert(kv.nonexistent == nil, 'a missing key is nil')
+
+-- Malformed input must not raise: a Lua error in conky is a blank screen.
+assert(type(data.kv_parse('')) == 'table', 'empty input gives a table')
+assert(type(data.kv_parse(nil)) == 'table', 'nil gives a table')
+assert(data.kv_parse('keyonly\n').keyonly == nil, 'a line with no value is skipped')
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: FAIL with `attempt to call a nil value (field 'kv_parse')`.
+
+- [ ] **Step 4: Implement**
+
+Append to `lib/data.lua`:
+
+```lua
+-- Parse 'key value' lines into a table of strings.
+--
+-- Values stay strings and are converted by the caller. The sampler writes
+-- epochs as integers and the widget decides whether an age reads as hours or
+-- days; a parser that guessed would have to know that.
+--
+-- Only the first space separates, so a value keeps its own spaces:
+-- 'version Slackware 15.0+' yields 'Slackware 15.0+', not 'Slackware'.
+function M.kv_parse(text)
+ local out = {}
+ if type(text) ~= 'string' then return out end
+ for line in text:gmatch('[^\n]+') do
+ local k, v = line:match('^(%S+)%s+(.*)$')
+ if k and v ~= '' then out[k] = v end
+ end
+ return out
+end
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: PASS, exit 0.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/data.lua test/test_data.lua test/fixtures/slackware_cache
+git commit -m "feat: parse the sampler's key/value cache format
+
+Only the first space separates, so a value keeps its own spaces. Values stay
+strings: the sampler writes epochs and the widget decides whether an age
+reads as hours or days.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 5: `data.cpu_model` and `data.board_name`
+
+**Files:**
+- Create: `test/fixtures/proc_cpuinfo`, `test/fixtures/proc_cpuinfo_intel`
+- Modify: `lib/data.lua`
+- Test: `test/test_data.lua`
+
+- [ ] **Step 1: Write the fixtures**
+
+Create `test/fixtures/proc_cpuinfo` (trimmed to the fields the parser reads):
+
+```
+processor : 0
+vendor_id : AuthenticAMD
+cpu family : 26
+model : 68
+model name : AMD Ryzen 7 9700X 8-Core Processor
+stepping : 0
+cpu MHz : 4491.436
+cache size : 1024 KB
+
+processor : 1
+vendor_id : AuthenticAMD
+model name : AMD Ryzen 7 9700X 8-Core Processor
+```
+
+Create `test/fixtures/proc_cpuinfo_intel`:
+
+```
+processor : 0
+vendor_id : GenuineIntel
+cpu family : 6
+model : 158
+model name : Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz
+stepping : 10
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Append to `test/test_data.lua`:
+
+```lua
+-- === Hardware identification ==============================================
+-- Both strings are constant for the machine's life, so the widget memoizes
+-- them. What is tested here is the stripping, which must be a rule rather
+-- than a special case for one host: the Intel fixture exists to prove it.
+assert(data.cpu_model(read('test/fixtures/proc_cpuinfo')) == 'Ryzen 7 9700X',
+ 'amd cpu, got ' .. tostring(data.cpu_model(read('test/fixtures/proc_cpuinfo'))))
+assert(data.cpu_model(read('test/fixtures/proc_cpuinfo_intel')) == 'Core i7-8700K',
+ 'intel cpu, got ' .. tostring(data.cpu_model(read('test/fixtures/proc_cpuinfo_intel'))))
+
+-- Unparseable input yields nil, so the card shows nothing rather than a
+-- half-stripped string.
+assert(data.cpu_model('') == nil, 'empty cpuinfo gives nil')
+assert(data.cpu_model(nil) == nil, 'nil cpuinfo gives nil')
+assert(data.cpu_model('processor\t: 0\n') == nil, 'cpuinfo with no model name gives nil')
+
+-- The board is two sysfs files joined, with the vendor's corporate suffix
+-- dropped: it is boilerplate on every board and costs a third of the row.
+assert(data.board_name('Gigabyte Technology Co., Ltd.\n', 'X870 EAGLE WIFI7\n')
+ == 'Gigabyte X870 EAGLE WIFI7',
+ 'board, got ' .. tostring(data.board_name('Gigabyte Technology Co., Ltd.\n', 'X870 EAGLE WIFI7\n')))
+assert(data.board_name('ASUSTeK COMPUTER INC.\n', 'PRIME B650-PLUS\n')
+ == 'ASUSTeK PRIME B650-PLUS',
+ 'asus board, got ' .. tostring(data.board_name('ASUSTeK COMPUTER INC.\n', 'PRIME B650-PLUS\n')))
+
+-- A machine that reports one and not the other shows what it has.
+assert(data.board_name(nil, 'X870 EAGLE WIFI7\n') == 'X870 EAGLE WIFI7',
+ 'name alone, got ' .. tostring(data.board_name(nil, 'X870 EAGLE WIFI7\n')))
+assert(data.board_name('Gigabyte\n', nil) == 'Gigabyte', 'vendor alone')
+assert(data.board_name(nil, nil) == nil, 'neither gives nil')
+
+-- A virtual machine reports placeholder DMI strings. Showing 'To be filled by
+-- O.E.M.' as the motherboard is worse than showing nothing.
+assert(data.board_name('To Be Filled By O.E.M.\n', 'To Be Filled By O.E.M.\n') == nil,
+ 'placeholder DMI gives nil')
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: FAIL with `attempt to call a nil value (field 'cpu_model')`.
+
+- [ ] **Step 4: Implement**
+
+Append to `lib/data.lua`:
+
+```lua
+-- The CPU's marketing name, stripped to what identifies it.
+--
+-- '/proc/cpuinfo' repeats the model name once per thread; the first is enough.
+-- The stripping is rules, not a table of known CPUs: a leading vendor word,
+-- the registered-trademark noise, and a trailing core count or clock, all of
+-- which are constant boilerplate that costs a third of the row on a card
+-- measured in pixels.
+--
+-- AMD Ryzen 7 9700X 8-Core Processor -> Ryzen 7 9700X
+-- Intel(R) Core(TM) i7-8700K CPU @ 3.7GHz -> Core i7-8700K
+function M.cpu_model(cpuinfo)
+ if type(cpuinfo) ~= 'string' then return nil end
+ local s = cpuinfo:match('model name%s*:%s*([^\n]+)')
+ if not s then return nil end
+ s = s:gsub('%(R%)', ''):gsub('%(TM%)', ''):gsub('%(tm%)', '')
+ s = s:gsub('^%s*AMD%s+', ''):gsub('^%s*Intel%s+', '')
+ s = s:gsub('%s+%d+%-Core Processor.*$', '')
+ s = s:gsub('%s+CPU%s*@.*$', '')
+ s = s:gsub('%s+Processor%s*$', '')
+ s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '')
+ if s == '' then return nil end
+ return s
+end
+
+-- The motherboard, from the two world-readable DMI files.
+--
+-- board_vendor and board_name are readable without privilege, unlike the
+-- serial fields in the same directory. The vendor's corporate suffix is
+-- dropped because every vendor has one and none of it identifies the board.
+--
+-- A board reporting the DMI placeholder is treated as no board at all:
+-- 'To Be Filled By O.E.M.' on the dashboard is worse than a blank row.
+local DMI_PLACEHOLDER = {
+ ['to be filled by o.e.m.'] = true,
+ ['system manufacturer'] = true,
+ ['default string'] = true,
+ ['unknown'] = true,
+ ['n/a'] = true,
+}
+
+local function dmi_clean(s)
+ if type(s) ~= 'string' then return nil end
+ s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '')
+ if s == '' or DMI_PLACEHOLDER[s:lower()] then return nil end
+ return s
+end
+
+function M.board_name(vendor, name)
+ vendor = dmi_clean(vendor)
+ name = dmi_clean(name)
+ if vendor then
+ -- Corporate boilerplate, longest first so 'Co., Ltd.' does not leave 'Co.'
+ vendor = vendor:gsub('%s+Technology Co%.,? Ltd%.?$', '')
+ vendor = vendor:gsub('%s+COMPUTER INC%.?$', '')
+ vendor = vendor:gsub('%s+Corporation$', ''):gsub('%s+Corp%.?$', '')
+ vendor = vendor:gsub('%s+Inc%.?$', ''):gsub('%s+INC%.?$', '')
+ vendor = vendor:gsub('%s+Co%.,?%s*Ltd%.?$', ''):gsub('%s+CO%.,?%s*LTD%.?$', '')
+ vendor = vendor:gsub('%s+GmbH$', ''):gsub('%s+LLC$', '')
+ vendor = vendor:gsub('%s+$', '')
+ if vendor == '' then vendor = nil end
+ end
+ if vendor and name then return vendor .. ' ' .. name end
+ return name or vendor
+end
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: PASS, exit 0.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/data.lua test/test_data.lua test/fixtures/proc_cpuinfo test/fixtures/proc_cpuinfo_intel
+git commit -m "feat: parse the CPU and motherboard model strings
+
+Stripping by rule rather than by a table of known parts: a leading vendor
+word, trademark noise, a trailing core count or clock. The Intel fixture
+exists to keep it a rule.
+
+DMI placeholders read as no board at all. 'To Be Filled By O.E.M.' on the
+dashboard is worse than a blank row.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 6: `avail` in `df_parse`, and the `fs[3]` gap
+
+The disks card needs free space, and `df -P -B1` already prints it. Deriving it
+as `size - used` would be wrong by the root-reserved blocks, typically 5%, which
+on a 263GB root is about 13GB of space you cannot actually use.
+
+This task also closes a gap recorded in the previous session's review: the
+suite never asserts `fs[3]`, the row whose device name ends in a digit
+(`/dev/sda1`), which is the row most likely to break a parser counting fields.
+
+**Files:**
+- Modify: `lib/data.lua` (`M.df_parse`)
+- Test: `test/test_data.lua`
+
+- [ ] **Step 1: Write the failing test**
+
+Insert into `test/test_data.lua`, in the `df_parse` section after the `fs[1]`
+assertions (around line 104):
+
+```lua
+-- Available is READ, never derived as size - used: the two differ by the
+-- root-reserved blocks, about 5%, which on this root is some 13GB that exists
+-- but cannot be used. A card claiming that space is free would be lying.
+assert(fs[1].avail == 41746907136, 'root available, got ' .. tostring(fs[1].avail))
+assert(fs[1].size - fs[1].used ~= fs[1].avail,
+ 'the fixture must exercise the reserved-block gap, or this assertion proves nothing')
+
+-- fs[3] is /dev/sda1: a device name ENDING IN A DIGIT, next to a numeric
+-- column. A parser counting fields from the left, or matching digits without
+-- anchoring, reads the partition number as a size.
+assert(fs[3].mount == '/data', 'sda1 mount, got ' .. tostring(fs[3].mount))
+assert(fs[3].dev == '/dev/sda1', 'sda1 device, got ' .. tostring(fs[3].dev))
+assert(fs[3].size == 983350091776, 'sda1 size, got ' .. tostring(fs[3].size))
+assert(fs[3].used == 547869650944, 'sda1 used, got ' .. tostring(fs[3].used))
+assert(fs[3].avail == 385453473792, 'sda1 available, got ' .. tostring(fs[3].avail))
+assert(fs[3].pct == 59, 'sda1 percent, got ' .. tostring(fs[3].pct))
+assert(fs[3].host == nil, 'a local device has no host')
+
+-- A full filesystem reports zero available, which is a real reading.
+assert(fs[6].avail == 0, 'a full mount has 0 available, got ' .. tostring(fs[6].avail))
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: FAIL at `root available, got nil`.
+
+- [ ] **Step 3: Implement**
+
+In `lib/data.lua`, change the pattern in `M.df_parse` to capture the available
+column, and add it to the returned entry:
+
+```lua
+ -- 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, avail, pct, mount =
+ line:match('(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%%%s+(%S+)%s*$')
+```
+
+and in the table it builds:
+
+```lua
+ out[#out + 1] = {
+ mount = mount,
+ dev = dev,
+ host = dev and dev:match('^([^/:]+):') or nil,
+ size = tonumber(size),
+ used = tonumber(used),
+ -- Read, not derived: size - used overstates free space by the
+ -- root-reserved blocks, which is tens of gigabytes on a large
+ -- filesystem and is not available to anyone but root.
+ avail = tonumber(avail),
+ pct = tonumber(pct),
+ }
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: PASS, exit 0.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add lib/data.lua test/test_data.lua
+git commit -m "feat: read the available column from df
+
+Free space is read rather than derived: size - used overstates it by the
+root-reserved blocks, tens of gigabytes on a large filesystem, none of it
+available to anyone but root.
+
+Also asserts fs[3], the row whose device name ends in a digit. It sits next
+to a numeric column and is the row most likely to break a parser counting
+fields, and nothing covered it.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 7: The public IP sampler
+
+**Files:**
+- Create: `bin/pubip-sample.sh`
+
+- [ ] **Step 1: Write the sampler**
+
+Create `bin/pubip-sample.sh`:
+
+```bash
+#!/bin/bash
+# Fetch the public IP address into a cache file, for widgets/network.lua.
+#
+# Run from conky's ${execi} every 30 minutes. The draw hook must never make a
+# network call: a hung request would block the Cairo draw and freeze the whole
+# dashboard, and a residential address is stable for days anyway.
+#
+# Exits non-zero with a message on stderr when it cannot fetch, leaving any
+# existing cache untouched rather than replacing a good address with an error
+# page.
+
+set -u
+
+CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
+CACHE="$CACHE_DIR/pubip.txt"
+
+umask 077
+mkdir -p "$CACHE_DIR"
+
+# The temp file sits in the same directory as the target: rename is only
+# atomic within a filesystem, and the widget reads this on its own 2s cadence.
+TMP="$CACHE.tmp.$$"
+trap 'rm -f "$TMP"' EXIT
+
+# --max-time, not just --connect-timeout: a server that accepts the connection
+# and then stalls would otherwise hold a curl process for as long as it likes.
+if ! IP=$(curl -fsS --max-time 10 https://ipinfo.io/ip 2>/dev/null); then
+ echo "pubip-sample: fetch failed" >&2
+ exit 1
+fi
+
+# Validate before writing. A captive portal, a rate-limit message and an error
+# page all arrive with a 200 and would otherwise be written to the cache and
+# drawn on the dashboard as though they were an address.
+IP=$(echo "$IP" | tr -d '[:space:]')
+if ! echo "$IP" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
+ echo "pubip-sample: response is not an IPv4 address" >&2
+ exit 1
+fi
+
+# The epoch travels with the address so the widget can refuse to show a stale
+# one. Same 'key value' format the slackware sampler uses.
+{
+ echo "ip $IP"
+ echo "fetched $(date +%s)"
+} > "$TMP"
+
+mv -f "$TMP" "$CACHE"
+```
+
+- [ ] **Step 2: Make it executable and run it**
+
+```bash
+chmod +x bin/pubip-sample.sh
+./bin/pubip-sample.sh && cat ~/.cache/udt/pubip.txt
+```
+
+Expected: two lines, `ip <your address>` and `fetched <epoch>`. **Do not paste
+the address into a commit message, a comment or a fixture.**
+
+- [ ] **Step 3: Verify the cache file's permissions**
+
+```bash
+stat -c '%a %n' ~/.cache/udt/pubip.txt
+```
+
+Expected: `600`.
+
+- [ ] **Step 4: Verify it rejects a bad response**
+
+```bash
+bash -c 'curl() { echo "<html>captive portal</html>"; }; export -f curl; ./bin/pubip-sample.sh'; echo "exit=$?"
+```
+
+Expected: `pubip-sample: response is not an IPv4 address` on stderr and a
+non-zero exit. (If the function export does not take effect in your shell, skip
+this step: the validation is exercised by inspection.)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add bin/pubip-sample.sh
+git commit -m "feat: sample the public IP into a cache file
+
+The draw hook must never make a network call: a hung request blocks the
+Cairo draw and freezes the dashboard. Thirty minutes, not the old config's
+five, because a residential address is stable for days.
+
+Validates the response before writing. A captive portal and a rate-limit
+message both arrive with a 200 and would otherwise be drawn as an address.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 8: The network card
+
+**Files:**
+- Create: `widgets/network.lua`
+- Modify: `lib/data.lua` (add `iface_addr`)
+- Create: `test/fixtures/ip_addr`
+- Test: `test/test_data.lua`
+
+- [ ] **Step 1: Write the fixture**
+
+Create `test/fixtures/ip_addr`. **TEST-NET-1 (`192.0.2.0/24`), never a real
+address:**
+
+```
+4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
+ inet 192.0.2.15/24 brd 192.0.2.255 scope global br0
+ valid_lft forever preferred_lft forever
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Append to `test/test_data.lua`:
+
+```lua
+-- === Interface address ====================================================
+-- The fixture uses TEST-NET-1 (192.0.2.0/24, RFC 5737). This repository is
+-- public: no real LAN address belongs in it, and no test may assert one.
+local addr = data.iface_addr_parse(read('test/fixtures/ip_addr'))
+assert(addr == '192.0.2.15', 'lan address, got ' .. tostring(addr))
+
+-- A down interface has no inet line. nil, so the card shows '--' rather than
+-- a stale or invented address.
+assert(data.iface_addr_parse('5: br0: <BROADCAST,MULTICAST> mtu 1500 state DOWN\n') == nil,
+ 'a down interface gives nil')
+assert(data.iface_addr_parse('') == nil, 'empty gives nil')
+assert(data.iface_addr_parse(nil) == nil, 'nil gives nil')
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: FAIL with `attempt to call a nil value (field 'iface_addr_parse')`.
+
+- [ ] **Step 4: Implement the parser**
+
+Append to `lib/data.lua`:
+
+```lua
+-- The IPv4 address from `ip -4 addr show <iface>` output.
+--
+-- Split from the command that produces it so it can be tested against a
+-- fixture: every other parser in this file follows the same split, and an
+-- address is exactly the kind of value that must not be hardcoded in a test.
+function M.iface_addr_parse(text)
+ if type(text) ~= 'string' then return nil end
+ return text:match('inet%s+(%d+%.%d+%.%d+%.%d+)')
+end
+
+-- The interface's IPv4 address, or nil when it has none.
+--
+-- This shells out, which the draw hook otherwise never does, so the caller
+-- memoizes it: an address does not change without an event this dashboard
+-- does not watch. Retried while nil, because a bridge may not be up when
+-- conky starts.
+function M.iface_addr(iface)
+ if type(iface) ~= 'string' then return nil end
+ local p = io.popen('ip -4 addr show ' .. ("%q"):format(iface) .. ' 2>/dev/null')
+ if not p then return nil end
+ local out = p:read('*a')
+ p:close()
+ return M.iface_addr_parse(out)
+end
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+```bash
+lua test/test_data.lua
+```
+
+Expected: PASS, exit 0.
+
+- [ ] **Step 6: Write the widget**
+
+Create `widgets/network.lua`:
+
+```lua
+-- Network: throughput as two lines on one chart, with the LAN and public
+-- addresses.
+--
+-- The rates come from the interface's own byte counters, read every draw: two
+-- file reads, no subprocess. The public address comes from a cache file,
+-- because a network call in the draw hook would freeze the dashboard when it
+-- hung.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local M = {}
+
+-- The interface to graph. br0 is this host's bridge, matching the old text
+-- config.
+--
+-- Note what this means: a bridge carries VM-to-host traffic that never reaches
+-- the router, so a local copy spikes the graph. That was true of the old
+-- config too and is accepted; this constant is the one edit that changes it.
+local IFACE = 'br0'
+
+local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
+ .. '/udt/pubip.txt'
+
+-- A public address older than this is not shown. Eight sampling intervals: by
+-- then the sampler has failed repeatedly, and an address that may no longer be
+-- yours displayed with confidence is worse than '--'.
+local STALE_AFTER = 4 * 3600
+
+local STAT = '/sys/class/net/' .. IFACE .. '/statistics/'
+
+-- Module state: the history outlives the frame, which is the whole point.
+-- It does not outlive a conky restart, so the chart starts empty and fills
+-- left to right.
+local rx_counter, tx_counter
+local rx_hist, tx_hist = {}, {}
+local hist_len = 0
+local lan_addr = nil
+
+-- Append to a fixed-length history, dropping the oldest. A plain array with
+-- table.remove(1) rather than a circular buffer with an index: the lengths
+-- here are a few hundred at most and the draw is every two seconds, so the
+-- O(n) shift is free and the array is already in draw order.
+local function push(hist, v, len)
+ hist[#hist + 1] = v
+ while #hist > len do table.remove(hist, 1) end
+end
+
+local function read_counter(file)
+ local s = data.slurp(STAT .. file)
+ if not s then return nil end
+ return tonumber(s:match('^%s*(%d+)'))
+end
+
+-- Bytes per second to a short string. Deliberately not card.human: a rate
+-- wants a '/s' and one decimal at most, and reusing card.human would put a
+-- suffix meant for capacity onto a speed.
+local function rate_str(bps)
+ if not bps then return '--' end
+ local units = { 'B', 'K', 'M', 'G' }
+ local n, i = bps, 1
+ while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end
+ if i == 1 then return string.format('%d%s/s', math.floor(n), units[i]) end
+ if n >= 100 then return string.format('%.0f%s/s', n, units[i]) end
+ return string.format('%.1f%s/s', n, units[i])
+end
+
+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 interval = (conky_info and conky_info.update_interval) or 2
+ rx_counter = rx_counter or data.new_rate_counter(interval)
+ tx_counter = tx_counter or data.new_rate_counter(interval)
+
+ local down = rx_counter:sample(read_counter('rx_bytes'))
+ local up = tx_counter:sample(read_counter('tx_bytes'))
+
+ -- Fluid type, as every other card does: one base size S drives everything,
+ -- taken as the smaller of a height budget and a measured width fit.
+ local LABEL_F, BIG_F, ROW_F = 0.30, 0.62, 0.30
+ local groups = {
+ { { 'NET', card.FONT_MONO, LABEL_F }, { '888.8M/s', card.FONT_HEAVY, BIG_F } },
+ { { '\u{F0318} 192.000.000.000', card.FONT_MONO, ROW_F } },
+ { { '\u{F0319} 888.8M/s', card.FONT_MONO, ROW_F },
+ { '\u{F01DA} 888.8M/s', card.FONT_MONO, ROW_F } },
+ }
+ local fixed_units = 1.0 + ROW_F * 2.2 * 3
+ local S = clamp(math.min(inner.h * 0.96 / (fixed_units + 1.2),
+ card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
+ local label_size = S * LABEL_F
+ local row_size = S * ROW_F
+
+ -- Header: the label left, the download rate as the big value at the right.
+ -- Download is the headline because it is the number that moves.
+ card.font(cr, card.FONT_HEAVY, S * BIG_F, false)
+ card.rgba(cr, colors.body)
+ card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, rate_str(down))
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, inner.y + label_size, 'NET')
+
+ local ey = inner.y + S * BIG_F + row_size * 1.4
+ local step = row_size * 2.2
+
+ -- The addresses. LAN is memoized on first success and retried while nil: a
+ -- bridge may not be up when conky starts.
+ lan_addr = lan_addr or data.iface_addr(IFACE)
+ card.font(cr, card.FONT_MONO, row_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, '\u{F0318}') -- LAN
+ card.rgba(cr, colors.value)
+ card.text_right(cr, inner.x + inner.w, ey, lan_addr or '--')
+ ey = ey + step
+
+ local pub = data.kv_parse(data.slurp(CACHE) or '')
+ local fetched = tonumber(pub.fetched)
+ local pub_txt = '--'
+ if pub.ip and fetched and (os.time() - fetched) < STALE_AFTER then
+ pub_txt = pub.ip
+ end
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, '\u{F059F}') -- globe
+ card.rgba(cr, pub_txt == '--' and colors.label or colors.value)
+ card.text_right(cr, inner.x + inner.w, ey, pub_txt)
+ ey = ey + step * 1.1
+
+ -- The chart takes the room left between the addresses and the rate row.
+ local rate_row_h = row_size * 2.4
+ local chart_top = ey
+ local chart_h = (inner.y + inner.h) - chart_top - rate_row_h
+ if chart_h > 12 then
+ -- One sample per pixel column, so the chart never interpolates. The
+ -- buffer is resized when the card is, keeping what it can: a moved card
+ -- should not clear the history.
+ local want = math.max(8, math.floor(inner.w))
+ if want ~= hist_len then
+ hist_len = want
+ while #rx_hist > hist_len do table.remove(rx_hist, 1) end
+ while #tx_hist > hist_len do table.remove(tx_hist, 1) end
+ end
+ if down then push(rx_hist, down, hist_len) end
+ if up then push(tx_hist, up, hist_len) end
+
+ -- One ceiling for both series. Scaled independently they would lie about
+ -- their relative size, which is the entire reason to draw them together.
+ local peak = 0
+ for _, v in ipairs(rx_hist) do if v > peak then peak = v end end
+ for _, v in ipairs(tx_hist) do if v > peak then peak = v end end
+
+ card.plot(cr, inner.x, chart_top, inner.w, chart_h, {
+ { values = rx_hist, colour = colors.ok },
+ { values = tx_hist, colour = colors.highlight },
+ }, peak, colors)
+
+ -- The peak, so a full-height line means something in absolute terms.
+ card.font(cr, card.FONT_MONO, row_size * 0.85, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, chart_top + row_size * 0.85, rate_str(peak))
+ end
+
+ -- The live rates, each in its series colour so the line and the number are
+ -- unmistakably the same thing.
+ local ry = inner.y + inner.h - row_size * 0.4
+ card.font(cr, card.FONT_MONO, row_size, false)
+ card.rgba(cr, colors.ok)
+ card.text(cr, inner.x, ry, '\u{F0319} ' .. rate_str(down))
+ card.rgba(cr, colors.highlight)
+ card.text_right(cr, inner.x + inner.w, ry, '\u{F01DA} ' .. rate_str(up))
+end
+
+return M
+```
+
+- [ ] **Step 7: Render it and look at it**
+
+```bash
+lua test/render.lua network 16 12 4 4 /tmp/net.png
+```
+
+Expected: a PNG at `/tmp/net.png`. **Open it and check every glyph.** A
+wrong-but-present codepoint draws a plausible neighbour rather than failing, so
+confirm: `\u{F0318}` is a LAN/ethernet mark, `\u{F059F}` a globe, `\u{F0319}` a
+download arrow, `\u{F01DA}` an upload arrow. If any is wrong, find the right
+codepoint and re-render before continuing.
+
+The chart will be empty on a single render: the history needs two samples and
+this harness draws one frame. That is expected here and is what the live board
+is for.
+
+- [ ] **Step 8: Run the suite**
+
+```bash
+lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
+```
+
+Expected: exit 0.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add widgets/network.lua lib/data.lua test/test_data.lua test/fixtures/ip_addr
+git commit -m "feat: add the network card
+
+Two lines on one chart against a shared ceiling, one sample per pixel column
+so nothing is interpolated. The history is module state sized from the card's
+width, so moving the card reframes the window rather than clearing it.
+
+The public address is refused once stale: an address that may no longer be
+yours, displayed with confidence, is worse than a dash.
+
+The fixture uses TEST-NET-1. This repository is public.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 9: The Slackware sampler
+
+**Files:**
+- Create: `bin/slackware-sample.sh`
+
+- [ ] **Step 1: Write the sampler**
+
+Create `bin/slackware-sample.sh`:
+
+```bash
+#!/bin/bash
+# Sample Slackware facts into a cache file, for widgets/slackware.lua.
+#
+# Conky's Lua has no lfs, so it cannot stat a file for an mtime, and counting
+# 2800 package files every two seconds would be a directory walk per draw.
+# Both belong here.
+
+set -u
+
+CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
+CACHE="$CACHE_DIR/slackware.txt"
+
+umask 077
+mkdir -p "$CACHE_DIR"
+
+TMP="$CACHE.tmp.$$"
+trap 'rm -f "$TMP"' EXIT
+
+VERSION=$(cat /etc/slackware-version 2>/dev/null || echo unknown)
+
+# The TRAILING SLASH is load-bearing. /var/log/packages is a symlink to
+# /var/lib/pkgtools/packages, and `ls -1 /var/log/packages` lists the link
+# itself: one entry. With the slash it lists the target's contents, which on
+# this machine is some 2800 packages. Do not remove it as cosmetic.
+PACKAGES=$(ls -1 /var/log/packages/ 2>/dev/null | wc -l)
+
+# Epochs, never formatted dates. The shell function this replaces rebuilds a
+# date string from `ls -l` output with the year hardcoded, which breaks every
+# January and on any file older than six months, when ls prints a year instead
+# of a time and the field offsets shift.
+CHANGELOG=$(stat -c %Y /var/lib/slackpkg/ChangeLog.txt 2>/dev/null || echo 0)
+
+# Filesystem birth time: the install date. Not every filesystem records it,
+# and those that do not report 0, which the widget shows as '--'.
+BIRTH=$(stat -c %W / 2>/dev/null || echo 0)
+
+KERNEL=$(uname -r)
+
+{
+ echo "version $VERSION"
+ echo "packages $PACKAGES"
+ echo "changelog $CHANGELOG"
+ echo "birth $BIRTH"
+ echo "kernel $KERNEL"
+} > "$TMP"
+
+mv -f "$TMP" "$CACHE"
+```
+
+- [ ] **Step 2: Make it executable and run it**
+
+```bash
+chmod +x bin/slackware-sample.sh
+./bin/slackware-sample.sh && cat ~/.cache/udt/slackware.txt
+```
+
+Expected: five lines.
+
+- [ ] **Step 3: Verify the package count against a known-good value**
+
+```bash
+ls -1 /var/log/packages/ | wc -l
+grep '^packages' ~/.cache/udt/slackware.txt
+```
+
+Expected: the same number, in the thousands. **If it reads 1, the trailing
+slash was dropped.** That is the specific failure this step exists to catch.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add bin/slackware-sample.sh
+git commit -m "feat: sample the Slackware facts into a cache file
+
+Conky's Lua has no lfs, so it cannot stat a file, and counting 2800 package
+files per draw would be a directory walk every two seconds.
+
+Writes epochs rather than formatted dates. The shell function this replaces
+rebuilds a date from ls -l output with the year hardcoded, which breaks every
+January and on any file older than six months.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 10: The Slackware card
+
+**Files:**
+- Create: `widgets/slackware.lua`
+
+- [ ] **Step 1: Write the widget**
+
+Create `widgets/slackware.lua`:
+
+```lua
+-- Slackware: the distribution version, package count, kernel, install age, and
+-- how long since slackpkg's ChangeLog last changed.
+--
+-- Everything comes from a cache file written by bin/slackware-sample.sh:
+-- conky's Lua cannot stat a file for an mtime, and counting the package
+-- directory every two seconds would be a walk per draw.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
+ .. '/udt/slackware.txt'
+
+-- Hours. A ChangeLog under a day old is current, under a week is ordinary, and
+-- past that is worth noticing. This extends the shell prompt's binary green/red
+-- into the board's three-state language rather than adding a fourth convention.
+local WARN_H, CRIT_H = 24, 168
+
+local M = {}
+
+-- An age in seconds as a short string: hours below two days, then days. Short
+-- because it is the card's big value and shares its line with the label.
+local function age_str(seconds)
+ if not seconds then return '--' end
+ -- A future timestamp yields a negative age. Clock skew and a mirror's dated
+ -- file both produce it, and '-8000h' on the dashboard reads as a bug.
+ if seconds < 0 then seconds = 0 end
+ local hours = seconds / 3600
+ if hours < 48 then return string.format('%dh', math.floor(hours)) end
+ return string.format('%dd', math.floor(hours / 24))
+end
+
+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_F, BIG_F, ROW_F = 0.30, 0.85, 0.32
+
+ local kv = data.kv_parse(data.slurp(CACHE) or '')
+
+ -- No cache at all: the sampler has not run. Name it, as the cache card does,
+ -- rather than leaving a blank cell that is indistinguishable from a crash.
+ if not kv.version then
+ local S = clamp(inner.h * 0.94 / (1.78 + 2 * 0.72), 10, 72)
+ S = math.min(S, card.fit_unit(cr, inner.w * 0.96, {
+ { { 'SLACKWARE', card.FONT_MONO, LABEL_F } },
+ { { 'no slackware data', card.FONT_UI, 0.36 } },
+ { { 'bin/slackware-sample.sh', card.FONT_MONO, ROW_F } },
+ }, 100))
+ card.font(cr, card.FONT_MONO, S * LABEL_F, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, inner.y + S * LABEL_F, 'SLACKWARE')
+ card.font(cr, card.FONT_UI, S * 0.36, true)
+ card.text(cr, inner.x, inner.y + S + S * LABEL_F * 2.6, 'no slackware data')
+ card.font(cr, card.FONT_MONO, S * ROW_F, false)
+ card.text(cr, inner.x, inner.y + S + S * LABEL_F * 2.6 + S * 0.72,
+ 'bin/slackware-sample.sh')
+ return
+ end
+
+ -- The ChangeLog age, the card's headline. A cache that exists without this
+ -- key is a different failure from no cache: the sampler ran and the
+ -- ChangeLog is what was missing, so the rows still draw and only this reads
+ -- '--'.
+ local changelog = tonumber(kv.changelog)
+ local age_s = (changelog and changelog > 0) and (os.time() - changelog) or nil
+ local age_txt = age_str(age_s)
+ local age_colour = colors.label
+ if age_s then
+ age_colour = card.threshold(math.max(age_s, 0) / 3600, WARN_H, CRIT_H, colors)
+ end
+
+ -- 'Slackware 15.0+' -> '15.0+': the header already says which distribution.
+ local version = (kv.version or ''):gsub('^Slackware%s+', '')
+
+ local birth = tonumber(kv.birth)
+ local install_age = (birth and birth > 0)
+ and string.format('%dd', math.floor((os.time() - birth) / 86400)) or '--'
+
+ local rows = {
+ { 'VERSION', version ~= '' and version or '--' },
+ { 'PACKAGES', kv.packages or '--' },
+ { 'KERNEL', kv.kernel or '--' },
+ { 'AGE', install_age },
+ }
+
+ -- Fluid type: the height budget grows the content to fill the cell, the
+ -- measured width fit pulls it back where a row would overrun.
+ local groups = {
+ { { 'SLACKWARE', card.FONT_MONO, LABEL_F }, { age_txt, card.FONT_HEAVY, BIG_F } },
+ }
+ for _, r in ipairs(rows) do
+ groups[#groups + 1] = { { r[1], card.FONT_MONO, ROW_F },
+ { tostring(r[2]), card.FONT_MONO, ROW_F } }
+ end
+ local S = clamp(math.min(inner.h * 0.94 / (1.5 + #rows * 0.66),
+ card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
+ local label_size = S * LABEL_F
+ local row_size = S * ROW_F
+
+ card.font(cr, card.FONT_HEAVY, S * BIG_F, false)
+ card.rgba(cr, age_colour)
+ card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, age_txt)
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, inner.y + label_size, 'SLACKWARE')
+
+ local ey = inner.y + S * BIG_F + row_size * 1.6
+ local step = row_size * 2.0
+ card.font(cr, card.FONT_MONO, row_size, false)
+ for _, r in ipairs(rows) do
+ if ey + step > inner.y + inner.h then break end
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, r[1])
+ card.rgba(cr, colors.value)
+ card.text_right(cr, inner.x + inner.w, ey, tostring(r[2]))
+ ey = ey + step
+ end
+end
+
+return M
+```
+
+- [ ] **Step 2: Render it and look at it**
+
+```bash
+lua test/render.lua slackware 16 12 3 4 /tmp/slack.png
+```
+
+Expected: a PNG showing the age top-right in a threshold colour, `SLACKWARE`
+top-left, and four rows.
+
+- [ ] **Step 3: Verify the no-cache state draws a notice**
+
+```bash
+mv ~/.cache/udt/slackware.txt /tmp/slackware.bak
+lua test/render.lua slackware 16 12 3 4 /tmp/slack_empty.png
+mv /tmp/slackware.bak ~/.cache/udt/slackware.txt
+```
+
+Expected: `/tmp/slack_empty.png` shows `no slackware data` and the sampler's
+name, not a blank card.
+
+- [ ] **Step 4: Run the suite**
+
+```bash
+lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
+```
+
+Expected: exit 0.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add widgets/slackware.lua
+git commit -m "feat: add the slackware card
+
+The big value is the time since slackpkg's ChangeLog changed, coloured green
+under a day, amber to a week, red past it.
+
+A cache with no changelog key is a different failure from no cache at all:
+the sampler ran and the ChangeLog is what was missing, so the rows still draw
+and only the age reads '--'. Collapsing the two would send the reader to the
+wrong problem.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 11: CPU and board rows on the system card
+
+**Files:**
+- Modify: `widgets/system.lua`
+
+- [ ] **Step 1: Add the memoized lookups**
+
+In `widgets/system.lua`, after the `counters` declaration near the top:
+
+```lua
+-- Read once, not per frame. Neither can change without a reboot, and
+-- re-reading /proc/cpuinfo every two seconds for a constant is waste: the file
+-- repeats its model-name line once per thread.
+--
+-- `false` is the cached miss, distinct from nil meaning "not looked up yet",
+-- so an absent DMI table is not re-read on every draw.
+local cpu_name, board = nil, nil
+
+local function hardware()
+ if cpu_name == nil then
+ cpu_name = data.cpu_model(data.slurp('/proc/cpuinfo') or '') or false
+ end
+ if board == nil then
+ local dmi = '/sys/devices/virtual/dmi/id/'
+ board = data.board_name(data.slurp(dmi .. 'board_vendor'),
+ data.slurp(dmi .. 'board_name')) or false
+ end
+ return cpu_name or nil, board or nil
+end
+```
+
+- [ ] **Step 2: Add the rows to the fluid-type group list**
+
+In `M.draw`, after `local cores = ...` and before the `groups` table, add:
+
+```lua
+ local cpu_txt, board_txt = hardware()
+```
+
+Then in the `groups` table, insert two rows after the header row so the width
+fit accounts for them:
+
+```lua
+ local groups = {
+ { { 'CPU', card.FONT_MONO, LABEL_F }, { load_txt, card.FONT_HEAVY, BIG_F } },
+ { { cpu_txt or '', card.FONT_MONO, ROW_F } },
+ { { board_txt or '', card.FONT_MONO, ROW_F } },
+ { { 'RAM', card.FONT_MONO, ROW_F }, { '999.9G / 999.9G', card.FONT_MONO, ROW_F } },
+ }
+```
+
+- [ ] **Step 3: Give the equaliser's budget the two rows**
+
+Change the `fixed_units` line to account for them. It currently reads:
+
+```lua
+ local fixed_units = 1.00 + 1.13 + temp_n * ROW_F * 1.8 + 0.10
+```
+
+Replace with:
+
+```lua
+ -- The two identification rows come out of the equaliser's budget: it is the
+ -- card's designated slack absorber, so it is what gives up the height.
+ local id_rows = (cpu_txt and 1 or 0) + (board_txt and 1 or 0)
+ local fixed_units = 1.00 + 1.13 + temp_n * ROW_F * 1.8 + 0.10
+ + id_rows * ROW_F * 1.5
+```
+
+- [ ] **Step 4: Draw the rows**
+
+Immediately after the `card.text(cr, inner.x, y + label_size, 'CPU')` call that
+draws the header label, insert:
+
+```lua
+ -- The hardware this card is about, under the header. Truncated by character,
+ -- never by byte: a model string can carry a multi-byte character and half of
+ -- one draws as a replacement box.
+ local id_y = y + big_size + row_size * 0.2
+ if cpu_txt or board_txt then
+ card.font(cr, card.FONT_MONO, row_size, false)
+ card.rgba(cr, colors.label)
+ local id_limit = math.max(8, math.floor(inner.w / (row_size * 0.55)))
+ if cpu_txt then
+ card.text(cr, inner.x, id_y, card.truncate(cpu_txt, id_limit))
+ id_y = id_y + row_size * 1.5
+ end
+ if board_txt then
+ card.text(cr, inner.x, id_y, card.truncate(board_txt, id_limit))
+ id_y = id_y + row_size * 1.5
+ end
+ end
+```
+
+Then change the equaliser's top to start below them. The line currently reads:
+
+```lua
+ local eq_top = y + big_size + S * 0.30
+```
+
+Replace with:
+
+```lua
+ -- Below the identification rows when there are any, otherwise where it was.
+ local eq_top = (cpu_txt or board_txt) and (id_y + S * 0.10) or (y + big_size + S * 0.30)
+```
+
+And change the equaliser height calculation to subtract the same rows. It reads:
+
+```lua
+ local eh = clamp((inner.y + inner.h) - eq_top - (S * 1.13 + temp_n * step),
+ 18, math.huge)
+```
+
+This already measures from `eq_top`, which now sits lower, so the subtraction
+is correct. But the `18` floor means that on a short card the equaliser keeps
+18px it no longer has, and the temperature rows draw over it. Change the floor
+to a proportion of what is actually left:
+
+```lua
+ -- The floor was a flat 18px. With the identification rows above it the
+ -- equaliser can genuinely run out of room, and a fixed floor means it keeps
+ -- height it does not have and the temperature rows draw over it. Zero is a
+ -- legitimate outcome: the loop below skips the band when it has no height.
+ local eh = clamp((inner.y + inner.h) - eq_top - (S * 1.13 + temp_n * step),
+ 0, math.huge)
+```
+
+and guard the equaliser block so it does not draw a zero-height band. The line
+that reads `if n > 0 then` becomes:
+
+```lua
+ if n > 0 and eh >= 8 then
+```
+
+- [ ] **Step 5: Render and check it fits**
+
+```bash
+lua test/render.lua system 16 12 3 5 /tmp/sys.png
+```
+
+Expected: header with the load percentage top-right, two identification rows,
+the equaliser, RAM, and five temperature rows, nothing overlapping.
+
+**This is the crowding risk the spec names.** If the rows collide or the
+equaliser is squeezed to nothing, stop and report it rather than tuning
+constants indefinitely: the fallback is one row (CPU only) or moving the pair
+to their own card, and that is the user's call.
+
+- [ ] **Step 6: Check it on the live board**
+
+```bash
+./restart.sh
+```
+
+The offscreen renderer crops to one card and says nothing about how the card
+looks beside its neighbours, which is a real failure mode here.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add widgets/system.lua
+git commit -m "feat: name the CPU and motherboard on the system card
+
+Both read once: neither changes without a reboot, and /proc/cpuinfo repeats
+its model-name line once per thread.
+
+The two rows come out of the equaliser's height budget, since it is the
+card's designated slack absorber.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 12: Free and total under each disk ring
+
+**Files:**
+- Modify: `widgets/disks.lua`
+- Modify: `widgets/cache.lua` (adopt `card.truncate`)
+- Modify: `TODO.md`
+
+- [ ] **Step 1: Replace the percentage with two rows**
+
+In `widgets/disks.lua`, find the block that draws the percentage under the
+ring:
+
+```lua
+ -- 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)
+```
+
+Replace it with:
+
+```lua
+ -- Free on top, total beneath, both centred under the ring.
+ --
+ -- The percentage that used to sit here is gone: the ring already draws
+ -- it as an angle, and a number repeating it costs a row in a column
+ -- about 75px wide. Free carries the threshold colour because it is the
+ -- measured quantity; the total is fixed, so it stays in `value`. One
+ -- coloured number per column, as the rest of the board reads.
+ local free_size = label_size
+ local total_size = label_size * 0.88
+ card.font(cr, card.FONT_MONO, free_size, false)
+ card.rgba(cr, colour)
+ local free_txt = card.human(e.avail) .. ' free'
+ local fw = card.measure(cr, free_txt)
+ card.text(cr, cx - fw / 2, cy + r + free_size * 1.7, free_txt)
+
+ card.font(cr, card.FONT_MONO, total_size, false)
+ card.rgba(cr, colors.value)
+ local total_txt = card.human(e.size)
+ local tw = card.measure(cr, total_txt)
+ card.text(cr, cx - tw / 2, cy + r + free_size * 1.7 + total_size * 1.4, total_txt)
+```
+
+- [ ] **Step 2: Give the second row vertical room**
+
+The ring radius is derived from the available height and must now leave room
+for two rows rather than one. Find:
+
+```lua
+ local r = math.min(cell_w * 0.34, avail_h * 0.30)
+```
+
+Replace with:
+
+```lua
+ -- 0.27 rather than 0.30: two rows sit under each ring now, not one, and the
+ -- radius is what gives up the height.
+ local r = math.min(cell_w * 0.34, avail_h * 0.27)
+```
+
+- [ ] **Step 3: Adopt `card.truncate` in the cache card**
+
+In `widgets/cache.lua`, find:
+
+```lua
+ local name = e.name
+ if #name > 18 then name = name:sub(1, 17) .. '\u{2026}' end
+ names[i] = name
+```
+
+Replace with:
+
+```lua
+ -- By character, not byte: a cache directory can carry an accented name and
+ -- a byte slice cuts one in half, which Cairo draws as a replacement box.
+ names[i] = card.truncate(e.name, 18)
+```
+
+- [ ] **Step 4: Render and check**
+
+```bash
+lua test/render.lua disks 16 12 5 3 /tmp/disks.png
+lua test/render.lua cache 16 12 2 3 /tmp/cache.png
+```
+
+Expected: each ring carries `NNG free` above `NNNG`, with no overlap between
+neighbouring columns and no text running outside the card.
+
+- [ ] **Step 5: Run the suite**
+
+```bash
+lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
+```
+
+Expected: exit 0.
+
+- [ ] **Step 6: Clear the completed TODO item**
+
+Edit `TODO.md` and remove the line:
+
+```
+ - [ ] Add a free space lable under every disk indicator in the disks widget
+```
+
+**`TODO.md` is untracked.** Do not `git add` it. Leave it in the working tree.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add widgets/disks.lua widgets/cache.lua
+git commit -m "feat: show free and total under each disk ring
+
+Replaces the percentage, which the ring already draws as an angle. A number
+repeating it costs a row in a column about 75px wide.
+
+Free carries the threshold colour because it is the measured quantity; the
+total is fixed and stays in the plain value colour, so each column has one
+coloured number.
+
+The cache card adopts card.truncate in the same pass: it was slicing names by
+byte, which halves a multi-byte character.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 13: Wire both cards into the dashboard
+
+**Files:**
+- Modify: `conky.conf.in`
+- Modify: `dashboard.lua`
+
+- [ ] **Step 1: Add the samplers to the conky config**
+
+In `conky.conf.in`, the `conky.text` line currently reads:
+
+```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}]]
+```
+
+Replace with:
+
+```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}${execi 1800 ~/.config/conky/bin/pubip-sample.sh}${execi 900 ~/.config/conky/bin/slackware-sample.sh}]]
+```
+
+- [ ] **Step 2: Add the layout entries**
+
+In `dashboard.lua`, add two entries to the `layout` table. Network under
+system, Slackware beside it:
+
+```lua
+local layout = {
+ { widget = 'clock', col = 1, row = 1, w = 1.5, h = 5 },
+ -- Under the clock, same column.
+ { widget = 'weather', col = 1, row = 6, w = 3, h = 5 },
+ { widget = 'system', col = 14, row = 4, w = 3, h = 5 },
+ { widget = 'gpu', col = 12, row = 4, w = 2, h = 3 },
+ { widget = 'disks', col = 12, row = 1, w = 5, h = 3 },
+ { widget = 'cache', col = 12, row = 7, w = 2, h = 3 },
+ -- Under system, with slackware beside it. Provisional: there is no settled
+ -- arrangement for the board yet.
+ { widget = 'network', col = 14, row = 9, w = 3, h = 3 },
+ { widget = 'slackware', col = 12, row = 10, w = 2, h = 3 },
+}
+```
+
+- [ ] **Step 3: Re-render the conky config**
+
+`conky.conf` is generated from `conky.conf.in` by the other repository's
+installer, which substitutes the palette:
+
+```bash
+../unified-desktop-theme/install.sh
+```
+
+Expected: `conky.conf` regenerated. Confirm the two new `execi` entries landed:
+
+```bash
+grep -c 'sample.sh' conky.conf
+```
+
+Expected: `1` (they are all on one line) and the line contains `pubip-sample`
+and `slackware-sample`:
+
+```bash
+grep -o 'pubip-sample.sh\|slackware-sample.sh' conky.conf
+```
+
+Expected: both names printed.
+
+- [ ] **Step 4: Restart and look at the board**
+
+```bash
+./restart.sh
+```
+
+Toggle the dashboard workspace:
+
+```bash
+hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")'
+```
+
+Expected: eight cards, no blank cells, no error overlay. Let it run two minutes
+so the network chart has history, then look again: two lines should be moving.
+
+- [ ] **Step 5: Run the suite**
+
+```bash
+lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
+```
+
+Expected: exit 0. `test_layout.lua` walks the layout table, so a malformed
+entry fails here.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add conky.conf.in dashboard.lua conky.conf
+git commit -m "feat: put the network and slackware cards on the board
+
+Placement is provisional: there is no settled arrangement yet.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 14: Documentation
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Document both cards**
+
+In `README.md`, find the section listing the widgets and add entries matching
+the existing format for `network` and `slackware`. Each entry states what the
+card shows, where the data comes from, and which sampler feeds it.
+
+Include, because they are the things a reader will otherwise get wrong:
+
+- `widgets/network.lua` graphs `br0`, **a bridge**, so VM-to-host traffic
+ appears in the chart without crossing the router. The `IFACE` constant at the
+ top of the file changes it.
+- The public address is refused once it is more than four hours old.
+- `bin/slackware-sample.sh` counts `/var/log/packages/` **with the trailing
+ slash**, because the path is a symlink and counting it without the slash
+ reports one package.
+
+- [ ] **Step 2: Document both samplers**
+
+In the section listing `bin/` scripts, add `pubip-sample.sh` (30 minutes) and
+`slackware-sample.sh` (15 minutes), in the format the existing three use.
+
+- [ ] **Step 3: Verify no personal data reached the docs**
+
+```bash
+grep -rniE '192\.168\.|10\.[0-9]+\.|danix@|/home/danix' README.md | grep -v 'danix@danix.xyz'
+```
+
+Expected: no output. A real LAN address, hostname or username in a committed
+file violates `AGENTS.md`, and the commit hooks will reject it anyway.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: document the network and slackware cards
+
+Notes the two things a reader would otherwise get wrong: br0 is a bridge, so
+local VM traffic shows up in the chart, and the package count needs the
+trailing slash because /var/log/packages is a symlink.
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 15: Whole-branch verification
+
+- [ ] **Step 1: Run the full suite**
+
+```bash
+lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua && echo "SUITE PASS"
+```
+
+Expected: `SUITE PASS`.
+
+- [ ] **Step 2: Render every card**
+
+```bash
+for w in clock weather system gpu disks cache network slackware; do
+ lua test/render.lua "$w" 16 12 3 4 "/tmp/card_$w.png" || echo "FAILED: $w"
+done
+```
+
+Expected: eight PNGs, no failures. Look at each one.
+
+- [ ] **Step 3: Confirm every commit is signed**
+
+```bash
+git log --format='%h %G? %s' -16
+```
+
+Expected: every line's second field is `G`. A `N` means an unsigned commit;
+recover with the procedure in the global preferences file rather than leaving
+it.
+
+- [ ] **Step 4: Confirm no personal data is staged anywhere in the branch**
+
+```bash
+git diff master@{u}..HEAD | grep -niE '192\.168\.|10\.[0-9]{1,3}\.[0-9]|inet 1[^9]|[a-z0-9]+@[a-z0-9]+\.[a-z]+' | grep -v 'danix@danix.xyz' | grep -v '192\.0\.2\.'
+```
+
+Expected: no output. `192.0.2.x` is TEST-NET and is allowed; a real address is
+not.
+
+- [ ] **Step 5: Check the live board one more time**
+
+```bash
+./restart.sh
+```
+
+Leave it running for five minutes, then confirm: the network chart has two
+moving lines with a sensible peak, the Slackware age is plausible against
+`stat -c %Y /var/lib/slackpkg/ChangeLog.txt`, and the disk rings show free and
+total without collision.
+
+- [ ] **Step 6: Push**
+
+```bash
+git push
+```
+
+Note that `origin` may carry multiple push URLs, in which case one push fans
+out to every configured destination.
+
+---
+
+## Notes for the implementer
+
+**If the system card crowds** (Task 11, Step 5): stop and report it. The
+fallback is one identification row or a separate card, and that is the user's
+decision, not a constant to tune indefinitely.
+
+**If a glyph draws wrong** (Task 8, Step 7): a wrong-but-present codepoint
+draws a plausible neighbour rather than failing, so it must be looked at, not
+assumed. Find the right codepoint in a Nerd Font cheat sheet and re-render.
+
+**Do not run the samplers from a cron job or a systemd timer.** They are tied
+to conky's `execi` deliberately: nothing should be fetching while the dashboard
+is not running.
+
+**Deferred, not forgotten.** These were recorded in a previous review and stay
+out of scope here: the RAM row has no vertical-fit guard, a full-circle ring at
+`frac == 1` leaves a small knob where the round caps coincide, `card.vbar` has
+no minimum fill height, and the disks bar-fallback can crowd its right-aligned
+figures on a very narrow card.