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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user