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
+35 -8
View File
@@ -31,11 +31,15 @@ from . import feeds, queries
from .db import connect, init_db
from .filters import filter_articles, prefs_from_json
from .llm import LocalModelClient
from .moods import MOODS
from .taxonomy import FLAVORS, TOPICS
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
STATIC_DIR = Path(__file__).resolve().parent / "static"
# Prefer the built SvelteKit site; fall back to the legacy single-page harness.
FRONTEND_DIR = ROOT / "frontend" / "build"
LEGACY_STATIC = Path(__file__).resolve().parent / "static"
STATIC_DIR = FRONTEND_DIR if FRONTEND_DIR.is_dir() else LEGACY_STATIC
def db_path() -> Path:
@@ -188,6 +192,12 @@ def create_app() -> FastAPI:
flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()],
)
@app.get("/api/moods")
def moods() -> list[dict]:
# The humane front door: each mood resolves to a filter preset the
# client merges with the user's own Calm Filters.
return MOODS
@app.get("/api/category-counts", response_model=list[CategoryCount])
def category_counts(accepted_only: bool = True, prefs: str | None = Query(None)) -> list[CategoryCount]:
fp = prefs_from_json(prefs)
@@ -220,20 +230,37 @@ def create_app() -> FastAPI:
if flavor and flavor.lower() not in FLAVORS:
raise HTTPException(400, f"unknown flavor: {flavor}")
fp = prefs_from_json(prefs)
now = datetime.now(timezone.utc)
with get_conn() as conn:
if fp.is_empty():
rows = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=limit, offset=offset
)
else:
# Over-fetch, apply the calm filters in Python (word-boundary
# avoid-terms can't be done in SQL), then slice to the page.
fetch_n = min(2000, (offset + limit) * 4 + 50)
raw = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=fetch_n, offset=0
# Categorical filters (include/mute topics+flavors incl. active
# pauses, cortisol ceiling) go to SQL so nothing is truncated by
# ranking. Only word-boundary avoid-terms need a Python pass, so
# over-fetch just enough to cover what they might remove.
kw = dict(
include_topics=fp.include_topics or None,
include_flavors=fp.include_flavors or None,
mute_topics=list(fp.muted_topics(now)) or None,
mute_flavors=list(fp.muted_flavors(now)) or None,
max_cortisol=fp.max_cortisol,
max_ragebait=fp.max_ragebait,
)
filtered = filter_articles(raw, fp, datetime.now(timezone.utc))
rows = filtered[offset : offset + limit]
if fp.avoid_terms:
raw = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=min(2000, (offset + limit) * 4 + 50), offset=0, **kw,
)
kept = filter_articles(raw, fp, now) # drops avoid-term matches
rows = kept[offset : offset + limit]
else:
rows = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=limit, offset=offset, **kw,
)
return FeedResponse(
topic=topic,
flavor=flavor,