diff --git a/app/_home/HomePage.tsx b/app/_home/HomePage.tsx index 92318a79..68ce3755 100644 --- a/app/_home/HomePage.tsx +++ b/app/_home/HomePage.tsx @@ -3,7 +3,7 @@ // (query-flow.tsx). The selection is in the URL (?studies=a,b), so a refresh or a shared link // lands on it. import { useSearchParams } from "next/navigation"; -import { PortalHeader } from "../portal/portal-chrome.tsx"; +import { SiteHeader } from "../_ui/site-header.tsx"; import { QueryFlow } from "./query-flow.tsx"; /** the selection written back in place: ticking studies is not a history entry */ @@ -16,7 +16,7 @@ export function HomePage() { const q = useSearchParams(); const studies = [...new Set((q.get("studies") || q.get("study") || "").split(",").map((s) => s.trim()).filter((s) => /^[A-Za-z0-9._-]+$/.test(s)))]; return <> - +
diff --git a/app/_ui/site-header.tsx b/app/_ui/site-header.tsx new file mode 100644 index 00000000..92f87b33 --- /dev/null +++ b/app/_ui/site-header.tsx @@ -0,0 +1,39 @@ +"use client"; +// THE app's header, the same on every page: the name (home), which page this is, the same links in +// the same order, a page's own tools (its assistant menu) and the sign-in. +import LoginButton from "../lab/login-button.tsx"; + +const LINKS = [ + ["studies", "/", "studies"], + ["examples", "/lab?tab=examples", "examples"], + ["reproductions", "/reproductions", "paper reproductions"], + ["campaigns", "/campaign", "hypothesis campaigns"], + ["verification", "/verification", "how we verify"], + ["rationale", "/rationale", "rationale"], +] as const; +export type SiteLink = typeof LINKS[number][0]; + +export function SiteHeader({ page, current, onLink, children }: { + /** which page this is, beside the name */ + page: string; + /** the link this page is, marked as the current one */ + current?: SiteLink; + /** a page that can open a link in place (the lab's own tabs) does, and says so by returning true */ + onLink?: (link: SiteLink) => boolean; + /** the page's own tools, before the sign-in */ + children?: React.ReactNode; +}) { + return ( +
+

cbio-lab

+ {page} + + {LINKS.map(([key, href, label]) => ( + { if (!e.metaKey && !e.ctrlKey && !e.shiftKey && onLink?.(key)) e.preventDefault(); }}>{label} + ))} + {children} + +
+ ); +} diff --git a/app/_ui/tabs.tsx b/app/_ui/tabs.tsx index 0f404702..6df90dd9 100644 --- a/app/_ui/tabs.tsx +++ b/app/_ui/tabs.tsx @@ -55,6 +55,8 @@ function TabsList({ ) } +// An unselected tab is full-strength text, teal on hover: shadcn's default fades it to 60%, which +// reads as disabled. The selected tab stands out by its own colour, underline or raised chip. // Chromium marks a tab focused by a click as :focus-visible, so a tab remembers that a pointer // focused it (data-pointer) and shows its focus ring again from the first key press. function TabsTrigger({ @@ -71,7 +73,7 @@ function TabsTrigger({ onKeyDown={(e) => { delete e.currentTarget.dataset.pointer; onKeyDown?.(e) }} onBlur={(e) => { delete e.currentTarget.dataset.pointer; onBlur?.(e) }} className={cn( - "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:hover:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "data-[pointer]:focus-visible:border-transparent data-[pointer]:focus-visible:ring-0 data-[pointer]:focus-visible:outline-none", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent", "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground", diff --git a/app/_ui/toolbar.ts b/app/_ui/toolbar.ts index ae17535d..9bf7d1fa 100644 --- a/app/_ui/toolbar.ts +++ b/app/_ui/toolbar.ts @@ -10,12 +10,16 @@ export const CHIP = "rounded-pill px-2.5 text-sm font-normal aria-pressed:border export const FIELD = "h-ctl bg-card text-md md:text-md"; /** a page's own sections (the lab's Explore / Saved / Analysis, a portal page's tabs): a line tab one step larger than LINE_TAB */ -export const PAGE_TAB = "h-auto flex-none rounded-none px-3.5 pt-2 pb-1.5 text-base leading-normal after:bottom-[-1px] data-[state=active]:text-accent-foreground data-[state=active]:after:bg-primary"; +export const PAGE_TAB = "h-auto flex-none rounded-none px-3.5 pt-2 pb-1.5 text-base leading-normal after:bottom-[-1px] data-[state=active]:text-accent-foreground dark:data-[state=active]:text-accent-foreground data-[state=active]:after:bg-primary"; /** a row of page tabs underlined by a rule (TabsList variant "line"), and one of its tabs */ export const LINE_TABS = "w-full flex-wrap justify-start gap-1.5 border-b border-border p-0 group-data-[orientation=horizontal]/tabs:h-auto"; -export const LINE_TAB = "h-auto flex-none rounded-none px-3 pt-1.5 pb-1.5 text-md after:bottom-[-1px] data-[state=active]:text-accent-foreground data-[state=active]:after:bg-primary"; +export const LINE_TAB = "h-auto flex-none rounded-none px-3 pt-1.5 pb-1.5 text-md after:bottom-[-1px] data-[state=active]:text-accent-foreground dark:data-[state=active]:text-accent-foreground data-[state=active]:after:bg-primary"; /** a body's own selector (a gene, a group) over what it shows: segmented tabs that wrap */ export const SEG_TABS = "h-auto max-w-full flex-wrap justify-start group-data-[orientation=horizontal]/tabs:h-auto"; -export const SEG_TAB = "h-7 flex-none px-2.5 text-sm font-normal data-[state=active]:font-semibold"; +export const SEG_TAB = "h-7 flex-none px-2.5 text-sm font-medium " + + // the selected one is a filled chip, in both schemes: a raised white chip on grey was too quiet to read. + // One weight for every state, so a chip keeps its width and the row does not shift when the choice moves. + "data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:hover:text-primary-foreground " + + "dark:data-[state=active]:border-transparent dark:data-[state=active]:bg-primary dark:data-[state=active]:text-primary-foreground"; diff --git a/app/campaign/[id]/page.tsx b/app/campaign/[id]/page.tsx index db738a73..e43c8340 100644 --- a/app/campaign/[id]/page.tsx +++ b/app/campaign/[id]/page.tsx @@ -11,7 +11,7 @@ import type { Campaign, Funnel, HypothesisRow } from "../../../lib/campaign.ts"; import { installAgentTools } from "../agent-tools.ts"; import { Prompt } from "../prompt.tsx"; import { AgentToolsMenu } from "../../_agent/AgentToolsMenu.tsx"; -import LoginButton from "../../lab/login-button.tsx"; +import { SiteHeader } from "../../_ui/site-header.tsx"; import { useCustomDataState } from "../../lab/custom-data-panel.tsx"; import { LoaderCircle, Lock, Play } from "lucide-react"; import { Button } from "../../_ui/button"; @@ -119,16 +119,10 @@ export default function CampaignPage() { return ( <> -
-

Hypothesis campaigns

- cbio-lab as the referee for AI-generated hypotheses - - analysis app - why this exists - + -
+
Research question · {c.study}
diff --git a/app/campaign/page.tsx b/app/campaign/page.tsx index 7b8558e6..654ae190 100644 --- a/app/campaign/page.tsx +++ b/app/campaign/page.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from "react"; import { installIndexTools } from "./agent-tools.ts"; import { Prompt } from "./prompt.tsx"; import { AgentToolsMenu } from "../_agent/AgentToolsMenu.tsx"; -import LoginButton from "../lab/login-button.tsx"; +import { SiteHeader } from "../_ui/site-header.tsx"; type Row = { id: string; question: string; study: string; status: string; lockedAt: string | null; createdAt: string }; @@ -33,16 +33,10 @@ export default function CampaignIndex() { return ( <> -
-

Hypothesis campaigns

- cbio-lab as the referee for AI-generated hypotheses - - analysis app - why this exists - + -
+
1 · You ask

Your research question, in plain words, to your AI assistant — Claude in the Chrome sidebar.

diff --git a/app/datasets/layout.tsx b/app/datasets/layout.tsx index d786d211..b81d3d45 100644 --- a/app/datasets/layout.tsx +++ b/app/datasets/layout.tsx @@ -1,14 +1,16 @@ // The data sets page reads the warehouse at request time, which under Cache Components must stream // inside a Suspense boundary; the fallback holds the page's shape while the catalog loads. import { Suspense } from "react"; +import { SiteHeader } from "../_ui/site-header.tsx"; export default function DatasetsLayout({ children }: { children: React.ReactNode }) { - return ( + return (<> +

Data sets

loading the studies this deployment holds…

}>{children} - ); + ); } diff --git a/app/datasets/page.tsx b/app/datasets/page.tsx index bf1094c5..4a19e514 100644 --- a/app/datasets/page.tsx +++ b/app/datasets/page.tsx @@ -26,11 +26,6 @@ export default async function DatasetsPage() { const sum = (k: keyof DatasetRow) => rows.reduce((a, r) => a + (Number(r[k]) || 0), 0); return (
-
- ← back to the app - paper reproductions - rationale -

Datasets

diff --git a/app/globals.css b/app/globals.css index 90fe31d3..e9439f2e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -295,6 +295,8 @@ pre { .skeleton-counts { display: inline-block; vertical-align: middle; width: 15rem; height: 0.9em; border-radius: var(--r-sm); } .skeleton-facet { height: 13rem; } .skeleton-view { height: 16rem; margin-bottom: 1rem; } +/* under a view's toolbar the figure stands where the drawing will be, as far below it as the drawing */ +.viewtb + .skeleton-view { margin-top: var(--s3); } .skeleton-addview { height: 3.4rem; margin-bottom: 1rem; } /* A view recomputing after a settings change: the figure area only (Updating measures where the header and toolbars end), opaque so the stale answer can't be read as the new one. */ @@ -601,6 +603,8 @@ main.analysis-main { max-width: min(132rem, 100%); } /* ---------------- small shared idioms ---------------- */ .toplink { color: var(--accent-ink); font-size: var(--t-md); } +/* the page you are on: plain text colour, no underline (same weight, so the header does not shift) */ +.toplink[aria-current=page] { color: var(--ink); text-decoration: none; } .row.center { align-items: center; } .row.baseline { align-items: baseline; } .row.g5 { gap: 0.5rem; } @@ -806,9 +810,6 @@ main.analysis-main { max-width: min(132rem, 100%); } .chatfigbody svg { display: block; } /* ---------------- /rationale — RATIONALE.md rendered as a page ---------------- */ -.rationale-nav { margin-bottom: 1.2rem; display: flex; align-items: center; gap: var(--s4); } -.rationale-nav a { color: var(--accent-ink); font-size: var(--t-md); text-decoration: none; } -.rationale-nav a:hover { text-decoration: underline; } .rationale-doc { line-height: 1.65; } .rationale-doc h1 { @@ -1331,9 +1332,6 @@ details.cohortpick .gearmenu-items { min-width: 22rem; max-width: 30rem; gap: 0; gallery card per paper, same .card base every other page's cards use. --------------------------------------------------------------------------- */ .repro-page { max-width: 900px; margin: 0 auto; padding: var(--s5) var(--s4) var(--s6); } -.repro-nav { display: flex; align-items: center; gap: var(--s4); margin-bottom: var(--s5); } -.repro-nav a { color: var(--accent-ink); font-size: var(--t-md); text-decoration: none; } -.repro-nav a:hover { text-decoration: underline; } .repro-intro h1 { font-family: "IBM Plex Serif", Georgia, serif; font-size: var(--t-2xl); margin: 0 0 var(--s2); } .repro-intro .lede { color: var(--muted); max-width: 68ch; line-height: 1.6; margin: 0; } .repro-tiles { display: flex; gap: var(--s3); flex-wrap: wrap; margin-top: var(--s4); } @@ -1412,8 +1410,6 @@ details.cohortpick .gearmenu-items { min-width: 22rem; max-width: 30rem; gap: 0; /* ---- hypothesis campaigns (/campaign) ---------------------------------------------------- */ .camp { max-width: 64rem; } -.camp-home, .camp-home:visited { color: inherit; text-decoration: none; } -.camp-nav { color: var(--accent-ink); font-size: var(--t-md); } .camp-eyebrow { font-size: var(--t-xs); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); font-weight: 600; } .camp-q { margin: var(--s3) 0 var(--s5); } .camp-q h2 { font-family: "IBM Plex Serif", Georgia, serif; font-size: var(--t-xl); font-weight: 600; line-height: 1.3; margin: var(--s1) 0 var(--s4); } diff --git a/app/groups/page.tsx b/app/groups/page.tsx index 9c764e49..3a2868bc 100644 --- a/app/groups/page.tsx +++ b/app/groups/page.tsx @@ -5,8 +5,8 @@ // item (the Saved tab's "share…"), not here. import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import LoginButton from "../lab/login-button.tsx"; import { useCustomDataState } from "../lab/custom-data-panel.tsx"; +import { SiteHeader } from "../_ui/site-header.tsx"; import { requestLogin } from "../lab/custom-data-client.ts"; import { Waiting } from "../_oncoprint/OncoprintCard.tsx"; import { api } from "../lab/api.ts"; @@ -40,13 +40,7 @@ export default function GroupsPage() { return ( <> -
-

Groups

- who you share saved cohorts, analyses and custom data with - - your saved work - -
+
{!login.loaded ?
: !login.unlocked ?

diff --git a/app/lab/analysis-sections.tsx b/app/lab/analysis-sections.tsx index 6b91bb8b..4c42396d 100644 --- a/app/lab/analysis-sections.tsx +++ b/app/lab/analysis-sections.tsx @@ -37,11 +37,6 @@ import type { ViewCtx } from "./view-cards.tsx"; import { isNewKind } from "../../lib/new-in-lab.ts"; import { NewMark } from "./new-mark.tsx"; -/** A view kind already in the document: filled with the accent colour, so "on" cannot be mistaken - * for a chip still loading (a pale pulsing fill). */ -const VIEW_ON = "aria-pressed:bg-primary aria-pressed:text-primary-foreground hover:aria-pressed:bg-primary/90 " + - "hover:aria-pressed:text-primary-foreground dark:aria-pressed:bg-primary dark:hover:aria-pressed:bg-primary/90"; - /** A kind not yet in the document: a grey fill, so each pill reads apart from its neighbours on the white card. */ const ADD_PILL = "border-transparent bg-border text-foreground hover:bg-border/70"; @@ -93,7 +88,6 @@ export interface AnalysisCtx { openWorkspaceSave: () => void; removeCohort: (name: string) => void; removePartitionNamed: (name: string) => void; - removeViews: (drop: (v: any, i: number) => boolean) => void; renamePartitionRow: (name: string, from: string, to: string) => void; result: AnalysisResult | null; saved: Record; @@ -377,7 +371,7 @@ export function DocDialog({ ctx }: { ctx: AnalysisCtx }) { } export function ViewColumn({ ctx }: { ctx: AnalysisCtx }) { - const { VIEW_KINDS, addView, busy, doc, docDialog, docHistory, explore, fetchExplore, goTab, openWorkspaceSave, removeViews, result, setCdFocus, spaces, viewCtx, viewIds } = ctx; + const { VIEW_KINDS, addView, busy, doc, docDialog, docHistory, explore, fetchExplore, goTab, openWorkspaceSave, result, setCdFocus, spaces, viewCtx, viewIds } = ctx; return <>

{(busy || result || (doc.views || []).length > 0) && ( @@ -448,13 +442,7 @@ export function ViewColumn({ ctx }: { ctx: AnalysisCtx }) { {(doc.views || []).length} of 6 in this document {VIEW_KINDS.map(({ kind, label, why, loading }) => { - // a kind already in the document is selected; clicking it again removes it - const present = (doc.views || []).filter((v: any) => v.kind === kind).length; - if (present) return ( - - ); + // each click adds another view, so a document can hold several of one kind const full = (doc.views || []).length >= 6; // with no cohorts yet there is no study to ask about: the view's cohorts decide later const known = Object.keys(doc.cohorts || {}).length > 0; @@ -463,7 +451,7 @@ export function ViewColumn({ ctx }: { ctx: AnalysisCtx }) { : waiting ? "loading this study's catalog — a moment" : (known && why ? why() : null); return ( - diff --git a/app/lab/page.tsx b/app/lab/page.tsx index 80ded767..4a3ccd0f 100644 --- a/app/lab/page.tsx +++ b/app/lab/page.tsx @@ -11,7 +11,7 @@ import { AgentToolsMenu } from "../_agent/AgentToolsMenu.tsx"; import { useCustomDataState } from "./custom-data-panel.tsx"; import CustomDataDialog, { type CustomDataFocus } from "./custom-data-dialog.tsx"; import CustomDataManager from "./custom-data-panel.tsx"; -import LoginButton from "./login-button.tsx"; +import { SiteHeader } from "../_ui/site-header.tsx"; import { upgradeDocument, type DocUpgrade } from "../../lib/doc-version.ts"; import type { OnCustomData } from "./pills"; import { requestLogin, referencedDatasets, bindUploads, unresolvedUploads } from "./custom-data-client.ts"; @@ -47,7 +47,8 @@ import { Input } from "../_ui/input"; import { PAGE_TAB } from "../_ui/toolbar.ts"; const TABS = ["explore", "cohorts", "analysis", "examples"] as const; -const TAB_LABELS: [typeof TABS[number], string][] = [["explore", "Explore"], ["cohorts", "Saved"], ["analysis", "Analysis"], ["examples", "Examples"]]; +/** the tabs the lab's own bar shows; Examples opens from the site header */ +const TAB_LABELS: [typeof TABS[number], string][] = [["explore", "Explore"], ["cohorts", "Saved"], ["analysis", "Analysis"]]; type SearchParams = { [key: string]: string | string[] | undefined }; @@ -357,7 +358,7 @@ export default function AnalysisPage() { const viewOptions = useViewOptions({ cat, catLoading, doc, explore, exploreBusy, result, trackAttrs, trackAttrsLoading, studies }); const { VIEW_KINDS, assayOptions, attrsLoading, cantDrawCohort, catalogAttrs, contProfile, drawsOwnHead, geneChoices, numericAttrs, profileOptions, profileCatalog, viewAttrs, viewEvents, viewGenes } = viewOptions; const viewEdits = useViewEdits({ VIEW_KINDS, assayOptions, busy, cantDrawCohort, catalogAttrs, contProfile, doc, docs, numericAttrs, profileOptions, result, setChooser, setToast, study, viewAttrs, viewEvents, viewGenes, viewIds }); - const { addCohortToView, addView, cohortChoice, docView, emptyViewHint, maxCohortsOf, moveView, ocView, openCohortChooser, patchView, removeView, removeViews, setViewAttrs, setViewCohorts, setViewGenes, updateView, updateViewLocal, viewTitle } = viewEdits; + const { addCohortToView, addView, cohortChoice, docView, emptyViewHint, maxCohortsOf, moveView, ocView, openCohortChooser, patchView, removeView, setViewAttrs, setViewCohorts, setViewGenes, updateView, updateViewLocal, viewTitle } = viewEdits; // ---- what the sections read ------------------------------------------------------------ /** what Explore's sections read and the edits they can make (app/lab/explore-sections.tsx) */ @@ -365,7 +366,7 @@ export default function AnalysisPage() { /** what every view card reads and the edits it can make (app/lab/view-cards.tsx) */ const viewCtx = { VIEW_KINDS, docHistory, study, assayOptions, attrsLoading, busy, cat, catLoading, catalogAttrs, cohortChoice, cohortFromSelection, contracts, doc, docView, drawsOwnHead, explore, featureFromSelection, maxCohortsOf, menuOpenRef, moveView, noteEdit, ocView, openCohortChooser, openGenes, patchView, pendingKeys, profileOptions, profileCatalog, removeView, setContracts, setViewAttrs, setViewCohorts, trackAttrs, trackAttrsLoading, updateView, updateViewLocal, viewEvents, viewIds }; /** what the Analysis tab's sections read and the actions they take (app/lab/analysis-sections.tsx) */ - const analysisCtx: AnalysisCtx = { lockCohorts, unlockCohort, unlockAll, canSave, analysisStudies, requestLogin, signIn: (!customState.enabled ? "off" : !loggedIn ? "locked" : "ready") as SignInState, VIEW_KINDS, addView, docHistory, applyText, busy, catalogAttrs, clearAnalysis, doc, docDialog, docText, docs, explore, fetchExplore, cohortCells, cohortSaveState, cohortSource, openCohortCells, emptyViewHint, genesFor, goTab, loggedIn, menuOpenRef, moveView, openCohortChooser, openCohortEdit, openCustomManager, openWorkspaceSave, removeCohort, removePartitionNamed, removeView, removeViews, renamePartitionRow, result, saveCohortToLibrary, saved, setCdFocus, setGenesFor, setPartitionDef, setUpgradeNote, setViewGenes, showClauseData, spaces, splitCohortBy, splitsFlat, studyNames, tab, updateFromSaved, upgradeNote, viewCtx, viewIds, viewTitle }; + const analysisCtx: AnalysisCtx = { lockCohorts, unlockCohort, unlockAll, canSave, analysisStudies, requestLogin, signIn: (!customState.enabled ? "off" : !loggedIn ? "locked" : "ready") as SignInState, VIEW_KINDS, addView, docHistory, applyText, busy, catalogAttrs, clearAnalysis, doc, docDialog, docText, docs, explore, fetchExplore, cohortCells, cohortSaveState, cohortSource, openCohortCells, emptyViewHint, genesFor, goTab, loggedIn, menuOpenRef, moveView, openCohortChooser, openCohortEdit, openCustomManager, openWorkspaceSave, removeCohort, removePartitionNamed, removeView, renamePartitionRow, result, saveCohortToLibrary, saved, setCdFocus, setGenesFor, setPartitionDef, setUpgradeNote, setViewGenes, showClauseData, spaces, splitCohortBy, splitsFlat, studyNames, tab, updateFromSaved, upgradeNote, viewCtx, viewIds, viewTitle }; // what the page offers the researcher's AI assistant (app/lab/agent-tools.ts), current every render const webmcp = useLabTools({ doc, result, busy, error, tab, study, savedAs: analysisSaved ? doc.title || null : null, @@ -385,18 +386,12 @@ export default function AnalysisPage() { return ( <> -
-

cbio-lab

- analysis documents — cohorts and views over one declarative object - - paper reproductions - how we verify - hypothesis campaigns - rationale + l === "examples" ? (openTab(l), true) : false}> - -
+
{/* Suggestions for every view's gene editor: the most-altered genes this exploration found. Typing any symbol is still allowed — the list is a diff --git a/app/lab/use-view-edits.ts b/app/lab/use-view-edits.ts index bcac1e72..dcd1f3a4 100644 --- a/app/lab/use-view-edits.ts +++ b/app/lab/use-view-edits.ts @@ -62,13 +62,7 @@ export function useViewEdits(page: ViewEditsPage) { * and the cohort definitions it draws from, neither of which removal * touches — so this splices `result.views` locally instead of re-running * everything, the same way moveView (below) already does. */ - const removeViews = (drop: (v: any, i: number) => boolean) => { - const all = doc.views || []; - const keep = all.map((v: any, j: number) => !drop(v, j)); - const views = all.filter((_: any, j: number) => keep[j]); - docs.set({ ...doc, views }); - }; - const removeView = (i: number) => removeViews((_, j) => j === i); + const removeView = (i: number) => docs.set({ ...doc, views: (doc.views || []).filter((_: any, j: number) => j !== i) }); /** Swap a view with its neighbour. Order is cosmetic — the computed result for * each view is unchanged, so this reorders `result.views` too rather than @@ -255,5 +249,5 @@ export function useViewEdits(page: ViewEditsPage) { /** the view at index vi, read as an oncoprint view (its editor only renders there) */ const ocView = (vi: number): OncoprintView => ((doc.views || [])[vi] || {}) as OncoprintView; - return { removeViews, removeView, moveView, addView, patchView, updateView, updateViewLocal, setViewCohorts, setViewAttrs, setViewGenes, docView, partitionShapes, cohortChoice, ocView, viewTitle, maxCohortsOf, emptyViewHint, cohortTarget, openCohortChooser, addCohortToView }; + return { removeView, moveView, addView, patchView, updateView, updateViewLocal, setViewCohorts, setViewAttrs, setViewGenes, docView, partitionShapes, cohortChoice, ocView, viewTitle, maxCohortsOf, emptyViewHint, cohortTarget, openCohortChooser, addCohortToView }; } diff --git a/app/portal/PortalTabs.tsx b/app/portal/PortalTabs.tsx index bb0c7cc0..5b62c936 100644 --- a/app/portal/PortalTabs.tsx +++ b/app/portal/PortalTabs.tsx @@ -75,7 +75,6 @@ export function PortalTabs({ doc, tabs, titles, open, onOpen, onDoc, onCounts, c onDoc({ ...doc, views: views.map((v, j) => { const m = idx.indexOf(j); return m >= 0 ? mine[m] : v; }) }); }; const st = state[key]; - const spinning = (k: string) => Object.entries(state).some(([x, s]) => (x === k || x.startsWith(k + " › ")) && s.loading); const chooser = tab && !sub ? tab.chooser : undefined; const chooseFrom = (o: string) => { const next = chooser!.apply(idx.map((i) => views[i]), o); @@ -87,7 +86,7 @@ export function PortalTabs({ doc, tabs, titles, open, onOpen, onDoc, onCounts, c {tabs.map((t) => ( - {t.title}{spinning(t.title) && } + {t.title} ))} @@ -100,7 +99,7 @@ export function PortalTabs({ doc, tabs, titles, open, onOpen, onDoc, onCounts, c {tab.sub.map((t) => ( - {t.title}{spinning(tab.title + " › " + t.title) && } + {t.title} ))} diff --git a/app/portal/comparison/page.tsx b/app/portal/comparison/page.tsx index 6ff2da62..58adc5d3 100644 --- a/app/portal/comparison/page.tsx +++ b/app/portal/comparison/page.tsx @@ -9,7 +9,8 @@ import type { AnalysisDoc, View } from "../../../lib/types"; import { PortalTabs } from "../PortalTabs"; import { useStudyKnown, expressionProfile, type StudyKnown } from "../study-known"; import { arrange, decodeDoc, usePageDoc, type PageLayout } from "../page-doc"; -import { PortalHeader, PortalSummary } from "../portal-chrome.tsx"; +import { PortalSummary, PortalLoading } from "../portal-chrome.tsx"; +import { SiteHeader } from "../../_ui/site-header.tsx"; import { fmtInt } from "../../../lib/chart/format.ts"; const CLINICAL_SHOWN = 8; @@ -42,7 +43,7 @@ export default function ComparisonPage() { const fresh = useMemo(() => linked && known && names.length >= 2 && names.length <= 6 ? comparisonLayout(linked, study, known) : null, [linked, known, study, names.length]); const page = usePageDoc(fresh, linked); const [counts, setCounts] = useState | null>(null); - const shell = (body: React.ReactNode) => <>
{body}
; + const shell = (body: React.ReactNode) => <>
{body}
; if (!linked || !study) return shell(

This page compares the cohorts of an analysis document passed as ?doc= (the lab's own link encoding); none was given.

); if (names.length < 2 || names.length > 6) return shell(

a comparison page compares 2 to 6 cohorts; this analysis has {names.length}

); if (error) return shell(
{error}
); @@ -53,7 +54,7 @@ export default function ComparisonPage() { groups={names} counts={counts} left={fresh?.left || []} /> {page.note &&

{page.note}

} {!ready - ? <>
+ ? : setCounts((o) => o || c)} />} diff --git a/app/portal/portal-chrome.tsx b/app/portal/portal-chrome.tsx index 1074bb11..00296c5a 100644 --- a/app/portal/portal-chrome.tsx +++ b/app/portal/portal-chrome.tsx @@ -1,24 +1,10 @@ "use client"; -// The chrome the portal pages share: the app's header (the lab's own), the summary card over the +// The chrome the portal pages share: the summary card over the // tabs (the study, the query, the groups and their sizes). import { SeriesSwatch } from "../lab/view-chrome.tsx"; import { Badge } from "../_ui/badge"; import { fmtInt, fmtPct } from "../../lib/chart/format.ts"; -/** the app's header, as the lab draws it, naming which page this is; the name goes home */ -export function PortalHeader({ page }: { page: string }) { - return ( -
-

cbio-lab

- {page} - - studies - analysis - how we verify -
- ); -} - /** one group's swatch, name and size; the swatch is the colour the figures draw it in */ function GroupChip({ name, i, n }: { name: string; i: number; n?: number }) { return ( @@ -86,3 +72,23 @@ export function PortalSummary({ eyebrow, title, study, studyTitle, detail, genes
); } + +/** + * A portal page while it learns what its studies hold (which decides its tabs): shaped like what + * replaces it — the tab row, then a view card's title, toolbar and figure — so the page does not + * change shape on the way to its first view. + */ +export function PortalLoading() { + return ( +
+
+ {[6, 9.5, 8, 3.5, 5.5, 7, 9, 6.5].map((w, i) => )} +
+
+
+
+
+
+
+ ); +} diff --git a/app/portal/results/page.tsx b/app/portal/results/page.tsx index 512b8d9e..7d88929f 100644 --- a/app/portal/results/page.tsx +++ b/app/portal/results/page.tsx @@ -17,7 +17,8 @@ import { Pencil } from "lucide-react"; import CohortPills from "../../lab/pills.tsx"; import { usePublicEnv } from "../../_env/public-env-context.tsx"; import { fmtInt } from "../../../lib/chart/format.ts"; -import { PortalHeader, PortalSummary } from "../portal-chrome.tsx"; +import { PortalSummary, PortalLoading } from "../portal-chrome.tsx"; +import { SiteHeader } from "../../_ui/site-header.tsx"; import { RESULTS_MAX_GENES, readResultsTarget, studiesProblem } from "../query-editor.tsx"; import { QueryFlow } from "../../_home/query-flow.tsx"; import { checkGeneQuery } from "../../../lib/gene-query.ts"; @@ -47,7 +48,7 @@ export default function ResultsPage() { const page = usePageDoc(fresh, linked); const [counts, setCounts] = useState | null>(null); const [editing, setEditing] = useState(false); - const shell = (body: React.ReactNode) => <>
{body}
; + const shell = (body: React.ReactNode) => <>
{body}
; // a query that is not one yet, or cannot be read, opens the homepage's flow at the step it needs const flow = (note?: string | null) => shell(<> {note &&
{note}
} @@ -89,7 +90,7 @@ export default function ResultsPage() { {editing && } {page.note &&

{page.note}

} {!ready - ? <>
+ ? : setCounts((o) => o || c)} />} diff --git a/app/rationale/page.tsx b/app/rationale/page.tsx index 8ef57b1a..efb01c6a 100644 --- a/app/rationale/page.tsx +++ b/app/rationale/page.tsx @@ -9,6 +9,7 @@ import { join } from "node:path"; import { marked } from "marked"; import MermaidBlock from "./mermaid-block.tsx"; import { PageTools } from "../_agent/PageTools.tsx"; +import { SiteHeader } from "../_ui/site-header.tsx"; export const metadata = { title: "Rationale — cbio-lab" }; @@ -23,19 +24,17 @@ export default function RationalePage() { // parts alternates: [markdown, mermaidSource, markdown, mermaidSource, ...] return ( -
- - {parts.map((part, i) => - i % 2 === 1 - ? - : part.trim() - ?
- : null - )} -
+ <> + +
+ {parts.map((part, i) => + i % 2 === 1 + ? + : part.trim() + ?
+ : null + )} +
+ ); } diff --git a/app/reproductions/page.tsx b/app/reproductions/page.tsx index cadc3a2d..7ab1950f 100644 --- a/app/reproductions/page.tsx +++ b/app/reproductions/page.tsx @@ -9,6 +9,7 @@ // analysis rather than living only on this listing page. import { REPRODUCTIONS, type PaperReproduction } from "../../lib/reproductions.ts"; import { PageTools } from "../_agent/PageTools.tsx"; +import { SiteHeader } from "../_ui/site-header.tsx"; export const metadata = { title: "Paper reproductions — cbio-lab" }; @@ -27,74 +28,70 @@ export default function ReproductionsPage() { ); return ( -
- + <> + +
-
-

Paper reproductions

-

- Published cancer-genomics findings, reproduced against the live database using this - tool's own analysis-document DSL — no source changes, just the DSL as it stands today. - Every number below was run live while this page's content was written; a rerun may - drift slightly as the warehouse reloads, which each view's own caveat discloses rather - than something this page freezes in place. -

-

- Reassessed on 28 September 2026, after eight new view kinds landed. Three papers moved: - Jonsson's 1p/19q co-deletion, blocked until now, reproduces from the copy-number segments, - alongside its cell-cycle and recurrence findings; Razavi's endocrine-resistance genes - (ESR1, NF1, ERBB2) reproduce against the closest proxy the study records; and Jee's - tissue-versus-plasma finding is compared within patients. The rest were checked and are - unchanged. The sarcoma study still has no copy-number data, and gastric cancer's - immunotherapy outcomes are too sparse and oddly coded. Endometrial survival is simply not - significant on this release. The prostate race analysis still needs a comparison held - within a second attribute (stage), which none of the new views provides. -

-
-
{REPRODUCTIONS.length}papers
-
{counts.full || 0}fully reproduced
-
{counts.partial || 0}partial
-
{counts.bugfound || 0}bug found
+
+

Paper reproductions

+

+ Published cancer-genomics findings, reproduced against the live database using this + tool's own analysis-document DSL — no source changes, just the DSL as it stands today. + Every number below was run live while this page's content was written; a rerun may + drift slightly as the warehouse reloads, which each view's own caveat discloses rather + than something this page freezes in place. +

+

+ Reassessed on 28 September 2026, after eight new view kinds landed. Three papers moved: + Jonsson's 1p/19q co-deletion, blocked until now, reproduces from the copy-number segments, + alongside its cell-cycle and recurrence findings; Razavi's endocrine-resistance genes + (ESR1, NF1, ERBB2) reproduce against the closest proxy the study records; and Jee's + tissue-versus-plasma finding is compared within patients. The rest were checked and are + unchanged. The sarcoma study still has no copy-number data, and gastric cancer's + immunotherapy outcomes are too sparse and oddly coded. Endometrial survival is simply not + significant on this release. The prostate race analysis still needs a comparison held + within a second attribute (stage), which none of the new views provides. +

+
+
{REPRODUCTIONS.length}papers
+
{counts.full || 0}fully reproduced
+
{counts.partial || 0}partial
+
{counts.bugfound || 0}bug found
+
-
-
- {REPRODUCTIONS.map((rep) => ( -
-
-

{rep.title}

- {BADGE_LABEL[rep.badge]} -
-
- {rep.citation} · {rep.studyIds.join(", ")} -
-

{rep.summary}

-
    - {rep.findings.map((f, i) => ( -
  • - {f.claim} - {f.result} -
  • - ))} -
-
- Open in Lab → +
+ {REPRODUCTIONS.map((rep) => ( +
+
+

{rep.title}

+ {BADGE_LABEL[rep.badge]} +
+
+ {rep.citation} · {rep.studyIds.join(", ")} +
+

{rep.summary}

+
    + {rep.findings.map((f, i) => ( +
  • + {f.claim} + {f.result} +
  • + ))} +
+
-
- ))} -
+ ))} +
-
- Every document above is a real, runnable Analysis document — its own notes (visible once - opened) explain how each figure connects back to the paper's claim, not just this page's - summary of it. -
-
+
+ Every document above is a real, runnable Analysis document — its own notes (visible once + opened) explain how each figure connects back to the paper's claim, not just this page's + summary of it. +
+
+ ); } diff --git a/app/verification/page.tsx b/app/verification/page.tsx index bc4d69e8..fad71a75 100644 --- a/app/verification/page.tsx +++ b/app/verification/page.tsx @@ -5,6 +5,7 @@ // fixtures the statistics tests read, and from lib/verification.ts — so the page reports the last // run, and says when that was, instead of restating claims by hand. import { PageTools } from "../_agent/PageTools.tsx"; +import { SiteHeader } from "../_ui/site-header.tsx"; import { STAT_CHECKS, COXNET_CHECK, JUDGMENT_CALLS } from "../../lib/verification.ts"; import { VERIFIED } from "../../lib/method.ts"; import { REPRODUCTIONS } from "../../lib/reproductions.ts"; @@ -75,327 +76,323 @@ export default function VerificationPage() { } return ( -
- + <> + +
-
-

How we check the numbers

-

- This page is the report of an automated verification suite: a set of programs that check - this tool's numbers against things we did not write, namely cBioPortal and the standard - statistics libraries. It is built to run in continuous integration (CI): automatically, on every - proposed change to the code, blocking any change that fails a check and publishing each run's - results as a report. The results below are a copy of that report saved with the code, from the - run on {when(lastRun)}. For the most recent run, see{" "} - the latest CI runs on master (on GitHub; it opens only for people with access - to the code). -

-

- Why this matters: much of this tool's code was written with an AI assistant. That is a reason to - check its numbers, not to trust them, so nothing here asks you to trust the code. Every check - compares against an independent source, every check can be rerun by anyone with the same access, - and the checks have themselves been tested by planting bugs in the code and confirming each bug is - caught. -

-
-
{parity.agree}/{parity.total}counts match cBioPortal
-
{logic.pass}/{logic.total}cohort rules hold
-
{STAT_CHECKS.length + 1}groups of statistics match the standard libraries
-
{mutants.caught}/{mutants.total}planted bugs caught
-
{repro.full}/{repro.total}papers fully reproduced (checked by hand, not by the suite)
+
+

How we check the numbers

+

+ This page is the report of an automated verification suite: a set of programs that check + this tool's numbers against things we did not write, namely cBioPortal and the standard + statistics libraries. It is built to run in continuous integration (CI): automatically, on every + proposed change to the code, blocking any change that fails a check and publishing each run's + results as a report. The results below are a copy of that report saved with the code, from the + run on {when(lastRun)}. For the most recent run, see{" "} + the latest CI runs on master (on GitHub; it opens only for people with access + to the code). +

+

+ Why this matters: much of this tool's code was written with an AI assistant. That is a reason to + check its numbers, not to trust them, so nothing here asks you to trust the code. Every check + compares against an independent source, every check can be rerun by anyone with the same access, + and the checks have themselves been tested by planting bugs in the code and confirming each bug is + caught. +

+
+
{parity.agree}/{parity.total}counts match cBioPortal
+
{logic.pass}/{logic.total}cohort rules hold
+
{STAT_CHECKS.length + 1}groups of statistics match the standard libraries
+
{mutants.caught}/{mutants.total}planted bugs caught
+
{repro.full}/{repro.total}papers fully reproduced (checked by hand, not by the suite)
+
-
- + -
-

How the suite runs, and what it depends on

-

- The suite has five stages. On every proposed change, CI runs all of them, together with a build of - the app and its end-to-end browser tests, and keeps the report as an artifact of the run. Anyone - can run the same five stages with one command, npm run verify, which - takes about ten minutes and stops with an error if any check fails. The report is written to{" "} - docs/verification/; this page shows the copy saved with the code, - updated by hand from a passing run. Every CI run on master keeps its own report, as a download - named verification-report on its page on GitHub. -

- - - - - - - - - - - - - - -
StageWhat it checksWhat it needs
StatisticsEach statistical method against lifelines, statsmodels and scipy (section 3)Nothing external: no network, no database. The reference answers are saved with the code.
Counts against cBioPortalCohort sizes, gene frequencies, clinical counts and filtered cohorts (section 1)The public cBioPortal API at www.cbioportal.org (no account or key), and the warehouse
Cohort logicAnd / or / not, splits, lines of therapy, held-out halves (section 2)The warehouse, including its OncoKB annotations for driver mutations
Method scriptsEach example analysis's Python script reproduces its numbers (section 4)The warehouse, and Python with pinned versions of the statistics libraries
Planted bugsEach planted bug makes at least one of the checks above fail (section 6)Whatever the stages it reruns need
-

- The warehouse is this tool's own database: a copy of the public studies in cBioPortal, - loaded into ClickHouse, which is the database every analysis in the app runs against. The suite - only reads from it. In CI, the read-only login is supplied as a secret. -

-

The studies the suite uses, chosen because each exercises something that is easy to get wrong:

- - - - {suiteStudies.map((st) => ( - - - - - - ))} - -
StudyUsed byWhy
{st.id}{st.usedBy.join(", ")}{st.why}
-

- One known source of false alarms. The cBioPortal comparison needs both sides to hold the same - release of each study. If cBioPortal updates a study before the warehouse is reloaded, the - comparison fails even though nothing in the code is wrong. That failure is intended: the suite - reports the difference rather than tolerating it, and someone has to check whether the cause is the - data or the code. -

-
+
+

How the suite runs, and what it depends on

+

+ The suite has five stages. On every proposed change, CI runs all of them, together with a build of + the app and its end-to-end browser tests, and keeps the report as an artifact of the run. Anyone + can run the same five stages with one command, npm run verify, which + takes about ten minutes and stops with an error if any check fails. The report is written to{" "} + docs/verification/; this page shows the copy saved with the code, + updated by hand from a passing run. Every CI run on master keeps its own report, as a download + named verification-report on its page on GitHub. +

+ + + + + + + + + + + + + + +
StageWhat it checksWhat it needs
StatisticsEach statistical method against lifelines, statsmodels and scipy (section 3)Nothing external: no network, no database. The reference answers are saved with the code.
Counts against cBioPortalCohort sizes, gene frequencies, clinical counts and filtered cohorts (section 1)The public cBioPortal API at www.cbioportal.org (no account or key), and the warehouse
Cohort logicAnd / or / not, splits, lines of therapy, held-out halves (section 2)The warehouse, including its OncoKB annotations for driver mutations
Method scriptsEach example analysis's Python script reproduces its numbers (section 4)The warehouse, and Python with pinned versions of the statistics libraries
Planted bugsEach planted bug makes at least one of the checks above fail (section 6)Whatever the stages it reruns need
+

+ The warehouse is this tool's own database: a copy of the public studies in cBioPortal, + loaded into ClickHouse, which is the database every analysis in the app runs against. The suite + only reads from it. In CI, the read-only login is supplied as a secret. +

+

The studies the suite uses, chosen because each exercises something that is easy to get wrong:

+ + + + {suiteStudies.map((st) => ( + + + + + + ))} + +
StudyUsed byWhy
{st.id}{st.usedBy.join(", ")}{st.why}
+

+ One known source of false alarms. The cBioPortal comparison needs both sides to hold the same + release of each study. If cBioPortal updates a study before the warehouse is reloaded, the + comparison fails even though nothing in the code is wrong. That failure is intended: the suite + reports the difference rather than tolerating it, and someone has to check whether the cause is the + data or the code. +

+
-
-

1 Are the patient and mutation counts the same as cBioPortal's?

-

- cBioPortal has been used and checked by the cancer genomics community for over a decade, and it was - built by a different team on separate code. We ask both tools the same questions about the same - studies and require exactly the same answer. A count either matches or it does not, so no - tolerance is allowed. The questions cover cohort sizes, how often each top gene is mutated, - amplified, deleted or fused (both the number of altered samples and the number of samples - sequenced for that gene), clinical counts, and filtered cohorts such as “patients with a TP53 - mutation”. -

- - - - {byStudy.map((s) => ( - - - - - - - ))} - -
StudyWhy it was chosenComparisonsAgree
{s.study}{STUDY_NOTE[s.study] || ""}{s.total} {s.agree}
-
- All {parity.total} comparisons - - +
+

1 Are the patient and mutation counts the same as cBioPortal's?

+

+ cBioPortal has been used and checked by the cancer genomics community for over a decade, and it was + built by a different team on separate code. We ask both tools the same questions about the same + studies and require exactly the same answer. A count either matches or it does not, so no + tolerance is allowed. The questions cover cohort sizes, how often each top gene is mutated, + amplified, deleted or fused (both the number of altered samples and the number of samples + sequenced for that gene), clinical counts, and filtered cohorts such as “patients with a TP53 + mutation”. +

+
StudyWhatThis toolcBioPortal
+ - {parity.checks.map((c, i) => ( - - - - - - + {byStudy.map((s) => ( + + + + + ))}
StudyWhy it was chosenComparisonsAgree
{c.study}{PARITY_GROUPS[c.group] || c.group}: {c.what}{c.ours}{c.portal}
{s.study}{STUDY_NOTE[s.study] || ""}{s.total} {s.agree}
-
-

Last run {when(parity.ranAt)} against {parity.reference}. Frequencies are shown as altered / sequenced for that gene.

-
+
+ All {parity.total} comparisons + + + + {parity.checks.map((c, i) => ( + + + + + + + + ))} + +
StudyWhatThis toolcBioPortal
{c.study}{PARITY_GROUPS[c.group] || c.group}: {c.what}{c.ours}{c.portal}
+
+

Last run {when(parity.ranAt)} against {parity.reference}. Frequencies are shown as altered / sequenced for that gene.

+ -
-

2 Does a cohort contain the patients its definition says?

-

- No reference program can tell us who belongs in “never-smokers with a TP53 mutation who - received immunotherapy”. What we can test is logic that must hold whatever the right answer - is. For example, “A and B” must be exactly the patients found in both A and B, and a - cohort plus its opposite must make up the whole study. The tool computes each side separately on - real data, and the check compares the actual patient lists, not just the totals. -

-
    - {claims.map((c) => ( -
  • - r.pass)} /> - {c.claim} - {c.runs.map((r, i) => {r.study} · {r.detail})} -
  • - ))} -
-

Last run {when(logic.ranAt)}.

-
+
+

2 Does a cohort contain the patients its definition says?

+

+ No reference program can tell us who belongs in “never-smokers with a TP53 mutation who + received immunotherapy”. What we can test is logic that must hold whatever the right answer + is. For example, “A and B” must be exactly the patients found in both A and B, and a + cohort plus its opposite must make up the whole study. The tool computes each side separately on + real data, and the check compares the actual patient lists, not just the totals. +

+
    + {claims.map((c) => ( +
  • + r.pass)} /> + {c.claim} + {c.runs.map((r, i) => {r.study} · {r.detail})} +
  • + ))} +
+

Last run {when(logic.ranAt)}.

+
-
-

3 Are the statistics computed correctly?

-

- Every statistical method the tool reports is checked against the library statisticians already - use for it: lifelines, statsmodels and scipy, which are widely used in published analyses. - Both are given the same datasets ({statCases.toLocaleString()} test cases in all). The test data - is chosen to be awkward: tied death times, empty groups, very small and very large counts, patients - who enter the study late. Our numbers must match the library's to the precision shown below, - and the check runs before every release. -

- - - - {STAT_CHECKS.map((c) => ( - - - - - - +
+

3 Are the statistics computed correctly?

+

+ Every statistical method the tool reports is checked against the library statisticians already + use for it: lifelines, statsmodels and scipy, which are widely used in published analyses. + Both are given the same datasets ({statCases.toLocaleString()} test cases in all). The test data + is chosen to be awkward: tied death times, empty groups, very small and very large counts, patients + who enter the study late. Our numbers must match the library's to the precision shown below, + and the check runs before every release. +

+
MethodUsed inChecked againstMust agree toCases
{c.method}{c.usedFor}{c.keys.map((k) => VERIFIED[k].library + " " + VERIFIED[k].version).filter((x, i, a) => a.indexOf(x) === i).join(", ")}{c.agreement}{caseCount(c.fixture, c.caseKeys)}
+ + + {STAT_CHECKS.map((c) => ( + + + + + + + + ))} + + + + + + - ))} - - - - - - - - -
MethodUsed inChecked againstMust agree toCases
{c.method}{c.usedFor}{c.keys.map((k) => VERIFIED[k].library + " " + VERIFIED[k].version).filter((x, i, a) => a.indexOf(x) === i).join(", ")}{c.agreement}{caseCount(c.fixture, c.caseKeys)}
{COXNET_CHECK.method}{COXNET_CHECK.usedFor}{COXNET_CHECK.reference}{COXNET_CHECK.agreement}{caseCount(COXNET_CHECK.fixture, COXNET_CHECK.caseKeys)}
{COXNET_CHECK.method}{COXNET_CHECK.usedFor}{COXNET_CHECK.reference}{COXNET_CHECK.agreement}{caseCount(COXNET_CHECK.fixture, COXNET_CHECK.caseKeys)}
-
+ + + -
-

4 Can I check a specific result myself?

-

- Yes. Every result in the analysis app has a How this was computed panel. It lists each - step from the database rows to the number on screen, the exact database queries that ran, and a - short Python script that recomputes the result with the standard libraries. You can send that - script to a statistician or run it yourself. -

-

- The scripts are checked too. Before every release, the analyses in the app's Examples gallery - ({EXAMPLES.length} of them) are run, and every script must reproduce the numbers the app showed. - If the code changes how a result is computed but the script is not updated to match, the check - fails. -

-
+
+

4 Can I check a specific result myself?

+

+ Yes. Every result in the analysis app has a How this was computed panel. It lists each + step from the database rows to the number on screen, the exact database queries that ran, and a + short Python script that recomputes the result with the standard libraries. You can send that + script to a statistician or run it yourself. +

+

+ The scripts are checked too. Before every release, the analyses in the app's Examples gallery + ({EXAMPLES.length} of them) are run, and every script must reproduce the numbers the app showed. + If the code changes how a result is computed but the script is not updated to match, the check + fails. +

+
-
-

5 Does it find what published papers found?

-

- We took {repro.total} published cancer genomics papers whose data is in cBioPortal and re-derived - their main findings from scratch with this tool. {repro.full} reproduce fully, {repro.partial}{" "} - partly, and the rest are blocked by data the study does not include. Each one opens in the app - as a runnable analysis, with the paper's claim written beside our result. One reproduction - found a bug in this tool: a sarcoma study with no copy-number data reported MDM2 amplification - as 0% instead of saying the data was missing. That bug has been fixed. -

-

See the paper reproductions →

-
+
+

5 Does it find what published papers found?

+

+ We took {repro.total} published cancer genomics papers whose data is in cBioPortal and re-derived + their main findings from scratch with this tool. {repro.full} reproduce fully, {repro.partial}{" "} + partly, and the rest are blocked by data the study does not include. Each one opens in the app + as a runnable analysis, with the paper's claim written beside our result. One reproduction + found a bug in this tool: a sarcoma study with no copy-number data reported MDM2 amplification + as 0% instead of saying the data was missing. That bug has been fixed. +

+

See the paper reproductions →

+
-
-

6 Would these checks notice a mistake?

-

- A check that always passes proves nothing. To show these checks would notice a real error, we - planted {mutants.total} realistic bugs in the code, one at a time, and reran the checks after - each. These are bugs that produce believable but wrong numbers rather than crashes. {mutants.caught}{" "} - of {mutants.total} were caught. - {shipped && <> One is a real bug this project once shipped: {shipped.replace(/^Shipped once: /, "")}{" "} - The cBioPortal comparison now catches it.} -

- - - - {mutants.mutants.map((m) => ( - - - - - - ))} - -
The planted bugResultWhat the check reported
{m.effect}{m.history && shipped once}{m.caught ? "caught" : "missed"} {GUARD_LABEL[m.guard]}{m.failures ? m.failures + " failing · " : ""}{m.evidence.replace(/^(FAIL |not ok \d+ - )/, "")}
-

Last run {when(mutants.ranAt)}.

-
+
+

6 Would these checks notice a mistake?

+

+ A check that always passes proves nothing. To show these checks would notice a real error, we + planted {mutants.total} realistic bugs in the code, one at a time, and reran the checks after + each. These are bugs that produce believable but wrong numbers rather than crashes. {mutants.caught}{" "} + of {mutants.total} were caught. + {shipped && <> One is a real bug this project once shipped: {shipped.replace(/^Shipped once: /, "")}{" "} + The cBioPortal comparison now catches it.} +

+ + + + {mutants.mutants.map((m) => ( + + + + + + ))} + +
The planted bugResultWhat the check reported
{m.effect}{m.history && shipped once}{m.caught ? "caught" : "missed"} {GUARD_LABEL[m.guard]}{m.failures ? m.failures + " failing · " : ""}{m.evidence.replace(/^(FAIL |not ok \d+ - )/, "")}
+

Last run {when(mutants.ranAt)}.

+
-
-

What these checks do not show

-
    -
  • - Matching cBioPortal shows we compute the same thing from the same data, not that the data - is right, and not that cBioPortal is right. We found an example while building this page. - In blca_mskcc_solit_2014, one copy-number dataset is labeled as - whole-exome even though the samples were sequenced on a panel. Both tools therefore count each - sample twice: copy-number frequencies are divided by 218 for a 109-sample study - {blcaShared && <> (for example, {blcaShared.what}: {blcaShared.ours} in both tools)}, so they - appear half as common as they are. We have not changed this tool to differ from cBioPortal: the fix - belongs in the study's data, where it corrects both. -
  • -
  • - The checks cover the studies, genes and questions listed above, not every possible query. - They were chosen to include the hard cases (several gene panels in one study, two copy-number - datasets, timelines), but an untested combination could still be wrong. -
  • -
  • - The statistics checks show the arithmetic matches the reference library. They do not show - that a method is the right choice for your question. The choices we made are listed below so a - statistician can review them. -
  • -
  • - No software can check study design. Comparing patients who reached a third line of - therapy with those who did not builds in survival bias (to reach a third line, a patient has to - live long enough). The tool attaches caveats like this to its results where it can detect them, - but interpreting a result is still the researcher's job. -
  • -
  • - The paper reproductions are rerun by hand when they are revised, not automatically. As - the database is updated, a rerun may give slightly different numbers. -
  • -
-
+
+

What these checks do not show

+
    +
  • + Matching cBioPortal shows we compute the same thing from the same data, not that the data + is right, and not that cBioPortal is right. We found an example while building this page. + In blca_mskcc_solit_2014, one copy-number dataset is labeled as + whole-exome even though the samples were sequenced on a panel. Both tools therefore count each + sample twice: copy-number frequencies are divided by 218 for a 109-sample study + {blcaShared && <> (for example, {blcaShared.what}: {blcaShared.ours} in both tools)}, so they + appear half as common as they are. We have not changed this tool to differ from cBioPortal: the fix + belongs in the study's data, where it corrects both. +
  • +
  • + The checks cover the studies, genes and questions listed above, not every possible query. + They were chosen to include the hard cases (several gene panels in one study, two copy-number + datasets, timelines), but an untested combination could still be wrong. +
  • +
  • + The statistics checks show the arithmetic matches the reference library. They do not show + that a method is the right choice for your question. The choices we made are listed below so a + statistician can review them. +
  • +
  • + No software can check study design. Comparing patients who reached a third line of + therapy with those who did not builds in survival bias (to reach a third line, a patient has to + live long enough). The tool attaches caveats like this to its results where it can detect them, + but interpreting a result is still the researcher's job. +
  • +
  • + The paper reproductions are rerun by hand when they are revised, not automatically. As + the database is updated, a rerun may give slightly different numbers. +
  • +
+
-
-

Choices a statistician should review

-

Where there is more than one defensible method, this is the one the tool uses. Each is pinned by the checks in section 3.

-
- {JUDGMENT_CALLS.map((j) => ( -
{j.choice}
{j.why}
- ))} -
-
+
+

Choices a statistician should review

+

Where there is more than one defensible method, this is the one the tool uses. Each is pinned by the checks in section 3.

+
+ {JUDGMENT_CALLS.map((j) => ( +
{j.choice}
{j.why}
+ ))} +
+
-
-

Running the suite

-

- With network access, the warehouse login in .env.local, and{" "} - uv for Python: -

-
{[
-          "npm run verify          # every stage below; writes the report to docs/verification/",
-          "",
-          "npm test                # statistics (no database or network needed)",
-          "npm run check:portal    # counts against cBioPortal",
-          "npm run check:cohorts   # cohort logic",
-          "npm run check:methods   # every example's Python script reproduces its numbers",
-          "npm run check:mutants   # plant each bug, confirm a check catches it (about three minutes)",
-        ].join("\n")}
-

- The reference answers for the statistics are generated by the Python scripts in{" "} - python/golden/, with library versions pinned, so a statistician can - regenerate them independently. -

-
-
+
+

Running the suite

+

+ With network access, the warehouse login in .env.local, and{" "} + uv for Python: +

+
{[
+            "npm run verify          # every stage below; writes the report to docs/verification/",
+            "",
+            "npm test                # statistics (no database or network needed)",
+            "npm run check:portal    # counts against cBioPortal",
+            "npm run check:cohorts   # cohort logic",
+            "npm run check:methods   # every example's Python script reproduces its numbers",
+            "npm run check:mutants   # plant each bug, confirm a check catches it (about three minutes)",
+          ].join("\n")}
+

+ The reference answers for the statistics are generated by the Python scripts in{" "} + python/golden/, with library versions pinned, so a statistician can + regenerate them independently. +

+
+
+ ); } diff --git a/e2e.cjs b/e2e.cjs index 73876d5a..28ed6f4d 100644 --- a/e2e.cjs +++ b/e2e.cjs @@ -167,7 +167,7 @@ async function main() { page.url() + " status " + rootResp.status()); await page.goto(BASE + "/lab", { waitUntil: "domcontentloaded" }); await page.waitForSelector("[aria-label='lab sections'] [role=tab]", { timeout: 90000 }); - ok(scheme + ": app shell loads", (await page.getByRole("tab").count()) >= 4); + ok(scheme + ": app shell loads", (await page.getByRole("tab").count()) >= 3); if (scheme === "light") { // No credentials anywhere client-visible: check for env var names plus the @@ -207,9 +207,9 @@ async function main() { await page.waitForFunction(() => document.querySelector(".mermaid-diagram svg") !== null, null, { timeout: 30000 }); const svgCount = await page.locator(".mermaid-diagram svg").count(); const fallback = await page.locator(".mermaid-fallback").count(); - const backLink = await page.locator(".rationale-nav a").getAttribute("href"); - rationaleOk = svgCount > 0 && fallback === 0 && backLink === "/lab"; - rationaleInfo = "svg=" + svgCount + " fallback=" + fallback + " backLink=" + backLink; + const here = await page.locator("header a.toplink[aria-current=page]").innerText(); + rationaleOk = svgCount > 0 && fallback === 0 && here === "rationale"; + rationaleInfo = "svg=" + svgCount + " fallback=" + fallback + " header marks=" + here; } catch (e) { rationaleInfo = String(e.message || e).slice(0, 100); } ok("/rationale renders with a live Mermaid diagram", rationaleOk, rationaleInfo); @@ -832,7 +832,7 @@ async function main() { await page.goto(BASE + "/lab?tab=examples&doc=" + enc); await page.waitForTimeout(1800); ok("an explicit ?tab= beats the document's default tab", - (await page.locator("[aria-label='lab sections'] [role=tab][aria-selected=true]").innerText()).trim() === "Examples"); + (await page.locator("header a.toplink[aria-current=page]").innerText()).trim() === "examples"); // Cohorts tab: tick cohorts, submit them together. No per-row "add" links. The example // cohorts live on the Examples page, so two saved cohorts are what get ticked here. @@ -897,18 +897,21 @@ async function main() { studyLines.length > 0 && studyLines.every(s => s && s.text && s.title), studyLines.map(s => s ? s.text : "missing").join(" | ")); - // A kind already in the document shows as a selected pill, and clicking it removes the view. - let pillOn = false, pillOff = false, left = []; + // The view buttons only add: a second click on a kind already in the document adds another of + // it (undone here, so the checks below see the document as it was). + let kinds2 = [], undone = []; try { - const pill = page.locator(".addview button:has-text('Lollipop')"); - pillOn = (await pill.getAttribute("aria-pressed")) === "true"; - await pill.click(); - await page.waitForFunction(() => !document.querySelector(".lp-plot circle.lollipop-head"), null, { timeout: 30000 }); - left = (JSON.parse(await page.locator("#docjson").inputValue()).views || []).map(v => v.kind); - pillOff = (await pill.getAttribute("aria-pressed")) === "false"; + const kinds = () => page.locator("#docjson").inputValue().then((t) => (JSON.parse(t).views || []).map(v => v.kind)); + await page.locator(".addview button:has-text('Lollipop')").click(); + await page.waitForFunction(() => (JSON.parse(document.querySelector("#docjson").value).views || []).filter(v => v.kind === "hotspots").length === 2, null, { timeout: 30000 }); + kinds2 = await kinds(); + await page.locator(".undo-doc").click(); + await page.waitForFunction(() => (JSON.parse(document.querySelector("#docjson").value).views || []).filter(v => v.kind === "hotspots").length === 1, null, { timeout: 30000 }); + undone = await kinds(); } catch { /* left as failed */ } - ok("a selected view pill removes its view when clicked", pillOn && pillOff && !left.includes("hotspots") && left.length > 0, - "selected " + pillOn + " · deselected " + pillOff + " · views now " + (left.join(", ") || "none")); + ok("a view button adds another view of a kind already in the document", + kinds2.filter(k => k === "hotspots").length === 2 && undone.filter(k => k === "hotspots").length === 1, + "after a second click: " + (kinds2.join(", ") || "none") + " · after undo: " + (undone.join(", ") || "none")); // The oncoprint does not go through a resolver, so its provenance is a separate // code path — and the one most likely to be left behind. It is also the one view @@ -990,7 +993,7 @@ async function main() { // The docs gate already proves each one RUNS; this proves the app can // launch them and that the federated views actually paint something. await page.goto(BASE + "/lab"); - await page.getByRole("tab", { name: "Examples", exact: true }).click(); + await page.locator("header a.toplink", { hasText: /^examples$/ }).click(); await page.waitForSelector(".excard", { timeout: 120000 }); const nCards = await page.locator(".excard").count(); const { EXAMPLES } = await import("./lib/dsl-reference.ts"); @@ -1024,7 +1027,7 @@ async function main() { ]; // a multi-view document must render EVERY one of its views, not just the first await page.goto(BASE + "/lab"); - await page.getByRole("tab", { name: "Examples", exact: true }).click(); + await page.locator("header a.toplink", { hasText: /^examples$/ }).click(); await page.waitForSelector(".excard", { timeout: 120000 }); await page.locator(".excard").filter({ hasText: "One cohort, five ways" }).first() .locator("button:has-text('Run')").click(); @@ -1102,7 +1105,7 @@ async function main() { for (const [title, sel, label] of launches) { await page.goto(BASE + "/lab"); - await page.getByRole("tab", { name: "Examples", exact: true }).click(); + await page.locator("header a.toplink", { hasText: /^examples$/ }).click(); await page.waitForSelector(".excard", { timeout: 120000 }); const card = page.locator(".excard").filter({ hasText: title }).first(); await card.locator("button:has-text('Run')").click();