// 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
{ 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.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 ;
if (type === "watch") return {lbl("What to watch next")};
if (type === "record") return ;
if (type === "methodology") return ;
if (type === "quote") return (
“{mdInline(body)}”
);
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) => (
))}
{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({items.map((it, j) => - {mdInline(it)}
)}
);
} 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 (

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 });