Restructure navigation around What's up / Opine and the reader's utilities
validate / validate (push) Successful in 22s

- Stories gain a required type (report | essay); all 28 classified per
  the boundary rule now stated in EDITORIAL.md
- Nav: What's up · Opine · Podcast · Book · Guides · Forum, each gated
  on real content (Forum waits on a forumUrl); logo is Home
- /whats-up (reports, newest first) and /opine (essays, editorial order)
  share a StoryArchive component with subject filters
- Static /tags/<tag> pages for subjects carried by 2+ stories; tags stay
  overlapping, never exclusive departments
- /corrections: generated from revision history (commits beginning
  'Correction:'), so the page cannot under-report
- About, RSS, Corrections, Weekly briefing move to the footer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Compleet
2026-07-14 14:37:44 +01:00
parent e8403dbfb4
commit 0e7871f395
40 changed files with 388 additions and 67 deletions
+127
View File
@@ -0,0 +1,127 @@
---
import type { CollectionEntry } from 'astro:content';
import StoryCard from './StoryCard.astro';
interface Props {
title: string;
count: string;
note?: string;
noteHref?: string;
entries: CollectionEntry<'stories'>[];
emptyMessage: string;
activeTag?: string;
}
const { title, count, note, noteHref, entries, emptyMessage, activeTag } = Astro.props;
const tagCounts = new Map<string, number>();
for (const entry of entries) {
for (const tag of entry.data.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
// Only subjects carried by 2+ of the listed stories get a filter link
// (which also guarantees the target tag page exists).
const tags = [...tagCounts.entries()]
.filter(([, count]) => count >= 2)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
---
<section class="shell archive-head">
<h1>{title}</h1>
<p>{count}{note && noteHref && <a href={noteHref}>{note}</a>}</p>
</section>
{tags.length > 1 && (
<nav class="shell tag-row" aria-label="Browse by subject">
{tags.map(([tag, n]) => (
<a href={`/tags/${tag}`} class:list={{ active: tag === activeTag }}>{tag}<span>{n}</span></a>
))}
</nav>
)}
<section class="shell archive-list">
{entries.length > 0 ? entries.map((entry, index) => <StoryCard entry={entry} index={index} />) : <p class="empty-state">{emptyMessage}</p>}
</section>
<style>
.archive-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1.5rem;
padding: clamp(1.6rem, 4vw, 3.25rem) 0 1rem;
border-bottom: 1px solid var(--ink);
}
.archive-head h1 {
margin: 0;
font-family: 'Newsreader Variable', Georgia, serif;
font-size: clamp(2.4rem, 5vw, 4.5rem);
font-weight: 560;
letter-spacing: -0.045em;
line-height: 0.9;
}
.archive-head p {
margin: 0;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.65rem;
text-transform: uppercase;
}
.archive-head a {
margin-left: 1rem;
color: var(--blue);
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
padding: 0.85rem 0;
}
.tag-row a {
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.66rem;
letter-spacing: 0.04em;
text-decoration: none;
text-transform: uppercase;
}
.tag-row a:hover,
.tag-row a.active {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 0.25em;
}
.tag-row span {
margin-left: 0.3rem;
color: var(--rule);
}
.tag-row a:hover span,
.tag-row a.active span {
color: var(--amend);
}
.archive-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin-top: 1px;
border: 1px solid var(--rule);
background: var(--rule);
}
@media (max-width: 900px) {
.archive-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px) {
.archive-head { align-items: flex-start; flex-direction: column; gap: 0.65rem; }
.archive-list { grid-template-columns: 1fr; }
}
</style>