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
This commit is contained in:
286
docs/superpowers/plans/2026-04-06-cover-refinements.md
Normal file
286
docs/superpowers/plans/2026-04-06-cover-refinements.md
Normal file
@@ -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"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user