Add shared responsive media intelligence UI
This commit is contained in:
parent
b2d653482e
commit
3e37444710
386
src/web.ts
Normal file
386
src/web.ts
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
type AnyRow = Record<string, any>;
|
||||||
|
|
||||||
|
function esc(value: unknown) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nz(value: unknown) {
|
||||||
|
if (!value) return "—";
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("en-NZ", {
|
||||||
|
timeZone: "Pacific/Auckland",
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short"
|
||||||
|
}).format(new Date(String(value)));
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compact(value: unknown) {
|
||||||
|
const n = Number(value ?? 0);
|
||||||
|
return Number.isFinite(n) ? new Intl.NumberFormat("en-NZ").format(n) : String(value ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: unknown) {
|
||||||
|
return String(name ?? "U")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||||
|
.join("") || "U";
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(label: unknown, tone = "neutral") {
|
||||||
|
return `<span class="badge badge-${esc(tone)}">${esc(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: string) {
|
||||||
|
if (status === "develop") return "purple";
|
||||||
|
if (status === "review" || status === "investigate") return "blue";
|
||||||
|
if (status === "watch" || status === "researching") return "amber";
|
||||||
|
if (status === "closed" || status === "ignore") return "neutral";
|
||||||
|
return "green";
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceTone(kind: unknown) {
|
||||||
|
if (kind === "source_document") return "green";
|
||||||
|
if (kind === "source_payload") return "blue";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shell(args: {
|
||||||
|
title: string;
|
||||||
|
active: "dashboard" | "stories" | "opportunities";
|
||||||
|
identity: string;
|
||||||
|
body: string;
|
||||||
|
}) {
|
||||||
|
const nav = [
|
||||||
|
["dashboard", "/", "Desk"],
|
||||||
|
["stories", "/stories", "Story Threads"],
|
||||||
|
["opportunities", "/opportunities", "Opportunities"]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<title>${esc(args.title)} — Media Intelligence</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--ink:#172033;--muted:#687387;--line:#dde2ea;--soft:#f5f7fa;--panel:#fff;
|
||||||
|
--nav:#111827;--nav-soft:#1f2937;--blue:#2762d2;--green:#16805c;--amber:#b66a08;
|
||||||
|
--red:#b33a3a;--purple:#7551c8;--shadow:0 10px 28px rgba(20,31,52,.07);
|
||||||
|
--radius:14px;--radius-sm:9px;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html{background:#eef1f5}
|
||||||
|
body{margin:0;color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#eef1f5;line-height:1.5}
|
||||||
|
a{color:inherit}
|
||||||
|
button,input,select{font:inherit}
|
||||||
|
.topbar{position:sticky;top:0;z-index:50;background:rgba(17,24,39,.97);backdrop-filter:blur(10px);color:#fff;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||||
|
.topbar-inner{max-width:1440px;margin:auto;padding:0 22px;min-height:62px;display:flex;align-items:center;gap:24px}
|
||||||
|
.brand{display:flex;align-items:center;gap:11px;text-decoration:none;font-weight:760;letter-spacing:-.01em;white-space:nowrap}
|
||||||
|
.brand-mark{width:31px;height:31px;border-radius:9px;background:linear-gradient(145deg,#fff,#cbd5e1);color:#111827;display:grid;place-items:center;font-weight:850;font-size:.85rem}
|
||||||
|
.nav{display:flex;gap:5px;overflow:auto;scrollbar-width:none}
|
||||||
|
.nav::-webkit-scrollbar{display:none}
|
||||||
|
.nav a{padding:8px 11px;border-radius:8px;text-decoration:none;color:#cbd5e1;font-size:.91rem;white-space:nowrap}
|
||||||
|
.nav a:hover,.nav a.active{background:var(--nav-soft);color:#fff}
|
||||||
|
.userbox{margin-left:auto;display:flex;align-items:center;gap:10px;min-width:0}
|
||||||
|
.avatar{width:32px;height:32px;border-radius:50%;background:#374151;display:grid;place-items:center;font-size:.74rem;font-weight:750}
|
||||||
|
.user-name{font-size:.85rem;color:#e5e7eb;max-width:170px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.signout{font-size:.8rem;color:#9ca3af;text-decoration:none}
|
||||||
|
.page{max-width:1440px;margin:0 auto;padding:28px 22px 52px}
|
||||||
|
.page-head{display:flex;gap:20px;align-items:flex-start;justify-content:space-between;margin-bottom:22px}
|
||||||
|
.page-head h1{font-size:clamp(1.6rem,2.6vw,2.35rem);letter-spacing:-.035em;line-height:1.08;margin:0}
|
||||||
|
.page-head p{color:var(--muted);max-width:830px;margin:9px 0 0}
|
||||||
|
.eyebrow{font-size:.76rem;text-transform:uppercase;letter-spacing:.11em;font-weight:800;color:var(--muted);margin-bottom:8px}
|
||||||
|
.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:38px;padding:8px 12px;border:1px solid #cfd6df;border-radius:9px;background:#fff;color:var(--ink);text-decoration:none;cursor:pointer;font-weight:650;font-size:.88rem}
|
||||||
|
.btn:hover{background:#f8fafc;border-color:#b8c1ce}
|
||||||
|
.btn-primary{background:#172033;color:#fff;border-color:#172033}
|
||||||
|
.btn-primary:hover{background:#0f172a}
|
||||||
|
.btn-sm{min-height:32px;padding:5px 9px;font-size:.8rem}
|
||||||
|
.grid{display:grid;gap:14px}
|
||||||
|
.grid-metrics{grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||||
|
.grid-2{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}
|
||||||
|
.grid-cards{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
|
||||||
|
.panel,.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}
|
||||||
|
.panel{padding:19px}
|
||||||
|
.metric{padding:17px}
|
||||||
|
.metric-value{font-size:1.8rem;font-weight:790;letter-spacing:-.035em;line-height:1}
|
||||||
|
.metric-label{font-size:.82rem;color:var(--muted);margin-top:6px}
|
||||||
|
.section{margin-top:27px}
|
||||||
|
.section-head{display:flex;align-items:end;justify-content:space-between;gap:14px;margin-bottom:11px}
|
||||||
|
.section-head h2{font-size:1.04rem;margin:0;letter-spacing:-.015em}
|
||||||
|
.section-head p{margin:3px 0 0;color:var(--muted);font-size:.84rem}
|
||||||
|
.meta{font-size:.79rem;color:var(--muted)}
|
||||||
|
.muted{color:var(--muted)}
|
||||||
|
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.79em;background:#f1f4f7;border-radius:6px;padding:2px 5px}
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:999px;font-size:.72rem;line-height:1.35;font-weight:740;border:1px solid transparent;white-space:nowrap}
|
||||||
|
.badge-neutral{background:#f1f3f6;color:#596477;border-color:#e1e5ea}
|
||||||
|
.badge-green{background:#eaf7f1;color:#116445;border-color:#ccebdd}
|
||||||
|
.badge-blue{background:#edf4ff;color:#2456a8;border-color:#d4e3fb}
|
||||||
|
.badge-amber{background:#fff5e7;color:#8d5508;border-color:#f2dfbd}
|
||||||
|
.badge-red{background:#fff0f0;color:#9c3131;border-color:#f2d1d1}
|
||||||
|
.badge-purple{background:#f4efff;color:#6441ae;border-color:#e0d5f8}
|
||||||
|
.story-row,.opportunity-row,.message-row,.source-row{display:block;text-decoration:none}
|
||||||
|
.story-row+.story-row,.opportunity-row+.opportunity-row,.message-row+.message-row,.source-row+.source-row{border-top:1px solid #edf0f4}
|
||||||
|
.list-row{padding:15px 4px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;align-items:start}
|
||||||
|
.list-row:hover .row-title{text-decoration:underline;text-decoration-color:#c2cad5;text-underline-offset:3px}
|
||||||
|
.row-title{font-weight:730;letter-spacing:-.012em}
|
||||||
|
.row-sub{color:var(--muted);font-size:.86rem;margin-top:4px;max-width:900px}
|
||||||
|
.row-meta{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px}
|
||||||
|
.right-meta{text-align:right;white-space:nowrap}
|
||||||
|
.callout{border-left:4px solid var(--blue)}
|
||||||
|
.callout.warning{border-left-color:var(--amber)}
|
||||||
|
.callout.success{border-left-color:var(--green)}
|
||||||
|
.source-row{display:block}
|
||||||
|
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px;background:#94a3b8}
|
||||||
|
.dot.ok{background:#22a06b}.dot.bad{background:#d04f4f}.dot.warn{background:#d78a1f}
|
||||||
|
.thread-hero{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:22px;align-items:start}
|
||||||
|
.thread-title{font-size:clamp(1.75rem,3vw,2.55rem);letter-spacing:-.045em;line-height:1.08;margin:5px 0 9px}
|
||||||
|
.thread-question{font-size:1.03rem;color:#536075;max-width:920px}
|
||||||
|
.status-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:15px}
|
||||||
|
.status-actions form{margin:0}
|
||||||
|
.audit{display:grid;grid-template-columns:auto minmax(0,1fr);gap:14px;align-items:start}
|
||||||
|
.audit-icon{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;font-weight:850}
|
||||||
|
.audit.fail .audit-icon{background:#fff3e5;color:#965908}.audit.pass .audit-icon{background:#eaf7f1;color:#116445}
|
||||||
|
.audit-title{font-weight:780}.audit-checks{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:6px 12px;margin-top:11px}
|
||||||
|
.check{font-size:.82rem;color:var(--muted)}.check::before{content:"○";margin-right:6px}.check.ok{color:#176d4f}.check.ok::before{content:"✓";font-weight:900}
|
||||||
|
.kpis{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||||
|
.graph-shell{background:#fbfcfe;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
||||||
|
.graph-toolbar{display:flex;align-items:center;gap:8px;justify-content:space-between;padding:11px 13px;border-bottom:1px solid var(--line);background:#fff}
|
||||||
|
.legend{display:flex;gap:10px;flex-wrap:wrap;font-size:.72rem;color:var(--muted)}
|
||||||
|
.legend span::before{content:"";display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px;background:#94a3b8}
|
||||||
|
.legend .evidence::before{background:#3978d4}.legend .claim::before{background:#2a946d}.legend .counter::before{background:#d25555}.legend .question::before{background:#d88424}.legend .forecast::before{background:#8a66ce}
|
||||||
|
.graph-scroll{overflow:auto}
|
||||||
|
.story-graph{display:block;width:100%;min-width:900px;height:auto}
|
||||||
|
.edge line{stroke:#a8b0bd;stroke-width:1.8}.edge text{font-size:11px;fill:#626d7e;paint-order:stroke;stroke:#fbfcfe;stroke-width:5px}
|
||||||
|
#arrow path{fill:#a8b0bd}.thread-centre rect{fill:#172033}.thread-centre text{fill:#fff;font-size:12px}
|
||||||
|
.graph-node rect{fill:#fff;stroke:#aeb7c4;stroke-width:1.7}.graph-node.evidence rect,.graph-node.actual rect,.graph-node.signal rect{fill:#eef5ff;stroke:#3978d4}.graph-node.claim rect{fill:#edf9f4;stroke:#2a946d}.graph-node.counterclaim rect{fill:#fff0f0;stroke:#d25555}.graph-node.question rect{fill:#fff5e8;stroke:#d88424}.graph-node.forecast rect,.graph-node.trend rect,.graph-node.metric rect{fill:#f5f1ff;stroke:#8a66ce}.graph-node.context rect{fill:#f3f5f7;stroke:#7f8a9b}
|
||||||
|
.node-type{font-size:9px;text-transform:uppercase;fill:#6a7484;font-weight:700}.node-title{font-size:11.5px;font-weight:700;fill:#172033}
|
||||||
|
.assertion-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}
|
||||||
|
.assertion{position:relative;padding-left:18px}.assertion::before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;border-radius:4px;background:#9aa4b2}
|
||||||
|
.assertion.supported::before{background:var(--green)}.assertion.qualified::before{background:var(--amber)}.assertion.contradicted::before{background:var(--red)}.assertion.unsupported::before{background:#8b96a6}
|
||||||
|
.assertion h3,.evidence-card h3{margin:8px 0 6px;font-size:.98rem;letter-spacing:-.012em}.assertion p,.evidence-card p{margin:7px 0;color:#536075;font-size:.88rem}
|
||||||
|
.evidence-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:12px}
|
||||||
|
.evidence-card{min-width:0}.evidence-head{display:flex;gap:8px;justify-content:space-between;align-items:flex-start}
|
||||||
|
.evidence-excerpt{white-space:pre-wrap;background:#f7f9fb;border:1px solid #e7ebf0;border-radius:9px;padding:11px;font-size:.82rem;color:#445064;max-height:210px;overflow:auto}
|
||||||
|
.provenance{display:grid;grid-template-columns:auto 1fr;gap:4px 9px;font-size:.78rem;margin-top:10px}.provenance dt{color:#7a8494}.provenance dd{margin:0;color:#394457;overflow-wrap:anywhere}
|
||||||
|
.relationship{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);gap:10px;align-items:center;padding:11px 0}.relationship+.relationship{border-top:1px solid #edf0f4}.relationship-title{font-size:.84rem;font-weight:650}.relationship-mid{text-align:center}
|
||||||
|
.filterbar{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}.filterbar a{padding:7px 10px;background:#fff;border:1px solid var(--line);border-radius:8px;text-decoration:none;font-size:.82rem;color:#536075}.filterbar a.active{background:#172033;color:#fff;border-color:#172033}
|
||||||
|
.empty{text-align:center;padding:30px;color:var(--muted)}
|
||||||
|
.toast{position:fixed;right:18px;bottom:18px;background:#172033;color:#fff;border-radius:9px;padding:10px 13px;font-size:.84rem;box-shadow:var(--shadow);opacity:0;transform:translateY(8px);pointer-events:none;transition:.18s}.toast.show{opacity:1;transform:translateY(0)}
|
||||||
|
.mobile-only{display:none}
|
||||||
|
@media(max-width:980px){.grid-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-2{grid-template-columns:1fr}.thread-hero{grid-template-columns:1fr}}
|
||||||
|
@media(max-width:700px){.topbar-inner{padding:0 14px;gap:13px}.brand span:last-child{display:none}.user-name,.signout{display:none}.page{padding:20px 14px 40px}.page-head{display:block}.page-head>.actions{margin-top:14px}.grid-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.list-row{grid-template-columns:1fr}.right-meta{text-align:left}.graph-toolbar{align-items:flex-start;flex-direction:column}.graph-scroll{display:none}.mobile-only{display:block}.assertion-grid,.evidence-grid{grid-template-columns:1fr}.relationship{grid-template-columns:1fr}.relationship-mid{text-align:left}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><div class="topbar-inner">
|
||||||
|
<a class="brand" href="/"><span class="brand-mark">MI</span><span>Media Intelligence</span></a>
|
||||||
|
<nav class="nav">${nav.map(([key, href, label]) => `<a href="${href}" class="${args.active === key ? "active" : ""}">${label}</a>`).join("")}</nav>
|
||||||
|
<div class="userbox"><div class="avatar">${esc(initials(args.identity))}</div><div><div class="user-name">${esc(args.identity)}</div><a class="signout" href="/auth/logout">Sign out</a></div></div>
|
||||||
|
</div></div>
|
||||||
|
<main class="page">${args.body}</main>
|
||||||
|
<div id="toast" class="toast">Link copied</div>
|
||||||
|
<script>
|
||||||
|
function copyCurrentLink(){
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(function(){
|
||||||
|
var t=document.getElementById("toast"); if(!t)return;
|
||||||
|
t.classList.add("show"); setTimeout(function(){t.classList.remove("show")},1800);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function graphSvg(story: AnyRow) {
|
||||||
|
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
||||||
|
const edges = Array.isArray(story.edges) ? story.edges : [];
|
||||||
|
const width = 1120, height = 690, cx = width / 2, cy = height / 2 + 20, rx = 430, ry = 245;
|
||||||
|
const positions = new Map<string, {x:number;y:number}>();
|
||||||
|
nodes.forEach((node: AnyRow, i: number) => {
|
||||||
|
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / Math.max(nodes.length, 1);
|
||||||
|
positions.set(node.id, {x:cx + Math.cos(angle)*rx, y:cy + Math.sin(angle)*ry});
|
||||||
|
});
|
||||||
|
|
||||||
|
const edgeSvg = edges.map((edge: AnyRow) => {
|
||||||
|
const a = positions.get(edge.from_node_id), b = positions.get(edge.to_node_id);
|
||||||
|
if (!a || !b) return "";
|
||||||
|
const mx=(a.x+b.x)/2, my=(a.y+b.y)/2;
|
||||||
|
return `<g class="edge"><line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" marker-end="url(#arrow)"></line><text x="${mx}" y="${my-6}" text-anchor="middle">${esc(edge.relationship)}</text></g>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const nodeSvg = nodes.map((node: AnyRow) => {
|
||||||
|
const p = positions.get(node.id)!;
|
||||||
|
const raw = String(node.title ?? "");
|
||||||
|
const label = raw.length > 39 ? `${raw.slice(0,36)}…` : raw;
|
||||||
|
return `<a href="#node-${encodeURIComponent(node.id)}"><g class="graph-node ${esc(node.node_type)}"><title>${esc(node.title)} — ${esc(node.node_type)}</title><rect x="${p.x-108}" y="${p.y-39}" width="216" height="78" rx="13"></rect><text class="node-type" x="${p.x}" y="${p.y-12}" text-anchor="middle">${esc(node.node_type)}</text><text class="node-title" x="${p.x}" y="${p.y+11}" text-anchor="middle">${esc(label)}</text></g></a>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
return `<svg class="story-graph" viewBox="0 0 ${width} ${height}" role="img" aria-label="Research graph for ${esc(story.title)}"><defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L0,6 L9,3 z"></path></marker></defs><g class="thread-centre"><rect x="${cx-135}" y="${cy-49}" width="270" height="98" rx="17"></rect><text x="${cx}" y="${cy-10}" text-anchor="middle">STORY THREAD</text><text x="${cx}" y="${cy+18}" text-anchor="middle">${esc(String(story.title).slice(0,42))}</text></g>${edgeSvg}${nodeSvg}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertionState(node: AnyRow, story: AnyRow) {
|
||||||
|
const evidenceIds = new Set((story.source_audit?.sources ?? []).map((s: AnyRow) => s.node_id));
|
||||||
|
const incoming = (story.edges ?? []).filter((e: AnyRow) => e.to_node_id === node.id && evidenceIds.has(e.from_node_id));
|
||||||
|
const rels = new Set(incoming.map((e: AnyRow) => e.relationship));
|
||||||
|
if ((rels.has("supports") && rels.has("contradicts")) || rels.has("qualifies")) return "qualified";
|
||||||
|
if (rels.has("contradicts")) return "contradicted";
|
||||||
|
if (rels.has("supports") || rels.has("actual_for") || rels.has("forecast_for")) return "supported";
|
||||||
|
return "unsupported";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceAuditPanel(story: AnyRow) {
|
||||||
|
const audit = story.source_audit ?? {robust:false,checks:{},counts:{}};
|
||||||
|
const labels: Record<string,string> = {
|
||||||
|
at_least_two_independent_sources:"Two independent source groups",
|
||||||
|
at_least_two_evidence_backed_nodes:"At least two substantive evidence records",
|
||||||
|
at_least_one_tier_one_source:"At least one Tier 1 source",
|
||||||
|
all_assertions_have_direct_evidence:"Assertions directly connected to evidence",
|
||||||
|
no_source_tag_only_direct_evidence_nodes:"No source-label-only evidence nodes"
|
||||||
|
};
|
||||||
|
return `<div class="panel audit ${audit.robust ? "pass" : "fail"}">
|
||||||
|
<div class="audit-icon">${audit.robust ? "✓" : "!"}</div>
|
||||||
|
<div><div class="audit-title">${audit.robust ? "Sourcing gate passed" : "Sourcing gaps remain"}</div>
|
||||||
|
<div class="muted" style="font-size:.86rem;margin-top:3px">${audit.robust ? "This thread meets the current source-diversity and evidence-linkage requirements." : "The thread stays in research until the missing evidence and independence checks are satisfied."}</div>
|
||||||
|
<div class="kpis">
|
||||||
|
${badge(`${compact(audit.counts?.evidence_backed_nodes)} evidence-backed`, audit.counts?.evidence_backed_nodes >= 2 ? "green" : "amber")}
|
||||||
|
${badge(`${compact(audit.counts?.distinct_independence_groups)} source groups`, audit.counts?.distinct_independence_groups >= 2 ? "green" : "amber")}
|
||||||
|
${badge(`${compact(audit.counts?.unsupported_claims)} unsupported assertions`, audit.counts?.unsupported_claims ? "red" : "green")}
|
||||||
|
</div>
|
||||||
|
<div class="audit-checks">${Object.entries(audit.checks ?? {}).map(([key,value]) => `<div class="check ${value ? "ok" : ""}">${esc(labels[key] ?? key.replace(/_/g," "))}</div>`).join("")}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceCard(node: AnyRow) {
|
||||||
|
const kind = node.evidence_kind ?? "discovery_metadata";
|
||||||
|
const publisher = node.provenance_publisher ?? node.provenance_source_id ?? "Unknown publisher";
|
||||||
|
const quality = kind === "source_document" ? "Source document" : kind === "source_payload" ? "Official source payload" : "Discovery metadata";
|
||||||
|
const excerpt = String(node.evidence_excerpt ?? node.summary ?? "").trim();
|
||||||
|
const sourceDate = node.snapshot_published_at ?? node.snapshot_retrieved_at ?? node.created_at;
|
||||||
|
return `<article class="panel evidence-card" id="node-${esc(node.id)}">
|
||||||
|
<div class="evidence-head"><div>${badge(quality,evidenceTone(kind))}${node.provenance_authority_tier ? badge(`Tier ${node.provenance_authority_tier}`,"neutral") : ""}</div><div class="meta">${esc(nz(sourceDate))}</div></div>
|
||||||
|
<h3>${esc(node.title)}</h3><p>${esc(node.summary)}</p>
|
||||||
|
${excerpt ? `<div class="evidence-excerpt">${esc(excerpt.slice(0,1500))}${excerpt.length > 1500 ? "…" : ""}</div>` : ""}
|
||||||
|
<dl class="provenance">
|
||||||
|
<dt>Publisher</dt><dd>${esc(publisher)}</dd>
|
||||||
|
<dt>Source</dt><dd>${esc(node.provenance_source_id ?? "—")}</dd>
|
||||||
|
<dt>Snapshot</dt><dd>${esc(node.snapshot_kind ?? "—")}${node.snapshot_id ? ` · <span class="mono">${esc(String(node.snapshot_id).slice(0,12))}</span>` : ""}</dd>
|
||||||
|
<dt>Captured</dt><dd>${esc(nz(node.snapshot_retrieved_at))}</dd>
|
||||||
|
</dl>
|
||||||
|
${node.evidence_url ? `<div style="margin-top:12px"><a class="btn btn-sm" href="${esc(node.evidence_url)}" target="_blank" rel="noreferrer">Open original ↗</a></div>` : ""}
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderStory(args: {story: AnyRow; identity: string}) {
|
||||||
|
const {story, identity} = args;
|
||||||
|
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
||||||
|
const edges = Array.isArray(story.edges) ? story.edges : [];
|
||||||
|
const evidenceNodes = nodes.filter((n: AnyRow) => n.evidence_id);
|
||||||
|
const assertions = nodes.filter((n: AnyRow) => ["claim","counterclaim","forecast","trend","metric"].includes(n.node_type));
|
||||||
|
const researchNodes = nodes.filter((n: AnyRow) => ["question","context","organisation","person","place"].includes(n.node_type));
|
||||||
|
const byId = new Map(nodes.map((n: AnyRow) => [n.id,n]));
|
||||||
|
const counter = (story.research_checks ?? []).find((c:AnyRow)=>c.check_type==="counter_evidence");
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<div class="thread-hero"><div><div class="eyebrow">360° Story Thread</div>
|
||||||
|
<div class="row-meta">${badge(story.status,statusTone(story.status))}${story.source_audit?.robust ? badge("sourcing ready","green") : badge("sourcing gaps","amber")}${counter ? badge(`counter-evidence: ${counter.result}`,"blue") : badge("counter-evidence pending","amber")}</div>
|
||||||
|
<h1 class="thread-title">${esc(story.title)}</h1><div class="thread-question">${esc(story.question)}</div>
|
||||||
|
<div class="meta" style="margin-top:11px">Created by ${esc(story.created_by_name)} · updated ${esc(nz(story.updated_at))}</div></div>
|
||||||
|
<div class="actions"><button class="btn" type="button" onclick="copyCurrentLink()">Copy link</button><a class="btn" href="/stories">All threads</a></div></div>
|
||||||
|
|
||||||
|
<div class="section">${sourceAuditPanel(story)}</div>
|
||||||
|
|
||||||
|
<div class="section grid grid-2">
|
||||||
|
<div class="panel"><div class="section-head"><div><h2>Editorial state</h2><p>Human-controlled progression. Research gates apply before review/develop.</p></div></div>
|
||||||
|
<div class="status-actions">${["researching","review","develop","closed"].map((s) => `<form method="post" action="/stories/${encodeURIComponent(story.id)}/status"><input type="hidden" name="status" value="${s}"><button class="btn btn-sm ${story.status===s ? "btn-primary" : ""}" type="submit">${esc(s)}</button></form>`).join("")}</div></div>
|
||||||
|
<div class="panel ${counter ? "callout success" : "callout warning"}"><div class="section-head"><div><h2>Counter-evidence pass</h2><p>${counter ? `${esc(counter.result)} · ${esc((counter.sources_checked ?? []).join(", "))}` : "Required before editorial progression"}</p></div></div>
|
||||||
|
<div style="font-size:.87rem;color:#536075">${counter ? esc(counter.summary) : "No completed counter-evidence check is stored for this thread."}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="section"><div class="section-head"><div><h2>Evidence network</h2><p>${compact(nodes.length)} nodes · ${compact(edges.length)} relationships</p></div></div>
|
||||||
|
<div class="graph-shell"><div class="graph-toolbar"><div class="legend"><span class="evidence">evidence</span><span class="claim">claim</span><span class="counter">counterclaim</span><span class="question">question</span><span class="forecast">forecast / trend</span></div><div class="meta">Tap a node to jump to its card</div></div>
|
||||||
|
<div class="graph-scroll">${graphSvg(story)}</div><div class="mobile-only" style="padding:12px 14px;color:#687387;font-size:.84rem">Graph view is condensed on mobile. Use the evidence and assertion cards below for the full research trail.</div></div></section>
|
||||||
|
|
||||||
|
<section class="section"><div class="section-head"><div><h2>Assertions</h2><p>Evidence linkage, not a confidence score.</p></div></div>
|
||||||
|
<div class="assertion-grid">${assertions.length ? assertions.map((node: AnyRow) => {
|
||||||
|
const state = assertionState(node, story);
|
||||||
|
const incoming = edges.filter((e:AnyRow)=>e.to_node_id===node.id);
|
||||||
|
return `<article class="panel assertion ${state}" id="node-${esc(node.id)}"><div class="row-meta">${badge(node.node_type,"neutral")}${badge(state,state==="supported"?"green":state==="contradicted"?"red":state==="qualified"?"amber":"neutral")}</div><h3>${esc(node.title)}</h3><p>${esc(node.summary)}</p><div class="meta">${incoming.length ? `${incoming.length} incoming relationship${incoming.length===1?"":"s"}` : "No evidence relationship yet"}${node.confidence != null ? ` · confidence ${esc(node.confidence)}` : ""}</div></article>`;
|
||||||
|
}).join("") : `<div class="panel empty">No assertions in this thread yet.</div>`}</div></section>
|
||||||
|
|
||||||
|
<section class="section"><div class="section-head"><div><h2>Evidence & provenance</h2><p>Exact publisher, evidence class, snapshot and original source.</p></div></div>
|
||||||
|
<div class="evidence-grid">${evidenceNodes.length ? evidenceNodes.map(evidenceCard).join("") : `<div class="panel empty">No evidence nodes yet.</div>`}</div></section>
|
||||||
|
|
||||||
|
${researchNodes.length ? `<section class="section"><div class="section-head"><div><h2>Research context</h2><p>Questions, context and entities that shape the investigation.</p></div></div><div class="grid grid-cards">${researchNodes.map((n:AnyRow)=>`<article class="panel" id="node-${esc(n.id)}"><div class="row-meta">${badge(n.node_type,"neutral")}</div><h3 style="margin:8px 0 5px;font-size:.98rem">${esc(n.title)}</h3><div class="muted" style="font-size:.87rem">${esc(n.summary)}</div><div class="meta" style="margin-top:10px">Added by ${esc(n.created_by_name)}</div></article>`).join("")}</div></section>` : ""}
|
||||||
|
|
||||||
|
<section class="section"><div class="section-head"><div><h2>Relationships</h2><p>Human-readable graph edges with rationale.</p></div></div>
|
||||||
|
<div class="panel">${edges.length ? edges.map((edge:AnyRow)=>{
|
||||||
|
const from=byId.get(edge.from_node_id), to=byId.get(edge.to_node_id);
|
||||||
|
return `<div class="relationship"><div><div class="meta">FROM</div><div class="relationship-title">${esc(from?.title ?? edge.from_node_id)}</div></div><div class="relationship-mid">${badge(edge.relationship,"neutral")}</div><div><div class="meta">TO</div><div class="relationship-title">${esc(to?.title ?? edge.to_node_id)}</div><div class="meta" style="margin-top:4px">${esc(edge.reason)}</div></div></div>`;
|
||||||
|
}).join("") : `<div class="empty">No relationships stored.</div>`}</div></section>`;
|
||||||
|
|
||||||
|
return shell({title:story.title,active:"stories",identity,body});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderStories(args: {stories: AnyRow[]; identity: string}) {
|
||||||
|
const body = `<div class="page-head"><div><div class="eyebrow">Research workspace</div><h1>Story Threads</h1><p>Evidence networks built around a reporting question, with explicit counter-evidence and sourcing gates before editorial review.</p></div></div>
|
||||||
|
<div class="panel">${args.stories.length ? args.stories.map((s:AnyRow)=>{
|
||||||
|
const audit=s.source_audit ?? {};
|
||||||
|
return `<a class="story-row" href="/stories/${encodeURIComponent(s.id)}"><div class="list-row"><div><div class="row-title">${esc(s.title)}</div><div class="row-sub">${esc(s.question)}</div><div class="row-meta">${badge(s.status,statusTone(s.status))}${audit.robust ? badge("sourcing ready","green") : badge("sourcing gaps","amber")}${s.counter_evidence_result ? badge(`counter: ${s.counter_evidence_result}`,"blue") : badge("counter pending","neutral")}</div></div><div class="right-meta meta">${compact(s.nodes?.length ?? s.node_count)} nodes<br>${compact(s.edges?.length ?? s.edge_count)} edges<br>updated ${esc(nz(s.updated_at))}</div></div></a>`;
|
||||||
|
}).join("") : `<div class="empty">No Story Threads yet.</div>`}</div>`;
|
||||||
|
return shell({title:"Story Threads",active:"stories",identity:args.identity,body});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOpportunity(args:{opportunity:AnyRow;identity:string}) {
|
||||||
|
const o=args.opportunity, evidence=Array.isArray(o.evidence)?o.evidence:[], notes=Array.isArray(o.notes)?o.notes:[];
|
||||||
|
const body=`<div class="page-head"><div><div class="eyebrow">Opportunity</div><div class="row-meta">${badge(o.status,statusTone(o.status))}${badge(`score ${o.score}`,"neutral")}</div><h1 style="margin-top:9px">${esc(o.title)}</h1><p>${esc(o.summary)}</p><div class="meta">${esc(o.source_id)} · ${esc(nz(o.published_at || o.detected_at))}</div></div><div class="actions"><button class="btn" type="button" onclick="copyCurrentLink()">Copy link</button><a class="btn" href="${esc(o.url)}" target="_blank" rel="noreferrer">Original source ↗</a></div></div>
|
||||||
|
<div class="grid grid-2"><div class="panel"><div class="section-head"><div><h2>Triage</h2><p>${esc((o.reasons??[]).join(" · "))}</p></div></div><div class="status-actions">${["watch","investigate","develop","ignore","closed"].map((s)=>`<form method="post" action="/opportunities/${encodeURIComponent(o.id)}/status"><input type="hidden" name="status" value="${s}"><input type="hidden" name="returnTo" value="detail"><button class="btn btn-sm ${o.status===s?"btn-primary":""}" type="submit">${esc(s)}</button></form>`).join("")}</div></div>
|
||||||
|
<div class="panel"><div class="section-head"><div><h2>Why it surfaced</h2></div></div><div class="muted" style="font-size:.88rem">${esc((o.reasons??[]).join(" · ")||"No scoring reasons stored.")}</div></div></div>
|
||||||
|
<section class="section"><div class="section-head"><div><h2>Stored evidence</h2><p>${evidence.length} record${evidence.length===1?"":"s"}</p></div></div><div class="evidence-grid">${evidence.length?evidence.map((e:AnyRow)=>`<article class="panel evidence-card"><div class="evidence-head"><div>${badge(e.evidence_kind??"stored evidence",evidenceTone(e.evidence_kind))}</div><div class="meta">${esc(nz(e.retrieved_at))}</div></div><div class="evidence-excerpt" style="margin-top:10px">${esc(e.excerpt)}</div><div style="margin-top:10px"><a class="btn btn-sm" href="${esc(e.source_url)}" target="_blank" rel="noreferrer">Open source ↗</a></div></article>`).join(""):`<div class="panel empty">No evidence stored.</div>`}</div></section>
|
||||||
|
<section class="section"><div class="section-head"><div><h2>Notes</h2></div></div>${notes.length?notes.map((n:AnyRow)=>`<div class="panel"><div class="meta">${esc(nz(n.created_at))}</div><div style="margin-top:5px">${esc(n.note)}</div></div>`).join(""):`<div class="panel empty">No notes yet.</div>`}</section>`;
|
||||||
|
return shell({title:o.title,active:"opportunities",identity:args.identity,body});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOpportunities(args:{opportunities:AnyRow[];identity:string;status?:string}) {
|
||||||
|
const states=["new","watch","investigate","develop","ignore","closed"];
|
||||||
|
const body=`<div class="page-head"><div><div class="eyebrow">Signal triage</div><h1>Opportunities</h1><p>Potential stories surfaced from monitored sources. Humans decide what to watch, investigate or develop.</p></div></div>
|
||||||
|
<div class="filterbar"><a class="${!args.status?"active":""}" href="/opportunities">Active</a>${states.map((s)=>`<a class="${args.status===s?"active":""}" href="/opportunities?status=${s}">${esc(s)}</a>`).join("")}</div>
|
||||||
|
<div class="panel">${args.opportunities.length?args.opportunities.map((o:AnyRow)=>`<a class="opportunity-row" href="/opportunities/${encodeURIComponent(o.id)}"><div class="list-row"><div><div class="row-title">${esc(o.title)}</div><div class="row-sub">${esc(o.summary)}</div><div class="row-meta">${badge(o.status,statusTone(o.status))}${badge(o.source_id,"neutral")}<span class="meta">${esc((o.reasons??[]).join(" · "))}</span></div></div><div class="right-meta meta">score ${esc(o.score)}<br>${esc(nz(o.published_at||o.detected_at))}</div></div></a>`).join(""):`<div class="empty">No opportunities in this view.</div>`}</div>`;
|
||||||
|
return shell({title:"Opportunities",active:"opportunities",identity:args.identity,body});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDashboard(args:{identity:string;counts:AnyRow;messages:AnyRow[];sources:AnyRow[];stories:AnyRow[]}) {
|
||||||
|
const c=args.counts??{}, sourceErrors=args.sources.filter((s)=>s.last_error), storyAttention=args.stories.filter((s)=>!s.source_audit?.robust && s.status!=="closed");
|
||||||
|
const body=`<div class="page-head"><div><div class="eyebrow">Newsroom desk</div><h1>Media Intelligence</h1><p>Public-source monitoring, evidence research and human editorial control in one shared workspace.</p></div><div class="actions"><a class="btn btn-primary" href="/stories">Open research desk</a></div></div>
|
||||||
|
<div class="grid grid-metrics">
|
||||||
|
<div class="panel metric"><div class="metric-value">${compact(c.signals)}</div><div class="metric-label">stored signals</div></div>
|
||||||
|
<div class="panel metric"><div class="metric-value">${compact(c.opportunities)}</div><div class="metric-label">active opportunities</div></div>
|
||||||
|
<div class="panel metric"><div class="metric-value">${compact(c.new_opportunities)}</div><div class="metric-label">new to triage</div></div>
|
||||||
|
<div class="panel metric"><div class="metric-value">${compact(c.story_threads)}</div><div class="metric-label">open Story Threads</div></div>
|
||||||
|
<div class="panel metric"><div class="metric-value">${compact(c.unread_messages)}</div><div class="metric-label">agent updates</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="section grid grid-2">
|
||||||
|
<div><div class="section-head"><div><h2>Research needing attention</h2><p>Threads that still have sourcing or evidence gaps.</p></div><a class="btn btn-sm" href="/stories">All threads</a></div>
|
||||||
|
<div class="panel">${storyAttention.length?storyAttention.slice(0,6).map((s:AnyRow)=>`<a class="story-row" href="/stories/${encodeURIComponent(s.id)}"><div class="list-row"><div><div class="row-title">${esc(s.title)}</div><div class="row-meta">${badge(s.status,statusTone(s.status))}${badge(`${s.source_audit?.counts?.unsupported_claims??0} unsupported`,(s.source_audit?.counts?.unsupported_claims??0)?"red":"green")}${badge(`${s.source_audit?.counts?.distinct_independence_groups??0} source groups`,"amber")}</div></div><div class="right-meta meta">${esc(nz(s.updated_at))}</div></div></a>`).join(""):`<div class="empty">No sourcing gaps in active threads.</div>`}</div>
|
||||||
|
</div>
|
||||||
|
<div><div class="section-head"><div><h2>Source health</h2><p>Current ingestion state.</p></div></div>
|
||||||
|
<div class="panel">${args.sources.map((s:AnyRow)=>`<div class="source-row"><div class="list-row"><div><div class="row-title"><span class="dot ${s.last_error?"bad":s.baseline_complete?"ok":"warn"}"></span>${esc(s.name)}</div><div class="row-meta">${badge(s.source_class??s.source_type,"neutral")}${s.authority_tier?badge(`Tier ${s.authority_tier}`,"neutral"):""}</div></div><div class="right-meta meta">${compact(s.signal_count)} signals<br>${s.last_error?"error":esc(nz(s.last_synced_at))}</div></div></div>`).join("")}</div>
|
||||||
|
${sourceErrors.length?`<div class="panel callout warning" style="margin-top:10px"><strong>${sourceErrors.length} source issue${sourceErrors.length===1?"":"s"}</strong><div class="meta" style="margin-top:4px">${sourceErrors.map((s)=>esc(s.name)).join(" · ")}</div></div>`:""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section class="section"><div class="section-head"><div><h2>Agent Activity</h2><p>Operational findings and source-monitor updates.</p></div></div>
|
||||||
|
<div class="panel">${args.messages.length?args.messages.map((m:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(m.title)}</div><div class="row-sub">${esc(m.body)}</div><div class="row-meta">${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}<span class="meta">${esc(m.actor_name)} via <span class="mono">${esc(m.actor_client_id)}</span></span></div><form method="post" action="/messages/${encodeURIComponent(m.id)}/ack" style="margin-top:9px"><button class="btn btn-sm" type="submit">Acknowledge</button></form></div><div class="right-meta meta">${esc(nz(m.created_at))}</div></div></div>`).join(""):`<div class="empty">No unread Agent Activity.</div>`}</div></section>`;
|
||||||
|
return shell({title:"Desk",active:"dashboard",identity:args.identity,body});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user