▍ humdrum codex / soft

Repo move fixes

fb1e47ac314d89d56655bcc9f61ea807e03346b4
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-16 08:09

parent 3596ea42

55 files changed

.claude/settings.local.json +32 −1
@@ -85,7 +85,38 @@       "Bash(sed -n '1,16p' lib/schemas/sources/weather.ts)",
       "Bash(sed -n '75,100p' scripts/seed.ts)",
       "Bash(sed -n '330,360p' components/cards/CardBodies.tsx)",
       "Bash(sed -n '1,20p' components/cards/CardBodies.tsx)",
-      "Bash(awk '/^else$/,/^fi$/' ~/bin/morning-briefing)"
+      "Bash(awk '/^else$/,/^fi$/' ~/bin/morning-briefing)",
+      "Bash(uvx --from fonttools python -c ' *)",
+      "Bash(doing --version)",
+      "Bash(tock --version)",
+      "Bash(tock --help)",
+      "Bash(doing \"testing bare entry syntax\")",
+      "Bash(echo \"exit: $?\")",
+      "Bash(tock -f /tmp/tock-test.md start A \"task one\")",
+      "Read(//tmp/**)",
+      "Bash(tock -f /tmp/tock-test.md stop)",
+      "Bash(tock -f /tmp/tock-test.md start B \"task two\")",
+      "Bash(rm /tmp/tock-test.md)",
+      "Bash(tock -f /tmp/tock-test.md start A \"task one\" -t 15:00)",
+      "Bash(tock -f /tmp/tock-test.md start B \"task two\" -t 16:10)",
+      "Bash(tock -f /tmp/tock-test.md start C \"task three\")",
+      "WebSearch",
+      "Bash(git -C /Users/kortum/Developer/Personal-Dashboard log --format='%ae %an' -5)",
+      "Read(//Users/kortum/.config/gita/**)",
+      "Bash(brew install *)",
+      "Read(//Users/kortum/**)",
+      "Bash(zsh -n ~/.zshrc)",
+      "Bash(zsh -n ~/.local/bin/micro-fzf)",
+      "Bash(FZF_DEFAULT_COMMAND='/opt/homebrew/bin/fd --color=always --type f --hidden --exclude .git --exclude .obsidian' /opt/homebrew/bin/fd --type f .)",
+      "Bash(grep -n '| `md`' \"/Users/kortum/Humdrum/Claude/Custom Tooling Index.md\")",
+      "Bash(pnpm --version)",
+      "Bash(pnpm config *)",
+      "Bash(pkill -f \"next start\")",
+      "Bash(pnpm start *)",
+      "Bash(curl -s -X POST http://localhost:4317/api/sources/155344c5-cbba-4689-b21c-4c29a1a3f72a/refresh)",
+      "Bash(curl -s -X POST http://localhost:4317/api/sources/b56e1116-3f5b-4085-b305-4532efd0b6cb/refresh)",
+      "WebFetch(domain:raw.githubusercontent.com)",
+      "mcp__backlog__task_create"
     ]
   }
 }
app/api/cider/next/route.ts +20 −0
@@ -0,0 +1,20 @@
+import { withErrors, ok } from "@/lib/api";
+import { requireUser } from "@/lib/auth";
+import { db } from "@/db";
+import { sources } from "@/db/schema";
+import { eq } from "drizzle-orm";
+import { CiderConfig } from "@/lib/schemas/sources/cider";
+
+export const POST = withErrors(async (req) => {
+  await requireUser(req);
+  const row = db.select().from(sources).where(eq(sources.kind, "cider")).get();
+  if (!row) return ok({ error: "no cider source" });
+
+  const config = CiderConfig.parse(row.config);
+  const res = await fetch(`${config.host}/api/v1/playback/next`, {
+    method: "POST",
+    headers: { apptoken: config.appToken },
+  });
+  if (!res.ok) throw new Error(`Cider ${res.status}`);
+  return ok({ ok: true });
+});
app/globals.css +2 −0
@@ -205,7 +205,9 @@   font-family: var(--font-mono); font-weight: 700; font-size: 11px;
   letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 2px;
 }
 .paper-wx-cond { font-family: var(--font-display); font-size: 19px; }
