# 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 " ``` --- ## 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 = , colour = }. `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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 ` and `fetched `. **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 "captive portal"; }; 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 " ``` --- ## 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: 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: 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 ` 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 " ``` --- ## 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) # /var/log/packages is a symlink to /var/lib/pkgtools/packages. GNU ls follows # it either way, so the trailing slash is belt and braces rather than the load # bearing part; what would break this is adding -d, which lists the link itself # and reports one package. # # The real trap is `ls` being aliased: the user's shell aliases it to `ls -lh`, # and a long-format listing counts a "total" header line as a package. This # script is not interactive so aliases do not apply, but do not move this # counting into anything that is. 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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.