NEWS RELAUNCH CUTOVER: promote the hub to /, feed to /news, go public

The big flip. /home3 (hub) becomes /; the feed lives at /news; both indexable.
- PROMOTE: routes/+page.svelte is now the hub (was the interim NewsFeed wrapper);
  noindex removed; "Read more good news" → /news. routes/home3 + home2 deleted.
- routes/+page.js: redirects legacy root-query links (/?view=latest, /?tag, /?source,
  /?q, /?view=today→highlights) to /news before the hub renders (no flash).
- /news: noindex dropped (route meta + Caddy @newsHidden removed); now public.
- LINKS: HubBar brand/Home → /, News default → /news; HubShell/art/play back → /;
  account Following + share.py Explore/Browse/source → /news.
- FOOTER: one shared Footer.svelte (motto + Send feedback + slot) across Hub/News/
  Play/Art/HubShell/Account/Zen; global layout footer removed (FeedbackModal stays).
- SITEMAP: + /news /art /play /word /quote /onthisday; cap 5k→50k; gated on
  has-summary; paywalled excluded; HEAD now 200 (api_route GET+HEAD).
- Head-patcher: /news entry. PWA + shell description broadened to the hub.
- Caddy: @newsHidden dropped; @hidden now admin-only (word/quote/onthisday public);
  /home2,/home3 → / 301. Mirrored to deploy/caddy snapshot.

