news behavior split: /news leads with Latest, Highlights via ?view=highlights

Base-aware so the frozen `/` is untouched (it's still the live, indexed site
until cutover); the new behavior applies only to /news. At cutover `/` becomes
the hub and only /news's behavior remains.

- defaultView(base): /news bare → Latest (the live firehose); `/` bare → Highlights.
- Brief is canonically /news?view=highlights, with ?view=today kept as an alias.
- Latest is pure chronological on /news — stop passing `home` into it (geo scope
  belongs to Highlights). The Closer-to-Home card/dial is hidden on /news Latest;
  Highlights keeps the scope dial. `/`'s Latest keeps geo (frozen).
- Back fixed: on /news it shows only for genuine drill-ins (tag/source/search),
  not the top-level lanes (Latest/Highlights/Following); `/` keeps its old rule.
- goBack's app-safe fallback lands on the base's default view.

feednav.js gains defaultView + def-aware parse/build; 36 frontend tests (9 new),
build clean. /news stays noindex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-28 16:21:42 -04:00
parent 39b38f0cf1
commit 54761f5083
3 changed files with 78 additions and 42 deletions
+20 -14
View File
@@ -18,7 +18,7 @@
import { track } from '$lib/analytics.js'; import { track } from '$lib/analytics.js';
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js'; import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
import { ritualState, markBriefSeen } from '$lib/ritual.js'; import { ritualState, markBriefSeen } from '$lib/ritual.js';
import { feedBase, parseView, viewUrl } from '$lib/feednav.js'; import { feedBase, defaultView, parseView, viewUrl } from '$lib/feednav.js';
// Which top bar to wear: 'legacy' = the feed's own Header (the interim `/` mount, kept // Which top bar to wear: 'legacy' = the feed's own Header (the interim `/` mount, kept
// unchanged), 'hub' = the shared editorial HubBar (the /news mount, part of the new family). // unchanged), 'hub' = the shared editorial HubBar (the /news mount, part of the new family).
@@ -36,8 +36,18 @@
// so `/` behaves exactly as today and `/news` is self-coherent. At cutover, `/` becomes // so `/` behaves exactly as today and `/news` is self-coherent. At cutover, `/` becomes
// the hub and only `/news` renders the feed. // the hub and only `/news` renders the feed.
function base() { return feedBase($page.url.pathname); } function base() { return feedBase($page.url.pathname); }
function urlForView(key) { return viewUrl(base(), key); } function urlForView(key) { return viewUrl(base(), key, defaultView(base())); }
let selected = $derived(parseView($page.url)); let selected = $derived(parseView($page.url, defaultView(feedBase($page.url.pathname))));
// Latest is local-first only on the frozen `/`; on /news it's the pure chronological
// firehose — the geo scope dial belongs to Highlights, not Latest (Codex).
let latestGeo = $derived(chrome !== 'hub');
// Back is for genuine drill-ins. On /news suppress it for the top-level lanes (Latest is
// the default there); on the frozen `/` keep the original "anything but Highlights" rule.
let showBack = $derived(
chrome === 'hub'
? (selected.startsWith('tag:') || selected.startsWith('source:') || selected === 'search')
: selected !== 'today'
);
let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim()); let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim());
let searchOpen = $state(false); let searchOpen = $state(false);
let searchText = $state(''); let searchText = $state('');
@@ -69,7 +79,7 @@
// Scope dial: the reader's "emotional radius" — nearby | region | country | world. // Scope dial: the reader's "emotional radius" — nearby | region | country | world.
// Persisted; default nearby. 'world' = the global brief/feed (no geo lead). // Persisted; default nearby. 'world' = the global brief/feed (no geo lead).
let homeScope = $state('nearby'); let homeScope = $state('nearby');
const homeActive = () => selected === 'latest' && !!homeValue; const homeActive = () => latestGeo && selected === 'latest' && !!homeValue;
let showSignIn = $state(false); let showSignIn = $state(false);
let showSaved = $state(false); // Saved flyout let showSaved = $state(false); // Saved flyout
let loading = $state(true); let loading = $state(true);
@@ -299,8 +309,8 @@
} }
if (key === 'latest') { if (key === 'latest') {
const q = P.param(prefs.data); const q = P.param(prefs.data);
// Closer to Home lives on the all-news browse lane (Latest). // Closer to Home lives on Highlights now; Latest stays pure chronological on /news.
const homeq = homeValue ? `&home=${encodeURIComponent(homeValue)}` : ''; const homeq = latestGeo && homeValue ? `&home=${encodeURIComponent(homeValue)}` : '';
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${homeq}${q ? '&' + q : ''}${exq}`; return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${homeq}${q ? '&' + q : ''}${exq}`;
} }
if (key === 'following') { if (key === 'following') {
@@ -338,7 +348,7 @@
let appNavDepth = 0; let appNavDepth = 0;
function goBack() { function goBack() {
if (appNavDepth > 0 && typeof history !== 'undefined') history.back(); if (appNavDepth > 0 && typeof history !== 'undefined') history.back();
else goto(urlForView('today')); // landed here directly → app-safe Highlights else goto(urlForView(defaultView(base()))); // landed directly → the base's default view
} }
// Load the data for a view. Called by afterNavigate (URL-driven: in-app goto, // Load the data for a view. Called by afterNavigate (URL-driven: in-app goto,
@@ -359,7 +369,7 @@
feed = items; feed = items;
feedNextOffset = resp.next_offset ?? null; feedNextOffset = resp.next_offset ?? null;
// Home lane pages by the API's world cursor; other lanes by simple length. // Home lane pages by the API's world cursor; other lanes by simple length.
feedDone = (key === 'latest' && homeValue) ? feedNextOffset == null : items.length < PAGE; feedDone = (key === 'latest' && latestGeo && homeValue) ? feedNextOffset == null : items.length < PAGE;
markDisplayed(feed); markDisplayed(feed);
if (key.startsWith('source:') && items[0]) { if (key.startsWith('source:') && items[0]) {
sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source }; sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
@@ -687,11 +697,7 @@
{isFollowing(followTarget.kind, followTarget.value) ? '✓ Following' : 'Follow ' + followTarget.noun} {isFollowing(followTarget.kind, followTarget.value) ? '✓ Following' : 'Follow ' + followTarget.noun}
</button> </button>
{/if} {/if}
<!-- TODO(latest-default): once bare /news becomes Latest, suppress Back for BOTH {#if showBack}
'latest' and 'today'; show it only for genuine drill-ins (tag/source/search),
or Back lands on the top-level Latest page. Don't change this until then —
it would alter the frozen `/` feed. -->
{#if selected !== 'today'}
<button class="viewback" onclick={goBack} aria-label="Go back"> <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> <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 Back
@@ -807,7 +813,7 @@
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p> <p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
{/if} {/if}
{:else if feed.length} {:else if feed.length}
{#if selected === 'latest'} {#if selected === 'latest' && latestGeo}
{#if homeEditing || (!homeValue && !homePromptDismissed)} {#if homeEditing || (!homeValue && !homePromptDismissed)}
<div class="homecard rise"> <div class="homecard rise">
<p class="homecopy">Want good news closer to home?</p> <p class="homecopy">Want good news closer to home?</p>
+19 -7
View File
@@ -8,20 +8,32 @@ export function feedBase(pathname) {
return (pathname || '').startsWith('/news') ? '/news' : '/'; return (pathname || '').startsWith('/news') ? '/news' : '/';
} }
// The default (bare-path) view per base: `/news` leads with Latest — the live, "as it
// comes in" firehose — while the interim `/` keeps leading with Highlights (frozen). At
// cutover `/` becomes the hub, so only the /news default remains.
export function defaultView(base) {
return base === '/news' ? 'latest' : 'today';
}
// The current view key, derived from query params only (path-agnostic, so a deep link // The current view key, derived from query params only (path-agnostic, so a deep link
// works at either base): search > source > tag > explicit view > 'today' (Highlights). // works at either base): search > source > tag > explicit view > the base's default.
export function parseView(url) { // 'highlights' is the canonical param for the Brief; 'today' stays accepted as an alias.
export function parseView(url, def = 'today') {
const p = url.searchParams; const p = url.searchParams;
if ((p.get('q') || '').trim()) return 'search'; if ((p.get('q') || '').trim()) return 'search';
if (p.get('source')) return 'source:' + p.get('source'); if (p.get('source')) return 'source:' + p.get('source');
if (p.get('tag')) return 'tag:' + p.get('tag'); if (p.get('tag')) return 'tag:' + p.get('tag');
return p.get('view') || 'today'; const v = p.get('view');
if (!v) return def;
if (v === 'highlights' || v === 'today') return 'today'; // Brief (alias-tolerant)
return v;
} }
// A link to a view at the given base path. 'today' is the bare base; everything else // A link to a view at the given base path. The base's default view is the bare path;
// carries its query param so the same parseView() reads it back identically. // the Brief is canonically `?view=highlights`; everything else carries its own param.
export function viewUrl(base, key) { export function viewUrl(base, key, def = 'today') {
if (key === 'today') return base; if (key === def) return base;
if (key === 'today') return base + '?view=highlights';
if (key.startsWith('source:')) return base + '?source=' + encodeURIComponent(key.slice(7)); if (key.startsWith('source:')) return base + '?source=' + encodeURIComponent(key.slice(7));
if (key.startsWith('tag:')) return base + '?tag=' + encodeURIComponent(key.slice(4)); if (key.startsWith('tag:')) return base + '?tag=' + encodeURIComponent(key.slice(4));
return base + '?view=' + encodeURIComponent(key); return base + '?view=' + encodeURIComponent(key);
+39 -21
View File
@@ -1,12 +1,12 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { feedBase, parseView, viewUrl } from './feednav.js'; import { feedBase, defaultView, parseView, viewUrl } from './feednav.js';
const view = (path) => parseView(new URL('https://x' + path)); const view = (path, def) => parseView(new URL('https://x' + path), def);
describe('feedBase', () => { describe('feedBase', () => {
it('is /news only under the /news path; everything else is /', () => { it('is /news only under the /news path; everything else is /', () => {
expect(feedBase('/news')).toBe('/news'); expect(feedBase('/news')).toBe('/news');
expect(feedBase('/news?view=latest')).toBe('/news'); expect(feedBase('/news?view=highlights')).toBe('/news');
expect(feedBase('/')).toBe('/'); expect(feedBase('/')).toBe('/');
expect(feedBase('/?view=latest')).toBe('/'); expect(feedBase('/?view=latest')).toBe('/');
expect(feedBase('')).toBe('/'); expect(feedBase('')).toBe('/');
@@ -14,37 +14,55 @@ describe('feedBase', () => {
}); });
}); });
describe('defaultView', () => {
it('/news leads with Latest; / (frozen) leads with Highlights', () => {
expect(defaultView('/news')).toBe('latest');
expect(defaultView('/')).toBe('today');
});
});
describe('parseView', () => { describe('parseView', () => {
it('derives the view from query params, path-agnostic', () => { it('bare path resolves to the base default', () => {
expect(view('/')).toBe('today'); expect(view('/')).toBe('today'); // / default (frozen)
expect(view('/news')).toBe('today'); expect(view('/news', 'latest')).toBe('latest'); // /news default = Latest
expect(view('/?view=latest')).toBe('latest'); });
expect(view('/news?view=following')).toBe('following'); it('Brief is reachable via ?view=highlights, with ?view=today as an alias', () => {
expect(view('/news?view=highlights', 'latest')).toBe('today');
expect(view('/news?view=today', 'latest')).toBe('today'); // alias kept
expect(view('/?view=today')).toBe('today');
});
it('reads the standard views regardless of base', () => {
expect(view('/news?view=latest', 'latest')).toBe('latest');
expect(view('/?view=following')).toBe('following');
expect(view('/?tag=clean-energy')).toBe('tag:clean-energy'); expect(view('/?tag=clean-energy')).toBe('tag:clean-energy');
expect(view('/?source=7')).toBe('source:7'); expect(view('/?source=7')).toBe('source:7');
expect(view('/?q=solar')).toBe('search'); expect(view('/?q=solar')).toBe('search');
}); });
it('prioritizes search > source > tag > view', () => { it('prioritizes search > source > tag > view, ignores whitespace q', () => {
expect(view('/?q=a&source=7&tag=b&view=latest')).toBe('search'); expect(view('/?q=a&source=7&tag=b&view=latest')).toBe('search');
expect(view('/?source=7&tag=b&view=latest')).toBe('source:7'); expect(view('/?source=7&tag=b&view=latest')).toBe('source:7');
expect(view('/?tag=b&view=latest')).toBe('tag:b'); expect(view('/?tag=b&view=latest')).toBe('tag:b');
expect(view('/?q=%20')).toBe('today'); // whitespace-only q is not a search expect(view('/?q=%20')).toBe('today');
}); });
}); });
describe('viewUrl', () => { describe('viewUrl', () => {
it('builds base-relative links that parseView reads back identically', () => { it('the base default is the bare path; the Brief is ?view=highlights', () => {
for (const base of ['/', '/news']) { // /news: Latest is the default (bare), Highlights is explicit
expect(viewUrl(base, 'today')).toBe(base); expect(viewUrl('/news', 'latest', 'latest')).toBe('/news');
expect(viewUrl(base, 'latest')).toBe(base + '?view=latest'); expect(viewUrl('/news', 'today', 'latest')).toBe('/news?view=highlights');
expect(viewUrl(base, 'tag:clean-energy')).toBe(base + '?tag=clean-energy'); expect(viewUrl('/news', 'following', 'latest')).toBe('/news?view=following');
expect(viewUrl(base, 'source:7')).toBe(base + '?source=7'); // / (frozen): Highlights is the default (bare), Latest is explicit
// round-trip: the generated URL resolves back to the same view key expect(viewUrl('/', 'today', 'today')).toBe('/');
expect(view(viewUrl(base, 'latest').replace(base, '/'))).toBe('latest'); expect(viewUrl('/', 'latest', 'today')).toBe('/?view=latest');
} });
it('round-trips: a generated link parses back to the same view', () => {
expect(view(viewUrl('/news', 'today', 'latest'), 'latest')).toBe('today');
expect(view(viewUrl('/news', 'latest', 'latest'), 'latest')).toBe('latest');
expect(view(viewUrl('/', 'latest', 'today').replace('/', '/'), 'today')).toBe('latest');
}); });
it('encodes tag/source/view values', () => { it('encodes tag/source/view values', () => {
expect(viewUrl('/news', 'tag:good news')).toBe('/news?tag=good%20news'); expect(viewUrl('/news', 'tag:good news', 'latest')).toBe('/news?tag=good%20news');
expect(viewUrl('/', 'view=a&b')).toBe('/?view=view%3Da%26b'); expect(viewUrl('/', 'source:42', 'today')).toBe('/?source=42');
}); });
}); });