// Mosaic site — FEED page (direction F3: the comparison IS the front page) // Exports: FeedPage // Each story links to its permanent static page at /topics/{slug} (crawlable + shareable). const topicHref = (s) => "/topics/" + (s.slug || s.id); // Use the real generated hero (assets/heroes/{id}.png) when present; else the procedural SArt collage. const Art = ({ story, radius = 0, style }) => story.hero ? : ; const LensColumns = ({ story, mode, bare, onOpen }) => (
{story.framings.map((f, i) => (

{f.t}

{f.b}

{f.q && (
{f.q}
— {f.a}
)} {f.takes && f.takes.length > 0 && (
{f.takes.map((t, k) => (
{t.voice}{t.view}
“{mdInline(t.evidence)}”
))}
)} {f.srcs && f.srcs.length > 0 && (
{f.n} source{f.n > 1 ? "s" : ""}
)}
))}
); // Accordion queue row — click to expand its framings inline; only one open at a time. const QueueRow = ({ story, mode, density, expanded, onToggle, onOpenStory }) => { const total = story.srcCount || story.framings.reduce((s, f) => s + f.n, 0); const allSrcs = [...new Set(story.framings.flatMap((f) => f.srcs))]; return (
{density !== "compact" &&
}
{story.cat} · {story.upd} · {total} sources · {story.framings.length} framings

{story.title}

{density !== "compact" &&

{story.standfirst}

}
{tickLabel(story, mode)}
{expanded && (
onOpenStory(story.id)} />
Columns are narratives — a source sits under the framing its coverage advances on this story, not its label. Full breakdown →
)}
); }; const FeedCard = ({ story, mode, open, onToggle, onOpenStory }) => { const total = story.srcCount || story.framings.reduce((s, f) => s + f.n, 0); return (
{story.cat} · {story.upd} · {total} sources · {story.framings.length} framings

{story.title}

{story.standfirst}

{tickLabel(story, mode)} f.srcs)} size={16} max={5} />
{open && (
onOpenStory(story.id)} />
Columns are narratives — a source sits under the framing its coverage advances on this story, not its label. Full breakdown →
)}
); }; const FeedPage = ({ mode, density, cat, layout, onOpenStory }) => { const list = SITE_STORIES.filter((s) => cat === "All" || s.cat === cat); const lead = list[0]; const queue = list.slice(1); const [openSet, setOpenSet] = React.useState(() => new Set()); React.useEffect(() => { setOpenSet(new Set(list.length ? [list[0].id] : [])); }, [cat]); const toggleCard = (id) => setOpenSet((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); // Editorial queue accordion — single open row (the lead hero stays open separately) const [openRow, setOpenRow] = React.useState(null); React.useEffect(() => { setOpenRow(null); }, [cat]); // Cards cut — every story is a full-width card that expands to its framings inline if (layout === "cards") { return (
{cat === "All" ? "Today · " + new Date().toLocaleDateString("en-GB", { day: "numeric", month: "long" }) : cat + " · today"} {list.length} stories
{list.map((s) => ( toggleCard(s.id)} onOpenStory={onOpenStory} /> ))}
); } return ( {/* lead story — image above the headline */}
{lead.tags} · updated {lead.upd}

{lead.title}

{lead.standfirst}

{tickLabel(lead, mode)}
onOpenStory(lead.id)} />
Columns are narratives — a source sits under the framing its coverage advances on this story, not its label. Full breakdown →
{/* queue */}
{cat === "All" ? "More today" : cat + " · more"} {queue.length} stories
{queue.map((s) => ( setOpenRow(openRow === s.id ? null : s.id)} onOpenStory={onOpenStory} /> ))}
); }; Object.assign(window, { FeedPage, LensColumns, QueueRow, FeedCard });