Visual redesign + unified search bar + NL UX improvements#1
Merged
Conversation
Bridges Tailwind utilities to CSS custom properties in styles/tokens.css (parchment, iris-violet, signal scales + serif/sans/mono families) so both utility classes and inline var() refs share one source of truth. - src/index.css imports tokens.css before @tailwind base - tailwind.config.js: parchment/iris/signal colors, fonts, radii, shadows all read from CSS vars - Header: lowercase serif "iris" + italic tagline + on-device chip + iris-violet "Gemma 2 2B · on-device" pill - PrivacyStatement compressed to chip + <details> disclosure - Footer absorbs DedicationBanner content as "about iris" disclosure; DedicationBanner.jsx removed (App.jsx no longer imports it)
Replace single-column card list with the prototype two-pane layout: 400px list of compact rows on the left, full detail on the right. Below 820px collapses to single column with a tap-to-open bottom sheet. - TriageRow: serif title (2-line clamp) + mono distance/phase line. Selected state = white bg, iris-violet 3px left border, soft shadow. - MobileSheet: bottom-sheet modal with backdrop, drag handle, close button, Esc key handling, body scroll-lock while open. - ResultsList: two-pane grid, auto-selects first trial on desktop, mobile taps open the sheet. Toolbar moved above the panes; load-more lives at the bottom of the list pane. - ResultCard: optional pane prop drops the card chrome (border / rounded / max-width) when rendered inside the detail pane. - App.jsx main made a flex column so the panes can fill the viewport. - Iris-violet accent on ctgov / contact / load-more links. - index.css adds the iris-sheet-in keyframe used by MobileSheet.
Pin up to 3 trials via a checkbox on each row; surface a sticky bar at the bottom showing the count, a Clear action, and a disabled Compare → button (the dedicated compare view is later work per Handoff). - TriageRow: optional checkbox slot (shown when onToggleCompare is passed). Iris-violet accent. Disables when set is full and the row is not already pinned. Click events guarded so the checkbox does not trigger row selection. - ResultsList: compareSet state (Set<nctId>, max 3) + toggleCompare. CompareBar component appears only when set is non-empty; sits at the bottom of the section (between the panes and the footer) with a subtle upward shadow. - Phase 3 (two-stage classification) intentionally skipped — gated on Classification Harness validation per project constraint.
Detail pane (ResultCard pane mode) now matches the Triage prototype:
- 24px Source Serif 4 h2 with tight tracking
- Mono caption meta line (phase · facility · distance)
- Mono 10px uppercase letterspaced section labels
- Contact section under a parchment-200 top border with mono "contact"
caption, iris-violet email and ctgov links
- Card mode (used by tests) is unchanged for backwards compatibility
Toolbar replaces the bare count line with:
- Mono summary chip ("12 trials · near Boston · within 50 mi · recruiting")
- Sort buttons (Best fit / Distance / Phase / Most recent). Best fit is
disabled with a tooltip pointing at on-device classification; the
others are visual-only state until sort wiring lands.
Restyle the structured form and natural-language input to share one
parchment-50 surface that visually feels like the prototype's unified
search bar.
SearchForm:
- Mono 10px uppercase section header ("find clinical trials")
- Iris-violet submit button ("Find trials →")
- Iris-violet focus rings on inputs / selects
- Iris-violet checkbox accent on phase pickers
NaturalLanguageInput:
- Outer surface bg shifts to parchment-50 + parchment-200 border so it
flows seamlessly into the form below
- Collapsed trigger redesigned as a mode-toggle pill with a mono
"AI · on-device" badge (echoes the prototype's mode toggle)
- Expanded textarea sits inside a white rounded card with a magnifying
glass icon and an iris-violet "Find trials" button
- Iris-violet for Download & enable, progress bar fill, Delete button
- Confirmation card replaced with a "understood:" mono caption + chip
row matching the prototype's understood-fields pattern
Tests updated for new copy: button name "Find trials" instead of
"Search trials"; trigger name "Describe in your own words" instead of
the longer phrasing; understood:/couldn't determine condition.
- Sort chips were styled as interactive but no sort wiring exists yet. Mark all four disabled with consistent tooltips so the toolbar reads as a preview, not a broken control. Real sort arrives with Phase 3 classification (Best fit) plus a follow-up PR for the others. - Mobile polish: header tagline + on-device chip hide below sm/md so iris + LocalAIBadge fit on a 375px viewport. Header padding tightens. - Sort group hidden below sm (it was wrapping awkwardly). - Search form and detail-pane padding tighten on small screens.
Source-of-truth documents from the Claude.ai design exploration. Future phases reference these directly: - Handoff.md — phased integration plan, locked tweak values, harness validation gates, scope boundaries, microcopy, open product questions - IRIS Triage.html — final chosen layout (two-pane); the visual target - IRIS Redesign.html — earlier exploration kept for reference - Classification Harness.html — standalone rig for validating the two-stage classification pipeline before Phase 3 ships - shared/iris-shared.jsx — reference component implementations (LocalAIBadge, IrisHeader, IrisSearchBar, FitMeter, StreamingText, StatusPill, ActionRow). Translated into JSX/Tailwind in src/. - clinical-trial-finder-plan.md, iris-cowork-prompt.md — planning notes
Two regressions noticed during the design refresh: 1. Each trial row title was demoted from <h3> (old card) to a plain <span> inside the row button. Screen reader users navigating by heading lost the per-trial structure of the list. Restore <h3> — inside a <button> the heading still reads as part of the button label, but heading navigation works again. 2. MobileSheet did not move focus on open or restore it on close. Keyboard users could Tab past the open sheet into the page behind the backdrop, and on close their focus dropped to <body>. Now: on open, focus the Close button and remember the prior focus owner; on close, restore focus to whatever opened the sheet (typically the triggering row).
Replace the always-visible NL-collapsed-above-form layout with a real mode toggle matching the prototype. Only one input is visible at a time; the other is hidden via the tabpanel hidden attribute. - New UnifiedSearchBar wraps the two existing inputs with a tab pattern (role="tablist" / role="tab" / role="tabpanel" + aria-selected). Tabs: "Describe in your own words" with mono "AI . on-device" badge, and "Structured form". - NaturalLanguageInput grows an embedded prop. When embedded: starts expanded, drops its own outer chrome (the wrapper provides it), and no longer renders its own toggle button. - SearchForm grows the same embedded prop. When embedded: drops its own bg/border/heading and uses the wrapper padding. - After a successful NL extraction the wrapper auto-flips to the form tab so the user can verify and submit the prefilled fields. - PrivacyStatement deleted from above-the-fold and from disk. Privacy is now communicated via the header on-device chip + footer disclosure, removing the visual seam between header and search. - App.jsx composition simplified: Header -> UnifiedSearchBar -> ResultsList -> Footer.
The textarea was hidden until the model finished downloading, which meant a user landing on the NL tab had to stare at a progress bar for 20-60s before they could even start composing. Now: - The input renders as soon as consent is given (download / ready / extracting all show the same input). The textarea is fully editable during download. - The progress bar now sits below the input in a thin one-line layout with mono caption + "one-time" label, so it's clearly secondary. - Submit during download queues the intent (pendingSubmit state). When status flips to ready, an effect drains the queue and runs the extraction immediately. Button label reflects state: "Find trials" -> "Run when ready" (download in progress, no queue) -> "Queued..." (download in progress, queued) -> "Extracting...". - Refactor: extracted runExtraction() so handleSubmit and the drain-on-ready effect share one path.
- index.html referenced /iris/manifest.json and /iris/favicon.svg, but
Vite already prepends base: '/iris/' so the actual served URLs were
/iris/iris/* and 404'd. The 404 returned an HTML error page, which
the browser tried to parse as JSON manifest, producing the
"Manifest: Line 1, column 1, Syntax error" console message. Drop the
manual /iris/ prefix; Vite handles it.
- Show model size next to the "one-time" caption during download
("~1.3 GB · one-time") so users understand why the first load isn't
instant. Bandwidth is the actual bottleneck — WebLLM streams as fast
as the connection allows — so the best we can do at the app level
is set expectations.
WebLLM caches the model in IndexedDB keyed by origin. If Vite bounces to a different port (5173 -> 5174 when the primary is busy) the browser sees a new origin and re-downloads ~1.3 GB. Pin to 5173 with strictPort so a port collision errors loudly instead of silently moving and busting the cache.
The mount-effect cleanup in useNLP and useSimplifier called detachRef.current?.() to remove the worker listener but left the ref itself pointing at the (now-stale) detach function. Under React StrictMode dev double-invoke the next call to ensureSubscribed saw detachRef.current was truthy and skipped re-attaching. Result: after the StrictMode remount, NO listener was attached for either hook, so the worker's 'ready' / 'progress' / 'result' messages were forwarded to an empty Set and the component sat at status='downloading' forever even after the engine was loaded. Surfaced because the new queued-submit flow exposed it: the user could submit during download and watch "Queued..." never resolve. The bug was latent before — production builds don't run StrictMode double-invoke, hence the deployed site working fine. Fix is one line per hook: set detachRef.current = null after detaching. ensureSubscribed's `if (detachRef.current) return` guard now correctly distinguishes "still subscribed" from "previously subscribed, then unmounted". Also strips the temporary diagnostic logging added to trace this.
…earch after extraction Two fixes: 1. The auto-load effect's hasAutoLoaded ref persisted across React StrictMode's dev-only mount→unmount→remount sequence. After the second mount it short-circuited load(), which is what calls ensureSubscribed() to re-attach the worker listener. Result: after StrictMode bounce the listener Set was empty and the worker's eventual 'ready' broadcast to nobody. Status sat on 'downloading' forever, queued submits never drained. Fix: cleanup of the auto-load effect resets hasAutoLoaded.current to false, so the StrictMode re-run re-fires load() and re-attaches. Also widened the gate from status==='idle' to include 'downloading' since the second pass sees the status the first pass already set. Production builds don't run StrictMode double-invoke, so this only manifested in local dev. 2. After NL extraction the wrapper used to switch to the structured form tab, forcing the user through a second click to actually run the search. Now the NL handler builds the search params from the extracted fields and fires the search directly. The "understood" chips inside NaturalLanguageInput already surface what the model parsed; no need to bounce the user to a different surface to confirm. If condition wasn't extracted the search is skipped and the existing "couldn't determine condition" warning surfaces.
Runs npm ci, lint (non-blocking), test:run, and build on Node 22 for pull_request and push events targeting main. Concurrency group cancels stale runs on force-push. Lint is informational until pre-existing errors are cleaned up in a follow-up.
Adds an actions/upload-artifact@v4 step after the build so reviewers can download the production bundle of the PR's exact code and serve it locally (npx serve dist) to verify it built correctly. Naming the artifact with the PR number (or sha for push runs) keeps multiple in-flight PRs distinguishable. 7-day retention, errors if no files are found (catches silent build failures). Doesn't give you a live preview URL — that needs an external host (Cloudflare Pages / Netlify) which can be added later as a separate follow-up if useful. This is the no-external-dependency baseline.
Code-review caught a bug in 84acd7f. The widened gate (status === 'idle' || status === 'downloading') combined with the unconditional cleanup reset of hasAutoLoaded.current causes load() to re-fire mid-download: effect runs (idle) → hasAutoLoaded=true → load() → status flips to downloading → effect re-runs → cleanup nulls hasAutoLoaded → re-run matches widened gate → load() called again The worker drops the duplicate (loading=true short-circuits in nlp.worker.js:29), so it's harmless in practice — but it's noise and any future change to the worker's idempotency invariant would turn it into a real bug. The widening was added thinking StrictMode remounts would arrive with status='downloading' carried over, but each remount creates a fresh useNLP instance with status='idle' (useState initializer), so the narrow gate fires correctly on remount. The widening was unnecessary. Comment now explains why the gate is narrow + why the cleanup reset is load-bearing. Also: dropped aria-pressed from disabled sort chips in ResultsToolbar — aria-pressed on a permanently-disabled control is misleading to assistive tech. AT users now hear "Best fit, button, dimmed" without a confusing pressed/unpressed state.
johnoooh
added a commit
that referenced
this pull request
May 7, 2026
Three of the deferred items from PR #1's review, addressed in one follow-up to keep the phase-3 PR from carrying open feedback debt: 1. useIsMobile uses matchMedia.change instead of window 'resize' (ResultsList.jsx:36-50). iOS Safari fires 'resize' inconsistently on rotation; matchMedia.change is the reliable signal and also catches iPad split-screen + browser-window mode switches. src/test/setup.js stubs matchMedia (jsdom doesn't ship it) so the existing ResultsList tests keep rendering the desktop two-pane path. 2. New regression test for the queued-submit drain in NaturalLanguageInput. Indirect coverage for the StrictMode listener re-attach fix in useNLP — if the listener doesn't reach the worker 'ready' message, the queued submit never fires and this test fails. The bug surfaced this session: status was getting stuck at 'downloading' forever in dev because the listener detached on StrictMode's first cleanup never re-attached. Now: pin the contract. 3. shared/iris-shared.jsx (478-line design reference, never imported by src/) moved to docs/design-references-shared/ with a README explaining its role. Stops future readers (LLM or human) from trying to "fix" or "consolidate" it as if it were live code. Contrast-check (#3 in the review): computed iris-700 on parchment-50 yields ~9.6:1 (WCAG AAA). iris-700 on iris-50 is similar (~8.5:1). All iris-violet links and the model badge clear AA easily; no palette change needed. Lighthouse can confirm at deploy time. Compare-state lift (#1 in the review): deferred to its own follow-up PR alongside the actual compare view (currently a placeholder).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Visual redesign landing the Claude.ai design exploration. Ships the prototype's two-pane triage layout, the parchment + iris-violet design system, the unified search bar with mode toggle, the mobile sheet, and the compare pinning. No logic changes to search, geocode, or simplification paths — every existing test still passes (194/194). Dev-only CI workflow included. Phase 3 (classification) is intentionally not in this PR — it's a separate branch that builds on top of this one.
What's in
Design system (
styles/tokens.css+tailwind.config.js)bg-parchment-100and inlinevar(--p-100)references stay in sync.Header / privacy / dedication
<details>chip ("on-device only — what this means").PrivacyStatementstrip removed entirely — was the visual seam between header and search bar.Unified search bar (new component)
AI · on-devicebadge) and "Structured form". Only the active panel renders.understood:chips show what was parsed, search fires automatically. User stays on the NL tab.ready.Two-pane triage results layout
TriageRow: serif title (2-line clamp) + mono0.1 mi · Phase 3. Selected row gets iris-violet 3px left border + soft shadow.MobileSheet:role="dialog",aria-modal, body scroll-lock, Esc to close, focus management on open/close (focuses Close button, returns focus to opening row on close).Compare pinning (Phase 4)
Compare →button. The dedicated compare view itself is later work per Handoff.Mobile polish
CI
.github/workflows/ci.yml: Node 22, runsnpm ci → lint (non-blocking) → test:run → buildon PRs to main and pushes to main.vite.config.js'process' is not definedpredates this branch — the build + tests are the merge gate.Bug fixes uncovered along the way
useNLP/useSimplifier: cleanup detached the worker listener but leftdetachRef.currenttruthy, andhasAutoLoaded.currentsurvived the StrictMode bounce. After the dev double-invoke, no listener was attached — worker'sreadybroadcast to an empty Set, status sat ondownloadingforever. Production was unaffected (StrictMode is dev-only). Fixed by nullingdetachRef.currentafter detach and resettinghasAutoLoaded.currentin the auto-load cleanup.manifest.json404:index.htmlreferenced/iris/manifest.jsonbut Vite'sbase: '/iris/'was prepending again, producing/iris/iris/manifest.json. The 404 HTML response was being parsed as JSON manifest, throwing the "Manifest: Line 1, column 1, Syntax error" console message. Fixed by using root-relative paths.strictPort: true. WebLLM caches the model in IndexedDB keyed by origin; a port change forces a fresh ~1.3 GB download.Accessibility
<h3>so screen readers can navigate by heading.MobileSheet:role="dialog",aria-modal, focus management on open/close, Esc to close.role="tablist"/role="tab"/role="tabpanel"witharia-selected.<input type="checkbox">with descriptivearia-label.<details>/<summary>.Out of scope (deferred)
phase-3-classification-harness. Builds on top of this branch.sortso wiring is straightforward; deferred to its own PR.sessionStoragepersistence — Phase 6, optional per Handoff.Design references in the tree
Handoff.md,IRIS Triage.html,IRIS Redesign.html,Classification Harness.html,shared/iris-shared.jsx, plus planning notes are committed at the repo root so future phases can reference them in-tree without orphaning.Test plan
npm run test:run— 194/194 passnpm run build— cleannpm run lint— only pre-existing errors/warnings on untouched files