diff --git a/CHANGELOG.md b/CHANGELOG.md index 625f179..d908a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Persisted publication responses now expose an explicit `origin` contract with `u PubMed parsing remains conservative: direct XML parsing and injected transports do not certify provider origin, while the ordinary built-in PubMed search path marks records as provider-derived before persistence. Tests now cover origin normalization, PubMed path classification, provider-shaped synthetic payloads, revision changes from unknown to manual origin, API list/detail/history responses, and the CI seed record's synthetic label. +The TypeScript dashboard now presents the origin and synthetic boundaries added to the API instead of flattening persisted publications and fixture evidence into the same visual treatment. Evidence cards label synthetic fixture status, review status, level, confidence, and citation boundary; publication cards label `origin`, tri-state `synthetic`, revision, provider identifier, parser version, retrieval time, and retraction status. The knowledge-graph view now matches the implemented `/api/v1/graph` response shape, and API text is rendered through DOM nodes instead of interpolated into raw HTML. + Evidence list, detail, and citation export now reconstruct current review metadata from the latest persisted event by ID. Historical events remain intact, synthetic origin remains authoritative, and configured storage failures return an error instead of stale fixture review metadata. Integration coverage exercises successive review states, deliberately backdated timestamps, and recovery through a fresh application instance. Review-event history now uses bounded cursor pagination with a default page size of fifty and a maximum of one hundred. Clients follow `next_after_id` to retrieve subsequent pages. PostgreSQL integration coverage verifies persisted review payloads, event-ID ordering, record isolation, and continued exclusion of reviewed fixtures from citation export. @@ -20,7 +22,7 @@ Documentation tooling now counts prose separately from fenced code and diagrams, The abbreviated license file has been replaced with the official Apache License 2.0 text. Project attribution is recorded separately in NOTICE, and citation metadata identifies the author as an independent Romanian researcher. The existing release tag is preserved. The citation's release version is not silently advanced to an unpublished development version merely because the Python package declares that version. -Known implementation issues remain visible: incomplete pathway adjustment, duplicate multi-omics handling, and the absence of an operational scientific review service. The frontend still needs evidence of a production build and interaction testing beyond compiler-only scripts. Publication origin classification is improved, but provider origin remains a retrieval-boundary label rather than scientific validation. No new deployment, clinical validation, or independent benchmark is asserted by this documentation entry. +Known implementation issues remain visible: incomplete pathway adjustment, duplicate multi-omics handling, and the absence of an operational scientific review service. The frontend now exposes key provenance boundaries, but it still needs a real production artifact and browser interaction testing beyond compiler-only scripts. Publication origin classification is improved, but provider origin remains a retrieval-boundary label rather than scientific validation. No new deployment, clinical validation, or independent benchmark is asserted by this documentation entry. ## [0.2.0] - 2026-09-14 diff --git a/ROADMAP.md b/ROADMAP.md index 1166532..1d45837 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,7 +34,7 @@ The current baseline is a research prototype with partially integrated infrastru ## Immediate integrity and documentation priorities -Publication-origin classification has moved from a fixed false synthetic flag to an explicit persistence contract. Synthetic seeds, manually documented records, ordinary PubMed provider-path records, and legacy unknown records now have different API semantics. Acceptance coverage includes the CI seed, ordinary PubMed search classification, a deliberately provider-shaped synthetic payload, persistence, list/detail responses, and revision history. The remaining work is presentation: the frontend must display these distinctions clearly rather than flattening them into a single visual style. +Publication-origin classification has moved from a fixed false synthetic flag to an explicit persistence contract. Synthetic seeds, manually documented records, ordinary PubMed provider-path records, and legacy unknown records now have different API semantics. Acceptance coverage includes the CI seed, ordinary PubMed search classification, a deliberately provider-shaped synthetic payload, persistence, list/detail responses, revision history, and dashboard labels for origin and synthetic state. The remaining work is deeper presentation testing: the frontend needs rendered browser checks across success, empty, unavailable, and responsive states. Complete the documentation expansion with distinct, source-backed explanations rather than repeated filler. Every tracked Markdown file has a minimum prose target, but accuracy and implementation alignment remain separate requirements. The automated inventory should continue to expose short documents, missing attribution, and structural problems. Code examples need execution checks, and diagrams need to identify proposed components clearly. A passing word-count gate is not a substitute for editorial review. diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index b8fb444..ee7cef8 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,53 +1,427 @@ -type EvidenceSummary = { records: number; active_records: number; mean_confidence: number; mean_navigation_score?: number; disclaimer: string }; -type SearchPayload = { items: Array>; total: number }; - -const app = document.querySelector("#app"); -if (!app) throw new Error("Dashboard root is missing"); - -app.innerHTML = ` -

OPEN SCIENCE · RESEARCH INFRASTRUCTURE · v0.2.0

OpenLongevity

-

Evidence mapping for biological aging research.

-
-

Results retain provenance and distinguish study designs.

-
OpenLongevityOpen science for a healthier tomorrow
Ciprian Ștefan PleșcaFounder · Principal Author · Lead Maintainer
GitHubWhitepaperAcademic manifesto
Research use only · Not medical advice · v0.2.0
`; - -const result = document.querySelector("#result"); -document.querySelector("#run")?.addEventListener("click", async () => { - const topic = document.querySelector("#topic")?.value.trim() ?? ""; - if (!topic || !result) return; - result.textContent = "Loading evidence summary…"; +type EvidenceItem = { + identifier: string; + title: string; + study_type: string; + species: string; + endpoint: string; + source: string; + confidence: number; + score: number; + level: string; + synthetic: boolean; + review_status: string; + reviewed_by?: string | null; + reviewed_at?: string | null; + review_notes?: string | null; +}; + +type EvidenceSummary = { + records: number; + active_records: number; + mean_confidence: number; + mean_navigation_score?: number; + disclaimer: string; +}; + +type EvidencePayload = { + items: EvidenceItem[]; + mode: string; + summary: EvidenceSummary; + disclaimer: string; +}; + +type PublicationItem = { + identifier: string; + title: string; + journal?: string | null; + publication_date?: string | null; + origin: "unknown" | "manual" | "provider" | "synthetic"; + synthetic: boolean | null; + retraction_status: string; + revision: number; + provenance: { + source_provider: string; + source_identifier: string; + source_url: string; + retrieved_at: string; + parser_version: string; + }; +}; + +type PublicationPayload = { + items: PublicationItem[]; + total: number; + mode: string; + disclaimer: string; +}; + +type GraphPayload = { + mode: string; + nodes: string[]; + edges: Array<{ source: string; target: string; relation: string }>; + disclaimer: string; +}; + +type HealthPayload = { + status: string; + version: string; + database: string; + provider_status: string; + disclaimer: string; +}; + +const appRoot = document.querySelector("#app"); +if (!appRoot) throw new Error("Dashboard root is missing"); +const root = appRoot; + +const state = { + topic: "cellular senescence", +}; + +renderShell(); +void updateHealth(); +void inspectEvidence(); + +function renderShell(): void { + root.replaceChildren( + element("header", { className: "hero" }, [ + element("p", { className: "eyebrow" }, ["Open science · research infrastructure · v0.3.0"]), + element("h1", {}, ["OpenLongevity"]), + element("p", { className: "lede" }, [ + "Evidence mapping for biological aging research with source labels, fixture boundaries, and review state visible at the point of use.", + ]), + element("nav", { className: "toolbar", ariaLabel: "Primary dashboard views" }, [ + actionButton("Evidence", "evidence", inspectEvidence), + actionButton("Knowledge Graph", "graph", inspectGraph), + actionButton("Sources", "sources", inspectSources), + ]), + ]), + element("section", { className: "query-panel" }, [ + element("label", { htmlFor: "topic" }, ["Explore a topic"]), + element("div", { className: "query-row" }, [ + input("topic", state.topic), + actionButton("Inspect", "inspect", inspectEvidence), + ]), + element("p", { className: "status", id: "status" }, ["API status: checking..."]), + ]), + element("section", { id: "result", className: "result", ariaLive: "polite" }, [ + element("p", {}, ["Loading evidence boundary..."]), + ]), + element("footer", { className: "site-footer" }, [ + element("div", {}, [ + element("strong", {}, ["OpenLongevity"]), + element("span", {}, ["Open science for a healthier tomorrow"]), + ]), + element("div", {}, [ + element("strong", {}, ["CIPRIAN ȘTEFAN PLEȘCA"]), + element("span", {}, ["Autor · cercetător român independent · maintainer"]), + ]), + element("div", { className: "footer-links" }, [ + link("GitHub", "https://github.com/Ciprian-LocalPulse/OpenLongevityLab"), + link("Whitepaper", "/WHITEPAPER.md"), + link("Academic manifesto", "/ACADEMIC_MANIFESTO.md"), + ]), + element("small", {}, ["Research use only · Not medical advice · synthetic fixtures stay labeled"]), + ]), + ); + + document.querySelector("#topic")?.addEventListener("input", (event) => { + state.topic = (event.target as HTMLInputElement).value; + }); +} + +async function inspectEvidence(): Promise { + const result = resultRegion(); + result.replaceChildren(element("p", {}, ["Loading evidence and publication context..."])); + const topic = state.topic.trim(); + if (!topic) { + result.replaceChildren(element("p", { className: "notice warn" }, ["Enter a topic first."])); + return; + } + + const evidence = await fetchJson( + `/api/v1/evidence?topic=${encodeURIComponent(topic)}`, + ); + if (!evidence.ok) { + result.replaceChildren(offlineNotice("The evidence endpoint is unavailable.")); + return; + } + + const publication = await fetchJson( + `/api/v1/search?query=${encodeURIComponent(topic)}&page=1&page_size=10`, + ); + + const sections: Node[] = [ + element("div", { className: "section-heading" }, [ + element("div", {}, [ + element("p", { className: "eyebrow" }, ["Evidence boundary"]), + element("h2", {}, [`${evidence.data.summary.active_records} active fixture record(s)`]), + ]), + originLegend(), + ]), + summaryGrid(evidence.data.summary, evidence.data.mode), + evidenceList(evidence.data.items), + ]; + + if (publication.ok) { + sections.push(publicationList(publication.data)); + } else { + sections.push(element("article", { className: "notice" }, [ + element("strong", {}, ["Persisted publications are not available"]), + element("p", {}, [ + "The local API can still show fixture evidence, but publication search needs a configured PostgreSQL repository.", + ]), + ])); + } + + result.replaceChildren(...sections); +} + +async function inspectGraph(): Promise { + const result = resultRegion(); + result.replaceChildren(element("p", {}, ["Loading graph demonstration..."])); + const graph = await fetchJson("/api/v1/graph"); + if (!graph.ok) { + result.replaceChildren(offlineNotice("The graph endpoint is unavailable.")); + return; + } + result.replaceChildren( + element("div", { className: "section-heading" }, [ + element("div", {}, [ + element("p", { className: "eyebrow" }, ["Knowledge graph"]), + element("h2", {}, [`${graph.data.nodes.length} nodes · ${graph.data.edges.length} edges`]), + ]), + badge(graph.data.mode, "neutral"), + ]), + element("ol", { className: "edge-list" }, graph.data.edges.map((edge) => element("li", {}, [ + element("strong", {}, [edge.source]), + element("span", {}, [edge.relation]), + element("strong", {}, [edge.target]), + ]))), + element("p", { className: "fineprint" }, [graph.data.disclaimer]), + ); +} + +function inspectSources(): void { + resultRegion().replaceChildren( + element("div", { className: "section-heading" }, [ + element("div", {}, [ + element("p", { className: "eyebrow" }, ["Connected sources"]), + element("h2", {}, ["Read-only provenance map"]), + ]), + ]), + element("div", { className: "source-grid" }, [ + sourceCard("PubMed", "Implemented ingestion path can mark persisted records as provider-origin."), + sourceCard("Europe PMC", "Adapter shape exists; persisted provider-origin classification is not yet wired."), + sourceCard("OpenAlex", "Read-only metadata adapter; use origin labels conservatively."), + sourceCard("Crossref", "Bibliographic metadata path; not a scientific validation service."), + sourceCard("ClinicalTrials.gov", "Registry adapter boundary; no mutation of upstream records."), + ]), + ); +} + +async function updateHealth(): Promise { + const health = await fetchJson("/api/v1/health"); + const status = document.querySelector("#status"); + if (!status) return; + if (health.ok) { + status.textContent = `API status: ${health.data.status} · database: ${health.data.database} · provider: ${health.data.provider_status}`; + } else { + status.textContent = "API status: offline · run the FastAPI service locally"; + } +} + +function summaryGrid(summary: EvidenceSummary, mode: string): HTMLElement { + return element("div", { className: "metric-grid" }, [ + metric("Mode", mode), + metric("Records", String(summary.records)), + metric("Active", String(summary.active_records)), + metric("Mean confidence", formatNumber(summary.mean_confidence)), + metric("Navigation score", summary.mean_navigation_score == null ? "n/a" : formatNumber(summary.mean_navigation_score)), + ]); +} + +function evidenceList(items: EvidenceItem[]): HTMLElement { + if (!items.length) { + return element("article", { className: "notice" }, [ + element("strong", {}, ["No matching fixture evidence"]), + element("p", {}, ["Try a topic such as cellular senescence."]), + ]); + } + return element("div", { className: "cards" }, items.map((item) => element("article", { className: "record-card" }, [ + element("div", { className: "card-top" }, [ + badge(item.synthetic ? "synthetic fixture" : "non-synthetic", item.synthetic ? "warn" : "ok"), + badge(item.review_status, item.review_status === "unreviewed" ? "neutral" : "ok"), + badge(`Level ${item.level}`, "neutral"), + ]), + element("h3", {}, [item.title]), + element("p", {}, [`${item.study_type} · ${item.species} · ${item.endpoint}`]), + element("dl", { className: "record-meta" }, [ + meta("Identifier", item.identifier), + meta("Source", item.source), + meta("Confidence", formatNumber(item.confidence)), + meta("Navigation score", formatNumber(item.score)), + ]), + item.reviewed_by ? element("p", { className: "fineprint" }, [ + `Reviewed by ${item.reviewed_by} at ${item.reviewed_at ?? "unknown time"}. ${item.review_notes ?? ""}`, + ]) : element("p", { className: "fineprint" }, [ + "Not human verified. Fixture records remain excluded from citation-eligible export.", + ]), + ]))); +} + +function publicationList(payload: PublicationPayload): HTMLElement { + if (!payload.items.length) { + return element("article", { className: "notice" }, [ + element("strong", {}, ["No persisted publications matched"]), + element("p", {}, ["Search currently filters locally stored publication titles only."]), + ]); + } + return element("section", { className: "publication-section" }, [ + element("div", { className: "section-heading" }, [ + element("div", {}, [ + element("p", { className: "eyebrow" }, ["Persisted publications"]), + element("h2", {}, [`${payload.total} stored match(es)`]), + ]), + badge(payload.mode, "neutral"), + ]), + element("div", { className: "cards" }, payload.items.map((item) => publicationCard(item))), + element("p", { className: "fineprint" }, [payload.disclaimer]), + ]); +} + +function publicationCard(item: PublicationItem): HTMLElement { + const syntheticLabel = item.synthetic === true + ? "synthetic" + : item.synthetic === false ? "not synthetic" : "synthetic unknown"; + const syntheticTone = item.synthetic === true ? "warn" : item.synthetic === false ? "ok" : "neutral"; + return element("article", { className: "record-card" }, [ + element("div", { className: "card-top" }, [ + badge(`origin: ${item.origin}`, item.origin === "provider" ? "ok" : item.origin === "synthetic" ? "warn" : "neutral"), + badge(syntheticLabel, syntheticTone), + badge(`revision ${item.revision}`, "neutral"), + ]), + element("h3", {}, [item.title]), + element("p", {}, [ + [item.journal, item.publication_date].filter(Boolean).join(" · ") || "No journal/date metadata", + ]), + element("dl", { className: "record-meta" }, [ + meta("Identifier", item.identifier), + meta("Provider", item.provenance.source_provider), + meta("Source ID", item.provenance.source_identifier), + meta("Parser", item.provenance.parser_version), + meta("Retrieved", item.provenance.retrieved_at), + meta("Retraction", item.retraction_status), + ]), + link("Open source record", item.provenance.source_url, "source-link"), + ]); +} + +function originLegend(): HTMLElement { + return element("div", { className: "legend", ariaLabel: "Origin legend" }, [ + badge("synthetic = teaching/test data", "warn"), + badge("provider = retrieval boundary", "ok"), + badge("unknown = not certified", "neutral"), + ]); +} + +function sourceCard(name: string, description: string): HTMLElement { + return element("article", { className: "record-card compact" }, [ + element("h3", {}, [name]), + element("p", {}, [description]), + ]); +} + +function metric(label: string, value: string): HTMLElement { + return element("div", { className: "metric" }, [ + element("span", {}, [label]), + element("strong", {}, [value]), + ]); +} + +function meta(label: string, value: string | number | null | undefined): HTMLElement { + return element("div", {}, [ + element("dt", {}, [label]), + element("dd", {}, [String(value ?? "unknown")]), + ]); +} + +function offlineNotice(message: string): HTMLElement { + return element("article", { className: "notice warn" }, [ + element("strong", {}, [message]), + element("p", {}, ["Start the local FastAPI service to inspect live API responses."]), + ]); +} + +function resultRegion(): HTMLDivElement { + const result = document.querySelector("#result"); + if (!result) throw new Error("Result region is missing"); + return result; +} + +async function fetchJson(path: string): Promise<{ ok: true; data: T } | { ok: false }> { try { - const response = await fetch(`/api/v1/evidence?topic=${encodeURIComponent(topic)}`); - if (!response.ok) throw new Error("API request failed"); - const payload = (await response.json()) as { summary: EvidenceSummary }; - result.innerHTML = `

