Phase B2: grouping pills + Explore-by-family (frontend)

The "wander" layer for the multi-tag model, sitting beneath the brief:

- Cards show up to 3 tappable grouping pills (the article's tags), falling back
  to the primary topic for articles the re-tag hasn't reached. Tap a pill →
  that tag's lane. Tags read as little doorways, not metadata confetti.
- New tag-lane view (select 'tag:<slug>' → /api/feed?tag=) with a calm heading
  and the parent family's description as subtitle.
- Replace the flat "Explore by topic" strip with four calm family bands
  (Discovery & Wonder / People & Kindness / Solutions & Progress / Mind & Craft)
  from /api/families; zero-count tags hide until tagging fills them in.
- Mood nav stays the primary emotional layer; the brief stays the front door.
- /api/families fetch is non-fatal so the page degrades gracefully when the B1
  backend isn't deployed yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-01 20:46:22 +00:00
parent 773b2f79fe
commit 3f2c73b210
2 changed files with 83 additions and 25 deletions
+23 -7
View File
@@ -1,5 +1,12 @@
<script>
let { article, onaction, onreplace, hero = false } = $props();
let { article, onaction, onreplace, ontag, hero = false } = $props();
const humanize = (s) => (s || '').replace(/-/g, ' ');
// Grouping tags are the doorways; cap at 3 so cards stay calm. Fall back to
// the primary topic for articles the re-tag hasn't reached yet.
let pills = $derived(
(article.tags?.length ? article.tags : article.topic ? [article.topic] : []).slice(0, 3)
);
// Reset image-failure when the article changes (cards swap in place).
let failed = $state(false);
@@ -34,8 +41,13 @@
<div class="body">
<div class="tags">
{#if article.topic}<span class="tag">{article.topic}</span>{/if}
{#if article.flavor}<span class="tag soft">{article.flavor}</span>{/if}
{#each pills as t, i (t)}
{#if ontag}
<button type="button" class="tag" class:soft={i > 0} onclick={() => ontag(t)}>{humanize(t)}</button>
{:else}
<span class="tag" class:soft={i > 0}>{humanize(t)}</span>
{/if}
{/each}
</div>
<div class="src">{article.source}</div>
@@ -79,13 +91,17 @@
}
.body { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.tags { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; font-size: 0.66rem; }
.tags { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.tag {
background: var(--accent); color: #fff; border-radius: 999px;
padding: 3px 11px 2px; font-family: var(--label); font-weight: 300;
text-transform: uppercase; letter-spacing: 0.055em;
background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 3px 11px 2px; font-family: var(--label); font-weight: 300; font-size: 0.66rem;
text-transform: uppercase; letter-spacing: 0.055em; line-height: 1.5;
transition: background 0.14s ease, color 0.14s ease;
}
.tag.soft { background: var(--accent-soft); color: var(--accent-deep); }
button.tag { cursor: pointer; }
button.tag:hover { background: var(--accent-deep); color: #fff; }
button.tag.soft:hover { background: var(--accent); color: #fff; }
/* Source on its own line below the tags, left-justified, for uniformity. */
.src { color: var(--muted); font-size: 0.78rem; margin: -2px 0 2px; }
+60 -18
View File
@@ -10,7 +10,8 @@
let moods = $state([]);
let topics = $state([]);
let selected = $state('today'); // 'today' | a mood key | a topic key
let families = $state([]);
let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:<slug>'
let brief = $state(null);
let feed = $state([]);
let userPrefs = $state(P.blank());
@@ -61,16 +62,28 @@
}
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
const humanize = (s) => (s || '').replace(/-/g, ' ');
let filtersOn = $derived(P.active(userPrefs));
let currentMood = $derived(moods.find((m) => m.key === selected));
let currentTopic = $derived(topics.find((t) => t.key === selected));
let currentTag = $derived(selected.startsWith('tag:') ? selected.slice(4) : null);
// The family a grouping tag belongs to — for the lane's calm subtitle.
let tagFamily = $derived(
currentTag ? families.find((f) => f.tags.some((t) => t.key === currentTag)) : null
);
let viewLabel = $derived(
selected === 'today' ? 'Highlights from Today' : (currentMood?.label ?? cap(currentTopic?.key) ?? '')
selected === 'today'
? 'Highlights from Today'
: currentTag
? humanize(currentTag)
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
);
let viewSubtitle = $derived(
selected === 'today'
? (brief?.brief_date ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
: currentTag
? (tagFamily?.description ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
);
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
@@ -108,6 +121,12 @@
try {
if (key === 'today') {
await loadToday(fresh);
} else if (key.startsWith('tag:')) {
const tag = key.slice(4);
const q = P.param(userPrefs);
const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
remember(feed);
} else {
const q = P.param(P.merge(userPrefs, viewFilter(key)));
const ex = Array.from(dismissed).join(',');
@@ -189,6 +208,10 @@
try {
moods = await getJSON('/api/moods');
topics = (await getJSON('/api/categories')).topics;
// Non-fatal: the groupings backend (B1) may not be deployed yet. If it
// isn't, the Explore-by-family section simply stays hidden and cards fall
// back to the topic pill — the rest of the page works unchanged.
try { families = await getJSON('/api/families'); } catch { families = []; }
await select('today');
} catch (e) {
error = 'Could not reach Upbeat Bytes.';
@@ -258,11 +281,11 @@
{#if selected === 'today'}
{#if brief?.items?.length}
<section class="rise">
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} />
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
{#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} />
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
{/each}
</div>
{/if}
@@ -274,7 +297,7 @@
{:else if feed.length}
<div class="grid rise">
{#each feed as a (a.id)}
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
{/each}
</div>
{:else}
@@ -282,12 +305,27 @@
{/if}
{/key}
{#if topics.length}
{#if families.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>
<h2>Explore Upbeat Bytes</h2>
<div class="families">
{#each families as f (f.name)}
{@const tags = f.tags.filter((t) => t.count > 0)}
{#if tags.length}
<div class="family">
<h3>{f.name}</h3>
<p class="fdesc">{f.description}</p>
<div class="chips">
{#each tags as t (t.key)}
<button
class="chip"
class:active={selected === 'tag:' + t.key}
onclick={() => select('tag:' + t.key)}
>{humanize(t.key)}</button>
{/each}
</div>
</div>
{/if}
{/each}
</div>
</section>
@@ -308,17 +346,21 @@
background: var(--accent); border-radius: 2px; margin-top: 14px; opacity: 0.8;
}
/* 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 — a quiet repository of groupings beneath the brief, not a nav row.
Four calm families, each a doorway into its tags. */
.explore { margin: 52px 0 8px; padding-top: 28px; 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;
font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--muted); font-family: var(--label); font-weight: 400; margin: 0 0 22px;
}
.explore .chips { display: flex; flex-wrap: wrap; gap: 8px; }
.families { display: grid; grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); gap: 26px 32px; }
.family h3 { font-size: 1.16rem; margin: 0 0 2px; }
.family .fdesc { margin: 0 0 11px; color: var(--muted); font-size: 0.86rem; line-height: 1.45; }
.explore .chips { display: flex; flex-wrap: wrap; gap: 7px; }
.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;
border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer;
transition: all 0.14s ease; text-transform: capitalize;
}
.explore .chip:hover { border-color: var(--accent); color: var(--accent-deep); }
.explore .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }