// Mosaic site — shared UI components // Exports: framingColor, SFav, SFavRow, STicks, SCollage, SMast, tickLabel // Resolve a framing's colour from the active colour mode const framingColor = (framing, idx, mode) => mode === "neutral" ? NEUTRAL_PALETTE[idx % NEUTRAL_PALETTE.length] : SENT_COLORS[framing.lean]; const SFav = ({ id, size = 20 }) => { const f = FAVS[id] || { l: "?", bg: "#666" }; // YouTube voices → round creator face; outlets → real favicon (DDG→Google→monogram); else monogram if (f.avatar) { return {f.n} { const s = e.target; s.style.display = "none"; if (s.nextSibling) s.nextSibling.style.display = "inline-flex"; }} />; } if (f.domain) { const onErr = (e) => { const t = e.target; t.style.display = "none"; if (t.nextSibling) t.nextSibling.style.display = "inline-flex"; }; return ( {f.n} {f.l} ); } return {f.l}; }; const SFavRow = ({ ids, size = 17, max = 6, gap = null }) => ( {ids.slice(0, max).map((id, i) => ( ))} ); // Segment ticks — one tick per source, coloured by the framing it sits under const STicks = ({ story, mode, tw = 9, th = 16, gap = 3, max = 60 }) => { const ticks = []; story.framings.forEach((f, fi) => { const c = framingColor(f, fi, mode); for (let i = 0; i < f.n && ticks.length < max; i++) ticks.push(c); }); return ( {ticks.map((c, i) => )} ); }; const tickLabel = (story, mode) => { const total = story.srcCount || story.framings.reduce((s, f) => s + f.n, 0); if (mode === "neutral") return `${story.framings.length} framings · ${total} sources`; const sums = { pos: 0, mix: 0, neg: 0 }; story.framings.forEach((f) => { sums[f.lean] += f.n; }); const top = Object.entries(sums).sort((a, b) => b[1] - a[1])[0]; const word = { pos: "Positive", mix: "Neutral", neg: "Negative" }[top[0]]; return `${Math.round((top[1] / total) * 100)}% ${word} · ${total} sources`; }; // Full record grouped by framing — explicit data if present, else derived from each framing's sources const recordOf = (story) => story.record || story.framings.map((f, i) => ({ f: i, // No per-article titles for derived stories — reuse the framing's real headline (never fabricate one) items: f.srcs.map((s) => ({ s, d: story.upd, h: (f.q || "").replace(/[“”"]/g, "") })), })); // Abstract paper-collage placeholder (stands in for Mosaic's editorial collage art) const SCOLLAGE_SEEDS = { a: [ { x: "-6%", y: "-10%", w: "48%", h: "70%", bg: "#C8602B", rot: -7 }, { x: "36%", y: "18%", w: "44%", h: "72%", bg: "#3E4A38", rot: 4 }, { x: "62%", y: "-12%", w: "42%", h: "52%", bg: "#EFE3CB", rot: -3 }, { x: "16%", y: "52%", w: "34%", h: "56%", bg: "#27292D", rot: 6 }, { x: "52%", y: "60%", w: "26%", h: "30%", bg: "#D7C5A1", rot: -10 }, ], b: [ { x: "-8%", y: "30%", w: "56%", h: "66%", bg: "#3E4A38", rot: 5 }, { x: "30%", y: "-14%", w: "46%", h: "62%", bg: "#EFE3CB", rot: -5 }, { x: "58%", y: "34%", w: "48%", h: "66%", bg: "#C8602B", rot: 3 }, { x: "10%", y: "-8%", w: "26%", h: "42%", bg: "#27292D", rot: -9 }, ], c: [ { x: "8%", y: "-12%", w: "50%", h: "74%", bg: "#EFE3CB", rot: 4 }, { x: "-10%", y: "42%", w: "44%", h: "62%", bg: "#C8602B", rot: -6 }, { x: "50%", y: "20%", w: "54%", h: "70%", bg: "#27292D", rot: 7 }, { x: "36%", y: "64%", w: "30%", h: "40%", bg: "#3E4A38", rot: -4 }, ], }; const SCollage = ({ seed = "a", radius = 8, style }) => ( {(SCOLLAGE_SEEDS[seed] || SCOLLAGE_SEEDS.a).map((p, i) => ( ))} ); // Generated zine-collage topic heroes (the gen_hero.py house style). Falls back to the // abstract SCollage for any story without a generated hero. const HERO_MAP = { gilgit: "gb", budget: "bud", fixedtax: "ftx", azad: "ajk", waziristan: "nwz", nec: "nec", textiles: "tex", // v1 bounded set — each topic has its own coloured hero (gen_hero.py, per-topic palette) "gb-election": "gb-election", "india-water": "india-water", "baldia-acquittal": "baldia-acquittal", "afghan-border-strikes": "afghan-border-strikes", "ajk-jaac": "ajk-jaac", "imran-health": "imran-health", "women-safety": "women-safety", "budget-fy27": "budget-fy27", "remittances-record": "remittances-record", "austerity-hours": "austerity-hours" }; const SArt = ({ id, seed = "a", radius = 8, style }) => { const file = HERO_MAP[id]; if (!file) return ; return ( ); }; // Flexible markdown renderer — styled to the design so a dossier's prose sections (and any future // sections) display without UI code changes. Handles ## headings, **bold**, *italic*, > quotes, - lists. const mdInline = (t) => { const parts = []; let rest = String(t), key = 0; // [text](url) link · **bold** · *italic* · _italic_ const re = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)|\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_/; let m; while ((m = re.exec(rest))) { if (m.index > 0) parts.push(rest.slice(0, m.index)); if (m[1]) { let host = ""; try { host = new URL(m[2]).hostname.replace(/^www\./, ""); } catch (e) {} const onErr = (e) => { const t = e.target; if (!t.dataset.fb) { t.dataset.fb = "1"; t.src = "https://www.google.com/s2/favicons?domain=" + host + "&sz=32"; } else { t.style.display = "none"; } }; parts.push( {host && } {m[1]} ); } else if (m[3]) parts.push({m[3]}); else parts.push({m[4] || m[5]}); rest = rest.slice(m.index + m[0].length); } if (rest) parts.push(rest); return parts; }; // parse `:::type{k="v" k2="v2"}` attrs const mdAttrs = (s) => { const a = {}; const re = /(\w+)="([^"]*)"/g; let m; while ((m = re.exec(s || ""))) a[m[1]] = m[2]; return a; }; // render one `:::component` directive as a neutral-mode React block (the live design language) const MDComp = ({ type, attrs, body }) => { const lbl = (t) =>
{t}
; if (type === "tldr") return
{lbl("The 30-second read")}
; if (type === "matters") return
why this matters{mdInline(body)}
; if (type === "snapshot") return
{mdInline(body)}
; if (type === "keyfacts") return
{lbl("What everyone agrees on")}
; if (type === "faultline") return
{lbl("The fault line")}
{mdInline(body)}
; if (type === "loudness") return
loudest: {mdInline(attrs.leader || "")}

