Paywall awareness (#6) + replace-an-article (#7)

- paywall.py: conservative domain-level paywall detection (New Scientist,
  Nature, and common hard/soft paywalls). Never fetches pages — an honest hint.
- API: Article gains a 'paywalled' flag; the brief now leads with a gentle AND
  readable story (paywalled/charged stories stay in the five, just not first).
- New GET /api/replacement returns the next-best readable, unshown article
  (honors mood+prefs via the merged prefs param; gentle=true for hero swaps).
- UI: paywalled cards show 'May need a subscription'; a Replace / 'Find one I
  can read' action (always visible, while tuning actions stay tucked) swaps the
  card for a readable alternative, with a gentle notice when none remain.
- Tests: paywall detection + replacement behavior (77 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-31 00:39:13 +00:00
parent 06c2704ae0
commit bfd612eb9b
7 changed files with 226 additions and 16 deletions
+21 -4
View File
@@ -1,5 +1,5 @@
<script>
let { article, onaction, hero = false } = $props();
let { article, onaction, onreplace, hero = false } = $props();
let imgOk = $state(!!article.image_url);
let hasImg = $derived(!!(article.image_url && imgOk));
@@ -37,6 +37,10 @@
<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}
@@ -46,6 +50,11 @@
{/if}
<div class="actions">
{#if onreplace}
<button class="replace" onclick={() => onreplace(article)}>
{article.paywalled ? 'Find one I can read' : 'Replace'}
</button>
{/if}
{#if article.topic}<button onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if article.flavor}<button onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if article.topic}<button onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
@@ -84,6 +93,11 @@
h3 { font-size: 1.18rem; }
h3 a:hover { color: var(--sage-deep); }
.desc { margin: 2px 0 0; color: #3c463a; }
.paywall {
margin: 0; font-size: 0.78rem; color: var(--gold);
display: inline-flex; align-items: center; gap: 5px;
}
.paywall::before { content: '🔒'; font-size: 0.72rem; filter: grayscale(0.3); }
.why {
margin: 2px 0 0; font-style: italic; color: var(--muted);
font-size: 0.9rem; padding-left: 12px; border-left: 2px solid var(--sage-soft);
@@ -104,10 +118,13 @@
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 { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions,
article:focus-within .actions { opacity: 1; }
.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 */