Evidence summary

${payload.summary.active_records} active record(s), mean confidence ${payload.summary.mean_confidence}; navigation score ${payload.summary.mean_navigation_score ?? "n/a"}

${payload.summary.disclaimer}`; - const search = await fetch(`/api/v1/search?query=${encodeURIComponent(topic)}&limit=10`); - if (search.ok) { - const data = (await search.json()) as SearchPayload; - result.innerHTML += `

Mapped resources (${data.total})

    ${data.items.map((item) => `
  • ${String(item.title ?? item.name ?? item.id)} ${String(item.type ?? item.kind ?? "resource")}
  • `).join("")}
`; - } + const response = await fetch(path, { headers: { Accept: "application/json" } }); + if (!response.ok) return { ok: false }; + return { ok: true, data: await response.json() as T }; } catch { - result.textContent = "The API is unavailable. Start the local FastAPI service to explore fixtures."; + return { ok: false }; } -}); +} + +function actionButton(label: string, view: string, handler: () => void | Promise): HTMLButtonElement { + const button = element("button", { type: "button", dataView: view }, [label]); + button.addEventListener("click", () => { + void handler(); + }); + return button; +} + +function input(id: string, value: string): HTMLInputElement { + const field = document.createElement("input"); + field.id = id; + field.value = value; + field.maxLength = 120; + field.autocomplete = "off"; + return field; +} -document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", async () => { - if (!result) return; - const view = button.dataset.view; - if (view === "graph") { - const response = await fetch("/api/v1/graph"); - const graph = (await response.json()) as Record>; - const edges = Object.entries(graph).flatMap(([subject, links]) => links.map((link) => `${subject} ${link.relation} ${link.object}`)); - result.innerHTML = `