+.paper-wx-alert { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; border: 1.5px solid #111; border-bottom: none; padding: 4px 8px; background: #eee; }
 .paper-wx-hl { font-family: var(--font-mono); font-size: 10px; color: #555; margin-top: 4px; }
+.paper-wx-astro { font-family: var(--font-mono); font-size: 9px; color: #777; margin-top: 2px; }
 .paper-wx-strip { flex: 1; display: flex; }
 .paper-wx-seg { flex: 1; text-align: center; padding: 10px 4px; border-left: 1px solid #ddd; }
 .paper-wx-seg:first-child { border-left: none; }
app/print/page.tsx +65 −35
@@ -87,45 +87,74 @@   if (code <= 86) return "🌨";
   return "⛈";
 }
 
+function fmtTime(iso: string) {
+  return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
+}
+
 function WeatherHero({ w }: { w: WeatherPayload }) {
   const deg = w.units === "metric" ? "°C" : "°F";
   const wind = w.units === "metric" ? "km/h" : "mph";
   const today = w.daily[0];
+  const showFeels =
+    w.current.feelsLike != null && Math.abs(w.current.feelsLike - w.current.temp) >= 2;
+  const highPollen = w.pollen.filter((p) => p.level !== "Low");
+
   return (
-    <div className="paper-wx">
-      <div className="paper-wx-now">
-        <div className="paper-wx-temp">
-          {w.current.temp}
-          {deg}
+    <>
+      {w.alerts.map((a, i) => (
+        <div className="paper-wx-alert" key={i}>
+          ⚠ {a.event}{a.headline ? ` — ${a.headline}` : ""}
         </div>
-        <div>
-          <div className="paper-wx-loc">{w.location}</div>
-          <div className="paper-wx-cond">{w.current.text}</div>
-          {today && (
-            <div className="paper-wx-hl">
-              H {today.max}
-              {deg} · L {today.min}
-              {deg} · Wind {w.current.wind} {wind}
-            </div>
-          )}
+      ))}
+      <div className="paper-wx">
+        <div className="paper-wx-now">
+          <div className="paper-wx-temp">
+            {w.current.temp}
+            {deg}
+          </div>
+          <div>
+            <div className="paper-wx-loc">{w.location}</div>
+            <div className="paper-wx-cond">{w.current.text}</div>
+            {today && (
+              <div className="paper-wx-hl">
+                H {today.max}{deg} · L {today.min}{deg} · Wind {w.current.wind} {wind}
+                {showFeels ? ` · Feels ${w.current.feelsLike}${deg}` : ""}
+                {w.current.humidity != null ? ` · ${w.current.humidity}% RH` : ""}
+                {w.current.uvIndex != null ? ` · UV ${w.current.uvIndex}` : ""}
+                {w.aqi != null ? ` · AQI ${w.aqi} ${w.aqiCategory}` : ""}
+              </div>
+            )}
+            {(w.sunrise || w.sunset || w.moonPhase) && (
+              <div className="paper-wx-astro">
+                {w.sunrise ? `↑ ${fmtTime(w.sunrise)}` : ""}
+                {w.sunset ? ` · ↓ ${fmtTime(w.sunset)}` : ""}
+                {w.moonPhase ? ` · ${w.moonEmoji} ${w.moonPhase}` : ""}
+              </div>
+            )}
+            {highPollen.length > 0 && (
+              <div className="paper-wx-astro">
+                Pollen: {highPollen.map((p) => `${p.label} ${p.level}`).join(" · ")}
+              </div>
+            )}
+          </div>
         </div>
-      </div>
-      {w.parts.length > 0 && (
-        <div className="paper-wx-strip">
-          {w.parts.map((p) => (
-            <div className="paper-wx-seg" key={p.label}>
-              <div className="paper-wx-seg-t">{p.label}</div>
-              <div className="paper-wx-seg-g">{wmoGlyph(p.code)}</div>
-              <div className="paper-wx-seg-d">
-                {p.temp}
-                {deg}
+        {w.parts.length > 0 && (
+          <div className="paper-wx-strip">
+            {w.parts.map((p) => (
+              <div className="paper-wx-seg" key={p.label}>
+                <div className="paper-wx-seg-t">{p.label}</div>
+                <div className="paper-wx-seg-g">{wmoGlyph(p.code)}</div>
+                <div className="paper-wx-seg-d">
+                  {p.temp}
+                  {deg}
+                </div>
+                <div className="paper-wx-seg-p">{p.precipProb != null ? `${p.precipProb}%` : "—"}</div>
               </div>
-              <div className="paper-wx-seg-p">{p.precipProb != null ? `${p.precipProb}%` : "—"}</div>
-            </div>
-          ))}
-        </div>
-      )}
-    </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </>
   );
 }
 
@@ -156,7 +185,7 @@           <p className="paper-item">
             <span className="yr">AQI {p.aqi ?? "—"}</span> {p.category}
             {p.pm25 != null && <span className="src"> — PM2.5 {p.pm25}</span>}
           </p>
-          {p.pollen.map((pl) => (
+          {p.pollen.slice(0, 3).map((pl) => (
             <p className="paper-item" key={pl.label}>
               {pl.label} <span className="src">— {pl.level}</span>
             </p>
@@ -169,7 +198,7 @@       const p = payload as MarketsPayload;
       if (p.quotes.length === 0) return null;
       return (
         <>
-          {p.quotes.map((q) => (
+          {p.quotes.slice(0, 6).map((q) => (
             <p className="paper-item" key={q.symbol}>
               <span className="yr">{q.symbol}</span>{" "}
               {q.price.toLocaleString(undefined, { maximumFractionDigits: 2 })}
@@ -190,7 +219,7 @@       const p = payload as HackerNewsPayload;
       if (p.stories.length === 0) return null;
       return (
         <>
-          {p.stories.map((s, i) => (
+          {p.stories.slice(0, 5).map((s, i) => (
             <p className="paper-item" key={i}>
               {s.title} <span className="src">— {s.source}</span>
             </p>
@@ -334,7 +363,8 @@       );
     }
     case "todos": {
       const p = payload as ThingsPayload;
-      if (p.tasks.length === 0) return null;
+      if (p.tasks.length === 0)
+        return <p className="paper-item paper-empty">No tasks today.</p>;
       return (
         <>
           {p.tasks.map((t) => (
- → Scoreboard-page-Golazo-plain-text-Bloomberg-terminal-style.md +31 −0
@@ -0,0 +1,31 @@
+---
+id: TASK-001
+title: 'Scoreboard page: Golazo/plain-text/Bloomberg terminal style'
+status: To Do
+assignee: []
+created_date: '2026-06-11 01:58'
+updated_date: '2026-06-11 02:00'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 1000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+TUI scoreboard — new page (key '4') in dashboard.py showing all live/today/upcoming games in a dense, Bloomberg-terminal-style monospace grid. Rows sorted live→today→upcoming, color-coded by state (live=yellow/bold, final=dim, upcoming=default). Polls /api/sources for sports payload on the same 30s interval as other pages.
+
+Clicking (or pressing Enter on) a game opens a full-screen GameDetailScreen (Textual ModalScreen or push_screen). Detail view: box score, current period/inning/quarter, scoring plays/timeline, player stats table (passing/rushing/shooting/batting etc depending on sport), updates on a shorter interval (15s or configurable). ESC or Q to exit detail and return to scoreboard.
+
+Navigation: up/down arrow keys or j/k to move between games, Enter to open detail, Esc to go back. The game list should be a Textual DataTable or custom OptionList so cursor/selection works naturally.
+
+Reference: existing render_sports() at line 423 in tui/dashboard.py for current game bucketing and color logic — detail view extends this.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Page renders at /scoreboard with live scores in terminal-grid layout; match states visually distinct; auto-refreshes; mobile-readable monospace font
+- [ ] #2 Page 4 shows live scoreboard; games navigable with arrow keys; Enter opens full-screen detail with box score + player stats; auto-refreshes; Esc closes detail
+<!-- AC:END -->
- → TUI-Foofaraw-page-editorial-pipeline-weekly-schedule.md +40 −0
@@ -0,0 +1,40 @@
+---
+id: TASK-002
+title: 'TUI Foofaraw page: editorial pipeline + weekly schedule'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:09'
+updated_date: '2026-06-11 02:13'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 2000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page (key '5') in tui/dashboard.py showing the full Foofaraw/QB editorial pipeline status and the week's publishing schedule.
+
+DATA SOURCE: All stats come from direct httpx REST queries to The Pile's Supabase DB (same creds as pile-admin/pile-reader).
+
+Stat panels:
+1. Open submissions — submissions WHERE submittable_status NOT IN ('accepted','completed','declined')
+2. Needs contract — acceptances WHERE status = 'Accepted'
+3. Contract sent, awaiting return — acceptances WHERE status = 'Contract Sent' AND contract_returned_at IS NULL
+4. Ready to pay — acceptances WHERE status = 'Contract Sent' AND contract_returned_at IS NOT NULL AND payout_batch_id IS NULL
+
+Note: stat 4 is only as fresh as the last pile-contracts run (which stamps contract_returned_at), but the data lives in the DB — no subprocess needed to read it.
+
+Weekly schedule: acceptances WHERE publish_date BETWEEN today AND today+7. Group by project_settings.template: 'standard' → Foofaraw, 'qb' → QB. Show date + author + title per row. Bold today/tomorrow.
+
+Navigation: j/k + Enter. Enter on stat panel shows underlying item list. Enter on schedule item shows detail (author, word_count, status, notes, story_url).
+
+Reference: ~/Developer/The-Pile/scripts/contracts-inbox.js (Supabase client + query patterns), ~/Developer/The-Pile/database/acceptances.sql, migrations/013_contracts_inbox.sql (contract_returned_at), migrations/009_payout_tracking.sql (payout_batch_id).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Page 5 shows 4 pipeline counts live from Supabase; week schedule lists Foofaraw+QB posts; auto-refreshes; Enter on stat row opens item list
+<!-- AC:END -->
- → TUI-news-page-split-Briefs-into-4-per-category-panels.md +38 −0
@@ -0,0 +1,38 @@
+---
+id: TASK-003
+title: 'TUI news page: split Briefs into 4 per-category panels'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:16'
+updated_date: '2026-06-11 02:24'
+labels:
+  - bug
+dependencies: []
+priority: medium
+ordinal: 3000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+In tui/dashboard.py, replace the entire page-news layout with exactly four equal DashPanel widgets — one per brief category (Politics, Technology, Sports, Culture). No other panels on this page.
+
+New layout (2×2 grid):
+  Row 1: np-brief-politics, np-brief-tech
+  Row 2: np-brief-sports, np-brief-culture
+
+Remove from page-news entirely: np-news (Headlines), np-feeds (Feeds), np-hackernews (HN). Those stay on page-personal only.
+
+Implementation:
+- Add render_brief_section(section: dict) -> Text for a single BriefSection.
+- In refresh_data(), look up each category by key from the briefs payload and feed its panel.
+- 'No brief.' dim fallback if category missing.
+
+Reference: render_briefs() line 513 tui/dashboard.py; BriefSection shape lib/schemas/sources/briefs.ts.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 page-news shows 4 equal brief panels (Politics/Tech/Sports/Culture); no merged briefs panel; no headlines panel on news page; each panel shows its category's stories independently
+- [ ] #2 page-news is exactly 4 panels in 2x2 grid (Politics/Tech/Sports/Culture); no other widgets on the page
+<!-- AC:END -->
- → TUI-music-page-Apple-Music-now-playing-controls.md +28 −0
@@ -0,0 +1,28 @@
+---
+id: TASK-004
+title: 'TUI music page: Apple Music now-playing + controls'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:48'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 4000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page (key '6' or next available) in tui/dashboard.py dedicated to Apple Music. Calls existing 'am' shell functions via subprocess (same pattern as kk captured commands).
+
+Panels:
+- Now playing: track, artist, album, elapsed/duration progress bar (poll 'am now' every 5s)
+- Controls row: keybindings for play/pause (space), next (n), prev (p), vol up/down (+/-)
+- Sparkline or ASCII progress bar showing position in track
+- Queue/recent: 'am queue' or similar if available; fall back to recently played
+
+Refresh: 5s interval for now-playing only (faster than the 30s global refresh). Controls dispatch immediately via subprocess without waiting for the refresh cycle.
+
+Reference: KK_CAPTURED and KK_INPUT entries for 'am play', 'am pause', 'am next', 'am prev', 'am now', 'am vol', 'am search', 'am playlist' in tui/dashboard.py — reuse those subprocess patterns.
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-day-page-today-only-mission-control.md +29 −0
@@ -0,0 +1,29 @@
+---
+id: TASK-005
+title: 'TUI day page: today-only mission control'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:48'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 5000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page in tui/dashboard.py showing a single unified view of today — Things tasks and Calendar events interleaved in time order, with a running 'hours left in day' indicator.
+
+Layout:
+- Top strip: date, day-of-week, hours remaining until midnight (or configurable end-of-day)
+- Main column: chronological list mixing calendar events (from calendar payload) and Things tasks (from todos payload). Events show start time; tasks without a time float to a 'Anytime' section at the bottom.
+- Side panel: today's weather summary (temp, conditions, precip chance) pulled from existing weather payload
+
+Data: all from existing /api/sources payload — todos (Things Today), calendar (Calendar.app), weather. No new API calls needed.
+
+Behavior: auto-refresh 60s. Completed Things tasks (if checkable) dim out. Pressing Enter on a Things task triggers the complete action (POST /api/integrations/things/complete, same as main page checkbox).
+
+Distinct from page-personal: page-personal is a multi-source overview; this page is a single-day timeline/focus view.
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-git-page-multi-repo-status-via-gita.md +29 −0
@@ -0,0 +1,29 @@
+---
+id: TASK-006
+title: 'TUI git page: multi-repo status via gita'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:48'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 6000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page in tui/dashboard.py showing live status of all repos tracked by gita.
+
+Data: run 'gita ll' via subprocess (thread=True worker, refresh on page enter + keybinding). Parse ANSI-stripped output — columns are: name, branch, status flags (*=dirty, ?=untracked, ↑=ahead, ↓=behind, ∅=no remote), last commit message.
+
+Display as DataTable (like sys-procs):
+  Columns: Name | Branch | Status | Last commit
+  Color coding: dirty/untracked rows = yellow, behind = red, ahead = blue, clean = dim
+  Rows sortable or at least grouped: dirty first, then ahead, then clean
+
+Keybindings: r = re-run gita ll, Enter on row = show full 'gita context <name>' or 'git log -5' in a modal.
+
+Note: gita ll output contains ANSI color codes — strip with re.sub or use strip_ansi before parsing. Sample output structure: 'Repo-Name  [magenta]branch  [flags]  last commit message'
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-reading-queue-Goodlinks-unread-list.md +35 −0
@@ -0,0 +1,35 @@
+---
+id: TASK-007
+title: 'TUI reading queue: Goodlinks unread list'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:50'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 7000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page in tui/dashboard.py showing unread articles from Goodlinks, read directly from its local SQLite database.
+
+DB path: ~/Library/Group Containers/group.com.ngocluu.goodlinks/Data/data.sqlite
+Table: link
+Unread query: SELECT title, url, author, summary, tags, addedAt FROM link WHERE readAt = 0 AND deletedAt = 0 ORDER BY addedAt DESC LIMIT 50
+
+Use Python's built-in sqlite3 module — no subprocess, no httpx. Query in a thread worker (DB is on local disk, fast).
+
+Display as scrollable list:
+  title (bold), author/domain (dim), tags (accent), added date
+  Summary shown as secondary line (truncated to 1 line)
+
+Keybindings:
+  Enter = open URL in browser (subprocess 'open <url>')
+  m = mark read (UPDATE link SET readAt = unixepoch() WHERE id = ?)
+  r = refresh list
+
+Caveat: Goodlinks DB may be locked briefly when the app is writing. Use sqlite3 in read-only URI mode: sqlite3.connect('file:///path?mode=ro', uri=True) to avoid contention.
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-social-page-Mastodon-Bluesky-timeline.md +28 −0
@@ -0,0 +1,28 @@
+---
+id: TASK-008
+title: 'TUI social page: Mastodon + Bluesky timeline'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:50'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 8000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page in tui/dashboard.py showing Mastodon and Bluesky home timelines side by side. Both source modules already exist (lib/sources/modules/mastodon.ts, bluesky.ts) and their payloads are available via /api/sources.
+
+Layout: two equal DashPanel columns
+  Left:  Mastodon timeline
+  Right: Bluesky timeline
+
+Each panel: scrollable post list — author (bold), timestamp (dim), post body (wrapped). Boost/repost indicated with '↻ boosted by' prefix line.
+
+Refresh: standard 30s global refresh cycle. Posts deduplicated by ID within each panel.
+
+Check payload shape from mastodon.ts and bluesky.ts schemas in lib/schemas/sources/ to confirm field names before implementing.
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-Hash-seeded-poll-jitter-on-startup.md +32 −0
@@ -0,0 +1,32 @@
+---
+id: TASK-009
+title: 'TUI: Hash-seeded poll jitter on startup'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - enhancement
+dependencies: []
+priority: medium
+ordinal: 9000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+All sources currently refresh simultaneously at t=0 on every startup and every 30s interval, causing a thundering herd against the Next.js API. Add deterministic per-source jitter so each source's first fetch is offset by `hash(source_kind) % MAX_JITTER_MS` milliseconds. The offset should be stable across restarts (same source = same offset), so phase relationships don't change between runs.
+
+Implementation sketch:
+- Write a `_jitter(kind: str, max_ms: int = 8000) -> float` function using `hashlib.md5(kind.encode()).digest()` as seed
+- In `on_mount`, instead of `self.set_interval(REFRESH_SECS, self.refresh_data)`, fire each source's first refresh after its individual jitter offset via `self.set_timer(jitter_secs, lambda: ...)` then start the repeating interval
+- Or: a single interval timer that checks per-source "next due" timestamps rather than refreshing everything at once
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Restarting the TUI does not cause all API calls to fire at the same instant
+- [ ] #2 Same source always gets the same jitter offset across restarts
+- [ ] #3 Max jitter is configurable via a constant at the top of the file
+- [ ] #4 Overall data refresh still completes within REFRESH_SECS of startup
+<!-- AC:END -->
- → TUI-Cache-age-aware-initial-refresh-timer.md +34 −0
@@ -0,0 +1,34 @@
+---
+id: TASK-010
+title: 'TUI: Cache-age-aware initial refresh timer'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - enhancement
+dependencies: []
+priority: medium
+ordinal: 10000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+On startup, the TUI always fires `refresh_data()` immediately even if the Next.js app's snapshots are only seconds old. Instead, read `refreshedAt` from each snapshot and compute how much of the TTL has already elapsed, then wait only the remaining time before the first refresh.
+
+For example: if a source has a 5-minute TTL and its snapshot is 4 minutes old, the TUI should wait ~1 minute before its first fetch, not 5 minutes (or 0).
+
+Implementation:
+- After the initial `fetch_sources()` call, extract `min(refreshedAt)` or per-source `refreshedAt` from snapshots
+- Compute `remaining = refresh_interval - (now - refreshedAt)`
+- Schedule the first repeating timer to fire after `max(0, remaining)` seconds
+- Fall back to immediate refresh if no snapshots exist
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Restarting TUI with fresh data does not immediately re-fetch all sources
+- [ ] #2 Restarting TUI with stale data fetches immediately
+- [ ] #3 Logic degrades gracefully when refreshedAt is missing from snapshot
+<!-- AC:END -->
- → TUI-Per-panel-staleness-badge-in-border-title.md +33 −0
@@ -0,0 +1,33 @@
+---
+id: TASK-011
+title: 'TUI: Per-panel staleness badge in border title'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: low
+ordinal: 11000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Users currently have no way to know how old the data in a panel is. Add a relative timestamp ("2m ago", "just now", "12m ago") to each `DashPanel`'s `border_title` showing when its source's snapshot was last refreshed.
+
+Implementation:
+- Extract `refreshedAt` from each source's snapshot during `refresh_data()`
+- Pass it to `DashPanel` alongside the content (e.g. `set_content(text, refreshed_at=dt)`)
+- `DashPanel` updates `border_title = f"{self._title}  {relative_time(refreshed_at)}"`
+- Add a lightweight 60s timer that re-renders titles in place (just the relative time strings) without re-fetching data
+- `relative_time(dt)` → "just now" (<2m), "Xm ago" (<1h), "Xh ago" (≥1h)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Each panel border shows relative age of its data
+- [ ] #2 Timestamps update every minute without re-fetching
+- [ ] #3 Panels without a snapshot (not configured) show no timestamp
+<!-- AC:END -->
- → TUI-Per-source-error-badge-in-panel-title.md +36 −0
@@ -0,0 +1,36 @@
+---
+id: TASK-012
+title: 'TUI: Per-source error badge in panel title'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - enhancement
+dependencies: []
+priority: medium
+ordinal: 12000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When a source has `snapshot.ok == false` or is missing entirely, panels currently just show "No data." with no indication of why. Distinguish three states visually:
+
+1. **Not configured** — source disabled or `configured: false` → dim "—" or italic "not configured"
+2. **Fetch error** — source enabled, snapshot exists, `ok: false` → `⚠` in border title + error message body
+3. **No snapshot yet** — source enabled but no snapshot row → "waiting for first refresh…"
+
+Implementation:
+- In `payload()`, return a richer sentinel or pass state flags through
+- Update each `DashPanel`'s border title with `⚠` prefix when `ok: false`
+- Show the `snapshot.error` field in the panel body when available
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Errored sources show ⚠ in panel border
+- [ ] #2 Error message from snapshot is displayed in panel body
+- [ ] #3 Not-configured sources are visually distinct from errored sources
+- [ ] #4 Healthy panels are unchanged
+<!-- AC:END -->
- → TUI-Tab-focus-cycling-between-panels.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-013
+title: 'TUI: Tab focus cycling between panels'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: high
+ordinal: 13000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Currently no panel is focusable — Tab does nothing and all panels render identically. Add keyboard focus cycling so users can select a panel to interact with (scroll, navigate items, zoom).
+
+Implementation:
+- `DashPanel` already has `:focus` CSS (`border: round $accent`). Need to make panels focusable: set `can_focus = True` on `DashPanel`
+- Add `Tab` / `Shift+Tab` bindings that cycle focus through panels on the active page
+- Focused panel gets the accent border; unfocused panels use the standard panel border
+- Focus state resets when switching pages (first panel on new page gets focus, or no focus)
+- Scroll within a focused panel works naturally since `DashPanel` extends `ScrollableContainer`
+
+This is a prerequisite for: keyboard-navigable items (TASK-007), in-panel search (TASK-009), panel zoom (TASK-006), quick open link (TASK-008).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Tab cycles focus forward through panels on the active page
+- [ ] #2 Shift+Tab cycles backward
+- [ ] #3 Focused panel has accent-colored border
+- [ ] #4 Switching pages resets focus
+- [ ] #5 Existing scroll behavior within panels is unaffected
+<!-- AC:END -->
- → TUI-Panel-zoom-—-expand-focused-panel-full-screen.md +38 −0
@@ -0,0 +1,38 @@
+---
+id: TASK-014
+title: 'TUI: Panel zoom — expand focused panel full-screen'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies:
+  - TASK-005
+priority: medium
+ordinal: 14000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When a panel is focused, pressing `z` should expand it to fill the full content area temporarily. This is especially useful for News, Calendar, Sports, and Feeds panels which are cramped at normal size.
+
+Implementation:
+- Bind `z` when a panel has focus (or as an app-level binding that acts on the focused panel)
+- Push a `ModalScreen` or use CSS to show only the focused panel at full size
+- Zoomed panel still polls/updates normally
+- `Esc` or `z` again collapses back to the normal layout
+- Zoomed state is displayed with a "[zoomed — Esc to close]" hint in the border title
+
+Depends on: TASK-005 (tab focus cycling)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 z on a focused panel expands it full-screen
+- [ ] #2 Esc collapses back to normal layout
+- [ ] #3 Data continues to update in zoomed state
+- [ ] #4 Zoom hint visible in border title
+- [ ] #5 Works on all 3 pages
+<!-- AC:END -->
- → TUI-Keyboard-navigable-items-within-panels.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-015
+title: 'TUI: Keyboard-navigable items within panels'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies:
+  - TASK-005
+priority: high
+ordinal: 15000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Currently panels render `Static` text — arrow keys scroll the container but don't select individual items. Upgrade content-rich panels (News, Feeds, HN, Calendar, Todos, Sports) to support item selection via up/down arrow keys when the panel is focused.
+
+Implementation options:
+- Replace `Static` in those panels with a `ListView` or `OptionList` widget where each item is selectable
+- Or keep `Static` for rendering but track a `_selected_idx` in the panel and re-render with a highlighted row on arrow key events
+
+The selected item should be visually highlighted (reverse or accent color). Selection state resets on data refresh (or preserves by title/id if feasible).
+
+Depends on: TASK-005 (tab focus)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Up/down arrow keys move selection within a focused panel
+- [ ] #2 Selected item is visually highlighted
+- [ ] #3 Applicable panels: News, Feeds, HN, Calendar, Todos, Sports
+- [ ] #4 Non-applicable panels (Weather, CPU, etc.) unaffected
+- [ ] #5 Selection resets on page change or data refresh
+<!-- AC:END -->
- → TUI-Open-selected-item-URL-in-browser.md +36 −0
@@ -0,0 +1,36 @@
+---
+id: TASK-016
+title: 'TUI: Open selected item URL in browser'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies:
+  - TASK-007
+priority: medium
+ordinal: 16000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+News, Feeds, and HN items already carry URLs that are clickable via `app.open_link`. Add a keyboard path: when an item is selected (via arrow keys), pressing `o` or `Enter` opens its URL in the default browser.
+
+Implementation:
+- In the item-navigation layer (from TASK-007), store the URL alongside each selectable item
+- Bind `o` (and `Enter` where it won't conflict) to `app.open_url(selected_item.url)`
+- Show a brief toast notification: "Opening: {title[:50]}"
+- Items without URLs (e.g. calendar events, todos) do nothing on `o` / show a "no link" toast
+
+Depends on: TASK-007 (keyboard-navigable items)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 o or Enter on a selected news/feed/HN item opens its URL in the browser
+- [ ] #2 Toast confirms the open action
+- [ ] #3 Items without URLs show a graceful no-op or message
+- [ ] #4 Existing mouse-click link behavior unchanged
+<!-- AC:END -->
- → TUI-In-panel-search-filter-with.md +41 −0
@@ -0,0 +1,41 @@
+---
+id: TASK-017
+title: 'TUI: In-panel search/filter with /'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies:
+  - TASK-005
+  - TASK-007
+priority: medium
+ordinal: 17000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When a panel is focused, pressing `/` opens an inline filter input at the bottom of the panel. Typing filters the panel's items in real time (case-insensitive substring match). `Esc` clears the filter and closes the input. `Enter` locks the filter (input hides, filtered items remain visible with a `[filter: "…"]` badge in the border title).
+
+Applicable panels: News, Feeds, HN, Sports, Calendar.
+
+Implementation:
+- Add a `filter: str = ""` attribute to `DashPanel` or the item-layer widget
+- Pressing `/` focuses an `Input` widget docked at the bottom of the panel
+- `on_input_changed` re-renders the content filtered to matching items
+- `Esc` clears `filter` and dismisses the input
+- `Enter` keeps the filter active but hides the input; border title shows `[…]`
+
+Depends on: TASK-005 (focus), TASK-007 (item navigation)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 / when panel is focused opens inline filter input
+- [ ] #2 Items filter in real time as user types
+- [ ] #3 Esc clears filter and hides input
+- [ ] #4 Active filter shown in border title
+- [ ] #5 Works in News, Feeds, HN, Sports, Calendar panels
+<!-- AC:END -->
- → TUI-Tabbed-stack-panel-for-News-Feeds-HN.md +40 −0
@@ -0,0 +1,40 @@
+---
+id: TASK-018
+title: 'TUI: Tabbed stack panel for News/Feeds/HN'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: medium
+ordinal: 18000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The News page currently shows 4 narrow columns: Headlines, Briefs, Feeds, HN. Collapse these into a single full-width tabbed panel where `,` / `.` (or number keys) cycle between tabs. This mirrors Glint's "stack" widget concept.
+
+Tabs: **Headlines** | **Briefs** | **Feeds** | **HN**
+
+Each tab renders the same content as the current panel, but with the full page width available. The active tab label is highlighted in the panel border title (e.g. `News  [ Headlines | Briefs | Feeds | HN ]`).
+
+The Personal page keeps its current layout; only `page-news` changes.
+
+Implementation:
+- Replace the 4-panel `Horizontal` in `page-news` with a single `DashPanel` wrapping a `ContentSwitcher`
+- Border title shows tab strip
+- `,` / `.` bindings cycle tabs (or `1`-`4` number keys scoped to the news page)
+- Each tab's content uses the existing render functions with higher `cap` values
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 News page shows a single tabbed panel instead of 4 columns
+- [ ] #2 Tab labels visible in panel border
+- [ ] #3 Comma/period or number keys cycle tabs
+- [ ] #4 Each tab shows correct content at full page width
+- [ ] #5 Personal page layout unchanged
+<!-- AC:END -->
- → TUI-Inline-summary-overlay-for-news-feed-items.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-019
+title: 'TUI: Inline summary overlay for news/feed items'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies:
+  - TASK-007
+priority: low
+ordinal: 19000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Briefs items have a `summary` field. News and feeds items may carry summaries too depending on source config. When a user selects an item with a summary and presses `Space`, show a popup overlay with the full summary text.
+
+Implementation:
+- Create a `SummaryScreen(ModalScreen)` that displays the item title + full summary in a scrollable box
+- Bind `Space` on a selected item to push `SummaryScreen` if `item.summary` is non-empty
+- Overlay is 80% width, auto height, dismissible with `Esc` or `Space`
+- If no summary available, show a brief "no summary" toast instead of a blank overlay
+
+Depends on: TASK-007 (keyboard-navigable items)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Space on a selected item with a summary opens the overlay
+- [ ] #2 Overlay shows full title and summary text
+- [ ] #3 Overlay is scrollable for long summaries
+- [ ] #4 Esc dismisses the overlay
+- [ ] #5 Items without summaries show a toast, not an empty overlay
+<!-- AC:END -->
- → TUI-7-day-weather-forecast-sparkline.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-020
+title: 'TUI: 7-day weather forecast sparkline'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: medium
+ordinal: 20000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The weather panel currently shows a plain text list of 3-day forecasts (`Mon  72° / 58°`). Replace or supplement this with a visual 7-day forecast bar using Textual's `Sparkline` widget or Unicode block characters — one column per day showing high/low temp range.
+
+Implementation:
+- Use the `daily[]` array from the weather payload (already fetched, has `max`, `min`, `date`, `precipProb`)
+- Render a mini bar chart: each day as a labeled column with a filled block representing the temp range, colored by high temp (cool→warm gradient)
+- Show day abbreviation + high/low below each bar
+- Precipitation probability shown as a `%` label or bar fill change when ≥ 20%
+- Textual `Sparkline` could show just highs; a custom `Static` Rich table gives more control over high/low ranges
+
+Weather payload already has up to 7 days of `daily` data.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Weather panel shows a visual 7-day forecast
+- [ ] #2 Each day shows abbreviated name, high, low
+- [ ] #3 Precipitation probability indicated when >= 20%
+- [ ] #4 Chart fits within the panel without horizontal scrolling at typical terminal widths
+- [ ] #5 Falls back gracefully if fewer than 7 days available
+<!-- AC:END -->
- → TUI-7-day-calendar-week-view.md +46 −0
@@ -0,0 +1,46 @@
+---
+id: TASK-021
+title: 'TUI: 7-day calendar week view'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: medium
+ordinal: 21000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The Calendar panel currently shows a flat list of upcoming events sorted by time. Add a 7-day grid view as an alternative display mode (toggled with `v` when the panel is focused): one column per day, events listed vertically within each day column.
+
+Layout sketch:
+```
+Mon     Tue     Wed     Thu     Fri     Sat     Sun
+Jun 9   Jun 10  Jun 11  Jun 12  Jun 13  Jun 14  Jun 15
+──────  ──────  ──────  ──────  ──────  ──────  ──────
+9:00am  All day         2:00pm
+Standup Release         Dentist
+```
+
+Implementation:
+- Group `events[]` from the calendar payload by `event.start` date into 7 buckets (today + 6 days)
+- Render as a `Horizontal` of 7 equal-width columns inside the panel
+- All-day events appear at top of their column
+- Time-specific events show time + title
+- `v` toggles between list view (current) and week grid view
+- Week view default or list view default is a constant at top of file
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 v toggles between list and week-grid view in Calendar panel
+- [ ] #2 Week grid shows 7 days starting from today
+- [ ] #3 All-day events appear at top of their column
+- [ ] #4 Timed events show time + title
+- [ ] #5 Today's column is visually highlighted
+- [ ] #6 Falls back to list view if fewer than 2 events in the window
+<!-- AC:END -->
- → TUI-Now-Playing-transport-control-keybindings.md +42 −0
@@ -0,0 +1,42 @@
+---
+id: TASK-022
+title: 'TUI: Now Playing transport control keybindings'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: medium
+ordinal: 22000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The Now Playing panel shows current track info but has no playback controls. Add keyboard shortcuts to control Apple Music without opening the kk menu.
+
+Proposed bindings (scoped so they don't conflict with global bindings):
+- `p` — play/pause (`am play` / `am pause`, or `am toggle`)
+- `Shift+N` — next track (`am next`)
+- `Shift+P` — previous track (`am prev`)
+
+These should work globally (not require panel focus) since music control is a frequent action.
+
+Implementation:
+- Add to `DashboardApp.BINDINGS`
+- Each binding calls `run_command("am play")` etc. via the existing worker — fire-and-forget, no output shown
+- On success: brief toast "⏸ Paused" / "▶ Playing" / "⏭ Next"
+- Show transport controls as static hints below the progress bar in `render_nowplaying`: `p play/pause  ⇧N next  ⇧P prev`
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 p toggles play/pause
+- [ ] #2 Shift+N skips to next track
+- [ ] #3 Shift+P goes to previous track
+- [ ] #4 Toast notification confirms each action
+- [ ] #5 Controls hint visible in Now Playing panel
+- [ ] #6 Bindings work regardless of which panel is focused
+<!-- AC:END -->
- → TUI-Countdown-to-next-calendar-event-in-subtitle.md +35 −0
@@ -0,0 +1,35 @@
+---
+id: TASK-023
+title: 'TUI: Countdown to next calendar event in subtitle'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: low
+ordinal: 23000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The subtitle bar currently shows `● 2:30 PM · Personal · flexoki`. Append the next upcoming calendar event and time until it starts: `● 2:30 PM · Standup in 42m · Personal · flexoki`.
+
+Implementation:
+- After `refresh_data()`, find the soonest upcoming (non-all-day) event from the calendar payload with `start > now`
+- Compute `delta = start - now` → format as "in Xm" (<1h), "in Xh Ym" (≥1h), "now" (<2m)
+- Update `_update_subtitle()` to include the event name (truncated to ~20 chars) and countdown
+- Refresh the countdown display every 60s via an existing or new lightweight timer (no re-fetch needed, just recompute from cached event data)
+- If no upcoming event within 24h, omit the field
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Subtitle shows next event name and time-until when one exists within 24h
+- [ ] #2 Countdown updates every minute without re-fetching
+- [ ] #3 No event within 24h: subtitle unchanged from current format
+- [ ] #4 All-day events excluded from the countdown
+- [ ] #5 Event name truncated to avoid subtitle overflow
+<!-- AC:END -->
- → TUI-Weather-alert-banner-at-top-of-Personal-page.md +42 −0
@@ -0,0 +1,42 @@
+---
+id: TASK-024
+title: 'TUI: Weather alert banner at top of Personal page'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: medium
+ordinal: 24000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When the weather payload has active `alerts[]` or AQI > 100, show a persistent single-line banner at the top of the Personal page (above the first row of panels).
+
+Banner format: `⚠  Freeze Warning · until 9:00 AM  ·  AQI 142 Unhealthy`
+
+Behavior:
+- Only shown when at least one condition is active
+- Styled bold red (alert) or dark orange (AQI only)
+- Dismissible with `x` until the next data refresh re-evaluates
+- Survives panel refreshes — only clears when the underlying alert is gone
+- Multiple alerts: show the first + "and N more"
+
+Implementation:
+- Add a `Static` widget above the `ContentSwitcher`, initially `display: none`
+- In `refresh_data()`, check weather payload for alerts and AQI; show/hide and update the widget accordingly
+- Track dismissed state in a set of alert event names; compare against current alerts on refresh
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Banner appears when alerts[] is non-empty or AQI > 100
+- [ ] #2 Banner is hidden when no alert conditions are active
+- [ ] #3 x dismisses the banner until next data refresh
+- [ ] #4 Multiple alerts show count
+- [ ] #5 Banner does not appear on News or System pages
+<!-- AC:END -->
- → TUI-Per-core-CPU-bars-on-System-page.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-025
+title: 'TUI: Per-core CPU bars on System page'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: low
+ordinal: 25000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The System page shows aggregate CPU% but no per-core breakdown. Add a per-core bar section to the CPU panel or as a new row between the sparkline and the process table.
+
+Implementation:
+- `psutil.cpu_percent(percpu=True)` returns a list of per-core percentages
+- Add this call to `SystemStats.sample()` → `"cores_pct": [...]`
+- Render as a grid of labeled bars: `C0 [████░░░░]  42%  C1 [██░░░░░░]  18%  …`
+- Use Unicode block chars (`█`, `░`) for bars, scaled to a fixed width (e.g. 8 chars)
+- Color: green < 50%, yellow 50–80%, red > 80%
+- Layout: 2 or 4 cores per row depending on terminal width / core count
+- Place in the existing `sys-cpu` panel below the aggregate line, or as a dedicated row
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Per-core CPU percentages shown as labeled bars
+- [ ] #2 Bars are color-coded by utilization level
+- [ ] #3 Layout adapts to core count (wraps to multiple rows)
+- [ ] #4 Updates on same SYS_REFRESH_SECS interval as existing system stats
+- [ ] #5 Fits within sys-cpu panel without requiring scroll on standard terminal widths
+<!-- AC:END -->
- → TUI-Network-I-O-history-sparkline-on-System-page.md +35 −0
@@ -0,0 +1,35 @@
+---
+id: TASK-026
+title: 'TUI: Network I/O history sparkline on System page'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: low
+ordinal: 26000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The CPU sparkline tracks 60 samples of CPU history. Add equivalent sparklines for network bytes-in and bytes-out on the System page.
+
+Implementation:
+- Add `net_down_history: deque[float]` and `net_up_history: deque[float]` (maxlen=60) to `SystemStats`
+- Append `down` and `up` values in `sample()` (use 0.0 when delta is unavailable)
+- Add two `Sparkline` widgets in a row below the CPU sparkline or beside it: "Net ↓" and "Net ↑"
+- Border titles show current rate: "Net ↓  1.2 MB/s"
+- Match the existing sparkline styling (`color: $accent`, round border)
+- Update via the same `_apply_system()` path
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Two sparklines (download, upload) visible on System page
+- [ ] #2 Border titles show current rate in human-readable format
+- [ ] #3 History tracks 60 samples at SYS_REFRESH_SECS interval
+- [ ] #4 Layout doesn't push process table off screen
+<!-- AC:END -->
- → TUI-Sort-process-table-by-memory-with-m-key.md +34 −0
@@ -0,0 +1,34 @@
+---
+id: TASK-027
+title: 'TUI: Sort process table by memory with m key'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - enhancement
+dependencies: []
+priority: low
+ordinal: 27000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The process table on the System page is always sorted by CPU%. Add a toggle so pressing `m` when on the System page (or when the process table is focused) switches sort between CPU% (default) and RSS memory.
+
+Implementation:
+- Add `_proc_sort: str = "cpu"` to `DashboardApp` (or a `SystemPage` widget if pages are refactored)
+- Bind `m` scoped to the system page: `action_toggle_proc_sort()`
+- `_apply_system()` sorts `procs` by the current sort field before adding rows
+- Process table border title reflects active sort: "Processes  [CPU]" or "Processes  [Mem]"
+- `m` key hint visible in the table border or `Footer`
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 m toggles process table sort between CPU% and RSS memory
+- [ ] #2 Current sort mode visible in table border title
+- [ ] #3 Sort persists until toggled again or page changes
+- [ ] #4 Table re-sorts immediately on keypress without waiting for next sample
+<!-- AC:END -->
- → TUI-Persist-theme-selection-across-restarts.md +34 −0
@@ -0,0 +1,34 @@
+---
+id: TASK-028
+title: 'TUI: Persist theme selection across restarts'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - enhancement
+dependencies: []
+priority: low
+ordinal: 28000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Theme resets to `flexoki` every time the TUI starts. Save the selected theme index to a local state file so the last-used theme is restored on next launch.
+
+State file location: `~/.local/share/dashboard/tui_state.json` (create dir if needed).
+
+Implementation:
+- On `action_next_theme()`, write `{"theme_idx": N, "page_id": "..."}` to the state file
+- On `on_mount()`, read the file if it exists and restore `_theme_idx` before applying the theme
+- Use `json` + `pathlib.Path`; silently ignore missing or malformed state file
+- A single state file shared with TASK-022 (page persistence)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Selected theme persists after quitting and relaunching the TUI
+- [ ] #2 Missing or corrupt state file falls back to flexoki without error
+- [ ] #3 State file is human-readable JSON
+<!-- AC:END -->
- → TUI-Persist-last-active-page-across-restarts.md +34 −0
@@ -0,0 +1,34 @@
+---
+id: TASK-029
+title: 'TUI: Persist last active page across restarts'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - enhancement
+dependencies:
+  - TASK-021
+priority: low
+ordinal: 29000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The TUI always opens on the Personal page. Save and restore the last active page alongside theme state.
+
+State file location: `~/.local/share/dashboard/tui_state.json` (shared with TASK-021).
+
+Implementation:
+- On `action_page()`, update the state file with `"page_id": page_id`
+- On `on_mount()`, read the file and call `action_page(saved_page_id)` after layout is ready
+- Validate that `saved_page_id` is in `PAGES` before applying; fall back to `page-personal`
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 TUI reopens on the page that was active when it was last closed
+- [ ] #2 Invalid saved page falls back to Personal without error
+- [ ] #3 Shares state file with theme persistence (TASK-021)
+<!-- AC:END -->
- → TUI-Preserve-panel-scroll-position-across-data-refreshes.md +33 −0
@@ -0,0 +1,33 @@
+---
+id: TASK-030
+title: 'TUI: Preserve panel scroll position across data refreshes'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - enhancement
+dependencies: []
+priority: medium
+ordinal: 30000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Every time `refresh_data()` runs, all `DashPanel` content is replaced via `set_content()`, which resets the scroll position to the top. If a user has scrolled halfway through a news feed, a background refresh jumps them back to the top.
+
+Implementation:
+- Before `set_content()`, save `panel.scroll_y` (or `panel.scroll_offset`)
+- After `set_content()` (which replaces the `Static`), restore the saved scroll position via `panel.scroll_to(y=saved_y, animate=False)`
+- Only restore if the new content height ≥ saved scroll position (avoid restoring to a position that no longer exists after content shrinks)
+- Apply to all `DashPanel` instances
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Scroll position is maintained across automatic background refreshes
+- [ ] #2 Manual r refresh also preserves scroll
+- [ ] #3 Scroll resets to top when the panel's source kind changes or content is significantly shorter
+- [ ] #4 No visible scroll jump during updates
+<!-- AC:END -->
- → TUI-Source-status-page-page-4.md +42 −0
@@ -0,0 +1,42 @@
+---
+id: TASK-031
+title: 'TUI: Source status page (page 4)'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: medium
+ordinal: 31000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Add a 4th page accessible via `4` key showing a status table of all sources — useful for debugging stale data, misconfigured sources, and forced refreshes.
+
+Columns: Kind | Enabled | Last Refreshed | Status (ok/error/unconfigured) | Error message (truncated)
+
+Features:
+- `r` on a selected row calls `POST /api/sources/{id}/refresh` to force-refresh that source
+- Rows color-coded: green = ok, red = error, dim = disabled
+- Auto-refreshes the table every 30s alongside the main data refresh
+- Selected row shows full error in a detail area below the table
+
+Implementation:
+- Add `("page-status", "Status")` to `PAGES`
+- Add `4` binding
+- Use `DataTable` widget for the table
+- Fetch from existing `/api/sources` endpoint; all data already available
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 4 key switches to the Status page
+- [ ] #2 Table shows all sources with kind, enabled state, last refresh time, and ok/error status
+- [ ] #3 r on selected row triggers a force-refresh for that source
+- [ ] #4 Error messages visible for failed sources
+- [ ] #5 Page updates on the standard refresh interval
+<!-- AC:END -->
- → TUI-Quick-add-shortcuts-for-task-and-scratch-note.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-032
+title: 'TUI: Quick-add shortcuts for task and scratch note'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - feature
+dependencies: []
+priority: medium
+ordinal: 32000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Creating a Things task or scratch note currently requires opening the kk overlay, filtering to the entry, then dealing with an input prompt — 3 interactions. Add direct single-key shortcuts that jump straight to the input.
+
+Proposed bindings:
+- `n` → native `KkInputScreen("new task", "task title…")` → runs `task <title>` (Things 3 task creation)
+- `S` (Shift+s) → native `KkInputScreen("scratch", "what's on your mind?")` → runs the existing `_scratch_cmd()`
+
+These bypass the kk menu entirely. They use the existing `KkInputScreen` modal and `run_command()` worker.
+
+Check that `n` and `S` don't conflict with any existing global bindings before landing.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 n key opens a native text prompt and creates a Things task on submit
+- [ ] #2 Shift+S opens a native text prompt and appends a timestamped scratch note
+- [ ] #3 Both bindings work from any page
+- [ ] #4 Empty submit is a no-op (no command run)
+- [ ] #5 Confirmation toast on success
+- [ ] #6 Bindings shown in Footer or help overlay
+<!-- AC:END -->
- → TUI-Auto-select-dark-light-theme-based-on-macOS-appearance.md +44 −0
@@ -0,0 +1,44 @@
+---
+id: TASK-033
+title: 'TUI: Auto-select dark/light theme based on macOS appearance'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+  - tui
+  - enhancement
+dependencies:
+  - TASK-021
+priority: low
+ordinal: 33000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+On startup, detect macOS Dark Mode and choose the matching theme variant automatically, rather than always defaulting to `flexoki`.
+
+Detection:
+```python
+import subprocess
+result = subprocess.run(
+    ["defaults", "read", "-g", "AppleInterfaceStyle"],
+    capture_output=True, text=True
+)
+is_dark = result.returncode == 0 and "Dark" in result.stdout
+```
+
+Startup behavior:
+- If state file (TASK-021) has a saved theme: use it as-is (user explicitly chose it)
+- If no saved theme: pick `flexoki-dark` when Dark Mode is on, `flexoki` when off
+- `t` cycling still works normally and saves to state file
+- A `--no-auto-theme` CLI flag or constant at top of file disables this behavior
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 First launch with no saved theme picks dark variant in Dark Mode, light variant otherwise
+- [ ] #2 Saved theme from state file takes priority over auto-detection
+- [ ] #3 Detection failure (non-macOS, permission error) falls back to flexoki silently
+- [ ] #4 Auto-detection runs synchronously before the app loop starts (no flash of wrong theme)
+<!-- AC:END -->
components/cards/CardBodies.tsx +206 −27
@@ -19,6 +19,7 @@ import type { UptimePayload } from "@/lib/schemas/sources/uptime";
 import type { CalendarPayload } from "@/lib/schemas/sources/calendar";
 import type { SportsPayload } from "@/lib/schemas/sources/sports";
 import type { LinksPayload } from "@/lib/schemas/sources/links";
+import type { CiderPayload } from "@/lib/schemas/sources/cider";
 import type { SourceKind } from "@/lib/schemas/source";
 
 const muted = { color: "var(--text-muted)" };
@@ -28,22 +29,80 @@ function dayName(iso: string) {
   return new Date(iso + "T00:00:00").toLocaleDateString(undefined, { weekday: "short" });
 }
 
+function fmtTime(iso: string) {
+  return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
+}
+
+function aqiColor(v: number | null): string {
+  if (v == null) return "inherit";
+  if (v <= 50) return "var(--green)";
+  if (v <= 100) return "var(--yellow)";
+  if (v <= 150) return "var(--orange)";
+  if (v <= 200) return "var(--red)";
+  return "var(--purple-400)";
+}
+
+function uvLabel(v: number): string {
+  if (v <= 2) return "Low";
+  if (v <= 5) return "Moderate";
+  if (v <= 7) return "High";
+  if (v <= 10) return "Very High";
+  return "Extreme";
+}
+
 function WeatherBody({ p, row }: { p: WeatherPayload; row: boolean }) {
   const deg = p.units === "metric" ? "°C" : "°F";
   const wind = p.units === "metric" ? "km/h" : "mph";
+  const showFeels =
+    p.current.feelsLike != null && Math.abs(p.current.feelsLike - p.current.temp) >= 2;
+  const highPollen = p.pollen.filter((pl) => pl.level !== "Low");
+
   const current = (
     <div className={row ? "shrink-0" : ""}>
+      {p.alerts.map((a, i) => (
+        <div
+          key={i}
+          className="text-xs px-2 py-0.5 mb-1.5 rounded"
+          style={{ background: "var(--red-200)", color: "var(--text)" }}
+        >
+          ⚠ {a.event}
+        </div>
+      ))}
       <div className="flex items-baseline gap-3">
         <span className="text-4xl" style={{ fontFamily: "var(--font-display)" }}>
           {p.current.temp}
           {deg}
         </span>
         <span style={muted}>{p.current.text}</span>
+        {showFeels && (
+          <span className="text-xs" style={faint}>
+            feels {p.current.feelsLike}
+            {deg}
+          </span>
+        )}
       </div>
       <p className="text-xs mt-1" style={faint}>
-        {p.location} · wind {p.current.wind} {wind}
-        {p.current.humidity != null ? ` · ${p.current.humidity}% humidity` : ""}
+        {p.location} · {p.current.wind} {wind}
+        {p.current.humidity != null ? ` · ${p.current.humidity}% RH` : ""}
+        {p.current.uvIndex != null ? ` · UV ${p.current.uvIndex} ${uvLabel(p.current.uvIndex)}` : ""}
+        {p.aqi != null && (
+          <span style={{ color: aqiColor(p.aqi) }}>
+            {" "}· AQI {p.aqi} {p.aqiCategory}
+          </span>
+        )}
       </p>
+      {(p.sunrise || p.sunset || p.moonPhase) && (
+        <p className="text-xs mt-0.5" style={faint}>
+          {p.sunrise ? `↑ ${fmtTime(p.sunrise)}` : ""}
+          {p.sunset ? ` · ↓ ${fmtTime(p.sunset)}` : ""}
+          {p.moonPhase ? ` · ${p.moonEmoji} ${p.moonPhase}` : ""}
+        </p>
+      )}
+      {highPollen.length > 0 && (
+        <p className="text-xs mt-0.5" style={faint}>
+          Pollen: {highPollen.map((pl) => `${pl.label} ${pl.level}`).join(" · ")}
+        </p>
+      )}
     </div>
   );
   const forecast = (
@@ -248,18 +307,31 @@     </ul>
   );
 }
 
+function calEventLabel(iso: string, allDay: boolean): string {
+  const d = new Date(iso);
+  const now = new Date();
+  const todayStr = now.toDateString();
+  const isToday = d.toDateString() === todayStr;
+  const datePart = isToday
+    ? "Today"
+    : d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
+  if (allDay) return datePart;
+  const timePart = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
+  return `${datePart} ${timePart}`;
+}
+
 function CalendarBody({ p }: { p: CalendarPayload }) {
-  if (p.events.length === 0) return <p className="text-sm" style={faint}>No upcoming events.</p>;
+  const cutoff = Date.now() + 7 * 24 * 60 * 60 * 1000;
+  const events = p.events.filter((e) => new Date(e.start).getTime() <= cutoff);
+  if (events.length === 0) return <p className="text-sm" style={faint}>No upcoming events.</p>;
   return (
     <ul className="space-y-1">
-      {p.events.map((e, i) => (
-        <li key={i} className="text-sm flex justify-between gap-3">
-          <span className="truncate">{e.summary}</span>
-          <span className="text-xs whitespace-nowrap" style={faint}>
-            {e.allDay
-              ? new Date(e.start).toLocaleDateString(undefined, { month: "short", day: "numeric" })
-              : shortTime(e.start)}
+      {events.map((e, i) => (
+        <li key={i} className="text-sm flex gap-2">
+          <span className="text-xs whitespace-nowrap shrink-0" style={faint}>
+            {calEventLabel(e.start, e.allDay)}
           </span>
+          <span className="truncate">{e.summary}</span>
         </li>
       ))}
     </ul>
@@ -270,27 +342,62 @@ function leagueName(path: string) {
   return (path.split("/").pop() ?? path).toUpperCase();
 }
 
-function SportsBody({ p }: { p: SportsPayload }) {
-  if (p.games.length === 0) return <p className="text-sm" style={faint}>No games.</p>;
-  const groups: Record<string, SportsPayload["games"]> = {};
-  for (const g of p.games) (groups[g.league] ||= []).push(g);
+type Game = SportsPayload["games"][number];
+
+function gameBucket(g: Game): "live" | "today" | "upcoming" {
+  if (g.state === "in" || g.state === "post") return "live";
+  if (g.startTime) {
+    const d = new Date(g.startTime);
+    return d.toDateString() === new Date().toDateString() ? "today" : "upcoming";
+  }
+  return "today";
+}
+
+function gameLabel(g: Game): { text: string; isLive: boolean; isFinal: boolean } {
+  const isLive = g.state === "in";
+  const isFinal = g.state === "post";
+  if (isLive || isFinal) {
+    const score = g.awayScore != null && g.homeScore != null ? `${g.awayScore}–${g.homeScore}` : "—";
+    const suffix = isFinal
+      ? (g.status.includes("OT") ? " F/OT" : " F")
+      : `  ${g.status}`;
+    return { text: score + suffix, isLive, isFinal };
+  }
+  if (g.startTime) {
+    const d = new Date(g.startTime);
+    const isToday = d.toDateString() === new Date().toDateString();
+    const datePart = isToday
+      ? ""
+      : d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + "  ";
+    return { text: datePart + d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), isLive, isFinal };
+  }
+  return { text: g.status, isLive, isFinal };
+}
+
+const BUCKET_LABELS = { live: "Live / Final", today: "Today", upcoming: "Upcoming" } as const;
 
+function SportsSection({ label, games }: { label: string; games: Game[] }) {
+  const groups: Record<string, Game[]> = {};
+  for (const g of games) (groups[g.league] ||= []).push(g);
   return (
-    <div className="space-y-3">
-      {Object.entries(groups).map(([league, games]) => (
+    <div className="space-y-2">
+      <h4 className="text-xs font-bold uppercase tracking-widest" style={muted}>{label}</h4>
+      {Object.entries(groups).map(([league, gs]) => (
         <div key={league}>
-          <h4 className="text-xs uppercase tracking-widest mb-1" style={muted}>
-            {leagueName(league)}
-          </h4>
+          <h5 className="text-xs uppercase tracking-widest mb-1" style={muted}>{leagueName(league)}</h5>
           <ul className="space-y-1">
-            {games.map((g, i) => (
-              <li key={i} className="text-sm flex justify-between gap-3">
-                <span className="truncate">
-                  {g.away} {g.awayScore ?? ""} @ {g.home} {g.homeScore ?? ""}
-                </span>
-                <span className="text-xs whitespace-nowrap" style={faint}>{g.status}</span>
-              </li>
-            ))}
+            {gs.map((g, i) => {
+              const { text, isLive } = gameLabel(g);
+              return (
+                <li key={i} className="text-sm flex gap-2">
+                  <span
+                    className="text-xs whitespace-nowrap shrink-0"
+                    style={isLive ? { color: "var(--text-accent)" } : faint}
+                  >{text}</span>
+                  <span className="truncate">{g.away} @ {g.home}</span>
+                </li>
+              );
+            })}
           </ul>
         </div>
       ))}
@@ -298,6 +405,24 @@     </div>
   );
 }
 
+function SportsBody({ p }: { p: SportsPayload }) {
+  const cutoff = Date.now() + 7 * 24 * 60 * 60 * 1000;
+  const games = p.games.filter((g) => !g.startTime || new Date(g.startTime).getTime() <= cutoff);
+  if (games.length === 0) return <p className="text-sm" style={faint}>No games.</p>;
+
+  const buckets: Record<"live" | "today" | "upcoming", Game[]> = { live: [], today: [], upcoming: [] };
+  for (const g of games) buckets[gameBucket(g)].push(g);
+
+  const sections = (["live", "today", "upcoming"] as const).filter((k) => buckets[k].length > 0);
+  return (
+    <div className="space-y-3">
+      {sections.map((k) => (
+        <SportsSection key={k} label={BUCKET_LABELS[k]} games={buckets[k]} />
+      ))}
+    </div>
+  );
+}
+
 function LinksBody({ p }: { p: LinksPayload }) {
   if (p.links.length === 0) return <p className="text-sm" style={faint}>No links yet.</p>;
   return (
@@ -418,6 +543,58 @@     </ul>
   );
 }
 
+function CiderBody({ p }: { p: CiderPayload }) {
+  if (!p.playing || !p.track) {
+    return <p className="text-sm" style={faint}>Nothing playing.</p>;
+  }
+  const { name, artist, album, artworkUrl, durationMs, currentMs } = p.track;
+  const pct = durationMs > 0 ? Math.min(100, (currentMs / durationMs) * 100) : 0;
+
+  function fmtDuration(ms: number) {
+    const s = Math.floor(ms / 1000);
+    return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+  }
+
+  async function skipNext() {
+    await fetch("/api/cider/next", { method: "POST" });
+  }
+
+  return (
+    <div className="flex gap-3 h-full items-start">
+      {artworkUrl && (
+        // eslint-disable-next-line @next/next/no-img-element
+        <img
+          src={artworkUrl}
+          alt={album}
+          width={72}
+          height={72}
+          className="rounded shrink-0"
+          style={{ objectFit: "cover" }}
+        />
+      )}
+      <div className="flex flex-col gap-0.5 min-w-0 flex-1">
+        <p className="font-semibold text-sm leading-snug truncate">{name}</p>
+        <p className="text-sm truncate" style={muted}>{artist}</p>
+        <p className="text-xs truncate" style={faint}>{album}</p>
+        <div className="mt-2 h-1 rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
+          <div className="h-full rounded-full" style={{ width: `${pct}%`, background: "var(--text-accent)" }} />
+        </div>
+        <div className="flex justify-between items-center mt-1">
+          <span className="text-xs tabular-nums" style={faint}>{fmtDuration(currentMs)}</span>
+          <button
+            onClick={skipNext}
+            className="text-xs px-2 py-0.5 rounded"
+            style={{ background: "var(--bg-elevated)", color: "var(--text-muted)", border: "1px solid var(--border)" }}
+          >
+            next ›
+          </button>
+          <span className="text-xs tabular-nums" style={faint}>{fmtDuration(durationMs)}</span>
+        </div>
+      </div>
+    </div>
+  );
+}
+
 export function CardBody({
   kind,
   payload,
@@ -479,6 +656,8 @@     case "sports":
       return <SportsBody p={payload as SportsPayload} />;
     case "links":
       return <LinksBody p={payload as LinksPayload} />;
+    case "cider":
+      return <CiderBody p={payload as CiderPayload} />;
     default:
       return (
         <pre className="text-xs overflow-auto" style={faint}>
lib/edition-data.ts +3 −3
@@ -105,15 +105,15 @@ // what matters first thing in the morning. Weather is the lead paragraph and is
 // not listed here. Kinds not listed fall to the end (in source order). `links`
 // is intentionally absent — it never belongs in the printed edition.
 const PRINT_ORDER: SourceKind[] = [
-  "aqi", // air quality — pairs with the weather hero
   "calendar", // today's events
   "todos", // agenda / tasks
-  "markets", // watchlist
   "sports", // scores
   "feeds", // my feeds
-  "hackernews", // tech reading
+  "news", // headlines
   "onthisday",
   "obsidian", // from the vault
+  "hackernews", // tech reading
+  "markets", // watchlist
   "briefs", // news briefs — flow last, as trailing column cards
 ];
 
lib/pdf.ts +1 −1
@@ -16,7 +16,7 @@   // Make the data current before snapshotting it into the paper.
   await Promise.all(
     listSources()
       .filter((s) => s.enabled)
-      .map((s) => ensureFresh(s)),
+      .map((s) => ensureFresh(s, { force: true })),
   );
 
   mkdirSync(EDITIONS_DIR, { recursive: true });
lib/schemas/source.ts +1 −0
@@ -21,6 +21,7 @@   "uptime",
   "obsidian",
   "todos",
   "links",
+  "cider",
 ] as const;
 
 export const SourceKind = z.enum(SOURCE_KINDS);
lib/schemas/sources/briefs.ts +1 −0
@@ -25,6 +25,7 @@ export const BriefStory = z.object({
   title: z.string(),
   summary: z.string(),
   sources: z.array(z.string()).default([]),
+  link: z.string().optional(), // first source article URL
 });
 
 export const BriefSection = z.object({
lib/schemas/sources/cider.ts +22 −0
@@ -0,0 +1,22 @@
+import { z } from "zod";
+
+export const CiderConfig = z.object({
+  host: z.string().default("http://localhost:10767"),
+  appToken: z.string().default(""),
+});
+export type CiderConfig = z.infer<typeof CiderConfig>;
+
+export const CiderTrack = z.object({
+  name: z.string(),
+  artist: z.string(),
+  album: z.string(),
+  artworkUrl: z.string().nullable(),
+  durationMs: z.number(),
+  currentMs: z.number(),
+});
+
+export const CiderPayload = z.object({
+  playing: z.boolean(),
+  track: CiderTrack.nullable(),
+});
+export type CiderPayload = z.infer<typeof CiderPayload>;
lib/schemas/sources/sports.ts +4 −0
@@ -12,6 +12,8 @@   leagues: z.array(z.string()).default(["basketball/nba"]),
   // Favorite teams — only these teams' games are added, even from leagues not
   // listed above. So you can keep all of one league + single teams from others.
   teams: z.array(SportsTeam).default([]),
+  // How many days ahead to fetch schedules (0 = today only).
+  daysAhead: z.number().int().min(0).max(14).default(7),
 });
 export type SportsConfig = z.infer<typeof SportsConfig>;
 
@@ -20,6 +22,8 @@   games: z.array(
     z.object({
       league: z.string(),
       status: z.string(),
+      // "pre" = scheduled, "in" = live, "post" = final
+      state: z.string().nullable().default(null),
       home: z.string(),
       away: z.string(),
       homeScore: z.string().nullable(),
lib/schemas/sources/weather.ts +23 −0
@@ -26,17 +26,40 @@   text: z.string(),
   precipProb: z.number().nullable(),
 });
 
+export const WeatherAlert = z.object({
+  event: z.string(),
+  severity: z.string(), // "Extreme" | "Severe" | "Moderate" | "Minor" | "Unknown"
+  headline: z.string().optional(),
+  ends: z.string().nullable().optional(),
+});
+
+export const WeatherPollen = z.object({
+  label: z.string(),
+  value: z.number(),
+  level: z.string(), // "Low" | "Moderate" | "High" | "Very High"
+});
+
 export const WeatherPayload = z.object({
   location: z.string(),
   units: z.enum(["metric", "imperial"]),
   current: z.object({
     temp: z.number(),
+    feelsLike: z.number().nullable().default(null),
     code: z.number(),
     text: z.string(),
     wind: z.number(),
     humidity: z.number().nullable(),
+    uvIndex: z.number().nullable().default(null),
   }),
   daily: z.array(WeatherDay),
   parts: z.array(WeatherPart).default([]),
+  sunrise: z.string().nullable().default(null),
+  sunset: z.string().nullable().default(null),
+  moonPhase: z.string().default(""),
+  moonEmoji: z.string().default(""),
+  aqi: z.number().nullable().default(null),
+  aqiCategory: z.string().default(""),
+  pollen: z.array(WeatherPollen).default([]),
+  alerts: z.array(WeatherAlert).default([]),
 });
 export type WeatherPayload = z.infer<typeof WeatherPayload>;
lib/sources/modules/briefs.ts +2 −1
@@ -13,7 +13,7 @@   category?: string;
   stories?: Array<{
     title?: string;
     summary?: string;
-    articles?: Array<{ publication?: string }>;
+    articles?: Array<{ publication?: string; link?: string }>;
   }>;
 }
 
@@ -76,6 +76,7 @@           summary: s.summary ?? "",
           sources: [
             ...new Set((s.articles ?? []).map((a) => a.publication).filter((p): p is string => !!p)),
           ],
+          link: (s.articles ?? []).find((a) => a.link)?.link,
         }));
         return { key, name, date: hit?.date ?? null, stories };
       }),
lib/sources/modules/cider.ts +59 −0
@@ -0,0 +1,59 @@
+import type { SourceModule } from "../registry";
+import {
+  CiderConfig,
+  CiderPayload,
+  type CiderConfig as Config,
+  type CiderPayload as Payload,
+} from "@/lib/schemas/sources/cider";
+
+export const ciderModule: SourceModule<Config, Payload> = {
+  kind: "cider",
+  label: "Now Playing",
+  keyless: false,
+  defaultRefreshSeconds: 30,
+  configSchema: CiderConfig,
+  payloadSchema: CiderPayload,
+
+  isConfigured: (config) => config.appToken.length > 0,
+
+  async fetch({ config, signal }) {
+    const res = await fetch(`${config.host}/api/v1/playback/now-playing`, {
+      headers: { apptoken: config.appToken },
+      signal,
+    });
+
+    if (res.status === 204) return { playing: false, track: null };
+    if (!res.ok) throw new Error(`Cider ${res.status}`);
+
+    const j = (await res.json()) as {
+      status?: string;
+      info?: {
+        name?: string;
+        artistName?: string;
+        albumName?: string;
+        artwork?: { url?: string };
+        durationInMillis?: number;
+        currentPlaybackTime?: number;
+      };
+    };
+
+    if (j.status !== "ok" || !j.info) return { playing: false, track: null };
+
+    const info = j.info;
+    const artRaw = info.artwork?.url ?? null;
+    // Replace {w}x{h} template Apple Music uses in artwork URLs
+    const artworkUrl = artRaw ? artRaw.replace("{w}", "300").replace("{h}", "300") : null;
+
+    return {
+      playing: true,
+      track: {
+        name: info.name ?? "Unknown",
+        artist: info.artistName ?? "Unknown",
+        album: info.albumName ?? "Unknown",
+        artworkUrl,
+        durationMs: (info.durationInMillis ?? 0),
+        currentMs: Math.round((info.currentPlaybackTime ?? 0) * 1000),
+      },
+    };
+  },
+};
lib/sources/modules/index.ts +2 −0
@@ -17,6 +17,7 @@ import { calendarModule } from "./calendar";
 import { sportsModule } from "./sports";
 import { thingsModule } from "./things";
 import { linksModule } from "./links";
+import { ciderModule } from "./cider";
 
 // Source modules register here. "todos" has no module — it's local data served
 // by /api/todos. Credentialed modules show "needs config" until their env keys
@@ -40,4 +41,5 @@   calendarModule as SourceModule,
   sportsModule as SourceModule,
   thingsModule as SourceModule,
   linksModule as SourceModule,
+  ciderModule as SourceModule,
 ];
lib/sources/modules/sports.ts +16 −7
@@ -15,7 +15,7 @@   score?: string;
 }
 interface ESPNEvent {
   date?: string;
-  status?: { type?: { shortDetail?: string; description?: string } };
+  status?: { type?: { shortDetail?: string; description?: string; state?: string } };
   competitions?: Array<{ competitors?: ESPNCompetitor[] }>;
 }
 
@@ -34,11 +34,19 @@
   async fetch({ config, signal }) {
     const collected = new Map<string, Game>();
 
-    // Fetch one league's scoreboard. If teamFilter is given, keep only games
-    // whose home/away matches one of those team substrings.
-    async function collect(league: string, teamFilter?: string[]) {
+    // Build YYYYMMDD strings for today + daysAhead days.
+    function dateStr(offsetDays: number): string {
+      const d = new Date();
+      d.setDate(d.getDate() + offsetDays);
+      return d.toISOString().slice(0, 10).replace(/-/g, "");
+    }
+    const dates = Array.from({ length: (config.daysAhead ?? 7) + 1 }, (_, i) => dateStr(i));
+
+    // Fetch one league's scoreboard for a given date. If teamFilter is given,
+    // keep only games whose home/away matches one of those team substrings.
+    async function collect(league: string, date: string, teamFilter?: string[]) {
       const res = await fetch(
-        `https://site.api.espn.com/apis/site/v2/sports/${league}/scoreboard`,
+        `https://site.api.espn.com/apis/site/v2/sports/${league}/scoreboard?dates=${date}`,
         { signal },
       );
       if (!res.ok) return;
@@ -58,6 +66,7 @@
         const game: Game = {
           league,
           status: ev.status?.type?.shortDetail ?? ev.status?.type?.description ?? "",
+          state: ev.status?.type?.state ?? null,
           home: name(home),
           away: name(away),
           homeScore: home?.score ?? null,
@@ -75,8 +84,8 @@       byLeague.set(league, [...(byLeague.get(league) ?? []), team]);
     }
 
     await Promise.all([
-      ...config.leagues.map((l) => collect(l)),
-      ...[...byLeague].map(([l, teams]) => collect(l, teams)),
+      ...config.leagues.flatMap((l) => dates.map((d) => collect(l, d))),
+      ...[...byLeague].flatMap(([l, teams]) => dates.map((d) => collect(l, d, teams))),
     ]);
 
     const games = [...collected.values()].sort(
lib/sources/modules/weather.ts +147 −19
@@ -19,7 +19,6 @@   99: "Thunderstorm + hail",
 };
 const codeText = (c: number) => WMO[c] ?? "—";
 
-// Resolve location from source config → settings → env. Returns null if none set.
 function resolveLocation(config: Config): { lat: number; lon: number; label: string } | null {
   if (config.lat != null && config.lon != null) {
     return { lat: config.lat, lon: config.lon, label: config.label ?? "Custom" };
@@ -32,6 +31,52 @@   if (envLat && envLon) return { lat: Number(envLat), lon: Number(envLon), label: "Home" };
   return null;
 }
 
+function aqiCategory(v: number | null): string {
+  if (v == null) return "";
+  if (v <= 50) return "Good";
+  if (v <= 100) return "Moderate";
+  if (v <= 150) return "Unhealthy (sensitive)";
+  if (v <= 200) return "Unhealthy";
+  if (v <= 300) return "Very Unhealthy";
+  return "Hazardous";
+}
+
+function pollenLevel(v: number): string {
+  if (v < 10) return "Low";
+  if (v < 30) return "Moderate";
+  if (v < 70) return "High";
+  return "Very High";
+}
+
+const POLLEN_KEYS: Array<[string, string]> = [
+  ["grass_pollen", "Grass"],
+  ["birch_pollen", "Birch"],
+  ["alder_pollen", "Alder"],
+  ["ragweed_pollen", "Ragweed"],
+  ["mugwort_pollen", "Mugwort"],
+  ["olive_pollen", "Olive"],
+];
+
+const SEVERITY_RANK: Record<string, number> = {
+  Extreme: 4, Severe: 3, Moderate: 2, Minor: 1, Unknown: 0,
+};
+
+function moonPhase(date: Date): { phase: string; emoji: string } {
+  // Reference new moon: Jan 6 2000 18:14 UTC
+  const refNewMoon = Date.UTC(2000, 0, 6, 18, 14, 0);
+  const synodicPeriod = 29.53058867;
+  const days = (date.getTime() - refNewMoon) / 86_400_000;
+  const pos = ((days % synodicPeriod) + synodicPeriod) % synodicPeriod;
+  if (pos < 1.85) return { phase: "New Moon", emoji: "🌑" };
+  if (pos < 7.38) return { phase: "Waxing Crescent", emoji: "🌒" };
+  if (pos < 9.22) return { phase: "First Quarter", emoji: "🌓" };
+  if (pos < 14.77) return { phase: "Waxing Gibbous", emoji: "🌔" };
+  if (pos < 16.61) return { phase: "Full Moon", emoji: "🌕" };
+  if (pos < 22.15) return { phase: "Waning Gibbous", emoji: "🌖" };
+  if (pos < 23.99) return { phase: "Last Quarter", emoji: "🌗" };
+  return { phase: "Waning Crescent", emoji: "🌘" };
+}
+
 export const weatherModule: SourceModule<Config, Payload> = {
   kind: "weather",
   label: "Weather",
@@ -50,34 +95,54 @@     const units = getSettings().units;
     const tempUnit = units === "metric" ? "celsius" : "fahrenheit";
     const windUnit = units === "metric" ? "kmh" : "mph";
 
-    const url = new URL("https://api.open-meteo.com/v1/forecast");
-    url.searchParams.set("latitude", String(place.lat));
-    url.searchParams.set("longitude", String(place.lon));
-    url.searchParams.set(
+    const weatherUrl = new URL("https://api.open-meteo.com/v1/forecast");
+    weatherUrl.searchParams.set("latitude", String(place.lat));
+    weatherUrl.searchParams.set("longitude", String(place.lon));
+    weatherUrl.searchParams.set(
       "current",
-      "temperature_2m,weather_code,wind_speed_10m,relative_humidity_2m",
+      "temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,uv_index",
     );
-    url.searchParams.set(
+    weatherUrl.searchParams.set(
       "daily",
-      "temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max",
+      "temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max,sunrise,sunset",
     );
-    url.searchParams.set(
+    weatherUrl.searchParams.set(
       "hourly",
       "temperature_2m,weather_code,precipitation_probability",
     );
-    url.searchParams.set("temperature_unit", tempUnit);
-    url.searchParams.set("wind_speed_unit", windUnit);
-    url.searchParams.set("timezone", "auto");
-    url.searchParams.set("forecast_days", "3");
+    weatherUrl.searchParams.set("temperature_unit", tempUnit);
+    weatherUrl.searchParams.set("wind_speed_unit", windUnit);
+    weatherUrl.searchParams.set("timezone", "auto");
+    weatherUrl.searchParams.set("forecast_days", "3");
 
-    const res = await fetch(url, { signal });
-    if (!res.ok) throw new Error(`Open-Meteo ${res.status}`);
-    const j = (await res.json()) as {
+    const aqiUrl = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
+    aqiUrl.searchParams.set("latitude", String(place.lat));
+    aqiUrl.searchParams.set("longitude", String(place.lon));
+    aqiUrl.searchParams.set("timezone", "auto");
+    aqiUrl.searchParams.set("current", "us_aqi,pm2_5");
+    aqiUrl.searchParams.set("hourly", POLLEN_KEYS.map(([k]) => k).join(","));
+
+    const alertsUrl =
+      `https://api.weather.gov/alerts/active?point=${place.lat},${place.lon}`;
+
+    const [weatherRes, aqiRes, alertsRes] = await Promise.all([
+      fetch(weatherUrl, { signal }),
+      fetch(aqiUrl, { signal }).catch(() => null),
+      fetch(alertsUrl, {
+        signal,
+        headers: { "User-Agent": "personal-dashboard/1.0" },
+      }).catch(() => null),
+    ]);
+
+    if (!weatherRes.ok) throw new Error(`Open-Meteo ${weatherRes.status}`);
+    const j = (await weatherRes.json()) as {
       current: {
         temperature_2m: number;
+        apparent_temperature: number;
         weather_code: number;
         wind_speed_10m: number;
         relative_humidity_2m: number;
+        uv_index: number | null;
       };
       daily: {
         time: string[];
@@ -85,6 +150,8 @@         temperature_2m_max: number[];
         temperature_2m_min: number[];
         weather_code: number[];
         precipitation_probability_max: (number | null)[];
+        sunrise: string[];
+        sunset: string[];
       };
       hourly?: {
         time: string[];
@@ -94,6 +161,56 @@         precipitation_probability: (number | null)[];
       };
     };
 
+    // --- AQI + pollen ---
+    let aqi: number | null = null;
+    let aqiCat = "";
+    const pollen: Payload["pollen"] = [];
+    if (aqiRes?.ok) {
+      const aq = (await aqiRes.json()) as {
+        current?: { time?: string; us_aqi?: number | null; pm2_5?: number | null };
+        hourly?: { time: string[] } & Record<string, (number | null)[]>;
+      };
+      aqi = aq.current?.us_aqi ?? null;
+      aqiCat = aqiCategory(aqi);
+      if (aq.hourly?.time?.length) {
+        const nowHour = (aq.current?.time ?? aq.hourly.time[0]).slice(0, 13);
+        let idx = aq.hourly.time.findIndex((t) => t.slice(0, 13) === nowHour);
+        if (idx < 0) idx = 0;
+        for (const [key, label] of POLLEN_KEYS) {
+          const v = aq.hourly[key]?.[idx];
+          if (typeof v === "number" && v > 0) {
+            pollen.push({ label, value: Math.round(v), level: pollenLevel(v) });
+          }
+        }
+      }
+    }
+
+    // --- NWS alerts (US only; non-US locations 404 or error → empty) ---
+    const alerts: Payload["alerts"] = [];
+    if (alertsRes?.ok) {
+      const al = (await alertsRes.json()) as {
+        features?: Array<{
+          properties: {
+            event?: string;
+            severity?: string;
+            headline?: string;
+            ends?: string | null;
+          };
+        }>;
+      };
+      const raw = (al.features ?? [])
+        .filter((f) => f.properties.event)
+        .map((f) => ({
+          event: f.properties.event!,
+          severity: f.properties.severity ?? "Unknown",
+          headline: f.properties.headline,
+          ends: f.properties.ends ?? null,
+        }));
+      raw.sort((a, b) => (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0));
+      alerts.push(...raw.slice(0, 3));
+    }
+
+    // --- Daily ---
     const daily = j.daily.time.map((date, i) => ({
       date,
       max: Math.round(j.daily.temperature_2m_max[i]),
@@ -103,9 +220,7 @@       text: codeText(j.daily.weather_code[i]),
       precipProb: j.daily.precipitation_probability_max[i] ?? null,
     }));
 
-    // Today split into four day-parts for the printed weather strip. Open-Meteo
-    // hourly times are local ("auto" tz), formatted "YYYY-MM-DDTHH:00". Pick the
-    // hour nearest each anchor (8am / 12pm / 6pm / 10pm) within today only.
+    // --- Day-parts strip ---
     const today = j.daily.time[0];
     const parts: Payload["parts"] = [];
     if (j.hourly && today) {
@@ -131,19 +246,32 @@           precipProb: j.hourly.precipitation_probability[best.i] ?? null,
         });
       }
     }
+
+    // --- Moon phase ---
+    const moon = moonPhase(new Date());
 
     return {
       location: place.label,
       units,
       current: {
         temp: Math.round(j.current.temperature_2m),
+        feelsLike: Math.round(j.current.apparent_temperature),
         code: j.current.weather_code,
         text: codeText(j.current.weather_code),
         wind: Math.round(j.current.wind_speed_10m),
         humidity: j.current.relative_humidity_2m ?? null,
+        uvIndex: j.current.uv_index != null ? Math.round(j.current.uv_index) : null,
       },
       daily,
       parts,
+      sunrise: j.daily.sunrise?.[0] ?? null,
+      sunset: j.daily.sunset?.[0] ?? null,
+      moonPhase: moon.phase,
+      moonEmoji: moon.emoji,
+      aqi,
+      aqiCategory: aqiCat,
+      pollen,
+      alerts,
     };
   },
 };
package.json +2 −2
@@ -5,8 +5,8 @@   "private": true,
   "type": "module",
   "scripts": {
     "dev": "SERWIST_SUPPRESS_TURBOPACK_WARNING=1 next dev --turbopack -p 4317",
-    "build": "next build --webpack",
-    "start": "next start -p 4317",
+    "build": "PATH=/opt/homebrew/opt/node@22/bin:$PATH next build --webpack",
+    "start": "PATH=/opt/homebrew/opt/node@22/bin:$PATH next start -p 4317",
     "lint": "next lint",
     "typecheck": "tsc --noEmit",
     "db:generate": "drizzle-kit generate",
pnpm-workspace.yaml +5 −0
@@ -0,0 +1,5 @@
+allowBuilds:
+  better-sqlite3: true
+  esbuild: true
+  puppeteer: true
+  sharp: true
pnpm.yaml +6 −0
@@ -0,0 +1,6 @@
+onlyBuiltDependencies:
+  - better-sqlite3
+  - puppeteer
+  - esbuild
+  - "@tailwindcss/oxide"
+  - sharp
scripts/seed.ts +1 −0
@@ -83,6 +83,7 @@   { kind: "hackernews", label: "Hacker News", enabled: true, refreshSeconds: 1800, size: "tall", config: { limit: 10, subreddits: [] } },
   { kind: "uptime", label: "Uptime", enabled: false, refreshSeconds: 600, size: "md", config: { urls: [] } },
   { kind: "obsidian", label: "Obsidian", enabled: true, refreshSeconds: 600, size: "md", config: { vault, limit: 8 } },
   { kind: "links", label: "Links", enabled: true, refreshSeconds: 86_400, size: "wide", config: { links: [] } },
+  { kind: "cider", label: "Now Playing", enabled: true, refreshSeconds: 30, size: "md", config: { host: "http://localhost:10767", appToken: "" } },
 ];
 
 function main() {
tui/dashboard.py +1422 −0
@@ -0,0 +1,1422 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.13"
+# dependencies = ["textual>=1.0,<2.0", "httpx>=0.27", "psutil>=6"]
+# ///
+"""
+Personal Dashboard TUI — polls the Next.js app (localhost:4317) and renders
+source snapshots full-screen. Auto-starts the production server if needed.
+
+Usage:  uv run tui/dashboard.py
+Keys:   1/2/3 = pages   [ ] = cycle pages   : = command bar   k = kk menu
+        r = refresh   t = cycle theme   q = quit
+
+Command bar (:): runs through `zsh -ic` so aliases/functions (tock, doing,
+note, daily-append, task…) all work. Prefix with ! to suspend the TUI and run
+interactively in the terminal — needed for gum prompts and full TUIs.
+
+kk overlay (k): native fuzzy-filter version of the dash menu, parsed live from
+the entries array in ~/.zshrc. Picks resolve in tiers, staying inside the TUI
+when possible: KK_INPUT entries (scratch, note, dict, am search/vol) get a
+native text prompt; kk_choices entries (periodic notes, dayreview, daydata,
+zine) get a native two-option pick; "am playlist" fetches the list and picks
+natively; KK_CAPTURED entries run straight through. Only real TUIs and complex
+gum flows (task, event, md, vault, newsboat, …) suspend and run `dash "<key>"`
+in the terminal (dash accepts a key argument to skip its gum menu).
+"""
+from __future__ import annotations
+
+import re
+import shlex
+import subprocess
+import time
+from collections import deque
+from datetime import date, datetime
+from pathlib import Path
+
+import httpx
+import psutil
+from textual import work
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, ScrollableContainer, Vertical
+from textual.screen import ModalScreen
+from textual.theme import Theme
+from textual.widgets import (
+    ContentSwitcher, DataTable, Footer, Header, Input, OptionList,
+    Sparkline, Static,
+)
+from textual.widgets.option_list import Option
+from rich.style import Style
+from rich.text import Text
+
+BASE_URL = "http://localhost:4317"
+REFRESH_SECS = 30
+SYS_REFRESH_SECS = 2.5
+REPO = Path(__file__).parent.parent
+
+PAGES = [
+    ("page-personal", "Personal"),
+    ("page-news", "News"),
+    ("page-system", "System"),
+]
+
+THEME_NAMES = ["flexoki", "flexoki-dark", "humdrum", "humdrum-dark"]
+
+# Owner palettes — mirrored from Donuts/themes.py + shared app kit tokens.css
+_THEMES: dict[str, Theme] = {
+    "flexoki": Theme(
+        name="flexoki",
+        primary="#205EA6",
+        secondary="#24837B",
+        warning="#AD8301",
+        error="#AF3029",
+        success="#66800B",
+        accent="#5E409D",
+        background="#FFFCF0",
+        surface="#F2F0E5",
+        panel="#CECDC3",
+        foreground="#100F0F",
+        dark=False,
+    ),
+    "flexoki-dark": Theme(
+        name="flexoki-dark",
+        primary="#4385BE",
+        secondary="#3AA99F",
+        warning="#D0A215",
+        error="#D14D41",
+        success="#879A39",
+        accent="#8B7EC8",
+        background="#100F0F",
+        surface="#1C1B1A",
+        panel="#282726",
+        foreground="#CECDC3",
+        dark=True,
+    ),
+    "humdrum": Theme(
+        name="humdrum",
+        primary="#0F80EA",
+        secondary="#0054A6",
+        warning="#985F00",
+        error="#BA333C",
+        success="#258200",
+        accent="#7550C2",
+        background="#F5F3EE",
+        surface="#FFFFFF",
+        panel="#C3BFB3",
+        foreground="#2A2825",
+        dark=False,
+    ),
+    "humdrum-dark": Theme(
+        name="humdrum-dark",
+        primary="#0F80EA",
+        secondary="#63A8F7",
+        warning="#CB9D2A",
+        error="#ED807E",
+        success="#75B966",
+        accent="#AB92F0",
+        background="#1F1D1A",
+        surface="#282622",
+        panel="#32302C",
+        foreground="#E8E5DD",
+        dark=True,
+    ),
+}
+
+
+# ── Server bootstrap ──────────────────────────────────────────────────────────
+
+def ensure_server() -> bool:
+    """Start the production Next.js server if not already responding."""
+    try:
+        httpx.get(f"{BASE_URL}/api/health", timeout=2)
+        return True
+    except Exception:
+        pass
+
+    build_id = REPO / ".next" / "BUILD_ID"
+    if not build_id.exists():
+        print(f"No production build at {REPO}. Run: pnpm build")
+        return False
+
+    print("Starting dashboard server…", flush=True)
+    subprocess.Popen(
+        ["pnpm", "start"],
+        cwd=REPO,
+        stdout=subprocess.DEVNULL,
+        stderr=subprocess.DEVNULL,
+    )
+    for _ in range(45):
+        time.sleep(1)
+        try:
+            httpx.get(f"{BASE_URL}/api/health", timeout=1)
+            return True
+        except Exception:
+            pass
+    print("Server did not start in time.")
+    return False
+
+
+# ── Fetching ──────────────────────────────────────────────────────────────────
+
+async def fetch_sources() -> list[dict]:
+    try:
+        async with httpx.AsyncClient(timeout=15) as c:
+            r = await c.get(f"{BASE_URL}/api/sources")
+            r.raise_for_status()
+            return r.json().get("sources", [])
+    except Exception:
+        return []
+
+
+def payload(sources: list[dict], kind: str) -> dict | None:
+    for s in sources:
+        src = s.get("source") or {}
+        snap = s.get("snapshot") or {}
+        if src.get("kind") == kind and src.get("enabled") and snap.get("ok"):
+            return snap.get("payload")
+    return None
+
+
+def payloads(sources: list[dict], *kinds: str) -> list[dict]:
+    out = []
+    for s in sources:
+        src = s.get("source") or {}
+        snap = s.get("snapshot") or {}
+        if src.get("kind") in kinds and src.get("enabled") and snap.get("ok"):
+            if p := snap.get("payload"):
+                out.append(p)
+    return out
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+_7DAYS_MS = 7 * 24 * 60 * 60 * 1000
+
+
+def to_local(iso: str) -> datetime:
+    """Parse ISO string (may be UTC/tz-aware) and return local naive datetime."""
+    d = datetime.fromisoformat(iso)
+    if d.tzinfo is not None:
+        d = d.astimezone().replace(tzinfo=None)
+    return d
+
+
+def within_7_days(iso: str | None) -> bool:
+    if not iso:
+        return True
+    try:
+        t = datetime.fromisoformat(iso).timestamp() * 1000
+        return t <= (datetime.now().timestamp() * 1000 + _7DAYS_MS)
+    except Exception:
+        return True
+
+
+def hm(iso: str) -> str:
+    try:
+        return to_local(iso).strftime("%-I:%M %p")
+    except Exception:
+        return iso[:5] if len(iso) >= 5 else iso
+
+
+def event_label(iso: str, all_day: bool) -> str:
+    try:
+        d = to_local(iso)
+        is_today = d.date() == date.today()
+        date_part = "Today" if is_today else d.strftime("%a %b %-d")
+        return date_part if all_day else f"{date_part}  {d.strftime('%-I:%M %p')}"
+    except Exception:
+        return iso[:16]
+
+
+def day_abbr(iso: str) -> str:
+    try:
+        return date.fromisoformat(iso).strftime("%a")
+    except Exception:
+        return iso[:3]
+
+
+def aqi_style(v: int | None) -> str:
+    if v is None:  return "dim"
+    if v <= 50:    return "green"
+    if v <= 100:   return "yellow"
+    if v <= 150:   return "dark_orange"
+    if v <= 200:   return "red"
+    return "magenta"
+
+
+def uv_label(v: int | None) -> str:
+    if v is None: return ""
+    if v <= 2:    return "Low"
+    if v <= 5:    return "Moderate"
+    if v <= 7:    return "High"
+    if v <= 10:   return "Very High"
+    return "Extreme"
+
+
+# ── Renderers ─────────────────────────────────────────────────────────────────
+
+def render_weather(p: dict) -> Text:
+    t = Text()
+    units = p.get("units", "imperial")
+    deg = "°C" if units == "metric" else "°F"
+    wu = "km/h" if units == "metric" else "mph"
+    cur = p.get("current", {})
+
+    for a in p.get("alerts", []):
+        t.append(f"⚠  {a['event']}\n", style="bold red")
+
+    t.append(f"{cur.get('temp', '—')}{deg}", style="bold yellow")
+    t.append(f"  {cur.get('text', '')}\n")
+
+    details: list[str] = []
+    feels = cur.get("feelsLike")
+    if feels is not None and abs(feels - (cur.get("temp") or feels)) >= 2:
+        details.append(f"Feels {feels}{deg}")
+    if cur.get("wind"):
+        details.append(f"Wind {cur['wind']} {wu}")
+    if cur.get("humidity") is not None:
+        details.append(f"{cur['humidity']}% RH")
+    if cur.get("uvIndex") is not None:
+        details.append(f"UV {cur['uvIndex']} {uv_label(cur['uvIndex'])}")
+    if details:
+        t.append("  ·  ".join(details) + "\n", style="dim")
+
+    if (aqi := p.get("aqi")) is not None:
+        t.append(f"AQI {aqi} ", style=aqi_style(aqi))
+        t.append(f"{p.get('aqiCategory', '')}\n", style="dim")
+
+    astro: list[str] = []
+    if p.get("sunrise"):   astro.append(f"↑ {hm(p['sunrise'])}")
+    if p.get("sunset"):    astro.append(f"↓ {hm(p['sunset'])}")
+    if p.get("moonPhase"): astro.append(f"{p.get('moonEmoji', '')} {p['moonPhase']}")
+    if astro:
+        t.append("  ·  ".join(astro) + "\n", style="dim")
+
+    high_pollen = [pl for pl in p.get("pollen", []) if pl.get("level") != "Low"]
+    if high_pollen:
+        t.append("Pollen: " + "  ·  ".join(
+            f"{pl['label']} {pl['level']}" for pl in high_pollen
+        ) + "\n", style="dim")
+
+    t.append("\n")
+    for d in p.get("daily", [])[:3]:
+        t.append(f"{day_abbr(d.get('date', '')):<4}", style="bold dim")
+        t.append(f"  {d.get('max', '—')}° / {d.get('min', '—')}°")
+        if prob := d.get("precipProb"):
+            t.append(f"  {prob}%", style="blue")
+        t.append("\n")
+    return t
+
+
+def render_calendar(p: dict) -> Text:
+    events = [e for e in p.get("events", []) if within_7_days(e.get("start"))]
+    if not events:
+        return Text("No upcoming events.", style="dim italic")
+    t = Text()
+    for e in events:
+        label = event_label(e.get("start", ""), e.get("allDay", False))
+        t.append(f"{label:<22}  ", style="dim")
+        t.append(f"{e.get('summary', '?')}\n")
+    return t
+
+
+def render_todos(p: dict) -> Text:
+    tasks = p.get("tasks", [])
+    if not tasks:
+        return Text("Nothing today.", style="dim italic")
+    t = Text()
+    for task in tasks:
+        t.append("☐  ")
+        t.append(task.get("title", "?"))
+        if proj := task.get("project"):
+            t.append(f"  {proj}", style="dim")
+        t.append("\n")
+    return t
+
+
+def render_markets(p: dict) -> Text:
+    quotes = p.get("quotes", [])
+    if not quotes:
+        return Text("No quotes.", style="dim italic")
+    t = Text()
+    for q in quotes:
+        pct = q.get("changePct")
+        price = q.get("price")
+        t.append(f"{q.get('symbol', '?'):<6}", style="bold")
+        if price is not None:
+            t.append(f"  {price:>12,.2f}")
+        if pct is not None:
+            color = "green" if pct >= 0 else "red"
+            t.append(f"  {'+'if pct>=0 else ''}{pct:.2f}%", style=color)
+        t.append("\n")
+    return t
+
+
+def _link(url: str) -> Style:
+    safe = url.replace('"', "%22").replace("'", "%27")
+    return Style(underline=True, meta={"@click": f'app.open_link("{safe}")'})
+
+
+def render_news(ps: list[dict], cap: int = 12) -> Text:
+    t, seen, n = Text(), set(), 0
+    for p in ps:
+        for item in p.get("items", []):
+            if n >= cap:
+                break
+            title = item.get("title", "?")
+            if title in seen:
+                continue
+            seen.add(title)
+            n += 1
+            url = item.get("link", "")
+            t.append("• ", style="dim")
+            t.append(f"{title}\n", style=_link(url) if url else "")
+            if src := item.get("source"):
+                t.append(f"  {src}\n", style="dim")
+    if n == 0:
+        return Text("No headlines.", style="dim italic")
+    return t
+
+
+def render_feeds(ps: list[dict], cap: int = 12) -> Text:
+    t, seen, n = Text(), set(), 0
+    for p in ps:
+        for item in p.get("items", []):
+            if n >= cap:
+                break
+            title = item.get("title", "?")
+            if title in seen:
+                continue
+            seen.add(title)
+            n += 1
+            url = item.get("link", "")
+            t.append("• ", style="dim")
+            t.append(f"{title}\n", style=_link(url) if url else "")
+            if src := item.get("source") or item.get("feedTitle"):
+                t.append(f"  {src}\n", style="dim")
+    if n == 0:
+        return Text("No feed items.", style="dim italic")
+    return t
+
+
+def render_hackernews(p: dict, cap: int = 10) -> Text:
+    stories = p.get("stories", [])[:cap]
+    if not stories:
+        return Text("No stories.", style="dim italic")
+    t = Text()
+    for s in stories:
+        url = s.get("url") or ""
+        t.append("• ", style="dim")
+        t.append(f"{s.get('title', '?')}\n", style=_link(url) if url else "")
+        if src := s.get("source"):
+            t.append(f"  {src}\n", style="dim")
+    return t
+
+
+def _game_bucket(g: dict) -> str:
+    state = g.get("state")
+    if state == "in":
+        return "live"
+    if state == "post":
+        return "live"
+    # pre or unknown — classify by date
+    st = g.get("startTime")
+    if st:
+        try:
+            d = to_local(st)
+            return "today" if d.date() == date.today() else "upcoming"
+        except Exception:
+            pass
+    return "today"
+
+
+def render_sports(p: dict) -> Text:
+    all_games = [g for g in p.get("games", []) if within_7_days(g.get("startTime"))]
+    if not all_games:
+        return Text("No games.", style="dim italic")
+
+    buckets: dict[str, list[dict]] = {"live": [], "today": [], "upcoming": []}
+    for g in all_games:
+        buckets[_game_bucket(g)].append(g)
+
+    HEADERS = {"live": "Live / Final", "today": "Today", "upcoming": "Upcoming"}
+    t = Text()
+    first_section = True
+
+    for key in ("live", "today", "upcoming"):
+        games = buckets[key]
+        if not games:
+            continue
+        if not first_section:
+            t.append("\n")
+        first_section = False
+        t.append(f"{HEADERS[key]}\n", style="bold")
+
+        cur_league = None
+        for g in games:
+            lg = g.get("league", "").split("/")[-1].upper()
+            if lg != cur_league:
+                cur_league = lg
+                t.append(f"  {lg}\n", style="dim")
+
+            state = g.get("state")
+            away_sc = g.get("awayScore")
+            home_sc = g.get("homeScore")
+            status = g.get("status", "")
+
+            if state == "in":
+                # Live: score + current period/inning
+                score = f"{away_sc}–{home_sc}" if away_sc is not None and home_sc is not None else "—"
+                lbl = f"{score}  {status}"
+                lbl_style = "bold yellow"
+            elif state == "post":
+                # Final: score + "F"
+                score = f"{away_sc}–{home_sc}" if away_sc is not None and home_sc is not None else "—"
+                final_tag = "F/OT" if "OT" in status or "OT" in (status or "") else "F"
+                lbl = f"{score} {final_tag}"
+                lbl_style = "dim"
+            else:
+                # Scheduled: start time
+                st = g.get("startTime")
+                if st:
+                    try:
+                        d = to_local(st)
+                        is_today = d.date() == date.today()
+                        lbl = d.strftime("%-I:%M %p") if is_today else d.strftime("%a %-d  %-I:%M %p")
+                    except Exception:
+                        lbl = status
+                else:
+                    lbl = status
+                lbl_style = "dim"
+
+            t.append(f"    ")
+            t.append(f"{lbl:<18}", style=lbl_style)
+            t.append(f"{g.get('away', '?')} @ {g.get('home', '?')}\n")
+
+    return t
+
+
+def render_uptime(p: dict) -> Text:
+    sites = p.get("sites", [])
+    if not sites:
+        return Text("No sites configured.", style="dim italic")
+    t = Text()
+    for s in sites:
+        ok = s.get("ok", False)
+        url = s.get("url", "?")
+        label = url.replace("https://", "").replace("http://", "").rstrip("/")
+        status_style = "green" if ok else "bold red"
+        status_icon = "●" if ok else "○"
+        t.append(f"{status_icon} ", style=status_style)
+        t.append(f"{label:<40}")
+        if ms := s.get("ms"):
+            t.append(f"  {ms}ms", style="dim")
+        elif not ok:
+            if code := s.get("status"):
+                t.append(f"  HTTP {code}", style="red")
+            else:
+                t.append("  down", style="red")
+        t.append("\n")
+    return t
+
+
+def render_briefs(p: dict) -> Text:
+    cats = [c for c in p.get("categories", []) if c.get("stories")]
+    if not cats:
+        return Text("No briefs.", style="dim italic")
+    t = Text()
+    for cat in cats:
+        t.append(f"{cat.get('name', '?').upper()}\n", style="bold")
+        for s in cat.get("stories", [])[:3]:
+            url = s.get("link") or ""
+            t.append("  • ", style="dim")
+            t.append(f"{s.get('title', '?')}\n", style=_link(url) if url else "")
+            if summ := s.get("summary"):
+                t.append(f"    {summ[:120]}\n", style="dim")
+    return t
+
+
+# ── System stats ──────────────────────────────────────────────────────────────
+
+def _duration(secs: float) -> str:
+    secs = int(secs)
+    d, rem = divmod(secs, 86400)
+    h, rem = divmod(rem, 3600)
+    m, s = divmod(rem, 60)
+    if d:
+        return f"{d}d {h}h {m}m"
+    if h:
+        return f"{h}h {m}m"
+    if m:
+        return f"{m}m {s}s"
+    return f"{s}s"
+
+
+def _rate(bps: float | None) -> str:
+    if bps is None:
+        return "—"
+    if bps >= 1024 * 1024:
+        return f"{bps / (1024 * 1024):.1f} MB/s"
+    if bps >= 1024:
+        return f"{bps / 1024:.1f} KB/s"
+    return f"{bps:.0f} B/s"
+
+
+def _gb(n: float) -> float:
+    return n / 2**30
+
+
+def _idle_seconds() -> float | None:
+    """Seconds since last keyboard/mouse input, via IOKit's HIDIdleTime."""
+    try:
+        out = subprocess.run(
+            ["ioreg", "-c", "IOHIDSystem"],
+            capture_output=True, text=True, timeout=3,
+        ).stdout
+        if m := re.search(r'"HIDIdleTime" = (\d+)', out):
+            return int(m.group(1)) / 1e9
+    except Exception:
+        pass
+    return None
+
+
+class SystemStats:
+    """Collects local host metrics. sample() is blocking — call off the UI thread."""
+
+    def __init__(self) -> None:
+        psutil.cpu_percent(interval=None)  # prime; next call returns a real delta
+        self.cpu_history: deque[float] = deque([0.0] * 60, maxlen=60)
+        self._last_net = psutil.net_io_counters()
+        self._last_net_t = time.monotonic()
+
+    def sample(self) -> dict:
+        cpu = psutil.cpu_percent(interval=None)
+        self.cpu_history.append(cpu)
+
+        net = psutil.net_io_counters()
+        now = time.monotonic()
+        dt = now - self._last_net_t
+        down = up = None
+        if dt > 0.5:
+            down = max(0.0, (net.bytes_recv - self._last_net.bytes_recv) / dt)
+            up = max(0.0, (net.bytes_sent - self._last_net.bytes_sent) / dt)
+        self._last_net, self._last_net_t = net, now
+
+        procs = []
+        for pr in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info"]):
+            info = pr.info
+            if info.get("cpu_percent") is None:
+                continue
+            procs.append(info)
+        procs.sort(key=lambda i: i["cpu_percent"], reverse=True)
+
+        return {
+            "cpu": cpu,
+            "load": psutil.getloadavg(),
+            "cores": psutil.cpu_count() or 0,
+            "mem": psutil.virtual_memory(),
+            "disk": psutil.disk_usage("/"),
+            "down": down,
+            "up": up,
+            "battery": psutil.sensors_battery(),
+            "uptime": time.time() - psutil.boot_time(),
+            "idle": _idle_seconds(),
+            "procs": procs[:12],
+            "history": list(self.cpu_history),
+        }
+
+
+def render_cpu(s: dict) -> Text:
+    t = Text()
+    t.append(f"{s['cpu']:.1f}", style="bold yellow")
+    t.append(" %\n")
+    l1, l5, l15 = s["load"]
+    t.append(f"Load {l1:.2f} · {l5:.2f} · {l15:.2f}\n", style="dim")
+    t.append(f"{s['cores']} cores\n", style="dim")
+    return t
+
+
+def render_memdisk(s: dict) -> Text:
+    mem, disk = s["mem"], s["disk"]
+    t = Text()
+    t.append(f"{mem.percent:.0f}", style="bold yellow")
+    t.append(" % mem\n")
+    t.append(f"{_gb(mem.used):.1f} / {_gb(mem.total):.0f} GB\n", style="dim")
+    t.append("\n")
+    t.append(f"{_gb(disk.free):.0f} GB", style="bold")
+    t.append(" disk free\n")
+    t.append(f"{_gb(disk.used):.0f} GB used · {disk.percent:.0f}%\n", style="dim")
+    return t
+
+
+def render_net(s: dict) -> Text:
+    t = Text()
+    t.append("↓ ", style="green")
+    t.append(f"{_rate(s['down'])}\n", style="bold")
+    t.append("↑ ", style="blue")
+    t.append(f"{_rate(s['up'])}\n", style="bold")
+    return t
+
+
+def render_power(s: dict) -> Text:
+    t = Text()
+    if batt := s["battery"]:
+        t.append(f"{batt.percent:.0f}", style="bold yellow")
+        t.append(" % battery")
+        if batt.power_plugged:
+            t.append("  ⚡", style="yellow")
+        t.append("\n")
+        if batt.power_plugged or batt.secsleft == psutil.POWER_TIME_UNLIMITED:
+            t.append("on AC\n", style="dim")
+        elif batt.secsleft == psutil.POWER_TIME_UNKNOWN:
+            t.append("—\n", style="dim")
+        else:
+            t.append(f"{_duration(batt.secsleft)} left\n", style="dim")
+    else:
+        t.append("No battery\n", style="dim italic")
+    t.append("\n")
+    t.append("Up ", style="dim")
+    t.append(_duration(s["uptime"]))
+    t.append("\n")
+    if (idle := s["idle"]) is not None:
+        t.append("Away ", style="dim")
+        t.append(_duration(idle))
+        t.append("\n")
+    return t
+
+
+# ── Widget ────────────────────────────────────────────────────────────────────
+
+class DashPanel(ScrollableContainer):
+    DEFAULT_CSS = """
+    DashPanel {
+        border: round $panel;
+        border-title-color: $primary;
+        padding: 0 1;
+        height: 100%;
+        scrollbar-size: 1 1;
+    }
+    DashPanel:focus {
+        border: round $accent;
+        border-title-color: $accent;
+    }
+    """
+
+    def __init__(self, title: str, panel_id: str, **kwargs):
+        super().__init__(id=panel_id, **kwargs)
+        self.border_title = title
+        self._body = Static("")
+
+    def compose(self) -> ComposeResult:
+        yield self._body
+
+    def set_content(self, content: Text) -> None:
+        self._body.update(content)
+
+
+def render_nowplaying(p: dict | None) -> Text:
+    if not p or not p.get("playing") or not p.get("track"):
+        return Text("Nothing playing.", style="dim italic")
+    track = p["track"]
+    t = Text()
+    t.append(f"♫  {track.get('name', '?')}\n", style="bold")
+    t.append(f"   {track.get('artist', '?')}\n")
+    t.append(f"   {track.get('album', '?')}\n", style="dim")
+    dur = track.get("durationMs", 0)
+    cur = track.get("currentMs", 0)
+    if dur > 0:
+        pct = min(1.0, cur / dur)
+        filled = int(pct * 20)
+        bar = "█" * filled + "░" * (20 - filled)
+        def fmt(ms: int) -> str:
+            s = ms // 1000
+            return f"{s // 60}:{s % 60:02d}"
+        t.append(f"\n   {bar}\n", style="dim")
+        t.append(f"   {fmt(cur)} / {fmt(dur)}\n", style="dim")
+    return t
+
+
+# ── kk overlay (native dash menu) ─────────────────────────────────────────────
+
+# Entries safe to run captured (no TTY needed) — everything else suspends the
+# TUI and runs `dash "<key>"` interactively (gum prompts, fzf pickers, TUIs).
+KK_CAPTURED = {
+    "day", "today", "week", "todos", "tasks", "weather", "claude-status",
+    "installed", "yesterday-note", "morning-paper --print",
+    "am play", "am pause", "am next", "am prev", "am now",
+}
+
+# Entries whose only interactivity is one text prompt — replicated natively.
+# key → (placeholder, command builder, allow_empty)
+def _scratch_cmd(text: str) -> str:
+    stamp = datetime.now().strftime("%I:%M %p")
+    return f"daily-append {shlex.quote(f'- `{stamp}` – {text}')}"
+
+
+KK_INPUT: dict[str, tuple[str, object, bool]] = {
+    "scratch": ("what's on your mind?", _scratch_cmd, False),
+    "note": ("the thought (AI names/files it)", lambda t: f"note {shlex.quote(t)}", False),
+    "dict": ("word", lambda t: f"dict {shlex.quote(t)}", False),
+    "am search": ("search library", lambda t: f"am search {shlex.quote(t)}", False),
+    "am vol": ("volume 0-100 (blank = show)",
+               lambda t: f"am vol {shlex.quote(t)}" if t else "am vol", True),
+}
+
+# Long-running captured commands (claude generation, puppeteer) get more rope.
+KK_SLOW_TIMEOUT = 600
+
+
+def kk_choices(key: str) -> list[tuple[str, str]] | None:
+    """(command, label) pairs for entries that branch on a small pick."""
+    if key in ("weeknotes", "monthnotes", "quarternotes", "yearnotes"):
+        period = key.removesuffix("notes")
+        return [(key, f"this {period}"), (f"{key} last", f"last {period}")]
+    if key == "dayreview":
+        return [("_dayreview", "yesterday"), ("_dayreview today", "today")]
+    if key == "daydata":
+        return [("_daydata", "yesterday"), ("_daydata today", "today")]
+    if key == "zine":
+        now = datetime.now()
+        this = now.strftime("%Y-%m")
+        nxt = f"{now.year + (now.month == 12):04d}-{(now.month % 12) + 1:02d}"
+        return [(f"_zine {this}", "this month"), (f"_zine {nxt}", "next month")]
+    return None
+
+
+def load_dash_entries() -> list[tuple[str, str]]:
+    """Parse the dash() entries array from ~/.zshrc → (key, label) pairs."""
+    try:
+        text = (Path.home() / ".zshrc").read_text()
+    except Exception:
+        return []
+    body = re.search(r"^dash\(\)\s*\{(.+?)^\}", text, re.S | re.M)
+    if not body:
+        return []
+    arr = re.search(r"entries=\((.*?)\n\s*\)", body.group(1), re.S)
+    if not arr:
+        return []
+    out: list[tuple[str, str]] = []
+    for line in arr.group(1).splitlines():
+        if m := re.match(r'^\s*"([^|"]+)\|(.*)"\s*$', line):
+            key, label = m.group(1), m.group(2)
+            if key == "dashboard":  # that's this app — skip
+                continue
+            out.append((key, label))
+    return out
+
+
+class KkFilterInput(Input):
+    def on_key(self, event) -> None:
+        if event.key == "escape":
+            event.stop()
+            event.prevent_default()
+            self.screen.dismiss(None)
+        elif event.key in ("up", "down"):
+            event.stop()
+            event.prevent_default()
+            lst = self.screen.query_one(OptionList)
+            if event.key == "up":
+                lst.action_cursor_up()
+            else:
+                lst.action_cursor_down()
+
+
+class KkScreen(ModalScreen[str | None]):
+    """Native fuzzy-filter version of the dash/kk gum menu."""
+
+    BINDINGS = [Binding("escape", "close", "Close", show=False)]
+    DEFAULT_CSS = """
+    KkScreen { align: center middle; }
+    #kk-box {
+        width: 84;
+        height: auto;
+        max-height: 80%;
+        background: $surface;
+        border: round $accent;
+        border-title-color: $accent;
+        padding: 0 1;
+    }
+    #kk-box Input { border: round $panel; }
+    #kk-list {
+        height: auto;
+        max-height: 24;
+        background: $surface;
+        scrollbar-size: 1 1;
+    }
+    """
+
+    def __init__(self, entries: list[tuple[str, str]], title: str = "kk — pick a command"):
+        super().__init__()
+        self._entries = entries
+        self._title = title
+
+    def compose(self) -> ComposeResult:
+        with Vertical(id="kk-box"):
+            yield KkFilterInput(placeholder="filter…", id="kk-filter")
+            yield OptionList(id="kk-list")
+
+    def on_mount(self) -> None:
+        self.query_one("#kk-box", Vertical).border_title = self._title
+        self._refilter("")
+        self.query_one("#kk-filter", KkFilterInput).focus()
+
+    def _refilter(self, query: str) -> None:
+        lst = self.query_one(OptionList)
+        lst.clear_options()
+        words = query.lower().split()
+        for key, label in self._entries:
+            hay = f"{key} {label}".lower()
+            if all(w in hay for w in words):
+                lst.add_option(Option(Text(label, no_wrap=True), id=key))
+        if lst.option_count:
+            lst.highlighted = 0
+
+    def on_input_changed(self, event: Input.Changed) -> None:
+        self._refilter(event.value)
+
+    def on_input_submitted(self, event: Input.Submitted) -> None:
+        lst = self.query_one(OptionList)
+        if lst.option_count and lst.highlighted is not None:
+            self.dismiss(lst.get_option_at_index(lst.highlighted).id)
+
+    def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+        self.dismiss(event.option.id)
+
+    def action_close(self) -> None:
+        self.dismiss(None)
+
+
+class KkPromptInput(Input):
+    def on_key(self, event) -> None:
+        if event.key == "escape":
+            event.stop()
+            event.prevent_default()
+            self.screen.dismiss(None)
+
+
+class KkInputScreen(ModalScreen[str | None]):
+    """Single text prompt — native replacement for an entry's gum input."""
+
+    BINDINGS = [Binding("escape", "close", "Close", show=False)]
+    DEFAULT_CSS = """
+    KkInputScreen { align: center middle; }
+    #kkp-box {
+        width: 84;
+        height: auto;
+        background: $surface;
+        border: round $accent;
+        border-title-color: $accent;
+        padding: 0 1;
+    }
+    #kkp-box Input { border: round $panel; }
+    """
+
+    def __init__(self, title: str, placeholder: str, allow_empty: bool = False):
+        super().__init__()
+        self._title = title
+        self._placeholder = placeholder
+        self._allow_empty = allow_empty
+
+    def compose(self) -> ComposeResult:
+        with Vertical(id="kkp-box"):
+            yield KkPromptInput(placeholder=self._placeholder, id="kkp-input")
+
+    def on_mount(self) -> None:
+        self.query_one("#kkp-box", Vertical).border_title = self._title
+        self.query_one("#kkp-input", KkPromptInput).focus()
+
+    def on_input_submitted(self, event: Input.Submitted) -> None:
+        value = event.value.strip()
+        if value or self._allow_empty:
+            self.dismiss(value)
+        else:
+            self.dismiss(None)
+
+    def action_close(self) -> None:
+        self.dismiss(None)
+
+
+# ── Command bar ───────────────────────────────────────────────────────────────
+
+class CommandInput(Input):
+    """Input with Esc-to-close and up/down history, delegated to the app."""
+
+    def on_key(self, event) -> None:
+        if event.key == "escape":
+            event.stop()
+            event.prevent_default()
+            self.app.hide_command_bar()
+        elif event.key == "up":
+            event.stop()
+            event.prevent_default()
+            self.app.history_nav(-1)
+        elif event.key == "down":
+            event.stop()
+            event.prevent_default()
+            self.app.history_nav(1)
+
+
+class CommandBar(Vertical):
+    DEFAULT_CSS = """
+    CommandBar {
+        height: auto;
+        display: none;
+        background: $surface;
+        padding: 0 1;
+    }
+    CommandBar.visible { display: block; }
+    CommandBar Input { border: round $accent; }
+    CommandBar #cmd-output-wrap {
+        height: auto;
+        max-height: 12;
+        scrollbar-size: 1 1;
+    }
+    CommandBar #cmd-output { padding: 0 2; }
+    """
+
+    def compose(self) -> ComposeResult:
+        yield CommandInput(
+            id="cmd-input",
+            placeholder="command…  (zsh, aliases work · !cmd = interactive in terminal · Esc = close)",
+        )
+        with ScrollableContainer(id="cmd-output-wrap"):
+            yield Static("", id="cmd-output")
+
+
+# ── App ───────────────────────────────────────────────────────────────────────
+
+class DashboardApp(App):
+    TITLE = "Personal Dashboard"
+    BINDINGS = [
+        ("1", "page('page-personal')", "Personal"),
+        ("2", "page('page-news')", "News"),
+        ("3", "page('page-system')", "System"),
+        Binding("[", "cycle_page(-1)", "Prev page", show=False),
+        Binding("]", "cycle_page(1)", "Next page", show=False),
+        Binding(":", "command_bar", "Cmd", key_display=":"),
+        ("k", "kk_menu", "kk"),
+        ("r", "refresh", "Refresh"),
+        ("t", "next_theme", "Theme"),
+        ("q", "quit", "Quit"),
+    ]
+    CSS = """
+    Screen { background: $background; }
+
+    ContentSwitcher { height: 1fr; }
+    #page-personal, #page-news, #page-system { height: 100%; }
+
+    .row      { height: 1fr; }
+    .row-tall { height: 2fr; }
+
+    #weather { width: 1.75fr; }
+    #uptime  { width: 0.8fr; }
+
+    #nowplaying { width: 1.25fr; }
+
+    #todos    { width: 1.75fr; }
+    #calendar { width: 1.25fr; }
+    #markets  { width: 0.8fr; }
+
+    #sports     { width: 1.75fr; }
+    #news       { width: 2fr; }
+    #feeds      { width: 1.25fr; }
+    #hackernews { width: 1fr; }
+
+    #np-news       { width: 1.25fr; }
+    #np-briefs     { width: 1fr; }
+    #np-feeds      { width: 1fr; }
+    #np-hackernews { width: 1fr; }
+
+    #sys-cpu, #sys-memdisk, #sys-net, #sys-power { width: 1fr; }
+
+    .sys-spark-row { height: 4; }
+    #sys-sparkline {
+        height: 100%;
+        margin: 0 0;
+        padding: 0 1;
+        border: round $panel;
+        border-title-color: $primary;
+        color: $accent;
+    }
+    #sys-procs {
+        width: 100%;
+        height: 100%;
+        border: round $panel;
+        border-title-color: $primary;
+        scrollbar-size: 1 1;
+    }
+    """
+
+    _theme_idx: int = 0
+    _page_id: str = "page-personal"
+    _connected: bool = False
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self._cmd_history: list[str] = []
+        self._hist_idx = 0
+
+    def on_mount(self) -> None:
+        for t in _THEMES.values():
+            self.register_theme(t)
+        self.theme = THEME_NAMES[self._theme_idx]
+
+        self._sys = SystemStats()
+        self._sys_timer = self.set_interval(
+            SYS_REFRESH_SECS, self.refresh_system, pause=True
+        )
+        table = self._q("#sys-procs", DataTable)
+        table.add_columns("PID", "Name", "CPU %", "Mem")
+        table.cursor_type = "none"
+
+        self.refresh_data()
+        self.set_interval(REFRESH_SECS, self.refresh_data)
+
+    def _q(self, selector: str, expect_type):
+        """query_one against the main screen — app.query_one resolves against
+        the TOP of the screen stack, so timer/worker lookups crash with
+        NoMatches while a modal (kk overlay) is open."""
+        return self.screen_stack[0].query_one(selector, expect_type)
+
+    def compose(self) -> ComposeResult:
+        yield Header()
+        with ContentSwitcher(initial="page-personal", id="switcher"):
+            with Vertical(id="page-personal"):
+                with Horizontal(classes="row"):
+                    yield DashPanel("Weather", "weather")
+                    yield DashPanel("Uptime", "uptime")
+                    yield DashPanel("Now Playing", "nowplaying")
+                with Horizontal(classes="row"):
+                    yield DashPanel("Agenda", "todos")
+                    yield DashPanel("Calendar", "calendar")
+                    yield DashPanel("Markets", "markets")
+                with Horizontal(classes="row row-tall"):
+                    yield DashPanel("Scores", "sports")
+                    yield DashPanel("Headlines", "news")
+                    yield DashPanel("Feeds", "feeds")
+                    yield DashPanel("Hacker News", "hackernews")
+            with Vertical(id="page-news"):
+                with Horizontal(classes="row"):
+                    yield DashPanel("Headlines", "np-news")
+                    yield DashPanel("Briefs", "np-briefs")
+                with Horizontal(classes="row"):
+                    yield DashPanel("Feeds", "np-feeds")
+                    yield DashPanel("Hacker News", "np-hackernews")
+            with Vertical(id="page-system"):
+                with Horizontal(classes="row"):
+                    yield DashPanel("CPU", "sys-cpu")
+                    yield DashPanel("Memory · Disk", "sys-memdisk")
+                    yield DashPanel("Network", "sys-net")
+                    yield DashPanel("Power · Uptime", "sys-power")
+                with Horizontal(classes="sys-spark-row"):
+                    spark = Sparkline([], id="sys-sparkline", summary_function=max)
+                    spark.border_title = "CPU history"
+                    yield spark
+                with Horizontal(classes="row row-tall"):
+                    procs = DataTable(id="sys-procs")
+                    procs.border_title = "Processes"
+                    yield procs
+        yield CommandBar(id="cmd-bar")
+        yield Footer()
+
+    @work(exclusive=True)
+    async def refresh_data(self) -> None:
+        sources = await fetch_sources()
+        connected = bool(sources)
+        no_data = Text("No data.", style="dim italic")
+
+        def panel(pid: str) -> DashPanel:
+            return self._q(f"#{pid}", DashPanel)
+
+        panel("weather").set_content(
+            render_weather(p) if (p := payload(sources, "weather")) else no_data
+        )
+        panel("calendar").set_content(
+            render_calendar(p) if (p := payload(sources, "calendar")) else no_data
+        )
+        panel("todos").set_content(
+            render_todos(p) if (p := payload(sources, "todos")) else no_data
+        )
+        panel("markets").set_content(
+            render_markets(p) if (p := payload(sources, "markets")) else no_data
+        )
+        panel("sports").set_content(
+            render_sports(p) if (p := payload(sources, "sports")) else no_data
+        )
+        feed_ps = payloads(sources, "feeds")
+        panel("feeds").set_content(
+            render_feeds(feed_ps) if feed_ps else no_data
+        )
+        panel("np-feeds").set_content(
+            render_feeds(feed_ps, cap=24) if feed_ps else no_data
+        )
+        news_ps = payloads(sources, "news")
+        panel("news").set_content(
+            render_news(news_ps) if news_ps else no_data
+        )
+        panel("np-news").set_content(
+            render_news(news_ps, cap=24) if news_ps else no_data
+        )
+        hn_p = payload(sources, "hackernews")
+        panel("hackernews").set_content(
+            render_hackernews(hn_p) if hn_p else no_data
+        )
+        panel("np-hackernews").set_content(
+            render_hackernews(hn_p, cap=20) if hn_p else no_data
+        )
+        panel("np-briefs").set_content(
+            render_briefs(p) if (p := payload(sources, "briefs")) else no_data
+        )
+        panel("uptime").set_content(
+            render_uptime(p) if (p := payload(sources, "uptime")) else no_data
+        )
+        panel("nowplaying").set_content(
+            render_nowplaying(payload(sources, "cider"))
+        )
+        self._connected = connected
+        self._update_subtitle()
+
+    @work(exclusive=True, thread=True, group="sys")
+    def refresh_system(self) -> None:
+        stats = self._sys.sample()
+        self.call_from_thread(self._apply_system, stats)
+
+    def _apply_system(self, s: dict) -> None:
+        def panel(pid: str) -> DashPanel:
+            return self._q(f"#{pid}", DashPanel)
+
+        panel("sys-cpu").set_content(render_cpu(s))
+        panel("sys-memdisk").set_content(render_memdisk(s))
+        panel("sys-net").set_content(render_net(s))
+        panel("sys-power").set_content(render_power(s))
+
+        self._q("#sys-sparkline", Sparkline).data = s["history"]
+
+        table = self._q("#sys-procs", DataTable)
+        table.clear(columns=False)
+        for pr in s["procs"]:
+            mem = pr.get("memory_info")
+            mem_lbl = f"{mem.rss / 2**20:.0f} MB" if mem else "—"
+            table.add_row(
+                str(pr["pid"]),
+                (pr.get("name") or "?")[:40],
+                f"{pr['cpu_percent']:.1f}",
+                mem_lbl,
+            )
+
+    def _update_subtitle(self) -> None:
+        now = datetime.now().strftime("%-I:%M %p")
+        dot = "●" if self._connected else "○ offline"
+        page_name = dict(PAGES)[self._page_id]
+        self.sub_title = f"{dot}  {now}  ·  {page_name}  ·  {THEME_NAMES[self._theme_idx]}"
+
+    def action_page(self, page_id: str) -> None:
+        if page_id == self._page_id:
+            return
+        self._q("#switcher", ContentSwitcher).current = page_id
+        self._page_id = page_id
+        if page_id == "page-system":
+            self._sys_timer.resume()
+            self.refresh_system()
+        else:
+            self._sys_timer.pause()
+        self._update_subtitle()
+
+    def action_cycle_page(self, delta: int) -> None:
+        ids = [pid for pid, _ in PAGES]
+        idx = (ids.index(self._page_id) + delta) % len(ids)
+        self.action_page(ids[idx])
+
+    # ── Command bar ──────────────────────────────────────────────────────────
+
+    def action_command_bar(self) -> None:
+        bar = self._q("#cmd-bar", CommandBar)
+        bar.add_class("visible")
+        self._q("#cmd-input", CommandInput).focus()
+
+    def hide_command_bar(self) -> None:
+        self._q("#cmd-bar", CommandBar).remove_class("visible")
+        self.set_focus(None)
+
+    def action_kk_menu(self) -> None:
+        entries = load_dash_entries()
+        if not entries:
+            self.notify("couldn't parse dash entries from ~/.zshrc", severity="error")
+            return
+
+        def picked(key: str | None) -> None:
+            if key:
+                self._kk_dispatch(key)
+
+        self.push_screen(KkScreen(entries), picked)
+
+    def _kk_run(self, cmd: str, timeout: int = 60) -> None:
+        self._set_cmd_output(Text(f"$ {cmd}\nrunning…", style="dim"))
+        self.run_command(cmd, timeout=timeout)
+
+    def _kk_dispatch(self, key: str) -> None:
+        # one text prompt → command (scratch, note, dict, am search/vol)
+        if key in KK_INPUT:
+            placeholder, build, allow_empty = KK_INPUT[key]
+
+            def submitted(value: str | None) -> None:
+                if value is not None:
+                    self._kk_run(build(value), timeout=KK_SLOW_TIMEOUT)
+
+            self.push_screen(KkInputScreen(key, placeholder, allow_empty), submitted)
+            return
+
+        # small fixed choice → command (periodic notes, dayreview, daydata, zine)
+        if choices := kk_choices(key):
+
+            def chose(cmd: str | None) -> None:
+                if cmd:
+                    self._kk_run(cmd, timeout=KK_SLOW_TIMEOUT)
+
+            self.push_screen(KkScreen(choices, title=key), chose)
+            return
+
+        # dynamic list pick
+        if key == "am playlist":
+            self._kk_playlist_flow()
+            return
+
+        if key in KK_CAPTURED:
+            timeout = KK_SLOW_TIMEOUT if key == "morning-paper --print" else 60
+            self._kk_run(key, timeout=timeout)
+            return
+
+        # real TUIs / complex gum flows (task, event, md, vault, newsboat, …)
+        self._run_interactive(f"dash {shlex.quote(key)}")
+
+    @work(thread=True, group="cmd", exclusive=True)
+    def _kk_playlist_flow(self) -> None:
+        try:
+            res = subprocess.run(
+                ["zsh", "-ic", "am playlists"],
+                capture_output=True, text=True, timeout=30,
+                stdin=subprocess.DEVNULL, start_new_session=True,
+            )
+            names = [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
+        except Exception:
+            names = []
+        self.call_from_thread(self._kk_playlist_pick, names)
+
+    def _kk_playlist_pick(self, names: list[str]) -> None:
+        if not names:
+            self.notify("no playlists found", severity="warning")
+            return
+
+        def chose(name: str | None) -> None:
+            if name:
+                self._kk_run(f"am playlist {shlex.quote(name)}")
+
+        choices = [(n, n) for n in names]
+        self.push_screen(KkScreen(choices, title="pick a playlist"), chose)
+
+    def history_nav(self, delta: int) -> None:
+        if not self._cmd_history:
+            return
+        self._hist_idx = max(0, min(len(self._cmd_history), self._hist_idx + delta))
+        inp = self._q("#cmd-input", CommandInput)
+        if self._hist_idx == len(self._cmd_history):
+            inp.value = ""
+        else:
+            inp.value = self._cmd_history[self._hist_idx]
+            inp.cursor_position = len(inp.value)
+
+    def on_input_submitted(self, event: Input.Submitted) -> None:
+        if event.input.id != "cmd-input":
+            return
+        cmd = event.value.strip()
+        event.input.value = ""
+        if not cmd:
+            self.hide_command_bar()
+            return
+        self._cmd_history.append(cmd)
+        self._hist_idx = len(self._cmd_history)
+        if cmd.startswith("!"):
+            self._run_interactive(cmd[1:].strip() or "dash")
+        else:
+            self._set_cmd_output(Text(f"$ {cmd}\nrunning…", style="dim"))
+            self.run_command(cmd)
+
+    def _run_interactive(self, cmd: str) -> None:
+        """Suspend the TUI and run cmd with the real terminal (gum menus, TUIs)."""
+        with self.suspend():
+            subprocess.run(["zsh", "-ic", cmd])
+        self.hide_command_bar()
+        self.notify(f"! {cmd}", title="ran in terminal", timeout=3)
+        self.refresh()
+        self.refresh_data()
+
+    _ZSH_NOISE = re.compile(
+        r"can't change option|stdin isn't a terminal|no job control|"
+        r"inappropriate ioctl|not a terminal"
+    )
+
+    @work(exclusive=True, thread=True, group="cmd")
+    def run_command(self, cmd: str, timeout: int = 60) -> None:
+        try:
+            # stdin=DEVNULL + new session: no controlling TTY, so interactive
+            # zsh (needed for aliases/functions) can't grab the terminal or
+            # SIGTTIN-suspend the dashboard's process group.
+            res = subprocess.run(
+                ["zsh", "-ic", cmd],
+                capture_output=True, text=True, timeout=timeout,
+                stdin=subprocess.DEVNULL,
+                start_new_session=True,
+            )
+            raw = (res.stdout + res.stderr).strip()
+            out = "\n".join(
+                ln for ln in raw.splitlines() if not self._ZSH_NOISE.search(ln)
+            ).strip()
+            code = res.returncode
+        except subprocess.TimeoutExpired:
+            out, code = f"timed out after {timeout}s (interactive? try !{cmd})", 1
+        self.call_from_thread(self._finish_cmd, cmd, out, code)
+
+    def _finish_cmd(self, cmd: str, out: str, code: int) -> None:
+        lines = out.splitlines()
+        if code == 0 and len(lines) <= 3:
+            # quick success — toast it and get out of the way
+            self.hide_command_bar()
+            self._set_cmd_output(Text(""))
+            msg = out if out else "done"
+            self.notify(msg[:200], title=f"✓ {cmd}"[:60], timeout=4)
+        else:
+            # long output worth reading, or a failure — show it in the bar
+            # (kk picks run with the bar hidden, so make sure it's visible)
+            text = Text()
+            text.append(f"$ {cmd}\n", style="bold")
+            text.append(Text.from_ansi(out) if out else Text("(no output)", style="dim italic"))
+            if code != 0:
+                text.append(f"\nexit {code}", style="bold red")
+            self._set_cmd_output(text)
+            self._q("#cmd-bar", CommandBar).add_class("visible")
+        self.refresh()  # full repaint clears any terminal residue
+        self.refresh_data()
+
+    def _set_cmd_output(self, text: Text) -> None:
+        self._q("#cmd-output", Static).update(text)
+
+    # ── Actions ──────────────────────────────────────────────────────────────
+
+    def action_refresh(self) -> None:
+        self.refresh_data()
+        if self._page_id == "page-system":
+            self.refresh_system()
+
+    def action_next_theme(self) -> None:
+        self._theme_idx = (self._theme_idx + 1) % len(THEME_NAMES)
+        self.theme = THEME_NAMES[self._theme_idx]
+        self._update_subtitle()
+
+    def action_open_link(self, url: str) -> None:
+        self.open_url(url)
+
+
+if __name__ == "__main__":
+    ensure_server()
+    DashboardApp().run()