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> <script>
let { article, onaction, onreplace, hero = false } = $props(); let { article, onaction, onreplace, hero = false } = $props();
// Cards can be replaced in place, so derive from the current article prop and // Reset image-failure when the article changes (cards swap in place).
// reset the image-failure flag whenever the image URL changes.
let failed = $state(false); let failed = $state(false);
$effect(() => { $effect(() => {
void article.image_url; void article.image_url;
failed = false; failed = false;
}); });
let hasImg = $derived(!!article.image_url && !failed); 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( let safeHref = $derived(
typeof article.url === 'string' && /^https?:\/\//.test(article.url) ? article.url : '#' typeof article.url === 'string' && /^https?:\/\//.test(article.url) ? article.url : '#'
); );
@@ -18,16 +19,13 @@
} }
</script> </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 <article
class:hero class:hero={hero && hasImg}
class:textfirst={!hero && !hasImg}
class:herotype={hero && !hasImg} class:herotype={hero && !hasImg}
class:tile={!hero}
data-topic={article.topic ?? ''} data-topic={article.topic ?? ''}
> >
{#if hasImg} {#if showImage}
<a class="media" href={safeHref} target="_blank" rel="noopener"> <a class="media" href={safeHref} target="_blank" rel="noopener">
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer" <img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => (failed = true)} /> onerror={() => (failed = true)} />
@@ -43,14 +41,14 @@
<h3><a href={safeHref} target="_blank" rel="noopener">{article.title}</a></h3> <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} {#if hero && article.description}
<p class="desc">{article.description}</p> <p class="desc">{article.description}</p>
{/if} {/if}
{#if article.paywalled}
<p class="paywall">May need a subscription to read</p>
{/if}
{#if article.reason_text} {#if article.reason_text}
<p class="why">{article.reason_text}</p> <p class="why">{article.reason_text}</p>
{/if} {/if}
@@ -79,13 +77,6 @@
flex-direction: column; flex-direction: column;
height: 100%; 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; } .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; } .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); margin: 2px 0 0; font-style: italic; color: var(--muted);
font-size: 0.9rem; padding-left: 12px; border-left: 2px solid var(--sage-soft); 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. */ /* Keep heights even: clamp variable-length text on tiles. */
article:not(.hero):not(.herotype) h3, .tile h3, .why {
.why { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 3;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
-webkit-line-clamp: 3;
} }
.hero .desc, .herotype .desc { .hero .desc, .herotype .desc {
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 6;
-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 { .actions button {
background: none; border: none; padding: 0; color: var(--muted); background: none; border: none; padding: 0; color: var(--muted);
font-size: 0.76rem; border-bottom: 1px dotted var(--line); font-size: 0.76rem; border-bottom: 1px dotted var(--line);
} }
.actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); } .actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); }
.actions .replace { color: var(--sage-deep); border-bottom-color: var(--sage-soft); } .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) { @media (hover: hover) {
.actions button:not(.replace) { opacity: 0; transition: opacity 0.16s ease; } .actions button:not(.replace) { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions button:not(.replace), article:hover .actions button:not(.replace),
article:focus-within .actions button:not(.replace) { opacity: 1; } article:focus-within .actions button:not(.replace) { opacity: 1; }
} }
/* text-first secondary card: a small accent instead of an empty image band */ /* ---- Typographic editorial tile (every non-hero card) ---- */
.textfirst .body { padding-top: 18px; } .tile { position: relative; }
.textfirst .body::before { .tile .body { padding-top: 18px; }
content: ''; display: block; width: 28px; height: 3px; .tile .body::before {
background: var(--sage-soft); border-radius: 2px; margin-bottom: 4px; 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 { display: grid; grid-template-columns: 1.1fr 1fr; }
.hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; } .hero .media {
.hero .body { padding: 28px 30px; justify-content: center; gap: 12px; } 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 h3 { font-size: 1.95rem; }
/* hero WITHOUT image: a fully typographic lead with a faint topic wordmark */ /* ---- Hero WITHOUT image: fully typographic lead ---- */
.herotype { .herotype {
position: relative; position: relative; overflow: hidden;
overflow: hidden;
background: background:
radial-gradient(120% 140% at 100% 0%, var(--sage-soft) 0%, transparent 55%), radial-gradient(120% 140% at 100% 0%, var(--sage-soft) 0%, transparent 55%),
linear-gradient(180deg, var(--surface), var(--surface)); var(--surface);
} }
.herotype::after { .herotype::after {
content: attr(data-topic); content: attr(data-topic);
position: absolute; position: absolute; right: 12px; bottom: -18px;
right: 8px; font-family: var(--serif); font-size: clamp(4rem, 12vw, 8rem); line-height: 1;
bottom: -18px; color: var(--sage); opacity: 0.07; text-transform: lowercase; letter-spacing: -0.02em;
font-family: var(--serif); pointer-events: none; user-select: none;
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 .body { position: relative; padding: 40px 36px; gap: 12px; }
.herotype h3 { font-size: 2.15rem; } .herotype h3 { font-size: 2.15rem; }
+23 -31
View File
@@ -118,6 +118,8 @@ def _candidate_articles(
s.reason_code, s.reason_code,
s.reason_text, s.reason_text,
s.model_name, s.model_name,
s.topic,
s.flavor,
CASE WHEN date(COALESCE(a.published_at, a.discovered_at)) = date(?) CASE WHEN date(COALESCE(a.published_at, a.discovered_at)) = date(?)
THEN 1 ELSE 0 END AS is_today THEN 1 ELSE 0 END AS is_today
FROM articles a FROM articles a
@@ -146,52 +148,42 @@ def _candidate_articles(
).fetchall() ).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). """Pick up to `limit` items from `rows` (already ranked best-first).
Contract: Contract:
1. Prefer higher-ranked items. 1. Prefer higher-ranked items.
2. Source diversity: take at most one item per source while other sources 2. Source diversity: at most one item per source while other sources remain.
remain; only repeat a source once distinct sources are exhausted. 3. Topic balance: no more than `max_per_topic` of the same topic while other
3. Category diversity: if the result ended up single-category and a different topics still have candidates — so the five don't cluster (e.g. several
category is available in the pool, swap in the highest-ranked off-category medical/science items) when community/culture/animals/environment exist.
candidate by evicting the lowest-ranked currently-selected item (so we 4. Always fill to `limit` when enough candidates exist: the source and topic
gain breadth without dropping a higher-ranked pick). caps are relaxed (in that order) only as needed to reach the count.
""" """
selected: list[sqlite3.Row] = [] selected: list[sqlite3.Row] = []
selected_ids: set = set()
seen_sources: set = set() seen_sources: set = set()
topic_count: dict = {}
# Pass 1: best-first, one per source. def consider(enforce_source: bool, enforce_topic: bool) -> None:
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}
for row in rows: for row in rows:
if len(selected) >= limit: if len(selected) >= limit:
break return
if row["id"] in selected_ids: if row["id"] in selected_ids:
continue 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.append(row)
selected_ids.add(row["id"]) 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. consider(enforce_source=True, enforce_topic=True) # distinct source, topic-balanced
categories = {row["default_category"] for row in selected} consider(enforce_source=True, enforce_topic=False) # relax topic cap to fill
if len(categories) < 2: consider(enforce_source=False, enforce_topic=False) # relax source too, last resort
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
return selected return selected
+9 -3
View File
@@ -1,14 +1,20 @@
| = tabled for now | = 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 * = 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] * 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] * 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] | 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] | 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.] * 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] * 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] * 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 from goodnews.briefs import _select_diverse
def row(id, source, category): def row(id, source, topic):
# _select_diverse only reads these three keys; plain dicts support [] access. # _select_diverse reads id, source_name, topic; plain dicts support [] access.
return {"id": id, "source_name": source, "default_category": category} return {"id": id, "source_name": source, "topic": topic}
def test_prefers_distinct_sources_best_first(): def test_prefers_distinct_sources_best_first():
rows = [ rows = [
row(1, "A", "science"), row(1, "A", "science"),
row(2, "A", "science"), # same source as #1 — should be skipped while others remain row(2, "A", "science"), # same source as #1 — skipped while others remain
row(3, "B", "science"), row(3, "B", "health"),
row(4, "C", "environment"), row(4, "C", "environment"),
] ]
selected = _select_diverse(rows, limit=3) assert [r["id"] for r in _select_diverse(rows, limit=3)] == [1, 3, 4]
ids = [r["id"] for r in selected]
assert ids == [1, 3, 4] # one per source, ranked order preserved
def test_backfills_when_sources_exhausted(): def test_caps_a_topic_when_alternatives_exist():
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():
rows = [ rows = [
row(1, "A", "science"), row(1, "A", "science"), row(2, "B", "science"),
row(2, "B", "science"), row(3, "C", "science"), row(4, "D", "science"),
row(3, "C", "science"), row(5, "E", "community"), row(6, "F", "animals"), row(7, "G", "culture"),
row(4, "D", "environment"), # the only other category, lowest ranked
] ]
selected = _select_diverse(rows, limit=3) selected = _select_diverse(rows, limit=5, max_per_topic=2)
cats = {r["default_category"] for r in selected} topics = [r["topic"] for r in selected]
assert len(selected) == 3 assert len(selected) == 5
assert len(cats) >= 2 # environment swapped in for diversity assert topics.count("science") == 2 # capped, even though 4 were available
assert any(r["default_category"] == "environment" for r in selected) assert {"community", "animals", "culture"} <= set(topics)
def test_keeps_single_category_when_no_alternative_exists(): def test_relaxes_cap_when_only_one_topic_available():
rows = [row(1, "A", "science"), row(2, "B", "science"), row(3, "C", "science")] rows = [row(i, f"S{i}", "science") for i in range(1, 6)]
selected = _select_diverse(rows, limit=3) selected = _select_diverse(rows, limit=5)
assert len(selected) == 3 assert len(selected) == 5 # all science: cap relaxed because nothing else exists
assert {r["default_category"] for r in selected} == {"science"}
def test_never_returns_more_than_limit(): def test_backfills_repeating_source_when_needed():
rows = [row(i, f"S{i}", "science") for i in range(10)] 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 assert len(_select_diverse(rows, limit=5)) == 5