diff --git a/dashboard-spec.md b/dashboard-spec.md
new file mode 100644
index 0000000..86edae7
--- /dev/null
+++ b/dashboard-spec.md
@@ -0,0 +1,271 @@
+# Pi Dashboard — Project Spec
+
+## Goal
+
+An always-on Raspberry Pi Zero W plugged into a TV, driving a rotating
+family dashboard at the calm, steady cadence of The Weather Channel's
+"Local on the 8s." Weather is the first slide; more will follow over time.
+
+## Hardware Target
+
+- **Raspberry Pi Zero W** (single-core ARM1176 @ 1GHz, 512 MB RAM)
+- HDMI output to a TV
+- Running **Raspberry Pi OS Lite 32-bit** (Bookworm) — no desktop environment
+- Co-located with **Pi-hole** (~60-80 MB RAM, negligible CPU)
+- Always on, boots unattended
+
+## Architecture
+
+### Display: Pillow → `/dev/fb0` via mmap
+
+No window manager, no browser, no X11. Render each slide as a
+`PIL.Image`, then blit it directly to the Linux framebuffer.
+
+```
+┌─────────────────────────────────────────────────┐
+│ main loop │
+│ │
+│ for each slide in rotation: │
+│ data = slide.fetch() # network I/O │
+│ image = slide.render(w, h, theme) │
+│ fb.write(image) # mmap blit │
+│ sleep(interval) │
+└─────────────────────────────────────────────────┘
+```
+
+Each frame: ~200-500 ms Pillow render + ~50 ms framebuffer write.
+Refresh interval: 10-30 seconds. CPU is idle >95% of the time.
+
+### Dual output (optional, for development)
+
+- **Framebuffer** (`/dev/fb0`): primary output to the TV
+- **Curses** (terminal): secondary output for SSH debugging —
+ renders the same data as coloured text, adapts to terminal size
+
+The curses path is not required for v1 but the data/render separation
+makes it easy to add later.
+
+### File structure
+
+```
+dashboard/
+├── main.py # entry point: slide loop, timing, signal handling
+├── framebuffer.py # open /dev/fb0, mmap, query size, blit PIL.Image
+├── render.py # shared drawing helpers (header bar, separator,
+│ # centered text, detail columns, etc.)
+├── theme.py # colour palettes (WeatherStar blue/gold is the
+│ # first; more can be added later)
+├── config.py # settings: API keys, slide order, intervals,
+│ # location, units (F/C), theme selection
+├── slides/
+│ ├── __init__.py
+│ ├── base.py # Slide base class / protocol
+│ ├── weather.py # current conditions + forecast (first slide)
+│ └── clock.py # large clock display (simple second slide)
+└── fonts/ # TTF fonts bundled for consistent rendering
+ └── (a small open-licensed font, e.g. DejaVu Sans Mono or Inter)
+```
+
+## Slide contract
+
+Each slide is a module that implements:
+
+```python
+def fetch(config) -> dict:
+ """Fetch data from network/system. Called before render.
+ May be skipped if cached data is fresh enough."""
+
+def render(data: dict, width: int, height: int, theme: Theme) -> PIL.Image:
+ """Return a PIL.Image sized to the framebuffer dimensions.
+ Must not do network I/O — all data comes from fetch()."""
+```
+
+Separating fetch from render means:
+- Network errors don't block display (show stale data with a staleness indicator)
+- Slides are testable without a framebuffer (render returns an Image you can .save())
+- New slides are added by creating a new file in `slides/` and adding it to config
+
+## Framebuffer interface (`framebuffer.py`)
+
+```python
+class Framebuffer:
+ def __init__(self, device="/dev/fb0"):
+ # open device, ioctl FBIOGET_VSCREENINFO for width/height/bpp
+ # mmap the device for direct pixel writes
+
+ @property
+ def size(self) -> tuple[int, int]:
+ """(width, height) in pixels, queried from hardware."""
+
+ def write(self, image: PIL.Image):
+ """Blit a PIL.Image to the screen.
+ Handles RGB→BGR conversion (Pi framebuffer is BGR).
+ Resizes image if it doesn't match framebuffer dimensions."""
+
+ def close(self):
+ """Unmap and close."""
+```
+
+### Pi-specific notes
+
+- Pixel format is typically XRGB8888 (32-bit) or RGB565 (16-bit) —
+ detect via `ioctl`, don't hardcode
+- Pi framebuffer byte order is **BGR**, not RGB —
+ use `image.tobytes("raw", "BGRA")` for 32-bit
+- Set `hdmi_force_hotplug=1` in `/boot/firmware/config.txt`
+ so `/dev/fb0` exists even if the TV is off at boot
+- User must be in the `video` group (default `pi` user already is)
+
+## Theme system (`theme.py`)
+
+```python
+@dataclass
+class Theme:
+ bg_top: tuple[int, int, int] # background gradient top
+ bg_bottom: tuple[int, int, int] # background gradient bottom
+ header_bg: tuple[int, int, int]
+ accent: tuple[int, int, int] # gold bars, separators
+ text: tuple[int, int, int] # primary text (white)
+ text_dim: tuple[int, int, int] # secondary text (light gray)
+ info_label: tuple[int, int, int] # cyan labels
+ temp_hi: tuple[int, int, int]
+ temp_lo: tuple[int, int, int]
+
+WEATHERSTAR = Theme(
+ bg_top=(20, 40, 120), bg_bottom=(5, 15, 60),
+ header_bg=(40, 70, 170), accent=(255, 200, 50),
+ text=(255, 255, 255), text_dim=(180, 190, 210),
+ info_label=(100, 220, 255),
+ temp_hi=(255, 100, 80), temp_lo=(100, 180, 255),
+)
+```
+
+## Weather slide (`slides/weather.py`)
+
+### Data source
+
+Any free weather API that serves JSON. Candidates:
+- **Open-Meteo** (free, no API key, has hourly + daily forecasts)
+- **OpenWeatherMap** (free tier, requires API key)
+- **National Weather Service API** (free, US only, no key)
+
+`fetch()` returns a dict:
+
+```python
+{
+ "location": "San Francisco, CA",
+ "temp": 62,
+ "condition": "Partly Cloudy",
+ "humidity": 72,
+ "wind": "W 12 mph",
+ "barometer": "30.12 in",
+ "dewpoint": 54,
+ "visibility": "10 mi",
+ "uv_index": "3 Moderate",
+ "forecast": [
+ {"day": "SAT", "hi": 68, "lo": 52, "condition": "Sunny"},
+ {"day": "SUN", "hi": 65, "lo": 50, "condition": "Cloudy"},
+ ...
+ ],
+ "fetched_at": "2026-02-23T14:30:00",
+}
+```
+
+### Layout
+
+Adapts to framebuffer resolution. The WeatherStar layout from the
+C prototype is the reference design:
+
+```
+┌─────────────────────────────────────────────┐
+│ ▬▬▬▬▬▬▬▬ gold accent bar ▬▬▬▬▬▬▬▬▬▬▬▬▬▬ │
+│ FAMILY DASHBOARD │
+│ ═══════════════════════════════════════════ │
+│ LOCAL FORECAST │
+│ ─────────────────────────────────────────── │
+│ San Francisco, CA │
+│ ┌───────────────────────────────────────┐ │
+│ │ Current Conditions │ │
+│ │ ☀☁ 62°F Partly Cloudy │ │
+│ │ Humidity: 72% Dewpoint: 54°F │ │
+│ │ Wind: W 12 mph Visibility: 10 mi │ │
+│ └───────────────────────────────────────┘ │
+│ ═══════════════════════════════════════════ │
+│ ┌─────┬─────┬─────┬─────┬─────┐ │
+│ │ SAT │ SUN │ MON │ TUE │ WED │ │
+│ │Hi 68│Hi 65│Hi 58│Hi 61│Hi 70│ │
+│ │Lo 52│Lo 50│Lo 48│Lo 49│Lo 54│ │
+│ └─────┴─────┴─────┴─────┴─────┘ │
+│ Saturday Feb 21, 2026 10:25 PM │
+└─────────────────────────────────────────────┘
+```
+
+## Main loop (`main.py`)
+
+```
+1. open framebuffer
+2. load config (slide list, intervals, API keys, theme)
+3. loop forever:
+ for slide_module in config.slides:
+ data = slide_module.fetch(config) # with cache / error handling
+ image = slide_module.render(data, fb.width, fb.height, theme)
+ fb.write(image)
+ sleep(config.interval) # 10-30 seconds
+```
+
+### Error handling
+
+- Network failures: show last cached data with a "last updated" timestamp
+- Framebuffer errors: log and retry
+- Unhandled exceptions in a slide: skip it, log, continue to next slide
+- SIGTERM/SIGINT: clean shutdown (unmap framebuffer, clear screen)
+
+## Dependencies
+
+```
+# requirements.txt
+pillow # image rendering — pre-installed on RPi OS
+requests # HTTP for weather API (or use stdlib urllib)
+```
+
+No compiled extensions. No C libraries beyond what ships with RPi OS.
+
+## Deployment
+
+- Clone to the Pi
+- `pip install -r requirements.txt` (likely already satisfied)
+- Auto-start via systemd unit:
+
+```ini
+[Unit]
+Description=Family Dashboard
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+ExecStart=/usr/bin/python3 /home/pi/dashboard/main.py
+Restart=always
+RestartSec=5
+User=pi
+Group=video
+
+[Install]
+WantedBy=multi-user.target
+```
+
+## Non-goals for v1
+
+- No touch input / interactivity
+- No web interface for configuration (edit config.py directly)
+- No animations or transitions between slides (instant swap)
+- No remote management beyond SSH
+
+## Future slide ideas
+
+- **Clock** — large readable time/date display
+- **Calendar** — upcoming events from an ICS feed
+- **Transit** — next bus/train departure times
+- **Photo** — rotating family photos from a directory
+- **Chores / reminders** — simple text list
+- **Air quality / pollen** — additional weather data
+- **Pi-hole stats** — queries blocked today (since it's right there)
diff --git a/misc/task-timer.html b/misc/task-timer.html
new file mode 100644
index 0000000..214441e
--- /dev/null
+++ b/misc/task-timer.html
@@ -0,0 +1,339 @@
+
+
+
+
+
+ 15-Minute Task Triage Timer
+
+
+
+
+
15-Minute Task Triage
+
+
Ready to start
+
+
15:00
+
+
+
+
+
+
+
+
Pick your next task, then click below to start triage:
+
+
+
+
+
+
Has external deadline or consequence?
+
+
+
+
+
+
Is deadline within 48 hours?
+
+
+
+
+
+
Set the real deadline, then:
+
+
+
+
+
Can someone else do it?
+
+
+
+
+
+
Takes less than 5 minutes AND you want to do it now?
+
+
+
+
+
+
Has this been rescheduled more than 2 times?
+
+
+
+
+
+
Is this routine/recurring?
+
+
+
+
+
+
+
+
+
+
diff --git a/prompts-library/README.md b/prompts-library/README.md
index a203f3a..73636dd 100644
--- a/prompts-library/README.md
+++ b/prompts-library/README.md
@@ -4,3 +4,8 @@ Useful prompts I'd like to share.
- [learn-interactive-general.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/learn-interactive-general.md): Short, general interactive learning prompt with step-by-step guidance and checks for understanding.
- [learn-interactive-general-variation-longer.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/learn-interactive-general-variation-longer.md): Longer variation with explicit teaching rules, practical examples, checkpoints, and exercises.
+
+**Coding**
+
+- [CODER-interactive.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/coding/roles/CODER-interactive.md): Interactive coding role prompt focused on test-driven developemnt, frequent commits, and small implementation steps.
+- [DOC-REVIEW-COACH.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/coding/roles/work/DOC-REVIEW-COACH.md) and [SPEC-ANALYSIS-COACH.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/coding/roles/work/SPEC-ANALYSIS-COACH.md) for reviewing code-related documents
diff --git a/prompts-library/coding/roles/CODER.md b/prompts-library/coding/roles/CODER.md
new file mode 100644
index 0000000..5b47fa2
--- /dev/null
+++ b/prompts-library/coding/roles/CODER.md
@@ -0,0 +1,35 @@
+# Role: CODER
+You are implementing an engineering task.
+This is a coding + verification task.
+
+# Implementation Prompt
+Goal: implement the next step, prove it with tests, and get user approval. Then repeat until all steps are completed.
+
+## Runtime parameter
+`EXECUTION_MODE`:
+- `interactive` (default): stop after each step and wait for user approval.
+- `autopilot`: continue step-by-step without pausing; only stop when scope is done, blocked, or risk changes materially.
+
+## Hard constraints
+- One step at a time; stop when that step is done.
+- Prefer minimal diffs; do not change unrelated files.
+- Avoid formatting-only changes unless required for the step.
+- If blocked by ambiguity, ask questions.
+- Stop and ask before proceeding if risk changes materially (for example: schema/data migration impact, auth/security/privacy behavior, public API contract changes, or production reliability risk).
+- Tests should be high-signal: validate behavior/risk, not implementation details; prefer fewer robust tests over brittle change-detectors.
+- If an existing test is low-value, redundant, or a change-detector, improve it or remove it in the same step.
+
+## Required loop (per step)
+1) Propose: 1–3 bullets (what you’ll do; target files/tests; risks).
+2) RED: add failing test(s) for essential/non-trivial logic; run the smallest relevant test command; confirm failure. Commit.
+3) GREEN: implement the smallest change that makes tests pass; re-run tests; confirm success. Commit.
+4) REFACTOR (optional): only if it reduces complexity/risk; prove tests still pass. Commit.
+5) LOOP REVIEW (brief): review the RED/GREEN/REFACTOR commits for side effects, test quality, and obvious simplifications; keep it shallow.
+ - If issues are found, fix immediately and re-run minimal relevant tests.
+
+## End-of-scope gate (before checking with user)
+When all scoped steps are complete, run a full branch review before asking for user input:
+1) Review the cumulative diff and commit series for correctness, regressions, and maintainability.
+2) Re-check test quality (remove/replace weak tests; keep only high-signal coverage).
+3) Run an appropriate final verification pass and report results + residual risk.
+4) Then present summary, findings/fixes, and any open questions to user.
diff --git a/prompts-library/coding/roles/wip/ARCHITECT.md b/prompts-library/coding/roles/wip/ARCHITECT.md
new file mode 100644
index 0000000..b55ffdf
--- /dev/null
+++ b/prompts-library/coding/roles/wip/ARCHITECT.md
@@ -0,0 +1,36 @@
+# Role: ARCHITECT
+You are decomposing a user-provided *Work Packet* into a repo-aware execution plan.
+This is a reasoning + repo-reading task. It is NOT a coding task.
+This work emerges from an agile user-focused startup where requirements may be vague or subject to change.
+
+# Decomposition Prompt
+Goal: turn a Work Packet + target repo into `coder_plan.md`: a set of concrete assertions (what files/symbols matter) and numbered, CODER-ready steps.
+
+Suggest changes, then if approved, write `coder_plan.md` and commit it. This may be an incremental loop.
+
+## Inputs
+- The Work Packet (requirements, constraints, acceptance criteria, plan).
+- The repo state on disk (read files; map likely touch points).
+
+## What you produce (`coder_plan.md`)
+- A short restatement of the goal + key constraints/non-goals.
+- Repo map: the specific files/symbols that likely matter (and why).
+- Tests: the most relevant test command(s) to run first, and what “green” means.
+- A numbered step list sized for CODER:
+ - Each step is a single, incremental change that CODER can implement with its normal RED→GREEN→(optional)REFACTOR loop.
+ - Each step should name the target files/symbols and the test focus (what to assert, not how to code it).
+ - Keep steps small enough to fit clean, reviewable commits.
+ - Prefer `1. [ ] ...` so CODER/CODER-batch can check off progress.
+- Open questions / risks that block accurate slicing.
+
+## Defaults (unless the Work Packet overrides)
+- Write `coder_plan.md` next to the Work Packet; if that’s ambiguous, ask where to place it.
+- Prefer 5–15 steps.
+- Prefer steps that introduce/adjust behavior behind stable boundaries (APIs, adapters) and keep blast radius contained.
+
+## Interactive loop
+- Ask only high-leverage questions needed to slice work into safe increments.
+- If you find a mismatch between Work Packet and repo reality, call it out and propose the least risky resolution.
+
+## Stopping condition
+Stop after `coder_plan.md` is approved, written, and committed.
diff --git a/prompts-library/coding/roles/wip/CODER-batch.md b/prompts-library/coding/roles/wip/CODER-batch.md
new file mode 100644
index 0000000..80122f5
--- /dev/null
+++ b/prompts-library/coding/roles/wip/CODER-batch.md
@@ -0,0 +1,29 @@
+# Role: CODER
+You are implementing an engineering task from a user-provided *Work Packet*.
+This is a coding + verification task.
+This work emerges from an agile user-focused startup where requirements may be vague or subject to change.
+
+# Implementation Prompt
+Goal: implement `coder_plan.md` steps, prove each with tests, refactor if needed. Continue until all planned steps are complete.
+
+## Source of truth
+- Read the Work Packet the user points you at.
+- Read `coder_plan.md` (generated by ARCHITECT).
+- Implement ONLY the steps in `coder_plan.md`, in order, treating them as confirmed.
+
+## Hard constraints
+- Prefer minimal diffs; do not change unrelated files.
+- Avoid formatting-only changes unless required for the step.
+- If blocked by ambiguity, ask questions.
+
+## Required loop (per step)
+1) Output: 1–3 bullets (what you’ll do; target files/tests; risks). Then proceed to RED.
+2) RED: add failing test(s) for essential/non-trivial logic; run the smallest relevant test command; confirm failure. Commit.
+3) GREEN: implement the smallest change that makes tests pass; re-run tests; confirm success. Commit.
+4) REFACTOR (optional): only if it reduces complexity/risk; prove tests still pass. Commit.
+
+After completing a step, check it off in `coder_plan.md` (e.g. `1. [x] ...`).
+
+You can make commits on your branch without pausing for user approval. You may move onto other steps of your task without approval.
+
+Never change other branches. Never push changes to remotes.
diff --git a/prompts-library/coding/roles/wip/CODER.md b/prompts-library/coding/roles/wip/CODER.md
new file mode 100644
index 0000000..a5e5844
--- /dev/null
+++ b/prompts-library/coding/roles/wip/CODER.md
@@ -0,0 +1,26 @@
+# Role: CODER
+You are implementing an engineering task from a user-provided *Work Packet*.
+This is a coding + verification task.
+This work emerges from an agile user-focused startup where requirements may be vague or subject to change.
+
+# Implementation Prompt
+Goal: implement the next step, prove it with tests, and get user approval. Then repeat until all steps are completed.
+
+## Source of truth
+- Read the Work Packet the user points you at.
+- If present, read `coder_plan.md` (generated by ARCHITECT) and use it to drive step selection.
+- Implement ONLY the next step that the user confirms (typically the next unchecked step in `coder_plan.md`).
+
+## Hard constraints
+- One step at a time; stop when that step is done.
+- Prefer minimal diffs; do not change unrelated files.
+- Avoid formatting-only changes unless required for the step.
+- If blocked by ambiguity, ask questions.
+
+## Required loop (per step)
+1) Propose: 1–3 bullets (what you’ll do; target files/tests; risks).
+2) RED: add failing test(s) for essential/non-trivial logic; run the smallest relevant test command; confirm failure. Commit.
+3) GREEN: implement the smallest change that makes tests pass; re-run tests; confirm success. Commit.
+4) REFACTOR (optional): only if it reduces complexity/risk; prove tests still pass. Commit.
+
+If using `coder_plan.md`, check off the step you completed.
diff --git a/prompts-library/coding/roles/wip/FINDER.md b/prompts-library/coding/roles/wip/FINDER.md
new file mode 100644
index 0000000..3326b90
--- /dev/null
+++ b/prompts-library/coding/roles/wip/FINDER.md
@@ -0,0 +1,25 @@
+# Role: FINDER
+You are investigating a product/engineering question across systems.
+This is a research + synthesis task.
+
+## Investigation Prompt
+Goal: answer the question with evidence, reconcile cross-repo/domain differences, and clearly state confidence and unknowns. The sources to use will be listed in the prompt.
+
+## Hard constraints
+- Evidence first: every substantive claim must point to source evidence.
+- Prefer primary sources (code, schema, migrations, API contracts, runbooks) over opinion/docs.
+- Distinguish facts from inference explicitly.
+- If terms differ by repo/system, identify and reconcile them before final conclusions.
+- Keep scope bounded; if new high-risk ambiguity appears, stop and ask.
+- Do not propose implementation changes unless asked; this role is for understanding.
+
+----
+
+# WIP / revisit later
+- use when the question is cross-repo/system behavior, not single-repo text search
+- require evidence-backed output with confidence and explicit unknowns
+- invocation pattern to keep context small:
+ - source manifest in prompt (repo/doc path + priority + expected relevance)
+ - two-pass investigation: map first, then deepen into only top files
+ - budget knobs: `MAX_FILES_PER_REPO`, `MAX_TOTAL_FILES`, `MAX_OPEN_QUESTIONS`
+ - output compact trace + evidence table (claims must cite source)
diff --git a/prompts-library/coding/roles/wip/PLANNER.md b/prompts-library/coding/roles/wip/PLANNER.md
new file mode 100644
index 0000000..a7f1af0
--- /dev/null
+++ b/prompts-library/coding/roles/wip/PLANNER.md
@@ -0,0 +1,15 @@
+# Role: PLANNER
+You are refining a *Work Packet* for an engineering task from user-provided inputs.
+This is a reasoning + synthesis task. It is NOT a coding task.
+This work emerges from an agile user-focused startup where requirements may be vague or subject to change.
+
+# Refinement Interview Prompt
+Goal: revise task into a refined work packet, implementation plan, testable acceptance criteria, and decisions.
+
+Suggest changes, then if approved, modify the work packet (add/change files) and commit changes. This may be an incremental loop.
+
+If the Work Packet plan is still too high-level to execute safely, propose running ARCHITECT to generate `coder_plan.md`.
+
+You may ask questions until there is enough context.
+You may challenge assumptions and identify vagueness.
+You may call out things that have not been considered.
diff --git a/prompts-library/coding/roles/work/DOC-REVIEW-COACH.md b/prompts-library/coding/roles/work/DOC-REVIEW-COACH.md
new file mode 100644
index 0000000..cba6ac1
--- /dev/null
+++ b/prompts-library/coding/roles/work/DOC-REVIEW-COACH.md
@@ -0,0 +1,21 @@
+You are my “Spec-Intuition Coach.” I want to understand this doc and leave useful comments, nothing more.
+
+Spec excerpt(s): [PASTE]
+My current understanding (rough): [BULLETS]
+My relevant experience: [BULLETS]
+Time box: 15–20 min
+
+Rules:
+- Ask up to 3 questions, then wait.
+- Teach me interactively: step by step, ask questions to check my understanding.
+- Correct misconceptions; introduce concepts only when needed.
+- Focus on mental models and reasoning, not just explanations.
+- Treat this as a collaborative lesson, not a lecture.
+- Use falsification checks to challenge my assumptions.
+- Move fast: if unclear, request a quick spike or propose a minimal default.
+- Capture only design-impact risks (cost/state/UX/concurrency/compliance).
+- No long analysis; minimal prose.
+
+Output only:
+1) 5–10 targeted comments/questions I can paste into the doc
+2) 3 “watchouts” (if any) that could change design later
diff --git a/prompts-library/coding/roles/work/SPEC-ANALYSIS-COACH.md b/prompts-library/coding/roles/work/SPEC-ANALYSIS-COACH.md
new file mode 100644
index 0000000..fa16268
--- /dev/null
+++ b/prompts-library/coding/roles/work/SPEC-ANALYSIS-COACH.md
@@ -0,0 +1,27 @@
+You are my “Spec-Intuition Coach.” I want to turn this spec into a concrete, safe action plan.
+
+Spec excerpt(s): [PASTE]
+My current understanding (rough): [BULLETS]
+My relevant experience: [BULLETS]
+Constraints/non-goals (if known): [BULLETS or "unknown"]
+Time box: 20–30 min
+
+Rules:
+- Ask up to 3 questions, then wait.
+- Teach me interactively: step by step, ask questions to check my understanding.
+- Correct misconceptions; introduce concepts only when needed.
+- Focus on mental models and reasoning, not just explanations.
+- Treat this as a collaborative lesson, not a lecture.
+- Challenge my understanding with “what would disprove this?”
+- Move fast: if unclear, request a quick spike or propose a minimal default.
+- Capture only design-impact risks.
+- Minimal prose.
+
+Finish with a Work Packet:
+- Outcome (1–3 bullets)
+- Non-goals
+- Decisions needed (with defaults)
+- Interfaces (API/data/worker)
+- Invariants
+- First vertical slice
+- Next commit (<1h)
diff --git a/weatherstar/.gitignore b/weatherstar/.gitignore
new file mode 100644
index 0000000..c634997
--- /dev/null
+++ b/weatherstar/.gitignore
@@ -0,0 +1 @@
+weatherstar
diff --git a/weatherstar/Makefile b/weatherstar/Makefile
new file mode 100644
index 0000000..903d4be
--- /dev/null
+++ b/weatherstar/Makefile
@@ -0,0 +1,18 @@
+CC ?= gcc
+CFLAGS ?= -O2 -Wall -Wextra
+LDFLAGS = -lpng -lz -lm
+TARGET = weatherstar
+HEADERS = fb.h font.h icons.h display.h screenshot.h
+
+all: $(TARGET)
+
+$(TARGET): main.c $(HEADERS)
+ $(CC) $(CFLAGS) -o $@ main.c $(LDFLAGS)
+
+screenshot: $(TARGET)
+ ./$(TARGET) --no-ansi --screenshot screenshot.png
+
+clean:
+ rm -f $(TARGET) screenshot.png
+
+.PHONY: all clean screenshot
diff --git a/weatherstar/display.h b/weatherstar/display.h
new file mode 100644
index 0000000..5626cde
--- /dev/null
+++ b/weatherstar/display.h
@@ -0,0 +1,293 @@
+/*
+ * display.h — WeatherStar 4000 screen layout composition
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * OVERVIEW
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * This file contains the single function that paints every element
+ * of the classic "Local on the 8s" display onto the framebuffer.
+ * Think of it as the "scene graph" — calling compose_display() fills
+ * the framebuffer from top to bottom with:
+ *
+ * ┌─────────────────────────────────────────────┐
+ * │ ████████ gold accent bar (4px) ████████ │ y = 0..3
+ * │ THE WEATHER CHANNEL │ y = 4..39
+ * │ ════════ gold separator ════════ │ y = 40..41
+ * │ LOCAL FORECAST │ y = 42..71
+ * │ ──────── rule line ──────── │ y = 73
+ * │ San Francisco, CA │ y = 80
+ * │ ┌──────────────────────────────────────┐ │
+ * │ │ Current Conditions │ │ y = 104..243
+ * │ │ ☀ ☁ 62°F Partly Cloudy │ │
+ * │ │ Humidity: 72% Dewpoint: 54 F │ │
+ * │ │ Wind: W 12 mph Visibility: 10 mi │ │
+ * │ │ Barometer: 30.12 UV Index: 3 Mod │ │
+ * │ └──────────────────────────────────────┘ │
+ * │ ════════ gold separator ════════ │ y = 252
+ * │ EXTENDED FORECAST │
+ * │ ┌─────┬─────┬─────┬─────┬─────┐ │
+ * │ │ SAT │ SUN │ MON │ TUE │ WED │ │ y = 278..372
+ * │ └─────┴─────┴─────┴─────┴─────┘ │
+ * │ ════════ gold separator ════════ │ y = 370
+ * │ Saturday Feb 07 Local on 8s 10:25 PM │ y = 370..399
+ * └─────────────────────────────────────────────┘
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * COORDINATE SYSTEM
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * All positions are in absolute pixel coordinates (origin = top-left).
+ * The layout is hardcoded for a 640×400 framebuffer. If you want to
+ * change the resolution, you'd need to adjust these coordinates — there
+ * is no relative/responsive layout engine here, and for a nostalgic
+ * recreation of fixed-resolution hardware, that's by design.
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * WEATHER DATA
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * The weather data is hardcoded (San Francisco, 62°F, etc.). In a
+ * real application you'd feed this from an API or config file, but
+ * for this demo the focus is on faithful visual reproduction of the
+ * WeatherStar 4000 aesthetic.
+ */
+
+#ifndef DISPLAY_H
+#define DISPLAY_H
+
+#include "fb.h"
+#include "font.h"
+#include "icons.h"
+#include
+#include
+
+static void compose_display(void) {
+ /*
+ * ── time and date strings ────────────────────────────────────
+ * We format the current local time for the bottom status bar.
+ * strftime %I gives 12-hour with leading zero, so "08:30 PM".
+ * We strip the leading zero for the classic WeatherStar look
+ * ("8:30 PM" not "08:30 PM").
+ */
+ time_t now = time(NULL);
+ struct tm *lt = localtime(&now);
+ char timebuf[64], datebuf[64];
+ strftime(timebuf, sizeof timebuf, "%I:%M %p", lt);
+ strftime(datebuf, sizeof datebuf, "%A %b %d, %Y", lt);
+ const char *timestr = (timebuf[0] == '0') ? timebuf + 1 : timebuf;
+
+ /*
+ * ── background gradient ──────────────────────────────────────
+ * The signature WeatherStar look: a deep blue that gets slightly
+ * darker toward the bottom of the screen.
+ */
+ fb_clear();
+
+ /*
+ * ── top gold accent bar ──────────────────────────────────────
+ * A 4-pixel-tall gold strip across the very top of the screen.
+ * This is the first visual cue that you're watching The Weather
+ * Channel. The gold colour was a TWC brand signature.
+ */
+ fb_rect(0, 0, FB_W, 4, COL_GOLD);
+
+ /*
+ * ── "THE WEATHER CHANNEL" header ─────────────────────────────
+ * A gradient-filled banner from y=4 to y=39. The gradient goes
+ * from a lighter blue (COL_HEADER) to a slightly darker blue,
+ * giving the bar a sense of depth. The text is rendered at
+ * scale=2 (10×14 pixel characters) and centred horizontally.
+ *
+ * The double spaces ("THE WEATHER CHANNEL") add extra letter
+ * spacing for the formal, broadcast-TV feel.
+ */
+ fb_rect_grad_v(0, 4, FB_W, 36, COL_HEADER, (rgb_t){30, 55, 140});
+ fb_string_centered(10, "THE WEATHER CHANNEL", COL_WHITE, 2);
+
+ /*
+ * ── gold separator (2px thick) ───────────────────────────────
+ * Gold horizontal rules are used throughout the WeatherStar UI
+ * to divide sections. Two adjacent 1px lines give a 2px rule.
+ */
+ fb_hline(0, FB_W - 1, 40, COL_GOLD);
+ fb_hline(0, FB_W - 1, 41, COL_GOLD);
+
+ /*
+ * ── "LOCAL FORECAST" sub-header ──────────────────────────────
+ * Another gradient bar, slightly darker than the main header.
+ * The cyan text distinguishes it from the white title above.
+ */
+ fb_rect_grad_v(0, 42, FB_W, 30, (rgb_t){30, 50, 140},
+ (rgb_t){20, 35, 110});
+ fb_string_centered(48, "LOCAL FORECAST", COL_CYAN, 2);
+
+ /* thin blue rule to separate the location bar from content */
+ fb_hline(20, FB_W - 21, 73, COL_SEP);
+
+ /*
+ * ── location name ────────────────────────────────────────────
+ * Centred, white, scale=2. On the real WeatherStar this would
+ * be pulled from your cable system's location configuration.
+ */
+ fb_string_centered(80, "San Francisco, CA", COL_WHITE, 2);
+
+ /*
+ * ── current conditions card ──────────────────────────────────
+ * A rounded rectangle that acts as a "card" containing the main
+ * weather data. The rounded corners (radius=6) add a bit of
+ * polish over a plain rectangle.
+ *
+ * Inside the card:
+ * - "Current Conditions" subheading in accent blue
+ * - A sun + cloud icon on the left (the "partly cloudy" icon)
+ * - Large temperature text (62°F) — scale=5 for the digits
+ * - Condition description ("Partly Cloudy") in light gray
+ * - Two columns of detail readings below
+ */
+ int card_y = 104;
+ draw_rounded_rect(20, card_y, FB_W - 40, 140, 6, (rgb_t){15, 25, 90});
+
+ /* top edge highlight — a 1px lighter line simulating a bevel */
+ fb_hline(22, FB_W - 23, card_y + 1, COL_SEP);
+
+ fb_string(36, card_y + 8, "Current Conditions", COL_ACCENT, 1);
+ fb_hline(36, FB_W - 57, card_y + 20, (rgb_t){40, 60, 130});
+
+ /*
+ * Weather icon: a sun partially behind a cloud.
+ * The sun is drawn first (at 90, card_y+55), then the cloud is
+ * drawn on top and slightly to the right (at 110, card_y+60),
+ * naturally overlapping the sun to create "partly cloudy".
+ */
+ draw_sun(90, card_y + 55, 18);
+ draw_cloud(110, card_y + 60, COL_LTGRAY);
+
+ /*
+ * Large temperature display. The "62" is rendered at scale=5
+ * (25×35 pixel characters). The degree symbol is rendered
+ * separately at scale=3 because it needs to sit as a superscript
+ * near the top of the digits. The "F" is at scale=4.
+ *
+ * The x-position arithmetic:
+ * "62" = 2 chars × 6px × scale5 = 60px wide
+ * degree symbol starts at 180 + 60 = 240
+ * "F" starts at 240 + 12 (degree width) = 252
+ */
+ fb_string(180, card_y + 30, "62", COL_WHITE, 5);
+ fb_degree(180 + 5 * 6 * 2, card_y + 30, COL_WHITE, 3);
+ fb_string(180 + 5 * 6 * 2 + 12, card_y + 30, "F", COL_WHITE, 4);
+
+ fb_string(180, card_y + 75, "Partly Cloudy", COL_LTGRAY, 2);
+
+ /*
+ * Detail readings in two columns. Labels are cyan, values are
+ * white. The columns are aligned by giving labels and values
+ * fixed x positions (val_x = lbl_x + 11 chars × 6px).
+ */
+ int lbl_x = 36, val_x = 36 + 11 * 6;
+ fb_string(lbl_x, card_y + 100, "Humidity:", COL_CYAN, 1);
+ fb_string(val_x, card_y + 100, "72%", COL_WHITE, 1);
+ fb_string(lbl_x, card_y + 112, "Wind:", COL_CYAN, 1);
+ fb_string(val_x, card_y + 112, "W 12 mph", COL_WHITE, 1);
+ fb_string(lbl_x, card_y + 124, "Barometer:", COL_CYAN, 1);
+ fb_string(val_x, card_y + 124, "30.12 in", COL_WHITE, 1);
+
+ int rlbl_x = 320, rval_x = 320 + 12 * 6;
+ fb_string(rlbl_x, card_y + 100, "Dewpoint:", COL_CYAN, 1);
+ fb_string(rval_x, card_y + 100, "54 F", COL_WHITE, 1);
+ fb_string(rlbl_x, card_y + 112, "Visibility:", COL_CYAN, 1);
+ fb_string(rval_x, card_y + 112, "10 mi", COL_WHITE, 1);
+ fb_string(rlbl_x, card_y + 124, "UV Index:", COL_CYAN, 1);
+ fb_string(rval_x, card_y + 124, "3 Moderate", COL_GREEN, 1);
+
+ /*
+ * ── forecast strip ───────────────────────────────────────────
+ * Below the current conditions, a gold-bordered section shows
+ * the 5-day extended forecast. Each day gets a fixed-width
+ * box containing the day name, high/low temperatures, and a
+ * short condition description.
+ */
+ int strip_y = 252;
+ fb_hline(20, FB_W - 21, strip_y, COL_GOLD);
+ fb_hline(20, FB_W - 21, strip_y + 1, COL_GOLD);
+ fb_string(30, strip_y + 8, "EXTENDED FORECAST", COL_GOLD, 1);
+ fb_hline(20, FB_W - 21, strip_y + 20, (rgb_t){40, 60, 130});
+
+ /*
+ * Forecast data — five days of hardcoded weather. In a real
+ * system these arrays would be populated from API data.
+ */
+ const char *days[] = { "SAT", "SUN", "MON", "TUE", "WED" };
+ const char *conds[] = { "Sunny","Cloudy", "Rain","P.Cloud", "Sunny" };
+ const int his[] = { 68, 65, 58, 61, 70 };
+ const int los[] = { 52, 50, 48, 49, 54 };
+
+ /*
+ * Layout: 5 boxes × 110px wide with 10px gaps between them,
+ * centred horizontally. Total width = 5×110 + 4×10 = 590px.
+ * Starting x = (640 − 590) / 2 = 25.
+ */
+ int box_w = 110, box_gap = 10;
+ int box_start = (FB_W - (5 * box_w + 4 * box_gap)) / 2;
+
+ for (int i = 0; i < 5; i++) {
+ int bx = box_start + i * (box_w + box_gap);
+ int by = strip_y + 26;
+
+ /* dark blue box background */
+ fb_rect(bx, by, box_w, 95, (rgb_t){15, 25, 90});
+ /* top edge highlight */
+ fb_hline(bx, bx + box_w - 1, by, COL_SEP);
+
+ /* day name, centred within the box */
+ int dw = fb_string_width(days[i], 2);
+ fb_string(bx + (box_w - dw) / 2, by + 4, days[i], COL_WHITE, 2);
+
+ /* high and low temperatures */
+ char buf[32];
+ snprintf(buf, sizeof buf, "Hi %d", his[i]);
+ fb_string(bx + 8, by + 30, buf, COL_TEMPHI, 1);
+ snprintf(buf, sizeof buf, "Lo %d", los[i]);
+ fb_string(bx + 8, by + 44, buf, COL_TEMPLO, 1);
+
+ /* short condition text */
+ fb_string(bx + 8, by + 62, conds[i], COL_LTGRAY, 1);
+
+ /*
+ * Mini weather indicator dot — a small filled circle in the
+ * lower-right of each box. Yellow for sunny/fair days,
+ * cyan for rainy days. A real WeatherStar would show a
+ * small icon here; we use a coloured dot as a compact hint.
+ */
+ rgb_t dot = (i == 2) ? COL_CYAN : COL_YELLOW;
+ for (int dy = -3; dy <= 3; dy++)
+ for (int dx = -3; dx <= 3; dx++)
+ if (dx * dx + dy * dy <= 9) {
+ int px = bx + box_w - 18 + dx;
+ int py = by + 72 + dy;
+ if (px >= 0 && px < FB_W && py >= 0 && py < FB_H)
+ fb[py][px] = dot;
+ }
+ }
+
+ /*
+ * ── bottom status bar ────────────────────────────────────────
+ * The very bottom of the screen shows the current date on the
+ * left, "Local on the 8s" centred (the classic TWC segment name),
+ * and the current time on the right in large text.
+ */
+ int bot_y = FB_H - 30;
+ fb_rect(0, bot_y, FB_W, 30, (rgb_t){10, 15, 55});
+ fb_hline(0, FB_W - 1, bot_y, COL_GOLD);
+ fb_hline(0, FB_W - 1, bot_y + 1, COL_GOLD);
+
+ fb_string(20, bot_y + 10, datebuf, COL_LTGRAY, 1);
+
+ int tw = fb_string_width(timestr, 2);
+ fb_string(FB_W - tw - 20, bot_y + 6, timestr, COL_WHITE, 2);
+
+ fb_string_centered(bot_y + 10, "Local on the 8s", COL_GOLD, 1);
+}
+
+#endif /* DISPLAY_H */
diff --git a/weatherstar/fb.h b/weatherstar/fb.h
new file mode 100644
index 0000000..8fab258
--- /dev/null
+++ b/weatherstar/fb.h
@@ -0,0 +1,203 @@
+/*
+ * fb.h — Framebuffer and primitive drawing
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * OVERVIEW
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * This file defines the pixel framebuffer at the heart of the renderer.
+ * Instead of writing to the screen directly, every drawing operation writes
+ * into a flat 2D array of RGB pixels (fb[][]). Once the full scene is
+ * composed, the framebuffer is read out either as ANSI terminal sequences
+ * or as a PNG file — the drawing code never needs to know which.
+ *
+ * The framebuffer is sized to 640×400 pixels, which matches the original
+ * WeatherStar 4000's NTSC-era resolution.
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * COLOUR MODEL
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * All colours are stored as 24-bit RGB triples (rgb_t). The WeatherStar
+ * 4000 palette is dominated by deep blues, golds, and cyans — a distinctive
+ * look that came from the Amiga-based hardware TWC used in the early '90s.
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * DRAWING PRIMITIVES
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * fb_clear() — Fill the entire framebuffer with a vertical gradient
+ * from COL_GRADTOP to COL_GRADBOT. This produces the
+ * subtle dark-blue gradient visible behind all content.
+ *
+ * fb_rect() — Solid-colour filled rectangle.
+ *
+ * fb_rect_grad_v() — Rectangle with a vertical colour gradient. Used for
+ * the header bar and location bar, where the colour
+ * shifts from a lighter blue at the top to a darker
+ * blue at the bottom.
+ *
+ * fb_hline() — Horizontal line spanning [x0, x1] at row y.
+ *
+ * draw_rounded_rect() — A filled rectangle with rounded corners. Works by
+ * first filling the whole rectangle, then "knocking
+ * out" corner pixels that fall outside the radius
+ * circle, replacing them with the background gradient.
+ */
+
+#ifndef FB_H_INCLUDED
+#define FB_H_INCLUDED
+
+#include
+
+/* ── framebuffer dimensions ─────────────────────────────────────── */
+/*
+ * 640×400 was chosen to approximate the original WeatherStar 4000
+ * output resolution. The CELL_W/CELL_H constants are vestigial
+ * (from a text-cell model) but kept for reference.
+ */
+#define FB_W 640
+#define FB_H 400
+#define CELL_W 8
+#define CELL_H 16
+
+/* ── colour type ────────────────────────────────────────────────── */
+/*
+ * A single pixel: 8 bits per channel, no alpha.
+ * "rgb_t" rather than a library type so we stay dependency-free.
+ */
+typedef struct { uint8_t r, g, b; } rgb_t;
+
+/* ── the framebuffer itself ─────────────────────────────────────── */
+/*
+ * A global 640×400 pixel grid. ~750 KB of static BSS memory.
+ * Every drawing function writes directly into this array.
+ */
+static rgb_t fb[FB_H][FB_W];
+
+/* ── WeatherStar colour palette ─────────────────────────────────── */
+/*
+ * These are tuned to match the WeatherStar 4000's signature look:
+ * deep blue backgrounds, gold accent bars, cyan info text, and
+ * white/light-gray content text.
+ */
+static const rgb_t COL_HEADER = { 40, 70, 170 }; /* header bar fill */
+static const rgb_t COL_ACCENT = { 60, 120, 210 }; /* subheadings */
+static const rgb_t COL_GOLD = { 255, 200, 50 }; /* gold separator bars */
+static const rgb_t COL_WHITE = { 255, 255, 255 };
+static const rgb_t COL_LTGRAY = { 180, 190, 210 };
+static const rgb_t COL_YELLOW = { 255, 255, 100 };
+static const rgb_t COL_CYAN = { 100, 220, 255 }; /* info label text */
+static const rgb_t COL_GREEN = { 80, 220, 120 };
+static const rgb_t COL_ORANGE = { 255, 160, 50 };
+static const rgb_t COL_GRADTOP = { 20, 40, 120 }; /* background gradient */
+static const rgb_t COL_GRADBOT = { 5, 15, 60 }; /* top → bottom */
+static const rgb_t COL_SEP = { 50, 80, 160 }; /* thin rule lines */
+static const rgb_t COL_TEMPHI = { 255, 100, 80 }; /* high temperature */
+static const rgb_t COL_TEMPLO = { 100, 180, 255 }; /* low temperature */
+
+/* ── helper: compute background gradient colour at a given y ──── */
+/*
+ * The background is a vertical gradient. Several routines need to
+ * "erase" pixels back to the gradient (e.g. rounded-rect corners),
+ * so we centralise the interpolation here.
+ */
+static rgb_t fb_bg_at(int y) {
+ float t = (float)y / FB_H;
+ return (rgb_t){
+ (uint8_t)(COL_GRADTOP.r + (COL_GRADBOT.r - COL_GRADTOP.r) * t),
+ (uint8_t)(COL_GRADTOP.g + (COL_GRADBOT.g - COL_GRADTOP.g) * t),
+ (uint8_t)(COL_GRADTOP.b + (COL_GRADBOT.b - COL_GRADTOP.b) * t)
+ };
+}
+
+/* ── fb_clear: fill the framebuffer with the background gradient ── */
+static void fb_clear(void) {
+ for (int y = 0; y < FB_H; y++) {
+ rgb_t c = fb_bg_at(y);
+ for (int x = 0; x < FB_W; x++)
+ fb[y][x] = c;
+ }
+}
+
+/* ── fb_rect: filled solid-colour rectangle ─────────────────────── */
+/*
+ * Clips to framebuffer bounds. Coordinates may be negative
+ * (partially off-screen) without crashing.
+ */
+static void fb_rect(int x0, int y0, int w, int h, rgb_t c) {
+ for (int y = y0; y < y0 + h && y < FB_H; y++)
+ for (int x = x0; x < x0 + w && x < FB_W; x++)
+ if (x >= 0 && y >= 0)
+ fb[y][x] = c;
+}
+
+/* ── fb_rect_grad_v: rectangle with a vertical colour gradient ─── */
+/*
+ * Linearly interpolates between `top` and `bot` colours from the
+ * first row to the last row of the rectangle. Used for the header
+ * and location bars where the colour subtly darkens downward.
+ */
+static void fb_rect_grad_v(int x0, int y0, int w, int h,
+ rgb_t top, rgb_t bot)
+{
+ for (int y = y0; y < y0 + h && y < FB_H; y++) {
+ float t = (h > 1) ? (float)(y - y0) / (h - 1) : 0;
+ rgb_t c = {
+ (uint8_t)(top.r + (bot.r - top.r) * t),
+ (uint8_t)(top.g + (bot.g - top.g) * t),
+ (uint8_t)(top.b + (bot.b - top.b) * t)
+ };
+ for (int x = x0; x < x0 + w && x < FB_W; x++)
+ if (x >= 0 && y >= 0)
+ fb[y][x] = c;
+ }
+}
+
+/* ── fb_hline: horizontal line from x0 to x1 at row y ──────────── */
+static void fb_hline(int x0, int x1, int y, rgb_t c) {
+ if (y < 0 || y >= FB_H) return;
+ for (int x = x0; x <= x1 && x < FB_W; x++)
+ if (x >= 0) fb[y][x] = c;
+}
+
+/* ── draw_rounded_rect: filled rectangle with rounded corners ──── */
+/*
+ * Strategy: fill the entire rectangle with the given colour, then
+ * iterate over the r×r corner regions. For each pixel, compute its
+ * distance from the corner's arc centre. If it falls outside the
+ * radius, overwrite it with the background gradient colour — this
+ * "punches out" the corners to simulate rounding.
+ *
+ * This is a visual approximation (the "erased" pixels assume the
+ * background gradient is showing through), but it works perfectly
+ * for our use case since rounded rects are only drawn over the
+ * gradient background.
+ */
+static void draw_rounded_rect(int x0, int y0, int w, int h,
+ int r, rgb_t fill)
+{
+ fb_rect(x0, y0, w, h, fill);
+
+ int r_sq = r * r; /* compare squared distances — avoids sqrtf */
+ for (int dy = 0; dy < r; dy++)
+ for (int dx = 0; dx < r; dx++) {
+ int dist_sq = (r-dx)*(r-dx) + (r-dy)*(r-dy);
+ if (dist_sq <= r_sq) continue; /* inside arc — keep */
+
+ /* Outside the arc: restore background at all four corners. */
+ int corners[4][2] = {
+ { x0 + dx, y0 + dy }, /* top-left */
+ { x0 + w - 1 - dx, y0 + dy }, /* top-right */
+ { x0 + dx, y0 + h - 1 - dy }, /* bottom-left */
+ { x0 + w - 1 - dx, y0 + h - 1 - dy }, /* bottom-right */
+ };
+ for (int c = 0; c < 4; c++) {
+ int cx = corners[c][0], cy = corners[c][1];
+ if (cx >= 0 && cx < FB_W && cy >= 0 && cy < FB_H)
+ fb[cy][cx] = fb_bg_at(cy);
+ }
+ }
+}
+
+#endif /* FB_H_INCLUDED */
diff --git a/weatherstar/font.h b/weatherstar/font.h
new file mode 100644
index 0000000..b5ad427
--- /dev/null
+++ b/weatherstar/font.h
@@ -0,0 +1,245 @@
+/*
+ * font.h — Built-in 5×7 bitmap font and text rendering
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * THE FONT
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * Each glyph is a 5-pixel-wide, 7-pixel-tall bitmap stored as 7 bytes.
+ * Within each byte, the 5 most-significant bits represent columns
+ * left-to-right:
+ *
+ * bit 4 (0x10) = leftmost column
+ * bit 3 (0x08) = second column
+ * bit 2 (0x04) = centre column
+ * bit 1 (0x02) = fourth column
+ * bit 0 (0x01) = rightmost column
+ *
+ * For example, the letter 'A' (index 65−32 = 33):
+ *
+ * {0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11}
+ *
+ * row 0: 0x0E = 01110 → .XXX.
+ * row 1: 0x11 = 10001 → X...X
+ * row 2: 0x11 = 10001 → X...X
+ * row 3: 0x1F = 11111 → XXXXX
+ * row 4: 0x11 = 10001 → X...X
+ * row 5: 0x11 = 10001 → X...X
+ * row 6: 0x11 = 10001 → X...X
+ *
+ * The font covers printable ASCII (space=32 through tilde=126).
+ * Characters outside this range silently render as a space.
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * TEXT RENDERING
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * fb_char() — Render one glyph at (px, py). The `scale`
+ * parameter integer-scales each pixel, so
+ * scale=2 produces 10×14 characters.
+ *
+ * fb_string() — Walk a C string, rendering each character
+ * with 6×scale pixel advance (5 glyph + 1 gap).
+ *
+ * fb_string_width() — Return the pixel width a string would occupy
+ * at a given scale. Used for centring.
+ *
+ * fb_string_centered()— Render a string horizontally centred on the
+ * framebuffer at a given y coordinate.
+ *
+ * fb_degree() — Render a tiny 3×3 degree symbol (°). This
+ * is separate from the main font because the
+ * degree sign needs to sit at the top of the
+ * line, superscript-style.
+ */
+
+#ifndef FONT_H
+#define FONT_H
+
+#include "fb.h"
+#include
+
+/* ── glyph data: ASCII 32 (' ') through 126 ('~') ──────────────── */
+/*
+ * 95 glyphs × 7 bytes = 665 bytes. This table is const and lives
+ * in the read-only data segment.
+ */
+static const uint8_t font5x7[][7] = {
+ /* 32 ' ' */ {0x00,0x00,0x00,0x00,0x00,0x00,0x00},
+ /* 33 '!' */ {0x04,0x04,0x04,0x04,0x04,0x00,0x04},
+ /* 34 '"' */ {0x0A,0x0A,0x00,0x00,0x00,0x00,0x00},
+ /* 35 '#' */ {0x0A,0x1F,0x0A,0x0A,0x1F,0x0A,0x00},
+ /* 36 '$' */ {0x04,0x0F,0x14,0x0E,0x05,0x1E,0x04},
+ /* 37 '%' */ {0x18,0x19,0x02,0x04,0x08,0x13,0x03},
+ /* 38 '&' */ {0x08,0x14,0x14,0x08,0x15,0x12,0x0D},
+ /* 39 ''' */ {0x04,0x04,0x00,0x00,0x00,0x00,0x00},
+ /* 40 '(' */ {0x02,0x04,0x08,0x08,0x08,0x04,0x02},
+ /* 41 ')' */ {0x08,0x04,0x02,0x02,0x02,0x04,0x08},
+ /* 42 '*' */ {0x00,0x04,0x15,0x0E,0x15,0x04,0x00},
+ /* 43 '+' */ {0x00,0x04,0x04,0x1F,0x04,0x04,0x00},
+ /* 44 ',' */ {0x00,0x00,0x00,0x00,0x00,0x04,0x08},
+ /* 45 '-' */ {0x00,0x00,0x00,0x1F,0x00,0x00,0x00},
+ /* 46 '.' */ {0x00,0x00,0x00,0x00,0x00,0x00,0x04},
+ /* 47 '/' */ {0x01,0x01,0x02,0x04,0x08,0x10,0x10},
+ /* 48 '0' */ {0x0E,0x11,0x13,0x15,0x19,0x11,0x0E},
+ /* 49 '1' */ {0x04,0x0C,0x04,0x04,0x04,0x04,0x0E},
+ /* 50 '2' */ {0x0E,0x11,0x01,0x06,0x08,0x10,0x1F},
+ /* 51 '3' */ {0x0E,0x11,0x01,0x06,0x01,0x11,0x0E},
+ /* 52 '4' */ {0x02,0x06,0x0A,0x12,0x1F,0x02,0x02},
+ /* 53 '5' */ {0x1F,0x10,0x1E,0x01,0x01,0x11,0x0E},
+ /* 54 '6' */ {0x06,0x08,0x10,0x1E,0x11,0x11,0x0E},
+ /* 55 '7' */ {0x1F,0x01,0x02,0x04,0x08,0x08,0x08},
+ /* 56 '8' */ {0x0E,0x11,0x11,0x0E,0x11,0x11,0x0E},
+ /* 57 '9' */ {0x0E,0x11,0x11,0x0F,0x01,0x02,0x0C},
+ /* 58 ':' */ {0x00,0x00,0x04,0x00,0x00,0x04,0x00},
+ /* 59 ';' */ {0x00,0x00,0x04,0x00,0x00,0x04,0x08},
+ /* 60 '<' */ {0x02,0x04,0x08,0x10,0x08,0x04,0x02},
+ /* 61 '=' */ {0x00,0x00,0x1F,0x00,0x1F,0x00,0x00},
+ /* 62 '>' */ {0x08,0x04,0x02,0x01,0x02,0x04,0x08},
+ /* 63 '?' */ {0x0E,0x11,0x01,0x02,0x04,0x00,0x04},
+ /* 64 '@' */ {0x0E,0x11,0x17,0x15,0x17,0x10,0x0E},
+ /* 65 'A' */ {0x0E,0x11,0x11,0x1F,0x11,0x11,0x11},
+ /* 66 'B' */ {0x1E,0x11,0x11,0x1E,0x11,0x11,0x1E},
+ /* 67 'C' */ {0x0E,0x11,0x10,0x10,0x10,0x11,0x0E},
+ /* 68 'D' */ {0x1E,0x11,0x11,0x11,0x11,0x11,0x1E},
+ /* 69 'E' */ {0x1F,0x10,0x10,0x1E,0x10,0x10,0x1F},
+ /* 70 'F' */ {0x1F,0x10,0x10,0x1E,0x10,0x10,0x10},
+ /* 71 'G' */ {0x0E,0x11,0x10,0x17,0x11,0x11,0x0F},
+ /* 72 'H' */ {0x11,0x11,0x11,0x1F,0x11,0x11,0x11},
+ /* 73 'I' */ {0x0E,0x04,0x04,0x04,0x04,0x04,0x0E},
+ /* 74 'J' */ {0x07,0x02,0x02,0x02,0x02,0x12,0x0C},
+ /* 75 'K' */ {0x11,0x12,0x14,0x18,0x14,0x12,0x11},
+ /* 76 'L' */ {0x10,0x10,0x10,0x10,0x10,0x10,0x1F},
+ /* 77 'M' */ {0x11,0x1B,0x15,0x15,0x11,0x11,0x11},
+ /* 78 'N' */ {0x11,0x19,0x15,0x13,0x11,0x11,0x11},
+ /* 79 'O' */ {0x0E,0x11,0x11,0x11,0x11,0x11,0x0E},
+ /* 80 'P' */ {0x1E,0x11,0x11,0x1E,0x10,0x10,0x10},
+ /* 81 'Q' */ {0x0E,0x11,0x11,0x11,0x15,0x12,0x0D},
+ /* 82 'R' */ {0x1E,0x11,0x11,0x1E,0x14,0x12,0x11},
+ /* 83 'S' */ {0x0E,0x11,0x10,0x0E,0x01,0x11,0x0E},
+ /* 84 'T' */ {0x1F,0x04,0x04,0x04,0x04,0x04,0x04},
+ /* 85 'U' */ {0x11,0x11,0x11,0x11,0x11,0x11,0x0E},
+ /* 86 'V' */ {0x11,0x11,0x11,0x11,0x0A,0x0A,0x04},
+ /* 87 'W' */ {0x11,0x11,0x11,0x15,0x15,0x1B,0x11},
+ /* 88 'X' */ {0x11,0x11,0x0A,0x04,0x0A,0x11,0x11},
+ /* 89 'Y' */ {0x11,0x11,0x0A,0x04,0x04,0x04,0x04},
+ /* 90 'Z' */ {0x1F,0x01,0x02,0x04,0x08,0x10,0x1F},
+ /* 91 '[' */ {0x0E,0x08,0x08,0x08,0x08,0x08,0x0E},
+ /* 92 '\' */ {0x10,0x10,0x08,0x04,0x02,0x01,0x01},
+ /* 93 ']' */ {0x0E,0x02,0x02,0x02,0x02,0x02,0x0E},
+ /* 94 '^' */ {0x04,0x0A,0x11,0x00,0x00,0x00,0x00},
+ /* 95 '_' */ {0x00,0x00,0x00,0x00,0x00,0x00,0x1F},
+ /* 96 '`' */ {0x08,0x04,0x00,0x00,0x00,0x00,0x00},
+ /* 97 'a' */ {0x00,0x00,0x0E,0x01,0x0F,0x11,0x0F},
+ /* 98 'b' */ {0x10,0x10,0x1E,0x11,0x11,0x11,0x1E},
+ /* 99 'c' */ {0x00,0x00,0x0E,0x11,0x10,0x11,0x0E},
+ /*100 'd' */ {0x01,0x01,0x0F,0x11,0x11,0x11,0x0F},
+ /*101 'e' */ {0x00,0x00,0x0E,0x11,0x1F,0x10,0x0E},
+ /*102 'f' */ {0x06,0x08,0x1E,0x08,0x08,0x08,0x08},
+ /*103 'g' */ {0x00,0x00,0x0F,0x11,0x0F,0x01,0x0E},
+ /*104 'h' */ {0x10,0x10,0x1E,0x11,0x11,0x11,0x11},
+ /*105 'i' */ {0x04,0x00,0x0C,0x04,0x04,0x04,0x0E},
+ /*106 'j' */ {0x02,0x00,0x06,0x02,0x02,0x12,0x0C},
+ /*107 'k' */ {0x10,0x10,0x12,0x14,0x18,0x14,0x12},
+ /*108 'l' */ {0x0C,0x04,0x04,0x04,0x04,0x04,0x0E},
+ /*109 'm' */ {0x00,0x00,0x1A,0x15,0x15,0x15,0x15},
+ /*110 'n' */ {0x00,0x00,0x1E,0x11,0x11,0x11,0x11},
+ /*111 'o' */ {0x00,0x00,0x0E,0x11,0x11,0x11,0x0E},
+ /*112 'p' */ {0x00,0x00,0x1E,0x11,0x1E,0x10,0x10},
+ /*113 'q' */ {0x00,0x00,0x0F,0x11,0x0F,0x01,0x01},
+ /*114 'r' */ {0x00,0x00,0x16,0x19,0x10,0x10,0x10},
+ /*115 's' */ {0x00,0x00,0x0F,0x10,0x0E,0x01,0x1E},
+ /*116 't' */ {0x08,0x08,0x1E,0x08,0x08,0x09,0x06},
+ /*117 'u' */ {0x00,0x00,0x11,0x11,0x11,0x11,0x0F},
+ /*118 'v' */ {0x00,0x00,0x11,0x11,0x0A,0x0A,0x04},
+ /*119 'w' */ {0x00,0x00,0x11,0x11,0x15,0x15,0x0A},
+ /*120 'x' */ {0x00,0x00,0x11,0x0A,0x04,0x0A,0x11},
+ /*121 'y' */ {0x00,0x00,0x11,0x11,0x0F,0x01,0x0E},
+ /*122 'z' */ {0x00,0x00,0x1F,0x02,0x04,0x08,0x1F},
+ /*123 '{' */ {0x02,0x04,0x04,0x08,0x04,0x04,0x02},
+ /*124 '|' */ {0x04,0x04,0x04,0x04,0x04,0x04,0x04},
+ /*125 '}' */ {0x08,0x04,0x04,0x02,0x04,0x04,0x08},
+ /*126 '~' */ {0x00,0x00,0x08,0x15,0x02,0x00,0x00},
+};
+
+/* ── fb_char: render a single glyph into the framebuffer ────────── */
+/*
+ * px, py — top-left pixel position of the character
+ * ch — ASCII character to draw
+ * fg — foreground colour
+ * scale — integer magnification (1 = native 5×7, 2 = 10×14, etc.)
+ *
+ * For each "on" pixel in the 5×7 bitmap, we fill a scale×scale
+ * block of framebuffer pixels with the foreground colour. This
+ * gives us arbitrary-size text from a single font definition.
+ *
+ * The bit-test expression (0x10 >> col) walks columns left-to-right:
+ * col=0 → test bit 4 (0x10) = leftmost pixel
+ * col=1 → test bit 3 (0x08)
+ * col=2 → test bit 2 (0x04) = centre pixel
+ * col=3 → test bit 1 (0x02)
+ * col=4 → test bit 0 (0x01) = rightmost pixel
+ */
+static void fb_char(int px, int py, char ch, rgb_t fg, int scale) {
+ int idx = ch - 32;
+ if (idx < 0 || idx > 94) idx = 0; /* out-of-range → space */
+ for (int row = 0; row < 7; row++)
+ for (int col = 0; col < 5; col++)
+ if (font5x7[idx][row] & (0x10 >> col))
+ for (int sy = 0; sy < scale; sy++)
+ for (int sx = 0; sx < scale; sx++) {
+ int fx = px + col * scale + sx;
+ int fy = py + row * scale + sy;
+ if (fx >= 0 && fx < FB_W && fy >= 0 && fy < FB_H)
+ fb[fy][fx] = fg;
+ }
+}
+
+/* ── fb_string: render a NUL-terminated string ──────────────────── */
+/*
+ * Each character occupies 6×scale pixels horizontally (5 glyph
+ * pixels + 1 pixel gap between characters).
+ */
+static void fb_string(int px, int py, const char *s, rgb_t fg, int scale) {
+ int spacing = 6 * scale;
+ for (; *s; s++, px += spacing)
+ fb_char(px, py, *s, fg, scale);
+}
+
+/* ── fb_string_width: compute rendered width in pixels ──────────── */
+static int fb_string_width(const char *s, int scale) {
+ return (int)strlen(s) * 6 * scale;
+}
+
+/* ── fb_string_centered: render a string centred on the display ─── */
+static void fb_string_centered(int y, const char *s, rgb_t fg, int scale) {
+ int w = fb_string_width(s, scale);
+ fb_string((FB_W - w) / 2, y, s, fg, scale);
+}
+
+/* ── fb_degree: render a small superscript degree symbol (°) ────── */
+/*
+ * The degree symbol is a 3×3 bitmap forming a tiny circle:
+ * .X. → 0x02
+ * X.X → 0x05
+ * .X. → 0x02
+ *
+ * It's rendered the same way as font glyphs (scale×scale blocks),
+ * but using a separate 3-byte bitmap because it doesn't fit the
+ * 5×7 grid neatly and needs to sit at the top of the line.
+ */
+static void fb_degree(int px, int py, rgb_t fg, int scale) {
+ static const uint8_t deg[3] = {0x02, 0x05, 0x02};
+ for (int row = 0; row < 3; row++)
+ for (int col = 0; col < 3; col++)
+ if (deg[row] & (0x04 >> col))
+ for (int sy = 0; sy < scale; sy++)
+ for (int sx = 0; sx < scale; sx++) {
+ int fx = px + col * scale + sx;
+ int fy = py + row * scale + sy;
+ if (fx >= 0 && fx < FB_W && fy >= 0 && fy < FB_H)
+ fb[fy][fx] = fg;
+ }
+}
+
+#endif /* FONT_H */
diff --git a/weatherstar/icons.h b/weatherstar/icons.h
new file mode 100644
index 0000000..9df8cf0
--- /dev/null
+++ b/weatherstar/icons.h
@@ -0,0 +1,110 @@
+/*
+ * icons.h — Weather icon drawing
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * OVERVIEW
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * The WeatherStar 4000 displayed simple, iconic weather graphics —
+ * a sun, clouds, rain drops. We draw these directly into the pixel
+ * framebuffer using basic geometry (filled circles, lines).
+ *
+ * All icons are drawn with a "centre point" coordinate system:
+ * you specify where the icon's visual centre should be, and the
+ * drawing functions radiate outward from there.
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * HOW THE CIRCLE-DRAWING WORKS
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * Both the sun and cloud use a brute-force filled-circle algorithm:
+ *
+ * for each (dx, dy) in the bounding box:
+ * if dx² + dy² ≤ r²: → pixel is inside the circle
+ *
+ * This is O(r²) per circle, which is perfectly fine for radii under
+ * ~20 pixels. A Bresenham or midpoint algorithm would be faster for
+ * large radii, but we're drawing a handful of tiny circles — the
+ * brute force approach is clearer and more than fast enough.
+ */
+
+#ifndef ICONS_H
+#define ICONS_H
+
+#include "fb.h"
+#include
+
+/* ── draw_sun: sun icon with rays ───────────────────────────────── */
+/*
+ * Draws a filled yellow circle for the sun body, then 8 radial rays
+ * extending outward at 45° intervals (every π/4 radians).
+ *
+ * Each ray is a line of pixels drawn by stepping outward from the
+ * circle edge (r+3) to a fixed length (r+10), computing (x,y) from
+ * polar coordinates: x = cos(θ)·d, y = sin(θ)·d.
+ *
+ * The ray is thickened by also writing the pixel one position to the
+ * right (px+1), giving it a 2-pixel width for visual weight.
+ */
+static void draw_sun(int cx, int cy, int r) {
+ /* filled circle — the sun body */
+ for (int dy = -r; dy <= r; dy++)
+ for (int dx = -r; dx <= r; dx++)
+ if (dx * dx + dy * dy <= r * r) {
+ int px = cx + dx, py = cy + dy;
+ if (px >= 0 && px < FB_W && py >= 0 && py < FB_H)
+ fb[py][px] = COL_YELLOW;
+ }
+
+ /* 8 rays at 45° intervals */
+ for (int i = 0; i < 8; i++) {
+ float angle = (float)i * 3.14159f / 4.0f;
+ for (int d = r + 3; d < r + 10; d++) {
+ int px = cx + (int)(cosf(angle) * (float)d);
+ int py = cy + (int)(sinf(angle) * (float)d);
+ if (px >= 0 && px < FB_W && py >= 0 && py < FB_H)
+ fb[py][px] = COL_ORANGE;
+ if (px + 1 >= 0 && px + 1 < FB_W && py >= 0 && py < FB_H)
+ fb[py][px + 1] = COL_ORANGE;
+ }
+ }
+}
+
+/* ── draw_cloud: cloud icon from overlapping circles ────────────── */
+/*
+ * A cloud shape is approximated by three overlapping filled circles:
+ *
+ * - A central circle (radius 12) at the given centre point
+ * - A left circle (radius 10, offset 10px left and 4px down)
+ * - A right circle (radius 10, offset 10px right and 4px down)
+ *
+ * The overlap of the three circles produces a convincing "puffy
+ * cloud" silhouette:
+ *
+ * ████████
+ * ██████████████
+ * ████████████████████
+ * ██████████████
+ *
+ * The `col` parameter lets the caller choose the cloud colour
+ * (e.g. light gray for fair-weather clouds, darker for overcast).
+ */
+static void draw_cloud(int cx, int cy, rgb_t col) {
+ int offsets[][3] = {
+ { 0, 0, 12 }, /* centre */
+ {-10, 4, 10 }, /* left-bottom lobe */
+ { 10, 4, 10 }, /* right-bottom lobe */
+ };
+ for (int i = 0; i < 3; i++) {
+ int ox = offsets[i][0], oy = offsets[i][1], r = offsets[i][2];
+ for (int dy = -r; dy <= r; dy++)
+ for (int dx = -r; dx <= r; dx++)
+ if (dx * dx + dy * dy <= r * r) {
+ int px = cx + ox + dx, py = cy + oy + dy;
+ if (px >= 0 && px < FB_W && py >= 0 && py < FB_H)
+ fb[py][px] = col;
+ }
+ }
+}
+
+#endif /* ICONS_H */
diff --git a/weatherstar/main.c b/weatherstar/main.c
new file mode 100644
index 0000000..c79fc95
--- /dev/null
+++ b/weatherstar/main.c
@@ -0,0 +1,94 @@
+/*
+ * main.c — WeatherStar 4000 console display — entry point
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * WHAT THIS PROGRAM DOES
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * Renders a faithful recreation of The Weather Channel's "Local on
+ * the 8s" display (circa early 1990s, WeatherStar 4000 era) using
+ * nothing but a C compiler and libpng.
+ *
+ * The architecture is simple:
+ *
+ * 1. compose_display() → Paint the full scene into a 640×400
+ * pixel framebuffer (fb.h + display.h)
+ *
+ * 2. ansi_output() → Dump the framebuffer to stdout as ANSI
+ * 24-bit colour escape sequences, using
+ * Unicode half-block characters (▀) to
+ * pack two pixel rows per terminal line.
+ *
+ * 3. write_png() → Optionally write the framebuffer as a
+ * PNG image file (screenshot.h).
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * FILE STRUCTURE
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * main.c — this file; CLI parsing and orchestration
+ * fb.h — framebuffer type, colour palette, drawing prims
+ * font.h — 5×7 bitmap font data and text rendering
+ * icons.h — weather icon shapes (sun, cloud)
+ * display.h — full WeatherStar screen layout composition
+ * screenshot.h — PNG file output and ANSI terminal output
+ *
+ * All headers use static functions and are included only by main.c,
+ * so the whole program compiles as a single translation unit:
+ *
+ * gcc -O2 -o weatherstar main.c -lpng -lz -lm
+ *
+ * This keeps the build trivially simple (no object files, no link
+ * ordering issues) while still splitting the logic into readable,
+ * focused modules.
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * USAGE
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * ./weatherstar # render to terminal
+ * ./weatherstar --screenshot out.png # terminal + PNG
+ * ./weatherstar --no-ansi --screenshot out.png # PNG only
+ * ./weatherstar --help
+ *
+ * Build: make
+ * (or: gcc -O2 -o weatherstar main.c -lpng -lz -lm)
+ */
+
+#include "display.h"
+#include "screenshot.h"
+#include
+#include
+
+int main(int argc, char **argv) {
+ const char *screenshot = NULL;
+ int no_ansi = 0;
+
+ /* ── argument parsing ─────────────────────────────────────── */
+ for (int i = 1; i < argc; i++) {
+ if (strcmp(argv[i], "--screenshot") == 0 && i + 1 < argc)
+ screenshot = argv[++i];
+ else if (strcmp(argv[i], "--no-ansi") == 0)
+ no_ansi = 1;
+ else if (strcmp(argv[i], "--help") == 0) {
+ printf("Usage: weatherstar "
+ "[--screenshot FILE.png] [--no-ansi]\n");
+ return 0;
+ }
+ }
+
+ /* ── render ───────────────────────────────────────────────── */
+ compose_display();
+
+ if (!no_ansi)
+ ansi_output();
+
+ if (screenshot) {
+ if (write_png(screenshot) == 0)
+ fprintf(stderr, "Screenshot saved to %s\n", screenshot);
+ else
+ return 1;
+ }
+
+ return 0;
+}
diff --git a/weatherstar/screenshot.h b/weatherstar/screenshot.h
new file mode 100644
index 0000000..7c148a7
--- /dev/null
+++ b/weatherstar/screenshot.h
@@ -0,0 +1,158 @@
+/*
+ * screenshot.h — PNG and ANSI output from the framebuffer
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * PNG OUTPUT
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * write_png() converts the in-memory framebuffer into a PNG file
+ * using libpng. The process is:
+ *
+ * 1. Open the output file for binary writing.
+ * 2. Create the libpng write structures (png_structp, png_infop).
+ * 3. Set the image header: 640×400, 8-bit RGB, non-interlaced.
+ * 4. Walk each row of the framebuffer, pack it into a flat
+ * [R,G,B, R,G,B, ...] byte array, and hand it to libpng.
+ * 5. Finalise the PNG and close the file.
+ *
+ * libpng uses setjmp/longjmp for error handling — if any libpng
+ * call fails, execution jumps back to the setjmp point, where we
+ * clean up and return an error code.
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * ANSI TERMINAL OUTPUT
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * ansi_output() renders the framebuffer to a terminal that supports
+ * 24-bit ("true color") ANSI escape sequences.
+ *
+ * The trick: Unicode character U+2580 (▀, "upper half block") fills
+ * the top half of a character cell. By setting the foreground colour
+ * to the upper pixel row and the background colour to the lower pixel
+ * row, each terminal character cell displays TWO vertical pixels.
+ * This doubles the effective vertical resolution.
+ *
+ * So a 640×400 pixel image requires a 640-column × 200-row terminal.
+ * That's huge — this mode is primarily useful piped to a file or
+ * viewed in a very wide terminal. The PNG output is the more
+ * practical output mode.
+ *
+ * The escape sequences used:
+ * \033[H — move cursor to home position (top-left)
+ * \033[2J — clear entire screen
+ * \033[38;2;R;G;Bm — set foreground to 24-bit RGB
+ * \033[48;2;R;G;Bm — set background to 24-bit RGB
+ * \033[0m — reset all attributes
+ */
+
+#ifndef SCREENSHOT_H
+#define SCREENSHOT_H
+
+#include "fb.h"
+#include
+#include
+#include
+
+/* ── write_png: save framebuffer as a PNG file ──────────────────── */
+static int write_png(const char *path) {
+ FILE *fp = fopen(path, "wb");
+ if (!fp) { perror(path); return -1; }
+
+ png_structp png = png_create_write_struct(
+ PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
+ if (!png) { fclose(fp); return -1; }
+
+ png_infop info = png_create_info_struct(png);
+ if (!info) {
+ png_destroy_write_struct(&png, NULL);
+ fclose(fp);
+ return -1;
+ }
+
+ /*
+ * Allocate the row buffer BEFORE setjmp so it can be freed on
+ * the longjmp error path. If we allocated after setjmp, a
+ * longjmp triggered between malloc and free would leak the row.
+ */
+ uint8_t *row = malloc(FB_W * 3);
+ if (!row) {
+ png_destroy_write_struct(&png, &info);
+ fclose(fp);
+ return -1;
+ }
+
+ /*
+ * libpng error handling: if any png_* call fails, it will
+ * longjmp back here. We clean up everything and return failure.
+ */
+ if (setjmp(png_jmpbuf(png))) {
+ free(row);
+ png_destroy_write_struct(&png, &info);
+ fclose(fp);
+ return -1;
+ }
+
+ png_init_io(png, fp);
+ png_set_IHDR(png, info, FB_W, FB_H, 8, PNG_COLOR_TYPE_RGB,
+ PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT,
+ PNG_FILTER_TYPE_DEFAULT);
+ png_write_info(png, info);
+
+ for (int y = 0; y < FB_H; y++) {
+ for (int x = 0; x < FB_W; x++) {
+ row[x * 3 + 0] = fb[y][x].r;
+ row[x * 3 + 1] = fb[y][x].g;
+ row[x * 3 + 2] = fb[y][x].b;
+ }
+ png_write_row(png, row);
+ }
+
+ free(row);
+ png_write_end(png, NULL);
+ png_destroy_write_struct(&png, &info);
+ fclose(fp);
+ return 0;
+}
+
+/* ── ansi_output: render framebuffer to terminal with true-color ── */
+/*
+ * Performance note: each pixel needs ~30 bytes of escape sequences.
+ * 640 pixels × 200 rows × 30 bytes ≈ 3.6 MB of output. Instead of
+ * calling printf 128,000 times (one per pixel), we write into a
+ * line buffer and flush once per row. This reduces syscall overhead
+ * dramatically and makes output roughly 10× faster.
+ */
+static void ansi_output(void) {
+ /*
+ * Worst case per pixel: two SGR sequences + 3-byte UTF-8 char.
+ * "\033[38;2;RRR;GGG;BBBm\033[48;2;RRR;GGG;BBBm\xe2\x96\x80"
+ * That's at most ~44 bytes. Plus "\033[0m\n" per line.
+ * Buffer: 640 × 48 + 16 = ~30 KB per line — fits easily on stack.
+ */
+ char line[640 * 48 + 16];
+
+ printf("\033[H\033[2J"); /* cursor home + clear screen */
+
+ for (int y = 0; y < FB_H; y += 2) {
+ int pos = 0;
+ for (int x = 0; x < FB_W; x++) {
+ rgb_t top = fb[y][x];
+ rgb_t bot = (y + 1 < FB_H) ? fb[y + 1][x] : top;
+ /*
+ * snprintf into the line buffer. The half-block trick:
+ * foreground = top pixel, background = bottom pixel.
+ */
+ pos += snprintf(line + pos, sizeof(line) - (size_t)pos,
+ "\033[38;2;%d;%d;%dm\033[48;2;%d;%d;%dm"
+ "\xe2\x96\x80",
+ top.r, top.g, top.b, bot.r, bot.g, bot.b);
+ }
+ pos += snprintf(line + pos, sizeof(line) - (size_t)pos,
+ "\033[0m\n");
+ fwrite(line, 1, (size_t)pos, stdout);
+ }
+ printf("\033[0m");
+ fflush(stdout);
+}
+
+#endif /* SCREENSHOT_H */
diff --git a/weatherstar/screenshot.png b/weatherstar/screenshot.png
new file mode 100644
index 0000000..e3abf42
Binary files /dev/null and b/weatherstar/screenshot.png differ