Frontend¶
The frontend is a Vite + React 18 SPA that consumes the Ogur REST API. Source lives in frontend/.
This doc is a technical overview. For visual language, component contracts, badge system, confidence composites, and the "what goes on which page" authority, see design/ux-spec.md. If something below conflicts with the UX spec, the UX spec wins.
Stack¶
| Layer | Choice | Why |
|---|---|---|
| Bundler | Vite 5 | Fast cold starts, first-class TS, /api dev proxy |
| Framework | React 18 + TypeScript 5 | Team familiarity |
| Routing | react-router-dom 6 |
Client-side routing, nested routes for Asset Detail tabs |
| Server state | TanStack Query 5 | Cache, polling, background refetch, SSR-ready |
| Client state | Zustand 4 | Inspector panel state, UI-only — not persisted |
| Virtualization | TanStack Virtual 3 | Large signal lists (~1,500 rows) |
| Styling | Tailwind 3 | Design tokens → utility classes |
| Components | shadcn/ui (Radix primitives) | Dialog, Tabs, Tooltip, ScrollArea, Collapsible |
| Animation | Framer Motion 11 | Inspector slide-in, tab transitions |
| Charts | Recharts 2 | Trial timelines, phase distribution |
| Icons | lucide-react |
Consistent stroke weight |
| Command palette | cmdk |
⌘K anywhere → command search |
| Dates | date-fns |
Timezone-safe, tree-shakeable |
Full manifest: frontend/package.json.
File layout¶
frontend/
index.html
vite.config.ts ← /api/* → http://localhost:8000 proxy
tailwind.config.ts
tsconfig.json
src/
main.tsx ← Entry, TanStack Query client, router
App.tsx ← Route tree + top-level layout
index.css ← Tailwind directives + design tokens
api/ ← One hook per endpoint (useBriefing, useSignals, useAsk, …)
client.ts ← fetch wrapper, shared error handling
useSignals.ts
useBriefing.ts
useAssetBriefing.ts
useAssetOverview.ts
useAssetTrials.ts
useAssetCompetitive.ts
useAsk.ts
components/
ask/ ← AskPanel, CitationChip
asset/ ← LandscapeTab (shared across tabs)
briefing/ ← BriefingDocument, EntityChip, ParsedProse,
PredictionCard, SignalAnalysisCard, KIQAnswersSection
portfolio/ ← AssetTable, FranchiseAssetTable
shell/ ← CommandPalette, Inspector, LeftRail, TopBar
signals/ ← SignalRow, SourceFilterChips, TimelineScrubber
lib/ ← cn, entity-parser (legacy regex), entity-registry,
entity-resolver, severity helpers
mocks/ ← Static data for offline / demo mode
store/
inspector.ts ← Zustand store for the right-rail Inspector
types/
index.ts ← Shared TS types
views/ ← One per top-level route
Portfolio.tsx
FranchisePortfolio.tsx
AssetDetail.tsx ← 6 tabs per ux-spec.md
GlobalSignals.tsx
GlobalAsk.tsx
Deals.tsx
Top-level views¶
The shell entry point is <ModeSelector> at /, which routes the analyst to one of two modes — Monitor (continuous franchise watching) or Explore (one-shot landscape mapping). The two modes wrap the principle of "do not hide synthesis behind a chatbot" differently — see design/ux-spec.md §1.5 for the full distinction.
| View | Route (App.tsx) |
Mode | Purpose |
|---|---|---|---|
| Mode selector | / |
— | Two-tile entry point: Monitor / Explore |
| Explore | /explore |
Explore | Two tabs: Workspaces (saved landscapes + new-research wizard) and Asset catalog (browseable drug table) |
| Franchise portfolio | /franchise/:id |
Monitor | Pre-seeded franchise view — LOE calendar, competitive threats, live signals |
| Asset Detail | /asset/:drug |
Monitor | 6 tabs: Overview, Trials, Competitive, Landscape, Evidence, Ask |
| Global Signals | /signals |
Monitor | Timeline-scrubbed feed of every signal across landscapes |
| Global Ask | /ask |
Monitor | Free-form Q&A against /api/ask |
| Deals | /deals |
Monitor | Licensing / M&A / investment signals filtered from SEC EDGAR |
Backend caveat for Explore. The intelligence engine is tuned for Monitor mode (pre-seeded landscapes). Explore mode currently runs on frontend mock data — see architecture.md §1. Treat
frontend/src/mocks/explore.tsas the source of truth for Explore views until the candidate-retrieval stage is built.
The Inspector pattern¶
A 380 px right rail that context-switches content based on the selected object (drug / trial / signal / company). Driven by a single Zustand store at store/inspector.ts:
type InspectorState =
| { kind: "drug"; drugName: string }
| { kind: "trial"; nctId: string }
| { kind: "signal"; signalId: string }
| { kind: "company"; normalizedName: string }
| null;
Clicking any EntityChip in a briefing swaps the Inspector content without navigating away. This is the key interaction pattern in the UX spec — users stay in flow while drilling into provenance.
Entity rendering — structured refs vs. legacy regex¶
Briefings now carry structured entity references alongside prose. Each signal_analyses[] and predictions[] entry can include optional drug_id, company_id, target_id, trial_nct_id fields, and kiq_answers[].evidence_refs[] is a list of those same shapes. The synthesizer emits IDs from the ENTITY CATALOG (see architecture.md §4.4).
Two paths render entities, picked per claim:
- Structured path (preferred). When a card has any of the four ID fields populated,
entity-registry.tsresolves them:
| ID prefix | Routes to |
|---|---|
drug-<slug> |
Asset Detail page if the drug is in useAssetIndex(); else Inspector |
co-<slug> |
Inspector → company panel |
tgt-<slug> |
Inspector → target panel |
NCT\d{8} |
External: https://clinicaltrials.gov/study/{nct} |
The card renders a chip row above the prose — <EntityChip resolved={…} /> — and clicks dispatch via react-router-dom href, external <a>, or the Zustand inspector store depending on ResolvedEntity.type.
- Legacy regex path. Falls back when no IDs are emitted (older briefings, or claims the synthesizer left untyped).
entity-parser.tsparses the prose itself, finds drug/trial mentions, and renders<EntityChip text={…} entity={…} />. This path is on the deprecation list — it's fragile (regex over prose) and the structured emitter has been live for several pipeline runs.
<EntityChip> (components/briefing/EntityChip.tsx) discriminates the two modes by prop shape. PredictionCard and SignalAnalysisCard resolve all four ID fields, render the chip row if any resolved, and fall back to ParsedProse for the body.
KIQ Answers¶
KIQAnswersSection.tsx renders the kiq_answers[] block. One card per active KIQ, each with:
- The KIQ question as a header
finding,evidence,uncertainty,implicationprose blocks- A
<ConfidenceBadge>(high / medium / low) - A row of
<EntityChip>s forevidence_refs[]— uses the same structured-path resolver as above
Drug briefings render only drug-specific KIQs (those keyed on the drug-composite landscape ID); class-level KIQs appear only in landscape briefings. See architecture.md §2 — Path C.
Section comments (OGUR-72)¶
Three ways to leave a comment on a report section, all writing the same
(variant, section_id)-anchored feedback row:
| Affordance | Where | Notes |
|---|---|---|
| Trigger in the section heading | SectionComments.tsx |
Always rendered — a bordered Comment pill, not a hover-only glyph. Shows a count once the section has comments. |
c |
useSectionCommentShortcut.ts |
Opens the composer for the section under the reading line (a third of the way down the viewport), resolved from the DOM at press time. Ignored while typing and under any modifier, so ⌘C stays copy. |
| End-of-section nudge | SectionCommentNudge.tsx |
An IntersectionObserver sentinel at each section's end offers a comment once the reader finishes it. |
Below the document there are two boxes, not one: <ReportFeedback> for
general feedback, and <SectionCommentsList> for every section comment on the
report (all variants, grouped by section, with a jump link where the section is
on screen). They partition the server list — no row on both surfaces, none on
neither. Both are <CollapsibleCard>s (components/ui/), open by default and
carrying their count in the header so a collapsed box still says what is in it.
The nudge is deliberately restrained: once per section per rendered
document (the store resets on variant or landscape change, so it is not once
per session), never over an open composer, never on a section already commented
on — and only once that section's comment list has settled, so the offer cannot
appear and then retract. Auto-retired after 9s. Open/nudge state is a small zustand store (store/sectionComments.ts)
because the shortcut and the nudge both open a popover they do not own.
Data fetching¶
Every backend endpoint has a matching TanStack Query hook in src/api/. Convention:
export function useBriefing(landscapeId: string) {
return useQuery({
queryKey: ["briefing", landscapeId],
queryFn: () => client.get<BriefingOut>(`/api/briefing/${landscapeId}`),
staleTime: 60_000, // 1 min
});
}
POST endpoints use useMutation — see useFeedback.ts
or useLogin.ts. The briefing/analyzer trigger mutations
and useTriggerBriefing.ts were removed in OGUR-76 along with the unauthenticated,
unmetered generation routes they called; briefings are produced offline.
The client.ts wrapper:
- Handles JSON parsing + error surfacing
- Throws on non-2xx so TanStack Query populates error
- Session-cookie auth: useCurrentUser.ts reads /api/auth/me (with retry: false
— load-bearing, a retry-scheduled query pauses forever when hidden/offline),
useLogin.ts posts credentials, RequireAuth.tsx gates the routed surfaces
Dev workflow¶
/api/* requests from the frontend proxy to :8000 via frontend/vite.config.ts. There is no CORS setup in production because we assume same-origin deployment.
Running against mock data¶
For demos / offline work, each view can import from src/mocks/. This is done by swapping the hook inside the view file — not driven by an env var. Cleaner separation is on the backlog.
What's built since this doc was first written (MVP-1, v1.1.0)¶
- Auth/session. Login page,
RequireAuthroute guard,useCurrentUser/useLoginhooks, sign-out (OGUR-64, #241/#256). Cross-client access to an ungranted landscape renders a 404 state, not an error toast (OGUR-77). - Report-pack surfaces. Served landscape reports and figures
(
useExploreReport), evidence-depth banner (OGUR-78), whole-report feedback box with explicit states (OGUR-71, #251). - Demo-surface gate. The default client build ships with all mock/demo
surfaces off;
VITE_DEMO_SURFACES=truerestores them (OGUR-70, #253). - Frontend tests. 368 Vitest tests across 37 files, run in CI
(
npm test→ Vitest;npm run buildtypechecks).
What's not built (yet)¶
- No error boundary strategy beyond TanStack's default. A 500 from the backend surfaces as a toast; no retry UI.
- No analytics instrumentation. Unplanned.