aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-18 14:25:05 +0200
committerDanilo M. <danix@danix.xyz>2026-10-18 14:25:05 +0200
commit0d8ad4d94c7aa96bdb1becac0c0f9e43ffa77dd0 (patch)
treeca8cd91ed8fe308488171ebf03717104ffba8b28 /docs
parent179e4e1faf8774f58139398f804ade2971de56d1 (diff)
downloadconky-theme-udt-0d8ad4d94c7aa96bdb1becac0c0f9e43ffa77dd0.tar.gz
conky-theme-udt-0d8ad4d94c7aa96bdb1becac0c0f9e43ffa77dd0.zip
docs: implementation plan for the Mirage-derived weather cards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-10-18-mirage-weather-cards.md1552
1 files changed, 1552 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-18-mirage-weather-cards.md b/docs/superpowers/plans/2026-10-18-mirage-weather-cards.md
new file mode 100644
index 0000000..2e044a5
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-18-mirage-weather-cards.md
@@ -0,0 +1,1552 @@
+# Mirage Weather Cards 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:** Replace the OpenWeatherMap `weather` card with five Open-Meteo cards (`now`, `hourly`, `week`, `details`, `sky`) adapted from Conky-Mirage and drawn in this repo's design language.
+
+**Architecture:** `bin/weather-fetch.sh` pulls Open-Meteo's forecast and air-quality APIs and flattens them with jq into a tab-separated cache, `~/.cache/udt/weather.txt`. `lib/weather.lua` parses that cache into a table and holds pure helpers (WMO glyphs, moon phase, thresholds). Each card reads the cache through it, draws its header and rows with a new shared `card.standard()`, and puts its Mirage-derived graphic in the space left over.
+
+**Tech Stack:** Lua 5.4 with conky's cairo bindings, bash, curl, jq. Spec: `docs/superpowers/specs/2026-10-18-mirage-weather-cards-design.md`.
+
+**Conventions for every task:**
+- Run from the repo root.
+- Full test command: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua`
+- Commits are GPG-signed (global config does it; never disable it). End every commit message with the line `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
+- No em dashes in code, comments, docs or commit messages.
+- Nothing raises: a Lua error in conky is a blank screen. Guard types, return nil or `'--'`.
+- No real location anywhere committed. Fixtures use epochs and numbers only.
+
+---
+
+## File map
+
+| File | Action | Responsibility |
+|---|---|---|
+| `test/fixtures/weather.txt` | create | Small location-free cache sample for the parser tests |
+| `test/fixtures/weather.json`, `test/fixtures/weather_truncated.json` | delete | OWM fixtures, no longer used |
+| `test/test_weather.lua` | rewrite | Checks for the new `lib/weather.lua` |
+| `lib/weather.lua` | rewrite | Open-Meteo parsing and domain helpers |
+| `bin/weather-fetch.sh` | rewrite | Geocode, fetch, flatten, atomic write |
+| `weather.env.example` | rewrite | `CITY`/`COUNTRY`/`UNITS`, no key |
+| `lib/card.lua` | modify | Title ICONs for the new cards, `text_centre`, `standard` |
+| `widgets/now.lua` | create | Current conditions |
+| `widgets/hourly.lua` | create | 24-hour temperature curve |
+| `widgets/week.lua` | create | 7-day range bars |
+| `widgets/details.lua` | create | Wind, humidity, UV, AQI, compass |
+| `widgets/sky.lua` | create | Sun times, moon, daylight arc |
+| `widgets/weather.lua` | delete | Replaced by `now` and `sky` |
+| `dashboard.lua` | modify | Layout rows for the five cards |
+| `README.md`, `DESIGN.md` | modify | Docs and Mirage credit |
+
+---
+
+### Task 1: Parser and helpers in `lib/weather.lua`
+
+**Files:**
+- Create: `test/fixtures/weather.txt`
+- Delete: `test/fixtures/weather.json`, `test/fixtures/weather_truncated.json`
+- Rewrite: `test/test_weather.lua`
+- Rewrite: `lib/weather.lua`
+
+- [ ] **Step 1: Create the fixture**
+
+Real tabs matter, so write it with printf rather than an editor:
+
+```bash
+printf '%s\n' \
+ $'units\tmetric' \
+ $'current\t1792325700\t13.2\t11.5\t3\t1\t77\t10.2\t72' \
+ $'sun\t1792301818\t1792339510' \
+ $'air\t37' \
+ $'hour\t1792324800\t13.1\t3\t0\t1\t1.15' \
+ $'hour\t1792328400\t13.4\t61\t40\t1\tnull' \
+ $'hour\t1792332000\tnull\t3\t0\t0\t0.95' \
+ $'day\t1792274400\t3\t8.1\t14.6\t10' \
+ $'day\t1792360800\t61\t7.4\t12.0\t80' \
+ > test/fixtures/weather.txt
+git rm -q test/fixtures/weather.json test/fixtures/weather_truncated.json
+```
+
+The `null` fields are deliberate: Open-Meteo emits `null` for a missing value and jq prints it as the word.
+
+- [ ] **Step 2: Write the failing test**
+
+Replace all of `test/test_weather.lua` with:
+
+```lua
+-- Checks for lib/weather.lua.
+-- Run from the repo root: lua test/test_weather.lua
+-- The domain tables are what silently misreport when a boundary is off by one,
+-- so every range boundary gets an assertion. The cards themselves are verified
+-- by rendering them.
+
+package.path = './?.lua;' .. package.path
+local weather = require 'lib.weather'
+
+local function slurp(path)
+ local f = assert(io.open(path, 'r'))
+ local s = f:read('*a')
+ f:close()
+ return s
+end
+
+-- === WMO code to glyph ====================================================
+-- Open-Meteo reports WMO weather codes. The groups are sparse (0-3, 45, 48,
+-- 51-57, ...), so each group's first and last code is asserted, plus a code
+-- in a gap, which must read as unknown rather than silently sunny.
+local I = weather.ICON
+assert(weather.icon(0, true) == I.clear_day, 'code 0 day is the sun')
+assert(weather.icon(0, false) == I.clear_night, 'code 0 night is the moon')
+assert(weather.icon(1, true) == I.few_day, 'code 1 is few clouds')
+assert(weather.icon(2, false) == I.few_night, 'code 2 night')
+assert(weather.icon(3, false) == I.overcast, 'code 3 is overcast, no night face')
+assert(weather.icon(45, true) == I.fog, 'code 45 is fog')
+assert(weather.icon(48, true) == I.fog, 'code 48 is fog')
+assert(weather.icon(51, true) == I.drizzle_day, 'code 51 is drizzle')
+assert(weather.icon(57, false) == I.drizzle_night, 'code 57 night')
+assert(weather.icon(61, true) == I.rain_day, 'code 61 is rain')
+assert(weather.icon(67, true) == I.rain_day, 'code 67 is still rain')
+assert(weather.icon(80, false) == I.rain_night, 'code 80 showers are rain')
+assert(weather.icon(82, true) == I.rain_day, 'code 82 is rain')
+assert(weather.icon(71, true) == I.snow, 'code 71 is snow')
+assert(weather.icon(77, false) == I.snow, 'code 77 is snow at night too')
+assert(weather.icon(85, true) == I.snow, 'code 85 snow showers')
+assert(weather.icon(86, true) == I.snow, 'code 86 snow showers')
+assert(weather.icon(95, true) == I.thunder_day, 'code 95 is thunder')
+assert(weather.icon(99, false) == I.thunder_night, 'code 99 night')
+
+-- Gaps and junk.
+assert(weather.icon(4, true) == I.unknown, 'code 4 is not a WMO code')
+assert(weather.icon(50, true) == I.unknown, 'code 50 sits in a gap')
+assert(weather.icon(-1, true) == I.unknown, 'negative code')
+assert(weather.icon(nil, true) == I.unknown, 'nil code')
+assert(weather.icon('3', true) == I.unknown, 'string code')
+
+-- An unknown day/night falls back to the day face, matching is_day().
+assert(weather.icon(0, nil) == I.clear_day, 'nil is_day draws the day face')
+
+-- The codepoint VALUES are deliberately not asserted. Whether a codepoint
+-- draws a snow cloud in the target font is not something an assertion can
+-- see; the glyph sheet in Task 3 is that check.
+
+-- === Descriptions =========================================================
+assert(weather.describe(0) == 'Clear sky', 'code 0 words')
+assert(weather.describe(63) == 'Rain', 'code 63 words')
+assert(weather.describe(99) == 'Thunderstorm, hail', 'code 99 words')
+assert(weather.describe(4) == 'Unknown', 'gap code words')
+assert(weather.describe(nil) == 'Unknown', 'nil code words')
+
+-- === Formatting ===========================================================
+assert(weather.deg(13.2) == '13\u{00B0}', 'rounds down')
+assert(weather.deg(13.5) == '14\u{00B0}', 'rounds half up')
+assert(weather.deg(-0.4) == '0\u{00B0}', 'just below zero reads 0')
+assert(weather.deg(-2.6) == '-3\u{00B0}', 'negative rounds away from zero')
+assert(weather.deg(nil) == '--', 'missing temperature is --, never 0')
+
+assert(weather.num(77) == '77', 'plain number')
+assert(weather.num(39.6, '%') == '40%', 'rounded with suffix')
+assert(weather.num(0, '%') == '0%', 'zero is a real reading')
+assert(weather.num(nil, '%') == '--', 'missing number is --')
+
+assert(weather.wind_unit('metric') == 'km/h', 'metric wind')
+assert(weather.wind_unit('imperial') == 'mph', 'imperial wind')
+assert(weather.wind_unit(nil) == 'km/h', 'unknown units default to metric')
+
+assert(weather.duration(37692) == '10h 28m', 'day length')
+assert(weather.duration(0) == '0h 00m', 'zero length')
+assert(weather.duration(-5) == '--', 'negative length is bad data')
+assert(weather.duration(nil) == '--', 'missing length')
+
+-- === Thresholds ===========================================================
+-- Each boundary and the value just under it.
+assert(weather.uv_role(2.9) == 'ok', 'UV under 3 is ok')
+assert(weather.uv_role(3) == 'warning', 'UV 3 is warning')
+assert(weather.uv_role(7.9) == 'warning', 'UV under 8 is warning')
+assert(weather.uv_role(8) == 'critical', 'UV 8 is critical')
+assert(weather.uv_role(nil) == nil, 'missing UV has no role')
+
+assert(weather.aqi_role(40) == 'ok', 'AQI 40 is ok')
+assert(weather.aqi_role(41) == 'warning', 'AQI 41 is warning')
+assert(weather.aqi_role(80) == 'warning', 'AQI 80 is warning')
+assert(weather.aqi_role(81) == 'critical', 'AQI 81 is critical')
+assert(weather.aqi_role(nil) == nil, 'missing AQI has no role')
+
+-- === Moon =================================================================
+-- Known 2024 phases (UTC): new 01-11 11:57, full 01-25 17:54, new 02-09 22:59.
+-- The mean synodic month drifts from the true one by up to about half a day,
+-- so the tolerance is one day of phase.
+local DAY_PHASE = 1 / 29.530588853
+local function circ(a, b) local d = math.abs(a - b) % 1; return math.min(d, 1 - d) end
+local p_new = weather.moon(1704974220)
+local p_full, lit_full = weather.moon(1706205240)
+local p_new2, lit_new2 = weather.moon(1707519540)
+assert(circ(p_new, 0) < DAY_PHASE, 'new moon phase near 0, got ' .. p_new)
+assert(circ(p_full, 0.5) < DAY_PHASE, 'full moon phase near 0.5, got ' .. p_full)
+assert(circ(p_new2, 0) < DAY_PHASE, 'next new moon near 0, got ' .. p_new2)
+assert(lit_full > 0.98, 'full moon is lit, got ' .. lit_full)
+assert(lit_new2 < 0.02, 'new moon is dark, got ' .. lit_new2)
+assert(weather.moon(nil) == nil, 'missing epoch')
+
+-- 28 glyphs from U+E38D. Phase 0 and phase 1 are both the new moon.
+assert(weather.moon_icon(0) == utf8.char(0xE38D), 'phase 0 is new moon')
+assert(weather.moon_icon(0.5) == utf8.char(0xE38D + 14), 'phase 0.5 is full moon')
+assert(weather.moon_icon(0.999) == utf8.char(0xE38D), 'phase near 1 wraps to new')
+assert(weather.moon_icon(nil) == I.unknown, 'missing phase')
+
+-- === Wind =================================================================
+-- Compass: eight points, each spanning 45 degrees, offset by half a step so
+-- north straddles 0 rather than starting at it.
+assert(weather.compass(0) == 'N', '0 is north')
+assert(weather.compass(360) == 'N', '360 wraps to north')
+assert(weather.compass(22) == 'N', 'just under the NE boundary')
+assert(weather.compass(23) == 'NE', 'just over it')
+assert(weather.compass(349) == 'N', 'just over the NW boundary wraps to north')
+assert(weather.compass(90) == 'E', '90 is east')
+assert(weather.compass(180) == 'S', '180 is south')
+assert(weather.compass(270) == 'W', '270 is west')
+assert(weather.compass(315) == 'NW', '315 is northwest')
+assert(weather.compass(nil) == nil, 'missing direction stays missing')
+
+-- === Sun position =========================================================
+-- t is the fraction of daylight elapsed. Sunrise 1000, sunset 2000.
+assert(weather.sun_t(1000, 1000, 2000) == 0, 'at sunrise t is 0')
+assert(weather.sun_t(1500, 1000, 2000) == 0.5, 'at midday t is half')
+assert(weather.sun_t(2000, 1000, 2000) == 1, 'at sunset t is 1')
+assert(weather.sun_t(500, 1000, 2000) == 0, 'before dawn clamps to 0')
+assert(weather.sun_t(9999, 1000, 2000) == 1, 'after dusk clamps to 1')
+assert(weather.sun_t(1500, 2000, 2000) == 0, 'zero-length day gives 0, not nan')
+assert(weather.sun_t(nil, 1000, 2000) == nil, 'missing now gives nil')
+
+assert(weather.is_day(1500, 1000, 2000) == true, 'midday is day')
+assert(weather.is_day(1000, 1000, 2000) == true, 'sunrise counts as day')
+assert(weather.is_day(500, 1000, 2000) == false, 'before dawn is night')
+assert(weather.is_day(nil, 1000, 2000) == true, 'unknown falls back to day')
+
+-- === Parsing the cache ====================================================
+local w = weather.parse(slurp('test/fixtures/weather.txt'))
+assert(w, 'the fixture parses')
+assert(w.units == 'metric', 'units')
+assert(w.current.dt == 1792325700, 'current time')
+assert(w.current.temp == 13.2, 'current temperature')
+assert(w.current.feels == 11.5, 'feels like')
+assert(w.current.code == 3, 'current code')
+assert(w.current.is_day == true, 'is_day 1 is day')
+assert(w.current.humidity == 77, 'humidity')
+assert(w.current.wind == 10.2, 'wind')
+assert(w.current.wind_deg == 72, 'wind direction')
+assert(w.sun.rise == 1792301818 and w.sun.set == 1792339510, 'sun times')
+assert(w.air.aqi == 37, 'air quality')
+assert(#w.hours == 3, 'three hours, got ' .. #w.hours)
+assert(w.hours[1].uv == 1.15, 'first hour UV')
+assert(w.hours[2].uv == nil, 'null UV reads as nil')
+assert(w.hours[2].precip == 40, 'rain chance')
+assert(w.hours[3].temp == nil, 'null temperature reads as nil')
+assert(w.hours[3].is_day == false, 'is_day 0 is night')
+assert(#w.days == 2, 'two days')
+assert(w.days[1].min == 8.1 and w.days[1].max == 14.6, 'today min and max')
+assert(w.days[2].precip == 80, 'tomorrow rain chance')
+
+-- No air line: still parses, air is nil, the card shows --.
+local no_air = slurp('test/fixtures/weather.txt'):gsub('air\t37\n', '')
+local wa = weather.parse(no_air)
+assert(wa and wa.air == nil, 'missing air line is not fatal')
+
+-- Required records missing: nil, so the card draws its notice.
+assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('current\t[^\n]*\n', ''))) == nil,
+ 'no current line is nil')
+assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('sun\t[^\n]*\n', ''))) == nil,
+ 'no sun line is nil')
+assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('hour\t[^\n]*\n', ''))) == nil,
+ 'no hour lines is nil')
+assert(weather.parse('') == nil, 'empty string')
+assert(weather.parse(nil) == nil, 'nil')
+assert(weather.parse('{"cod":401,"message":"bad"}') == nil, 'garbage')
+
+-- === Staleness ============================================================
+-- Three missed fetches at the 15-minute interval.
+assert(weather.is_stale(1000, 1000 + 45 * 60) == false, 'exactly 45m is not stale')
+assert(weather.is_stale(1000, 1000 + 45 * 60 + 1) == true, 'over 45m is stale')
+assert(weather.is_stale(nil, 1000) == true, 'no timestamp is stale')
+assert(weather.age_str(1000, 1000 + 12 * 60) == '12m', 'minutes')
+assert(weather.age_str(1000, 1000 + 3 * 3600) == '3h', 'hours')
+assert(weather.age_str(1000, 1000 + 2 * 86400) == '2d', 'days')
+assert(weather.age_str(2000, 1000) == '0m', 'a future dt reads as 0m')
+assert(weather.age_str(nil, 1000) == '?', 'missing dt')
+
+print('test_weather: all assertions passed')
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `lua test/test_weather.lua`
+Expected: FAIL, an assertion error on the first new-API call (e.g. `code 1 is few clouds`, or `attempt to call a nil value (field 'describe')`).
+
+- [ ] **Step 4: Write the implementation**
+
+Replace all of `lib/weather.lua` with:
+
+```lua
+-- Open-Meteo domain knowledge and cache parsing.
+--
+-- Separate from lib/data.lua: that reads /proc and /sys, this reads the cache
+-- bin/weather-fetch.sh flattens Open-Meteo's responses into. Same testable
+-- shape though, every function takes a string or numbers and returns a value,
+-- so the tests need no filesystem and no network.
+--
+-- The forecast cards built on this (hourly, week, details, sky) are adapted
+-- from the Conky-Mirage theme by alexsson-xexpanderx:
+-- https://github.com/alexsson-xexpanderx/Conky-themes/tree/master/Conky-Mirage
+--
+-- Nothing here raises. A Lua error in this project is a blank screen, so every
+-- parse path returns nil and lets the widget draw its "no data" state.
+
+local M = {}
+
+M.CACHE = (os.getenv('XDG_CACHE_HOME') or ((os.getenv('HOME') or '') .. '/.cache'))
+ .. '/udt/weather.txt'
+
+-- Nerd Font codepoints, all present in Inconsolata Nerd Font (card.FONT_MONO).
+-- Verified by rendering the glyphs and looking at them, not by fontconfig
+-- alone: a missing glyph draws as an invisible blank rather than an error.
+M.ICON = {
+ thunder_day = '\u{E30F}',
+ thunder_night = '\u{E32A}',
+ drizzle_day = '\u{E306}',
+ drizzle_night = '\u{E326}',
+ rain_day = '\u{E308}',
+ rain_night = '\u{E325}',
+ snow = '\u{E31A}',
+ fog = '\u{E313}',
+ clear_day = '\u{E30D}',
+ clear_night = '\u{E32B}',
+ few_day = '\u{E302}',
+ few_night = '\u{E379}',
+ overcast = '\u{E312}',
+ unknown = '\u{E374}',
+}
+
+-- WMO weather code to glyph. The codes are sparse groups, not ranges, so a
+-- code in a gap is unknown rather than borrowed from a neighbour.
+-- `is_day` false picks the night face; nil (unknown) draws the day face, which
+-- is what is_day() falls back to as well.
+function M.icon(code, is_day)
+ if type(code) ~= 'number' then return M.ICON.unknown end
+ local d = is_day ~= false
+ local I = M.ICON
+ if code == 0 then return d and I.clear_day or I.clear_night
+ elseif code == 1 or code == 2 then return d and I.few_day or I.few_night
+ elseif code == 3 then return I.overcast
+ elseif code == 45 or code == 48 then return I.fog
+ elseif code >= 51 and code <= 57 then return d and I.drizzle_day or I.drizzle_night
+ elseif (code >= 61 and code <= 67) or (code >= 80 and code <= 82) then
+ return d and I.rain_day or I.rain_night
+ elseif (code >= 71 and code <= 77) or code == 85 or code == 86 then return I.snow
+ elseif code >= 95 and code <= 99 then return d and I.thunder_day or I.thunder_night
+ end
+ return I.unknown
+end
+
+local DESCRIBE = {
+ [0] = 'Clear sky', [1] = 'Mainly clear', [2] = 'Partly cloudy', [3] = 'Overcast',
+ [45] = 'Fog', [48] = 'Rime fog',
+ [51] = 'Light drizzle', [53] = 'Drizzle', [55] = 'Dense drizzle',
+ [56] = 'Freezing drizzle', [57] = 'Freezing drizzle',
+ [61] = 'Light rain', [63] = 'Rain', [65] = 'Heavy rain',
+ [66] = 'Freezing rain', [67] = 'Freezing rain',
+ [71] = 'Light snow', [73] = 'Snow', [75] = 'Heavy snow', [77] = 'Snow grains',
+ [80] = 'Rain showers', [81] = 'Rain showers', [82] = 'Violent showers',
+ [85] = 'Snow showers', [86] = 'Snow showers',
+ [95] = 'Thunderstorm', [96] = 'Thunderstorm, hail', [99] = 'Thunderstorm, hail',
+}
+
+-- WMO code in words. Reading a nil key from a table is not an error.
+function M.describe(code)
+ return DESCRIBE[code] or 'Unknown'
+end
+
+local function round(v) return math.floor(v + 0.5) end
+
+-- A temperature as the cards print it. Missing is '--', never '0': zero is a
+-- legitimate reading. The '-0' a value just under zero would round to is
+-- folded into '0'.
+function M.deg(v)
+ if type(v) ~= 'number' then return '--' end
+ local r = round(v)
+ if r == 0 then r = 0 end
+ return string.format('%d\u{00B0}', r)
+end
+
+-- A rounded number with an optional suffix, or '--'.
+function M.num(v, suffix)
+ if type(v) ~= 'number' then return '--' end
+ return string.format('%d', round(v)) .. (suffix or '')
+end
+
+-- The fetcher asks Open-Meteo for km/h or mph to match UNITS.
+function M.wind_unit(units)
+ return units == 'imperial' and 'mph' or 'km/h'
+end
+
+-- Seconds as '10h 28m'.
+function M.duration(s)
+ if type(s) ~= 'number' or s < 0 then return '--' end
+ return string.format('%dh %02dm', math.floor(s / 3600), math.floor(s % 3600 / 60))
+end
+
+-- Threshold role names, which index the palette (colors.ok and so on).
+-- UV: WHO bands, low under 3, very high from 8.
+function M.uv_role(uv)
+ if type(uv) ~= 'number' then return nil end
+ if uv >= 8 then return 'critical' elseif uv >= 3 then return 'warning' end
+ return 'ok'
+end
+
+-- European AQI: good and fair up to 40, moderate and poor up to 80.
+function M.aqi_role(aqi)
+ if type(aqi) ~= 'number' then return nil end
+ if aqi > 80 then return 'critical' elseif aqi > 40 then return 'warning' end
+ return 'ok'
+end
+
+-- Moon phase from the mean synodic month against a known new moon
+-- (2000-01-06 18:14 UTC). Returns phase 0..1 (0 new, 0.5 full) and the lit
+-- fraction 0..1. The mean month is off the true one by under a day, which a
+-- 28-step glyph cannot show anyway.
+local SYNODIC = 29.530588853
+local NEW_MOON = 947182440
+
+function M.moon(epoch)
+ if type(epoch) ~= 'number' then return nil end
+ local phase = ((epoch - NEW_MOON) / 86400 / SYNODIC) % 1
+ return phase, (1 - math.cos(2 * math.pi * phase)) / 2
+end
+
+-- One of the 28 Nerd Font moon glyphs, U+E38D (new) to U+E3A8, in phase order.
+function M.moon_icon(phase)
+ if type(phase) ~= 'number' then return M.ICON.unknown end
+ return utf8.char(0xE38D + round(phase * 28) % 28)
+end
+
+-- Eight compass points. The half-step offset is what makes north straddle
+-- zero: without it, 350 degrees would land in NW and 10 in NE.
+local POINTS = { 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW' }
+
+function M.compass(deg)
+ if type(deg) ~= 'number' then return nil end
+ local i = math.floor(((deg % 360) + 22.5) / 45) % 8
+ return POINTS[i + 1]
+end
+
+-- Fraction of daylight elapsed, clamped to 0..1.
+--
+-- The cache carries TODAY's sunrise and sunset, so before dawn this is
+-- negative and after dusk it is over 1. Clamping parks the dot at the
+-- corresponding end of the arc.
+function M.sun_t(now, sunrise, sunset)
+ if type(now) ~= 'number' or type(sunrise) ~= 'number'
+ or type(sunset) ~= 'number' then return nil end
+ local span = sunset - sunrise
+ if span <= 0 then return 0 end -- polar day/night or bad data: no division
+ local t = (now - sunrise) / span
+ if t < 0 then return 0 elseif t > 1 then return 1 end
+ return t
+end
+
+-- Whether the sun is up. Inclusive at both ends; unknown falls back to day.
+function M.is_day(now, sunrise, sunset)
+ if type(now) ~= 'number' or type(sunrise) ~= 'number'
+ or type(sunset) ~= 'number' then return true end
+ return now >= sunrise and now <= sunset
+end
+
+-- Parse the cache: tab-separated records, first field the record type.
+--
+-- units metric
+-- current epoch temp feels code is_day humidity wind wind_deg
+-- sun sunrise sunset
+-- air european_aqi
+-- hour epoch temp code precip_prob is_day uv (24 of them)
+-- day epoch code min max precip_prob (7 of them)
+--
+-- A field Open-Meteo left null arrives as the word 'null', which tonumber
+-- turns into nil. Returns nil when current, sun or every hour is missing:
+-- a partial card is worse than an honest "no data".
+function M.parse(src)
+ if type(src) ~= 'string' or src == '' then return nil end
+ local w = { hours = {}, days = {} }
+ for line in src:gmatch('[^\n]+') do
+ local f = {}
+ for field in (line .. '\t'):gmatch('([^\t]*)\t') do f[#f + 1] = field end
+ local function n(i) return tonumber(f[i]) end
+ local kind = f[1]
+ if kind == 'units' then
+ w.units = f[2]
+ elseif kind == 'current' then
+ w.current = { dt = n(2), temp = n(3), feels = n(4), code = n(5),
+ is_day = n(6) ~= 0, humidity = n(7), wind = n(8),
+ wind_deg = n(9) }
+ elseif kind == 'sun' then
+ w.sun = { rise = n(2), set = n(3) }
+ elseif kind == 'air' then
+ w.air = { aqi = n(2) }
+ elseif kind == 'hour' then
+ w.hours[#w.hours + 1] = { t = n(2), temp = n(3), code = n(4),
+ precip = n(5), is_day = n(6) ~= 0, uv = n(7) }
+ elseif kind == 'day' then
+ w.days[#w.days + 1] = { t = n(2), code = n(3), min = n(4), max = n(5),
+ precip = n(6) }
+ end
+ end
+ if not (w.current and w.current.temp and w.sun and w.sun.rise and w.sun.set
+ and #w.hours > 0) then
+ return nil
+ end
+ return w
+end
+
+-- Three missed fetches at the 15-minute interval.
+M.STALE_AFTER = 45 * 60
+
+function M.is_stale(dt, now)
+ if type(dt) ~= 'number' or type(now) ~= 'number' then return true end
+ return (now - dt) > M.STALE_AFTER
+end
+
+-- Short age: "12m", "3h", "2d". A negative age (clock skew) reads as fresh.
+function M.age_str(dt, now)
+ if type(dt) ~= 'number' or type(now) ~= 'number' then return '?' end
+ local s = now - dt
+ if s < 0 then s = 0 end
+ if s < 3600 then return math.floor(s / 60) .. 'm' end
+ if s < 86400 then return math.floor(s / 3600) .. 'h' end
+ return math.floor(s / 86400) .. 'd'
+end
+
+return M
+```
+
+Note on `is_day = n(6) ~= 0`: a missing field gives `nil ~= 0`, which is `true`, so unknown draws the day face, matching the rest of the module.
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `lua test/test_weather.lua`
+Expected: `test_weather: all assertions passed`
+
+If `weather.deg(-0.4)` fails with `-0°`: `round(-0.4)` is `math.floor(0.1)` = `0` (integer), so it should pass; if Lua returns float `-0.0`, the `r == 0` fold handles it.
+
+- [ ] **Step 6: Commit**
+
+`widgets/weather.lua` still requires the old API and will draw wrongly until Task 4 deletes it. That is acceptable for one commit in the middle of this plan; do not restart conky until Task 4.
+
+```bash
+git add lib/weather.lua test/test_weather.lua test/fixtures/weather.txt
+git commit -m "feat(weather): parse an Open-Meteo cache instead of OWM JSON
+
+WMO codes to glyphs and words, moon phase, UV and AQI roles, and a
+parser for the tab-separated cache the fetcher will write. Groundwork
+for the Mirage-derived forecast cards.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Open-Meteo fetcher
+
+**Files:**
+- Rewrite: `bin/weather-fetch.sh`
+- Rewrite: `weather.env.example`
+
+- [ ] **Step 1: Rewrite the fetcher**
+
+Replace all of `bin/weather-fetch.sh` with (keep it executable):
+
+```bash
+#!/bin/bash
+# Fetch the forecast from Open-Meteo into a cache file. Run from conky's
+# ${texeci}, so it prints nothing on success and never blocks the dashboard:
+# the widgets only ever read the cache.
+#
+# Open-Meteo needs no key. The location is a city name from weather.env,
+# geocoded once and cached, so no coordinates live in this repository.
+#
+# Exits non-zero with a message on stderr when it cannot fetch, and leaves any
+# existing cache untouched rather than replacing good data with an error.
+
+set -u
+
+ENV_FILE="${WEATHER_ENV:-$HOME/.config/udt/weather.env}"
+CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
+CACHE="$CACHE_DIR/weather.txt"
+LOC="$CACHE_DIR/weather-loc"
+
+if [ ! -r "$ENV_FILE" ]; then
+ echo "weather-fetch: no $ENV_FILE (copy weather.env.example)" >&2
+ exit 1
+fi
+
+set -a
+# shellcheck source=/dev/null
+. "$ENV_FILE"
+set +a
+
+if [ -z "${CITY:-}" ]; then
+ echo "weather-fetch: CITY is empty in $ENV_FILE" >&2
+ exit 1
+fi
+UNITS="${UNITS:-metric}"
+
+mkdir -p "$CACHE_DIR"
+
+# Location data shares a directory with UDT's other generated state, all of
+# which is 600. Set the mode before anything lands, so no file is briefly
+# world-readable.
+umask 077
+
+# Temp files sit beside the target, because rename is only atomic within a
+# filesystem. The widgets read the cache on their own 2s cadence, so a fetch
+# killed mid-write would otherwise hand them half a cache.
+TMP="$CACHE.tmp.$$"
+FC="$TMP.forecast"
+AIR="$TMP.air"
+trap 'rm -f "$TMP" "$FC" "$AIR"' EXIT
+
+# Geocode once per CITY/COUNTRY pair. The cache's first field records which
+# pair it answers, so editing weather.env re-geocodes on the next run.
+PAIR="$CITY,${COUNTRY:-}"
+lat="" lon="" cached=""
+if [ -r "$LOC" ]; then
+ IFS=$'\t' read -r cached lat lon < "$LOC"
+ [ "$cached" = "$PAIR" ] || lat=""
+fi
+if [ -z "$lat" ]; then
+ read -r lat lon < <(curl -fsS --max-time 15 --get \
+ "https://geocoding-api.open-meteo.com/v1/search" \
+ --data-urlencode "name=$CITY" --data-urlencode "count=10" 2>/dev/null \
+ | jq -r --arg cc "${COUNTRY:-}" '
+ [.results[]? | select($cc == "" or .country_code == ($cc | ascii_upcase))][0]
+ | select(. != null) | "\(.latitude) \(.longitude)"')
+ if [ -z "$lat" ]; then
+ echo "weather-fetch: cannot geocode $PAIR" >&2
+ exit 1
+ fi
+ printf '%s\t%s\t%s\n' "$PAIR" "$lat" "$lon" > "$LOC"
+fi
+
+if [ "$UNITS" = imperial ]; then
+ TEMP_UNIT=fahrenheit WIND_UNIT=mph
+else
+ TEMP_UNIT=celsius WIND_UNIT=kmh
+fi
+
+if ! curl -fsS --max-time 15 --get "https://api.open-meteo.com/v1/forecast" \
+ --data-urlencode "latitude=$lat" --data-urlencode "longitude=$lon" \
+ --data-urlencode "timeformat=unixtime" --data-urlencode "timezone=auto" \
+ --data-urlencode "forecast_days=7" \
+ --data-urlencode "temperature_unit=$TEMP_UNIT" \
+ --data-urlencode "wind_speed_unit=$WIND_UNIT" \
+ --data-urlencode "current=temperature_2m,apparent_temperature,weather_code,is_day,relative_humidity_2m,wind_speed_10m,wind_direction_10m" \
+ --data-urlencode "hourly=temperature_2m,weather_code,precipitation_probability,is_day,uv_index" \
+ --data-urlencode "daily=weather_code,temperature_2m_min,temperature_2m_max,precipitation_probability_max,sunrise,sunset" \
+ -o "$FC" 2>/dev/null; then
+ echo "weather-fetch: forecast request failed" >&2
+ exit 1
+fi
+
+# Air quality is a second service. Losing it costs one row, not the cache, so
+# a failure leaves an empty file and the air line is simply omitted.
+curl -fsS --max-time 15 --get "https://air-quality-api.open-meteo.com/v1/air-quality" \
+ --data-urlencode "latitude=$lat" --data-urlencode "longitude=$lon" \
+ --data-urlencode "current=european_aqi" \
+ -o "$AIR" 2>/dev/null || : > "$AIR"
+
+# Flatten to the line format lib/weather.lua parses. The 24 hours start at
+# the current hour: the first hourly slot later than an hour before now.
+if ! jq -r --arg units "$UNITS" --slurpfile air "$AIR" '
+ .current.time as $now
+ | ([.hourly.time | to_entries[] | select(.value > $now - 3600) | .key] | first) as $i
+ | "units\t\($units)",
+ (.current | "current\t\(.time)\t\(.temperature_2m)\t\(.apparent_temperature)\t\(.weather_code)\t\(.is_day)\t\(.relative_humidity_2m)\t\(.wind_speed_10m)\t\(.wind_direction_10m)"),
+ "sun\t\(.daily.sunrise[0])\t\(.daily.sunset[0])",
+ ($air[0].current.european_aqi // empty | "air\t\(.)"),
+ (.hourly as $h | range($i; [$i + 24, ($h.time | length)] | min) as $k
+ | "hour\t\($h.time[$k])\t\($h.temperature_2m[$k])\t\($h.weather_code[$k])\t\($h.precipitation_probability[$k])\t\($h.is_day[$k])\t\($h.uv_index[$k])"),
+ (.daily as $d | range(0; $d.time | length) as $k
+ | "day\t\($d.time[$k])\t\($d.weather_code[$k])\t\($d.temperature_2m_min[$k])\t\($d.temperature_2m_max[$k])\t\($d.precipitation_probability_max[$k])")
+ ' "$FC" > "$TMP" 2>/dev/null; then
+ echo "weather-fetch: cannot read the forecast response" >&2
+ exit 1
+fi
+
+# A 200 whose body is not a usable forecast must not replace a good cache.
+if ! grep -q $'^current\t[0-9]*\t-\\?[0-9]' "$TMP"; then
+ echo "weather-fetch: forecast has no current temperature" >&2
+ exit 1
+fi
+
+mv -f "$TMP" "$CACHE"
+```
+
+- [ ] **Step 2: Rewrite the env example**
+
+Replace all of `weather.env.example` with:
+
+```
+# Copy to ~/.config/udt/weather.env and fill in. That path is outside this
+# repo on purpose: a location is personal data, and anything committed is
+# potentially public. chmod 600 it.
+#
+# The forecast comes from Open-Meteo (https://open-meteo.com), which needs no
+# key. CITY is geocoded once through Open-Meteo's geocoding API; COUNTRY is the
+# two-letter ISO code that picks the right place among same-named ones.
+# UNITS is metric (C, km/h) or imperial (F, mph).
+
+CITY=Example City
+COUNTRY=XX
+UNITS=metric
+```
+
+- [ ] **Step 3: Run the fetcher against the live API**
+
+The user's `~/.config/udt/weather.env` already has `CITY`/`COUNTRY`/`UNITS` from the OWM setup; its `KEY` line is now ignored.
+
+Run: `bash -n bin/weather-fetch.sh && ./bin/weather-fetch.sh; echo "exit: $?"; wc -l ~/.cache/udt/weather.txt; cut -f1 ~/.cache/udt/weather.txt | sort | uniq -c`
+Expected: `exit: 0`, `35` lines, and counts `7 day`, `24 hour`, `1 air`, `1 current`, `1 sun`, `1 units`. Do not paste the file contents or `weather-loc` anywhere committed: they locate the user.
+
+- [ ] **Step 4: Check the failure paths keep the cache**
+
+Run: `cp ~/.cache/udt/weather.txt /tmp/claude-wx.bak; WEATHER_ENV=/nonexistent ./bin/weather-fetch.sh; echo "exit: $?"; cmp ~/.cache/udt/weather.txt /tmp/claude-wx.bak && echo unchanged; rm /tmp/claude-wx.bak`
+Expected: `weather-fetch: no /nonexistent (copy weather.env.example)`, `exit: 1`, `unchanged`.
+
+- [ ] **Step 5: Check the parser reads the live cache**
+
+Run: `lua -e "package.path='./?.lua;'..package.path; local w=require('lib.weather'); local f=io.open(w.CACHE); local t=w.parse(f:read('a')); f:close(); print(t and (#t.hours..' hours '..#t.days..' days aqi '..tostring(t.air and t.air.aqi)) or 'PARSE FAILED')"`
+Expected: `24 hours 7 days aqi <a number>`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add bin/weather-fetch.sh weather.env.example
+git commit -m "feat(weather): fetch from Open-Meteo, no API key
+
+Forecast and air quality from Open-Meteo, flattened by jq into the
+tab-separated cache lib/weather.lua parses. The city is geocoded once
+and cached; a failed air-quality call drops one line, not the cache.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Card primitives and glyph check
+
+**Files:**
+- Modify: `lib/card.lua` (the `M.ICON` table near the end, plus two new functions after `M.text_right`)
+
+- [ ] **Step 1: Add the title glyphs**
+
+In `lib/card.lua`, replace the `M.ICON` table with:
+
+```lua
+M.ICON = {
+ breaktimer = '\u{F13AB}', -- timer
+ calendar = '\u{F0E17}', -- calendar-month
+ immich = '\u{F02F9}', -- image-multiple
+ hourly = '\u{F0150}', -- clock-outline
+ week = '\u{F0A33}', -- calendar-week
+ details = '\u{F059D}', -- weather-windy
+ sky = '\u{F0594}', -- weather-night
+}
+```
+
+- [ ] **Step 2: Add `text_centre` after `M.text_right`**
+
+```lua
+-- Centred text: x is the CENTRE.
+function M.text_centre(cr, x, y, s)
+ local w = M.measure(cr, s)
+ M.text(cr, x - w / 2, y, s)
+end
+```
+
+- [ ] **Step 3: Add `standard` after `M.fit_unit`**
+
+```lua
+-- The DESIGN.md card in one call: the title top-left, the big value top-right,
+-- then label-left / value-right rows, all from one fluid base size S.
+--
+-- spec.icon, spec.title the title run, as M.title draws it
+-- spec.big the big value; nil drops it (a band carries the value)
+-- spec.big_color defaults to colors.body
+-- spec.big_font defaults to FONT_HEAVY; FONT_MONO for a glyph
+-- spec.rows { {label, value, color}, ... }, color defaults to colors.value
+-- spec.share fraction of inner.h the header and rows may use (default 1)
+--
+-- Returns the y where the drawn content ends and S, so a card can put its
+-- variable band in what is left and size the band's type off the same S.
+-- The proportions are the breaktimer card's, which this generalises.
+function M.standard(cr, inner, colors, spec)
+ local LABEL_F, BIG_F, ROW_F = 0.30, 1.0, 0.32
+ local rows = spec.rows or {}
+ local big_font = spec.big_font or M.FONT_HEAVY
+ local head = { { M.title_str(spec.icon, spec.title), M.FONT_MONO, LABEL_F } }
+ if spec.big then head[2] = { spec.big, big_font, BIG_F } end
+ local groups = { head }
+ for _, r in ipairs(rows) do
+ groups[#groups + 1] = { { r[1], M.FONT_MONO, ROW_F },
+ { tostring(r[2]), M.FONT_MONO, ROW_F } }
+ end
+ -- Height in units of S: the header line, the gap under it, a step per row.
+ local head_f = spec.big and BIG_F or LABEL_F * 1.2
+ local budget = inner.h * (spec.share or 1)
+ local S = clamp(math.min(budget * 0.94 / (head_f + 0.5 + #rows * 0.66),
+ M.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
+ local label_size, row_size = S * LABEL_F, S * ROW_F
+
+ if spec.big then
+ M.font(cr, big_font, S * BIG_F, false)
+ M.rgba(cr, spec.big_color or colors.body)
+ M.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, spec.big)
+ end
+ M.font(cr, M.FONT_MONO, label_size, false)
+ M.title(cr, inner.x, inner.y + label_size, spec.icon, spec.title, colors)
+
+ local step = row_size * 2.0
+ local y = inner.y + S * head_f + row_size * 1.6
+ local last = inner.y + S * head_f
+ M.font(cr, M.FONT_MONO, row_size, false)
+ for _, r in ipairs(rows) do
+ if y > inner.y + inner.h then break end
+ M.rgba(cr, colors.label)
+ M.text(cr, inner.x, y, r[1])
+ M.rgba(cr, r[3] or colors.value)
+ M.text_right(cr, inner.x + inner.w, y, tostring(r[2]))
+ last = y
+ y = y + step
+ end
+ return last + row_size * 0.8, S
+end
+```
+
+- [ ] **Step 4: Render a glyph sheet and look at it**
+
+Write `/tmp/claude-glyphs.lua` (scratch, never committed):
+
+```lua
+package.cpath = '/usr/lib64/conky/lib?.so;' .. package.cpath
+package.path = './?.lua;' .. package.path
+require 'cairo'
+local card = require 'lib.card'
+local weather = require 'lib.weather'
+local items = {}
+for k, v in pairs(card.ICON) do items[#items + 1] = { 'card.' .. k, v } end
+for k, v in pairs(weather.ICON) do items[#items + 1] = { k, v } end
+for i = 0, 27 do items[#items + 1] = { 'moon ' .. i, utf8.char(0xE38D + i) } end
+table.sort(items, function(a, b) return a[1] < b[1] end)
+local s = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 900, 40 * #items // 3 + 60)
+local cr = cairo_create(s)
+cairo_set_source_rgb(cr, 0.1, 0.1, 0.12); cairo_paint(cr)
+for i, it in ipairs(items) do
+ local x, y = ((i - 1) % 3) * 300 + 10, ((i - 1) // 3) * 40 + 36
+ card.font(cr, card.FONT_MONO, 28, false); cairo_set_source_rgb(cr, 1, 1, 1)
+ card.text(cr, x, y, it[2])
+ card.font(cr, card.FONT_MONO, 14, false); cairo_set_source_rgb(cr, 0.7, 0.7, 0.7)
+ card.text(cr, x + 44, y - 6, it[1])
+end
+cairo_surface_write_to_png(s, '/tmp/claude-glyphs.png')
+```
+
+Run: `lua /tmp/claude-glyphs.lua` then open `/tmp/claude-glyphs.png` with the Read tool.
+Expected: every glyph drawn and matching its name; moon 0 is a dark disc, moon 14 a full disc, phases progress in between. If any title glyph is wrong or blank, pick a neighbour from the Nerd Font cheat sheet (`nf-md-*`), update the codepoint, rerender. Do not commit until all look right.
+
+- [ ] **Step 5: Run the suite**
+
+Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua`
+Expected: three `all assertions passed` lines (test_layout prints its own pass line).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/card.lua
+git commit -m "feat(card): standard header-and-rows helper, centred text, forecast glyphs
+
+card.standard() draws the DESIGN.md card (title left, big value right,
+label/value rows) from one fluid size and returns where it stopped, so
+the forecast cards share it instead of copying breaktimer's layout five
+times.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: `now` card, replacing `weather`
+
+**Files:**
+- Create: `widgets/now.lua`
+- Delete: `widgets/weather.lua`
+- Modify: `dashboard.lua` (layout row `widget = 'weather'`)
+
+- [ ] **Step 1: Write the widget**
+
+Create `widgets/now.lua`:
+
+```lua
+-- Now: the current conditions. Replaces the OpenWeatherMap weather card; the
+-- daylight arc it carried moved to the sky card.
+--
+-- One of the forecast cards adapted from the Conky-Mirage theme by
+-- alexsson-xexpanderx (see lib/weather.lua).
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+local weather = require 'lib.weather'
+
+local M = {}
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local w = weather.parse(data.slurp(weather.CACHE))
+ if not w then
+ -- Closed explicitly: this branch reruns every frame until a fetch lands,
+ -- so an unclosed handle would accumulate one per draw.
+ local envf = io.open((os.getenv('HOME') or '') .. '/.config/udt/weather.env', 'r')
+ local hint = 'set ~/.config/udt/weather.env'
+ if envf then
+ envf:close()
+ hint = 'bin/weather-fetch.sh'
+ end
+ card.notice(cr, inner, colors, 'WEATHER', 'no weather data', hint)
+ return
+ end
+
+ local c, today, now = w.current, w.days[1] or {}, os.time()
+ local rows = {
+ { 'FEELS LIKE', weather.deg(c.feels) },
+ { 'HIGH / LOW', weather.deg(today.max) .. ' / ' .. weather.deg(today.min) },
+ { 'RAIN', weather.num(today.precip, '%') },
+ { 'UPDATED', weather.age_str(c.dt, now),
+ weather.is_stale(c.dt, now) and colors.warning or nil },
+ }
+ local y, S = card.standard(cr, inner, colors, {
+ icon = weather.icon(c.code, c.is_day), title = 'WEATHER',
+ big = weather.deg(c.temp), rows = rows, share = 0.85,
+ })
+
+ -- The condition in words, in the prose face, in what the rows left.
+ local size = S * 0.36
+ if y + size * 1.4 <= inner.y + inner.h then
+ card.font(cr, card.FONT_UI, size, false)
+ card.rgba(cr, colors.body)
+ card.text(cr, inner.x, y + size * 1.4, weather.describe(c.code))
+ end
+end
+
+return M
+```
+
+- [ ] **Step 2: Swap the layout row and delete the old card**
+
+In `dashboard.lua`, change:
+
+```lua
+ { widget = 'weather', col = 1, row = 7, w = 3, h = 5 },
+```
+
+to:
+
+```lua
+ { widget = 'now', col = 1, row = 7, w = 3, h = 5 },
+```
+
+Then: `git rm -q widgets/weather.lua`
+
+- [ ] **Step 3: Render offscreen and look**
+
+Run: `lua test/render.lua now 16 12 3 5 /tmp/claude-now.png`
+Expected: `now at 3x5 on a 16x12 grid -> /tmp/claude-now.png`. Open it with Read: condition glyph and `WEATHER` top-left, big temperature top-right, four label/value rows, description under them, nothing overlapping or clipped.
+
+Also check the no-data face: `XDG_CACHE_HOME=/nonexistent lua test/render.lua now 16 12 3 5 /tmp/claude-now-empty.png` and open it: chrome with `WEATHER`, `no weather data`, `bin/weather-fetch.sh`.
+
+- [ ] **Step 4: Suite, then commit**
+
+Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua`
+Expected: all pass.
+
+```bash
+git add widgets/now.lua dashboard.lua
+git commit -m "feat(now): current-conditions card replaces the OWM weather card
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: `hourly` card
+
+**Files:**
+- Create: `widgets/hourly.lua`
+
+- [ ] **Step 1: Write the widget**
+
+```lua
+-- Hourly: the next 24 hours as a temperature curve over rain-chance bars.
+--
+-- Adapted from the Conky-Mirage theme's hourly tile by alexsson-xexpanderx
+-- (see lib/weather.lua), redrawn in this repo's palette, glyphs and fonts.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+local weather = require 'lib.weather'
+
+local M = {}
+
+-- The band, top to bottom: glyph line, temperature line, curve, hour line.
+-- Every third hour is labelled; all 24 shape the curve.
+local function draw_curve(cr, x, y, w, h, colors, hours, S)
+ local n = #hours
+ local label = math.max(9, math.min(S * 0.30, h * 0.11))
+ local glyph = label * 1.5
+ local slot = w / n
+ local function px(i) return x + (i - 0.5) * slot end
+ local top = y + glyph + label * 1.6
+ local base = y + h - label * 1.6
+ if base - top < label * 2 then return end -- no room: no curve beats a broken one
+
+ local lo, hi = math.huge, -math.huge
+ for _, hr in ipairs(hours) do
+ if hr.temp then lo, hi = math.min(lo, hr.temp), math.max(hi, hr.temp) end
+ end
+ if lo > hi then return end
+ -- A flat day still needs a span to divide by; 1 degree keeps it mid-band.
+ local span = math.max(hi - lo, 1)
+ local function py(t) return base - (base - top) * (0.15 + 0.7 * (t - lo) / span) end
+
+ -- Rain chance as bars rising from the baseline, under the curve.
+ card.rgba(cr, colors.rule, 0.5)
+ for i, hr in ipairs(hours) do
+ if hr.precip and hr.precip > 0 then
+ local bh = (base - top) * 0.5 * hr.precip / 100
+ cairo_rectangle(cr, px(i) - slot * 0.3, base - bh, slot * 0.6, bh)
+ end
+ end
+ cairo_fill(cr)
+
+ card.rgba(cr, colors.rule, 0.6)
+ cairo_set_line_width(cr, 1)
+ cairo_move_to(cr, x, base)
+ cairo_line_to(cr, x + w, base)
+ cairo_stroke(cr)
+
+ -- The curve, broken where an hour has no temperature rather than bridged.
+ card.rgba(cr, colors.highlight)
+ cairo_set_line_width(cr, math.max(1.5, label * 0.15))
+ local pen = false
+ for i, hr in ipairs(hours) do
+ if hr.temp then
+ if pen then cairo_line_to(cr, px(i), py(hr.temp))
+ else cairo_move_to(cr, px(i), py(hr.temp)) end
+ pen = true
+ else
+ pen = false
+ end
+ end
+ cairo_stroke(cr)
+
+ for i = 1, n, 3 do
+ local hr = hours[i]
+ card.font(cr, card.FONT_MONO, glyph, false)
+ card.rgba(cr, colors.highlight)
+ card.text_centre(cr, px(i), y + glyph, weather.icon(hr.code, hr.is_day))
+ card.font(cr, card.FONT_MONO, label, false)
+ card.rgba(cr, colors.value)
+ card.text_centre(cr, px(i), y + glyph + label * 1.3, weather.deg(hr.temp))
+ card.rgba(cr, colors.label)
+ card.text_centre(cr, px(i), y + h - label * 0.2,
+ hr.t and os.date('%H', hr.t) or '--')
+ end
+end
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local w = weather.parse(data.slurp(weather.CACHE))
+ if not w then
+ card.notice(cr, inner, colors, 'NEXT 24H', 'no weather data', 'bin/weather-fetch.sh')
+ return
+ end
+ -- hours[1] is the current hour, so the big value is the one after it.
+ local y, S = card.standard(cr, inner, colors, {
+ icon = card.ICON.hourly, title = 'NEXT 24H',
+ big = weather.deg((w.hours[2] or {}).temp), share = 0.3,
+ })
+ draw_curve(cr, inner.x, y, inner.w, inner.y + inner.h - y, colors, w.hours, S)
+end
+
+return M
+```
+
+- [ ] **Step 2: Render and look**
+
+Run: `lua test/render.lua hourly 16 12 7 3 /tmp/claude-hourly.png`, open it.
+Expected: header line, then 8 labelled columns (glyph, temperature, hour), a continuous highlight curve, faint bars where rain is forecast, nothing clipped. Also render at `5 3` and `7 2` and check the curve drops out cleanly rather than overlapping when the band is too short.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add widgets/hourly.lua
+git commit -m "feat(hourly): next-24-hours card adapted from Conky-Mirage
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: `week` card
+
+**Files:**
+- Create: `widgets/week.lua`
+
+- [ ] **Step 1: Write the widget**
+
+```lua
+-- Week: seven days of glyph and min-to-max range bar.
+--
+-- Adapted from the Conky-Mirage theme's week tile by alexsson-xexpanderx
+-- (see lib/weather.lua). Each bar sits on the week's overall min..max scale,
+-- so a warm day reads as a bar further right, not just a longer one.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+local weather = require 'lib.weather'
+
+local M = {}
+
+local function draw_days(cr, x, y, w, h, colors, days, S)
+ local n = #days
+ if n == 0 then return end
+ local step = h / n
+ local size = math.min(S * 0.32, step * 0.55)
+ if size < 8 then return end
+
+ local lo, hi = math.huge, -math.huge
+ for _, d in ipairs(days) do
+ if d.min then lo = math.min(lo, d.min) end
+ if d.max then hi = math.max(hi, d.max) end
+ end
+ local span = (hi > lo) and (hi - lo) or 1
+ -- Columns as fractions of the width: day | glyph | min | bar | max.
+ local gx, minx = x + w * 0.20, x + w * 0.42
+ local bx0, bx1 = x + w * 0.46, x + w * 0.84
+ local function bx(t) return bx0 + (bx1 - bx0) * (t - lo) / span end
+
+ cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
+ for i, d in ipairs(days) do
+ local by = y + step * (i - 0.5) + size * 0.35 -- baseline, centred in the row
+ card.font(cr, card.FONT_MONO, size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, x, by, i == 1 and 'TODAY'
+ or (d.t and os.date('%a', d.t):upper() or '--'))
+ card.rgba(cr, colors.highlight)
+ card.text(cr, gx, by, weather.icon(d.code, true))
+ card.rgba(cr, colors.value)
+ card.text_right(cr, minx, by, weather.deg(d.min))
+ card.text_right(cr, x + w, by, weather.deg(d.max))
+
+ -- The track spans the week; the fill is this day.
+ local my = by - size * 0.35
+ cairo_set_line_width(cr, size * 0.35)
+ card.rgba(cr, colors.rule, 0.35)
+ cairo_move_to(cr, bx0, my)
+ cairo_line_to(cr, bx1, my)
+ cairo_stroke(cr)
+ if d.min and d.max then
+ card.rgba(cr, colors.highlight)
+ cairo_move_to(cr, bx(d.min), my)
+ cairo_line_to(cr, bx(d.max), my)
+ cairo_stroke(cr)
+ end
+ end
+ cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT)
+end
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local w = weather.parse(data.slurp(weather.CACHE))
+ if not w then
+ card.notice(cr, inner, colors, 'WEEK', 'no weather data', 'bin/weather-fetch.sh')
+ return
+ end
+ local y, S = card.standard(cr, inner, colors, {
+ icon = card.ICON.week, title = 'WEEK', share = 0.25,
+ })
+ draw_days(cr, inner.x, y, inner.w, inner.y + inner.h - y, colors, w.days, S)
+end
+
+return M
+```
+
+`cairo_set_line_cap` is reset to BUTT at the end because cairo state is shared with every card drawn after this one.
+
+- [ ] **Step 2: Render and look**
+
+Run: `lua test/render.lua week 16 12 3.5 9 /tmp/claude-week.png`, open it.
+Expected: title, then 7 evenly spaced rows `TODAY`, weekday names, glyphs, min right-aligned before the bar, bars offset by temperature, max at the right edge. Columns do not collide. Also render at `3 5`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add widgets/week.lua
+git commit -m "feat(week): seven-day range-bar card adapted from Conky-Mirage
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: `details` card
+
+**Files:**
+- Create: `widgets/details.lua`
+
+- [ ] **Step 1: Write the widget**
+
+```lua
+-- Details: wind, humidity, UV and air quality, over a wind compass.
+--
+-- Adapted from the Conky-Mirage theme's details tiles by alexsson-xexpanderx
+-- (see lib/weather.lua). UV and AQI are the measured quantities here, so they
+-- carry the ok / warning / critical roles.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+local weather = require 'lib.weather'
+
+local M = {}
+
+local function draw_compass(cr, x, y, w, h, colors, deg, S)
+ local r = math.min(w, h) / 2 - S * 0.1
+ if r < 24 then return end
+ local cx, cy = x + w / 2, y + h / 2
+ card.rgba(cr, colors.rule, 0.8)
+ cairo_set_line_width(cr, 1)
+ cairo_new_path(cr)
+ cairo_arc(cr, cx, cy, r, 0, math.pi * 2)
+ cairo_stroke(cr)
+
+ local size = math.max(9, r * 0.2)
+ card.font(cr, card.FONT_MONO, size, false)
+ card.rgba(cr, colors.label)
+ for i, p in ipairs({ 'N', 'E', 'S', 'W' }) do
+ local a = (i - 1) * math.pi / 2
+ card.text_centre(cr, cx + (r - size) * math.sin(a),
+ cy - (r - size) * math.cos(a) + size * 0.35, p)
+ end
+
+ if type(deg) ~= 'number' then return end
+ -- Open-Meteo gives where the wind comes FROM; the needle points where it goes.
+ local a = math.rad(deg + 180)
+ local tip = r - size * 2
+ local tx, ty = cx + tip * math.sin(a), cy - tip * math.cos(a)
+ card.rgba(cr, colors.highlight)
+ cairo_set_line_width(cr, math.max(2, r * 0.04))
+ cairo_move_to(cr, cx - tip * 0.6 * math.sin(a), cy + tip * 0.6 * math.cos(a))
+ cairo_line_to(cr, tx, ty)
+ cairo_stroke(cr)
+ cairo_new_path(cr)
+ cairo_arc(cr, tx, ty, math.max(3, r * 0.06), 0, math.pi * 2)
+ cairo_fill(cr)
+end
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local w = weather.parse(data.slurp(weather.CACHE))
+ if not w then
+ card.notice(cr, inner, colors, 'DETAILS', 'no weather data', 'bin/weather-fetch.sh')
+ return
+ end
+
+ local c = w.current
+ -- UV is the forecast for the current hour; Open-Meteo's current block has none.
+ local uv = (w.hours[1] or {}).uv
+ local aqi = w.air and w.air.aqi
+ local wind = '--'
+ if type(c.wind) == 'number' then
+ wind = weather.num(c.wind) .. ' ' .. weather.wind_unit(w.units)
+ .. ' ' .. (weather.compass(c.wind_deg) or '')
+ end
+ local rows = {
+ { 'WIND', wind },
+ { 'HUMIDITY', weather.num(c.humidity, '%') },
+ { 'UV', type(uv) == 'number' and string.format('%.1f', uv) or '--',
+ colors[weather.uv_role(uv)] },
+ { 'AIR (EAQI)', weather.num(aqi), colors[weather.aqi_role(aqi)] },
+ }
+ local y, S = card.standard(cr, inner, colors, {
+ icon = card.ICON.details, title = 'DETAILS', rows = rows, share = 0.6,
+ })
+ draw_compass(cr, inner.x, y, inner.w, inner.y + inner.h - y, colors, c.wind_deg, S)
+end
+
+return M
+```
+
+`colors[nil]` is a plain table read of a nil key, which returns nil without raising, so a missing role falls back to `colors.value` inside `card.standard`.
+
+- [ ] **Step 2: Render and look**
+
+Run: `lua test/render.lua details 16 12 3.5 4.5 /tmp/claude-details.png`, open it.
+Expected: title, four rows with UV and AQI in a threshold colour, compass circle with N/E/S/W and a needle below. Also render at `2 3` and confirm the compass drops out rather than overlapping the rows.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add widgets/details.lua
+git commit -m "feat(details): wind, humidity, UV and AQI card adapted from Conky-Mirage
+
+UV and the European AQI carry the ok/warning/critical roles, which puts
+critical back on the board.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: `sky` card
+
+**Files:**
+- Create: `widgets/sky.lua`
+
+- [ ] **Step 1: Write the widget**
+
+```lua
+-- Sky: sunrise, sunset, day length and the moon, over the daylight arc.
+--
+-- Adapted from the Conky-Mirage theme's sky tile by alexsson-xexpanderx
+-- (see lib/weather.lua). The arc is the one the old weather card drew, minus
+-- its time labels: the rows carry the times now.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+local weather = require 'lib.weather'
+
+local M = {}
+
+-- The dot is placed by evaluating the curve at t rather than by computing a
+-- point on a circle: the curve IS the path, so evaluating it keeps the dot on
+-- the arc if the control points are ever adjusted.
+local function draw_arc(cr, x, y, w, h, colors, rise, set, now)
+ local pad = math.max(4, w * 0.012)
+ local x0, x1 = x + pad, x + w - pad
+ local base = y + h - pad
+ local top = y + h * 0.08
+
+ -- Cubic Bezier control points, chosen so the curve peaks near the middle.
+ local p0 = { x0, base }
+ local c1 = { x0 + (x1 - x0) * 0.22, top - h * 0.14 }
+ local c2 = { x1 - (x1 - x0) * 0.22, top - h * 0.14 }
+ local p3 = { x1, base }
+
+ -- The horizon the sun rises from and sets into.
+ card.rgba(cr, colors.rule, 0.6)
+ cairo_set_line_width(cr, 1)
+ cairo_move_to(cr, x0, base)
+ cairo_line_to(cr, x1, base)
+ cairo_stroke(cr)
+
+ card.rgba(cr, colors.rule, 0.9)
+ cairo_set_line_width(cr, 1.5)
+ cairo_move_to(cr, p0[1], p0[2])
+ cairo_curve_to(cr, c1[1], c1[2], c2[1], c2[2], p3[1], p3[2])
+ cairo_stroke(cr)
+
+ local t = weather.sun_t(now, rise, set) or 0
+ local function bez(a, b, c, d)
+ local u = 1 - t
+ return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d
+ end
+ -- At night the dot is parked at an end, so it takes the dim colour: a bright
+ -- dot on the horizon would read as a sun that is still up.
+ card.rgba(cr, weather.is_day(now, rise, set) and colors.body or colors.label)
+ cairo_new_path(cr)
+ cairo_arc(cr, bez(p0[1], c1[1], c2[1], p3[1]), bez(p0[2], c1[2], c2[2], p3[2]),
+ math.max(3, math.min(9, h * 0.045)), 0, math.pi * 2)
+ cairo_fill(cr)
+end
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local w = weather.parse(data.slurp(weather.CACHE))
+ if not w then
+ card.notice(cr, inner, colors, 'SKY', 'no weather data', 'bin/weather-fetch.sh')
+ return
+ end
+
+ local now = os.time()
+ local rise, set = w.sun.rise, w.sun.set
+ local phase, lit = weather.moon(now)
+ local rows = {
+ { 'SUNRISE', os.date('%H:%M', rise) },
+ { 'SUNSET', os.date('%H:%M', set) },
+ { 'DAYLIGHT', weather.duration(set - rise) },
+ { 'MOON', weather.num(lit * 100, '%') },
+ }
+ local y = card.standard(cr, inner, colors, {
+ icon = card.ICON.sky, title = 'SKY',
+ big = weather.moon_icon(phase), big_font = card.FONT_MONO,
+ big_color = colors.heading, rows = rows, share = 0.65,
+ })
+
+ -- An arch taller than about 0.6 of its width reads as a door, not a day.
+ local arc_h = math.min(inner.y + inner.h - y, inner.w * 0.6)
+ if arc_h >= 50 then
+ draw_arc(cr, inner.x, inner.y + inner.h - arc_h, inner.w, arc_h, colors, rise, set, now)
+ end
+end
+
+return M
+```
+
+`parse()` guarantees `w.sun.rise` and `w.sun.set` are numbers, so the `os.date` and subtraction calls are safe.
+
+- [ ] **Step 2: Render and look**
+
+Run: `lua test/render.lua sky 16 12 3.5 4.5 /tmp/claude-sky.png`, open it.
+Expected: `SKY` title with a moon glyph big at the right in the heading colour, four rows, the arc pinned to the bottom with the dot on it (dim at night). Also render at `2 3`: the arc drops out below 50px.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add widgets/sky.lua
+git commit -m "feat(sky): sun, moon and daylight-arc card adapted from Conky-Mirage
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 9: Layout and live check
+
+**Files:**
+- Modify: `dashboard.lua` (the `layout` table)
+
+- [ ] **Step 1: Add the forecast rows**
+
+In `dashboard.lua`, add these entries at the end of the `layout` table, after the `immich` row:
+
+```lua
+ -- The forecast set, in the right side the quickshell moves left open.
+ { widget = 'hourly', col = 10, row = 1, w = 7, h = 3 },
+ { widget = 'week', col = 10, row = 4, w = 3.5, h = 9 },
+ { widget = 'details', col = 13.5, row = 4, w = 3.5, h = 4.5 },
+ { widget = 'sky', col = 13.5, row = 8.5, w = 3.5, h = 4.5 },
+```
+
+- [ ] **Step 2: Suite**
+
+Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua`
+Expected: all pass.
+
+- [ ] **Step 3: Restart and look at the live board**
+
+Run: `./restart.sh`
+Then show the `dash` special workspace and screenshot it (`grim` needs the active workspace; see the Hyprland memory note), and open the screenshot with Read. Check: no card overlaps another, type size reads consistently beside the clock and calendar, every card shows data (not its notice), no Lua error overlay. The screenshot shows calendar titles and photos: never commit or share it.
+
+If the user wants positions changed, edit only the four rows above and `./restart.sh` again (a `dashboard.lua` edit alone needs no restart, but a widget edit does).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add dashboard.lua
+git commit -m "feat(layout): place the hourly, week, details and sky cards
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 10: Docs
+
+**Files:**
+- Modify: `README.md`, `DESIGN.md`
+
+- [ ] **Step 1: Rewrite README's Weather section**
+
+Replace everything from `## Weather` up to (not including) `## System widgets` with:
+
+```markdown
+## Weather
+
+Five cards cover the weather: `now` (current conditions), `hourly` (the next
+24 hours), `week` (seven days), `details` (wind, humidity, UV, air quality) and
+`sky` (sun, moon and the daylight arc). The forecast cards are adapted from
+[Conky-Mirage](https://github.com/alexsson-xexpanderx/Conky-themes/tree/master/Conky-Mirage)
+by alexsson-xexpanderx, redrawn in this dashboard's palette, glyphs and fonts.
+
+Data comes from [Open-Meteo](https://open-meteo.com), which needs no key. Set
+the location:
+
+ cp weather.env.example ~/.config/udt/weather.env
+ chmod 600 ~/.config/udt/weather.env
+
+That path is outside the repo because a location is personal data, and
+anything committed is potentially public. `weather.env` is gitignored here as a
+second line of defence. `CITY` is geocoded once through Open-Meteo and the
+result cached in `~/.cache/udt/weather-loc`; changing `CITY` or `COUNTRY`
+re-geocodes on the next fetch.
+
+Fetching is scheduled by conky itself: `conky.text` carries
+`${texeci 900 ~/.config/conky/bin/weather-fetch.sh}`, so the cache refreshes
+every 15 minutes while the dashboard runs and never while it does not. The
+cards only ever read `~/.cache/udt/weather.txt`; they make no network call and
+cannot block the draw. Until the first fetch lands, each card draws its own
+chrome with `no weather data` rather than vanishing.
+
+To check the fetch by hand:
+
+ ./bin/weather-fetch.sh; echo "exit: $?"
+
+It prints nothing and exits 0 on success. On failure it says why on stderr and
+leaves any existing cache alone. A failed air-quality call is not a failure:
+the AQI row reads `--` and everything else updates.
+```
+
+- [ ] **Step 2: Fix the scattered mentions in README**
+
+Make these exact replacements:
+
+| Find | Replace with |
+|---|---|
+| `The key sits in a plain file, like the weather key, because the fetch runs` | `The key sits in a plain file because the fetch runs` |
+| ` { widget = 'weather', col = 1, row = 2, w = 1, h = 1 },` | ` { widget = 'now', col = 1, row = 2, w = 1, h = 1 },` |
+| `a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock and weather` / `cards do.` | `a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock does.` (joined onto one line) |
+| `The` / `weather card stacks from the top and lets the arc take the remainder.` | `The` / `sky card stacks its rows from the top and pins the arc to the bottom.` |
+| `overlap. The weather arc does this: below 70px it is not drawn at all, because a` / `curve crossing the stat rows is worse than no curve.` | `overlap. The sky arc does this: below 50px it is not drawn at all, because a` / `curve crossing the rows is worse than no curve.` |
+| ``~/.fonts/o/Oswald`) for squared numerals while the weather card keeps`` | ``~/.fonts/o/Oswald`) for squared numerals while the other cards keep`` |
+| The whole paragraph starting `**A bad OWM key returns 401 and an unknown city returns 404**` | `**A 200 is not proof of a forecast.** `curl -f` rejects HTTP errors, and the` / `fetch script also checks the flattened cache has a current temperature before` / `replacing the old one, so an unusable body never reaches the cards.` |
+| `weather text once shrank to a whisper beside the clock when its cell got shorter` | `old weather card's text once shrank to a whisper beside the clock when its cell got shorter` |
+
+- [ ] **Step 3: Fix DESIGN.md**
+
+| Find | Replace with |
+|---|---|
+| `card: the daylight arc in `weather`. Or the type itself fills the cell.` | `card: the daylight arc in `sky`, the curve in `hourly`. Or the type itself fills the cell.` |
+| `weather puts the condition glyph in its header, and every card title` | ``now` puts the condition glyph in its header, and every card title` |
+
+- [ ] **Step 4: Check nothing stale remains**
+
+Run: `grep -n -i "openweather\|OWM\|weather\.json\|widget = 'weather'" README.md DESIGN.md AGENTS.md lib widgets bin test dashboard.lua conky.conf.in`
+Expected: no output. Also `grep -c '—' README.md DESIGN.md` prints `0` for both.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add README.md DESIGN.md
+git commit -m "docs: the Open-Meteo weather cards and their Conky-Mirage origin
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 6: Push**
+
+Run: `git push`
+(`origin` may fan out to several push URLs; that is expected.)