diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index 3803642..dfecb1e 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -1,14 +1,15 @@ -
- {#if hasImg} + {#if showImage} (failed = true)} /> @@ -43,14 +41,14 @@

{article.title}

- {#if article.paywalled} -

May need a subscription to read

- {/if} - {#if hero && article.description}

{article.description}

{/if} + {#if article.paywalled} +

May need a subscription to read

+ {/if} + {#if article.reason_text}

{article.reason_text}

{/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; } diff --git a/goodnews/briefs.py b/goodnews/briefs.py index 76c0ea7..4dfdc4f 100644 --- a/goodnews/briefs.py +++ b/goodnews/briefs.py @@ -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 diff --git a/ideas.md b/ideas.md index 5d10202..31205a2 100644 --- a/ideas.md +++ b/ideas.md @@ -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] \ No newline at end of file +* 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 diff --git a/tests/test_briefs.py b/tests/test_briefs.py index a601879..28092a2 100644 --- a/tests/test_briefs.py +++ b/tests/test_briefs.py @@ -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