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