diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-17 18:58:49 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-17 18:58:49 +0200 |
| commit | db50ae8b65f6be6b2c6d9e2a9a019c8b21adfaab (patch) | |
| tree | 1e29a56f9c7ecbe811d485d7eb099358880b7558 | |
| parent | 4b6c6b4856c8aa39375542aeb912b56168533a3a (diff) | |
| download | conky-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.md | 2046 |
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. |