Knowledge graph

${Object.keys(graph).length} subjects · ${edges.length} typed edges

    ${edges.map((edge) => `
  • ${edge}
  • `).join("")}
`; - } else if (view === "sources") { - result.innerHTML = `

Connected sources

PubMed · Europe PMC · OpenAlex · Crossref · ClinicalTrials.gov

Each record carries a source identifier, retrieval timestamp, URL, and normalization version.

`; +function badge(text: string, tone: "ok" | "warn" | "neutral"): HTMLElement { + return element("span", { className: `badge ${tone}` }, [text]); +} + +function link(text: string, href: string, className?: string): HTMLAnchorElement { + const anchor = document.createElement("a"); + anchor.textContent = text; + anchor.href = href; + anchor.rel = "noreferrer"; + if (className) anchor.className = className; + return anchor; +} + +function element( + tag: K, + props: Partial & Record = {}, + children: Array = [], +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(props)) { + if (value == null) continue; + if (key === "className") node.className = String(value); + else if (key === "htmlFor" && node instanceof HTMLLabelElement) node.htmlFor = String(value); + else if (key === "ariaLabel") node.setAttribute("aria-label", String(value)); + else if (key === "ariaLive") node.setAttribute("aria-live", String(value)); + else if (key === "dataView") node.dataset.view = String(value); + else if (key in node) { + (node as unknown as Record)[key] = value; + } else { + node.setAttribute(key, String(value)); + } } -})); + node.append(...children); + return node; +} -fetch("/api/v1/health").then((response) => response.json()).then((health: { status: string }) => { - const status = document.querySelector("#status"); - if (status) status.textContent = `API status: ${health.status}`; -}).catch(() => { - const status = document.querySelector("#status"); - if (status) status.textContent = "API status: offline (run FastAPI locally)"; -}); +function formatNumber(value: number): string { + return Number.isFinite(value) ? value.toFixed(2) : "n/a"; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 972720a..69fffc8 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1,15 +1,339 @@ -:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #071521; color: #e9f4ff; } -body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 70% 15%, #164466, #071521 60%); } -main { max-width: 900px; margin: auto; padding: 4rem 1.5rem; } -.eyebrow { color: #72d9c7; letter-spacing: .18em; font-size: .75rem; } -h1 { font-size: clamp(3rem, 10vw, 6rem); margin: .2rem 0; } -.search, #result { margin-top: 2rem; padding: 1.5rem; border: 1px solid #315d79; border-radius: 1rem; background: #0b2233cc; } -input, button { padding: .75rem; border-radius: .5rem; border: 1px solid #5385a4; margin: .5rem .5rem 0 0; } -button { background: #53d8b4; color: #062018; cursor: pointer; } -footer { margin-top: 3rem; opacity: .9; } -.site-footer { display: grid; grid-template-columns: 1.2fr 1.4fr 1fr; gap: 1rem; max-width: 1100px; margin: 4rem auto 0; padding: 2rem 1.5rem; border-top: 1px solid #315d79; color: #b9d3e5; } -.site-footer div { display: flex; flex-direction: column; gap: .35rem; } -.site-footer strong { color: #e9f4ff; letter-spacing: .04em; } -.site-footer span, .site-footer small { color: #86a9bf; font-size: .85rem; } -.site-footer a { color: #72d9c7; text-decoration: none; margin-right: .7rem; } -@media (max-width: 700px) { .site-footer { grid-template-columns: 1fr; } } +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #07131a; + color: #edf7fb; + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(135deg, rgba(8, 39, 56, 0.96), rgba(7, 19, 26, 0.98)), + linear-gradient(45deg, rgba(88, 215, 184, 0.13), transparent 38%), + #07131a; +} + +button, +input { + font: inherit; +} + +button { + min-height: 2.6rem; + border: 1px solid rgba(119, 219, 199, 0.55); + border-radius: 0.45rem; + background: #58d7b8; + color: #062018; + cursor: pointer; + font-weight: 700; + padding: 0.65rem 0.9rem; +} + +button:hover, +button:focus-visible { + background: #8af3dc; + outline: 2px solid #e9fff9; + outline-offset: 2px; +} + +input { + width: min(100%, 32rem); + min-height: 2.6rem; + border: 1px solid rgba(133, 176, 198, 0.7); + border-radius: 0.45rem; + background: rgba(5, 19, 28, 0.92); + color: #edf7fb; + padding: 0.65rem 0.75rem; +} + +a { + color: #8be8d7; + text-decoration: none; +} + +a:hover, +a:focus-visible { + text-decoration: underline; +} + +#app { + width: min(1180px, calc(100% - 2rem)); + margin: 0 auto; + padding: 3rem 0 2rem; +} + +.hero { + max-width: 880px; + padding: 1rem 0 2rem; +} + +.eyebrow { + color: #78ddcc; + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0.14em; + margin: 0 0 0.55rem; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + font-size: clamp(3.2rem, 9vw, 6.2rem); + line-height: 0.96; + margin-bottom: 0.85rem; +} + +h2 { + font-size: clamp(1.55rem, 3vw, 2.45rem); + line-height: 1.1; + margin-bottom: 0; +} + +h3 { + font-size: 1.06rem; + line-height: 1.25; + margin-bottom: 0.6rem; +} + +.lede { + max-width: 48rem; + color: #c7dce7; + font-size: 1.12rem; +} + +.toolbar, +.query-row, +.card-top, +.legend { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.query-panel, +.result, +.record-card, +.notice, +.metric { + border: 1px solid rgba(122, 169, 194, 0.34); + background: rgba(6, 24, 34, 0.86); + box-shadow: 0 22px 50px rgba(0, 0, 0, 0.22); +} + +.query-panel, +.result { + border-radius: 0.65rem; + padding: 1.25rem; +} + +.query-panel label { + display: block; + margin-bottom: 0.5rem; + color: #d8edf5; + font-weight: 750; +} + +.status, +.fineprint { + color: #a9c3d0; + font-size: 0.88rem; + margin: 0.8rem 0 0; +} + +.result { + display: grid; + gap: 1.25rem; + margin-top: 1rem; +} + +.section-heading { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.metric-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.metric { + border-radius: 0.55rem; + padding: 1rem; +} + +.metric span, +.record-meta dt { + color: #9fc2d2; + display: block; + font-size: 0.78rem; +} + +.metric strong { + color: #f5fbfe; + display: block; + font-size: 1.12rem; + margin-top: 0.2rem; + overflow-wrap: anywhere; +} + +.cards, +.source-grid { + display: grid; + gap: 0.9rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.source-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.record-card, +.notice { + border-radius: 0.55rem; + padding: 1rem; +} + +.record-card.compact { + min-height: 9rem; +} + +.record-card p { + color: #c7dce7; +} + +.record-meta { + display: grid; + gap: 0.65rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 1rem 0; +} + +.record-meta div { + min-width: 0; +} + +.record-meta dd { + margin: 0.12rem 0 0; + overflow-wrap: anywhere; +} + +.badge { + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 999px; + display: inline-flex; + font-size: 0.76rem; + font-weight: 800; + min-height: 1.75rem; + padding: 0.2rem 0.65rem; +} + +.badge.ok { + background: rgba(64, 210, 156, 0.18); + color: #9cf0cf; +} + +.badge.warn { + background: rgba(238, 188, 91, 0.18); + color: #ffd487; +} + +.badge.neutral { + background: rgba(124, 162, 187, 0.18); + color: #c8ddeb; +} + +.edge-list { + display: grid; + gap: 0.7rem; + list-style: none; + margin: 0; + padding: 0; +} + +.edge-list li { + align-items: center; + background: rgba(255, 255, 255, 0.055); + border-radius: 0.45rem; + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + padding: 0.8rem; +} + +.edge-list span { + color: #8be8d7; +} + +.source-link { + display: inline-flex; + font-weight: 800; +} + +.notice.warn { + border-color: rgba(238, 188, 91, 0.42); +} + +.site-footer { + border-top: 1px solid rgba(122, 169, 194, 0.34); + color: #b9d3e5; + display: grid; + gap: 1rem; + grid-template-columns: 1.2fr 1.4fr 1.3fr; + margin-top: 3rem; + padding: 1.4rem 0 0; +} + +.site-footer div { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.site-footer strong { + color: #edf7fb; + letter-spacing: 0.03em; +} + +.site-footer span, +.site-footer small { + color: #9fb8c7; + font-size: 0.86rem; +} + +.footer-links { + align-content: start; +} + +@media (max-width: 900px) { + .metric-grid, + .cards, + .source-grid, + .site-footer { + grid-template-columns: 1fr; + } + + #app { + width: min(100% - 1rem, 720px); + padding-top: 1.5rem; + } + + .section-heading { + display: grid; + } +}