aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 08:42:48 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 08:42:48 +0200
commit0f5bd9dbded3c6b51731581cb466bad0cbd4c5d1 (patch)
tree690312ba96772a09cf653fe9e2ee71a921a69e7c
parentb7692a3c33e6e66261c292d497a8cf1c9b8b07ba (diff)
downloadconky-theme-udt-0f5bd9dbded3c6b51731581cb466bad0cbd4c5d1.tar.gz
conky-theme-udt-0f5bd9dbded3c6b51731581cb466bad0cbd4c5d1.zip
docs: add the weather widget implementation plan
Nine tasks, TDD throughout: the domain tables and parser get tests, the card gets a screenshot. The icon codepoints in the plan were each rendered and inspected rather than derived. That caught U+E30A, which is present in the font and draws a sun-with-rain, standing in for snow; the correct glyph is U+E31A. A wrong-but-present codepoint fails this way, plausibly, which is why the plan tells the implementer to look at the rendered sheet instead of trusting a fontconfig query. Task 6 and Task 8 both state which outcomes are acceptable when the OWM key is still activating, so an inactive key blocks neither. Paths are relative to the repo root rather than absolute: the first version of this file hardcoded a home directory in eighteen places and the pre-commit hook rejected it, correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-09-17-weather-widget.md1186
1 files changed, 1186 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-17-weather-widget.md b/docs/superpowers/plans/2026-09-17-weather-widget.md
new file mode 100644
index 0000000..14c7044
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-17-weather-widget.md
@@ -0,0 +1,1186 @@
+# Weather Widget 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:** One vertical dashboard card showing current weather on top and a sunrise-to-sunset arc at the bottom, fed by a cached OpenWeatherMap response.
+
+**Architecture:** A shell script curls OWM into `~/.cache/udt/weather.json` on conky's own `${execi}` schedule. `lib/weather.lua` parses that file and owns the domain tables (condition ids, Beaufort, compass); `widgets/weather.lua` draws the card from the parsed table. The dashboard never touches the network.
+
+**Tech Stack:** Lua 5.4 + Cairo (via conky's bindings), bash + curl for the fetch, `jq` only inside the fetch script. No JSON library: Lua patterns read the handful of fields the card draws.
+
+**Spec:** `docs/superpowers/specs/2026-09-17-weather-widget-design.md`
+
+---
+
+## Critical platform facts
+
+Read these before touching anything. Each one cost time to learn on this project.
+
+- **A Lua error is a blank screen.** Conky reports a Lua fault on no stream. The `pcall` overlay in `dashboard.lua` is the only reason failures are visible. A parser that raises takes the whole dashboard down, so every parse path returns `nil` instead.
+- **`conky_parse('${color3}')` returns an empty string.** Colours are parsed out of the file at `conky_config`. Widgets never read colours themselves; they use the `colors` table they are handed.
+- **Never call `cairo_text_extents_t:create()` per call.** It leaks ~182KB per 5000 allocations and `collectgarbage()` does not reclaim it. `lib/card.lua` already owns one reused struct; use `card.measure()` and `card.advance()`.
+- **`card.measure()` is ink width, `card.advance()` is cursor movement.** Stepping a cursor by ink width collapses spaces. Use `advance()` for runs of text, `measure()` only to centre or right-align.
+- **`${execi}` fires even when it prints nothing** and even though `conky.text` is otherwise empty (verified 2026-09-17: a probe config ran the command twice over a 12s run at `execi 2`). This is what makes the fetch scheduling work.
+- **Conky never rereads its config.** Any `conky.conf.in` change needs UDT's `install.sh` to re-render and restart conky.
+- **`Inconsolata Nerd Font` has all the glyphs used here** (Weather Icons U+E3xx plus U+E34C/U+E34D). Verified by rendering. A missing glyph would be an invisible blank, not an error.
+- **The API key may still be inactive.** A new OWM key returns `401 Invalid API key` for minutes to hours after creation. As of this plan's writing the key in `~/.config/udt/weather.env` still returns 401, which is expected and not a bug in this code. Every task below is testable without a working key; Task 8 is where a live key finally matters.
+
+---
+
+## File structure
+
+| Path | Responsibility |
+|---|---|
+| `bin/weather-fetch.sh` | Fetch and cache. Knows the API and the env file; knows nothing about drawing. |
+| `lib/weather.lua` | Parse the cache; condition/Beaufort/compass tables; sun position maths. Pure functions over strings and numbers. |
+| `widgets/weather.lua` | Draw the card. Knows the layout bands; knows nothing about the API. |
+| `test/test_weather.lua` | Checks for everything in `lib/weather.lua`. |
+| `test/fixtures/weather.json` | A real-shaped OWM response with placeholder key and city. |
+| `test/fixtures/weather_truncated.json` | A half-written response, for the atomicity failure mode. |
+| `weather.env.example` | Placeholder config, committed. The real file is gitignored. |
+
+Modified: `conky.conf.in` (the `execi` line), `dashboard.lua` (one layout row), `README.md`, and UDT's `install.sh` (symlink `bin/`, silence its conky restart).
+
+---
+
+## Task 1: Condition id to icon
+
+**Files:**
+- Create: `lib/weather.lua`
+- Create: `test/test_weather.lua`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/test_weather.lua`:
+
+```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 card itself is verified by
+-- screenshot.
+
+package.path = './?.lua;' .. package.path
+local weather = require 'lib.weather'
+
+-- === Condition id to icon =================================================
+-- OWM ids group by hundreds, but the boundaries are not round numbers: the
+-- ranges come from the polybar script that ran against this API for years.
+-- Each assertion pairs the last id in a range with the first id of the next,
+-- which is where an off-by-one would hide.
+local DAY, NIGHT = 1000, 2000 -- timestamps either side of the sun, set below
+
+local function icon(id, now)
+ return weather.icon(id, now, 1500, 1900) -- sunrise 1500, sunset 1900
+end
+
+-- Thunderstorm: everything up to 232.
+assert(icon(200, DAY) == weather.ICON.thunder_day, 'id 200 day')
+assert(icon(232, DAY) == weather.ICON.thunder_day, 'id 232 is still thunder')
+assert(icon(232, NIGHT) == weather.ICON.thunder_night, 'id 232 night')
+
+-- Light drizzle: 233..311.
+assert(icon(233, DAY) == weather.ICON.drizzle_day, 'id 233 leaves thunder')
+assert(icon(311, DAY) == weather.ICON.drizzle_day, 'id 311 is still light drizzle')
+
+-- Heavy drizzle: 312..321.
+assert(icon(312, DAY) == weather.ICON.drizzle_heavy_day, 'id 312 is heavy drizzle')
+assert(icon(321, DAY) == weather.ICON.drizzle_heavy_day, 'id 321 is still heavy drizzle')
+
+-- Rain: 322..531.
+assert(icon(322, DAY) == weather.ICON.rain_day, 'id 322 is rain')
+assert(icon(531, DAY) == weather.ICON.rain_day, 'id 531 is still rain')
+
+-- Snow: 532..622. One icon, no day/night variant.
+assert(icon(600, DAY) == weather.ICON.snow, 'id 600 is snow')
+assert(icon(622, NIGHT) == weather.ICON.snow, 'snow is the same at night')
+
+-- Fog: 623..771.
+assert(icon(741, DAY) == weather.ICON.fog, 'id 741 is fog')
+assert(icon(771, DAY) == weather.ICON.fog, 'id 771 is still fog')
+
+-- Tornado is a single id, not a range.
+assert(icon(781, DAY) == weather.ICON.tornado, 'id 781 is tornado')
+
+-- Clear and few clouds each have a day and a night face.
+assert(icon(800, DAY) == weather.ICON.clear_day, 'id 800 day is the sun')
+assert(icon(800, NIGHT) == weather.ICON.clear_night, 'id 800 night is the moon')
+assert(icon(801, DAY) == weather.ICON.few_day, 'id 801 day')
+assert(icon(801, NIGHT) == weather.ICON.few_night, 'id 801 night')
+
+-- Overcast: 802..804, no night variant.
+assert(icon(804, DAY) == weather.ICON.overcast, 'id 804 is overcast')
+
+-- Anything outside the known ids must be visibly wrong, not silently sunny.
+assert(icon(999, DAY) == weather.ICON.unknown, 'unknown id gets the error glyph')
+assert(weather.icon(nil, DAY, 1500, 1900) == weather.ICON.unknown, 'nil id')
+
+print('test_weather: all assertions passed')
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `lua test/test_weather.lua`
+Expected: FAIL with `module 'lib.weather' not found`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `lib/weather.lua`:
+
+```lua
+-- OpenWeatherMap domain knowledge and cache parsing.
+--
+-- Separate from lib/data.lua: that reads /proc and /sys, this reads a cached
+-- HTTP response and carries its own tables. Same testable shape though, every
+-- function takes a string or numbers and returns a value, so the tests need no
+-- filesystem and no network.
+--
+-- 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 = {}
+
+-- 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}',
+ drizzle_heavy_day = '\u{E308}',
+ drizzle_heavy_night = '\u{E325}',
+ rain_day = '\u{E308}',
+ rain_night = '\u{E325}',
+ snow = '\u{E31A}',
+ fog = '\u{E313}',
+ tornado = '\u{E351}',
+ clear_day = '\u{E30D}',
+ clear_night = '\u{E32B}',
+ few_day = '\u{E302}',
+ few_night = '\u{E379}',
+ overcast = '\u{E312}',
+ unknown = '\u{E374}',
+ sunrise = '\u{E34C}',
+ sunset = '\u{E34D}',
+}
+
+-- Condition id to icon, by upper bound. Ported from the polybar script's
+-- accumulated knowledge; the ranges are not round hundreds.
+-- `now`, `sunrise` and `sunset` are unix timestamps and pick the day or night
+-- face for the conditions that have both.
+function M.icon(id, now, sunrise, sunset)
+ if type(id) ~= 'number' then return M.ICON.unknown end
+ local day = true
+ if sunrise and sunset then day = (now >= sunrise and now <= sunset) end
+ local function pick(d, n) return day and d or n end
+
+ if id <= 232 then return pick(M.ICON.thunder_day, M.ICON.thunder_night)
+ elseif id <= 311 then return pick(M.ICON.drizzle_day, M.ICON.drizzle_night)
+ elseif id <= 321 then return pick(M.ICON.drizzle_heavy_day, M.ICON.drizzle_heavy_night)
+ elseif id <= 531 then return pick(M.ICON.rain_day, M.ICON.rain_night)
+ elseif id <= 622 then return M.ICON.snow
+ elseif id <= 771 then return M.ICON.fog
+ elseif id == 781 then return M.ICON.tornado
+ elseif id == 800 then return pick(M.ICON.clear_day, M.ICON.clear_night)
+ elseif id == 801 then return pick(M.ICON.few_day, M.ICON.few_night)
+ elseif id <= 804 then return M.ICON.overcast
+ end
+ return M.ICON.unknown
+end
+
+return M
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `lua test/test_weather.lua`
+Expected: PASS, printing `test_weather: all assertions passed`
+
+- [ ] **Step 5: Verify the glyphs actually render**
+
+The test proves the mapping, not that the codepoints are real glyphs. Render them once and look:
+
+```bash
+f=$(fc-match -f '%{file}' 'Inconsolata Nerd Font')
+magick -background '#0d1b26' -fill '#c5d8e3' -font "$f" -pointsize 54 \
+ label:$'                ' \
+ /tmp/wicons.png
+```
+
+Open `/tmp/wicons.png`. Expected, in order: thunder day, thunder night,
+drizzle day, drizzle night, rain day, rain night, snow (a cloud with
+snowflakes, no sun), fog, tornado, clear day, clear night (a bare crescent),
+few clouds day, few clouds night, overcast, an "N/A" box, sunrise, sunset.
+
+All seventeen were rendered and inspected while this plan was written, so they
+are known good. Look anyway: a wrong-but-present codepoint draws a plausible
+neighbouring glyph rather than failing, which is how `E30A` was caught standing
+in for snow with a sun-and-rain icon. A blank means the codepoint is absent
+entirely.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/weather.lua test/test_weather.lua
+git commit -m "feat: add OWM condition id to icon mapping"
+```
+
+---
+
+## Task 2: Wind, Beaufort and compass
+
+**Files:**
+- Modify: `lib/weather.lua`
+- Modify: `test/test_weather.lua`
+
+- [ ] **Step 1: Write the failing test**
+
+Insert into `test/test_weather.lua`, immediately before the final `print` line:
+
+```lua
+-- === Wind =================================================================
+-- OWM metric gives m/s; the card shows km/h. The conversion is where a wrong
+-- factor would look plausible but read 3.6x off.
+assert(weather.kmh(10) == 36, '10 m/s is 36 km/h')
+assert(weather.kmh(0) == 0, 'calm')
+assert(weather.kmh(nil) == nil, 'missing wind speed stays missing')
+
+-- Beaufort thresholds, in km/h, from the polybar script. Each assertion sits
+-- on a boundary and just past it, which is where an inclusive/exclusive
+-- mistake hides.
+assert(weather.beaufort(0) == 0, 'calm is force 0')
+assert(weather.beaufort(1) == 0, '1 km/h is still force 0')
+assert(weather.beaufort(2) == 1, 'just over 1 is force 1')
+assert(weather.beaufort(5) == 1, '5 is still force 1')
+assert(weather.beaufort(6) == 2, 'just over 5 is force 2')
+assert(weather.beaufort(11) == 2, '11 is still force 2')
+assert(weather.beaufort(12) == 3, 'just over 11 is force 3')
+assert(weather.beaufort(19) == 3, '19 is still force 3')
+assert(weather.beaufort(28) == 4, '28 is force 4')
+assert(weather.beaufort(38) == 5, '38 is force 5')
+assert(weather.beaufort(49) == 6, '49 is force 6')
+assert(weather.beaufort(61) == 7, '61 is force 7')
+assert(weather.beaufort(74) == 8, '74 is force 8')
+assert(weather.beaufort(88) == 9, '88 is force 9')
+assert(weather.beaufort(102) == 10, '102 is force 10')
+assert(weather.beaufort(117) == 11, '117 is force 11')
+assert(weather.beaufort(118) == 12, 'over 117 is hurricane force')
+
+-- Compass: eight points, each spanning 45 degrees, offset by half a step so
+-- north straddles 0 rather than starting at it. The wraparound is the case
+-- that a naive floor(deg/45) gets wrong.
+assert(weather.compass(0) == 'N', '0 is north')
+assert(weather.compass(360) == 'N', '360 wraps to north')
+assert(weather.compass(11) == 'N', 'just under the NE boundary')
+assert(weather.compass(349) == 'N', 'just over the NW boundary wraps to north')
+assert(weather.compass(45) == 'NE', '45 is northeast')
+assert(weather.compass(90) == 'E', '90 is east')
+assert(weather.compass(135) == 'SE', '135 is southeast')
+assert(weather.compass(180) == 'S', '180 is south')
+assert(weather.compass(225) == 'SW', '225 is southwest')
+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')
+
+-- The arrow points where the wind is going, and OWM reports where it comes
+-- from, so a north wind draws a downward arrow.
+assert(weather.arrow(0) == '\u{2193}', 'a north wind blows southward')
+assert(weather.arrow(180) == '\u{2191}', 'a south wind blows northward')
+assert(weather.arrow(nil) == '', 'no direction draws nothing')
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `lua test/test_weather.lua`
+Expected: FAIL with `attempt to call a nil value (field 'kmh')`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add to `lib/weather.lua`, before the final `return M`:
+
+```lua
+-- OWM's metric units report wind in m/s. The card shows km/h.
+function M.kmh(ms)
+ if type(ms) ~= 'number' then return nil end
+ return ms * 3.6
+end
+
+-- Beaufort force from km/h. Upper bounds carried over from the polybar
+-- script; a value equal to a bound stays in the lower force.
+local BEAUFORT = { 1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117 }
+
+function M.beaufort(kmh)
+ if type(kmh) ~= 'number' then return nil end
+ for force, bound in ipairs(BEAUFORT) do
+ if kmh <= bound then return force - 1 end
+ end
+ return 12
+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, leaving north
+-- with only half its arc.
+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
+
+-- Arrow glyphs, in the same order as POINTS but rotated half a turn: OWM
+-- reports the direction the wind comes FROM, and an arrow reads as the
+-- direction it goes TO.
+local ARROWS = { '\u{2193}', '\u{2199}', '\u{2190}', '\u{2196}',
+ '\u{2191}', '\u{2197}', '\u{2192}', '\u{2198}' }
+
+function M.arrow(deg)
+ if type(deg) ~= 'number' then return '' end
+ local i = math.floor(((deg % 360) + 22.5) / 45) % 8
+ return ARROWS[i + 1]
+end
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `lua test/test_weather.lua`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add lib/weather.lua test/test_weather.lua
+git commit -m "feat: add wind conversion, Beaufort and compass tables"
+```
+
+---
+
+## Task 3: Sun position along the arc
+
+**Files:**
+- Modify: `lib/weather.lua`
+- Modify: `test/test_weather.lua`
+
+- [ ] **Step 1: Write the failing test**
+
+Insert into `test/test_weather.lua`, immediately before the final `print` line:
+
+```lua
+-- === Sun position =========================================================
+-- t is the fraction of daylight elapsed, and the widget uses it to place the
+-- dot along the arc. Sunrise 1000, sunset 2000, so midday is 1500.
+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')
+
+-- OWM reports today's sunrise and sunset, so between midnight and sunrise the
+-- numerator is negative. The clamp is the whole handling; without it the dot
+-- would be drawn off the left end of the arc.
+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')
+
+-- Degenerate input must not divide by zero.
+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.sun_t(1500, nil, 2000) == nil, 'missing sunrise gives nil')
+
+-- is_day drives both the icon face and the dot's colour: a dot parked at the
+-- end of the arc must not read as a sun that is still up.
+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(2000, 1000, 2000) == true, 'sunset counts as day')
+assert(weather.is_day(500, 1000, 2000) == false, 'before dawn is night')
+assert(weather.is_day(2500, 1000, 2000) == false, 'after dusk is night')
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `lua test/test_weather.lua`
+Expected: FAIL with `attempt to call a nil value (field 'sun_t')`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add to `lib/weather.lua`, before the final `return M`:
+
+```lua
+-- Fraction of daylight elapsed, clamped to 0..1.
+--
+-- OWM returns 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, which is the cheap correct-enough behaviour; multi-day astronomy
+-- buys nothing for a dot on a curve.
+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, matching the polybar script's
+-- comparison, so the instant of sunrise draws the day face.
+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
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `lua test/test_weather.lua`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add lib/weather.lua test/test_weather.lua
+git commit -m "feat: add sun position along the daylight arc"
+```
+
+---
+
+## Task 4: Parse the cached response
+
+**Files:**
+- Modify: `lib/weather.lua`
+- Modify: `test/test_weather.lua`
+- Create: `test/fixtures/weather.json`
+- Create: `test/fixtures/weather_truncated.json`
+
+- [ ] **Step 1: Create the fixtures**
+
+Create `test/fixtures/weather.json`. This is a real OWM response shape with the
+location replaced by a placeholder, since no real location belongs in committed
+files:
+
+```json
+{"coord":{"lon":11.0,"lat":45.0},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"base":"stations","main":{"temp":18.34,"feels_like":18.11,"temp_min":16.67,"temp_max":19.44,"pressure":1014,"humidity":72},"visibility":10000,"wind":{"speed":4.12,"deg":230},"clouds":{"all":75},"dt":1789625000,"sys":{"type":2,"id":2004688,"country":"XX","sunrise":1789600000,"sunset":1789646000},"timezone":7200,"id":1,"name":"Example City","cod":200}
+```
+
+Create `test/fixtures/weather_truncated.json`, a response cut mid-write, which
+is what an unprotected cache would hand the parser:
+
+```json
+{"coord":{"lon":11.0,"lat":45.0},"weather":[{"id":501,"main":"Rain","descrip
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Insert into `test/test_weather.lua`, immediately before the final `print` line:
+
+```lua
+-- === Parsing the cache ====================================================
+-- The parser reads scalars by key with Lua patterns rather than pulling in a
+-- JSON library: the response is flat and known, and six numbers do not justify
+-- a dependency. Caching the whole response still costs nothing, since a field
+-- added later is already on disk.
+local function read(path)
+ local f = assert(io.open(path, 'r'))
+ local s = f:read('*a')
+ f:close()
+ return s
+end
+
+local w = weather.parse(read('test/fixtures/weather.json'))
+assert(w, 'the fixture must parse')
+assert(w.id == 501, 'condition id, got ' .. tostring(w.id))
+assert(w.description == 'moderate rain', 'description, got ' .. tostring(w.description))
+assert(w.temp == 18.34, 'temp, got ' .. tostring(w.temp))
+assert(w.feels_like == 18.11, 'feels like, got ' .. tostring(w.feels_like))
+assert(w.humidity == 72, 'humidity, got ' .. tostring(w.humidity))
+assert(w.wind_speed == 4.12, 'wind m/s, got ' .. tostring(w.wind_speed))
+assert(w.wind_deg == 230, 'wind direction, got ' .. tostring(w.wind_deg))
+assert(w.sunrise == 1789600000, 'sunrise, got ' .. tostring(w.sunrise))
+assert(w.sunset == 1789646000, 'sunset, got ' .. tostring(w.sunset))
+assert(w.city == 'Example City', 'city, got ' .. tostring(w.city))
+assert(w.dt == 1789625000, 'observation time, got ' .. tostring(w.dt))
+
+-- temp comes before feels_like in the response and both live under "main",
+-- so a lazy pattern would read one for the other. They differ in the fixture
+-- precisely so this is checkable.
+assert(w.temp ~= w.feels_like, 'temp and feels_like must not collapse')
+
+-- Failure modes all return nil rather than raising: a Lua error here is a
+-- blank dashboard, not a message.
+assert(weather.parse(read('test/fixtures/weather_truncated.json')) == nil,
+ 'a truncated response must give nil')
+assert(weather.parse('') == nil, 'empty input gives nil')
+assert(weather.parse('not json at all') == nil, 'garbage gives nil')
+assert(weather.parse(nil) == nil, 'nil input gives nil')
+
+-- An error body carries cod 401 and no weather. It must not parse as data.
+assert(weather.parse('{"cod":401,"message":"Invalid API key."}') == nil,
+ 'an API error body must give nil')
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `lua test/test_weather.lua`
+Expected: FAIL with `attempt to call a nil value (field 'parse')`
+
+- [ ] **Step 4: Write minimal implementation**
+
+Add to `lib/weather.lua`, before the final `return M`:
+
+```lua
+-- Parse the cached OWM response.
+--
+-- Lua patterns, not a JSON library: the current-weather response is flat and
+-- its shape is known, so six scalars do not justify a dependency. Returns nil
+-- on anything unreadable, never an error.
+function M.parse(src)
+ if type(src) ~= 'string' or src == '' then return nil end
+
+ -- Scalars are matched inside their own object where the key would otherwise
+ -- be ambiguous. "temp" appears as a prefix of "temp_min" and "temp_max", so
+ -- it is anchored to the character that follows it.
+ local function num(pat)
+ return tonumber(src:match(pat))
+ end
+
+ local main = src:match('"main"%s*:%s*(%b{})') or ''
+ local wind = src:match('"wind"%s*:%s*(%b{})') or ''
+ local sys = src:match('"sys"%s*:%s*(%b{})') or ''
+ local cond = src:match('"weather"%s*:%s*%[%s*(%b{})') or ''
+
+ local w = {
+ id = tonumber(cond:match('"id"%s*:%s*(%-?%d+)')),
+ description = cond:match('"description"%s*:%s*"([^"]*)"'),
+ temp = tonumber(main:match('"temp"%s*:%s*(%-?[%d%.]+)')),
+ feels_like = tonumber(main:match('"feels_like"%s*:%s*(%-?[%d%.]+)')),
+ humidity = tonumber(main:match('"humidity"%s*:%s*(%d+)')),
+ wind_speed = tonumber(wind:match('"speed"%s*:%s*([%d%.]+)')),
+ wind_deg = tonumber(wind:match('"deg"%s*:%s*(%d+)')),
+ sunrise = tonumber(sys:match('"sunrise"%s*:%s*(%d+)')),
+ sunset = tonumber(sys:match('"sunset"%s*:%s*(%d+)')),
+ city = src:match('"name"%s*:%s*"([^"]*)"'),
+ dt = num('"dt"%s*:%s*(%d+)'),
+ }
+
+ -- Without these the card has nothing to draw, and a partial card is worse
+ -- than an honest "no data". An API error body fails here too: it carries a
+ -- cod and a message, no weather.
+ if not (w.id and w.temp and w.sunrise and w.sunset) then return nil end
+ return w
+end
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `lua test/test_weather.lua`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/weather.lua test/test_weather.lua test/fixtures/weather.json test/fixtures/weather_truncated.json
+git commit -m "feat: parse the cached OWM response"
+```
+
+---
+
+## Task 5: Staleness
+
+**Files:**
+- Modify: `lib/weather.lua`
+- Modify: `test/test_weather.lua`
+
+- [ ] **Step 1: Write the failing test**
+
+Insert into `test/test_weather.lua`, immediately before the final `print` line:
+
+```lua
+-- === Staleness ============================================================
+-- 45 minutes is three missed fetches at the 15-minute interval, so one
+-- transient failure does not flag the card.
+assert(weather.is_stale(1000, 1000) == false, 'a fresh reading is not stale')
+assert(weather.is_stale(1000, 1000 + 45 * 60) == false, 'exactly 45 min is the boundary')
+assert(weather.is_stale(1000, 1000 + 45 * 60 + 1) == true, 'past 45 min is stale')
+assert(weather.is_stale(nil, 1000) == true, 'no observation time counts as stale')
+
+-- The age string is what the card prints beside the city, so it must be short.
+assert(weather.age_str(1000, 1000 + 90) == '1m', 'ninety seconds reads as 1m')
+assert(weather.age_str(1000, 1000 + 3600) == '1h', 'an hour reads as 1h')
+assert(weather.age_str(1000, 1000 + 7200) == '2h', 'two hours')
+assert(weather.age_str(1000, 1000 + 86400 * 2) == '2d', 'days, once it gets that bad')
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `lua test/test_weather.lua`
+Expected: FAIL with `attempt to call a nil value (field 'is_stale')`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add to `lib/weather.lua`, before the final `return M`:
+
+```lua
+-- Three missed fetches at the 15-minute interval.
+M.STALE_AFTER = 45 * 60
+
+function M.is_stale(dt, now)
+ if type(dt) ~= 'number' then return true end
+ return (now - dt) > M.STALE_AFTER
+end
+
+-- Short age for the marker beside the city: "12m", "3h", "2d".
+function M.age_str(dt, now)
+ if type(dt) ~= 'number' then return '?' end
+ local s = now - dt
+ 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
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `lua test/test_weather.lua`
+Expected: PASS
+
+- [ ] **Step 5: Run the whole suite**
+
+Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua`
+Expected: three "all assertions passed" lines
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/weather.lua test/test_weather.lua
+git commit -m "feat: add cache staleness classification"
+```
+
+---
+
+## Task 6: The fetch script
+
+**Files:**
+- Create: `bin/weather-fetch.sh`
+- Create: `weather.env.example`
+
+- [ ] **Step 1: Write the example config**
+
+Create `weather.env.example`:
+
+```bash
+# Copy to ~/.config/udt/weather.env and fill in. That path is outside this
+# repo on purpose: it holds a key, and anything committed is potentially
+# public. chmod 600 it.
+#
+# Get a key at https://openweathermap.org/api. A new key returns
+# "401 Invalid API key" for minutes to hours after creation; that is the API
+# activating it, not a mistake in this file.
+
+KEY=your_32_character_api_key_here
+CITY=Your City
+COUNTRY=XX
+UNITS=metric
+```
+
+- [ ] **Step 2: Write the fetch script**
+
+Create `bin/weather-fetch.sh`:
+
+```bash
+#!/bin/bash
+# Fetch current weather into a cache file. Run from conky's ${execi}, so it
+# prints nothing on success and never blocks the dashboard: the widget only
+# ever reads the cache.
+#
+# 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.json"
+
+if [ ! -r "$ENV_FILE" ]; then
+ echo "weather-fetch: no $ENV_FILE (copy weather.env.example)" >&2
+ exit 1
+fi
+
+# shellcheck source=/dev/null
+set -a; . "$ENV_FILE"; set +a
+
+if [ -z "${KEY:-}" ]; then
+ echo "weather-fetch: KEY is empty in $ENV_FILE" >&2
+ exit 1
+fi
+
+mkdir -p "$CACHE_DIR"
+
+# The temp file sits in the same directory as the target, because rename is
+# only atomic within a filesystem. The widget reads this cache on its own 2s
+# cadence, so a fetch killed mid-write would otherwise hand it half a response.
+TMP="$CACHE.tmp.$$"
+trap 'rm -f "$TMP"' EXIT
+
+URL="https://api.openweathermap.org/data/2.5/weather"
+if ! curl -fsS --max-time 15 --get "$URL" \
+ --data-urlencode "appid=$KEY" \
+ --data-urlencode "units=${UNITS:-metric}" \
+ --data-urlencode "lang=${LANG_CODE:-en}" \
+ --data-urlencode "q=${CITY},${COUNTRY}" \
+ -o "$TMP" 2>/dev/null; then
+ echo "weather-fetch: request failed" >&2
+ exit 1
+fi
+
+# A bad key returns HTTP 200 with a JSON error body, so the status code alone
+# proves nothing. This check is carried over from the polybar script, which
+# learned it the same way.
+if [ "$(jq -r '.cod // empty' "$TMP" 2>/dev/null)" != "200" ]; then
+ echo "weather-fetch: API error: $(jq -r '.message // "unknown"' "$TMP" 2>/dev/null)" >&2
+ exit 1
+fi
+
+mv -f "$TMP" "$CACHE"
+```
+
+- [ ] **Step 3: Make it executable and check it with shellcheck**
+
+```bash
+# from the repo root
+chmod +x bin/weather-fetch.sh
+shellcheck bin/weather-fetch.sh
+```
+
+Expected: no output. If `shellcheck` is not installed, skip it.
+
+- [ ] **Step 4: Verify the three failure paths**
+
+Each must fail loudly and leave the cache alone.
+
+```bash
+# from the repo root
+# Missing env file
+WEATHER_ENV=/nonexistent ./bin/weather-fetch.sh; echo "exit: $?"
+```
+Expected: `weather-fetch: no /nonexistent (copy weather.env.example)` and `exit: 1`
+
+```bash
+# Empty key
+printf 'KEY=\nCITY=X\nCOUNTRY=XX\nUNITS=metric\n' > /tmp/empty.env
+WEATHER_ENV=/tmp/empty.env ./bin/weather-fetch.sh; echo "exit: $?"
+```
+Expected: `weather-fetch: KEY is empty in /tmp/empty.env` and `exit: 1`
+
+```bash
+# Bad key: HTTP 200 with an error body, the case the .cod check exists for
+printf 'KEY=%s\nCITY=London\nCOUNTRY=GB\nUNITS=metric\n' \
+ 00000000000000000000000000000000 > /tmp/bad.env
+WEATHER_ENV=/tmp/bad.env ./bin/weather-fetch.sh; echo "exit: $?"
+ls ~/.cache/udt/weather.json 2>/dev/null || echo "cache correctly not created"
+rm -f /tmp/empty.env /tmp/bad.env
+```
+Expected: an `API error` message, `exit: 1`, and no cache file written.
+
+- [ ] **Step 5: Verify the real key, which may not be active yet**
+
+```bash
+./bin/weather-fetch.sh; echo "exit: $?"
+```
+
+Two acceptable outcomes:
+- `exit: 0` and `~/.cache/udt/weather.json` exists: the key is live, continue.
+- `weather-fetch: API error: Invalid API key.` and `exit: 1`: the key is still
+ activating. **This is not a defect in this task.** Confirm the script's
+ behaviour is right (it printed a message and wrote no cache) and continue;
+ Task 8 retries with a live key.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add bin/weather-fetch.sh weather.env.example
+git commit -m "feat: add the weather fetch script"
+```
+
+---
+
+## Task 7: The card
+
+**Files:**
+- Create: `widgets/weather.lua`
+- Modify: `dashboard.lua` (the `layout` table)
+- Modify: `conky.conf.in` (the `conky.text` block)
+
+- [ ] **Step 1: Write the widget**
+
+Create `widgets/weather.lua`:
+
+```lua
+-- Weather: current conditions over a sunrise-to-sunset arc.
+--
+-- Shape follows idea1.png, which splits this across two cards; this merges
+-- them into one vertical cell. Every colour comes from the palette, every size
+-- derives from the rect, so the card composes on either monitor.
+
+local card = require 'lib.card'
+local weather = require 'lib.weather'
+
+local M = {}
+
+local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
+ .. '/udt/weather.json'
+
+-- Draw the card chrome with a message in it. Used for every no-data state, so
+-- the dashboard keeps its shape instead of showing an empty cell, which is
+-- indistinguishable from a crashed widget.
+local function draw_notice(cr, inner, colors, line1, line2)
+ card.font(cr, card.FONT_UI, 15, true)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, inner.y + 24, line1)
+ card.font(cr, card.FONT_MONO, 12, false)
+ card.text(cr, inner.x, inner.y + 48, line2)
+end
+
+-- The daylight arc: a curve, its baseline, the sun, and the two times.
+--
+-- 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, w_data, now)
+ local pad = 4
+ local x0, x1 = x + pad, x + w - pad
+ -- Room under the curve for the time labels.
+ local base = y + h - 20
+ local top = y + 6
+
+ -- Cubic Bezier control points, chosen so the curve peaks near the middle at
+ -- about two thirds of the band height.
+ local p0 = { x0, base }
+ local c1 = { x0 + (x1 - x0) * 0.22, top - 10 }
+ local c2 = { x1 - (x1 - x0) * 0.22, top - 10 }
+ local p3 = { x1, base }
+
+ -- The baseline: 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)
+
+ -- The arc itself.
+ 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, w_data.sunrise, w_data.sunset) or 0
+ local day = weather.is_day(now, w_data.sunrise, w_data.sunset)
+
+ -- Evaluate the cubic at t.
+ 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
+ local sx = bez(p0[1], c1[1], c2[1], p3[1])
+ local sy = bez(p0[2], c1[2], c2[2], p3[2])
+
+ -- At night the dot is parked at an end, so it takes the dim colour: a bright
+ -- dot sitting on the horizon would read as a sun that is still up.
+ card.rgba(cr, day and colors.body or colors.label, 1)
+ cairo_arc(cr, sx, sy, 5, 0, math.pi * 2)
+ cairo_fill(cr)
+
+ -- The times, each behind its own glyph, at the ends of the arc.
+ card.font(cr, card.FONT_MONO, 12, false)
+ card.rgba(cr, colors.label)
+ local ty = y + h - 2
+ card.text(cr, x0, ty,
+ weather.ICON.sunrise .. ' ' .. os.date('%H:%M', w_data.sunrise))
+ card.text_right(cr, x1, ty,
+ weather.ICON.sunset .. ' ' .. os.date('%H:%M', w_data.sunset))
+end
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local now = os.time()
+
+ local src = nil
+ local f = io.open(CACHE, 'r')
+ if f then src = f:read('*a'); f:close() end
+
+ local w = weather.parse(src)
+ if not w then
+ if io.open(os.getenv('HOME') .. '/.config/udt/weather.env', 'r') then
+ draw_notice(cr, inner, colors, 'no weather data', 'waiting for first fetch')
+ else
+ draw_notice(cr, inner, colors, 'no weather data', 'set ~/.config/udt/weather.env')
+ end
+ return
+ end
+
+ local y = inner.y
+
+ -- Header: condition glyph, then the temperature with the city beneath it.
+ local glyph = weather.icon(w.id, now, w.sunrise, w.sunset)
+ card.font(cr, card.FONT_MONO, 36, false)
+ card.rgba(cr, colors.highlight)
+ card.text(cr, inner.x, y + 34, glyph)
+ local gx = inner.x + card.advance(cr, glyph) + 14
+
+ card.font(cr, card.FONT_HEAVY, 44, false)
+ card.rgba(cr, colors.body)
+ card.text(cr, gx, y + 38, string.format('%d\u{00B0}', math.floor(w.temp + 0.5)))
+
+ card.font(cr, card.FONT_MONO, 11, false)
+ card.rgba(cr, colors.label)
+ local city = (w.city or ''):upper()
+ if weather.is_stale(w.dt, now) then
+ city = city .. ' stale ' .. weather.age_str(w.dt, now)
+ end
+ card.text(cr, gx, y + 56, city)
+
+ -- Condition, in OWM's own words, first letter capitalised.
+ card.font(cr, card.FONT_UI, 13, false)
+ card.rgba(cr, colors.body)
+ local desc = w.description or ''
+ desc = desc:sub(1, 1):upper() .. desc:sub(2)
+ card.text(cr, inner.x, y + 84, desc)
+
+ -- Rule.
+ local ry = y + 100
+ card.rgba(cr, colors.rule, 0.8)
+ cairo_set_line_width(cr, 1)
+ cairo_move_to(cr, inner.x, ry)
+ cairo_line_to(cr, inner.x + inner.w, ry)
+ cairo_stroke(cr)
+
+ -- Stats: label left, value right.
+ local kmh = weather.kmh(w.wind_speed)
+ local wind_txt = kmh
+ and string.format('%s %d km/h', weather.arrow(w.wind_deg), math.floor(kmh + 0.5))
+ or '--'
+ local rows = {
+ { 'FEELS LIKE', w.feels_like and string.format('%d\u{00B0}', math.floor(w.feels_like + 0.5)) or '--' },
+ { 'HUMIDITY', w.humidity and (w.humidity .. '%') or '--' },
+ { 'WIND', wind_txt },
+ }
+ local sy = ry + 22
+ for _, row in ipairs(rows) do
+ card.font(cr, card.FONT_MONO, 11, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, sy, row[1])
+ card.rgba(cr, colors.value)
+ card.text_right(cr, inner.x + inner.w, sy, row[2])
+ sy = sy + 20
+ end
+
+ -- Rule.
+ local ry2 = sy + 2
+ card.rgba(cr, colors.rule, 0.8)
+ cairo_move_to(cr, inner.x, ry2)
+ cairo_line_to(cr, inner.x + inner.w, ry2)
+ cairo_stroke(cr)
+
+ -- The arc fills whatever height is left.
+ draw_arc(cr, inner.x, ry2 + 10, inner.w, (inner.y + inner.h) - (ry2 + 10),
+ colors, w, now)
+end
+
+return M
+```
+
+- [ ] **Step 2: Add the widget to the layout**
+
+In `dashboard.lua`, replace the `layout` table:
+
+```lua
+local layout = {
+ { widget = 'clock', col = 1, row = 1, w = 1, h = 2 },
+ { widget = 'weather', col = 2, row = 1, w = 1, h = 2 },
+}
+```
+
+- [ ] **Step 3: Schedule the fetch**
+
+In `conky.conf.in`, replace the final two lines:
+
+```lua
+-- Cairo output covers conky.text, so nothing here is visible. The execi still
+-- fires on schedule: verified with a probe config whose only text was an execi
+-- producing no output. This is what refreshes the weather cache, and tying it
+-- to conky means nothing fetches while the dashboard is down.
+conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}]]
+```
+
+- [ ] **Step 4: Check the Lua parses**
+
+Run: `luac -p widgets/weather.lua dashboard.lua lib/weather.lua && echo "syntax ok"`
+Expected: `syntax ok`
+
+A syntax error here would show as a blank dashboard with no message, so this
+check is worth its two seconds.
+
+- [ ] **Step 5: Run the test 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` asserts on the
+grid, and the layout table just changed, so this is not a formality.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add widgets/weather.lua dashboard.lua conky.conf.in
+git commit -m "feat: add the weather card"
+```
+
+---
+
+## Task 8: Install, look at it, and iterate
+
+The card has never been drawn at this point. Everything below is about seeing it.
+
+- [ ] **Step 1: Link `bin/` and silence the restart in UDT's install.sh**
+
+In `../unified-desktop-theme/install.sh`, after the existing
+`widgets` symlink (around line 189):
+
+```bash
+ln -sfn "$conky_repo/bin" "$HOME/.config/conky/bin"
+```
+
+And the conky restart (around line 216) gains the workspace rule it currently
+lacks, so a reinstall does not pop the dashboard open:
+
+```bash
+ (hyprctl dispatch 'hl.dsp.exec_cmd("[workspace special:dash silent] conky -c ~/.config/conky/conky.conf -d")' >/dev/null 2>&1 &)
+```
+
+- [ ] **Step 2: Render and restart**
+
+```bash
+cd ../unified-desktop-theme && ./install.sh
+```
+
+Expected: it reports conky among the reloaded components, and
+`~/.config/conky/bin` now resolves.
+
+- [ ] **Step 3: Confirm the dashboard did not steal the workspace**
+
+```bash
+hyprctl monitors -j | python3 -c 'import json,sys;print([(m["name"],m["specialWorkspace"]["name"]) for m in json.load(sys.stdin)])'
+```
+
+Expected: empty special workspace names. If `special:dash` is showing, Step 1's
+restart change did not take.
+
+- [ ] **Step 4: Retry the API key**
+
+```bash
+./bin/weather-fetch.sh; echo "exit: $?"
+```
+
+If it still reports `Invalid API key`, the key is not active yet. Continue to
+Step 5 anyway: the no-data state is exactly what should be verified first, and
+it is what the user sees today.
+
+- [ ] **Step 5: Screenshot the dashboard**
+
+`grim` captures screen coordinates, so it cannot shoot a window on an inactive
+workspace. Switch first, and poll until the switch settles rather than sleeping
+a fixed time:
+
+```bash
+hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null
+for i in $(seq 20); do
+ s=$(hyprctl monitors -j | python3 -c 'import json,sys;print(json.load(sys.stdin)[0]["specialWorkspace"]["name"])')
+ [ "$s" = "special:dash" ] && break
+ sleep 0.2
+done
+grim -o DP-1 /tmp/dash.png
+hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null
+```
+
+- [ ] **Step 6: Look at the PNG**
+
+Open `/tmp/dash.png` and read it. Checking that `grim` exited 0 proves nothing
+about what was drawn; a blank card and a correct card both exit 0.
+
+With no live key, expect: the card chrome in place beside the clock, with
+"no weather data" and the path to set. With a live key, expect: the glyph,
+temperature and city on top, the condition line, three stat rows, and the arc
+with a dot between the two times.
+
+Compare against `idea1.png`. Likely first-pass problems, each fixed in
+`widgets/weather.lua` and re-screenshotted:
+- Text overflowing the card's right edge: reduce the font size or shorten the label.
+- The arc cramped or overlapping the stats: adjust the `ry2 + 10` offset.
+- The sun dot off the curve: the Bezier evaluation and the drawn curve have
+ diverged, which means the control points differ between them.
+
+- [ ] **Step 7: Commit any adjustments**
+
+```bash
+# from the repo root
+git add widgets/weather.lua
+git commit -m "fix: adjust the weather card against the screenshot"
+```
+
+And in the UDT repo, which is a separate checkout:
+
+```bash
+cd ../unified-desktop-theme
+git add install.sh
+git commit -m "feat: link the conky bin dir, silence the conky restart"
+```
+
+---
+
+## Task 9: Documentation
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Document the widget and its setup**
+
+In `README.md`, add to the widgets section a description of the weather card,
+and a setup step stating: copy `weather.env.example` to
+`~/.config/udt/weather.env`, add an OWM key, `chmod 600` it, and expect a new
+key to return 401 for up to a few hours while it activates.
+
+Add to the "Gotchas worth knowing" section:
+
+- `${execi}` fires even though `conky.text` renders nothing, which is what
+ schedules the weather fetch.
+- The cache is written to a temp file and renamed, because the widget reads it
+ on an unrelated cadence.
+- A bad OWM key returns HTTP 200 with an error body, so the status code alone
+ proves nothing.
+
+- [ ] **Step 2: Verify the documented commands**
+
+Run every command the README now claims works, and confirm the output matches
+what is written. A README that documents a command nobody ran is how the last
+one drifted.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: document the weather widget and its setup"
+```
+
+---
+
+## Self-review notes
+
+Checked against the spec: every section has a task. The spec's "out of scope"
+(forecast) stays out. The `pressure` and `temp_min`/`temp_max` fields the user
+declined are not parsed, though they remain in the cached response, which is
+the point of caching it whole.
+
+Two things this plan cannot settle, both flagged in place:
+- The API key may still be inactive. Tasks 6 and 8 both state which outcomes
+ are acceptable, so neither blocks.
+- The exact band offsets in `widgets/weather.lua` are a first pass. Task 8
+ Step 6 is where they get corrected against a screenshot, which is the only
+ way to judge them.