Option A: typographic editorial tiles + single treated hero image; balance brief topics

Frontend (the premium baseline):
- The hero is now the ONLY image slot. Soft feed images get an atmospheric
  gradient overlay; no over-reliance on inconsistent RSS image quality.
- Every secondary/lane card is a uniform typographic editorial tile: no
  thumbnails, equal visual weight, a faint topic wordmark watermark, a slim
  sage top accent, consistent source, reason text as the trust signal, visible
  Replace with quiet tuning actions. Fixes the jarring mixed-media row rhythm
  and removes muddy thumbnails entirely.

Backend (composition):
- _select_diverse now balances topics: no more than 2 of one topic while other
  topics have candidates (relaxing source then topic caps only to fill), so the
  daily five stop clustering medical/science items. Candidates now carry s.topic.

Tests updated for the topic-balance contract (79 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-31 12:10:05 +00:00
parent ba801d90f6
commit 541f59ed6e
4 changed files with 107 additions and 118 deletions
+49 -53
View File
@@ -1,14 +1,15 @@
<script>
let { article, onaction, onreplace, hero = false } = $props();
// Cards can be replaced in place, so derive from the current article prop and
// reset the image-failure flag whenever the image URL changes.
// Reset image-failure when the article changes (cards swap in place).
let failed = $state(false);
$effect(() => {
void article.image_url;
failed = false;
});
let hasImg = $derived(!!article.image_url && !failed);
// The hero is the ONLY image slot; every other card is typographic.
let showImage = $derived(hero && hasImg);
let safeHref = $derived(
typeof article.url === 'string' && /^https?:\/\//.test(article.url) ? article.url : '#'
);
@@ -18,16 +19,13 @@
}
</script>
<!-- Image-less cards are designed, not missing: secondary cards go text-first
(no empty media band), and the hero becomes a fully typographic lead with a
faint topic wordmark behind it. We never reserve big empty image space. -->
<article
class:hero
class:textfirst={!hero && !hasImg}
class:hero={hero && hasImg}
class:herotype={hero && !hasImg}
class:tile={!hero}
data-topic={article.topic ?? ''}
>
{#if hasImg}
{#if showImage}
<a class="media" href={safeHref} target="_blank" rel="noopener">
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => (failed = true)} />
@@ -43,14 +41,14 @@
<h3><a href={safeHref} target="_blank" rel="noopener">{article.title}</a></h3>
{#if article.paywalled}
<p class="paywall">May need a subscription to read</p>
{/if}
{#if hero && article.description}
<p class="desc">{article.description}</p>
{/if}
{#if article.paywalled}
<p class="paywall">May need a subscription to read</p>
{/if}
{#if article.reason_text}
<p class="why">{article.reason_text}</p>
{/if}
@@ -79,13 +77,6 @@
flex-direction: column;
height: 100%;
}
.media {
position: relative;
display: block;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, var(--sage-soft), #f1ece0);
}
.media img { width: 100%; height: 100%; object-fit: cover; }
.body { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; }
@@ -108,66 +99,71 @@
margin: 2px 0 0; font-style: italic; color: var(--muted);
font-size: 0.9rem; padding-left: 12px; border-left: 2px solid var(--sage-soft);
}
/* Keep card heights even: clamp variable-length text on lane/grid cards. */
article:not(.hero):not(.herotype) h3,
.why {
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
-webkit-line-clamp: 3;
/* Keep heights even: clamp variable-length text on tiles. */
.tile h3, .why {
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 3;
}
.hero .desc, .herotype .desc {
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
-webkit-line-clamp: 6;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 6;
}
.actions { margin-top: auto; padding-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; }
.actions { margin-top: auto; padding-top: 12px; display: flex; gap: 14px; flex-wrap: wrap; }
.actions button {
background: none; border: none; padding: 0; color: var(--muted);
font-size: 0.76rem; border-bottom: 1px dotted var(--line);
}
.actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); }
.actions .replace { color: var(--sage-deep); border-bottom-color: var(--sage-soft); }
/* Tuning actions stay quiet until hover/focus on pointer devices; the Replace
escape hatch stays visible so a paywalled card always shows a way through. */
@media (hover: hover) {
.actions button:not(.replace) { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions button:not(.replace),
article:focus-within .actions button:not(.replace) { opacity: 1; }
}
/* text-first secondary card: a small accent instead of an empty image band */
.textfirst .body { padding-top: 18px; }
.textfirst .body::before {
content: ''; display: block; width: 28px; height: 3px;
background: var(--sage-soft); border-radius: 2px; margin-bottom: 4px;
/* ---- Typographic editorial tile (every non-hero card) ---- */
.tile { position: relative; }
.tile .body { padding-top: 18px; }
.tile .body::before {
content: ''; display: block; width: 30px; height: 3px;
background: var(--sage); border-radius: 2px; margin-bottom: 10px; opacity: 0.85;
}
.tile::after {
content: attr(data-topic);
position: absolute; right: 10px; bottom: -8px;
font-family: var(--serif); font-size: 3.4rem; line-height: 1;
color: var(--sage); opacity: 0.05; text-transform: lowercase; letter-spacing: -0.02em;
pointer-events: none; user-select: none;
}
.tile .tags, .tile h3, .tile .why, .tile .actions { position: relative; }
/* hero WITH image: two columns */
/* ---- Hero WITH image: two columns, with an atmospheric overlay ---- */
.hero { display: grid; grid-template-columns: 1.1fr 1fr; }
.hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; }
.hero .body { padding: 28px 30px; justify-content: center; gap: 12px; }
.hero .media {
position: relative; display: block; height: 100%; min-height: 300px;
background: linear-gradient(135deg, var(--sage-soft), #f1ece0);
}
.hero .media img { width: 100%; height: 100%; object-fit: cover; }
/* Soft feed images read as atmospheric, not broken. */
.hero .media::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(180deg, rgba(33, 40, 31, 0) 55%, rgba(33, 40, 31, 0.16));
}
.hero .body { padding: 30px 32px; justify-content: center; gap: 12px; }
.hero h3 { font-size: 1.95rem; }
/* hero WITHOUT image: a fully typographic lead with a faint topic wordmark */
/* ---- Hero WITHOUT image: fully typographic lead ---- */
.herotype {
position: relative;
overflow: hidden;
position: relative; overflow: hidden;
background:
radial-gradient(120% 140% at 100% 0%, var(--sage-soft) 0%, transparent 55%),
linear-gradient(180deg, var(--surface), var(--surface));
var(--surface);
}
.herotype::after {
content: attr(data-topic);
position: absolute;
right: 8px;
bottom: -18px;
font-family: var(--serif);
font-size: clamp(4rem, 12vw, 8rem);
line-height: 1;
color: var(--sage);
opacity: 0.07;
text-transform: lowercase;
letter-spacing: -0.02em;
pointer-events: none;
user-select: none;
position: absolute; right: 12px; bottom: -18px;
font-family: var(--serif); font-size: clamp(4rem, 12vw, 8rem); line-height: 1;
color: var(--sage); opacity: 0.07; text-transform: lowercase; letter-spacing: -0.02em;
pointer-events: none; user-select: none;
}
.herotype .body { position: relative; padding: 40px 36px; gap: 12px; }
.herotype h3 { font-size: 2.15rem; }
+23 -31
View File
@@ -118,6 +118,8 @@ def _candidate_articles(
s.reason_code,
s.reason_text,
s.model_name,
s.topic,
s.flavor,
CASE WHEN date(COALESCE(a.published_at, a.discovered_at)) = date(?)
THEN 1 ELSE 0 END AS is_today
FROM articles a
@@ -146,52 +148,42 @@ def _candidate_articles(
).fetchall()
def _select_diverse(rows: list[sqlite3.Row], limit: int) -> list[sqlite3.Row]:
def _select_diverse(rows: list[sqlite3.Row], limit: int, max_per_topic: int = 2) -> list[sqlite3.Row]:
"""Pick up to `limit` items from `rows` (already ranked best-first).
Contract:
1. Prefer higher-ranked items.
2. Source diversity: take at most one item per source while other sources
remain; only repeat a source once distinct sources are exhausted.
3. Category diversity: if the result ended up single-category and a different
category is available in the pool, swap in the highest-ranked off-category
candidate by evicting the lowest-ranked currently-selected item (so we
gain breadth without dropping a higher-ranked pick).
2. Source diversity: at most one item per source while other sources remain.
3. Topic balance: no more than `max_per_topic` of the same topic while other
topics still have candidates — so the five don't cluster (e.g. several
medical/science items) when community/culture/animals/environment exist.
4. Always fill to `limit` when enough candidates exist: the source and topic
caps are relaxed (in that order) only as needed to reach the count.
"""
selected: list[sqlite3.Row] = []
selected_ids: set = set()
seen_sources: set = set()
topic_count: dict = {}
# Pass 1: best-first, one per source.
for row in rows:
if len(selected) >= limit:
break
if row["source_name"] in seen_sources:
continue
selected.append(row)
seen_sources.add(row["source_name"])
# Pass 2: if short on distinct sources, backfill best-first regardless.
if len(selected) < limit:
selected_ids = {row["id"] for row in selected}
def consider(enforce_source: bool, enforce_topic: bool) -> None:
for row in rows:
if len(selected) >= limit:
break
return
if row["id"] in selected_ids:
continue
if enforce_source and row["source_name"] in seen_sources:
continue
topic = row["topic"]
if enforce_topic and topic_count.get(topic, 0) >= max_per_topic:
continue
selected.append(row)
selected_ids.add(row["id"])
seen_sources.add(row["source_name"])
topic_count[topic] = topic_count.get(topic, 0) + 1
# Pass 3: ensure >= 2 categories when the pool allows it.
categories = {row["default_category"] for row in selected}
if len(categories) < 2:
selected_ids = {row["id"] for row in selected}
for row in rows:
if row["id"] in selected_ids:
continue
if row["default_category"] not in categories:
selected[-1] = row # evict the lowest-ranked selected item
break
consider(enforce_source=True, enforce_topic=True) # distinct source, topic-balanced
consider(enforce_source=True, enforce_topic=False) # relax topic cap to fill
consider(enforce_source=False, enforce_topic=False) # relax source too, last resort
return selected
+9 -3
View File
@@ -1,14 +1,20 @@
| = tabled for now
- = Yet to do
-l = Yet to do low priority
- = Yet to do medium priority
-h = yet to do high priority
* = completed item
$ = informational
* Ability to silence some categories temporarily (Maybe a user doesn't even want to see health-related articles, even good ones, so they're not reminded of an ongoing medical issue -- a way to avoid something purposely for a bit) [done: pause topics/flavors in Boundaries]
* Terms to avoid list (To filter even good news that you'd rather not hear about) [done: avoid words/phrases in Boundaries]
| Favorite/save articles [tabled: needs accounts/logins for a larger footprint]
| Soothing background colors/gradients per each category as you scroll. Maybe a user preference. [tabled: revisit deliberately; if done, whisper-quiet translucent tints, not neon]
- I really like the coloring for the metadata highlighting in each card (The grading bubbles)
$ I really like the coloring for the metadata highlighting in each card (The grading bubbles)
* Some articles are behind paywalls.. what can we do? [done: domain-level paywall flag, readable hero, paywalled downweighted out of the daily five, and a "paywall-heavy" advisory source-health flag (Nature/New Scientist). Replace handles any that remain in browse.]
* After an article is read, can we add a refresh button to fetch a replacement for it in the list? [done: "Find one I can read" / Replace swaps in the next readable article]
* I want the top 5 to be tere, but I want the remaining categories to be hidden behing their selections. So the main screen should show just the current highlights, and then the other articles should only be visible when in that category. [done]
* Title headings should be a little larger -- if you select Today, Today should look like a proper heading, bold and beautiful. Switching to Wondow should show "Wonder" all nice and whatnot. [done]
* Title headings should be a little larger -- if you select Today, Today should look like a proper heading, bold and beautiful. Switching to Wondow should show "Wonder" all nice and whatnot. [done]
-l Shomehow include a daily inspirational/motivational/uplifting quote that would change each day.
- Allow ability to forward/share articles
+26 -31
View File
@@ -1,50 +1,45 @@
from goodnews.briefs import _select_diverse
def row(id, source, category):
# _select_diverse only reads these three keys; plain dicts support [] access.
return {"id": id, "source_name": source, "default_category": category}
def row(id, source, topic):
# _select_diverse reads id, source_name, topic; plain dicts support [] access.
return {"id": id, "source_name": source, "topic": topic}
def test_prefers_distinct_sources_best_first():
rows = [
row(1, "A", "science"),
row(2, "A", "science"), # same source as #1 — should be skipped while others remain
row(3, "B", "science"),
row(2, "A", "science"), # same source as #1 — skipped while others remain
row(3, "B", "health"),
row(4, "C", "environment"),
]
selected = _select_diverse(rows, limit=3)
ids = [r["id"] for r in selected]
assert ids == [1, 3, 4] # one per source, ranked order preserved
assert [r["id"] for r in _select_diverse(rows, limit=3)] == [1, 3, 4]
def test_backfills_when_sources_exhausted():
rows = [row(1, "A", "science"), row(2, "A", "science"), row(3, "A", "science")]
selected = _select_diverse(rows, limit=2)
assert len(selected) == 2 # repeats source A only because no others exist
def test_injects_second_category_without_shrinking():
def test_caps_a_topic_when_alternatives_exist():
rows = [
row(1, "A", "science"),
row(2, "B", "science"),
row(3, "C", "science"),
row(4, "D", "environment"), # the only other category, lowest ranked
row(1, "A", "science"), row(2, "B", "science"),
row(3, "C", "science"), row(4, "D", "science"),
row(5, "E", "community"), row(6, "F", "animals"), row(7, "G", "culture"),
]
selected = _select_diverse(rows, limit=3)
cats = {r["default_category"] for r in selected}
assert len(selected) == 3
assert len(cats) >= 2 # environment swapped in for diversity
assert any(r["default_category"] == "environment" for r in selected)
selected = _select_diverse(rows, limit=5, max_per_topic=2)
topics = [r["topic"] for r in selected]
assert len(selected) == 5
assert topics.count("science") == 2 # capped, even though 4 were available
assert {"community", "animals", "culture"} <= set(topics)
def test_keeps_single_category_when_no_alternative_exists():
rows = [row(1, "A", "science"), row(2, "B", "science"), row(3, "C", "science")]
selected = _select_diverse(rows, limit=3)
assert len(selected) == 3
assert {r["default_category"] for r in selected} == {"science"}
def test_relaxes_cap_when_only_one_topic_available():
rows = [row(i, f"S{i}", "science") for i in range(1, 6)]
selected = _select_diverse(rows, limit=5)
assert len(selected) == 5 # all science: cap relaxed because nothing else exists
def test_never_returns_more_than_limit():
rows = [row(i, f"S{i}", "science") for i in range(10)]
def test_backfills_repeating_source_when_needed():
rows = [row(1, "A", "science"), row(2, "A", "science"), row(3, "A", "science")]
assert len(_select_diverse(rows, limit=2)) == 2
def test_never_exceeds_limit():
rows = [row(i, f"S{i}", "science") for i in range(20)]
assert len(_select_diverse(rows, limit=5)) == 5