Files
upbeatBytes/frontend/src/routes/home3/+page.svelte
T
thejayman77 dc23277b38 Read-time: full-article "Full story · ~N min" badge (Option B)
Replaces the gist-based read-time with the SOURCE article's full read time — the
contrast that sells the gist ("calm 1-min version here; ~10 min for the deep dive").

- goodnews/readtime.py: word_count_from_html (strips script/style/nav/header/
  footer/form/button/aside furniture before counting) + source_read_minutes
  (~225 wpm, 200-word floor, None when extraction looks failed/too thin).
- articles.source_words + read_checked_at columns (count only, never the body;
  fits the privacy posture). Idempotent migration.
- enrich.fetch_source_words + enrich_read_times: a bounded, retry-guarded cycle
  step (mirrors the image enrichers) that counts words for recent accepted
  articles. Only ever writes a real count; never overwrites good with zero. Wired
  into the cycle after recent-image enrichment.
- queries: source_words flows through _ARTICLE_COLUMNS; api exposes
  source_read_minutes on Article (null when unknown).
- home3: News card shows "Full story · ~N min", hidden entirely when null (no
  misleading "1 min").
- Tests: furniture stripping, threshold/rounding, enrich idempotency + no
  zero-overwrite, API null handling. 412 backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:09:00 -04:00

481 lines
28 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 HubBar from '$lib/components/HubBar.svelte';
// /home3 — the Claude Design "Frame A" direction (editorial, with colour), rebuilt in
// our codebase with our real logo + self-hosted fonts, wired to live data. Hidden
// prototype (noindex), alongside /home2 so we can compare.
let news = $state(null); // {id, title, summary, image}
let art = $state(null); // {title, artist, year, image}
let newsFit = $state('cover'); // 'cover' = full-bleed photo; 'contain' = framed-plate figure
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');
// The badge shows how long the FULL source article takes — the contrast that sells
// the gist ("the calm 1-min version here; ~10 min if you want the deep dive"). Computed
// server-side from the source word count; hidden entirely when we couldn't measure it.
let fullRead = $derived(news?.source_read_minutes ? `Full story · ~${news.source_read_minutes} min` : '');
// small-joys shelf: 3 cells shown two at a time, rotated by the reader (no auto-motion)
const JOY_ACCENTS = ['#4f7da8', '#b06a86', '#b06a45'];
let joyIdx = $state(0);
const prevJoy = () => (joyIdx = (joyIdx + 2) % 3);
const nextJoy = () => (joyIdx = (joyIdx + 1) % 3);
onMount(async () => {
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 */ }
try {
const it = (await getJSON(`/api/brief?limit=1${homeq}`))?.items?.[0];
if (it) news = { id: it.id, title: it.title, summary: it.summary || it.description || '', image: it.image_url || null };
// Photos display full (cover); only wide/tall figures (diagrams) get the framed plate.
if (news?.image) {
const probe = new Image();
probe.onload = () => {
const a = probe.naturalWidth / probe.naturalHeight;
newsFit = a >= 0.85 && a <= 1.9 ? 'cover' : 'contain';
};
probe.src = news.image;
}
} 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="robots" content="noindex" />
<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">A good thing today</span></div>
<p class="fact-hero"><span class="year">{fact?.year ?? '1928'}</span> <span class="onthis">ON THIS DAY</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">
<div class="news-photo {newsFit}">
<div class="news-plate" style={news?.image ? `background-image:url(${news.image})` : ''}></div>
</div>
</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 fullRead}<span class="meta">{fullRead}</span>{/if}
</div>
<hr class="news-div" />
<a class="news-more" href="/">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" — two jewels at a time, rotated by the reader (3 cells total) -->
<div class="joys-shelf">
<div class="joys-head">
<div class="joys-title"><span class="jt-label">Small joys for today</span><span class="jt-count">· {joyIdx + 1} of 3</span></div>
<div class="joys-nav">
<div class="joys-dots" aria-hidden="true">
{#each [0, 1, 2] as d}
<span class="dot" class:on={d === joyIdx} style={d === joyIdx ? `background:${JOY_ACCENTS[joyIdx]}` : ''}></span>
{/each}
</div>
<div class="joys-arrows">
<button class="arrow" onclick={prevJoy} aria-label="Previous small joys"></button>
<button class="arrow" onclick={nextJoy} aria-label="More small joys"></button>
</div>
</div>
</div>
<div class="joys">
{@render joyCard(joyIdx)}
{@render joyCard((joyIdx + 1) % 3)}
</div>
</div>
</div>
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</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; }
.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;
}
.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 */
.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;
}
/* 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); }
.label { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; }
.link { font-size: 14px; font-weight: 600; padding-bottom: 2px; align-self: flex-start; }
/* card titles a touch larger + bolder so they jump on hover/scan */
h2, h3 { font-family: 'Newsreader', Georgia, serif; font-weight: 600; letter-spacing: -0.01em; color: var(--ink); }
/* Good News — tall, photo on top */
.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; }
.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;
}
.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;
}
/* the gist FILLS the room available (the card runs tall), fading only at the very bottom;
it's also a link to the article (whole content block clickable) */
.summary-a {
flex: 1 1 auto; min-height: 0; overflow: hidden; display: block; text-decoration: none; color: inherit;
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2.1em), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 2.1em), transparent);
}
.summary { font-size: 14.5px; line-height: 1.55; color: var(--body); margin: 12px 0 0; }
.news-foot { display: flex; align-items: center; justify-content: flex-end; padding-top: 18px; }
.meta { font-size: 12px; color: var(--muted); }
/* 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: 13.5px; line-height: 1.5; color: #6f6280; margin: 9px 0 0; }
.ital { font-style: italic; font-family: 'Newsreader', Georgia, serif; }
.art-link { margin-top: auto; 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; }
.joys-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.joys-title { display: flex; align-items: baseline; gap: 8px; }
.jt-label { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 17px; color: #7a6f5b; }
.jt-count { font-size: 12px; color: #b3a890; }
.joys-nav { display: flex; align-items: center; gap: 14px; }
.joys-dots { display: flex; align-items: center; gap: 6px; }
.dot { width: 6px; height: 6px; border-radius: 50%; background: #d9cdb8; transition: width 0.2s ease, background 0.2s ease; }
.dot.on { width: 18px; border-radius: 3px; }
.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;
}
.arrow:hover { background: #fff; color: #9a7b3e; }
.joys { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
/* all cells share one compact height (tight, not crowded) so every rotation matches */
/* clamp every card to the WORD card's natural height (its tallest) so rotations never jump */
.joy {
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); }
.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: #b06a45; --rule: #b06a45; }
.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; }
.foot {
text-align: center; max-width: 1180px; width: 100%; margin: 14px auto 0; box-sizing: border-box;
padding: 20px clamp(18px, 5vw, 44px) 30px; font-size: 13px; color: var(--muted);
border-top: 1px solid var(--news-border);
}
/* responsive — collapse the bento on narrow screens */
@media (max-width: 860px) {
.bento { grid-template-columns: 1fr; }
.news { grid-row: auto; }
/* single column = natural card height, so the gist is never truncated; drop the
bottom fade (it would otherwise dim the final line for no reason) */
.summary-a { -webkit-mask-image: none; mask-image: none; flex: 0 1 auto; }
}
@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; }
.joys { grid-template-columns: 1fr; }
/* tighten the joys header so the title + dots/arrows never collide on a phone */
.joys-head { flex-wrap: wrap; gap: 8px 12px; }
}
</style>