Build the SvelteKit frontend: calm home with mood modes

- New frontend/ SvelteKit static SPA (Svelte 5), served by FastAPI from
  frontend/build (falls back to the legacy page if unbuilt).
- Calm design system: cream/sage palette, serif headlines, generous space,
  no urgency colors, gentle motion (respects prefers-reduced-motion).
- Home screen: mood-mode nav (Today/Wonder/People Helping/Solutions/Light
  Only/Grounded), the daily brief as a hero + remaining four, browsable mood
  lanes, an explicit calm end-state, inline Not today / Less like this / Hide
  affordances, and device-local Calm Filters mirroring goodnews/filters.py.
- Backend: moods.py + GET /api/moods (single source of truth for the modes);
  FilterPrefs gains max_cortisol/max_ragebait ceilings (for Light Only).
- Push categorical filters (include/mute topics+flavors, ceilings) into SQL in
  queries.feed so low-ranked-but-matching items (e.g. discovery for Wonder)
  are not truncated by ranking; only avoid-terms stay a Python pass.
- PWA manifest + icon (installable; offline deferred per plan).
- Multi-stage Dockerfile builds the site then serves it from the API.
- Tests: queries.feed categorical filters (63 total). README updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-30 22:27:46 +00:00
parent 1e190c5e88
commit 5601022cf7
24 changed files with 2262 additions and 16 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
/build/
/.svelte-kit/
+1444
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "goodnews-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.1.0",
"vite": "^6.0.0"
}
}
+54
View File
@@ -0,0 +1,54 @@
/* goodNews calm design system.
Warm but crisp: cream surfaces, sage accent, a serif voice for headlines,
strong readable contrast, generous space. No urgency colors (no red). */
:root {
--bg: #faf6ee;
--surface: #fffdf8;
--ink: #21281f;
--muted: #616c60;
--sage: #2f7d5b;
--sage-deep: #235e44;
--sage-soft: #e6efe6;
--line: #e7e1d4;
--gold: #a9802f;
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--maxw: 880px;
--radius: 16px;
--shadow: 0 1px 2px rgba(40, 38, 28, 0.04), 0 10px 30px rgba(40, 38, 28, 0.055);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 17px;
line-height: 1.62;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, h3 {
font-family: var(--serif);
font-weight: 600;
line-height: 1.18;
letter-spacing: -0.012em;
margin: 0;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
button { font-family: inherit; cursor: pointer; }
::selection { background: var(--sage-soft); }
.container { max-width: var(--maxw); margin: 0 auto; padding: 0 20px; }
.muted { color: var(--muted); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); gap: 18px; }
/* a soft, slow fade for content as it arrives — calm, not flashy */
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.rise { animation: rise 0.5s ease both; }
@media (prefers-reduced-motion: reduce) { .rise { animation: none; } html { scroll-behavior: auto; } }
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2f7d5b" />
<meta name="description" content="Calm, constructive news worth your attention — and nothing that isn't." />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
export async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error((await r.text().catch(() => '')) || r.statusText);
return r.json();
}
@@ -0,0 +1,102 @@
<script>
let { article, onaction, hero = false } = $props();
function act(kind, value) {
if (value) onaction?.(kind, value);
}
</script>
<article class:hero>
<a class="media" href={article.url} target="_blank" rel="noopener">
{#if article.image_url}
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={(e) => e.currentTarget.parentElement.classList.add('noimg')} />
{/if}
<span class="fallback">{article.topic ?? 'good news'}</span>
</a>
<div class="body">
<div class="tags">
{#if article.topic}<span class="tag">{article.topic}</span>{/if}
{#if article.flavor}<span class="tag soft">{article.flavor}</span>{/if}
<span class="src">{article.source}</span>
</div>
<h3><a href={article.url} target="_blank" rel="noopener">{article.title}</a></h3>
{#if hero && article.description}
<p class="desc">{article.description}</p>
{/if}
{#if article.reason_text}
<p class="why">{article.reason_text}</p>
{/if}
<div class="actions">
{#if article.topic}<button onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if article.flavor}<button onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if article.topic}<button onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
</div>
</div>
</article>
<style>
article {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
height: 100%;
}
.media {
position: relative;
display: block;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, var(--sage-soft), #f1ece0);
}
.media img { width: 100%; height: 100%; object-fit: cover; }
.fallback {
position: absolute; inset: 0; display: none;
align-items: center; justify-content: center;
font-family: var(--serif); font-style: italic; color: var(--sage-deep); opacity: 0.6;
text-transform: lowercase; letter-spacing: 0.02em;
}
.media.noimg img { display: none; }
.media.noimg .fallback { display: flex; }
.body { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; }
.tag {
background: var(--sage); color: #fff; border-radius: 999px;
padding: 2px 9px; font-weight: 600; text-transform: capitalize;
}
.tag.soft { background: var(--sage-soft); color: var(--sage-deep); }
.src { color: var(--muted); margin-left: auto; }
h3 { font-size: 1.18rem; }
.hero h3 { font-size: 1.95rem; }
h3 a:hover { color: var(--sage-deep); }
.desc { margin: 2px 0 0; color: #3c463a; }
.why {
margin: 2px 0 0; font-style: italic; color: var(--muted);
font-size: 0.9rem; padding-left: 12px; border-left: 2px solid var(--sage-soft);
}
.actions { margin-top: auto; padding-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; }
.actions button {
background: none; border: none; padding: 0; color: var(--muted);
font-size: 0.78rem; border-bottom: 1px dotted var(--line);
}
.actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); }
.hero { display: grid; grid-template-columns: 1.1fr 1fr; }
.hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; }
.hero .body { padding: 28px 30px; justify-content: center; gap: 12px; }
@media (max-width: 640px) {
.hero { grid-template-columns: 1fr; }
.hero .media { min-height: 200px; }
.hero h3 { font-size: 1.6rem; }
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<script>
import ArticleCard from './ArticleCard.svelte';
let { title, description, items, onaction, onmore } = $props();
</script>
{#if items?.length}
<section class="lane rise">
<div class="lane-head">
<h2>{title}</h2>
{#if description}<span class="muted">{description}</span>{/if}
{#if onmore}<button class="more" onclick={onmore}>see more →</button>{/if}
</div>
<div class="grid">
{#each items as a (a.id)}
<ArticleCard article={a} {onaction} />
{/each}
</div>
</section>
{/if}
<style>
.lane { margin: 34px 0; }
.lane-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; }
.lane-head h2 { font-size: 1.35rem; }
.more { margin-left: auto; background: none; border: none; color: var(--sage-deep); font-size: 0.85rem; }
.more:hover { text-decoration: underline; }
</style>
@@ -0,0 +1,37 @@
<script>
let { moods, selected, onselect } = $props();
</script>
<nav class="moods">
{#each moods as m}
<button class:active={selected === m.key} title={m.description} onclick={() => onselect(m.key)}>
{m.label}
</button>
{/each}
</nav>
<style>
.moods {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
padding: 4px 0 6px;
}
button {
background: var(--surface);
border: 1px solid var(--line);
color: var(--muted);
border-radius: 999px;
padding: 8px 16px;
font-size: 0.92rem;
transition: all 0.14s ease;
}
button:hover { color: var(--sage-deep); border-color: var(--sage); }
button.active {
background: var(--sage);
border-color: var(--sage);
color: #fff;
box-shadow: 0 4px 14px rgba(47, 125, 91, 0.25);
}
</style>
+63
View File
@@ -0,0 +1,63 @@
// Calm Filters preferences — the canonical FilterPrefs shape, kept in
// localStorage (device-local, no accounts). Mirrors goodnews/filters.py.
const KEY = 'goodnews:prefs';
export function blank() {
return {
include_topics: [], include_flavors: [],
mute_topics: [], mute_flavors: [],
avoid_terms: [], pauses: [], max_cortisol: null,
};
}
export function load() {
let p;
try { p = JSON.parse(localStorage.getItem(KEY)) || {}; } catch { p = {}; }
p = Object.assign(blank(), p);
const now = Date.now();
p.pauses = (p.pauses || []).filter((x) => x && x.until && new Date(x.until).getTime() > now);
return p;
}
export function save(p) {
localStorage.setItem(KEY, JSON.stringify(p));
}
export function active(p) {
return !!(p.mute_topics.length || p.mute_flavors.length || p.avoid_terms.length || p.pauses.length);
}
const hours = (h) => new Date(Date.now() + h * 3600e3).toISOString();
export function notToday(p, topic) {
p.pauses = p.pauses.filter((x) => !(x.kind === 'topic' && x.value === topic));
p.pauses.push({ kind: 'topic', value: topic, until: hours(24) });
return p;
}
export function lessLikeThis(p, flavor) {
p.pauses = p.pauses.filter((x) => !(x.kind === 'flavor' && x.value === flavor));
p.pauses.push({ kind: 'flavor', value: flavor, until: hours(72) });
return p;
}
export function alwaysHide(p, topic) {
if (!p.mute_topics.includes(topic)) p.mute_topics.push(topic);
return p;
}
// Merge a mood's filter preset over the user's standing prefs into one object.
export function merge(userPrefs, moodFilter = {}) {
const m = Object.assign(blank(), userPrefs);
if (moodFilter.include_topics) m.include_topics = moodFilter.include_topics;
if (moodFilter.include_flavors) m.include_flavors = moodFilter.include_flavors;
if (moodFilter.max_cortisol != null) m.max_cortisol = moodFilter.max_cortisol;
return m;
}
// "" or "prefs=<encoded json>" for a query string.
export function param(prefs) {
const empty =
!prefs.include_topics.length && !prefs.include_flavors.length &&
!prefs.mute_topics.length && !prefs.mute_flavors.length &&
!prefs.avoid_terms.length && !prefs.pauses.length && prefs.max_cortisol == null;
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(prefs));
}
+3
View File
@@ -0,0 +1,3 @@
// Static SPA: prerender the shell, fetch all data client-side from the API.
export const prerender = true;
export const ssr = false;
+47
View File
@@ -0,0 +1,47 @@
<script>
import '../app.css';
let { children } = $props();
</script>
<header class="site">
<div class="container">
<a class="brand" href="/">good<span>News</span></a>
<p class="tagline">Calm, constructive news worth your attention — and nothing that isn't.</p>
</div>
</header>
<main class="container">
{@render children()}
</main>
<footer class="site">
<div class="container">
goodNews · metadata &amp; links only, no stored articles · <a href="/docs">API</a>
</div>
</footer>
<style>
header.site {
text-align: center;
padding: 38px 0 22px;
background: linear-gradient(180deg, var(--surface), var(--bg));
border-bottom: 1px solid var(--line);
}
.brand {
font-family: var(--serif);
font-size: 2.1rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.brand span { color: var(--sage); }
.tagline { margin: 6px 0 0; color: var(--muted); font-size: 0.98rem; }
main.container { padding-top: 18px; padding-bottom: 40px; min-height: 60vh; }
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(--sage-deep); }
</style>
+153
View File
@@ -0,0 +1,153 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import * as P from '$lib/prefs.js';
import MoodNav from '$lib/components/MoodNav.svelte';
import Lane from '$lib/components/Lane.svelte';
import ArticleCard from '$lib/components/ArticleCard.svelte';
let moods = $state([]);
let selected = $state('today');
let brief = $state(null);
let feed = $state([]);
let lanes = $state([]);
let userPrefs = $state(P.blank());
let loading = $state(true);
let error = $state('');
let filtersOn = $derived(P.active(userPrefs));
const moodByKey = (k) => moods.find((m) => m.key === k);
function feedUrl(moodKey, limit) {
const merged = P.merge(userPrefs, moodByKey(moodKey)?.filter ?? {});
const q = P.param(merged);
return `/api/feed?limit=${limit}${q ? '&' + q : ''}`;
}
function briefUrl() {
const q = P.param(userPrefs);
return `/api/brief?limit=5${q ? '&' + q : ''}`;
}
async function loadToday() {
brief = await getJSON(briefUrl());
const keys = ['wonder', 'people-helping', 'solutions'];
lanes = await Promise.all(
keys.map(async (k) => ({ ...moodByKey(k), items: (await getJSON(feedUrl(k, 4))).items }))
);
}
async function loadMood(key) {
feed = (await getJSON(feedUrl(key, 24))).items;
}
async function select(key) {
selected = key;
error = '';
try {
if (key === 'today') await loadToday();
else await loadMood(key);
} catch (e) {
error = 'Something went quiet — could not reach the feed.';
}
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
}
function applyAction(kind, value) {
P[kind]?.(userPrefs, value);
userPrefs = { ...userPrefs };
P.save(userPrefs);
select(selected);
}
function resetFilters() {
userPrefs = P.blank();
P.save(userPrefs);
select(selected);
}
onMount(async () => {
userPrefs = P.load();
try {
moods = await getJSON('/api/moods');
await select('today');
} catch (e) {
error = 'Could not reach goodNews.';
}
loading = false;
});
</script>
{#if moods.length}
<MoodNav {moods} {selected} onselect={select} />
{/if}
{#if filtersOn}
<div class="calmbar">
<span>Calm filters on — your feed is personalized on this device.</span>
<button onclick={resetFilters}>reset</button>
</div>
{/if}
{#if loading}
<p class="muted center pad">Gathering the good news…</p>
{:else if error}
<p class="muted center pad">{error}</p>
{:else if selected === 'today'}
{#if brief?.items?.length}
<section class="rise">
<h2 class="kicker">{brief.title ?? 'Five Good Things Today'}</h2>
<ArticleCard article={brief.items[0]} hero onaction={applyAction} />
{#if brief.items.length > 1}
<div class="grid rest">
{#each brief.items.slice(1) as a (a.id)}
<ArticleCard article={a} onaction={applyAction} />
{/each}
</div>
{/if}
</section>
{:else}
<p class="muted center pad">No brief yet today — try a calmer filter, or check back soon.</p>
{/if}
{#each lanes as lane (lane.key)}
<Lane title={lane.label} description={lane.description} items={lane.items}
onaction={applyAction} onmore={() => select(lane.key)} />
{/each}
<p class="endcap rise">✦ that's the good news for today ✦</p>
{:else}
<section class="rise">
<h2 class="kicker">{moodByKey(selected)?.label}</h2>
<p class="muted lede">{moodByKey(selected)?.description}</p>
{#if feed.length}
<div class="grid">
{#each feed as a (a.id)}
<ArticleCard article={a} onaction={applyAction} />
{/each}
</div>
{:else}
<p class="muted center pad">Nothing in this lane right now — try another mood or ease a filter.</p>
{/if}
</section>
{/if}
<style>
.calmbar {
display: flex; align-items: center; justify-content: center; gap: 12px;
background: var(--sage-soft); color: var(--sage-deep);
border-radius: 999px; padding: 6px 16px; margin: 6px auto 0; width: fit-content;
font-size: 0.85rem;
}
.calmbar button { background: none; border: none; color: var(--sage-deep); text-decoration: underline; font-size: 0.85rem; }
.kicker {
font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.12em;
color: var(--gold); margin: 22px 0 14px; font-family: var(--sans); font-weight: 700;
}
.lede { margin: -8px 0 18px; font-size: 1.05rem; }
.rest { margin-top: 18px; }
.center { text-align: center; }
.pad { padding: 48px 0; }
.endcap {
text-align: center; color: var(--muted); font-family: var(--serif);
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#2f7d5b"/>
<circle cx="32" cy="29" r="11" fill="#faf6ee"/>
<path d="M14 44 q12 9 24 3 q9 -4 12 1" stroke="#faf6ee" stroke-width="3.4" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 288 B

+13
View File
@@ -0,0 +1,13 @@
{
"name": "goodNews",
"short_name": "goodNews",
"description": "Calm, constructive news worth your attention — and nothing that isn't.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#faf6ee",
"theme_color": "#2f7d5b",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
+11
View File
@@ -0,0 +1,11 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** Static SPA: prerender the shell, fetch data from the API at runtime.
* FastAPI serves the built `build/` directory. */
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ fallback: 'index.html' }),
},
};
+7
View File
@@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
export default {
plugins: [sveltekit()],
// Dev convenience: proxy API calls to the FastAPI server.
server: { proxy: { '/api': 'http://127.0.0.1:8000', '/healthz': 'http://127.0.0.1:8000' } },
};