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 @@
// 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>