Files
upbeatBytes/frontend/src/routes/+page.svelte
T
thejayman77 b4b02b5050 Scope dial polish (Codex): hero stays closest-first + visible Clear
- Hero constraint: _pick_lead now runs only within the CLOSEST non-empty section of a
  personalized Brief, so a "gentler" wider-region/world story can never be floated into
  the hero slot above a local one. Only widens if the closest section is empty.
- Dial gains a visible Clear (alongside Change) so a reader never feels locked into
  personalization; "World" stays the keep-home-but-go-global option.

366 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:06:06 -04:00

1066 lines
52 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount, untrack } from 'svelte';
import { goto, afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { getJSON, postJSON } from '$lib/api.js';
import * as P from '$lib/prefs.js';
import Header from '$lib/components/Header.svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
import MoodNav from '$lib/components/MoodNav.svelte';
import LanePicker from '$lib/components/LanePicker.svelte';
import ArticleCard from '$lib/components/ArticleCard.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import SavedFlyout from '$lib/components/SavedFlyout.svelte';
import { auth, refresh as refreshAuth, isFollowing, toggleFollow, followKeys } from '$lib/auth.svelte.js';
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
import { track } from '$lib/analytics.js';
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
import { ritualState, markBriefSeen } from '$lib/ritual.js';
let moods = $state([]);
let topics = $state([]);
let families = $state([]);
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
let showLanes = $state(false);
// The URL is the single source of truth for the current view, so the in-page
// Back button and the browser Back button share ONE history. The view is
// derived from query params: /?view=latest, /?tag=clean-energy, /?source=7,
// /?view=<mood|topic>, or bare / for Highlights.
function parseView(url) {
const p = url.searchParams;
if ((p.get('q') || '').trim()) return 'search';
if (p.get('source')) return 'source:' + p.get('source');
if (p.get('tag')) return 'tag:' + p.get('tag');
return p.get('view') || 'today';
}
function urlForView(key) {
if (key === 'today') return '/';
if (key.startsWith('source:')) return '/?source=' + encodeURIComponent(key.slice(7));
if (key.startsWith('tag:')) return '/?tag=' + encodeURIComponent(key.slice(4));
return '/?view=' + encodeURIComponent(key);
}
let selected = $derived(parseView($page.url));
let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim());
let searchOpen = $state(false);
let searchText = $state('');
function toggleSearch() { searchOpen = !searchOpen; if (searchOpen) searchText = searchQuery; }
function runSearch() {
const q = searchText.trim();
goto(q ? '/?q=' + encodeURIComponent(q) : '/');
}
let sourceNames = $state({}); // source id -> name, for an instant header label
let brief = $state(null);
let heroIdx = $state(0);
// Daily Ritual ("today's calm set") — derived from local game state + a
// brief-seen flag, keyed on the brief's SERVER date (never the browser's).
let ritual = $state({ items: [], count: 0, total: 0 });
let endcapEl = $state(null);
function refreshRitual() {
const d = brief?.brief_date;
if (d) ritual = ritualState(d, prefs.data.ritual);
}
let feed = $state([]);
let feedDone = $state(false); // no more pages for the current feed view
let loadingMore = $state(false);
// Closer to Home: the reader's opt-in home ('US' or 'US-NY'), localStorage-only.
// Empty = the default non-personalized feed. feedNextOffset carries the API's
// world-tier paging cursor so the one-time near/country lead block never skews paging.
let homeValue = $state('');
let homePromptDismissed = $state(false);
let feedNextOffset = $state(null);
// Scope dial: the reader's "emotional radius" — nearby | region | country | world.
// Persisted; default nearby. 'world' = the global brief/feed (no geo lead).
let homeScope = $state('nearby');
const homeActive = () => selected === 'latest' && !!homeValue;
let showSignIn = $state(false);
let showSaved = $state(false); // Saved flyout
let loading = $state(true);
let error = $state('');
let notice = $state('');
// Device-local browsing memory (separate from history): seen = "displayed"
// (so Replace doesn't recycle), dismissed = swapped/avoided this session.
const SEEN_KEY = 'goodnews:seen';
const DISMISSED_KEY = 'goodnews:dismissed';
const BRIEF_VIEW_KEY = 'goodnews:brief_view';
let seenIds = new Set();
let dismissed = $state(new Set());
function persistSession() {
P.saveJSON(SEEN_KEY, [...seenIds]);
P.saveJSON(DISMISSED_KEY, [...dismissed]);
}
function markDisplayed(items) {
let changed = false;
for (const a of items || []) {
if (a && !seenIds.has(a.id)) { seenIds.add(a.id); changed = true; }
}
if (changed) P.saveJSON(SEEN_KEY, [...seenIds]);
}
function openAccount() {
if (auth.user) goto('/account');
else showSignIn = true;
}
// "Since you last visited" — a calm welcome-back cue on Highlights only. Read
// the previous visit time, ask how many new calm reads arrived, then stamp now.
const LAST_SEEN_KEY = 'goodnews:last_seen';
let sinceCount = $state(0);
let sinceItems = $state([]);
let sinceOpen = $state(false);
let sinceDismissed = $state(false);
async function checkSince() {
let prev = null;
try { prev = localStorage.getItem(LAST_SEEN_KEY); localStorage.setItem(LAST_SEEN_KEY, new Date().toISOString()); }
catch { return; }
if (!prev) return; // first visit on this device → no note
try {
const q = P.param(prefs.data);
const r = await getJSON(`/api/since?ts=${encodeURIComponent(prev)}${q ? '&' + q : ''}`);
if (r.count > 0) { sinceCount = r.count; sinceItems = r.items; }
} catch { /* quiet */ }
}
// React to sign-in only (untrack the body so browsing doesn't retrigger it).
$effect(() => {
const u = auth.user;
if (u && typeof window !== 'undefined') untrack(() => onLogin(u));
});
async function onLogin(u) {
const key = 'goodnews:imported:' + u.id;
if (!localStorage.getItem(key)) {
try {
await postJSON('/api/import', { seen: deviceIds(), saved: [] });
localStorage.setItem(key, '1');
} catch { /* best-effort */ }
}
loadServerHistory();
await syncPrefsOnLogin(); // adopt account prefs or seed from device
loadView(selected, true); // reflect any adopted boundaries in the current view
}
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
const humanize = (s) => (s || '').replace(/-/g, ' ');
// Show the brief's date in the VIEWER's local timezone (from its UTC freshness
// stamp), so everyone sees their own correct date — never "tomorrow."
function localDateLabel(b) {
const ts = b?.generated_at;
if (ts) {
const d = new Date(ts.replace(' ', 'T') + 'Z');
if (!isNaN(d)) return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
}
return b?.brief_date ?? '';
}
let filtersOn = $derived(prefsActive());
let currentMood = $derived(moods.find((m) => m.key === selected));
let currentTopic = $derived(topics.find((t) => t.key === selected));
let currentTag = $derived(selected.startsWith('tag:') ? selected.slice(4) : null);
let tagFamily = $derived(
currentTag ? families.find((f) => f.tags.some((t) => t.key === currentTag)) : null
);
let viewLabel = $derived(
selected === 'today' ? 'Highlights from Today'
: selected === 'search' ? `Search: “${searchQuery}”`
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
: selected === 'latest' ? 'Latest'
: selected === 'following' ? 'Following'
: currentTag ? humanize(currentTag)
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
);
let viewSubtitle = $derived(
selected === 'today' ? localDateLabel(brief)
: selected === 'search' ? 'Results across Upbeat Bytes'
: selected.startsWith('source:') ? 'Latest from this source'
: selected === 'latest' ? 'Freshest calm reads — newest first'
: selected === 'following' ? 'From the sources & topics you follow'
: currentTag ? (tagFamily?.description ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
);
let activeTab = $derived(
selected === 'today' ? 'today' : selected === 'latest' ? 'latest'
: selected === 'following' ? 'following' : 'browse'
);
// Customizable nav rail: the pinned lanes (Highlights + Latest) are always
// first, then the reader's chosen lanes (or the default set if they've never
// customized). Resolve each key to its {label, description} from the pool.
let laneMap = $derived(
new Map(
lanePool
? [
...lanePool.pinned.map((p) => [p.key, p]),
...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])),
]
: []
)
);
let pinnedLaneKeys = $derived(
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
);
let navLanes = $derived(
lanePool
? [
...lanePool.pinned,
...(auth.user ? [{ key: 'following', label: 'Following' }] : []),
...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean),
]
: []
);
function saveLanes(keys) {
prefs.data.lanes = keys;
persistPrefs();
// If the reader unpinned the lane they're viewing, fall back to Highlights.
// (The special pinned lanes — Highlights/Latest/Following — are never in
// `keys`, so don't bounce away from them.)
if (!['today', 'latest', 'following'].includes(selected) && !keys.includes(selected)) navigate('today');
}
// Hero is the only image slot; if its image won't load, promote the next one.
let heroArticle = $derived(brief?.items?.[heroIdx] ?? null);
let restArticles = $derived((brief?.items ?? []).filter((_, i) => i !== heroIdx));
function heroImageFailed() {
const items = brief?.items ?? [];
for (let i = heroIdx + 1; i < items.length; i++) {
if (items[i]?.image_url) { heroIdx = i; return; }
}
}
// A source or tag view can be followed (durable interest).
let followTarget = $derived(
selected.startsWith('source:') ? { kind: 'source', value: selected.slice(7), noun: 'source' }
: selected.startsWith('tag:') ? { kind: 'tag', value: selected.slice(4), noun: 'topic' }
: null
);
function viewFilter(key = selected) {
if (key === 'today') return {};
const m = moods.find((x) => x.key === key);
if (m) return m.filter ?? {};
return { include_topics: [key] };
}
function mergedParam() {
return P.param(P.merge(prefs.data, viewFilter()));
}
// Signature of the filters that shaped a brief — boundaries/prefs + dismissals.
// Instant-paint and the merge only reuse a saved brief when this still matches,
// so a boundary change can never briefly resurface content it should now hide.
function briefSig() {
const h = homeValue && homeScope !== 'world' ? `${homeValue}:${homeScope}` : '';
return P.param(prefs.data) + '|' + Array.from(dismissed).sort().join(',') + '|h:' + h;
}
async function loadToday(fresh) {
const q = P.param(prefs.data);
const ex = Array.from(dismissed).join(',');
let fetched;
// Only personalize when a home is set and the dial isn't on 'world' (global).
const homeq = homeValue && homeScope !== 'world'
? `&home=${encodeURIComponent(homeValue)}&scope=${homeScope}` : '';
try {
fetched = await getJSON(`/api/brief?limit=7${homeq}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
} catch (e) {
if (brief) return; // already showing a saved brief — a failed background refresh stays invisible
throw e; // true first load with nothing painted → let the caller surface the error
}
const sig = briefSig();
const view = P.loadJSON(BRIEF_VIEW_KEY, null);
// Reuse the saved arrangement only when BOTH the server brief and the reader's
// filter signature are unchanged — otherwise the merge's `?? it` fallback could
// re-add a story the current boundaries now hide.
const sameServerBrief =
view && view.generated_at && fetched.generated_at &&
view.generated_at === fetched.generated_at && view.sig === sig;
if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) {
const freshById = new Map(fetched.items.map((a) => [a.id, a]));
const items = view.items.map((it) => freshById.get(it.id) ?? it);
brief = { ...fetched, items };
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items, sig });
} else {
brief = fetched;
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items, sig });
}
heroIdx = 0;
markDisplayed(brief.items);
}
// Build a /api/feed URL for a lane view at a given page offset. 'latest' is
// the chronological firehose (sort=latest); 'tag:x' browses a grouping tag;
// anything else resolves through its mood/topic filter.
const PAGE = 24;
function feedUrl(key, offset) {
const ex = Array.from(dismissed).join(',');
const exq = ex ? `&exclude=${ex}` : '';
if (key === 'search') {
const q = P.param(prefs.data);
return `/api/search?q=${encodeURIComponent(searchQuery)}&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}`;
}
if (key === 'latest') {
const q = P.param(prefs.data);
// Closer to Home lives on the all-news browse lane (Latest).
const homeq = homeValue ? `&home=${encodeURIComponent(homeValue)}` : '';
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${homeq}${q ? '&' + q : ''}${exq}`;
}
if (key === 'following') {
const q = P.param(prefs.data);
return `/api/feed?following=true&sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
}
if (key.startsWith('tag:')) {
const q = P.param(prefs.data);
return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`;
}
if (key.startsWith('source:')) {
// A publication feed: this source's articles, newest first, still
// accepted/non-duplicate/boundary-filtered.
const q = P.param(prefs.data);
return `/api/feed?source_id=${encodeURIComponent(key.slice(7))}&sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
}
const q = P.param(P.merge(prefs.data, viewFilter(key)));
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
}
// All navigation goes through the URL (goto), so browser Back/Forward and the
// in-page Back button traverse the same single history.
function navigate(key) {
goto(urlForView(key));
}
// Drilling into a tag/source from a card: cache the source name for an instant
// label, then navigate by URL (no private stack).
function drill(key, source = null) {
if (source) sourceNames = { ...sourceNames, [source.id]: source.name };
navigate(key);
}
// How many forward in-app navigations deep we are from the landing entry, so
// the in-page Back stays INSIDE the app even when someone deep-links in from
// email/social/an article (history.length can't tell us that).
let appNavDepth = 0;
function goBack() {
if (appNavDepth > 0 && typeof history !== 'undefined') history.back();
else goto(urlForView('today')); // landed here directly → app-safe Highlights
}
// Load the data for a view. Called by afterNavigate (URL-driven: in-app goto,
// browser back/forward, initial load) and directly on a boundary re-filter.
let loadSeq = 0;
async function loadView(key, fresh = false) {
const seq = ++loadSeq; // a newer navigation supersedes a slow in-flight one
error = '';
feedDone = false;
try {
if (key === 'today') {
await loadToday(fresh);
if (seq !== loadSeq) return; // a newer navigation superseded this one
} else {
const resp = await getJSON(feedUrl(key, 0));
if (seq !== loadSeq) return;
const items = resp.items;
feed = items;
feedNextOffset = resp.next_offset ?? null;
// Home lane pages by the API's world cursor; other lanes by simple length.
feedDone = (key === 'latest' && homeValue) ? feedNextOffset == null : items.length < PAGE;
markDisplayed(feed);
if (key.startsWith('source:') && items[0]) {
sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
}
}
} catch (e) {
if (seq === loadSeq) error = 'Something went quiet — could not reach the feed.';
}
if (seq === loadSeq && typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
}
// Every URL change (in-app goto, browser back/forward, link) reloads the view.
// The initial 'enter' is handled by onMount, after its data deps are fetched.
afterNavigate((nav) => {
if (nav.type === 'enter') return;
// Forward in-app nav deepens the stack; popstate moves by its signed delta
// (Back = -1, Forward = +1, ±N for jumps), so a back-then-forward dance keeps
// an accurate count. Clamped at 0, so an out-of-app landing stays app-safe.
if (nav.type === 'goto' || nav.type === 'link') appNavDepth += 1;
else if (nav.type === 'popstate') appNavDepth = Math.max(0, appNavDepth + (nav.delta ?? -1));
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on shared/back links
loadView(selected);
});
// The brief is "enjoyed" only when its end-cap actually scrolls into view (the
// finite read), not on mere page-open. When it does, mark it for today and
// refresh the calm-set — which also picks up any Word / Word Search played in
// this session. The block lives inside the end-cap, so by the time the reader
// sees it, the brief tick has already settled.
$effect(() => {
if (!endcapEl || !brief?.brief_date) return;
const date = brief.brief_date;
const io = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) { markBriefSeen(date); refreshRitual(); }
}, { threshold: 0.4 });
io.observe(endcapEl);
return () => io.disconnect();
});
// "Load more" for any feed view (Latest, topics, tags, moods): fetch the next
// page at the current length and append, de-duping against what's shown.
async function loadMore() {
if (loadingMore || feedDone || selected === 'today') return;
loadingMore = true;
try {
const off = homeActive() && feedNextOffset != null ? feedNextOffset : feed.length;
const resp = await getJSON(feedUrl(selected, off));
const items = resp.items;
const have = new Set(feed.map((a) => a.id));
const fresh = items.filter((a) => !have.has(a.id));
feed = [...feed, ...fresh];
feedNextOffset = resp.next_offset ?? null;
feedDone = homeActive() ? feedNextOffset == null : items.length < PAGE;
markDisplayed(fresh);
} catch {
flash('Could not load more just now.');
} finally {
loadingMore = false;
}
}
// The finite-ending's gentle nudge: one tap to get tomorrow's brief by email.
let digestBusy = $state(false);
let pendingDigestOptIn = $state(false);
async function subscribeDigest() {
if (!auth.user) { pendingDigestOptIn = true; showSignIn = true; return; } // sign in → auto-enable
if (auth.user.digest_enabled || digestBusy) return;
digestBusy = true;
try { await postJSON('/api/account/digest', { enabled: true }); await refreshAuth(); }
catch { /* leave as-is */ }
finally { digestBusy = false; }
}
// Keep the one-tap promise: if they tapped while signed out, enable right after sign-in.
$effect(() => {
if (pendingDigestOptIn && auth.user) {
pendingDigestOptIn = false;
if (!auth.user.digest_enabled) subscribeDigest();
}
});
const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
function applyAction(kind, value) {
applyPrefAction(kind, value); // updates + persists + syncs to account
if (MIX_EVENT[kind]) track(MIX_EVENT[kind]); // emotional-mix signal (no value stored)
loadView(selected, true); // re-filter the current view in place (no nav)
}
function flash(msg) {
notice = msg;
if (typeof window !== 'undefined') setTimeout(() => (notice = ''), 4000);
}
async function replaceArticle(article) {
const list = selected === 'today' ? brief?.items : feed;
if (!list) return;
const isHero = selected === 'today' && heroArticle?.id === article.id;
const exclude = Array.from(seenIds).join(',');
const q = mergedParam();
const url = `/api/replacement?exclude=${exclude}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
let repl;
try {
repl = await getJSON(url);
} catch {
flash('Could not reach the feed just now.');
return;
}
if (!repl) {
flash("That's everything fresh for now — nothing new to swap in.");
track('replace_none');
return;
}
track('replace_used', article.id);
if (article.paywalled) track('paywall_replace', article.id);
dismissed.add(article.id);
seenIds.add(article.id);
markDisplayed([repl]);
record(article); // keep the swapped-away story (recoverable)
persistSession();
if (selected === 'today') {
const i = brief.items.findIndex((a) => a.id === article.id);
if (i >= 0) {
brief.items[i] = repl;
brief = { ...brief, items: [...brief.items] };
// Sign the save (dismissed just changed) so the next load can still
// instant-paint this edited brief instead of falling back to "Gathering…".
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: brief.generated_at, items: brief.items, sig: briefSig() });
}
} else {
const i = feed.findIndex((a) => a.id === article.id);
if (i >= 0) {
feed[i] = repl;
feed = [...feed];
}
}
}
async function browse() {
if (selected !== 'today') await goto(urlForView('today'));
document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
}
// --- Closer to Home: opt-in, localStorage-only, easy to clear ---
function reloadHomeViews() {
if (selected === 'today') loadToday(true);
else if (selected === 'latest') loadView('latest', true);
}
function persistScope() { try { localStorage.setItem('goodnews:homeScope', homeScope); } catch { /* ignore */ } }
function setHome(v) {
homeValue = v || '';
// No US state -> nearby/region don't apply; collapse the dial to country.
if (!String(v).includes('-') && (homeScope === 'nearby' || homeScope === 'region')) homeScope = 'country';
try { v ? localStorage.setItem('goodnews:home', v) : localStorage.removeItem('goodnews:home'); } catch { /* ignore */ }
persistScope();
reloadHomeViews();
}
function setScope(s) { homeScope = s; persistScope(); reloadHomeViews(); }
function clearHome() { setHome(''); }
function dismissHomePrompt() {
homePromptDismissed = true;
try { localStorage.setItem('goodnews:homeDismissed', '1'); } catch { /* ignore */ }
}
let homeCountry = $derived(homeValue ? homeValue.split('-')[0] : '');
let homeState = $derived(homeValue.includes('-') ? homeValue.split('-')[1] : '');
// US census regions (mirror of the backend) — for concrete section labels.
const US_REGIONS = {
Northeast: ['CT','ME','MA','NH','RI','VT','NJ','NY','PA'],
Midwest: ['IL','IN','MI','OH','WI','IA','KS','MN','MO','NE','ND','SD'],
South: ['DE','FL','GA','MD','NC','SC','VA','DC','WV','AL','KY','MS','TN','AR','LA','OK','TX'],
West: ['AZ','CO','ID','MT','NV','NM','UT','WY','AK','CA','HI','OR','WA'],
};
let homeStateName = $derived(homeState ? (US_STATES.find(([c]) => c === homeState)?.[1] ?? homeState) : '');
let homeRegionName = $derived(homeState ? (Object.keys(US_REGIONS).find((r) => US_REGIONS[r].includes(homeState)) ?? '') : '');
let homeCountryLabel = $derived(homeCountry === 'US' ? 'the US' : homeCountry === 'GB' ? 'the UK'
: (HOME_COUNTRIES.find(([c]) => c === homeCountry)?.[1] ?? homeCountry));
// The dial's stops adapt to what's entered: full radius for a US state, else Country/World.
let scopeStops = $derived(homeState
? [['nearby', 'Nearby'], ['region', 'Region'], ['country', 'Country'], ['world', 'World']]
: [['country', 'Country'], ['world', 'World']]);
// Concrete, soft section labels — honest place names so the reader sees the dial worked.
function sectionLabel(key) {
if (key === 'state') return homeStateName ? `Around ${homeStateName}` : 'Near you';
if (key === 'region') return homeRegionName ? `Across the ${homeRegionName}` : 'Your region';
if (key === 'country') return `Across ${homeCountryLabel}`;
if (key === 'world') return 'Around the world';
if (key === 'near') return homeStateName ? `Around ${homeStateName}` : 'Near you'; // legacy feed key
return '';
}
// Home picker. Countries are the well-covered ones (matches backend normalization);
// US gets state granularity. Kept short on purpose — calm, not a config wall.
const HOME_COUNTRIES = [
['US', 'United States'], ['GB', 'United Kingdom'], ['CA', 'Canada'], ['AU', 'Australia'],
['IE', 'Ireland'], ['NZ', 'New Zealand'], ['FR', 'France'], ['DE', 'Germany'],
['NL', 'Netherlands'], ['BE', 'Belgium'], ['IT', 'Italy'], ['ES', 'Spain'], ['IN', 'India'],
];
const US_STATES = [
['AL','Alabama'],['AK','Alaska'],['AZ','Arizona'],['AR','Arkansas'],['CA','California'],
['CO','Colorado'],['CT','Connecticut'],['DE','Delaware'],['DC','Washington DC'],['FL','Florida'],
['GA','Georgia'],['HI','Hawaii'],['ID','Idaho'],['IL','Illinois'],['IN','Indiana'],['IA','Iowa'],
['KS','Kansas'],['KY','Kentucky'],['LA','Louisiana'],['ME','Maine'],['MD','Maryland'],
['MA','Massachusetts'],['MI','Michigan'],['MN','Minnesota'],['MS','Mississippi'],['MO','Missouri'],
['MT','Montana'],['NE','Nebraska'],['NV','Nevada'],['NH','New Hampshire'],['NJ','New Jersey'],
['NM','New Mexico'],['NY','New York'],['NC','North Carolina'],['ND','North Dakota'],['OH','Ohio'],
['OK','Oklahoma'],['OR','Oregon'],['PA','Pennsylvania'],['RI','Rhode Island'],['SC','South Carolina'],
['SD','South Dakota'],['TN','Tennessee'],['TX','Texas'],['UT','Utah'],['VT','Vermont'],
['VA','Virginia'],['WA','Washington'],['WV','West Virginia'],['WI','Wisconsin'],['WY','Wyoming'],
];
let homeEditing = $state(false);
let pickCountry = $state('');
let pickState = $state('');
function applyHomePick() {
if (!pickCountry) return;
setHome(pickCountry === 'US' && pickState ? `${pickCountry}-${pickState}` : pickCountry);
homeEditing = false;
}
function openHomeEditor() { pickCountry = homeCountry; pickState = homeState; homeEditing = true; }
onMount(async () => {
initPrefs();
initHistory();
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
try {
homeValue = localStorage.getItem('goodnews:home') || '';
homePromptDismissed = localStorage.getItem('goodnews:homeDismissed') === '1';
homeScope = localStorage.getItem('goodnews:homeScope') || 'nearby';
if (!homeValue.includes('-') && (homeScope === 'nearby' || homeScope === 'region')) homeScope = 'country';
} catch { /* ignore */ }
refreshAuth();
// trackVisit() now fires once in the global layout (covers every landing page).
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on direct/shared link
// Instant paint: render the last saved Today brief immediately and refresh
// it behind the scenes, so the first view never blocks on a (personalized,
// origin-bound) /api/brief request. "Gathering the good news…" then only
// shows on a genuine first visit with nothing cached yet.
if (selected === 'today') {
const cached = P.loadJSON(BRIEF_VIEW_KEY, null);
// Only paint instantly if the saved brief was shaped by the SAME
// boundaries/prefs/dismissals — never flash content the current settings
// should hide. A mismatch falls through to "Gathering…" + a fresh fetch.
if (cached && Array.isArray(cached.items) && cached.items.length && cached.sig === briefSig()) {
brief = cached;
heroIdx = 0;
loading = false;
}
}
// Critical path: moods + categories (needed to resolve a mood/topic view)
// load in PARALLEL, then the requested view. Every getJSON times out, and
// loading ALWAYS clears in finally, so the page can never get stuck on
// "Gathering the good news…".
try {
const [m, c] = await Promise.all([
getJSON('/api/moods').catch(() => []),
getJSON('/api/categories').catch(() => ({ topics: [] })),
]);
moods = m; topics = c.topics;
await loadView(selected);
} catch (e) {
if (!brief) error = 'Could not reach Upbeat Bytes.'; // keep painted content if a refresh failed
} finally {
loading = false;
}
checkSince(); // after the first paint; non-blocking
// Pure decoration (nav-rail customization + tag families): non-blocking — a
// slow or failed one fills in late instead of holding up the content.
Promise.allSettled([
getJSON('/api/lanes').then((r) => (lanePool = r)),
getJSON('/api/families').then((r) => (families = r)),
]);
});
</script>
<Header onSaved={() => (showSaved = true)} onaccount={openAccount} user={auth.user} boundariesActive={filtersOn} />
{#if showSignIn}<SignIn onclose={() => { showSignIn = false; if (!auth.user) pendingDigestOptIn = false; }} />{/if}
{#if showSaved && auth.user}<SavedFlyout onclose={() => (showSaved = false)} />{/if}
{#if showLanes && lanePool}
<LanePicker pool={lanePool} selected={pinnedLaneKeys} onsave={saveLanes} onclose={() => (showLanes = false)} />
{/if}
<main class="container">
{#if navLanes.length}
<MoodNav lanes={navLanes} {selected} onselect={navigate} oncustomize={() => (showLanes = true)} />
{/if}
{#if notice}<p class="notice rise">{notice}</p>{/if}
{#if loading}
<p class="muted center pad">Gathering the good news…</p>
{:else if error}
<p class="muted center pad">{error}</p>
{:else}
{#key selected}
<header class="view-head rise">
<div class="vh-text">
<h1>{viewLabel}</h1>
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
</div>
<div class="vh-actions">
<button class="searchtoggle" class:on={searchOpen || selected === 'search'} onclick={toggleSearch} aria-label="Search articles" title="Search articles">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M21 21l-4.4-4.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
{#if auth.user && followTarget}
<button class="followbtn" class:on={isFollowing(followTarget.kind, followTarget.value)}
onclick={() => toggleFollow(followTarget.kind, followTarget.value)}>
{isFollowing(followTarget.kind, followTarget.value) ? '✓ Following' : 'Follow ' + followTarget.noun}
</button>
{/if}
{#if selected !== 'today'}
<button class="viewback" onclick={goBack} aria-label="Go back">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
Back
</button>
{/if}
</div>
</header>
{#if searchOpen || selected === 'search'}
<div class="searchbar rise">
<input type="search" bind:value={searchText} placeholder="Search articles, topics, or a source…"
autocapitalize="off" autocomplete="off" spellcheck="false"
onkeydown={(e) => (e.key === 'Enter' ? runSearch() : e.key === 'Escape' ? (searchOpen = false) : null)} />
<button class="searchgo" onclick={runSearch}>Search</button>
{#if selected === 'search'}<button class="searchclear" onclick={() => { searchOpen = false; goto('/'); }}>Clear</button>{/if}
</div>
{/if}
{#if selected === 'today'}
{#if sinceCount > 0 && !sinceDismissed}
<div class="welcomeback rise">
<p class="wb-text">
Since you were last here, {sinceCount} new calm read{sinceCount === 1 ? '' : 's'} came in.
{#if !sinceOpen}<button class="wb-cta" onclick={() => (sinceOpen = true)}>See whats new</button>{/if}
</p>
<button class="wb-x" onclick={() => (sinceDismissed = true)} aria-label="Dismiss">×</button>
</div>
{#if sinceOpen && sinceItems.length}
<section class="rise sincesec">
<h2 class="since-h">New since your last visit</h2>
<div class="grid">
{#each sinceItems as a (a.id)}
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
{/each}
</div>
</section>
{/if}
{/if}
{#if brief?.items?.length}
{#if homeEditing || (!homeValue && !homePromptDismissed)}
<div class="homecard rise">
{#if !homeValue}<p class="homecopy">Want your good news closer to home?</p>{/if}
<div class="homepick">
<select bind:value={pickCountry} aria-label="Country">
<option value="">Pick a country…</option>
{#each HOME_COUNTRIES as [code, label] (code)}<option value={code}>{label}</option>{/each}
</select>
{#if pickCountry === 'US'}
<select bind:value={pickState} aria-label="State">
<option value="">All of the US</option>
{#each US_STATES as [code, label] (code)}<option value={code}>{label}</option>{/each}
</select>
{/if}
<button class="hset" onclick={applyHomePick} disabled={!pickCountry}>Show local first</button>
{#if homeValue}<button class="linkish" onclick={() => (homeEditing = false)}>Cancel</button>
{:else}<button class="linkish" onclick={dismissHomePrompt}>Not now</button>{/if}
</div>
</div>
{:else if homeValue}
<div class="scopedial rise">
<span class="sd-label">Good news closest first</span>
<div class="sd-stops">
{#each scopeStops as [s, label] (s)}
<button class="sd-btn" class:on={homeScope === s} onclick={() => setScope(s)}>{label}</button>
{/each}
</div>
<span class="sd-actions">
<button class="linkish" onclick={openHomeEditor}>Change</button>
<button class="linkish" onclick={clearHome}>Clear</button>
</span>
</div>
{/if}
<section class="rise">
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} onimageerror={heroImageFailed} />
{#if restArticles.length}
<div class="grid rest">
{#each restArticles as a, i (a.id)}
{#if a.section && a.section !== restArticles[i - 1]?.section && a.section !== heroArticle?.section}
<h3 class="feed-section">{sectionLabel(a.section)}</h3>
{/if}
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
{/each}
</div>
{/if}
</section>
<div class="endcap rise" bind:this={endcapEl}>
<p class="endmark">✦ that's the good news for today ✦</p>
<p class="endsub">You're caught up for now.</p>
{#if ritual.total}
<div class="calmset">
<p class="cs-head">Today's calm set</p>
<ul class="cs-items">
{#each ritual.items as it (it.key)}
<li class="cs-item" class:done={it.done}>
<span class="cs-mark" aria-hidden="true"></span>{#if it.done || it.key === 'brief'}{it.label}{:else}<a href={it.href}>{it.label}</a>{/if}
</li>
{/each}
</ul>
<p class="cs-foot">
{ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} · fresh set tomorrow · <a class="cs-edit" href="/account?section=calmset">make it yours</a>
</p>
</div>
{/if}
{#if auth.user?.digest_enabled}
<p class="digestnote">Tomorrow's brief is headed to your inbox ☕</p>
{:else}
<button class="digestcta" onclick={subscribeDigest} disabled={digestBusy}>
{digestBusy ? '…' : 'Get tomorrows brief by email'}
</button>
{/if}
</div>
{:else}
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
{/if}
{:else if feed.length}
{#if selected === 'latest'}
{#if homeEditing || (!homeValue && !homePromptDismissed)}
<div class="homecard rise">
<p class="homecopy">Want good news closer to home?</p>
<div class="homepick">
<select bind:value={pickCountry} aria-label="Country">
<option value="">Pick a country…</option>
{#each HOME_COUNTRIES as [code, label] (code)}<option value={code}>{label}</option>{/each}
</select>
{#if pickCountry === 'US'}
<select bind:value={pickState} aria-label="State">
<option value="">All of the US</option>
{#each US_STATES as [code, label] (code)}<option value={code}>{label}</option>{/each}
</select>
{/if}
<button class="hset" onclick={applyHomePick} disabled={!pickCountry}>Show local first</button>
{#if homeValue}
<button class="linkish" onclick={() => (homeEditing = false)}>Cancel</button>
{:else}
<button class="linkish" onclick={dismissHomePrompt}>Not now</button>
{/if}
</div>
</div>
{:else if homeValue}
<div class="homebar">📍 Showing local first · <button class="linkish" onclick={openHomeEditor}>Change</button> · <button class="linkish" onclick={clearHome}>Clear</button></div>
{/if}
{/if}
<div class="grid rise">
{#each feed as a, i (a.id)}
{#if a.section && a.section !== feed[i - 1]?.section}
<h3 class="feed-section">{sectionLabel(a.section)}</h3>
{/if}
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
{/each}
</div>
{#if !feedDone}
<div class="loadmore">
<button onclick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading…' : 'Load more'}
</button>
</div>
{:else}
<p class="endcap rise">✦ you're all caught up ✦</p>
{/if}
{:else if selected === 'search'}
<p class="muted center pad">No articles found for “{searchQuery}”. Try a different word, or a source name like “Nature”.</p>
{:else if selected === 'following'}
<p class="muted center pad">
{#if auth.user}Nothing here yet — open a source or a grouping and tap <strong>Follow</strong> to fill this lane with what you care about.
{:else}Sign in and follow a few sources or topics, and this becomes your own calm lane.{/if}
</p>
{:else}
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
{/if}
{/key}
{#if families.length}
<section id="explore" class="explore">
<h2>Explore Upbeat Bytes</h2>
<div class="families">
{#each families as f (f.name)}
{@const tags = f.tags.filter((t) => t.count > 0)}
{#if tags.length}
<div class="family">
<h3>{f.name}</h3>
<p class="fdesc">{f.description}</p>
<div class="chips">
{#each tags as t (t.key)}
<button class="chip" class:active={selected === 'tag:' + t.key} onclick={() => drill('tag:' + t.key)}>{humanize(t.key)}</button>
{/each}
</div>
</div>
{/if}
{/each}
</div>
</section>
{/if}
{#if !pwa.isStandalone && !pwa.dismissed && (pwa.canInstall || pwa.isIOS)}
<aside class="install rise">
<div class="install-text">
<strong>Keep Upbeat Bytes a tap away.</strong>
{#if pwa.canInstall}Add it to your home screen — it opens like an app, no store needed.
{:else}On iPhone: tap the <span class="ios-share">Share</span> button, then “Add to Home Screen.”{/if}
</div>
<div class="install-actions">
{#if pwa.canInstall}<button class="install-go" onclick={installApp}>Install</button>{/if}
<button class="install-x" onclick={dismissPwa}>Not now</button>
</div>
</aside>
{/if}
{/if}
</main>
<BottomNav active={activeTab} onToday={() => navigate('today')} onLatest={() => navigate('latest')} onPlay={() => goto('/play')} onYou={openAccount} user={auth.user} />
<style>
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
.view-head {
margin: 18px 0 18px; display: flex; align-items: flex-start;
justify-content: space-between; gap: 16px;
}
.view-head .vh-text { flex: 1; min-width: 0; }
.viewback {
flex-shrink: 0; margin-top: 8px;
display: inline-flex; align-items: center; gap: 5px;
background: none; border: 1px solid var(--line); color: var(--accent-deep);
border-radius: 999px; padding: 6px 14px; font-size: 0.85rem; cursor: pointer;
transition: border-color 0.14s ease;
}
.viewback:hover { border-color: var(--accent); }
.viewback svg { width: 16px; height: 16px; display: block; }
.vh-actions { flex-shrink: 0; display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.searchtoggle { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px;
background: none; border: 1px solid var(--line); color: var(--accent-deep); border-radius: 999px;
cursor: pointer; transition: border-color 0.14s ease, background 0.14s ease; }
.searchtoggle:hover { border-color: var(--accent); }
.searchtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.searchtoggle svg { width: 17px; height: 17px; display: block; }
.searchbar { display: flex; gap: 8px; margin: 0 0 18px; }
.searchbar input { flex: 1; min-width: 0; font: inherit; font-size: 1rem; padding: 10px 14px;
border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); }
.searchbar input:focus { outline: none; border-color: var(--accent); }
.searchgo { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: 0 18px;
font: inherit; font-weight: 600; cursor: pointer; }
.searchgo:hover { background: var(--accent-deep); }
.searchclear { background: none; border: 1px solid var(--line); color: var(--muted); border-radius: 10px;
padding: 0 14px; font: inherit; cursor: pointer; }
.followbtn {
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
background: none; border: 1px solid var(--accent); color: var(--accent-deep);
border-radius: 999px; padding: 6px 15px; font-size: 0.85rem; cursor: pointer;
transition: background 0.14s ease, color 0.14s ease;
}
.followbtn:hover { background: var(--accent-soft); }
.followbtn.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.view-head h1 { font-size: clamp(2.1rem, 5.5vw, 2.8rem); line-height: 1.05; text-transform: capitalize; }
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
.view-head .vh-text::after {
content: ''; display: block; width: 46px; height: 3px;
background: var(--accent); border-radius: 2px; margin-top: 14px; opacity: 0.8;
}
.explore { margin: 52px 0 8px; padding-top: 28px; border-top: 1px solid var(--line); }
.explore h2 {
font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--muted); font-family: var(--label); font-weight: 400; margin: 0 0 22px;
}
.families { display: grid; grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); gap: 26px 32px; }
.family h3 { font-size: 1.16rem; margin: 0 0 2px; }
.family .fdesc { margin: 0 0 11px; color: var(--muted); font-size: 0.86rem; line-height: 1.45; }
.explore .chips { display: flex; flex-wrap: wrap; gap: 7px; }
.explore .chip {
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer;
transition: all 0.14s ease; text-transform: capitalize;
}
.explore .chip:hover { border-color: var(--accent); color: var(--accent-deep); }
.explore .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.notice {
text-align: center; color: var(--accent-deep); background: var(--accent-soft);
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content; font-size: 0.86rem;
}
.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;
}
.endcap .endmark { margin: 0; }
.endcap .endsub { margin: 4px 0 0; font-size: 0.92rem; }
.endcap .digestnote { margin: 14px 0 0; font-style: normal; font-family: var(--label); font-size: 0.86rem; color: var(--accent-deep); }
.endcap .digestcta {
margin-top: 16px; font-family: var(--label); font-style: normal; font-size: 0.9rem; cursor: pointer;
background: var(--accent); color: #fff; border: none; border-radius: 999px; padding: 10px 22px;
}
.endcap .digestcta:hover { background: var(--accent-deep); }
.endcap .digestcta:disabled { opacity: 0.6; cursor: default; }
/* Daily Ritual — "today's calm set". Gentle, non-instrumental: a soft check
for what's been enjoyed, no streak, no pressure to finish the rest. */
.calmset {
margin: 20px auto 4px; max-width: 320px; padding-top: 16px;
border-top: 1px solid var(--line); font-style: normal; font-family: var(--label);
}
.cs-head {
margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em;
font-size: 0.66rem; font-weight: 600; color: var(--accent-deep);
}
.cs-items {
list-style: none; margin: 0; padding: 0; display: flex; gap: 16px;
justify-content: center; flex-wrap: wrap;
}
.cs-item { display: inline-flex; align-items: center; gap: 7px; font-size: 0.9rem; color: var(--muted); }
.cs-item a { color: inherit; text-decoration: none; }
.cs-item a:hover { color: var(--accent-deep); }
.cs-item.done { color: var(--ink); }
.cs-mark {
width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid var(--line);
flex-shrink: 0; transition: background 0.16s ease, border-color 0.16s ease;
}
.cs-item.done .cs-mark {
background: var(--accent); border-color: var(--accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: 13px; background-repeat: no-repeat; background-position: center;
}
.cs-foot { margin: 12px 0 0; font-size: 0.82rem; color: var(--muted); }
.cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; }
/* "Since you last visited" — a calm welcome-back cue on Highlights */
.welcomeback {
display: flex; align-items: center; gap: 12px; justify-content: space-between;
background: var(--accent-soft); border: 1px solid var(--accent-soft); color: var(--accent-deep);
border-radius: 14px; padding: 12px 16px; margin: 4px 0 18px;
}
.welcomeback .wb-text { margin: 0; font-size: 0.95rem; }
.wb-cta { background: none; border: none; color: var(--accent-deep); font: inherit; font-weight: 600;
cursor: pointer; text-decoration: underline; margin-left: 6px; padding: 0; }
.wb-x { background: none; border: none; color: var(--accent-deep); font-size: 1.3rem; line-height: 1;
cursor: pointer; padding: 0 4px; opacity: 0.7; flex-shrink: 0; }
.wb-x:hover { opacity: 1; }
.sincesec { margin: 0 0 26px; }
.since-h { font-size: 1.1rem; margin: 0 0 12px; color: var(--ink); }
/* PWA install banner — gentle, dismissible, never nagging */
.install {
display: flex; align-items: center; gap: 14px; justify-content: space-between; flex-wrap: wrap;
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
padding: 16px 20px; margin: 28px 0 0;
}
.install-text { font-size: 0.92rem; color: var(--ink); line-height: 1.5; }
.install-text strong { display: block; margin-bottom: 2px; }
.ios-share { color: var(--accent-deep); font-weight: 600; }
.install-actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
.install-go { background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 9px 20px; font: inherit; font-weight: 600; cursor: pointer; }
.install-go:hover { background: var(--accent-deep); }
.install-x { background: none; border: none; color: var(--muted); font: inherit; font-size: 0.88rem; cursor: pointer; }
.install-x:hover { color: var(--accent-deep); }
/* Closer to Home: calm inline prompt + slim indicator + section headers */
.homecard { border: 1px solid var(--line); border-radius: 14px; background: var(--accent-soft);
padding: 14px 16px; margin: 0 0 18px; }
.homecopy { margin: 0 0 10px; font-family: var(--label); color: var(--accent-deep); }
.homepick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.homepick select { font: inherit; font-size: 0.9rem; padding: 7px 10px; border: 1px solid var(--line);
border-radius: 9px; background: var(--bg); color: var(--ink); }
.hset { font: inherit; font-weight: 600; padding: 7px 16px; border: none; border-radius: 999px;
background: var(--accent); color: #fff; cursor: pointer; }
.hset:hover { background: var(--accent-deep); }
.hset:disabled { opacity: 0.55; cursor: default; }
.linkish { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.86rem;
cursor: pointer; text-decoration: underline; padding: 0; }
.homebar { font-size: 0.86rem; color: var(--muted); margin: 0 0 16px; }
.scopedial { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 0 0 16px; }
.sd-label { font-size: 0.82rem; color: var(--muted); }
.sd-stops { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
.sd-btn { font: inherit; font-size: 0.85rem; font-weight: 600; padding: 6px 14px; border: none;
background: var(--bg); color: var(--ink); cursor: pointer; border-right: 1px solid var(--line); }
.sd-stops .sd-btn:last-child { border-right: none; }
.sd-btn.on { background: var(--accent-soft); color: var(--accent-deep); }
.sd-actions { margin-left: auto; display: inline-flex; gap: 12px; }
.feed-section { grid-column: 1 / -1; margin: 8px 0 2px; font-family: var(--label); font-size: 0.78rem;
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.grid > .feed-section:first-child { margin-top: 0; }
.loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
.loadmore button {
background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
border-radius: 999px; padding: 10px 28px; font-size: 0.92rem; cursor: pointer;
transition: all 0.14s ease;
}
.loadmore button:hover { border-color: var(--accent); background: var(--accent-soft); }
.loadmore button:disabled { opacity: 0.6; cursor: default; }
</style>