Skip to content

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 has ClarifyingQuestionsPanel and AssetCatalogTab) and <ResearchThesisPanel>, which does not exist anywhere in frontend/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 RealPOST /api/explore/decompose, class-aware, fails closed on an unregistered class the API
Registered landscape classes RealGET /api/explore/classes the API
A pre-computed landscape's report + figures RealGET /api/explore/landscapes/{id}/report\|figures, served from a frozen run pack the API
A pre-computed landscape's evidence rows RealGET /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.

  1. 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.

  2. 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.

  3. 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.

  4. 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):

  5. In Monitor mode, do not collapse synthesis behind a chatbot interface. Every <SignalAnalysisCard> exposes the what_happenedwhy_it_matters chain before the conclusion. KIQ answers, briefings, and strategic implications are presented as structured documents, not chat replies.
  6. 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.

  7. 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.

  8. 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_at sort, 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 use rounded-md (6px) or rounded-lg (8px). The inconsistency is repo-wide and predates section comments. Either §3 moves to the token scale actually in use, or components/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" — never alert, 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.

  1. A global binding is chorded. ⌘ or Ctrl. Bare letters collide with browser and assistive-technology behaviour.
  2. A bare-letter binding is route-scoped and registered by the view that owns it, never at the app shell. c is the first of these.
  3. 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.
  4. No binding may shadow a browser or OS chord. A handler for a bare letter returns immediately if any of metaKey / ctrlKey / altKey is set, so ⌘C stays copy.
  5. Registered once per surface, never once per item — N listeners racing to answer one keypress is a bug, not a redundancy.
  6. 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]
- Drug name + brand: Inter 16px bold - Company, target, phase: Inter 13px, muted - ChEMBL ID: IBM Plex Mono 11px — always - [↻ 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.7
  • signal_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_implications block — Source Serif 4
  • watchlist items — 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 every signal_analyses item — see §6.2
  • Reviewer slot: Reviewed by — [Assign reviewer] ghost button in briefing header, stores to localStorage

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_at relative (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_signals from response → <CitationChip> elements below each answer → click → Inspector
  • sources_used badge 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-title for the c shortcut 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-controls and a real <button> trigger come from the library rather than from a re-implementation
  • the header carries a summary slot 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/M2 below are the Sanofi-demo-era milestones (May/June 2026), not the current MVP-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 is docs/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/ask is 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.