OGUR — UX Design Specification¶
Canonical reference for all frontend work. Before writing any UI code, read the section that covers your area. If a task conflicts with this document, stop and flag it — do not silently deviate. This file is the authoritative source. Build plans describe intent; this file describes constraint.
§1 Purpose¶
This document specifies the information architecture, visual language, component contracts, and entity-page structure for OGUR's frontend. It is read by Claude Code before any frontend task and by human developers before any component work. Decisions here override developer intuition — if something is unspecified, ask before inventing. If something conflicts with this file, this file wins.
Target audience: pharma CI analysts, BD teams, strategy teams at mid-cap pharma/biotech. Primary use context: 1440px+ analyst workstation, single-tenant, no mobile.
§1.5 Two modes — Monitor and Explore¶
The frontend's entry point (<ModeSelector> at /) routes the analyst to one of two modes. They serve distinct intents and the principles in §2 apply differently to each. Read this before designing any new top-level view.
Mode comparison¶
| Monitor | Explore | |
|---|---|---|
| Route | /franchise/:companySlug |
/explore |
| Tile copy | "Watch your franchise. LOE calendar, competitive threats, live signal feed." | "Search the asset landscape. Map a competitive landscape from scratch." |
| Analyst intent | "What changed in my franchise this week, and what does it mean?" | "Build a new competitive landscape — what assets are out there for X?" |
| Scope shape | Pre-seeded landscape_id (e.g. nsclc-001, immunology-001) with a fixed asset list curated up front |
Open-ended, analyst-described — "alternatives to Dupixent for asthma & atopic dermatitis", "Phase 3 KRAS G12C inhibitors", etc. |
| Cadence | Continuous — DB refresh + briefing synthesis on a weekly cron (scheduler not built; batch CLIs today) | One-shot — analyst kicks off a search, refines the thesis, saves a workspace |
| LLM role | Synthesis. Turn this week's new signals into strategic implications, KIQ answers, prediction cards. | Scope capture. Turn natural-language intent into a structured search thesis (target, modalities, phase, indication, geography). |
| Confidence applies to | Synthesized blocks — executive_summary, strategic_implications, KIQ answers, predictions |
Evidence rows surfaced by the search; the thesis itself is editable, not "confident" |
| Provenance applies to | Every claim in the briefing → <SourceChip> to raw signal source |
Every asset row in search results → <SourceChip> to source record |
| Primary surfaces | <BriefingDocument>, <SignalAnalysisCard>, <KIQAnswersSection>, <TimelineScrubber> |
<WorkspaceCard>, <ClarifyingQuestionsPanel>, <AssetCatalogTab>, <ReportDocument> |
Name drift corrected 2026-08-19: this row previously named
<ClarifyingQuestionsGrid>and<AssetCatalog>(the code hasClarifyingQuestionsPanelandAssetCatalogTab) and<ResearchThesisPanel>, which does not exist anywhere infrontend/src— it was specified and never built.<ReportDocument>replaces it here because the served brief is what Explore actually renders today.
Why this distinction matters¶
Monitor is a synthesis product. The whole point is that an analyst can read the briefing in five minutes and walk into a Monday meeting with a defensible point of view. The reasoning chain must be visible — collapsing it behind a chatbot would destroy the product.
Explore is a scoping product. The analyst comes in with rough intent ("I want to know what's out there for X") and needs the LLM to scaffold a search, not answer a question. The chat is the front door to a structured filter object — the LLM never returns synthesized prose. The clarifying-questions grid makes every inferred decision visible and overridable.
Backend implication (current state)¶
The intelligence engine (ogur/engine/pipeline.py) is built for Monitor: it operates on pre-seeded landscapes with a fixed asset list, and ChangeDetector reads from a populated Signal table that was scraped against that asset list. Explore's "real" use case — ad-hoc search across the full multi-million-row signal store — would need a candidate-retrieval stage (vector search + structured pre-filter) in front of the existing pipeline.
Explore is now partly real. Check which surface you are building before reaching for a mock. The line runs between open-ended discovery and a landscape that has already been discovered — not between Explore and Monitor.
| Surface | State | Wire to |
|---|---|---|
| Thesis decomposition | Real — POST /api/explore/decompose, class-aware, fails closed on an unregistered class |
the API |
| Registered landscape classes | Real — GET /api/explore/classes |
the API |
| A pre-computed landscape's report + figures | Real — GET /api/explore/landscapes/{id}/report\|figures, served from a frozen run pack |
the API |
| A pre-computed landscape's evidence rows | Real — GET /api/signals?landscape_id=… |
the API |
| Open-ended ad-hoc search across the full signal store | Not built | mocks/explore.ts, [DEMO] badge |
| Workspace persistence, cost estimation, discovery jobs | Not built | mocks/explore.ts, [DEMO] badge |
What has not changed is the hard part: ad-hoc search across the multi-million-row signal store still needs a candidate-retrieval stage (vector search + structured pre-filter) in front of the existing pipeline, and there is no POST /api/explore/discover, no discovery job store and no DiscoveredEntity table. A landscape becomes servable by being discovered offline and seeded, which is how gns561-cca-autophagy-001 exists. Closing exactly this gap is the direction of the active milestone — MVP-2, the Explore backend (docs/product/mvp-scope.md §1). Since MVP-1 (v1.1.0) the "Real" rows above additionally sit behind session auth, and pre-computed reports carry evidence-depth disclosure and a feedback box.
So the honest framing for a customer remains: Ogur can show you a competitive landscape it has already built, end to end and fully sourced. It cannot yet build you one live in the room. Anything still simulated carries a [DEMO] badge per §7 — that rule is unchanged and now matters more, because real and simulated Explore surfaces sit side by side.
Backend extension is tracked in docs/architecture.md §1.
§2 Core UX Principles¶
These are non-negotiable. They apply to every component, every tab, every screen.
-
Provenance is non-negotiable. Every claim the product displays must carry a
<SourceChip>and be one click from the raw source. No orphan text. Sources are inline — there is no separate "References" tab. -
The knowledge graph is infrastructure, never UI. No node-edge visualizations, no graph views, no force-directed layouts. Entity relationships surface as typed cards,
<EntityChip>inline chips, and structured triples only. See §6.4. -
Confidence is a first-class output. Every AI-synthesized block carries a
<ConfidenceBadge>. Freshness-weighted decay is architecturally designed in (M3 implementation), but the badge placeholder must be present from M1. See §6.2. -
Human-in-the-loop is a feature, not friction. Surface the reasoning chain. The locus of that chain depends on which mode the analyst is in (see §1.5):
- In Monitor mode, do not collapse synthesis behind a chatbot interface. Every
<SignalAnalysisCard>exposes thewhat_happened→why_it_matterschain before the conclusion. KIQ answers, briefings, and strategic implications are presented as structured documents, not chat replies. -
In Explore mode, the chat input is a scope-capture layer, not a synthesis layer. Every implicit decision the LLM makes from a freeform query (target, modalities, phase, indication, geography, "lead programs only", etc.) must surface as editable chips in a
<ClarifyingQuestionsGrid>before the search executes. The analyst always sees and can override the inferred research thesis. -
Do not promise what public sources cannot deliver. COGS, primary-research surrogates, KOL sentiment, internal clinical data, partner deal terms — these are out of scope. If a user question touches these, the Ask panel should say so, not hallucinate.
-
Temporal signals matter more than static facts. Change detection, signal velocity, and recency are the core value proposition. Architectures that make temporal queries cheap (timeline scrubber,
detected_atsort, velocity indicators) take priority over completeness of static profile data.
§3 Visual Language¶
Design tokens¶
| Token | Value | Use |
|---|---|---|
canvas |
#FAFAFA |
App background — cold clinical white |
surface |
#FFFFFF |
Cards, panels, modals |
text-primary |
#0A0A0A |
Graphite, not pure black |
border |
#E5E5E5 |
1px hairline only |
accent |
#3B4CCA |
Interactive elements, links, active states |
severity-critical |
#C0392B |
CRITICAL severity badge |
severity-high |
#E67E22 |
HIGH severity badge |
severity-medium |
#2980B9 |
MEDIUM severity badge (not yellow — unreadable) |
severity-low |
#8C9099 |
LOW severity badge |
Box shadow: 0 1px 2px rgba(0,0,0,0.04) only — no elevation system beyond this.
Border radius: 2–4px max. Never rounded-2xl.
⚠ Open decision — this rule does not describe the code.
<Card>,<Dropdown>,<Modal>, the feedback composer and the section-comment surfaces all userounded-md(6px) orrounded-lg(8px). The inconsistency is repo-wide and predates section comments. Either §3 moves to the token scale actually in use, orcomponents/ui/is declared non-conforming and scheduled. Flagged rather than resolved here, per CLAUDE.md: a spec conflict is a founder call.
Typography (three semantic registers)¶
| Register | Font | Size | Line-height | Used for |
|---|---|---|---|---|
| UI chrome | Inter or IBM Plex Sans | 13px base, 12px metadata | 1.4 | Nav, labels, table rows, filters |
| Identifiers | IBM Plex Mono | 11px | 1.3 | ChEMBL IDs, NCT IDs, timestamps, model_used, UUIDs |
| Prose | Source Serif 4 or Literata | 14px (report reading surface: 15px, tables 13px) | 1.7 | executive_summary, strategic_implications, Ask answers, landscape report body |
The three registers must never bleed into each other. Prose in a monospaced font, or IDs in a serif, is a spec violation.
Density¶
High. Enterprise analysts resent whitespace. 13px base, 12px metadata, 11px mono for identifiers. No padding above 16px inside cards.
Motion¶
120ms ease-out on hover states and the Inspector slide-in. One further
exception, added with <SectionCommentNudge>: a transient surface (§3.1)
may play a single entrance transition at duration-quick — fade plus a ≤8px
translate — because a card that materializes instantly in the corner of a
document reads as a rendering fault rather than as an arrival.
Nothing else. No bounce, no staggered mount animations, and no fade-in-on-scroll on content — the prohibition that matters is dressing up the report itself. A transient surface is chrome, and its entrance is what distinguishes it from the document underneath.
§3.1 Transient surfaces¶
A surface that appears unbidden and retires itself. There is exactly one rule, and it decides whether a thing is allowed to be transient at all:
An offer may be transient and auto-retire. A finding, an error, or a provenance statement never may — it renders in place, in full, carrying the actions that resolve it.
This is the line the code already draws and it is why OGUR has no toast layer:
a failed write on feedback (<ReportFeedback>) is a finding the analyst must
see and act on, so it is an in-place banner with Retry / Check again, never
a notification that scrolls away. An invitation to comment is an offer, so it
may be a toast.
Rules for any transient surface:
- fixed to a viewport corner, one at a time, max width 21rem
role="status"— neveralert, which is reserved for findings- auto-retires (≤10s) and carries an explicit dismiss control
- never covers the surface it refers to, and never interrupts an open composer
- offered once per rendered document, not once per sighting
Owner: <Toast>. Two hand-rolled corner cards exist today —
<SectionCommentNudge> and the Ask hint in App.tsx. The second is the one to
migrate when <Toast> is extracted; until then, both must satisfy the rules
above.
§4 Shell Layout¶
┌─ Left rail (56px collapsed / 220px expanded) ─┬─ Main workspace ──────────────────┬─ Inspector (380px, toggle) ─┐
│ OGUR │ │ │
│ ▸ Portfolio │ [context-dependent view] │ [selected object detail] │
│ ▸ Signals │ │ │
│ ▸ Ask │ │ │
│ ▸ Settings │ │ │
└────────────────────────────────────────────────┴───────────────────────────────────┴─────────────────────────────┘
Four primary views (all share the Inspector and shell):
| View | Route | Primary data source |
|---|---|---|
| Portfolio | / |
Signals aggregate + DrugProfile |
| Asset Detail | /asset/:drugName |
Briefing + Signals + DrugProfile |
| Signals | /signals |
GET /api/signals |
| Ask | /ask |
POST /api/ask |
Tab state lives in URL hash, not component state. Entity URLs are stable and shareable.
TopBar carries: breadcrumb, ⌘K trigger, health indicator dot, watchlist strip (pulses on new HIGH signal for watchlisted drugs).
§4.1 Keyboard bindings¶
Every binding in the app, and the rules that govern new ones. A binding that is not in this table does not exist; adding one means adding a row in the same commit.
| Binding | Scope | Does | Advertised in |
|---|---|---|---|
⌘K / Ctrl+K |
global | opens <CommandPalette> |
TopBar trigger |
⌘J / Ctrl+J |
global (demo builds only) | toggles the demo surface gate | — (undiscoverable; known gap) |
← / → |
/asset/:drugName |
previous / next asset | — (undiscoverable; known gap) |
c |
/explore/landscape/:landscapeId |
comment on the section under the reading line (§6.7) | <SectionCommentNudge>, composer footer |
⌘↵ / Ctrl+↵ |
any open composer | submit | composer footer |
Esc |
any open popover, palette, or modal | close | composer footer |
Rules.
- A global binding is chorded. ⌘ or Ctrl. Bare letters collide with browser and assistive-technology behaviour.
- A bare-letter binding is route-scoped and registered by the view that
owns it, never at the app shell.
cis the first of these. - Every binding yields. It does nothing while the reader is typing (input,
textarea, select,
contenteditable) and nothing while an overlay owns the screen (a modal, the palette, a figure's inspect card). A surface's own anchored popover is not an overlay for this purpose — otherwise the binding that opened it can never move on. - No binding may shadow a browser or OS chord. A handler for a bare letter
returns immediately if any of
metaKey/ctrlKey/altKeyis set, so⌘Cstays copy. - Registered once per surface, never once per item — N listeners racing to answer one keypress is a bug, not a redundancy.
- Discoverable from one place. This table is that place; the two rows marked as gaps are pre-existing and should be closed when a shortcut sheet exists.
§5 Entity Pages¶
§5.1 Asset Page (hero view — 70% of demo time)¶
Header strip:
pembrolizumab ∙ KEYTRUDA [Merck] Target: PD-1 Phase: Approved
ChEMBL1201585 Last briefing: 2h ago [↻ Refresh]
[↻ Refresh] button: triggers POST /api/briefing/nsclc-001 → 202 → poll GET /api/briefing/nsclc-001 every 3s → skeleton state → new briefing renders
Tab bar: Overview · Briefing · Signals · Trials · Internal Knowledge · Ask
Overview tab¶
| Zone | Content | Data source |
|---|---|---|
| Top-left | Fact sheet: target, MoA, chemistry class, pathway, indication | DrugProfile |
| Top-right | Biology/pathway card — inline SVG diagram for 5 hero drugs (PD-1/PD-L1, EGFR, ALK, KRAS G12C, MET exon 14); text card fallback | src/mocks/pathway-diagrams.ts |
| Bottom-left | Active trials count + phase breakdown bar | Signal filter: signal_type ∈ {trial_registered, trial_status_change} |
| Bottom-right | Signal counts last 30d by source | GET /api/signals aggregated client-side |
Briefing tab¶
The full latest briefing as a document. Read-path: GET /api/briefing/nsclc-001.
executive_summary— Source Serif 4, 14px, line-height 1.7signal_analyses— expandable<SignalAnalysisCard>list (headline → what-happened → why-it-matters → confidence chip → provenance citations)- Entity parsing: drug/target/company names in prose are parsed client-side (
entity-parser.ts) and rendered as<EntityChip>. Click → Inspector. This is the ontology moment; it is the highest-priority demo beat. - Citation chips: each
<SignalAnalysisCard>shows which signals it draws from as footnote-style<CitationChip>elements. Hover → highlights those signals in the Signals tab feed. strategic_implicationsblock — Source Serif 4watchlistitems — persistent strip in TopBar (not a section in the briefing document)- Metadata footer:
generated_at,signals_count,model_used— IBM Plex Mono 11px <ConfidenceBadge>on everysignal_analysesitem — see §6.2- Reviewer slot:
Reviewed by — [Assign reviewer]ghost button in briefing header, stores tolocalStorage
Signals tab¶
Feed for this asset, filtered client-side by drug_name from GET /api/signals?landscape_id=nsclc-001.
- Timeline scrubber (
<TimelineScrubber>): horizontal strip, last 180 days, each signal a colored dot (severity color). Hover → tooltip. Click → scrolls feed to that signal. Hand-rolled SVG (~80 lines) — do not use Recharts for this. - Feed (
<SignalRow>list): virtualized (TanStack Virtual). Each row: severity dot,<SourceChip>, title, company,detected_atrelative (absolute on hover). - Source filter chips (
<SourceFilterChips>) above the timeline. - Row click → Inspector (Signal schema).
Trials tab¶
Clinical trial signals grouped by phase. Source: signals where signal_type ∈ {trial_registered, trial_amendment, trial_status_change}.
Labeled: "Trial context for this asset — sourced from ClinicalTrials.gov signals."
Each trial is expandable to show amendments and status changes as a sub-timeline. This is derived from real signal data (not mock).
Internal Knowledge tab¶
Simulated. Mandatory SIMULATED corner badge in top-right of the panel (amber, cannot be dismissed).
5–8 fake internal documents per hero asset (hand-authored, src/mocks/internal-knowledge.ts):
- "2024 Due Diligence Memo — [drug] combination strategy" (PDF icon)
- "Q3 2025 Competitive Briefing — landscape"
- "Clinical Ops Update — trial enrollment"
- "Investment memo — LOE scenarios"
- "BD scan — combo partners Q4 2025"
Click → modal with 2–3 paragraphs of plausible hand-authored text. The SIMULATED badge is visible inside the modal too.
Ask tab¶
POST /api/ask scoped to landscape_id: nsclc-001.
- Messages styled as a document, not bubble chat — left-aligned
- User prompts: IBM Plex Mono
- Answers: Source Serif 4 prose
key_signalsfrom response →<CitationChip>elements below each answer → click → Inspectorsources_usedbadge on each answer card- Suggested prompts on first load (3 examples)
- Question history in collapsible accordion above input, persisted in
localStorage
Component: <AskPanel landscapeId="nsclc-001" /> — same component used in global Ask view.
§5.2 Company Page (M2)¶
Stub for M1 — display company name and a "Full company profile coming soon" placeholder.
M2 spec: OVERVIEW tab (headquarters, therapeutic focus, pipeline count) + PIPELINE tab (asset table filtered by company) + EVIDENCE tab (publications, press releases).
§5.3 Trial Page (M2)¶
Stub for M1 — display NCT ID and trial title, "Full trial view coming soon."
M2 spec: trial metadata, enrollment timeline, amendment history, cross-asset signal connections.
§5.4 Landscape Report page¶
/explore/landscape/:landscapeId — the frozen competitive brief, and the only
client-reachable report surface in production. It was built for MVP-1 and went
undocumented here until section comments were added; this section is that
backfill.
The whole route sits behind <RequireAuth>, which renders its <Outlet> only
when the identity is ready. Every state below therefore assumes a signed-in
named reader — a signed-out visitor is redirected to /login and never reaches
a section. Components on this page do not render sign-in states; that is not an
unspecified empty state, it is an unreachable one.
Variant lives in the URL (?variant=full|compact|tldr) via <SegmentedTabs>,
so a specific rendering is shareable and survives reload (§4's rule that tab
state does not hide in component state). An unknown variant renders an
<EmptyState> and loads nothing — serving the full brief under a URL that
asked for something else is indistinguishable, to the reader, from the brief
they requested.
| Region | Contents |
|---|---|
| Header | landscape id (mono), title, <PreparedForMark> |
| Banners | derived-variant note, <EvidenceDepthBanner>, <ProvenanceLegend> (only where rows carry tags) |
| Document | <ReportSection> per section, in the API's order — never re-sorted; the order encodes the argument |
| Section heading | <SectionComments> (§6.7) |
| Corner | <SectionCommentNudge> (§6.8), one per document |
| Below the document | <ReportFeedback> — general feedback; then <SectionCommentsList> (§6.10) — every section comment on the report. Both are <CollapsibleCard>s (§6.9), open by default, each carrying its count in the header so a collapsed box still says what is in it |
| Right rail | contents rail (xl: and up), sticky |
Anchoring. A comment anchors to the pair (variant, section_id), where
section_id is the pack's section slug. Never to Signal.id — a fresh uuid on
every reseed. The pair, not the slug alone: slugs are unique only within a
variant, and compact is derived from full carrying its own roster, so a slug
alone would silently move comments between documents.
Two feedback surfaces, split by what they are about. General feedback (the report as a whole) and section comments (a part of it) are separate boxes. Folding them together forces every section comment appearing in the general list to arrive carrying an explanation of why it is there — and there is no one sentence that is true for both of the cases that produce it.
Feedback is never unreachable. Visibility comes from <SectionCommentsList>
listing every section comment on the landscape, across all variants — not from
a fallback catching leftovers. Feedback is the one unregenerable dataset in the
system; a row that no surface renders is data loss in the only place it cannot
be recovered.
§6 System Components¶
All components use PascalCase and must match the names in this section exactly. Name drift is a spec violation.
§6.1 Inspector Panel (<Inspector>)¶
Universal right-panel detail surface. Width: 380px. Animation: Framer Motion, 120ms ease-out slide from right.
Four object schemas — context-switched by selected object type:
| Object | Inspector contents |
|---|---|
| Signal | Full record (all fields except raw_data); "Cross-source connections" (related signals as clickable rows); "Appears in briefing" link if referenced |
| Asset | Mini fact sheet (target, MoA, phase, company); open full view button |
| Target | Target name; list of associated assets in landscape; signal count last 30d |
| Company | Company name; assets in current landscape; recent signal activity summary |
Pin button (isPinned guard in Zustand store) keeps Inspector open across navigations. Header always shows: object type badge + object name.
§6.2 <ConfidenceBadge>¶
Variants: HIGH CONFIDENCE / MEDIUM CONFIDENCE / LOW CONFIDENCE
Style: muted label, no bright color fill — not a severity badge. Uses source data field confidence: "high" | "medium" | "low" from signal_analyses items.
Rules:
- Appears on every <SignalAnalysisCard> (one per AI-synthesized analysis block)
- Appears on every Ask answer card
- Must not appear on raw signal rows (those use severity, not confidence)
- In M3: badge will carry a freshness-decay timestamp. Design the component to accept an optional decayedAt prop from day one.
Micro-actions: ⚑ Flag and ✓ Confirm — toggle component state only (non-persisted, UI-only). Required for M1 Sanofi demo.
§6.3 <SourceChip>¶
Inline chip showing which source a signal comes from.
| Source key | Display label | Icon |
|---|---|---|
clinicaltrials |
ClinicalTrials.gov | flask icon |
openfda |
FDA | shield icon |
pubmed |
PubMed | book icon |
opentargets |
OpenTargets | dna icon |
conferences |
Conference | calendar icon |
Rules:
- Appears on every <SignalRow> (always inline, never in a tooltip)
- Appears in Inspector Signal schema
- One click on a SourceChip opens the external URL for that signal (from Signal.raw_data.url if present, else source homepage)
- Never appears without the source label — icon alone is insufficient for provenance
§6.4 <EntityChip>¶
Interactive inline chip for drug/target/company names parsed from briefing prose.
- Produced by
entity-parser.ts(regex + known-entity list → chip positions) - Renders as a subtle highlighted span within Source Serif prose
- Click → Inspector (context-switches to Asset, Target, or Company schema based on entity type)
- Do not use node-edge graphs to represent entity relationships. EntityChip is the only UI surface for entity traversal in M1.
Known entities list lives in entity-parser.ts. It must cover all NSCLC assets and targets (PD-1, PD-L1, EGFR, ALK, KRAS, ROS1, MET, HER2, BRAF, RET, NTRK, VEGF, VEGFR) plus the key companies (Merck, AstraZeneca, Roche, Pfizer, Novartis, BMS, Lilly, Amgen, J&J).
§6.5 Severity Badge (dot + label)¶
Used on <SignalRow> and Timeline scrubber dots. Color map lives in src/lib/severity.ts.
| Value | Dot color | Label |
|---|---|---|
high |
#E67E22 |
HIGH |
medium |
#2980B9 |
MEDIUM |
low |
#8C9099 |
LOW |
§6.6 <Badge> (general primitive)¶
General-purpose label badge. Two special variants:
| Variant | Style | When used |
|---|---|---|
[DEMO] |
Grey, 11px, low contrast | Simulated portfolio assets in the asset table |
SIMULATED |
Amber, corner badge, persistent | Internal Knowledge tab panel and modal |
The SIMULATED badge cannot be dismissed or hidden. Its purpose is to prevent demo confusion with real data.
§6.7 <SectionComments>¶
The per-section comment affordance on §5.4. A trigger in the section heading row plus an anchored popover carrying that section's comments and a composer.
Scope, deliberately: no threading, no resolve states, no text-selection anchoring. A comment is a flat note on a section.
Rules:
- The anchor is the pair
(variant, section_id). A slug alone names nothing. - The trigger is always rendered — never hover-revealed. Minimum 28px high, bordered, and it carries the word Comment, not a bare glyph. Muted while idle; it firms up on hover of the section. A control that does not exist until the pointer finds it cannot be found by someone who does not know it is there. Once the section has comments the trigger shows the count in place of the word.
- One popover open at a time, by construction — open state is a single target in the store, so opening one closes the last.
- The composer takes focus on open, which is what makes
c(§4.1) worth having: press, type,⌘↵, no pointer at any step. - An unresolved write is never auto-resent. Only a 4xx proves nothing committed; a dropped connection or a 5xx may have landed. The state is named honestly, the text preserved, and the choice left to the analyst — see §6.7's states table below.
- Comment text renders in the UI-chrome register (13px sans), never Source Serif. Source Serif carries synthesized, sourced claims; an analyst's comment set in the prose register would be visually indistinguishable from report content, which is a provenance failure of the same shape as a fabricated chip.
| State | Trigger | Popover |
|---|---|---|
| 0 comments | muted, label Comment | "No comments on this section yet" + composer |
| n > 0 | bordered, icon + count | list (author, timestamp, optional quoted excerpt) + composer |
| list failed | renders, no count | inline role="alert"; composer stays usable |
| submitting | — | button loading, textarea disabled |
| submit refused (4xx) | — | error + Retry |
| submit unresolved (5xx / no response) | — | "may or may not have been filed", text preserved, no auto-resend |
| anchor orphaned | absent — no such section | — (row appears in <ReportFeedback>, labelled) |
| signed out | unreachable — see §5.4 | — |
<SourceChip> and <ConfidenceBadge> must not appear on a comment. An
analyst comment is a fourth data class (§7) — neither a product claim nor an
LLM-synthesized block.
§6.8 <SectionCommentNudge>¶
A transient surface (§3.1) offering a comment when the reader reaches the end of a section — the moment they have an opinion about it, and the moment that section's own trigger has scrolled off the top of the screen.
Rendered once per document in the viewport corner, driven by the same store as §6.7 — never once per section.
Restraint is the design:
- once per section per rendered document — not once per session; the store resets on variant or landscape change, so returning to a report re-offers it. A reader scrolling back up re-crosses every section end, and a prompt that reappears each time stops reading as an offer and starts reading as nagging
- never over an open composer
- never on a section THIS READER has already commented on — another analyst's comment is a reason to invite, not to stay quiet; suppressing on any comment would let the first person to comment silence the invitation for the whole team — and never before that section's comment list has settled, since an offer that appears and then retracts is worse than one that never appeared
- never on a section that renders no composer. The level-1 title section has
no heading row and therefore no trigger, so it is not a comment target at all:
no end sentinel, and no
data-section-titlefor thecshortcut to resolve. An offer that opens nothing is worse than no offer - auto-retires after 9s, and carries a dismiss control
- names the section it is about, and advertises
c
§6.9 <CollapsibleCard>¶
A <Card> whose body folds away behind its header. The shared owner of that
behaviour — a second hand-rolled toggle is the drift §8 exists to stop.
- built on Radix Collapsible, so
aria-expanded,aria-controlsand a real<button>trigger come from the library rather than from a re-implementation - the header carries a
summaryslot that stays visible while collapsed — a count, a status. A collapsed panel that says nothing about its contents is a lid, not a summary: the reader has to open it to learn whether it was worth opening - no height animation. §3 permits 120ms on hover states and the Inspector slide-in, §3.1 adds one entrance transition for transient surfaces; an expand/collapse tween is neither, and adding motion the spec does not cover is how the spec stops being true
- state is per mount, not a stored preference
Used by both feedback surfaces below a landscape report (§5.4).
§6.10 <SectionCommentsList>¶
Every section comment on the report, grouped by section, rendered below the
document beside <ReportFeedback> (§5.4).
It lists comments from all variants, not just the one on screen: the anchor decides where a comment is pinned, not whether it exists, and a reader of the compact rendering is still reading this report. Three labels, and the third is silence:
| Situation | Label | Jump link |
|---|---|---|
| anchored to a section of the document on screen | none — it needs no explanation | yes |
left on the variant this rendering was projected from (derived_from) |
"left on the <variant> brief" |
yes |
| left on some other variant | "left on the <variant> brief" |
no |
| section not in this rendering at all | "… · not in this rendering" | no |
Grouping is on the full (variant, section_id) pair, and so is the jump
rule. Keying on the slug alone merges two variants' same-slug sections into
one group titled by whichever section the rendered document happens to carry —
mislabelling one variant's feedback and pointing its link at an unrelated
section. A cross-variant jump is offered only where the mapping is provable:
a derived rendering is a subset of its parent carrying the same section ids, so
a shared slug there is the same section. Between two unrelated variants nothing
in the payload distinguishes "same section" from "same slug, different
section", so the link is withheld rather than guessed.
The second row is why this component exists. These two situations were once
folded into <ReportFeedback> under a single label, "on a section no longer in
this document", which was false for the common case: compact is derived
from full and shares its slugs, so the section was usually right there on the
page and only the variant differed. A label that misdescribes the data is worse
than no label — the reader cannot tell which of the two they are looking at.
Group order follows the document; sections not in this rendering sort last, since they are the ones the reader cannot navigate to. A section absent from the document is titled by its raw slug — inventing a prettier title would misdescribe a document that is not on screen.
§7 Simulation Strategy¶
Three explicit buckets. Never mix. Real API hooks must never import from src/mocks/.
| Bucket | Source | Label |
|---|---|---|
| Real | API endpoints (GET /api/signals, GET /api/briefing, POST /api/ask) |
No label |
| Derived | Client-side aggregation from real signal data (phase breakdown bar, signal counts, trial grouping) | No label |
| Simulated | Hand-authored in src/mocks/ (non-NSCLC portfolio assets, internal documents, pathway SVGs) |
[DEMO] or SIMULATED badge |
| Analyst-authored | Written by a named signed-in reader (<ReportFeedback>, <SectionComments>) |
Attribution block — display name, organization, timestamp, resolved server-side |
Analyst-authored is not a claim and must never render as one. It carries no
<SourceChip> and no <ConfidenceBadge> — there is no source to cite and
nothing was synthesized. It renders in the UI-chrome register (13px sans), never
Source Serif, so it cannot be mistaken for report content. Attribution is always
the server's answer from the session identity, never a client claim.
src/mocks/ is a single directory. A SIMULATED: true boolean constant gates mock data. If SIMULATED is false, no mock data is loaded anywhere.
§8 Component Naming Convention¶
All component names are PascalCase. They must match the names in §6 and the inventory below exactly. If a component is renamed, update this file in the same commit.
Component inventory¶
| Source path | Component | Used in |
|---|---|---|
components/shell/LeftRail.tsx |
<LeftRail> |
App.tsx |
components/shell/TopBar.tsx |
<TopBar> |
App.tsx |
components/shell/Inspector.tsx |
<Inspector> |
App.tsx (global, context-switched) |
components/shell/CommandPalette.tsx |
<CommandPalette> |
<TopBar> |
components/portfolio/AssetTable.tsx |
<AssetTable> |
views/Portfolio.tsx |
components/portfolio/AssetRow.tsx |
<AssetRow> |
<AssetTable> |
components/portfolio/PhaseHeatmap.tsx |
<PhaseHeatmap> |
views/Portfolio.tsx (toggle view) |
components/asset/AssetHeader.tsx |
<AssetHeader> |
views/AssetDetail.tsx |
components/asset/OverviewTab.tsx |
<OverviewTab> |
views/AssetDetail.tsx |
components/asset/BriefingTab.tsx |
<BriefingTab> |
views/AssetDetail.tsx |
components/asset/SignalsTab.tsx |
<SignalsTab> |
views/AssetDetail.tsx |
components/asset/TrialsTab.tsx |
<TrialsTab> |
views/AssetDetail.tsx |
components/asset/InternalKnowledgeTab.tsx |
<InternalKnowledgeTab> |
views/AssetDetail.tsx |
components/asset/AskTab.tsx |
<AskTab> |
views/AssetDetail.tsx |
components/briefing/BriefingDocument.tsx |
<BriefingDocument> |
<BriefingTab> |
components/briefing/SignalAnalysisCard.tsx |
<SignalAnalysisCard> |
<BriefingDocument> |
components/briefing/EntityChip.tsx |
<EntityChip> |
<ParsedProse> |
components/briefing/ParsedProse.tsx |
<ParsedProse> |
<BriefingDocument> |
components/briefing/WatchlistStrip.tsx |
<WatchlistStrip> |
<TopBar> |
components/signals/SignalRow.tsx |
<SignalRow> |
<SignalsTab>, views/GlobalSignals.tsx |
components/signals/TimelineScrubber.tsx |
<TimelineScrubber> |
<SignalsTab> |
components/signals/SourceFilterChips.tsx |
<SourceFilterChips> |
<SignalsTab>, views/GlobalSignals.tsx |
components/ask/AskPanel.tsx |
<AskPanel> |
<AskTab>, views/GlobalAsk.tsx |
components/ask/CitationChip.tsx |
<CitationChip> |
<AskPanel>, <SignalAnalysisCard> |
components/explore/report/ReportDocument.tsx |
<ReportDocument> |
views/explore/LandscapeReport.tsx |
components/explore/report/ReportSection.tsx |
<ReportSection> |
<ReportDocument> |
components/explore/report/ReportTable.tsx |
<ReportTable> |
<ReportSection> |
components/explore/report/EvidenceDepthBanner.tsx |
<EvidenceDepthBanner> |
<ReportDocument> |
components/explore/report/ProvenanceLabel.tsx |
<ProvenanceLabel>, <ProvenanceLegend> |
<ReportTable>, <ReportDocument> |
components/explore/report/PreparedForMark.tsx |
<PreparedForMark> |
<ReportDocument> |
components/explore/report/SectionComments.tsx |
<SectionComments> |
<ReportSection> |
components/explore/report/SectionCommentNudge.tsx |
<SectionCommentNudge> |
<ReportDocument> |
components/explore/report/figures/SwimlaneFigure.tsx |
<SwimlaneFigure> |
<ReportSection> |
components/explore/report/figures/PatentWhitespaceFigure.tsx |
<PatentWhitespaceFigure> |
<ReportSection> |
components/explore/report/figures/BullseyeFigure.tsx |
<BullseyeFigure> |
<ReportSection> |
components/ui/CollapsibleCard.tsx |
<CollapsibleCard> |
<ReportFeedback>, <SectionCommentsList> |
components/explore/report/SectionCommentsList.tsx |
<SectionCommentsList> |
views/explore/LandscapeReport.tsx |
components/explore/ReportFeedback.tsx |
<ReportFeedback> |
views/explore/LandscapeReport.tsx |
components/explore/ClarifyingQuestionsPanel.tsx |
<ClarifyingQuestionsPanel> |
views/explore/ResearchWizard.tsx |
components/explore/BuiltLandscapesSection.tsx |
<BuiltLandscapesSection> |
views/explore/Explore.tsx |
Known gap: the three figure components are inventoried above but §3 and §6 carry no figure primitive or contract — how a figure is captioned, what its empty state is, and what it may claim are unspecified. Flagged, not resolved.
<SourceChip>, <ConfidenceBadge>, <Badge> — shared primitives, live in components/shared/ or inline in the components that use them.
Inspector sub-components: <SignalInspector>, <AssetInspector>, <TargetInspector>, <CompanyInspector> — all rendered inside <Inspector> via context switch.
§9 Sequencing Milestones¶
Historical.
M1/M2below are the Sanofi-demo-era milestones (May/June 2026), not the currentMVP-1/MVP-2. They are kept as a record and must not be reconciled with or renumbered against the MVP milestones — the current milestone contract isdocs/product/mvp-scope.md, which supersedes this section. Notably, "Auth, user management" listed as out of scope for M1 below did ship in MVP-1 (v1.1.0, 2026-08-13).
M1 — Sanofi Demo (target: May/June 2026)¶
In scope:
- Asset page: OVERVIEW + BRIEFING + SIGNALS + TRIALS + INTERNAL KNOWLEDGE + ASK (all 6 tabs)
- Portfolio view (asset table + phase heatmap toggle + [DEMO] badges)
- Global Signals view (cross-asset feed)
- Global Ask view
- Inspector (all 4 schemas: Signal, Asset, Target, Company)
- <ConfidenceBadge>, <SourceChip>, <EntityChip>, severity badges
- Human-in-the-loop hooks: Flag/Confirm micro-actions, reviewer slot
- <TimelineScrubber> (hand-rolled SVG)
- <CommandPalette> (⌘K — asset + target + signal jump)
- Watchlist strip (TopBar)
- Briefing refresh flow (trigger → 202 → poll → render)
- Company page: stub only
- Trial page: stub only
Out of scope for M1: - Auth, user management, multi-tenant - Timeline tab (not in asset detail tabs for M1) - Threat-flag lane - Briefing tab (standalone — exists as Asset Detail tab only) - Amendment detection UI - Freshness-weighted decay display on ConfidenceBadge - Real non-NSCLC data (landscape selector visible but disabled) - Writeback persistence (flagging/confirming is component state only) - WebSockets (polling is sufficient) - Mobile layout - People tab - Collaboration features
M2 — Post-Sanofi¶
- Company page full spec (OVERVIEW + PIPELINE + EVIDENCE)
- Trial page full spec (enrollment timeline, amendment history, cross-asset connections)
- Timeline tab in Asset Detail
- Threat-flag lane
- Freshness-weighted decay on
<ConfidenceBadge>(Phase 3 backend prerequisite) - Vector search backend feeding the Ask panel (Phase 3 backend prerequisite)
- People tab
- Multi-landscape selector (non-NSCLC landscapes)
§10 Demo Script (5 minutes, 8 beats)¶
Design every screen the script visits before anything else. These 8 beats are the acceptance criteria for M1.
| Time | Action | What it demonstrates |
|---|---|---|
| 0:00–0:30 | Land on Portfolio → filter Oncology → NSCLC. Hover an asset row → Inspector shows quick stats. | Grid, filtering, Inspector |
| 0:30–1:00 | Click pembrolizumab → Overview tab. Pathway diagram, fact sheet. | Asset detail, visual anchor |
| 1:00–2:30 | Switch to Briefing tab → read executive summary → hover a drug name in prose → Inspector opens. Click a citation chip → Inspector shows underlying signal. | The ontology moment — load-bearing demo beat |
| 2:30–3:15 | Switch to Signals tab → scrub the timeline → click a HIGH dot → Inspector. | Timeline, severity system |
| 3:15–3:45 | Switch to Internal Knowledge → open due diligence memo modal. Note SIMULATED badge. | 360° view story |
| 3:45–4:45 | Switch to Ask → type "What's the main competitive risk to this asset in the next 6 months?" → answer renders → citation chips appear → click one → Inspector. | AI + provenance chain |
| 4:45–5:15 | Hit Refresh Briefing → progress pill → new briefing renders. ⌘K → jump to osimertinib. | Freshness, keyboard navigation |
The briefing tab entity parsing → Inspector jump (beat 3) is the highest-risk beat technically. It must be implemented and polished before any other beat is considered done.
§11 API Surface (reference)¶
Superseded by
docs/api-reference.md— the table below is the M1-era surface. Since then: all client surfaces sit behind session auth (/api/auth/*, OGUR-64);POST /api/briefing/...and the other generation POSTs were deleted in the OGUR-76 security review (#259);POST /api/askis authenticated and spend-metered; and the report-pack, explore, and feedback routes were added. In production the API and built frontend are same-origin.
M1-era table (historical):
| Endpoint | Used by |
|---|---|
GET /api/signals?landscape_id=nsclc-001&limit=500 |
Signals tab, Portfolio derived stats |
GET /api/briefing/nsclc-001 |
Briefing tab, Overview tab, WatchlistStrip |
POST /api/briefing/nsclc-001 |
Refresh button — route deleted in #259 |
POST /api/ask body: {question, landscape_id} |
AskPanel (asset + global) — now authenticated + metered |
GET /health |
TopBar status indicator dot |
Base URL: http://localhost:8000. Vite proxies /api to :8000 in dev.