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
+16
View File
@@ -69,12 +69,20 @@ class FilterPrefs:
mute_flavors: list[str] = field(default_factory=list)
avoid_terms: list[str] = field(default_factory=list)
pauses: list[Pause] = field(default_factory=list)
max_cortisol: int | None = None
max_ragebait: int | None = None
@classmethod
def from_dict(cls, data: dict | None) -> "FilterPrefs":
if not isinstance(data, dict):
return cls()
def _opt_int(value: object) -> int | None:
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
def _str_list(value: object) -> list[str]:
if not isinstance(value, list):
return []
@@ -96,6 +104,8 @@ class FilterPrefs:
mute_flavors=_str_list(data.get("mute_flavors")),
avoid_terms=_str_list(data.get("avoid_terms")),
pauses=pauses,
max_cortisol=_opt_int(data.get("max_cortisol")),
max_ragebait=_opt_int(data.get("max_ragebait")),
)
def muted_topics(self, now: datetime) -> set[str]:
@@ -117,6 +127,8 @@ class FilterPrefs:
or self.mute_flavors
or self.avoid_terms
or self.pauses
or self.max_cortisol is not None
or self.max_ragebait is not None
)
@@ -148,6 +160,10 @@ def allows(article: dict, prefs: FilterPrefs, now: datetime) -> bool:
return False
if flavor in prefs.muted_flavors(now):
return False
if prefs.max_cortisol is not None and (article.get("cortisol_score") or 0) > prefs.max_cortisol:
return False
if prefs.max_ragebait is not None and (article.get("ragebait_score") or 0) > prefs.max_ragebait:
return False
blob = f"{article.get('title') or ''} {article.get('description') or ''}"
if text_matches_avoid_terms(blob, prefs.avoid_terms):
return False