From c6d37039a8694acc132c30ab01228888c95b69b2 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 1 Jun 2026 17:28:25 +0000 Subject: [PATCH] Visual/IA pass: brand mark, real header, mobile bottom tabs, topic browse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/src/lib/components/BottomNav.svelte | 45 +++ frontend/src/lib/components/Header.svelte | 74 +++++ frontend/src/routes/+layout.svelte | 30 +- frontend/src/routes/+page.svelte | 286 +++++++++++-------- frontend/static/favicon.svg | 9 +- 5 files changed, 294 insertions(+), 150 deletions(-) create mode 100644 frontend/src/lib/components/BottomNav.svelte create mode 100644 frontend/src/lib/components/Header.svelte diff --git a/frontend/src/lib/components/BottomNav.svelte b/frontend/src/lib/components/BottomNav.svelte new file mode 100644 index 0000000..774ed47 --- /dev/null +++ b/frontend/src/lib/components/BottomNav.svelte @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte new file mode 100644 index 0000000..691205f --- /dev/null +++ b/frontend/src/lib/components/Header.svelte @@ -0,0 +1,74 @@ + + +
+
+ + + UpbeatBytes + + + +
+
+ + diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c5087b0..d23931e 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -3,16 +3,7 @@ let { children } = $props(); -
-
- Upbeat Bytes -

Calm, constructive news worth your attention — and nothing that isn't.

-
-
- -
- {@render children()} -
+{@render children()}
@@ -21,21 +12,6 @@
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 9db47db..00f6149 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -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; }); -{#if moods.length} - -{/if} +
(showBoundaries = !showBoundaries)} onHistory={() => (showHistory = !showHistory)} {filtersOn} /> -
- - -
+
+ {#if moods.length} + + {/if} -{#if showBoundaries} - (showBoundaries = false)} /> -{/if} + {#if showBoundaries} + (showBoundaries = false)} /> + {/if} -{#if showHistory} -
-
-

What you've seen

- -
-

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.)

- {#if history.length} -
    - {#each history as a (a.id)} -
  • - {a.title} - {a.source} -
  • - {/each} -
- {:else} -

Nothing yet — your seen stories will appear here.

- {/if} - {#if history.length || dismissed.size} - - {/if} -
-{/if} - -{#if notice} -

{notice}

-{/if} - -{#if loading} -

Gathering the good news…

-{:else if error} -

{error}

-{:else} - {#key selected} -
-

{viewLabel}

- {#if viewSubtitle}

{viewSubtitle}

{/if} -
- - {#if selected === 'today'} - {#if brief?.items?.length} -
- - {#if brief.items.length > 1} -
- {#each brief.items.slice(1) as a (a.id)} - - {/each} -
- {/if} -
-

✦ that's the good news for today ✦

- {:else} -

No highlights yet today — try a calmer filter, or check back soon.

- {/if} - {:else if feed.length} -
- {#each feed as a (a.id)} - - {/each} + {#if showHistory} +
+
+

What you've seen

+
- {:else} -

Nothing in this mood right now — try another, or ease a boundary.

+

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).

+ {#if history.length} +
    + {#each history as a (a.id)} +
  • {a.title}{a.source}
  • + {/each} +
+ {:else} +

Nothing yet — your seen stories will appear here.

+ {/if} + {#if history.length || dismissed.size} + + {/if} +
+ {/if} + + {#if showYou} +
+

You

+ + +
+ {/if} + + {#if notice}

{notice}

{/if} + + {#if loading} +

Gathering the good news…

+ {:else if error} +

{error}

+ {:else} + {#key selected} +
+

{viewLabel}

+ {#if viewSubtitle}

{viewSubtitle}

{/if} +
+ + {#if selected === 'today'} + {#if brief?.items?.length} +
+ + {#if brief.items.length > 1} +
+ {#each brief.items.slice(1) as a (a.id)} + + {/each} +
+ {/if} +
+

✦ that's the good news for today ✦

+ {:else} +

No highlights yet today — try a calmer filter, or check back soon.

+ {/if} + {:else if feed.length} +
+ {#each feed as a (a.id)} + + {/each} +
+ {:else} +

Nothing here right now — try another, or ease a boundary.

+ {/if} + {/key} + + {#if topics.length} +
+

Explore by topic

+
+ {#each topics as t (t.key)} + + {/each} +
+
{/if} - {/key} -{/if} + {/if} +
+ + select('today')} onBrowse={browse} onYou={() => (showYou = !showYou)} />