project-context.md
Internal reference, rendered read-only. Edit the real file in the repo — this page just displays it. Not indexed; it should sit behind auth once Clerk lands.
Exploding Bacteria — Project Context
Purpose of this file: Durable context for Claude Code. It states decisions and specs, not the reasoning that produced them. Point
CLAUDE.mdat this file (run/init, then reference it) so every session starts knowing the project. This is a blueprint, not a discussion.
What this product is
A paid clinical antibiotic decision-support platform (exploding-bacteria.com), $269/year, targeting residents and clinicians. A faster, more opinionated alternative to Sanford Guide. It takes positions where guidelines hedge, eliminates false-precision numbers, cites primary literature over organizations, and optimizes for speed of clinical decision-making. Antibiotic guide only — ACT/airway content is out of scope.
Solo builder (an ED physician). Code flows one direction: Claude generates, the builder reviews/verifies. The builder does not author code independently.
The stack (locked)
- Framework: Astro (static-first, islands for interactivity)
- Host: Cloudflare Pages
- Build agent: Claude Code, desktop app, Code tab, Ask-permission mode ON (never auto-accept)
- Version control: git + GitHub
- Auth: Clerk — LOCKED. Login, sessions, gating. Right choice regardless of where we sell; billing rides on top of it separately (see Business model & payments).
- Billing provider: OPEN — decided at Phase 6. Clerk Billing (Stripe-based, we own global tax) vs. a Merchant-of-Record (Paddle / Lemon Squeezy, they own global tax) — the call hinges on the international-tax appetite below. $269/year + 3–7 day trial either way.
- Search: Pagefind + a drug alias/synonym map + failed-query logging
- Interactive tools: React islands
- Engine: rewritten from scratch, mined from the existing
abx-js-part-one.js,abx-js-part-two.js,abx-po.jsas the behavioral spec — written to the frozen contract below. Not ported line-for-line; the existing engine is the proven-behavior reference.
Business model & payments (decided 2026-07-10; execute at Phase 6)
Model: free trial (3–7 days) → $269/year. A handful of diseases + tools are free (the funnel: try it → trial → paid). All MDCalc-equivalent tools stay permanently free and public — they’re the SEO/growth wedge against MDCalc, indexed and fast, funneling to the paid guide.
Buy, don’t build — the search decision does NOT generalize here. We custom-built search because it was simple, stateless, low-risk (~200 lines of client filtering) and the third-party tool (Pagefind) was the wrong fit. Auth + billing is the opposite: security-critical, stateful, unforgiving (password/session security, Stripe webhooks, renewals, dunning, proration, refunds, trial expiry, tax). Rolling our own = owning subscription-management software forever and a breach as the failure mode. This is the clearest “pay the money” call in the project. Use a managed provider.
Separate the two layers in your head:
- Auth = Clerk. LOCKED, and independent of geography. Login/sessions/gating work identically everywhere.
- Billing/tax = the only thing “international” touches, and it’s an OPEN Phase-6 call (see below).
Sell internationally — yes. The product is US-centric (US resistance patterns, drug availability, brand names, IDSA-style positions). An international physician gets a genuine but ~97% version. That is NOT a reason to geo-block — it’s a reason to be honest about scope: a one-line banner (“Reflects US resistance patterns, drug availability, and guidelines”). Withholding a great tool for lack of localization leaves money and clinical value on the table. Not geo-blocking is the default → selling globally is nearly free; localizing (e.g. a UK edition) is a separate future project, not a launch requirement.
What selling internationally means for protecting the business — it’s a TAX problem, not a product problem. Selling a digital subscription into the EU/UK/AU/etc. triggers VAT/GST obligations, often from the first sale with no threshold, across many jurisdictions. Two ways to handle it:
- Clerk Billing (Stripe-based): cheapest fees (~2.9% + Stripe Tax calculates tax), but WE are the merchant → we register and file tax in each jurisdiction. Back-office on us.
- Merchant of Record — Paddle or Lemon Squeezy: the MoR legally becomes the seller and remits every country’s tax worldwide; we never register anywhere. Costs ~5% (the extra ~2% buys us out of global tax compliance + audit risk). Architecture becomes Clerk (auth + gating) + MoR (payments + tax), wired via webhooks — slightly more integration glue.
Leaning MoR because the builder is solo and explicitly does not want to run a subscription/tax back-office — same logic as “don’t build your own auth.” But this does not need deciding until Phase 6, with real numbers. Clerk-for-auth is locked regardless; only the billing provider is open.
Gating implication for the architecture (the one real technical consequence): the site is static (Astro SSG on Cloudflare). You cannot truly gate a static page — hiding content with JS still ships the HTML (view-source defeats it). So gated routes must be server/edge-rendered (Astro SSR via the Cloudflare adapter), checking the Clerk session before sending HTML. Astro is hybrid: free diseases + all MDCalc tools stay static/fast/indexable; only gated routes flip to SSR. Solved path (@clerk/astro), just real work — a Phase-6 concern, blocks nothing before it.
Architecture
No database. Clinical content is static and known at build time. There is nothing to query at runtime.
- Disease pages: static HTML compiled from structured content at build time.
- Drug pages (drug→disease, future): build-time derived views. A build function scans all content-collection entries, finds every instance of a drug, and assembles the drug page (with disease context, pairing, and tier) from the same source records. No runtime query, no DB. The “scan” runs at build, not on user request — so delivery is instant regardless of library size (~5k entries is a seconds-scale build cost, invisible to users).
- A real database is only needed later for per-user runtime data (saved lists, annotations, view history). If that day comes: managed Postgres (Supabase or Neon). Not Xano, not Airtable.
Page composition:
- The page template/layout is a static component — NOT an island. It renders to plain HTML.
- Islands are the discrete interactive widgets embedded in the static template (PO calc, IV calc, acid-base tool, MRSA/Pseudo toggle, Adult/Peds filter).
- Do not nest islands. Make one appropriately-sized island with normal React child components. The acid-base tool (“10 MDCalcs in one”) = one island, with its sub-calculators as ordinary children.
- Islands are isolated by default. The Adult/Peds filter (see below) crosses that boundary and needs a shared store — this is the one place “just drop in an island” isn’t the whole story.
Frozen DOM contract (engine spec)
Canonical nesting: regimen > combo > group > drug > drug-card
Exclusivity is driven by DOM containment (.closest), NOT by number fields. The engine reads ancestry. This is critical: a flat list + a “group number” field cannot drive the behavior. The nesting must physically exist.
- One-open-per-group: automatic/native. Opening a drug closes other open drugs in the same group.
- Combos close each other by default (unless the shell is marked multi).
- Exclusive combo: opening a drug in one group closes open drugs in other groups within that combo (pick only one group’s drug).
- Combination therapy (“one from Group A AND one from Group B”): a
combowrapping twogroups. Each group allows one open; siblings don’t close each other, so A-open + B-open coexist. Zero new code. Requires nesting, not a number field. - Radio/tab selector =
classify→ shows/hidesregimensuper-groups. - Primary + Alt regimen: two
combos inside oneregimen. Default cross-combo close already gives “select Alt → Primary closes” for free. - Same drug, different doses (e.g. azithro 5-day vs 3-day): separate
drugaccordions. - Header: its own record + sort order. (This replaces the legacy “header paired to first drug” escape hatch — do not reproduce that.)
- Multi-drug, no-calc accordion: multi-card, no calc slots.
- PO calc: the engine builds the entire calculator from a single
data-po-keyreference. Never hand-build a calc. One key → full calc. - IV calc: renders on weight entry (no separate click).
- Calc slots bind by key (
data-po-key/data-iv-key) → global dosing configs. - Drug formulations / identity: looked up from the global drug DB by slug (
data-drug).
Phase 0 decisions — RESOLVED (2026-07-07)
Primary/Alt regimen — RESOLVED: auto-close-on-open (default combo behavior). Two combos inside one non-multi regimen; default cross-combo close means selecting Alt collapses Primary. Zero new code. A true nested toggle (Alt visually replaces Primary) is rejected for now — revisit only if auto-close proves confusing in real use.
MRSA / Pseudomonas — RESOLVED (2026-07-07, refined against the legacy CAP page).
The data-risk system is an informational disclosure, NOT a coverage gate. It renders a labeled accordion (e.g. “Pseudomonas Risk Factors”, “MRSA Risk Factors”) that expands to show the risk-factor reference list from ABX_RISK_DATA. It never hides or reveals drugs — the clinician reads it to decide whether the patient needs coverage. Risk accordions are independent free disclosures, outside the regimen exclusivity tree; they never participate in combo/group close.
Coverage itself is driven by regimen structure, not by the risk accordions:
- Pseudomonas = a disease-level flag (
pseudomonas: true/false) surfaced through theclassifyscenario radio. On a pseudomonas disease (e.g. CAP), the “Rx Selector” radio includes a WITH-Pseudomonas scenario whoseregimencarries antipseudomonal agents (an antipseudomonal β-lactam group AND an antipseudomonal fluoroquinolone group). Selecting that radio shows the antipseudomonal regimen. Pseudomonas coverage is a clinical scenario in theclassifyradio, not a boolean toggle. A non-pseudomonas disease simply has no such scenario authored. - MRSA = a disease-level relevance decision (
mrsa: true/false), symmetric to the pseudomonas flag but surfaced differently. The MRSA apparatus is authored only for diseases where MRSA is clinically plausible (e.g. CAP — MRSA pneumonia occurs). Diseases where it is not (e.g. sinusitis → augmentin) carry no MRSA group at all. When present, it is an always-visible OPTIONAL group at the foot of the regimen (“Consider ONE Anti-MRSA — ONLY if risk factors”), with the “MRSA Risk Factors” disclosure beside its header — the clinician picks one if indicated or ignores the group. When MRSA is primary for a disease (e.g. purulent SSTI), its agents are instead a required/primary group in that disease’s regimen rather than an optional trailing group. - Coexistence is automatic: the pseudomonas-scenario regimen simply contains the optional MRSA group at its foot. MRSA + Pseudomonas coverage appear together with zero boolean logic.
This supersedes the earlier “two independent boolean toggles that reveal coverage” framing and resolves the built-in sub-question: built-in coverage is never reveal-gated — Pseudomonas is scenario-selected, MRSA is always-shown-and-optional. data-risk never gates drug visibility.
(The row of pill chips in the legacy screenshot — Reset Allergies / Penicillin / Ceph₃ / Ceph₄ / … — is the separate allergy filter / allergy map, a Phase 3 island, not part of this decision.)
Data model
Per-disease dose records (Option A). Doses are per-infection clinical decisions, not shared globals. Clinical evidence arrives and updates per disease — no research paper forces a global amoxicillin change. Records are scoped per disease.
Split drug data into two types:
- Drug IDENTITY (formulations, class, safety, brand names) = one global record, one source of truth. Rarely changes. Referenced by slug. Never duplicated per page.
- Dose-in-context (dose, route, frequency for this infection) = many records, one per disease context. Expected to be numerous (thousands at completion) and that is correct.
Each regimen entry is a structured record that carries its own disease reference plus its structural fields. Both disease pages and drug pages are build-time-derived views filtered from the same collection (disease pages filter by disease; drug pages filter by drug). Membership/context lives on the entry as structured data — this gives single-source integrity (what renders and what you audit are the same source; they can’t drift).
Naming:
- Display name (human-facing): e.g.
CAP — Amoxicillin (adult). Can change freely. - Stable KEY (engine binding): encodes route/dose/freq, e.g.
cap-amox-po-1000-q8h. Pages bind to the KEY, never the display name — so renaming never breaks references. - Order the human name for how it’s searched (drug-first if search is drug-led).
Structured fields, not prose
Every fact the build needs to gather or compare must be a labeled field, not buried in a sentence.
- Fielded (data):
drug,dose,route,frequency,tier(ranking),paired_with,population,duration. - Prose (rich-text
notes): editorial reasoning / clinical judgment — the “why this beats the alternative.” Never parsed; displayed as-is.
Add tier and paired_with to the schema from day one. They make drug→disease pages a free build step later. Retrofitting them across 121 pages is expensive.
Verification note: Claude writes fields fast (mechanical), but tier and paired_with are clinical editorial judgments = the builder’s red-pen, same as dose. A fielded judgment propagates to derived drug pages, so a wrong value spreads to every view that reads it. Do not auto-accept tier/pairing as if they were mechanical.
Search specifics (Pagefind)
- Global instant search lives in the layout (built once → present on every page).
- Adult/Peds filter = Pagefind filter tags (
data-pagefind-filter), driven by thepopulationfield on each entry. This is the island that needs the shared store (it affects the rest of the page). - Required build task, not optional polish: a drug alias/synonym map so typos and brand/generic names resolve (e.g. “azithromyacin”, “Bactrim”↔“TMP-SMX”, “co-amoxiclav”↔“amoxicillin-clavulanate”). Pagefind’s out-of-box typo tolerance is weak; without the alias map, search will be worse than the old Finsweet setup on exactly the queries clinicians make.
- Add failed-query logging (client-side fetch to an analytics endpoint) so the alias map can be improved from real misses.
Engineering philosophy (Astro-native, lean — supersedes “port as-is”)
Legacy code is a behavioral spec, not source to import. Mine the old files for behavior/clinical logic, then re-express lean in the modern idiom. Do not carry over DOM-soup, jQuery-isms, Webflow scaffolding, or unused config. (Full statement in CLAUDE.md — “Engineering philosophy”.)
- Static content/template: no JS. React islands only where UI needs state (calculators, shared filters). Ported tools: re-express cleanly, don’t wrap old DOM code.
- One source of truth for anything appearing in >1 place (design tokens, clinical prose, shared UI, behavior) — duplication is a bug to fix.
- Data-driven, not hand-built: structure lives in the typed schema; one
[disease]/[drug]template renders whatever the record declares — never per-page copies. - Delete what isn’t needed — CODE only, NEVER clinical content. Dead code/Webflow scaffolding goes; medical content (doses, warnings, notes, drug facts, citations) is the product and is never deleted as “cleanup” — when unsure, keep and ask. Lean applies to the substrate, not the medicine.
- Correctness stays sacred: clinical math/values preserved exactly, still the builder’s red-pen. Modernizing the engine never changes the medicine. Prefer build-time failure (loud) over silent wrong renders.
Migration phases
- Phase 0 — Freeze spec (no code): finalize the DOM contract, the MRSA/Pseudo decision, and the Primary/Alt decision.
- Phase 1 — Prove the pipeline: toolchain + empty Astro project + hello-world deploy to Cloudflare Pages. Confirm push → build → live URL before real content exists.
- Phase 2 — One page end-to-end (the real test): template component, typed content-collection schema, engine rewrite for one page’s tools, one disease converted to full parity (working calc, accordions, toggles). Do not proceed until one page is genuinely at parity.
- Phase 3 — Port tools as islands: acid-base (one island), PO calc, IV calc, MRSA/Pseudo, allergy map. Each verified.
- Phase 4 — Bulk content port: remaining ~67 pages. Minutes each + a fast visual confirm against the original per page (the confirm is not optional).
- Phase 5 — Search: Pagefind, global instant search, Adult/Peds filter + shared store, alias map, query logging.
- Phase 6 — Auth + billing (isolated, near-last): Clerk auth (locked), then the billing provider chosen per Business model & payments below (Clerk Billing vs. MoR). Define the free tier vs. gated set. Gated pages must be SSR’d at the edge — a static site can’t truly gate (client-side hiding leaks the HTML). Test payment flows hard (trial→paid, cancel, failed card, expired card). This is the one place a bug is invisible and costs money/trust.
- Phase 7 — Cutover: DNS, URL redirects from Webflow paths (protect SEO), full parity sweep, go live. Kill the Webflow subscription only after Astro is confirmed stable in production.
Parity line (the stopping condition)
Migration is done when: all 68 existing pages render on Astro with working tools, AND a brand-new disease page publishes end-to-end (content in → rendered, searchable page out).
When both are true: infrastructure work STOPS and clinical content resumes. Not “when it’s clean.” Not “when the engine is elegant.” Those two conditions, then walk away from the substrate.
Guardrails / standing principles
- All-or-nothing, never silent (hard safety rule). A clinical tool must work 100% or not render at all. A broken tool that does nothing is strictly better than one that shows a wrong dose — wrong output is the worst possible outcome. Missing/misconfigured/half-wired data must fail the build (loud), never silently omit a tool or render a partial calculation a clinician could mistake for a real dose. Enforce with build-time guards (e.g. the PO calc’s poKey→config→drug-data guard throws at build). When in doubt, break loudly. This is the builder’s core disposition: “I’d rather have a broken tool that does nothing than a user give the wrong dose to a patient.”
- The red-pen clinical verification pass is the bottleneck. Nothing in this migration makes it faster. The migration buys the correct foundation and zero clinical velocity. Do not tell the builder otherwise.
- Flag medical-content errors immediately; log unfixed ones on the roadmap. Never quietly fix, guess past, or paper over clinical content — surface it to the builder. If it isn’t corrected in the moment, add it to
src/data/roadmap.ts(the/roadmapto-do list) so it survives. Lower-confidence clinical values you author (a guessed MDT, a derived formulation) get flagged the same way. A tracked possible-error beats a silent one. - Named failure mode: optimizing the substrate (perfecting the setup) instead of shipping. A blank-slate Astro build is where this runs wildest. Build to the parity line, not to perfect. Push back when substrate-polishing surfaces.
- Citation integrity is a real risk. Fabricated/misattributed PMIDs have appeared before. Verify PMIDs before publishing. Never reproduce guideline text verbatim; cite primary literature.
- External links open in a NEW TAB (
target="_blank" rel="noopener noreferrer") — research papers and any off-site link. Never navigate the user away from Exploding Bacteria. Automatic in Markdown/MDX (rehype-external-links); add explicitly for hand-written anchors / React. Internal links stay same-tab. - Keep Ask-permission mode on in Claude Code. Keep secrets out of the repo (env vars, gitignored). Lean on git — every change reversible.
- One source of truth for anything that appears identically across contexts (drug identity, dosing configs, shared UI).
Disease-shape features — now SCHEMA requirements (2026-07-08), not deferred
Reviewing the three legacy exemplars (CAP, otitis-media-peds, sexual-assault-PEP) shows most of these are structural, so they belong in the disease schema up front — a template built only around CAP cannot render otitis or PEP, which would force per-disease hacks (the mess we’re avoiding). They were originally logged as “deferred UI”; promoted because the single disease template must handle every shape on day one. Purely additive content (renal/hepatic rows, full per-drug safety authoring) can still arrive incrementally — but the schema must leave room for it. The list below is the feature inventory; the schema-shape summary lives in CLAUDE.md → Data model.
- Preferred-drug star — a star marker on the preferred option in a group (tooltip “Preferred drug”). Legacy
data-preferred. - Conditional checkboxes inside a drug accordion — user-tickable conditions (e.g. otitis: “No recent use of Amoxicillin AND”, “Typical sick presentation of OM”). Legacy
drug-condition-item. - Drug warning boxes — short cross-drug allergy warnings (e.g. “Do NOT give aztreonam to those with a Ceftazidime₃ allergy”) and long literature caveats (e.g. the amoxicillin max-daily-dosing note). Authored per drug/context; a basic caution box exists in the mockup, but the global
DRUG_WARNINGSinjection + allergy-linked warnings are deferred. - Escalating (“OR”) regimen type — the otitis-media pattern: single-drug boxes in sequence (Preferred → Alternative → Failed Above → Last Line), each with a contextual subtitle, escalation logic “if not the first, then the second…”. Distinct from CAP’s AND-combination regimen. The engine needs to support this as a second regimen shape.
- Age-split doses/durations within a drug — a drug body showing age-banded values (e.g. “<24 Months → 10 days”, “>24 Months → 5–7 days”; or two Levofloxacin entries “6 mo–5 y” vs “≥5 y”). Legacy multi-card / age splits.
- Weight-based PO/IV dose calculators — the peds mg/kg calculators (already in the legacy engine); come online in the Pediatric population view.
- Renal / hepatic / elderly dosing rows — additional rows inside the drug accordion body (explicitly post-MVP per the builder).
- Full drug-safety data — Pregnancy / Breastfeeding / Warfarin status (✓ / ! / ✕ / ?) with hover tips, authored for every drug (mockup only demoed two).
Frozen visual-design direction (from the Phase-2 mockup)
Dark-default + light toggle; blue accent (trust; green reserved as a semantic “good” only). Monospace for structural headers + all numeric/dose text, humanist sans for prose. Left scroll-spy nav rail (collapses on mobile); single ~620px content column; sticky “Your Regimen” panel on wide screens. Regimen = decision boxes (one clinical choice each), drugs as accordions inside; a regimen-formula strip (slots + +/±, doses included) communicates how many drugs to pick. Page-level Drug Safety icon overlay; per-drug Drug Info panel (formulations/brands/safety, later renal/hepatic). Unified Adult/Pediatric toggle → one page per disease, one search result.