# 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"
```