425 backend + 36 frontend tests green; build clean; Caddy valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-28 19:16:43 -04:00
parent 1c1ecefde8
commit 2cfffdfd6a
21 changed files with 682 additions and 793 deletions
+8 -7
View File
@@ -46,6 +46,12 @@ upbeatbytes.com {
encode gzip zstd
# Retired prototype routes (promoted/removed at the news relaunch) → the hub.
@oldhome path /home2 /home2.html /home3 /home3.html
handle @oldhome {
redir / permanent
}
# Dynamic API + server-rendered pages (share, digest, sitemap) → FastAPI.
@api path /api/* /healthz /docs /docs/* /openapi.json /a/* /today /sitemap.xml
handle @api {
reverse_proxy upbeatbytes-api:8000
@@ -60,15 +66,10 @@ upbeatbytes.com {
# Hidden in-progress prototypes — keep crawlers out at the HTTP level (the JS
# <meta robots> isn't seen by non-JS bots since the static shell is generic).
# Only admin stays out of the index now — news, art, play, and the joy pages are public.
header @hidden X-Robots-Tag "noindex, nofollow"
@hidden path /admin /admin.html
header @hidden X-Robots-Tag "noindex, nofollow"
# Content-hashed assets never change for a given URL — cache them forever.
# noindex (FOLLOW, so link equity still flows) until cutover, when this line is removed
# and /news enters the sitemap. Avoids publishing a duplicate indexable feed.
@newsHidden path /news /news.html
header @newsHidden X-Robots-Tag "noindex, follow"
# Content-hashed assets never change for a given URL — cache them forever.
@immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
+6
View File
@@ -11,6 +11,12 @@ const BASE = 'https://upbeatbytes.com';
// Per-page <head> overrides. Keep titles/descriptions in sync with each page's intent.
const PAGES = [
{
file: 'news.html', path: '/news',
title: 'News · upbeatBytes — calm, constructive news',
desc: 'Calm, constructive news, newest first — and a daily Highlights brief. ' +
'No ads, no paywalls, no doomscrolling.',
},
{
file: 'play.html', path: '/play',
title: 'Play · Upbeat Bytes — calm daily games',
+3 -3
View File
@@ -7,17 +7,17 @@
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0083ad" />
<meta name="description" content="Calm, constructive news worth your attention — and nothing that isn't." />
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
<title>Upbeat Bytes — calm, constructive news</title>
<link rel="canonical" href="https://upbeatbytes.com/" />
<meta property="og:site_name" content="Upbeat Bytes" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Upbeat Bytes — calm, constructive news" />
<meta property="og:description" content="Calm, constructive news worth your attention — and nothing that isn't. Summarized, so you get the gist and go deeper only if you want." />
<meta property="og:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets. No ads, no paywalls, no doomscrolling." />
<meta property="og:url" content="https://upbeatbytes.com/" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Upbeat Bytes — calm, constructive news" />
<meta name="twitter:description" content="Calm, constructive news, summarized — get the gist, go deeper only if you want." />
<meta name="twitter:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
%sveltekit.head%
<style>
#boot-fallback {
+35
View File
@@ -0,0 +1,35 @@
<script>
// The one footer across the hub family. Consistent core — the motto + a Send
// feedback link (a low-friction "talk to a human") — plus an optional per-section
// slot. `padBottomNav` reserves space on pages with the fixed mobile BottomNav
// (the /news feed) so the footer never hides behind it. FeedbackModal lives in the
// global layout; this only triggers it.
import { openFeedback } from '$lib/feedback.svelte.js';
let { children, padBottomNav = false } = $props();
</script>
<footer class="ub-foot" class:padnav={padBottomNav}>
<p class="motto">upbeatBytes — no ads, no paywalls, no doomscrolling.</p>
<p class="acts">
<button class="fb" onclick={openFeedback}>Send feedback</button>
{#if children}<span class="sep">·</span>{@render children()}{/if}
</p>
</footer>
<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; }
.ub-foot {
text-align: center; padding: 30px 18px 36px; margin-top: 10px;
border-top: 1px solid rgba(120, 90, 40, 0.14);
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
}
.motto { margin: 0; font-size: 13px; color: #a4977f; }
.acts { margin: 7px 0 0; font-size: 13px; color: #a4977f; }
.fb { background: none; border: none; color: #7c6444; font: inherit; font-size: 13px; cursor: pointer; text-decoration: underline; padding: 0; }
.fb:hover { color: #5f4d2e; }
.sep { margin: 0 6px; }
.ub-foot :global(a) { color: #7c6444; }
@media (max-width: 720px) {
.ub-foot.padnav { padding-bottom: calc(36px + 64px + env(safe-area-inset-bottom)); }
}
</style>
+4 -5
View File
@@ -2,9 +2,8 @@
// Shared editorial top bar for the hub (/home3) and its detail pages (/word, /quote,
// /onthisday). Full horizontal nav on wide screens; a hamburger + drop panel on phones
// so the bar stays clean. `active` highlights the current section.
// `newsHref` is configurable so the transitional /news instance links News → /news
// (not the current `/`, which is still the live feed until cutover).
let { active = '', newsHref = '/' } = $props();
// News now lives at /news (the hub is `/`). `newsHref` stays overridable for safety.
let { active = '', newsHref = '/news' } = $props();
let open = $state(false);
// Close the menu when we cross into desktop width, so it can't linger open and reappear
@@ -17,7 +16,7 @@
});
let LINKS = $derived([
{ key: 'home', href: '/home3', label: 'Home' },
{ key: 'home', href: '/', label: 'Home' },
{ key: 'news', href: newsHref, label: 'News' },
{ key: 'games', href: '/play', label: 'Games' },
{ key: 'art', href: '/art', label: 'Art' },
@@ -27,7 +26,7 @@
<svelte:window on:keydown={(e) => { if (e.key === 'Escape') open = false; }} />
<header class="bar">
<a class="brand" href="/home3" aria-label="upbeatBytes home">
<a class="brand" href="/" aria-label="upbeatBytes home">
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
</a>
+3 -8
View File
@@ -3,6 +3,7 @@
// top bar, footer, fonts, and design tokens. Content goes in the default slot.
import { afterNavigate, goto } from '$app/navigation';
import HubBar from './HubBar.svelte';
import Footer from './Footer.svelte';
let { active = '', back = true, backLabel = 'Back', children } = $props();
// Same single-history rule as the News reader's in-page Back: if we arrived here by an
@@ -14,7 +15,7 @@
if (cameFromApp && typeof history !== 'undefined') history.back();
// Cold deep-link: no in-app origin. REPLACE this entry rather than pushing one, so the
// browser Back from the hub doesn't bounce the reader straight back into the detail page.
else goto('/home3', { replaceState: true });
else goto('/', { replaceState: true });
}
</script>
@@ -33,7 +34,7 @@
{@render children?.()}
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
<Footer />
</div>
<style>
@@ -65,10 +66,4 @@
.back:hover { color: var(--teal); }
.back svg { transition: transform 0.15s ease; }
.back:hover svg { transform: translateX(-2px); }
.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);
}
</style>
@@ -7,6 +7,7 @@
import Header from '$lib/components/Header.svelte';
import HubBar from '$lib/components/HubBar.svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
import Footer from '$lib/components/Footer.svelte';
import MoodNav from '$lib/components/MoodNav.svelte';
import LanePicker from '$lib/components/LanePicker.svelte';
import ArticleCard from '$lib/components/ArticleCard.svelte';
@@ -907,6 +908,8 @@
{/if}
</main>
<Footer padBottomNav />
<BottomNav active={activeTab} onToday={() => navigate('today')} onLatest={() => navigate('latest')} onPlay={() => goto('/play')} onYou={openAccount} user={auth.user} />
<style>
+3 -25
View File
@@ -2,7 +2,7 @@
import '../app.css';
import { onMount } from 'svelte';
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
import { fb, closeFeedback } from '$lib/feedback.svelte.js';
import { trackVisit } from '$lib/analytics.js';
let { children } = $props();
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
@@ -19,27 +19,5 @@
{#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if}
<footer class="site">
<div class="container">
<button class="fb" onclick={openFeedback}>Send feedback</button>
<span class="dot">·</span>
Upbeat Bytes · metadata &amp; links only, no stored articles · <a href="/docs">API</a>
</div>
</footer>
<style>
footer.site {
text-align: center;
color: var(--muted);
font-size: 0.82rem;
padding: 26px 0 34px;
border-top: 1px solid var(--line);
}
footer.site a { color: var(--accent-deep); }
footer.site .fb { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.82rem; cursor: pointer; text-decoration: underline; padding: 0; }
footer.site .dot { margin: 0 4px; }
/* room for the mobile bottom tab bar */
@media (max-width: 720px) {
footer.site { padding-bottom: calc(34px + 64px + env(safe-area-inset-bottom)); }
}
</style>
<!-- Footer is per-surface now (shared $lib/components/Footer.svelte) so each section
can add its own extras; FeedbackModal stays global, triggered from any footer. -->
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from '@sveltejs/kit';
// Keep legacy root-query links alive after the cutover: the old feed lived at `/`
// (e.g. /?view=latest, /?tag=…, /?source=…, /?q=…); the feed is now /news. Redirect
// here in load() — before the hub renders — so bookmarks/old shares never flash the
// hub. Bare `/` (no query) falls through to the hub. parseView's alias keeps
// /news?view=today working too.
export function load({ url }) {
const p = url.searchParams;
if (p.get('q')) throw redirect(307, '/news?q=' + encodeURIComponent(p.get('q')));
if (p.get('source')) throw redirect(307, '/news?source=' + encodeURIComponent(p.get('source')));
if (p.get('tag')) throw redirect(307, '/news?tag=' + encodeURIComponent(p.get('tag')));
const v = p.get('view');
if (v === 'today') throw redirect(307, '/news?view=highlights');
if (v === 'latest') throw redirect(307, '/news');
if (v) throw redirect(307, '/news?view=' + encodeURIComponent(v));
}
+567 -5
View File
@@ -1,8 +1,570 @@
<script>
// Interim: `/` still renders the news feed (extracted to NewsFeed) with NO visible or
// behavioral change — base-aware links keep `/` behaving exactly as before. At cutover
// this route becomes the hub (moved from /home3) and the feed lives only at /news.
import NewsFeed from '$lib/components/NewsFeed.svelte';
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.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
// 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 () => {
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, topic: it.topic || null, source_read_minutes: it.source_read_minutes };
// 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>
<NewsFeed chrome="legacy" />
<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}
<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; }
.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>
+4 -1
View File
@@ -8,6 +8,7 @@
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
import { track } from '$lib/analytics.js';
import { openFeedback } from '$lib/feedback.svelte.js';
import Footer from '$lib/components/Footer.svelte';
import AccountPanel from '$lib/components/AccountPanel.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import LanePicker from '$lib/components/LanePicker.svelte';
@@ -193,7 +194,7 @@
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
{:else}
<h2>Following</h2>
<p class="dnote">Sources and topics you follow feed your <a href="/?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
<p class="dnote">Sources and topics you follow feed your <a href="/news?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
{#if follows.length}
<ul class="follows">
{#each follows as f (f.kind + ':' + f.value)}
@@ -228,6 +229,8 @@
</div>
</main>
<Footer />
<style>
.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
+3 -7
View File
@@ -3,6 +3,7 @@
import { getJSON } from '$lib/api.js';
import { afterNavigate, goto } from '$app/navigation';
import HubBar from '$lib/components/HubBar.svelte';
import Footer from '$lib/components/Footer.svelte';
// Virtual frames the viewer can switch between — remembered locally, no account needed.
const FRAMES = [
@@ -38,7 +39,7 @@
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
function goBack() {
if (cameFromApp && typeof history !== 'undefined') history.back();
else goto('/home3', { replaceState: true });
else goto('/', { replaceState: true });
}
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
@@ -213,7 +214,7 @@
{/if}
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
<Footer />
</div>
{#if zoom && art}
@@ -489,11 +490,6 @@
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
.foot {
text-align: center; color: var(--muted); font-size: 0.84rem;
padding: 28px 16px 36px; border-top: 1px solid var(--line); margin-top: 24px;
}
.lightbox {
position: fixed; inset: 0; z-index: 50; border: none; cursor: zoom-out;
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
-143
View File
@@ -1,143 +0,0 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import { ROOMS } from '$lib/rooms.js';
import RoomCard from '$lib/components/RoomCard.svelte';
// Hidden prototype of the new homepage hub (/home2). Iterate the look here, then
// promote to / and remove this clone — same approach we used for /art.
let artImg = $state(null);
let newsHeadline = $state('');
let newsImg = $state(null);
onMount(async () => {
try { artImg = (await getJSON('/api/art/today'))?.image_url ?? null; } catch { /* card falls back to blurb */ }
// Respect the reader's saved Closer-to-Home filter so the headline matches their Brief.
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 { /* default global brief */ }
try {
const it = (await getJSON(`/api/brief?limit=1${homeq}`))?.items?.[0];
newsHeadline = it?.title ?? '';
newsImg = it?.image_url ?? null;
} catch { /* card falls back to blurb */ }
});
</script>
<svelte:head>
<title>upbeatBytes — a calmer corner of the internet</title>
<meta name="robots" content="noindex" />
<meta name="description" content="A calmer corner of the internet: good news, daily art, small games, and little resets." />
</svelte:head>
<div class="room">
<header class="bar">
<a class="brand" href="/home2" aria-label="upbeatBytes home">
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
</a>
<nav class="nav">
<a href="/">News</a>
<a href="/play">Games</a>
<a href="/art">Art</a>
<a class="acct" href="/account" aria-label="Your account">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="8" r="3.3" />
<path d="M5.5 19.2a6.5 6.5 0 0 1 13 0" />
</svg>
</a>
</nav>
</header>
<main class="hub">
<div class="intro">
<h1>A calmer corner of the internet.</h1>
<p>Good news, daily art, small games, and little resets.</p>
</div>
<div class="grid">
{#each ROOMS as r (r.id)}
<RoomCard room={r} {artImg} {newsImg} {newsHeadline} />
{/each}
</div>
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
</div>
<style>
/* Self-hosted modern sans (OFL) — privacy-friendly, no Google hotlink. */
@font-face {
font-family: 'Manrope';
src: url('/fonts/manrope-var.woff2') format('woff2');
font-weight: 200 800; font-style: normal; font-display: swap;
}
.room {
--canvas: #faf6ee; --surface: #ffffff; --ink: #232a31; --muted: #707b86;
--line: #ece5d8; --accent: #0a93c0; --accent-deep: #066c8e;
--sans: 'Manrope', ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
min-height: 100vh; background: var(--canvas); color: var(--ink);
font-family: var(--sans);
display: flex; flex-direction: column;
}
/* Bigger, more present top bar — logo + lighter, modern nav labels. */
.bar {
display: flex; align-items: center; justify-content: space-between;
padding: 20px clamp(18px, 5vw, 64px);
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
}
.brand { display: block; line-height: 0; }
.brand img { height: 58px; width: auto; display: block; }
.nav { display: flex; align-items: center; gap: clamp(18px, 3vw, 40px); }
.nav a {
font-family: var(--sans); color: #4a525c; text-decoration: none;
font-weight: 500; font-size: 1.1rem; letter-spacing: 0.01em;
transition: color 0.15s ease;
}
.nav a:hover { color: var(--accent); }
.acct {
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 44px; border-radius: 50%; color: var(--muted);
}
.acct:hover { color: var(--accent); background: #eef6f9; }
.hub {
flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; box-sizing: border-box;
padding: clamp(12px, 2.4vw, 30px) clamp(18px, 5vw, 64px) clamp(40px, 6vw, 72px);
}
.intro { text-align: center; margin: clamp(6px, 1.2vw, 14px) 0 clamp(40px, 6vw, 64px); }
.intro h1 {
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: clamp(1.9rem, 4.5vw, 3rem); margin: 0; letter-spacing: -0.015em; line-height: 1.08;
}
.intro p { color: var(--muted); margin: 12px 0 0; font-size: clamp(1rem, 2vw, 1.2rem); }
.grid {
display: grid; gap: clamp(14px, 1.8vw, 22px);
grid-template-columns: repeat(4, 1fr); grid-auto-flow: dense;
}
/* size variants → grid spans (RoomCard owns min-heights) */
:global(.grid .card--large) { grid-column: span 2; grid-row: span 2; }
:global(.grid .card--wide) { grid-column: span 2; }
:global(.grid .card--tall) { grid-row: span 2; }
@media (max-width: 860px) {
.grid { grid-template-columns: repeat(2, 1fr); }
:global(.grid .card--large), :global(.grid .card--wide) { grid-column: span 2; }
:global(.grid .card--large), :global(.grid .card--tall) { grid-row: auto; }
}
@media (max-width: 540px) {
.grid { grid-template-columns: 1fr; }
:global(.grid .card--large), :global(.grid .card--wide), :global(.grid .card--tall) { grid-column: span 1; }
}
.foot {
text-align: center; color: var(--muted); font-size: 0.86rem;
padding: 28px 16px 40px; border-top: 1px solid var(--line); margin-top: auto;
}
</style>
-575
View File
@@ -1,575 +0,0 @@
<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, topic, ...}
let art = $state(null); // {title, artist, year, image}
let newsFit = $state('cover'); // 'cover' = full-bleed photo; 'contain' = framed-plate figure
// 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 () => {
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, topic: it.topic || null, source_read_minutes: it.source_read_minutes };
// 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">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}
<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="/">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 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; }
@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; }
.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; }
.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: 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>
+2 -7
View File
@@ -1,12 +1,7 @@
<script>
// /news — the feed's permanent home. During transition it renders the SAME extracted
// NewsFeed as `/` and stays hidden (noindex) so we never publish a duplicate indexable
// surface. At cutover the noindex is dropped and /news enters the sitemap.
// /news — the feed's home: Latest by default, Highlights at ?view=highlights, plus
// search/categories/following/saved. Renders the shared NewsFeed in hub chrome.
import NewsFeed from '$lib/components/NewsFeed.svelte';
</script>
<svelte:head>
<meta name="robots" content="noindex, follow" />
</svelte:head>
<NewsFeed chrome="hub" />
+4 -1
View File
@@ -4,6 +4,7 @@
import { page } from '$app/stores';
import { getJSON } from '$lib/api.js';
import HubBar from '$lib/components/HubBar.svelte';
import Footer from '$lib/components/Footer.svelte';
import { pushGameStatesBatch } from '$lib/gamesync.js';
import { initPrefs } from '$lib/prefs.svelte.js';
import { auth } from '$lib/auth.svelte.js';
@@ -210,7 +211,7 @@
let cameFromApp = $state(false); // arrived via in-app nav (e.g. the hub) → Back returns there
function leavePlay() {
if (cameFromApp && typeof history !== 'undefined') history.back();
else goto('/home3', { replaceState: true });
else goto('/', { replaceState: true });
}
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
@@ -464,6 +465,8 @@
{/if}
</main>
<Footer />
<style>
/* in-game step-back, below the shared HubBar (matches the hub Back affordance) */
.gameback { color: var(--accent-deep); font-size: 0.9rem; font-weight: 600; display: inline-flex;
+3
View File
@@ -4,6 +4,7 @@
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte.js';
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
import Footer from '$lib/components/Footer.svelte';
let canvas = $state();
let failed = $state(false);
@@ -119,6 +120,8 @@
{/if}
</main>
<Footer />
<style>
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Upbeat Bytes",
"short_name": "Upbeat Bytes",
"description": "Calm, constructive news worth your attention — and nothing that isn't.",
"description": "A calmer, brighter corner of the internet: good news, daily art, small games, and little resets.",
"start_url": "/",
"scope": "/",
"display": "standalone",
+12 -2
View File
@@ -1695,22 +1695,32 @@ def create_app() -> FastAPI:
return HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
return HTMLResponse(share.render_digest(items, PUBLIC_BASE_URL, b.get("brief_date")))
@app.get("/sitemap.xml")
@app.api_route("/sitemap.xml", methods=["GET", "HEAD"])
def sitemap() -> Response:
with get_conn() as conn:
pwx = queries.paywalled_source_ids(conn)
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
# Only articles with a real summary (the page has substance), paywalled excluded.
# Cap near the 50k protocol ceiling so older canonical pages aren't dropped.
rows = conn.execute(
"SELECT a.id, COALESCE(a.published_at, a.discovered_at) AS lm "
"FROM articles a JOIN article_scores s ON s.article_id = a.id "
"WHERE s.accepted = 1 AND a.duplicate_of IS NULL" + pw_clause + " "
"ORDER BY lm DESC LIMIT 5000",
"AND EXISTS (SELECT 1 FROM article_summaries asum WHERE asum.article_id = a.id) "
"ORDER BY lm DESC LIMIT 50000",
pwx,
).fetchall()
base = PUBLIC_BASE_URL
# Hub + the public sections, then every summarized article page.
urls = [
f"<url><loc>{base}/</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>",
f"<url><loc>{base}/news</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>",
f"<url><loc>{base}/today</loc><changefreq>daily</changefreq><priority>0.9</priority></url>",
f"<url><loc>{base}/art</loc><changefreq>daily</changefreq><priority>0.6</priority></url>",
f"<url><loc>{base}/play</loc><changefreq>weekly</changefreq><priority>0.5</priority></url>",
f"<url><loc>{base}/word</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
f"<url><loc>{base}/quote</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
f"<url><loc>{base}/onthisday</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
]
for r in rows:
lm = (r["lm"] or "")[:10]
+3 -3
View File
@@ -27,7 +27,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
source_id = article.get("source_id")
# Link the source name into the app's publication feed for that source.
source_html = (
f'<a class="src srclink" href="/?source={source_id}">{escape(source)}</a>'
f'<a class="src srclink" href="/news?source={source_id}">{escape(source)}</a>'
if source_id else f'<div class="src">{escape(source)}</div>'
)
src_url = article.get("canonical_url") or base_url
@@ -207,7 +207,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
<div class="actions">
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
<button class="secondary" type="button" data-share>Copy link</button>
<a class="secondary" href="/">Explore Upbeat Bytes </a>
<a class="secondary" href="/news">Explore Upbeat Bytes </a>
</div>
<p class="note">Upbeat Bytes summarizes in its own words and links to the original publisher it doesn't host the article.</p>
</div>
@@ -329,7 +329,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
<h1>Today's good news</h1>
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
{cards}
<p class="more"><a href="/">Browse more on Upbeat Bytes </a></p>
<p class="more"><a href="/news">Browse more on Upbeat Bytes </a></p>
</main>
</body>
</html>"""
+1
View File
@@ -61,6 +61,7 @@ def test_sitemap_excludes_paywalled_and_restores_on_free(tmp_path, monkeypatch):
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,published_at) VALUES (?,?,?,?,?,?)",
(aid, sid, f"http://x/{aid}", f"t{aid}", f"h{aid}", "2026-06-05T08:00:00"))
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,))
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (aid, f"s{aid}")) # sitemap requires a summary
c.commit()
tc = TestClient(api.create_app())
xml = tc.get("/sitemap.xml").text