Unify navigation: URL-backed views, one shared history

Per audit (user + Codex): the in-page Back and browser Back were two separate
histories, which is confusing — especially for less-technical users. Make the
URL the single source of truth so both traverse one history.

* The view derives from the URL (/?view=latest, /?tag=, /?source=, bare / for
  Highlights); `selected` is $derived from $page.url.
* All navigation goes through goto(); afterNavigate is the single loader hook,
  so in-app clicks AND browser back/forward reload the same way.
* The in-page Back button now just calls history.back() (fallback to Highlights)
  — identical to the browser Back. Removed the private navStack.
* Stop stripping ?source= — the URL stays honest, so source/tag views are
  shareable and survive reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 09:05:57 -04:00
parent a8175db63e
commit dc245ab6ea
+61 -42
View File
@@ -1,6 +1,7 @@
<script>
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
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';
@@ -20,8 +21,24 @@
let families = $state([]);
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
let showLanes = $state(false);
let selected = $state('today'); // 'today' | mood | topic | 'tag:<slug>' | 'source:<id>'
let currentSource = $state(null); // {id, name} for a 'source:<id>' view's header
// 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('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 sourceNames = $state({}); // source id -> name, for an instant header label
let brief = $state(null);
let heroIdx = $state(0);
let feed = $state([]);
@@ -74,7 +91,7 @@
}
loadServerHistory();
await syncPrefsOnLogin(); // adopt account prefs or seed from device
select(selected, true); // reflect any adopted boundaries in the feed
loadView(selected, true); // reflect any adopted boundaries in the current view
}
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
@@ -99,7 +116,7 @@
);
let viewLabel = $derived(
selected === 'today' ? 'Highlights from Today'
: selected.startsWith('source:') ? (currentSource?.name ?? feed[0]?.source ?? 'Source')
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
: selected === 'latest' ? 'Latest'
: currentTag ? humanize(currentTag)
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
@@ -140,7 +157,7 @@
persistPrefs();
// If the reader unpinned the lane they're viewing, fall back to Highlights.
// (The pinned Highlights/Latest lanes are never in `keys`, so don't bounce.)
if (selected !== 'today' && selected !== 'latest' && !keys.includes(selected)) select('today');
if (selected !== 'today' && selected !== 'latest' && !keys.includes(selected)) navigate('today');
}
// Hero is the only image slot; if its image won't load, promote the next one.
@@ -208,27 +225,27 @@
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
}
// Drill into a tag or source view from a card, remembering where we came from
// so the in-feed Back button can return there (chains of drill-ins too).
let navStack = $state([]); // [{ key, source }] views to return to
// 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) {
navStack = [...navStack, { key: selected, source: currentSource }];
if (source) currentSource = source;
select(key);
if (source) sourceNames = { ...sourceNames, [source.id]: source.name };
navigate(key);
}
function goBack() {
const prev = navStack[navStack.length - 1];
if (!prev) return;
navStack = navStack.slice(0, -1);
currentSource = prev.source;
select(prev.key);
if (typeof history !== 'undefined' && history.length > 1) history.back();
else goto(urlForView('today'));
}
async function select(key, fresh = false) {
// Top-level navigation (nav rail / bottom bar) resets the drill-in history;
// only tag/source drill-ins build it up.
if (!key.startsWith('tag:') && !key.startsWith('source:')) navStack = [];
selected = key;
// 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 {
@@ -236,16 +253,27 @@
await loadToday(fresh);
} else {
const items = (await getJSON(feedUrl(key, 0))).items;
if (seq !== loadSeq) return;
feed = items;
feedDone = items.length < PAGE;
markDisplayed(feed);
if (key.startsWith('source:') && items[0]) {
sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
}
}
} catch (e) {
error = 'Something went quiet — could not reach the feed.';
if (seq === loadSeq) error = 'Something went quiet — could not reach the feed.';
}
if (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;
loadView(selected);
});
// "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() {
@@ -269,7 +297,7 @@
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)
select(selected, true); // re-filter the feed
loadView(selected, true); // re-filter the current view in place (no nav)
}
function flash(msg) {
@@ -319,10 +347,9 @@
}
}
function browse() {
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
if (selected !== 'today') select('today').then(go);
else go();
async function browse() {
if (selected !== 'today') await goto(urlForView('today'));
document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
}
onMount(async () => {
@@ -337,17 +364,9 @@
topics = (await getJSON('/api/categories')).topics;
try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; }
try { families = await getJSON('/api/families'); } catch { families = []; }
// Deep link from an article page's source name (/?source=<id>) → its feed.
const srcParam = typeof location !== 'undefined' && new URLSearchParams(location.search).get('source');
if (srcParam && /^\d+$/.test(srcParam)) {
await select('source:' + srcParam);
// Seed the back history so the in-feed Back returns to Highlights, and
// strip the ?source= param so it can't linger and confuse browser back.
navStack = [{ key: 'today', source: null }];
if (typeof history !== 'undefined') history.replaceState(null, '', location.pathname);
} else {
await select('today');
}
// Load whatever the URL asks for (Highlights, or a deep-linked source/tag/
// view) — the param stays in the URL so reload and share are honest.
await loadView(selected);
} catch (e) {
error = 'Could not reach Upbeat Bytes.';
}
@@ -365,7 +384,7 @@
<main class="container">
{#if navLanes.length}
<MoodNav lanes={navLanes} {selected} onselect={select} oncustomize={() => (showLanes = true)} />
<MoodNav lanes={navLanes} {selected} onselect={navigate} oncustomize={() => (showLanes = true)} />
{/if}
{#if notice}<p class="notice rise">{notice}</p>{/if}
@@ -381,7 +400,7 @@
<h1>{viewLabel}</h1>
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
</div>
{#if navStack.length}
{#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
@@ -449,7 +468,7 @@
{/if}
</main>
<BottomNav active={activeTab} onToday={() => select('today')} onLatest={() => select('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
<BottomNav active={activeTab} onToday={() => navigate('today')} onLatest={() => navigate('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
<style>
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }