Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
094b2c6
feat(design): Phase 1 visual system — tokens, header, privacy chip
johnoooh May 6, 2026
ddd7b58
feat(design): Phase 2 — two-pane triage layout
johnoooh May 6, 2026
7941b04
feat(design): Phase 4 — compare pinning + sticky bar
johnoooh May 6, 2026
11f452f
feat(design): detail-pane prototype styling + enriched toolbar
johnoooh May 7, 2026
ec635c6
feat(design): unified search surface — iris CTA, mono labels, NL chip
johnoooh May 7, 2026
067d1b1
fix(design): mobile padding + disable inert sort chips
johnoooh May 7, 2026
01b2291
docs(design): add handoff + prototype reference assets
johnoooh May 7, 2026
9f35dc6
fix(a11y): row title heading + sheet focus management
johnoooh May 7, 2026
5e572eb
feat(design): real mode toggle in unified search bar
johnoooh May 7, 2026
6a5d605
feat(nlp): allow typing during model download with queued submit
johnoooh May 7, 2026
2f51ed5
fix: manifest 404 + surface model size during download
johnoooh May 7, 2026
a1106b3
fix(dev): pin dev server to port 5173 to preserve WebLLM cache
johnoooh May 7, 2026
7916ea9
fix(nlp): null detachRef on unmount so StrictMode remount re-subscribes
johnoooh May 7, 2026
84acd7f
fix(nlp): re-attach worker listener after StrictMode remount + auto-s…
johnoooh May 7, 2026
7c69a07
ci: add build+test workflow for PRs into main
johnoooh May 7, 2026
dea7ba5
ci: upload built dist/ as a workflow artifact for PR review
johnoooh May 7, 2026
3c2e750
fix(nlp): tighten auto-load gate to status==='idle' only
johnoooh May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

# Cancel in-progress runs for the same ref so a force-push doesn't queue
# stale CI.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build-test:
name: build-test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
# vite.config.js conditionally emits --no-experimental-webstorage
# for Node >= 22, so CI must run Node 22 to match local dev.
node-version: 22
cache: 'npm'

- name: Install dependencies
run: npm ci

# Lint currently fails on pre-existing errors in main (e.g. vite.config.js
# 'process' is not defined, useNLP exhaustive-deps warnings). Run as a
# non-blocking informational step until those are fixed in a follow-up;
# build + tests are the real merge gate.
- name: Lint (non-blocking)
run: npm run lint
continue-on-error: true

- name: Test
run: npm run test:run

- name: Build
run: npm run build

# Upload the built dist/ as a workflow artifact so reviewers can
# download and serve it locally (e.g. `npx serve dist`) to verify the
# production bundle of the PR's exact code. Retention is short to
# avoid clutter; the artifact is for review-time only, not archival.
- name: Upload built site
uses: actions/upload-artifact@v4
with:
name: dist-${{ github.event.pull_request.number || github.sha }}
path: dist/
retention-days: 7
if-no-files-found: error
338 changes: 338 additions & 0 deletions Classification Harness.html

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions Handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# IRIS — Claude Code handoff

Design exploration is settled. This document is the bridge from the Claude.ai prototypes to the live `johnoooh/iris` codebase. Hand it to Claude Code along with the three artifact files.

---

## What's settled

**Direction:** Triage two-pane layout (list + detail). Mobile collapses to list + tap-to-sheet.

**Visual system:** Evolved warm-parchment palette + new iris-violet accent. Source Serif 4 (display), Inter Tight (UI), JetBrains Mono (technical). Tokens live in `styles/tokens.css`.

**Locked tweak values from the prototype:**
- Accent: `iris` (the violet)
- Density: `comfy`
- List width: `400px`

**New patterns introduced:**
- **Fit meter** — three-bar visualization (Likely / May / Unclear fit) shown per row and per detail pane
- **Two-stage AI pipeline** — fast classify pass, then on-demand full simplification (still being validated, see harness)
- **Local-AI badge** — always-visible mono pill in header announcing on-device model
- **Compare** — pin up to 3 trials; sticky bar; compare-view page is later work
- **Streaming shimmer** — placeholder lines shimmer until tokens land, then fade in
- **Unified search** — NL + structured form become one input with a mode toggle

---

## Files to reference

| File | Role |
|---|---|
| `IRIS Triage.html` | Final chosen direction. Reference for layout, spacing, microcopy. |
| `styles/tokens.css` | Drop-in CSS variables. Colors, fonts, shadows, shimmer keyframes. |
| `shared/iris-shared.jsx` | Reference implementations of header, search bar, fit meter, status pill, streaming text, action row. **Translate to your stack** (vanilla JS / whatever the live app uses) — don't copy React if the app isn't React. |
| `Classification Harness.html` | Standalone rig for validating the two-stage classify before wiring it in. |

---

## Integration plan (recommended order)

### Phase 1 — Visual system (low risk, ship first)

1. **Add Google Fonts link** to `<head>`:
```html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700&family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
```
2. **Drop in `styles/tokens.css`** as new variables alongside existing ones; gradually replace the old palette.
3. **Replace header markup** with the new dense header + privacy chip + local-AI badge.
4. **Demote dedication banner** to a footer or "About IRIS" disclosure. Don't lose it — it's part of the project's soul, just not above-the-fold task-blocking.
5. **Compress the privacy paragraph** into the on-device chip + an expandable details element with the long form.

Ship after Phase 1 — already a meaningful UX improvement, no logic changes.

### Phase 2 — Row format + accordion or two-pane

The current cards are full-width prose blocks. Swap for compact rows:

```
[ ☐ ] Phase IIIb Study of Ribociclib + ET in Early Breast Cancer
▌▌▌ Likely fit · 0.1 mi · Phase 3
```

Two implementation paths — pick one based on engineering appetite:

- **(a) Accordion in place** — easier. Click a row, it expands inline with the detail content. Works at any width. No layout fork.
- **(b) Two-pane** — matches the prototype. CSS grid `grid-template-columns: 400px 1fr`, collapses to single-column under 820px (`@media` query + state-driven sheet on mobile).

Recommend **(a) for first pass**, **(b) when you're ready to invest in the layout fork.**

### Phase 3 — Two-stage classification

**Don't ship this until the harness validates it.** See [Classification harness](#classification-harness) below.

When ready:
1. After search returns trial list, immediately render rows with title/distance/phase only — no fit meter yet.
2. Kick off `classifyAll(trials, userDesc)` with concurrency 2–3.
3. As each verdict returns, update that row's fit meter in place.
4. Show `evaluating fit · 7 of 20` indicator in the toolbar while running.
5. Once stage 1 is complete, default sort flips to "Best fit"; collapse UNLIKELY trials under a `12 less likely matches` disclosure.
6. Stage 2 (full simplification) only fires for the currently-selected trial in the detail pane, or top N likely matches as the user scrolls.

### Phase 4 — Compare

1. `Set<nctId>` in memory, max size 3.
2. Checkbox on each row.
3. Sticky bar appears when set is non-empty: `[ 2 in compare ] [ Compare → ]`.
4. Compare view itself is later — start with a placeholder route.

### Phase 5 — Mobile polish

1. Bottom-sheet pattern: tap row → sheet slides up with full detail. Backdrop dismiss + close button + drag handle.
2. Sticky compare bar at bottom.
3. Compact search summary chip replaces the full search bar on mobile (tap to expand).

### Phase 6 — Persistence (optional, session-only)

1. `sessionStorage` only — no PII to disk, in keeping with the privacy story.
2. Save: search query, comparing set, currently-selected trial.
3. Clear on a "Start over" button.

---

## Classification harness

`Classification Harness.html` is a standalone page with a mocked `classifyOne()` that simulates 200–1500ms latency and ~85% parse success.

**To validate the real model:**

1. Open the harness.
2. Replace the body of `classifyOne()` with your live on-device call — the function signature is `(prompt, trial) => Promise<{ verdict, reason, raw, latencyMs }>`.
3. Run with the included fixture (6 trials, with expected verdicts).
4. Check the stats row: parse rate, avg latency, max latency, agreement with expected.

**Pass criteria for moving to Phase 3:**
- Parse rate ≥ 90% on 50+ real trials
- Avg latency < 1.5s per trial on a mid-range laptop
- Agreement ≥ 80% on a labeled held-out set
- No catastrophic UNLIKELY false-negatives (a viable trial ranked as UNLIKELY)

**If parse rate is low:** try constrained decoding, or tighten the prompt to demand a single token first (`Output a single token: LIKELY, POSSIBLE, or UNLIKELY. Then on a new line, one sentence of reasoning.`).

**If latency is high:** drop concurrency to 1 (avoid model thrashing on small WebGPU buffers), truncate eligibility more aggressively (1500 → 800 chars), or run only on the top 10 by simple keyword pre-filter.

---

## Things explicitly out of scope for this pass

- Compare view (3-up side-by-side) — deferred
- Account / login / save across sessions — deferred, conflicts with privacy story
- Server-side fallback for the model — not consistent with on-device promise
- Distance map view — nice-to-have, not on the critical path
- Question-prep checklist — separate feature, separate PRD

---

## Open questions for product

1. **Fit meter wording** — "Likely fit / May fit / Unclear fit" is the current draft. Does that read right, or do we want softer phrasing ("Worth a look / Maybe / Probably not")?
2. **UNLIKELY default behavior** — collapse them, or just sort to bottom? Risk of hiding viable trials if the model is wrong.
3. **Compare view** — which dimensions matter most? Probably: phase, distance, drug/intervention, eligibility deltas, contact info.
4. **Fit meter on mobile rows** — keep at full size or shrink to just the bars? Currently same component, both contexts.

---

## Microcopy already drafted

- Header sub: `clinical trial finder`
- On-device chip: `on-device only`
- Local-AI badge: `Gemma 2 2B · on-device`
- Mode toggle: `Describe in your words` / `Structured form`
- Mode toggle pill: `AI · on-device`
- Understood section: `understood:` (mono, lowercase)
- Section labels in detail: `What this study is testing`, `Who can join`
- Fit panel caption: `based on what you described`
- Toolbar count: `20 trials · near Boston · within 50 mi · recruiting`
- Sort options: `Best fit`, `Distance`, `Phase`, `Most recent`
- Compare bar (mobile): `**N** in compare` / `Compare →`
- Sheet handle: drag affordance only, no label
- Fit verdicts: `Likely fit`, `May fit`, `Unclear fit`
143 changes: 143 additions & 0 deletions IRIS Redesign.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>IRIS — redesign explorations</title>
<link rel="stylesheet" href="styles/tokens.css" />
<style>
html, body { margin: 0; height: 100%; }
body { font-family: 'Inter Tight', system-ui, sans-serif; background: #f0eee9; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>

<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="shared/iris-shared.jsx"></script>
<script type="text/babel" src="variations/editorial.jsx"></script>
<script type="text/babel" src="variations/triage.jsx"></script>
<script type="text/babel" src="variations/dossier.jsx"></script>
<script type="text/babel" src="variations/mobile-triage.jsx"></script>
<script type="text/babel" src="variations/mobile-dossier.jsx"></script>
<script type="text/babel" src="variations/shimmer-showcase.jsx"></script>

<script type="text/babel" data-presets="react">
function Note({ children }) {
return (
<div style={{
fontSize: 12, color: 'rgba(60,50,40,0.75)', lineHeight: 1.55,
maxWidth: 720, marginBottom: 8,
}}>{children}</div>
);
}

function App() {
return (
<DesignCanvas
title="IRIS — redesign explorations"
subtitle="Three results-layout directions on an evolved warm-parchment system. Same data, same streaming-AI behavior, different scanning models."
>
<DCSection
id="system"
title="Design system shifts"
subtitle="What's the same, what's evolved"
>
<DCArtboard id="legend" label="System notes" width={520} height={760}>
<div style={{ padding: '24px 26px', fontFamily: 'Inter Tight, sans-serif', fontSize: 13, lineHeight: 1.55, color: 'var(--p-900)', background: 'var(--p-50)', height: '100%', overflow: 'auto' }}>
<h2 style={{ fontFamily: 'Source Serif 4, serif', fontSize: 22, margin: '0 0 4px', color: 'var(--p-950)', letterSpacing: '-0.01em' }}>What I changed</h2>
<p style={{ color: 'var(--p-700)', margin: '0 0 18px', fontSize: 12.5 }}>Same warm parchment soul. Sharper hierarchy, an iris-violet accent, and a built-in scanning aid (the fit meter).</p>

<h4 style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--iris-700)', margin: '0 0 8px' }}>Type</h4>
<p style={{ margin: '0 0 4px' }}>
<span style={{ fontFamily: 'Source Serif 4, serif', fontSize: 22, fontWeight: 600 }}>Source Serif 4</span>
<span style={{ color: 'var(--p-700)', fontSize: 12, marginLeft: 8 }}>headlines &amp; trial titles</span>
</p>
<p style={{ margin: '0 0 4px' }}>
<span style={{ fontFamily: 'Inter Tight, sans-serif', fontSize: 16, fontWeight: 500 }}>Inter Tight</span>
<span style={{ color: 'var(--p-700)', fontSize: 12, marginLeft: 8 }}>UI &amp; body</span>
</p>
<p style={{ margin: '0 0 18px' }}>
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 13 }}>JetBrains Mono</span>
<span style={{ color: 'var(--p-700)', fontSize: 12, marginLeft: 8 }}>technical labels (echoes the privacy/local-AI ethos)</span>
</p>

<h4 style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--iris-700)', margin: '0 0 8px' }}>Color</h4>
<div style={{ display: 'flex', gap: 6, marginBottom: 4 }}>
{['var(--p-50)','var(--p-100)','var(--p-200)','var(--p-300)','var(--p-500)','var(--p-700)','var(--p-900)','var(--p-950)'].map(c => (
<div key={c} style={{ width: 36, height: 36, background: c, borderRadius: 4, border: '1px solid rgba(0,0,0,0.05)' }} />
))}
</div>
<p style={{ margin: '0 0 12px', color: 'var(--p-700)', fontSize: 11 }}>parchment — kept &amp; lightly retuned</p>
<div style={{ display: 'flex', gap: 6, marginBottom: 4 }}>
{['var(--iris-50)','var(--iris-100)','var(--iris-300)','var(--iris-500)','var(--iris-700)','var(--iris-900)'].map(c => (
<div key={c} style={{ width: 36, height: 36, background: c, borderRadius: 4, border: '1px solid rgba(0,0,0,0.05)' }} />
))}
</div>
<p style={{ margin: '0 0 18px', color: 'var(--p-700)', fontSize: 11 }}>iris — new, named for the namesake. CTA + AI accent.</p>

<h4 style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--iris-700)', margin: '0 0 8px' }}>New patterns</h4>
<ul style={{ margin: 0, paddingLeft: 18 }}>
<li><b>Fit meter</b> — visualizes how the user's described situation maps to eligibility. Three states: likely / may / unclear.</li>
<li><b>Streaming shimmer</b> — AI summary placeholders shimmer until tokens arrive, then fade in. Honest about latency.</li>
<li><b>Local-AI badge</b> — mono-set, always visible. Pulse dot when the model is working.</li>
<li><b>Unified search</b> — NL and structured form become one input with a mode toggle, not two stacked sections.</li>
<li><b>Compare</b> — pin up to 3 trials. Shared toolbar pill across variations.</li>
<li><b>Share / Save / Print</b> on every card. Save is session-only, no PII persisted.</li>
</ul>
</div>
</DCArtboard>
</DCSection>

<DCSection
id="results"
title="Results layout — three directions"
subtitle="The primary concern was scanability. Each variation answers it differently."
>
<DCArtboard id="editorial" label="A · Editorial list" width={920} height={900}>
<EditorialVariation />
</DCArtboard>
<DCArtboard id="triage" label="B · Triage two-pane" width={1180} height={900}>
<TriageVariation />
</DCArtboard>
<DCArtboard id="dossier" label="C · Dossier grid" width={1100} height={900}>
<DossierVariation />
</DCArtboard>
</DCSection>

<DCSection
id="mobile"
title="Mobile views — rebuilt for the form factor"
subtitle="Editorial works as-is on phone (single-column already). Triage and Dossier needed mobile-native rethinks: triage → tap-to-sheet, dossier → filter chips + collapsible cards. Sticky compare bar for both."
>
<DCArtboard id="editorial-m" label="A · Editorial (mobile) — direct port" width={390} height={760}>
<EditorialVariation />
</DCArtboard>
<DCArtboard id="triage-m" label="B · Triage (mobile) — list + bottom sheet" width={390} height={760}>
<MobileTriageVariation />
</DCArtboard>
<DCArtboard id="dossier-m" label="C · Dossier (mobile) — filter chips + collapsible" width={390} height={760}>
<MobileDossierVariation />
</DCArtboard>
</DCSection>

<DCSection
id="shimmer"
title="AI streaming — what loading looks like"
subtitle="The local model evaluates trials serially, which takes time. Toggle between Queued and In-progress to see how the wait reads."
>
<DCArtboard id="shimmer" label="AI streaming states" width={760} height={900}>
<ShimmerShowcase />
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
Loading
Loading