Crisp hero (prefer og:image), 7-card Highlights, no-recycle Replace + session History

- Hero blur fix: brief enrichment now prefers a page's og:image even when a
  feed thumbnail exists (feed thumbs are often tiny; the hero is shown large).
  Verified: BBC hero upgrades to the 1024px share image, ScienceDaily to 1920px.
- Today is now 'Highlights from Today' — hero + 6 (brief size 7), which also
  makes the secondary grid a balanced 3+3 instead of an orphaned 3+1.
- Replace now excludes every article seen this session (a client-side seen-set),
  so it never cycles back to something already shown.
- New session History panel (this tab only, no account): lists everything seen,
  including swapped-away stories, so they stay recoverable. Persistent
  history/favorites are tabled for sign-in later.

Tests: og:image upgrade of an existing feed image (86 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-31 12:56:57 +00:00
parent 9e8eddf46d
commit d8d665ee35
6 changed files with 111 additions and 37 deletions
+84 -31
View File
@@ -12,39 +12,52 @@
let feed = $state([]);
let userPrefs = $state(P.blank());
let showBoundaries = $state(false);
let showHistory = $state(false);
let loading = $state(true);
let error = $state('');
// Session memory (this tab only — cleared on close, no account):
// every article shown is remembered so Replace never recycles something
// already seen, and so a replaced-away story stays recoverable in History.
const seenIds = new Set();
let history = $state([]);
function remember(items) {
for (const a of items || []) {
if (a && !seenIds.has(a.id)) {
seenIds.add(a.id);
history.unshift(a);
}
}
}
let filtersOn = $derived(P.active(userPrefs));
let current = $derived(moods.find((m) => m.key === selected));
let viewLabel = $derived(current?.label ?? 'Today');
let viewLabel = $derived(selected === 'today' ? 'Highlights from Today' : (current?.label ?? ''));
let viewSubtitle = $derived(
selected === 'today'
? brief?.brief_date
? `Five good things · ${brief.brief_date}`
: 'Five good things today'
: (current?.description ?? '')
selected === 'today' ? (brief?.brief_date ?? '') : (current?.description ?? '')
);
function feedUrl(moodKey, limit) {
const mood = moods.find((m) => m.key === moodKey);
const merged = P.merge(userPrefs, mood?.filter ?? {});
const q = P.param(merged);
return `/api/feed?limit=${limit}${q ? '&' + q : ''}`;
}
function briefUrl() {
const q = P.param(userPrefs);
return `/api/brief?limit=5${q ? '&' + q : ''}`;
function mergedParam() {
const merged = P.merge(userPrefs, current?.filter ?? {});
return P.param(merged);
}
async function select(key) {
selected = key;
error = '';
try {
// Today = just the day's highlights. Other moods reveal that category only
// when chosen (categories live behind their selection, not on the home).
if (key === 'today') brief = await getJSON(briefUrl());
else feed = (await getJSON(feedUrl(key, 24))).items;
// Today = the day's highlights (hero + six). Other moods reveal that
// category only when chosen.
if (key === 'today') {
const q = P.param(userPrefs);
brief = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}`);
remember(brief.items);
} else {
const mood = moods.find((m) => m.key === key);
const q = P.param(P.merge(userPrefs, mood?.filter ?? {}));
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}`)).items;
remember(feed);
}
} catch (e) {
error = 'Something went quiet — could not reach the feed.';
}
@@ -70,11 +83,11 @@
async function replaceArticle(article) {
const list = selected === 'today' ? brief?.items : feed;
if (!list) return;
const shown = list.map((a) => a.id).join(',');
const isHero = selected === 'today' && list[0]?.id === article.id;
const merged = P.merge(userPrefs, current?.filter ?? {});
const q = P.param(merged);
const url = `/api/replacement?exclude=${shown}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
// Exclude everything seen this session, so Replace never cycles back.
const exclude = Array.from(seenIds).join(',');
const q = mergedParam();
const url = `/api/replacement?exclude=${exclude}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
let repl;
try {
repl = await getJSON(url);
@@ -83,9 +96,10 @@
return;
}
if (!repl) {
flash('Nothing else to swap in right now — try easing a boundary.');
flash("That's everything fresh for now — nothing new to swap in.");
return;
}
remember([repl]);
if (selected === 'today') {
const i = brief.items.findIndex((a) => a.id === article.id);
if (i >= 0) {
@@ -118,7 +132,8 @@
{/if}
<div class="toptools">
<button class="boundaries" class:on={filtersOn} onclick={() => (showBoundaries = !showBoundaries)}>
<button class="link" class:on={history.length} onclick={() => (showHistory = !showHistory)}>History</button>
<button class="link" class:on={filtersOn} onclick={() => (showBoundaries = !showBoundaries)}>
{filtersOn ? 'Boundaries ·' : 'Boundaries'}
</button>
</div>
@@ -127,6 +142,28 @@
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
{/if}
{#if showHistory}
<section class="panel rise">
<div class="phead">
<h2>This session</h2>
<button class="close" onclick={() => (showHistory = false)}>done</button>
</div>
<p class="reassure">Everything you've seen this visit, including stories you swapped away. Kept on this device for this tab only — it clears when you close it. (Saved history & favorites come with sign-in, later.)</p>
{#if history.length}
<ul class="hist">
{#each history as a (a.id)}
<li>
<a href={a.url} target="_blank" rel="noopener">{a.title}</a>
<span class="hsrc">{a.source}</span>
</li>
{/each}
</ul>
{:else}
<p class="empty">Nothing yet — your seen stories will appear here.</p>
{/if}
</section>
{/if}
{#if notice}
<p class="notice rise">{notice}</p>
{/if}
@@ -156,7 +193,7 @@
</section>
<p class="endcap rise">✦ that's the good news for today ✦</p>
{:else}
<p class="muted center pad">No brief yet today — try a calmer filter, or check back soon.</p>
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
{/if}
{:else if feed.length}
<div class="grid rise">
@@ -171,13 +208,13 @@
{/if}
<style>
.toptools { display: flex; justify-content: flex-end; margin: 2px 0 0; }
.boundaries {
.toptools { display: flex; justify-content: flex-end; gap: 18px; margin: 2px 0 0; }
.link {
background: none; border: none; color: var(--muted);
font-size: 0.82rem; padding: 4px 2px; letter-spacing: 0.01em;
}
.boundaries:hover { color: var(--sage-deep); }
.boundaries.on { color: var(--sage-deep); font-weight: 600; }
.link:hover { color: var(--sage-deep); }
.link.on { color: var(--sage-deep); font-weight: 600; }
.view-head { margin: 20px 0 20px; }
.view-head h1 {
@@ -185,12 +222,28 @@
line-height: 1.05;
}
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
/* a quiet sage rule under the heading */
.view-head::after {
content: ''; display: block; width: 46px; height: 3px;
background: var(--sage); border-radius: 2px; margin-top: 14px; opacity: 0.8;
}
/* History panel */
.panel {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 20px 22px; margin: 8px 0 18px;
}
.phead { display: flex; align-items: baseline; justify-content: space-between; }
.phead h2 { font-size: 1.3rem; }
.close { background: none; border: none; color: var(--sage-deep); font-size: 0.85rem; text-decoration: underline; }
.reassure { margin: 4px 0 14px; color: var(--muted); font-size: 0.85rem; }
.hist { list-style: none; margin: 0; padding: 0; }
.hist li { padding: 8px 0; border-bottom: 1px solid var(--line); display: flex; gap: 12px; align-items: baseline; }
.hist li:last-child { border-bottom: none; }
.hist a { color: var(--ink); }
.hist a:hover { color: var(--sage-deep); }
.hsrc { margin-left: auto; color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
.empty { margin: 0; color: var(--muted); font-style: italic; font-size: 0.85rem; }
.notice {
text-align: center; color: var(--sage-deep); background: var(--sage-soft);
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content;