Add a permanent "Latest" lane beside "Highlights"

Restructure the nav around two permanent lanes, then the reader's chosen ones:
"Highlights" (the curated daily brief — formerly "Today") and "Latest" (the
freshest accepted stories, newest-first). Now that the gate is tight, a
chronological "incoming" feed is safe to expose.

* feed(): new sort="latest" (pure recency) alongside the default best-first
  rank; /api/feed exposes sort=ranked|latest (validated). Still accepted-only
  and boundary-respecting either way.
* lanes.py: two pinned lanes (Highlights + Latest) instead of one.
* Home: "Latest" view + "Load more" pagination for every feed view (offset-
  paged, de-duped). Mobile bottom bar gains a Latest tab.
* LanePicker shows both pinned lanes; nav rail renders them first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-06 15:56:48 -04:00
parent d87347b032
commit c25e14ed6a
8 changed files with 143 additions and 37 deletions
+8 -4
View File
@@ -1,14 +1,18 @@
<script>
// Mobile-only primary navigation. Today = the brief, Browse = mood/topic
// discovery, You = account + personal controls (shows the user's avatar in).
// Mobile-only primary navigation. Highlights = the brief, Latest = the
// chronological feed, Browse = mood/topic discovery, You = account.
import Avatar from './Avatar.svelte';
let { active = 'today', onToday, onBrowse, onYou, user = null } = $props();
let { active = 'today', onToday, onLatest, onBrowse, onYou, user = null } = $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>
<span>Highlights</span>
</button>
<button class:active={active === 'latest'} onclick={onLatest}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<span>Latest</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>
@@ -26,10 +26,12 @@
{#snippet body()}
<h2>Your lanes</h2>
<p class="sub">Pick the quick-access lanes above the feed. <strong>Today</strong> always stays — choose the rest. Changes apply right away.</p>
<p class="sub"><strong>Highlights</strong> and <strong>Latest</strong> always stay — choose the rest. Changes apply right away.</p>
<div class="pinned-row">
<span class="chip pinned" title="Always shown">Today <span class="lock">📌</span></span>
{#each pool?.pinned ?? [] as p (p.key)}
<span class="chip pinned" title="Always shown">{p.label} <span class="lock">📌</span></span>
{/each}
</div>
{#each pool?.groups ?? [] as g (g.name)}
+79 -22
View File
@@ -24,6 +24,8 @@
let brief = $state(null);
let heroIdx = $state(0);
let feed = $state([]);
let feedDone = $state(false); // no more pages for the current feed view
let loadingMore = $state(false);
let showSignIn = $state(false);
let showSaved = $state(false); // Saved flyout
let loading = $state(true);
@@ -96,24 +98,28 @@
);
let viewLabel = $derived(
selected === 'today' ? 'Highlights from Today'
: currentTag ? humanize(currentTag)
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
: selected === 'latest' ? 'Latest'
: currentTag ? humanize(currentTag)
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
);
let viewSubtitle = $derived(
selected === 'today' ? localDateLabel(brief)
: currentTag ? (tagFamily?.description ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
: selected === 'latest' ? 'Freshest calm reads — newest first'
: currentTag ? (tagFamily?.description ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
);
let activeTab = $derived(
selected === 'today' ? 'today' : selected === 'latest' ? 'latest' : 'browse'
);
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
// Customizable nav rail: Today is always first, then the reader's pinned
// lanes (or the default set if they've never customized). Resolve each key
// to its {label, description} from the pool.
// 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.key, lanePool.pinned],
...lanePool.pinned.map((p) => [p.key, p]),
...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])),
]
: []
@@ -123,14 +129,15 @@
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
);
let navLanes = $derived(
lanePool ? [lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
lanePool ? [...lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
);
function saveLanes(keys) {
prefs.data.lanes = keys;
persistPrefs();
// If the reader unpinned the lane they're currently viewing, fall back home.
if (selected !== 'today' && !keys.includes(selected)) select('today');
// 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');
}
// Hero is the only image slot; if its image won't load, promote the next one.
@@ -173,22 +180,36 @@
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 === 'latest') {
const q = P.param(prefs.data);
return `/api/feed?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}`;
}
const q = P.param(P.merge(prefs.data, viewFilter(key)));
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
}
async function select(key, fresh = false) {
selected = key;
error = '';
feedDone = false;
try {
if (key === 'today') {
await loadToday(fresh);
} else if (key.startsWith('tag:')) {
const tag = key.slice(4);
const q = P.param(prefs.data);
const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
markDisplayed(feed);
} else {
const q = P.param(P.merge(prefs.data, viewFilter(key)));
const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
const items = (await getJSON(feedUrl(key, 0))).items;
feed = items;
feedDone = items.length < PAGE;
markDisplayed(feed);
}
} catch (e) {
@@ -197,6 +218,25 @@
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
}
// "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 items = (await getJSON(feedUrl(selected, feed.length))).items;
const have = new Set(feed.map((a) => a.id));
const fresh = items.filter((a) => !have.has(a.id));
feed = [...feed, ...fresh];
feedDone = items.length < PAGE;
markDisplayed(fresh);
} catch {
flash('Could not load more just now.');
} finally {
loadingMore = false;
}
}
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
@@ -325,6 +365,15 @@
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} 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}
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
{/if}
@@ -354,7 +403,7 @@
{/if}
</main>
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={openAccount} user={auth.user} />
<BottomNav active={activeTab} onToday={() => select('today')} onLatest={() => select('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
<style>
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
@@ -395,4 +444,12 @@
text-align: center; color: var(--muted); font-family: var(--serif);
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
}
.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>