Files
upbeatBytes/frontend/src/routes/+page.svelte
T
thejayman77 8a7606e20d images: fix two fetcher bugs + add source-level image-rights policy (Codex)
Fetcher (the two remaining bugs Codex found):
- Real redirects are now followed. _NoRedirect makes urllib RAISE HTTPError on 3xx, so
  the old status-branch was dead code (mocked tests masked it). Handle 301/302/303/307/308
  HTTPError as redirects (re-validate the destination); classify 4xx≠429 as PERMANENT
  (negative-cached), 429/5xx/network as transient. Real-opener redirect + 404/5xx tests.
- The megapixel ceiling is now enforced: explicit `w*h > _MAX_PIXELS` check BEFORE load()
  (Pillow only warns at MAX_IMAGE_PIXELS). Test with a lowered ceiling.

Image-rights policy (per Codex + owner decision — only cache what's cleared):
- sources.image_policy: 'cache' (re-host a downscaled copy — license/permission/PD only),
  'remote' (hotlink the publisher's image — the conservative DEFAULT), 'none' (no image).
- newsimg.display_url resolves the display URL per policy; applied in Article.from_row so
  feed/brief/history return the right URL, and in share.py (og/twitter still reference the
  publisher's own image, never re-hosted). warm() + /api/img both gated on 'cache'.
- Frontend uses the server-resolved image_url (reverted the hardcoded /api/img); the
  graceful retry covers remote hotlinks too. Admin: per-source image-policy selector +
  POST /api/admin/sources/{id}/image-policy. Default 'remote' → nothing re-hosted until
  a source is explicitly cleared.

445 backend + 36 frontend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:01:11 -04:00

592 lines
34 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import * as P from '$lib/prefs.js';
import { prefs, initPrefs } from '$lib/prefs.svelte.js';
import HubBar from '$lib/components/HubBar.svelte';
import Footer from '$lib/components/Footer.svelte';
// The homepage hub (Claude Design "Frame A" — editorial, with colour): a news teaser,
// Daily Art, Play, and the rotating small-joys rail, wired to live data. Legacy
// root-query links (/?view=…) are redirected to /news by +page.js before this renders.
let news = $state(null); // {id, title, summary, image, topic, ...}
let art = $state(null); // {title, artist, year, image}
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
// well: the topic word on a soft topic-tinted field, color-coded across the feed.
const TOPIC_TINT = {
science: { bg: '#e2edf1', ink: '#2f6d86' },
technology: { bg: '#e8e9f4', ink: '#5a5fa0' },
environment: { bg: '#e6efe2', ink: '#3a7d5b' },
health: { bg: '#f4e8e8', ink: '#a8566a' },
community: { bg: '#f3ece0', ink: '#a9763f' },
culture: { bg: '#ece5f3', ink: '#7e51b0' },
world: { bg: '#e6ecf3', ink: '#4a6da0' },
space: { bg: '#e7e7f2', ink: '#4a4e8a' },
};
const TOPIC_DEFAULT = { bg: '#e6edef', ink: '#3f6378' };
let topicTint = $derived(TOPIC_TINT[(news?.topic || '').toLowerCase()] || TOPIC_DEFAULT);
let word = $state(null); // /api/word/today
let quote = $state(null); // /api/quote/today
let fact = $state(null); // /api/onthisday/today
// small-joys display helpers
const POS = { noun: 'n.', adjective: 'adj.', verb: 'v.', adverb: 'adv.', pronoun: 'pron.',
preposition: 'prep.', conjunction: 'conj.', interjection: 'interj.' };
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
const clip = (s, n) => {
if (!s || s.length <= n) return s || '';
const cut = s.slice(0, n), i = cut.lastIndexOf(' ');
return (i > 0 ? cut.slice(0, i) : cut).replace(/[\s,;:.]+$/, '') + '…';
};
// truncation handled by CSS (-webkit-line-clamp:2) — breaks on whole words, fills 2 full lines
let headline = $derived(news?.title ?? 'What went right this week: the good news that actually matters');
// Sell the value proposition right on the card: the quick UB brief vs. the full article.
// "1 min brief · ~10 min full story" (both known)
// "1 min brief" (full-story time unknown — never fake it)
// hidden (no brief/summary yet)
const briefMins = (t) => Math.max(1, Math.round((t || '').trim().split(/\s+/).filter(Boolean).length / 200));
let readBadge = $derived.by(() => {
if (!news?.summary) return '';
const brief = `${briefMins(news.summary)} min brief`;
return news.source_read_minutes ? `${brief} · ~${news.source_read_minutes} min full story` : brief;
});
// small-joys shelf: a single-row, swipeable rail (all 3 cards live in the row; the next
// peeks). Arrows scroll it; the counter/dots track the snapped card from scroll position.
// Phones get a swipeable rail (all 3 cards in a scroll row, the next peeking). Wider
// screens keep the original two-up view with the arrows rotating which two show — a
// rail there felt like a broken carousel. isNarrow drives BOTH the DOM and the layout
// so they never disagree (no "3 cards wrapping in a 2-col grid").
let railEl = $state(null);
let joyIdx = $state(0);
let isNarrow = $state(false);
$effect(() => {
const mq = window.matchMedia('(max-width: 520px)');
const sync = (e) => { isNarrow = e.matches; };
sync(mq);
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
});
function railTo(i) {
joyIdx = Math.max(0, Math.min(2, i));
const card = railEl?.children?.[joyIdx];
if (card) railEl.scrollTo({ left: card.offsetLeft, behavior: 'smooth' });
}
let scrollRAF = 0;
function onRailScroll() {
if (!railEl) return;
cancelAnimationFrame(scrollRAF);
scrollRAF = requestAnimationFrame(() => {
const x = railEl.scrollLeft;
let best = 0, bestD = Infinity;
for (let i = 0; i < railEl.children.length; i++) {
const d = Math.abs(railEl.children[i].offsetLeft - x);
if (d < bestD) { bestD = d; best = i; }
}
joyIdx = best;
});
}
// desktop = rotate which two are shown; mobile = scroll the rail
const prevJoy = () => (isNarrow ? railTo(joyIdx - 1) : (joyIdx = (joyIdx + 2) % 3));
const nextJoy = () => (isNarrow ? railTo(joyIdx + 1) : (joyIdx = (joyIdx + 1) % 3));
onMount(async () => {
initPrefs(); // load the reader's saved boundaries so the teaser honors them (like the News Brief)
try {
const a = await getJSON('/api/art/today');
if (a) art = { title: a.title, artist: a.artist, year: a.date_text, image: a.image_url };
} catch { /* fall back to gradient swatch */ }
let homeq = '';
try {
const hv = localStorage.getItem('goodnews:home') || '';
const hs = localStorage.getItem('goodnews:homeScope') || 'nearby';
if (hv && hs !== 'world') homeq = `&home=${encodeURIComponent(hv)}&scope=${hs}`;
} catch { /* global brief */ }
const q = P.param(prefs.data); // the reader's boundaries (excluded topics, ceilings)
try {
const it = (await getJSON(`/api/brief?limit=1${homeq}${q ? '&' + q : ''}`))?.items?.[0];
// image_url is already resolved server-side per the source's image policy (our
// cached copy for cleared sources, else the publisher URL); the probe below loads
// it with retries either way.
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 };
// 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) {
const url = news.image;
let tries = 0;
const load = () => {
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;
};
load();
}
} catch { /* fall back to design copy */ }
// small joys (each falls back to its placeholder if the engine has nothing yet)
try { word = await getJSON('/api/word/today'); } catch { /* placeholder */ }
try { quote = await getJSON('/api/quote/today'); } catch { /* placeholder */ }
try { fact = await getJSON('/api/onthisday/today'); } catch { /* placeholder */ }
});
</script>
<svelte:head>
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
</svelte:head>
{#snippet joyCard(i)}
{#if i === 0}
<a class="joy joy-word" href="/word">
<span class="wm" aria-hidden="true">{word ? cap(word.word)[0] : 'S'}</span>
<div class="joy-in">
<div class="tag"><span class="rule"></span><span class="tag-label">Word of the day</span></div>
<p class="word-line"><span class="word">{word ? cap(word.word) : 'Serene'}</span> <span class="word-pos">{word ? (POS[word.part_of_speech] ?? word.part_of_speech ?? '') : 'adj.'}</span></p>
<p class="word-pron">{word?.phonetic ?? '/səˈriːn/'}</p>
<p class="def">{word ? clip(word.definition, 78) : 'Calm, peaceful, and untroubled. The quiet after a storm passes.'}</p>
</div>
</a>
{:else if i === 1}
<a class="joy joy-quote" href="/quote">
<span class="wm wm-q" aria-hidden="true"></span>
<div class="joy-in">
<div class="tag"><span class="rule"></span><span class="tag-label">Quote of the day</span></div>
<p class="quote">{quote ? clip(quote.text, 92) : 'Very little is needed to make a happy life.'}</p>
<div class="attrib"><span class="attrib-rule"></span><span class="attrib-by">{quote?.author ?? 'Marcus Aurelius'}</span></div>
</div>
</a>
{:else}
<a class="joy joy-fact" href="/onthisday">
<div class="joy-in">
<div class="tag"><span class="rule"></span><span class="tag-label">On this day</span></div>
<p class="fact-hero"><span class="year">{fact?.year ?? '1928'}</span> <span class="onthis">in history</span></p>
<p class="fact">{fact ? clip(fact.text, 96) : 'Penicillin was discovered by a happy accident.'}</p>
</div>
</a>
{/if}
{/snippet}
<div class="page">
<HubBar active="home" />
<section class="hero">
<h1>A <span class="t">calmer</span>, <span class="b">brighter</span> corner of the internet.</h1>
<p class="sub">Good news, daily art, small games, and little resets.</p>
</section>
<main class="bento">
<!-- Good News (tall) — a card with TWO links, so it's a div, not a single anchor -->
<div class="card news">
<a class="news-photo-a" href={news?.id ? `/a/${news.id}` : '/'} aria-label="Read this article">
{#if news?.image && newsImgOk}
<div class="news-photo {newsFit}">
<div class="news-plate" style={`background-image:url(${news.image})`}></div>
</div>
{:else}
<!-- pictureless: typographic category cover -->
<div class="news-typo" style={`background:${topicTint.bg}`}>
<span class="news-typo-word" style={`color:${topicTint.ink}`}>{news?.topic || 'good news'}</span>
</div>
{/if}
</a>
<div class="news-body">
<span class="label" style="color:#0083ad">GOOD NEWS</span>
<a class="headline-a" href={news?.id ? `/a/${news.id}` : '/'}><h2>{headline}</h2></a>
<a class="summary-a" href={news?.id ? `/a/${news.id}` : '/'}>
<p class="summary">{news?.summary || "We read the week so you don't have to doomscroll it. Five quietly hopeful stories, summarised to the gist."}</p>
</a>
<div class="news-foot">
{#if readBadge}<span class="meta">{readBadge}</span>{/if}
</div>
<hr class="news-div" />
<a class="news-more" href="/news">Read more good news →</a>
</div>
</div>
<div class="rightcol">
<!-- Daily Art (wide) -->
<a class="card art" href="/art">
<div class="art-body">
<span class="label" style="color:#8857C2">DAILY ART</span>
<h3>A masterwork a day</h3>
<p class="art-today">
{#if art}Today: <span class="ital">{art.title}</span>{#if art.artist} — {art.artist}{/if}{#if art.year}, {art.year}{/if}.
{:else}Today: <span class="ital">Among the Sierra Nevada</span> — Bierstadt, 1868.{/if}
</p>
<span class="link art-link">View today</span>
</div>
<div class="art-swatch" style={art?.image ? `--art:url(${art.image})` : ''}></div>
</a>
<!-- bottom pair — vertically centered in the space beneath the pinned Art card -->
<div class="pair-wrap">
<div class="pair">
<a class="card play" href="/play">
<div class="play-top">
<span class="label" style="color:#A8650F">PLAY</span>
<h3>A little daily puzzle</h3>
</div>
<!-- "bleeding boards": three game motifs clipping at the card edges (decorative) -->
<div class="play-band" aria-hidden="true">
<div class="wb">
<div class="wb-row"><span class="wb-t wb-a">E</span><span class="wb-t wb-n">A</span><span class="wb-t wb-a">T</span></div>
<div class="wb-row"><span class="wb-t wb-g">Y</span><span class="wb-t wb-g">T</span><span class="wb-t wb-g">E</span></div>
<div class="wb-row"><span class="wb-t wb-e"></span><span class="wb-t wb-e"></span><span class="wb-t wb-d"></span></div>
</div>
<div class="ws">
<span>K</span><span>R</span><span>O</span><span>A</span><span>E</span><span>S</span>
<span class="hl">B</span><span class="hl">Y</span><span class="hl">T</span><span class="hl">E</span><span class="hl">S</span><span>W</span>
<span>T</span><span>I</span><span>M</span><span>U</span><span>H</span><span>P</span>
<span>G</span><span>E</span><span>B</span><span>O</span><span>R</span><span>L</span>
<span>F</span><span>I</span><span>N</span><span>D</span><span>C</span><span>A</span>
<span>Z</span><span>O</span><span>S</span><span>E</span><span>K</span><span>Y</span>
</div>
<div class="mm">
<span class="mm-a"></span>
<span class="mm-w"><span class="mm-dot" style="background:#6bbf8c"></span></span>
<span class="mm-a"></span>
<span class="mm-w"><span class="mm-dot" style="background:#6bbf8c"></span></span>
<span class="mm-a"></span>
<span class="mm-a"></span>
<span class="mm-a"></span>
<span class="mm-w"><span class="mm-dot" style="background:#D2861B"></span></span>
<span class="mm-a"></span>
</div>
</div>
<div class="play-foot"><span class="link play-link">Enter</span></div>
</a>
<div class="card moment">
<div class="moment-top">
<span class="label" style="color:#3F9A66">ENTERTAINMENT</span>
<span class="soon">SOON</span>
</div>
<div class="moment-mid">
<div class="ent-icons" aria-hidden="true">
<span class="ent-icon">
<svg viewBox="0 0 24 24"><circle cx="8" cy="9" r="3" fill="#e3a24c" /><circle cx="16" cy="9" r="3" fill="#5aa0c8" /><circle cx="12" cy="15.5" r="3" fill="#5bbf86" /></svg>
</span>
<span class="ent-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#3F9A66" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9" /><path d="M8.5 14a4.5 4.5 0 0 0 7 0" /><circle cx="9" cy="10" r="1" fill="#3F9A66" stroke="none" /><circle cx="15" cy="10" r="1" fill="#3F9A66" stroke="none" /></svg>
</span>
<span class="ent-icon">
<svg viewBox="0 0 24 24" fill="#3F9A66"><path d="M12 3l1.6 7.4L21 12l-7.4 1.6L12 21l-1.6-7.4L3 12l7.4-1.6z" /></svg>
</span>
</div>
<p class="moment-line">A little something to enjoy.</p>
</div>
<span class="moment-meta">Coloring, characters, and curiosities. Coming soon.</span>
</div>
</div>
</div>
<!-- "small joys" — a single swipeable row; all three live in the rail, the next peeks -->
<div class="joys-shelf">
<div class="joys-head">
<span class="jt-label">Small joys for today</span>
<span class="jt-line"></span>
<span class="jt-count">{joyIdx + 1} / 3</span>
<div class="joys-arrows">
<button class="arrow" onclick={prevJoy} aria-label="Previous small joy"></button>
<button class="arrow" onclick={nextJoy} aria-label="Next small joy"></button>
</div>
</div>
<div class="joys" class:rail={isNarrow} bind:this={railEl} onscroll={onRailScroll}>
{#if isNarrow}
{@render joyCard(0)}
{@render joyCard(1)}
{@render joyCard(2)}
{:else}
{@render joyCard(joyIdx)}
{@render joyCard((joyIdx + 1) % 3)}
{/if}
</div>
</div>
</div>
</main>
<Footer />
</div>
<style>
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
@font-face { font-family: 'Work Sans'; src: url('/fonts/work-sans-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
.page {
--ink: #1c1916; --body: #6b6256; --muted: #a89e8c; --teal: #0083ad;
--canvas: #FFF9EF; --news-border: #f2e7d3;
min-height: 100vh; background: var(--canvas); color: #23201b;
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
display: flex; flex-direction: column;
overflow-x: clip; /* seatbelt: a wide child (e.g. the joys rail) can never scroll the page */
}
.page :global(*) { box-sizing: border-box; }
/* Hero — spacing tuned per the /home2 pass: pulled up a touch, more air before cards */
.hero { text-align: center; max-width: 1180px; width: 100%; margin: 0 auto; padding: clamp(24px, 4vw, 34px) clamp(18px, 5vw, 44px) clamp(38px, 5vw, 48px); }
.hero h1 {
font-family: 'Newsreader', Georgia, serif; font-weight: 500;
font-size: clamp(2.1rem, 5vw, 50px); line-height: 1.04; letter-spacing: -0.015em; margin: 0; color: var(--ink);
}
.hero h1 .t { color: #0083ad; }
.hero h1 .b { color: #E0852C; }
.hero .sub { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 2vw, 19px); color: #857b6c; margin: 14px 0 0; }
/* Bento grid. The cards use Work Sans (per CD's mockup — a touch bolder/darker than our
body Hanken); headings stay Newsreader (set on h2,h3 below). */
.bento {
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
padding: 0 clamp(18px, 5vw, 44px) 16px;
display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(0, 1.82fr); gap: 16px;
font-family: 'Work Sans', 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
}
/* Containment: the grid items + flex chain must be allowed to shrink below their content,
or the phone joys rail forces the whole page wider than the viewport. (NOT .bento — it
keeps its own max-width:1180px; listing it here would override that and go full-width.) */
.news, .rightcol, .pair-wrap, .joys-shelf, .joys { min-width: 0; max-width: 100%; }
/* right column matches the News height; Art stays pinned to the TOP and the Play/Moment
pair to the BOTTOM, with the extra space distributed BETWEEN them (FIX1). The cards
themselves keep their natural size and never stretch. */
.rightcol { display: flex; flex-direction: column; gap: 16px; }
.rightcol .art { flex: none; } /* Art pinned to the top */
.pair-wrap { flex: 1; display: flex; align-items: center; } /* fill the rest; pair vertically centered */
.card {
border-radius: 18px; overflow: hidden; text-decoration: none; color: inherit;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
a.card:hover { transform: translateY(-2px); }
/* every card opens with the same "eyelash" — a short dash in the card's accent (via
currentColor, so each label's inline colour drives it) + a tracked uppercase label */
.label {
display: inline-flex; align-items: center; gap: 9px;
font-size: 11px; font-weight: 600; letter-spacing: 0.18em; line-height: 1;
}
.label::before { content: ''; width: 20px; height: 2px; border-radius: 2px; background: currentColor; flex: none; }
.link { font-size: 14px; font-weight: 600; padding-bottom: 2px; align-self: flex-start; }
/* card titles: Newsreader, a calmer medium weight (per CD's mockup) */
h2, h3 { font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.01em; color: var(--ink); }
/* Good News — photo on top, equal height to the right column. The gist flex-fills the
card (basis:0 so it never drives the row height → the right column always sets it, never
stretched) and fades softly into a comfortable margin above the read-time. */
.news {
background: #fff; border: 1px solid var(--news-border);
display: flex; flex-direction: column; box-shadow: 0 6px 20px -14px rgba(0, 131, 173, 0.4);
}
/* photo + headline both link to the article (clickable, not just the text links) */
.news-photo-a { display: block; }
.news-photo-a:hover { filter: brightness(0.97); }
.headline-a { display: block; text-decoration: none; color: inherit; }
.headline-a:hover h2 { color: var(--teal); }
/* 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. */
.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-photo.cover .news-plate { width: 100%; height: 100%; background-size: cover; }
.news-photo.contain {
/* silvery at top, fading down into the card's white so the matte isn't a hard band */
background: linear-gradient(180deg, #e6edef 0%, #f3f6f5 55%, #ffffff 100%);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.news-photo.contain .news-plate {
width: 100%; height: 100%; box-sizing: border-box; padding: 12px;
background-color: #fff; border: 1px solid #e7edee; border-radius: 8px;
box-shadow: 0 6px 18px -10px rgba(30, 60, 70, 0.28);
background-size: contain; background-origin: content-box;
}
/* pictureless fallback: topic word on a soft topic-tinted field, same footprint as the photo */
.news-typo { aspect-ratio: 5/4; display: flex; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box; }
.news-typo-word {
font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(1.9rem, 5vw, 2.9rem);
line-height: 1.05; letter-spacing: -0.01em; text-transform: lowercase; text-align: center; max-width: 100%;
}
.news-body { padding: 24px 26px; flex: 1; display: flex; flex-direction: column; }
.news h2 {
font-size: clamp(1.55rem, 2.6vw, 30px); line-height: 1.14; margin: 12px 0 0;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
}
/* Desktop: the gist grows to fill the card (basis:0 so it never inflates the row height),
softly fading out so it never jams against the read-time — a comfortable margin either way. */
.summary-a {
flex: 1 1 0; min-height: 0; overflow: hidden; display: block; text-decoration: none; color: inherit;
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
}
.summary { font-size: 15px; line-height: 1.5; color: #5a5346; margin: 12px 0 0; }
.news-foot { display: flex; align-items: center; justify-content: flex-end; padding-top: 16px; }
.meta { font-size: 12px; color: var(--muted); line-height: 1.35; overflow-wrap: anywhere; }
/* divider sets the secondary "feed" link apart as its own thing */
.news-div { border: none; border-top: 1px solid #e6d9bf; margin: 14px 0 12px; }
.news-more { display: inline-block; font-size: 13px; font-weight: 600; color: var(--teal); text-decoration: none; }
.news-more:hover { text-decoration: underline; }
.news-more:hover { color: var(--teal); }
/* Daily Art — wide, text left + artwork swatch right */
.art { background: #F3EEF9; border: 1px solid #e4d8f1; display: flex; min-height: 188px; }
.art-body { flex: 1; padding: 24px 26px; display: flex; flex-direction: column; }
.art h3 { font-size: clamp(1.35rem, 2.1vw, 25px); line-height: 1.16; margin: 10px 0 0; color: #2a1c3d; }
.art-today { font-size: 14.5px; line-height: 1.5; color: #6f6280; margin: 11px 0 0; }
.ital { font-style: italic; font-family: 'Newsreader', Georgia, serif; }
/* a little breathing room before the link (per CD), not pinned tight under the caption */
.art-link { margin-top: 20px; color: #8857C2; border-bottom: 2px solid #c9aef0; }
/* swatch crops a few px off every edge (::after inset) so scanned paintings don't show
their ragged/black canvas edge at the top */
.art-swatch {
width: 46%; min-width: 130px; position: relative; overflow: hidden;
background: linear-gradient(170deg, #bfe0f0 0%, #a9cf9a 50%, #d89a4e 100%);
}
.art-swatch::after {
content: ""; position: absolute; inset: -6px;
background-image: var(--art); background-size: cover; background-position: center;
}
/* bottom pair */
.pair { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.play { background: #FFF3DC; border: 1px solid #f6e2b8; display: flex; flex-direction: column; }
.play-top { padding: 22px 24px 0; }
.play h3 { font-size: clamp(1.25rem, 1.9vw, 23px); margin: 14px 0 0; color: #5c3d0c; }
.play-foot { margin-top: auto; padding: 16px 24px 22px; }
.play-link { color: #A8650F; border-bottom: 2px solid #e0a94f; }
/* "bleeding boards" — three game motifs clipping at the card edges (decorative) */
/* Word search is the centred highlight; the two side games are the SAME size and each
bleeds ~half a column off its edge (consistent both sides) to imply "more under the hood". */
.play-band { position: relative; height: 124px; margin-top: 24px; overflow: hidden; }
.wb { position: absolute; top: 50%; left: -12px; transform: translateY(-50%); display: flex; flex-direction: column; gap: 4px; }
.wb-row { display: flex; gap: 4px; }
.wb-t { width: 24px; height: 24px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; color: #fff; }
.wb-g { background: #6bbf8c; } .wb-a { background: #E6A02C; } .wb-n { background: #d9c39a; }
.wb-e { background: #fff; border: 1.5px solid #ecca84; } .wb-d { background: #fff; border: 1.5px dashed #e0bb6f; }
.ws {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
display: grid; grid-template-columns: repeat(6, 14px); gap: 3px;
font-weight: 600; font-size: 10.5px; line-height: 14px; color: #d4b576; text-align: center;
background: #fff; border: 1.5px solid #f0d597; border-radius: 10px; padding: 9px;
box-shadow: 0 5px 16px -8px rgba(210, 134, 27, 0.55);
}
.ws .hl { color: #B5701A; font-weight: 800; }
.mm { position: absolute; top: 50%; right: -12px; transform: translateY(-50%); display: grid; grid-template-columns: repeat(3, 24px); grid-auto-rows: 24px; gap: 4px; }
.mm > span { border-radius: 6px; }
.mm-a { background: #E6A02C; }
.mm-w { background: #fff; border: 1.5px solid #f0d597; display: flex; align-items: center; justify-content: center; }
.mm-dot { width: 7px; height: 7px; border-radius: 50%; display: block; }
.moment { background: #E6F3E9; border: 1px solid #cee6d3; padding: 22px 24px; display: flex; flex-direction: column; }
.moment-top { display: flex; align-items: center; justify-content: space-between; }
.soon { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: #3F9A66; background: #fff; border-radius: 999px; padding: 3px 8px; }
/* centered motif: three small enjoy-icons + tagline fill the middle, caption at the foot */
.moment-mid { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; text-align: center; }
.ent-icons { display: flex; gap: 10px; }
.ent-icon { width: 46px; height: 46px; border-radius: 13px; background: #fff; border: 1px solid #d3e4d8; box-shadow: 0 4px 12px -8px rgba(40, 90, 60, 0.4); display: flex; align-items: center; justify-content: center; }
.ent-icon svg { width: 24px; height: 24px; display: block; }
.moment-line { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 18px; line-height: 1.3; color: #214a35; margin: 0; }
.moment-meta { margin-top: 14px; font-size: 13px; color: #6f9683; text-align: center; }
/* "small joys" rail — little jewels: one big focal point per card, a faint oversized
watermark glyph, an accent-tag label, soft diagonal gradient + long low shadow. */
.joys-shelf { flex: none; }
/* header: italic title · hairline rule · counter · arrows (the gesture is genuinely
horizontal now, so the 1/3 + affordance is honest) */
.joys-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.jt-label { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 18px; color: #3a342b; }
.jt-line { flex: 1; height: 1px; background: #e6dcc8; }
.jt-count { font-size: 12px; color: #b0a690; white-space: nowrap; }
.joys-arrows { display: flex; gap: 8px; }
.arrow {
width: 30px; height: 30px; border-radius: 50%; border: 1px solid #e0d3b8; background: transparent;
color: #b09a6e; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
padding: 0; line-height: 1; transition: background 0.15s ease, color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.arrow:hover { background: #fff; color: #9a7b3e; }
/* Desktop/tablet: the original two-up view (arrows rotate which two show). */
.joys { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.joy {
min-width: 0; position: relative; overflow: hidden; border-radius: 20px; padding: 18px 22px;
min-height: 170px; box-sizing: border-box; display: block; text-decoration: none; color: inherit;
transition: transform 0.16s ease, box-shadow 0.16s ease;
}
.joy:hover { transform: translateY(-2px); }
/* Phone: a single swipeable row; the next card peeks. Scrollbar hidden; snap per card. */
.joys.rail {
display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory;
padding-bottom: 6px; scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
.joys.rail::-webkit-scrollbar { display: none; }
.joys.rail .joy { flex: 0 0 min(86%, 344px); scroll-snap-align: start; }
.joy-in { position: relative; } /* content sits above the watermark */
.wm { position: absolute; font-family: 'Newsreader', Georgia, serif; line-height: 1; pointer-events: none; }
/* a fresh trio, distinct from the doors above (teal/plum/amber/green): sky · rose · clay */
.joy-word { background: linear-gradient(165deg, #EAF2F9, #DBE8F4); border: 1px solid #d2e1f0; box-shadow: 0 10px 30px -22px rgba(60, 100, 145, 0.55); --accent: #4f7da8; --rule: #4f7da8; }
.joy-word .wm { right: -14px; bottom: -30px; font-size: 150px; font-weight: 400; color: rgba(79, 125, 168, 0.13); }
.joy-quote { background: linear-gradient(165deg, #F9EDF1, #F1DEE6); border: 1px solid #eed6df; box-shadow: 0 10px 30px -22px rgba(150, 85, 115, 0.5); --accent: #b06a86; --rule: #b06a86; }
.joy-quote .wm { left: 14px; top: -26px; font-size: 120px; color: rgba(176, 106, 134, 0.16); }
.joy-fact { background: linear-gradient(165deg, #F7EAE1, #EFDACB); border: 1px solid #ecd5c4; box-shadow: 0 10px 30px -22px rgba(150, 90, 55, 0.5); --accent: #9a5a38; --rule: #9a5a38; }
.tag { display: flex; align-items: center; gap: 8px; }
.tag .rule { width: 18px; height: 2px; border-radius: 2px; background: var(--rule); }
.tag-label { font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent); }
.word-line { margin: 8px 0 0; display: flex; align-items: baseline; gap: 9px; }
.joy .word { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: 32px; line-height: 1; letter-spacing: -0.01em; color: #2c3a48; }
.word-pos { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 13px; color: #7d93a8; }
.word-pron { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 13px; color: #5f7791; margin: 2px 0 0; }
.joy .def { font-size: 13.5px; color: #45535d; margin: 8px 0 0; line-height: 1.45; }
.joy .quote { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 21px; line-height: 1.3; color: #3e2c36; margin: 16px 0 0; }
.attrib { display: flex; align-items: center; gap: 9px; margin-top: 12px; }
.attrib-rule { width: 22px; height: 1px; background: #d8afc1; }
.attrib-by { font-family: 'Newsreader', Georgia, serif; font-size: 13px; color: #97667f; }
.fact-hero { display: flex; align-items: baseline; gap: 8px; margin: 12px 0 0; }
.year { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: 30px; color: #7a4a30; line-height: 0.9; }
.onthis { font-size: 11px; color: #9e7a64; letter-spacing: 0.04em; }
.joy .fact { font-family: 'Newsreader', Georgia, serif; font-size: 16px; color: #5e4636; margin: 8px 0 0; line-height: 1.34; }
/* responsive — collapse the bento on narrow screens */
@media (max-width: 860px) {
.bento { grid-template-columns: minmax(0, 1fr); } /* minmax(0,…) so a wide child can't widen the page */
.news { grid-row: auto; }
/* phone: a short, clean 3-line taste (no fill/fade — the card is natural height here) */
.summary-a { flex: none; -webkit-mask-image: none; mask-image: none; }
.summary { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; }
.news-foot { padding-top: 12px; } /* snug under the text, not a line's gap */
}
@media (max-width: 520px) {
/* Art becomes an image-first card: the painting on top in a proper landscape frame
(aspect-ratio, not a stubby fixed-height band that crop the work to a sliver),
caption beneath. */
.art { flex-direction: column; min-height: 0; }
.art-swatch { width: 100%; min-width: 0; order: -1; aspect-ratio: 3 / 2; }
.pair { grid-template-columns: 1fr; }
/* Entertainment: when stacked it loses the height it borrowed from Play on desktop,
so the content felt crowded. Give it room to breathe (not as tall as desktop). */
.moment { padding: 30px 24px; gap: 6px; }
.moment-mid { gap: 18px; padding: 12px 0; }
.moment-meta { margin-top: 18px; }
}
</style>