b4b02b5050
- 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>
1066 lines
52 KiB
Svelte
1066 lines
52 KiB
Svelte
<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 what’s 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 tomorrow’s 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>
|