From d6cef67420da506565c67cb9368d3cb6d5060f72 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 6 Apr 2026 18:56:38 -0400 Subject: [PATCH] Add cover refinements implementation plan Five-task plan covering pixel-width truncation, font size bump, and obituary filtering across three call sites. Made-with: Cursor --- .../plans/2026-04-06-cover-refinements.md | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-cover-refinements.md diff --git a/docs/superpowers/plans/2026-04-06-cover-refinements.md b/docs/superpowers/plans/2026-04-06-cover-refinements.md new file mode 100644 index 0000000..3c9a401 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-cover-refinements.md @@ -0,0 +1,286 @@ +# Cover Refinements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Improve cover headline rendering by filtering out obituaries, increasing font size, and using pixel-width truncation. + +**Architecture:** Three isolated changes that converge on the cover: (1) a new `_truncate_to_width` helper in `cover.py` replaces character-count truncation with Pillow `textbbox` measurement, combined with font/spacing bumps; (2) the three call sites that build headline lists gain an obituary filter; (3) tests validate both behaviors. + +**Tech Stack:** Python, Pillow (PIL), Flask, SQLAlchemy + +--- + +### Task 1: Add pixel-width truncation helper and update font/spacing in cover.py + +**Files:** +- Modify: `src/cover.py:73-114` (the `_draw_text_overlays` function) +- Test: `tests/test_cover.py` + +- [ ] **Step 1: Write failing test for pixel-width truncation** + +Add a new test to `tests/test_cover.py` that verifies headlines are truncated based on pixel width, not character count. Import `_draw_text_overlays` and the helper. + +```python +# Add to imports at top of tests/test_cover.py: +from PIL import ImageDraw, ImageFont +from src.cover import _truncate_to_width, _get_font + +# Add this test: +def test_truncate_to_width_respects_pixel_budget(): + font = _get_font(18) + short = "Short text" + result = _truncate_to_width(short, font, 400) + assert result == short, "Short text should not be truncated" + + long = "A" * 200 + result = _truncate_to_width(long, font, 400) + assert result.endswith("\u2026"), "Long text should end with ellipsis" + assert len(result) < len(long), "Truncated text should be shorter" + + +def test_truncate_to_width_fits_within_budget(): + font = _get_font(18) + long = "This is a really long headline that should definitely be truncated to fit" + max_width = 448 + result = _truncate_to_width(long, font, max_width) + + dummy = PILImage.new("RGBA", (1, 1)) + draw = ImageDraw.Draw(dummy) + bbox = draw.textbbox((0, 0), result, font=font) + rendered_width = bbox[2] - bbox[0] + assert rendered_width <= max_width, f"Rendered width {rendered_width} exceeds {max_width}" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_cover.py::test_truncate_to_width_respects_pixel_budget tests/test_cover.py::test_truncate_to_width_fits_within_budget -v` +Expected: FAIL with `ImportError` (cannot import `_truncate_to_width`) + +- [ ] **Step 3: Implement `_truncate_to_width` and update `_draw_text_overlays`** + +In `src/cover.py`, add the new helper function after `_get_dominant_category` (after line 70): + +```python +def _truncate_to_width( + text: str, font: ImageFont.FreeTypeFont, max_width: int +) -> str: + dummy = PILImage.new("RGBA", (1, 1)) + draw = ImageDraw.Draw(dummy) + bbox = draw.textbbox((0, 0), text, font=font) + if (bbox[2] - bbox[0]) <= max_width: + return text + ellipsis = "\u2026" + for i in range(len(text) - 1, 0, -1): + candidate = text[:i] + ellipsis + bbox = draw.textbbox((0, 0), candidate, font=font) + if (bbox[2] - bbox[0]) <= max_width: + return candidate + return ellipsis +``` + +Then update `_draw_text_overlays` — change line 83 and lines 101-114: + +```python + headline_font = _get_font(18) +``` + +And replace the headline strip block: + +```python + if headlines: + max_headlines = min(len(headlines), 5) + strip_height = 28 + max_headlines * 26 + strip_top = height - strip_height + draw.rectangle( + [(0, strip_top), (width, height)], fill=(0, 0, 0, 170) + ) + max_width = width - 32 + y = strip_top + 8 + for headline in headlines[:max_headlines]: + text = f"\u2022 {headline}" + text = _truncate_to_width(text, headline_font, max_width) + draw.text((16, y), text, fill="white", font=headline_font) + y += 26 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_cover.py -v` +Expected: ALL PASS (including the existing tests which verify image size/format) + +- [ ] **Step 5: Commit** + +```bash +git add src/cover.py tests/test_cover.py +git commit -m "feat(cover): pixel-width headline truncation and larger font + +Replace character-count truncation (45 chars) with Pillow textbbox +pixel-width measurement. Bump headline font 14→18, line spacing 22→26." +``` + +--- + +### Task 2: Filter obituaries from cover headlines in publish.py + +**Files:** +- Modify: `src/routes/publish.py:162-169` + +- [ ] **Step 1: Update headline and category list building to filter obituaries** + +In `src/routes/publish.py`, replace lines 162-169 in the `create_issue` function: + +```python + articles_for_issue = ( + Article.query.filter(Article.id.in_(included_ids)) + .order_by(Article.pub_date.asc()) + .all() + ) + headlines = [ + a.title for a in articles_for_issue + if "Obituaries" not in json.loads(a.categories) + ] + categories_list = [] + for a in articles_for_issue: + categories_list.extend(json.loads(a.categories)) +``` + +This queries articles once (instead of twice), filters obituaries from headlines only, and keeps all categories for AI prompt selection. + +- [ ] **Step 2: Run existing tests** + +Run: `python -m pytest -v` +Expected: ALL PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/publish.py +git commit -m "feat(cover): filter obituaries from headlines in manual publish" +``` + +--- + +### Task 3: Filter obituaries from cover headlines in issues.py + +**Files:** +- Modify: `src/routes/issues.py:106-113` + +- [ ] **Step 1: Update the regenerate route's headline building** + +In `src/routes/issues.py`, replace lines 106-113 in the `regenerate` function: + +```python + articles_for_issue = ( + Article.query.filter(Article.id.in_(article_ids)) + .order_by(Article.pub_date.asc()) + .all() + ) + headlines = [ + a.title for a in articles_for_issue + if "Obituaries" not in json.loads(a.categories) + ] + categories_list = [] + for a in articles_for_issue: + categories_list.extend(json.loads(a.categories)) +``` + +- [ ] **Step 2: Run existing tests** + +Run: `python -m pytest -v` +Expected: ALL PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/issues.py +git commit -m "feat(cover): filter obituaries from headlines in issue regenerate" +``` + +--- + +### Task 4: Filter obituaries from cover headlines in scheduler.py + +**Files:** +- Modify: `src/scheduler.py:78-83` + +- [ ] **Step 1: Update auto-publish headline building** + +In `src/scheduler.py`, replace lines 78-83 in the `_run_auto_publish` method: + +```python + article_ids = [a.id for a in articles] + headlines = [ + a.title for a in articles + if "Obituaries" not in json.loads(a.categories) + ] + + categories_list = [] + for a in articles: + categories_list.extend(json.loads(a.categories)) +``` + +- [ ] **Step 2: Run existing tests** + +Run: `python -m pytest -v` +Expected: ALL PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/scheduler.py +git commit -m "feat(cover): filter obituaries from headlines in auto-publish" +``` + +--- + +### Task 5: Add test for obituary filtering + +**Files:** +- Test: `tests/test_cover.py` + +- [ ] **Step 1: Write an integration test that verifies obituaries are excluded** + +Since the obituary filtering happens at the call site (not inside `cover.py`), this test validates the filtering pattern used across all three call sites. Add to `tests/test_cover.py`: + +```python +import json + +def test_obituary_headlines_filtered(): + """Verify the filtering pattern used by publish/issues/scheduler call sites.""" + class FakeArticle: + def __init__(self, title, categories): + self.title = title + self.categories = json.dumps(categories) + + articles = [ + FakeArticle("Town Meeting Approves Budget", ["Government"]), + FakeArticle("John Smith, 85, beloved teacher", ["Obituaries"]), + FakeArticle("Harbor Festival This Weekend", ["Culture"]), + FakeArticle("Jane Doe, 92, retired nurse", ["Obituaries"]), + FakeArticle("Panthers Win Championship", ["Sports"]), + ] + + headlines = [ + a.title for a in articles + if "Obituaries" not in json.loads(a.categories) + ] + + assert len(headlines) == 3 + assert "John Smith, 85, beloved teacher" not in headlines + assert "Jane Doe, 92, retired nurse" not in headlines + assert "Town Meeting Approves Budget" in headlines + assert "Harbor Festival This Weekend" in headlines + assert "Panthers Win Championship" in headlines +``` + +- [ ] **Step 2: Run the new test** + +Run: `python -m pytest tests/test_cover.py::test_obituary_headlines_filtered -v` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_cover.py +git commit -m "test: add obituary headline filtering test" +```