Visual/IA pass: brand mark, real header, mobile bottom tabs, topic browse
- Logo mark: SVG rising-dots wave (sage dots + warm gold peak = 'upbeat bytes'), used as favicon/PWA icon and in the header. - Header: full-width app bar — mark + mixed-type wordmark (Upbeat serif ink / Bytes sans sage) on the left, housed Boundaries/History utility cluster on the right (desktop). No more floating text links. - Mobile: fixed bottom tab bar (Today / Browse / You); utilities move into a 'You' sheet. One-handed, modern, calm. - Browse: moods stay the primary front door; added a quiet 'Explore by topic' section (existing topics) below the content — selecting a topic loads its feed. - Layout trimmed (header now in-page, full width); footer keeps clearance for the bottom bar. Phase A of the consensus pass; Phase B (add technology + learning topics and reclassify) is next. Live site untouched until publish.sh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<script>
|
||||
// Mobile-only primary navigation. Today = the brief, Browse = mood/topic
|
||||
// discovery, You = personal controls (Boundaries, History).
|
||||
let { active = 'today', onToday, onBrowse, onYou } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="bottomnav" aria-label="Primary">
|
||||
<button class:active={active === 'today'} onclick={onToday}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||
<span>Today</span>
|
||||
</button>
|
||||
<button class:active={active === 'browse'} onclick={onBrowse}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M15.5 8.5l-2 5-5 2 2-5 5-2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||
<span>Browse</span>
|
||||
</button>
|
||||
<button class:active={active === 'you'} onclick={onYou}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.5" r="3.6" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M5 20c0-3.6 3.1-5.5 7-5.5s7 1.9 7 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||
<span>You</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.bottomnav { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
.bottomnav {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
z-index: 30;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -2px 14px rgba(40, 38, 28, 0.05);
|
||||
}
|
||||
.bottomnav button {
|
||||
flex: 1;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||||
background: none; border: none; padding: 6px 0; cursor: pointer;
|
||||
color: var(--muted); font-size: 0.7rem; letter-spacing: 0.02em;
|
||||
}
|
||||
.bottomnav button svg { width: 23px; height: 23px; }
|
||||
.bottomnav button.active { color: var(--sage-deep); }
|
||||
.bottomnav button.active svg { stroke: var(--sage); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script>
|
||||
let { onBoundaries, onHistory, filtersOn = false } = $props();
|
||||
</script>
|
||||
|
||||
<header class="appbar">
|
||||
<div class="container bar">
|
||||
<a class="brand" href="/" aria-label="Upbeat Bytes — home">
|
||||
<svg class="mark" viewBox="0 0 40 30" aria-hidden="true">
|
||||
<path d="M3 24 Q15 21 23 11 T37 5" fill="none" stroke="var(--sage)" stroke-width="2.2"
|
||||
opacity="0.3" stroke-linecap="round" />
|
||||
<circle cx="3" cy="24" r="2.6" fill="var(--sage)" />
|
||||
<circle cx="13" cy="19" r="2.8" fill="var(--sage)" />
|
||||
<circle cx="23" cy="12" r="3" fill="var(--sage)" />
|
||||
<circle cx="35" cy="5" r="3.9" fill="var(--gold)" />
|
||||
</svg>
|
||||
<span class="word"><span class="up">Upbeat</span><span class="by">Bytes</span></span>
|
||||
</a>
|
||||
|
||||
<nav class="utils" aria-label="Your controls">
|
||||
<button class:on={filtersOn} onclick={onBoundaries} title="Your boundaries">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"
|
||||
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||
<span>Boundaries</span>
|
||||
</button>
|
||||
<button onclick={onHistory} title="What you've seen">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" /><path d="M12 7v5l3.5 2" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||
<span>History</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.appbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
/* a whisper of warmth under the bar */
|
||||
box-shadow: 0 1px 0 rgba(40, 38, 28, 0.02);
|
||||
}
|
||||
.bar { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||||
|
||||
.brand { display: inline-flex; align-items: center; gap: 11px; }
|
||||
.mark { height: 28px; width: auto; display: block; }
|
||||
.word { display: inline-flex; align-items: baseline; gap: 0.28em; letter-spacing: -0.01em; }
|
||||
.up { font-family: var(--serif); font-weight: 600; font-size: 1.5rem; color: var(--ink); }
|
||||
.by {
|
||||
font-family: var(--sans); font-weight: 700; font-size: 1.42rem; color: var(--sage);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.utils { display: flex; gap: 6px; }
|
||||
.utils button {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
background: none; border: 1px solid transparent; border-radius: 999px;
|
||||
padding: 7px 13px; color: var(--muted); font-size: 0.86rem; cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
.utils button svg { width: 17px; height: 17px; }
|
||||
.utils button:hover { background: var(--sage-soft); color: var(--sage-deep); }
|
||||
.utils button.on { color: var(--sage-deep); }
|
||||
|
||||
/* On phones the utilities live in the bottom tab bar ("You") instead. */
|
||||
@media (max-width: 720px) {
|
||||
.bar { height: 58px; justify-content: center; }
|
||||
.utils { display: none; }
|
||||
.up { font-size: 1.35rem; }
|
||||
.by { font-size: 1.28rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -3,16 +3,7 @@
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<header class="site">
|
||||
<div class="container">
|
||||
<a class="brand" href="/">Upbeat <span>Bytes</span></a>
|
||||
<p class="tagline">Calm, constructive news worth your attention — and nothing that isn't.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{@render children()}
|
||||
</main>
|
||||
{@render children()}
|
||||
|
||||
<footer class="site">
|
||||
<div class="container">
|
||||
@@ -21,21 +12,6 @@
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
header.site {
|
||||
text-align: center;
|
||||
padding: 38px 0 22px;
|
||||
background: linear-gradient(180deg, var(--surface), var(--bg));
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand {
|
||||
font-family: var(--serif);
|
||||
font-size: 2.1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.brand span { color: var(--sage); }
|
||||
.tagline { margin: 6px 0 0; color: var(--muted); font-size: 0.98rem; }
|
||||
main.container { padding-top: 18px; padding-bottom: 40px; min-height: 60vh; }
|
||||
footer.site {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
@@ -44,4 +20,8 @@
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
footer.site a { color: var(--sage-deep); }
|
||||
/* room for the mobile bottom tab bar */
|
||||
@media (max-width: 720px) {
|
||||
footer.site { padding-bottom: calc(34px + 64px + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
+164
-122
@@ -2,32 +2,33 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { getJSON } 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 ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||
|
||||
let moods = $state([]);
|
||||
let selected = $state('today');
|
||||
let topics = $state([]);
|
||||
let selected = $state('today'); // 'today' | a mood key | a topic key
|
||||
let brief = $state(null);
|
||||
let feed = $state([]);
|
||||
let userPrefs = $state(P.blank());
|
||||
let showBoundaries = $state(false);
|
||||
let showHistory = $state(false);
|
||||
let showYou = $state(false); // mobile "You" sheet
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// Device-local memory (no account): persisted in localStorage so it survives
|
||||
// a refresh. `seen` stops Replace recycling; `dismissed` (replaced-away) is
|
||||
// excluded from the brief so swaps stick; `history` keeps everything seen
|
||||
// recoverable. A reader can wipe it all from the History panel.
|
||||
// Device-local memory (no account), persisted in localStorage.
|
||||
const SEEN_KEY = 'goodnews:seen';
|
||||
const DISMISSED_KEY = 'goodnews:dismissed';
|
||||
const HISTORY_KEY = 'goodnews:history';
|
||||
const BRIEF_VIEW_KEY = 'goodnews:brief_view'; // {date, items} — the reader's curated brief
|
||||
const BRIEF_VIEW_KEY = 'goodnews:brief_view';
|
||||
const HISTORY_CAP = 200;
|
||||
|
||||
let seenIds = new Set(); // handler-only, not rendered — no reactivity needed
|
||||
let dismissed = $state(new Set()); // read in the template (Clear button) — must be reactive
|
||||
let seenIds = new Set();
|
||||
let dismissed = $state(new Set());
|
||||
let history = $state([]);
|
||||
|
||||
function persistSession() {
|
||||
@@ -54,21 +55,34 @@
|
||||
dismissed = new Set();
|
||||
history = [];
|
||||
persistSession();
|
||||
P.saveJSON(BRIEF_VIEW_KEY, null); // drop the pinned brief so it re-composes fresh
|
||||
P.saveJSON(BRIEF_VIEW_KEY, null);
|
||||
showHistory = false;
|
||||
select(selected, true);
|
||||
}
|
||||
|
||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
let filtersOn = $derived(P.active(userPrefs));
|
||||
let current = $derived(moods.find((m) => m.key === selected));
|
||||
let viewLabel = $derived(selected === 'today' ? 'Highlights from Today' : (current?.label ?? ''));
|
||||
let viewSubtitle = $derived(
|
||||
selected === 'today' ? (brief?.brief_date ?? '') : (current?.description ?? '')
|
||||
let currentMood = $derived(moods.find((m) => m.key === selected));
|
||||
let currentTopic = $derived(topics.find((t) => t.key === selected));
|
||||
let viewLabel = $derived(
|
||||
selected === 'today' ? 'Highlights from Today' : (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
);
|
||||
let viewSubtitle = $derived(
|
||||
selected === 'today'
|
||||
? (brief?.brief_date ?? '')
|
||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||
);
|
||||
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
|
||||
|
||||
// The filter for the current view: a mood's preset, a topic include, or none.
|
||||
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] }; // a topic
|
||||
}
|
||||
function mergedParam() {
|
||||
const merged = P.merge(userPrefs, current?.filter ?? {});
|
||||
return P.param(merged);
|
||||
return P.param(P.merge(userPrefs, viewFilter()));
|
||||
}
|
||||
|
||||
async function loadToday(fresh) {
|
||||
@@ -76,9 +90,6 @@
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
|
||||
const view = P.loadJSON(BRIEF_VIEW_KEY, null);
|
||||
// Keep the reader's curated view (swaps + hero) across plain refreshes — but
|
||||
// only while the server's brief is unchanged. When genuinely fresh data
|
||||
// arrives (generated_at advances), the server wins and the pin is dropped.
|
||||
const sameServerBrief =
|
||||
view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at;
|
||||
if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) {
|
||||
@@ -92,15 +103,13 @@
|
||||
|
||||
async function select(key, fresh = false) {
|
||||
selected = key;
|
||||
showYou = false;
|
||||
error = '';
|
||||
try {
|
||||
// Today = the day's highlights (hero + six). Other moods reveal that
|
||||
// category only when chosen.
|
||||
if (key === 'today') {
|
||||
await loadToday(fresh);
|
||||
} else {
|
||||
const mood = moods.find((m) => m.key === key);
|
||||
const q = P.param(P.merge(userPrefs, mood?.filter ?? {}));
|
||||
const q = P.param(P.merge(userPrefs, viewFilter(key)));
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
||||
remember(feed);
|
||||
@@ -114,7 +123,7 @@
|
||||
function refreshPrefs() {
|
||||
userPrefs = { ...userPrefs };
|
||||
P.save(userPrefs);
|
||||
select(selected, true); // boundaries changed — re-fetch so they apply
|
||||
select(selected, true);
|
||||
}
|
||||
function applyAction(kind, value) {
|
||||
P[kind]?.(userPrefs, value);
|
||||
@@ -131,7 +140,6 @@
|
||||
const list = selected === 'today' ? brief?.items : feed;
|
||||
if (!list) return;
|
||||
const isHero = selected === 'today' && list[0]?.id === article.id;
|
||||
// Exclude everything seen (persisted), so Replace never cycles back.
|
||||
const exclude = Array.from(seenIds).join(',');
|
||||
const q = mergedParam();
|
||||
const url = `/api/replacement?exclude=${exclude}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
|
||||
@@ -146,8 +154,6 @@
|
||||
flash("That's everything fresh for now — nothing new to swap in.");
|
||||
return;
|
||||
}
|
||||
// Remember the swap so it sticks across refreshes (the dismissed story is
|
||||
// excluded from future briefs; it stays recoverable in History).
|
||||
dismissed.add(article.id);
|
||||
seenIds.add(article.id);
|
||||
remember([repl]);
|
||||
@@ -157,8 +163,6 @@
|
||||
if (i >= 0) {
|
||||
brief.items[i] = repl;
|
||||
brief = { ...brief, items: [...brief.items] };
|
||||
// Pin the swap against the current server brief; it holds until fresh
|
||||
// server data arrives (then the server's version takes over).
|
||||
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: brief.generated_at, items: brief.items });
|
||||
}
|
||||
} else {
|
||||
@@ -170,6 +174,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function browse() {
|
||||
showYou = false;
|
||||
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
|
||||
if (selected !== 'today') select('today').then(go);
|
||||
else go();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
userPrefs = P.load();
|
||||
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
||||
@@ -177,121 +188,145 @@
|
||||
history = P.loadJSON(HISTORY_KEY, []);
|
||||
try {
|
||||
moods = await getJSON('/api/moods');
|
||||
topics = (await getJSON('/api/categories')).topics;
|
||||
await select('today');
|
||||
} catch (e) {
|
||||
error = 'Could not reach goodNews.';
|
||||
error = 'Could not reach Upbeat Bytes.';
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if moods.length}
|
||||
<MoodNav {moods} {selected} onselect={select} />
|
||||
{/if}
|
||||
<Header onBoundaries={() => (showBoundaries = !showBoundaries)} onHistory={() => (showHistory = !showHistory)} {filtersOn} />
|
||||
|
||||
<div class="toptools">
|
||||
<button class="link" class:on={history.length} onclick={() => (showHistory = !showHistory)}>History</button>
|
||||
<button class="link" class:on={filtersOn} onclick={() => (showBoundaries = !showBoundaries)}>
|
||||
{filtersOn ? 'Boundaries ·' : 'Boundaries'}
|
||||
</button>
|
||||
</div>
|
||||
<main class="container">
|
||||
{#if moods.length}
|
||||
<MoodNav {moods} {selected} onselect={select} />
|
||||
{/if}
|
||||
|
||||
{#if showBoundaries}
|
||||
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
|
||||
{/if}
|
||||
{#if showBoundaries}
|
||||
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showHistory}
|
||||
<section class="panel rise">
|
||||
<div class="phead">
|
||||
<h2>What you've seen</h2>
|
||||
<button class="close" onclick={() => (showHistory = false)}>done</button>
|
||||
</div>
|
||||
<p class="reassure">Everything you've seen here, including stories you swapped away — so a swap sticks and stays recoverable. Kept on this device only (no account, nothing sent). (Saved history & favorites come with sign-in, later.)</p>
|
||||
{#if history.length}
|
||||
<ul class="hist">
|
||||
{#each history as a (a.id)}
|
||||
<li>
|
||||
<a href={a.url} target="_blank" rel="noopener">{a.title}</a>
|
||||
<span class="hsrc">{a.source}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">Nothing yet — your seen stories will appear here.</p>
|
||||
{/if}
|
||||
{#if history.length || dismissed.size}
|
||||
<button class="reset" onclick={clearSession}>Clear what I've seen (start fresh)</button>
|
||||
{/if}
|
||||
</section>
|
||||
{/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">
|
||||
<h1>{viewLabel}</h1>
|
||||
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
||||
</header>
|
||||
|
||||
{#if selected === 'today'}
|
||||
{#if brief?.items?.length}
|
||||
<section class="rise">
|
||||
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} />
|
||||
{#if brief.items.length > 1}
|
||||
<div class="grid rest">
|
||||
{#each brief.items.slice(1) as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<p class="endcap rise">✦ that's the good news for today ✦</p>
|
||||
{:else}
|
||||
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
|
||||
{/if}
|
||||
{:else if feed.length}
|
||||
<div class="grid rise">
|
||||
{#each feed as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
|
||||
{/each}
|
||||
{#if showHistory}
|
||||
<section class="panel rise">
|
||||
<div class="phead">
|
||||
<h2>What you've seen</h2>
|
||||
<button class="close" onclick={() => (showHistory = false)}>done</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted center pad">Nothing in this mood right now — try another, or ease a boundary.</p>
|
||||
<p class="reassure">Everything you've seen here, including stories you swapped away — so a swap sticks and stays recoverable. Kept on this device only (no account, nothing sent).</p>
|
||||
{#if history.length}
|
||||
<ul class="hist">
|
||||
{#each history as a (a.id)}
|
||||
<li><a href={a.url} target="_blank" rel="noopener">{a.title}</a><span class="hsrc">{a.source}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">Nothing yet — your seen stories will appear here.</p>
|
||||
{/if}
|
||||
{#if history.length || dismissed.size}
|
||||
<button class="reset" onclick={clearSession}>Clear what I've seen (start fresh)</button>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if showYou}
|
||||
<section class="panel rise youmenu">
|
||||
<div class="phead"><h2>You</h2><button class="close" onclick={() => (showYou = false)}>done</button></div>
|
||||
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
|
||||
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
|
||||
</button>
|
||||
<button class="yourow" onclick={() => { showYou = false; showHistory = true; }}>
|
||||
<span>What you've seen</span>{#if history.length}<span class="dot">{history.length}</span>{/if}
|
||||
</button>
|
||||
</section>
|
||||
{/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">
|
||||
<h1>{viewLabel}</h1>
|
||||
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
||||
</header>
|
||||
|
||||
{#if selected === 'today'}
|
||||
{#if brief?.items?.length}
|
||||
<section class="rise">
|
||||
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} />
|
||||
{#if brief.items.length > 1}
|
||||
<div class="grid rest">
|
||||
{#each brief.items.slice(1) as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<p class="endcap rise">✦ that's the good news for today ✦</p>
|
||||
{:else}
|
||||
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
|
||||
{/if}
|
||||
{:else if feed.length}
|
||||
<div class="grid rise">
|
||||
{#each feed as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
{#if topics.length}
|
||||
<section id="explore" class="explore">
|
||||
<h2>Explore by topic</h2>
|
||||
<div class="chips">
|
||||
{#each topics as t (t.key)}
|
||||
<button class="chip" class:active={selected === t.key} onclick={() => select(t.key)}>{cap(t.key)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={() => (showYou = !showYou)} />
|
||||
|
||||
<style>
|
||||
.toptools { display: flex; justify-content: flex-end; gap: 18px; margin: 2px 0 0; }
|
||||
.link {
|
||||
background: none; border: none; color: var(--muted);
|
||||
font-size: 0.82rem; padding: 4px 2px; letter-spacing: 0.01em;
|
||||
}
|
||||
.link:hover { color: var(--sage-deep); }
|
||||
.link.on { color: var(--sage-deep); font-weight: 600; }
|
||||
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
||||
|
||||
.view-head { margin: 20px 0 20px; }
|
||||
.view-head h1 {
|
||||
font-size: clamp(2.1rem, 5.5vw, 2.8rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
.view-head { margin: 18px 0 18px; }
|
||||
.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::after {
|
||||
content: ''; display: block; width: 46px; height: 3px;
|
||||
background: var(--sage); border-radius: 2px; margin-top: 14px; opacity: 0.8;
|
||||
}
|
||||
|
||||
/* History panel */
|
||||
/* Explore by topic — a quiet secondary discovery section, not a nav row. */
|
||||
.explore { margin: 48px 0 8px; padding-top: 26px; border-top: 1px solid var(--line); }
|
||||
.explore h2 {
|
||||
font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.12em;
|
||||
color: var(--muted); font-family: var(--sans); font-weight: 700; margin: 0 0 12px;
|
||||
}
|
||||
.explore .chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.explore .chip {
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||||
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem; cursor: pointer; transition: all 0.14s ease;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.explore .chip:hover { border-color: var(--sage); color: var(--sage-deep); }
|
||||
.explore .chip.active { background: var(--sage); border-color: var(--sage); color: #fff; }
|
||||
|
||||
/* Panels (Boundaries handled by its own component; History + You here) */
|
||||
.panel {
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); padding: 20px 22px; margin: 8px 0 18px;
|
||||
box-shadow: var(--shadow); padding: 20px 22px; margin: 12px 0 6px;
|
||||
}
|
||||
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.phead h2 { font-size: 1.3rem; }
|
||||
@@ -307,10 +342,17 @@
|
||||
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; margin-top: 12px; }
|
||||
.reset:hover { color: var(--sage-deep); }
|
||||
|
||||
.youmenu .yourow {
|
||||
width: 100%; display: flex; align-items: center; justify-content: space-between;
|
||||
background: none; border: none; border-bottom: 1px solid var(--line);
|
||||
padding: 14px 2px; font-size: 1rem; color: var(--ink); cursor: pointer; text-align: left;
|
||||
}
|
||||
.youmenu .yourow:last-child { border-bottom: none; }
|
||||
.youmenu .dot { background: var(--sage-soft); color: var(--sage-deep); border-radius: 999px; padding: 1px 9px; font-size: 0.78rem; }
|
||||
|
||||
.notice {
|
||||
text-align: center; color: var(--sage-deep); background: var(--sage-soft);
|
||||
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content;
|
||||
font-size: 0.86rem;
|
||||
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; }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#2f7d5b"/>
|
||||
<circle cx="32" cy="29" r="11" fill="#faf6ee"/>
|
||||
<path d="M14 44 q12 9 24 3 q9 -4 12 1" stroke="#faf6ee" stroke-width="3.4" fill="none" stroke-linecap="round"/>
|
||||
<rect width="64" height="64" rx="15" fill="#2f7d5b"/>
|
||||
<path d="M13 45 Q26 41 35 26 T53 14" fill="none" stroke="#faf6ee" stroke-width="3" opacity="0.32" stroke-linecap="round"/>
|
||||
<circle cx="13" cy="45" r="3.6" fill="#faf6ee"/>
|
||||
<circle cx="25" cy="37" r="4" fill="#faf6ee"/>
|
||||
<circle cx="38" cy="26" r="4.4" fill="#faf6ee"/>
|
||||
<circle cx="51" cy="14" r="6" fill="#e8c87a"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 449 B |
Reference in New Issue
Block a user