{mdInline(body)}

; if (type === "spectrum") return
{lbl("The spread of views")}

{mdInline(body)}

; if (type === "intl") return
{lbl("The foreign vantage")}
; if (type === "blindspot") return
{lbl("The blind spot")}
; if (type === "watch") return
{lbl("What to watch next")}
; if (type === "record") return
{lbl("The full record")}
; if (type === "methodology") return ; if (type === "quote") return (

“{mdInline(body)}”

{attrs.url ? {attrs.voice} : {attrs.voice}} {attrs.view && {attrs.view}} {attrs.engagement && {attrs.engagement}}
); if (type === "tweet") { const plat = (attrs.platform || "x").toLowerCase(); const icon = plat === "reddit" ? "ph-reddit-logo" : plat === "youtube" ? "ph-youtube-logo" : "ph-x-logo"; return (
{attrs.url ? {attrs.handle} : {attrs.handle}} {attrs.engagement && {attrs.engagement}}

{mdInline(body)}

); } if (type === "chart") { const rows = body.split("\n").filter((r) => r.includes("|")).map((r) => { const k = r.lastIndexOf("|"); return [r.slice(0, k).trim(), parseFloat(r.slice(k + 1).replace(/[^0-9.]/g, "")) || 0]; }); const mx = Math.max(...rows.map((r) => r[1]), 1); return (
{mdInline(attrs.title || "")}
{rows.map(([l, v], j) => (
{l}
{v}
))} {attrs.source &&
source: {mdInline(attrs.source)}{attrs.unit ? " · " + attrs.unit : ""}
} {attrs.note &&
{mdInline(attrs.note)}
}
); } return
; }; const MD = ({ md }) => { if (!md) return null; const lines = String(md).replace(/\r/g, "").split("\n"); const blocks = []; let i = 0; while (i < lines.length) { let line = lines[i]; const dir = line.match(/^:::(\w+)(?:\{(.*)\})?\s*$/); if (dir) { const buf = []; i++; while (i < lines.length && lines[i].trim() !== ":::") { buf.push(lines[i]); i++; } i++; blocks.push(); } else if (/^#{1,6}\s/.test(line)) { const text = line.replace(/^#+\s/, "").replace(/\s*\{#[^}]+\}\s*$/, ""); blocks.push(
{text}
); i++; } else if (/^>\s?/.test(line)) { const buf = []; while (i < lines.length && /^>\s?/.test(lines[i])) { buf.push(lines[i].replace(/^>\s?/, "")); i++; } blocks.push(
{mdInline(buf.join(" "))}
); } else if (/^[-*]\s/.test(line)) { const items = []; while (i < lines.length && /^[-*]\s/.test(lines[i])) { items.push(lines[i].replace(/^[-*]\s/, "")); i++; } blocks.push(); } else if (line.trim() === "") { i++; } else { const buf = []; while (i < lines.length && lines[i].trim() !== "" && !/^(#{1,6}\s|>\s?|[-*]\s)/.test(lines[i])) { buf.push(lines[i]); i++; } blocks.push(

{mdInline(buf.join(" "))}

); } } return
{blocks}
; }; // Entrance fade — transition-based so the resting state is fully visible (capture/print safe) const FadeIn = ({ children, style }) => { const [pre, setPre] = React.useState(true); React.useEffect(() => { const tm = setTimeout(() => setPre(false), 30); return () => clearTimeout(tm); }, []); return
{children}
; }; // Masthead — nav drives page + category filter const SMast = ({ page, cat, theme, onNav, onTheme }) => { const navs = [ { k: "today", label: "Today" }, { k: "politics", label: "Politics" }, { k: "economy", label: "Economy" }, { k: "sources", label: "Sources" }, ]; const active = page === "sources" ? "sources" : (cat === "Politics" ? "politics" : cat === "Economy" ? "economy" : "today"); return (
Mosaic onNav("today")} />
{new Date().toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}
); }; Object.assign(window, { framingColor, SFav, SFavRow, STicks, SCollage, SArt, SMast, tickLabel, recordOf, MD, FadeIn });