home: reveal the news photo only once it actually loads (retry + graceful fallback)

The hub painted the lead news image as a CSS background straight from the source's
hotlinked URL — one transient failure (slow/rate-limited third-party CDN) left a
blank plate until you refreshed and the browser served it from cache. Now the probe
that already runs for cover-vs-figure detection gates the photo: load with up to two
retries (0.5s/1s backoff), reveal the plate only once it's truly loaded (and cached),
and otherwise keep the typographic topic cover. Soft fade-in on arrival; reduced-motion
honored. No more blank-until-refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-29 19:58:26 -04:00
parent d98cec9ded
commit cb06d550bd
+22 -8
View File
@@ -12,6 +12,7 @@
let news = $state(null); // {id, title, summary, image, topic, ...} let news = $state(null); // {id, title, summary, image, topic, ...}
let art = $state(null); // {title, artist, year, image} let art = $state(null); // {title, artist, year, image}
let newsFit = $state('cover'); // 'cover' = full-bleed photo; 'contain' = framed-plate figure let newsFit = $state('cover'); // 'cover' = full-bleed photo; 'contain' = framed-plate figure
let newsImgOk = $state(false); // reveal the photo only once it's truly loaded (else the typo cover stays)
// Pictureless articles (~half the feed) get a typographic category cover instead of a blank // Pictureless articles (~half the feed) get a typographic category cover instead of a blank
// well: the topic word on a soft topic-tinted field, color-coded across the feed. // well: the topic word on a soft topic-tinted field, color-coded across the feed.
@@ -112,14 +113,25 @@
try { try {
const it = (await getJSON(`/api/brief?limit=1${homeq}${q ? '&' + q : ''}`))?.items?.[0]; const it = (await getJSON(`/api/brief?limit=1${homeq}${q ? '&' + q : ''}`))?.items?.[0];
if (it) news = { id: it.id, title: it.title, summary: it.summary || it.description || '', image: it.image_url || null, topic: it.topic || null, source_read_minutes: it.source_read_minutes }; if (it) news = { id: it.id, title: it.title, summary: it.summary || it.description || '', image: it.image_url || null, topic: it.topic || null, source_read_minutes: it.source_read_minutes };
// Photos display full (cover); only wide/tall figures (diagrams) get the framed plate. // News images are hotlinked from the source, so a single fetch can transiently
// fail (slow CDN, rate limit, hiccup) and leave a blank plate until you refresh.
// Probe with a couple of retries and reveal the photo only once it's truly loaded
// (and thus cached) — otherwise the typographic topic cover stays. The probe also
// tells us cover (photo) vs. framed plate (wide/tall figure) from real dimensions.
if (news?.image) { if (news?.image) {
const probe = new Image(); const url = news.image;
probe.onload = () => { let tries = 0;
const a = probe.naturalWidth / probe.naturalHeight; const load = () => {
newsFit = a >= 0.85 && a <= 1.9 ? 'cover' : 'contain'; const probe = new Image();
probe.onload = () => {
const a = probe.naturalWidth / probe.naturalHeight;
newsFit = a >= 0.85 && a <= 1.9 ? 'cover' : 'contain';
newsImgOk = true;
};
probe.onerror = () => { if (++tries <= 2) setTimeout(load, 500 * tries); };
probe.src = url;
}; };
probe.src = news.image; load();
} }
} catch { /* fall back to design copy */ } } catch { /* fall back to design copy */ }
@@ -178,7 +190,7 @@
<!-- Good News (tall) — a card with TWO links, so it's a div, not a single anchor --> <!-- Good News (tall) — a card with TWO links, so it's a div, not a single anchor -->
<div class="card news"> <div class="card news">
<a class="news-photo-a" href={news?.id ? `/a/${news.id}` : '/'} aria-label="Read this article"> <a class="news-photo-a" href={news?.id ? `/a/${news.id}` : '/'} aria-label="Read this article">
{#if news?.image} {#if news?.image && newsImgOk}
<div class="news-photo {newsFit}"> <div class="news-photo {newsFit}">
<div class="news-plate" style={`background-image:url(${news.image})`}></div> <div class="news-plate" style={`background-image:url(${news.image})`}></div>
</div> </div>
@@ -383,7 +395,9 @@
.headline-a:hover h2 { color: var(--teal); } .headline-a:hover h2 { color: var(--teal); }
/* Photos fill edge-to-edge (cover, no box). Only figures/diagrams (detected by their /* Photos fill edge-to-edge (cover, no box). Only figures/diagrams (detected by their
wide/tall shape) get the soft tinted matte + white framed plate, so labels stay whole. */ wide/tall shape) get the soft tinted matte + white framed plate, so labels stay whole. */
.news-photo { aspect-ratio: 5/4; } .news-photo { aspect-ratio: 5/4; animation: news-photo-in 0.45s ease both; }
@keyframes news-photo-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .news-photo { animation: none; } }
.news-plate { background-position: center; background-repeat: no-repeat; } .news-plate { background-position: center; background-repeat: no-repeat; }
.news-photo.cover .news-plate { width: 100%; height: 100%; background-size: cover; } .news-photo.cover .news-plate { width: 100%; height: 100%; background-size: cover; }
.news-photo.contain { .news-photo.contain {