Skip to content

Visual redesign + unified search bar + NL UX improvements#1

Merged
johnoooh merged 17 commits into
mainfrom
redesign/phase-1-visual-system
May 7, 2026
Merged

Visual redesign + unified search bar + NL UX improvements#1
johnoooh merged 17 commits into
mainfrom
redesign/phase-1-visual-system

Conversation

@johnoooh
Copy link
Copy Markdown
Owner

@johnoooh johnoooh commented May 7, 2026

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)

  • New parchment + iris-violet + signal scales as CSS custom properties.
  • Source Serif 4 (display), Inter Tight (UI), JetBrains Mono (technical) loaded via Google Fonts.
  • Shimmer / fadein / pulse keyframes.
  • Tailwind utilities now read from the same CSS vars, so bg-parchment-100 and inline var(--p-100) references stay in sync.

Header / privacy / dedication

  • New header: lowercase serif "iris" + italic tagline + on-device privacy chip + "Gemma 2 2B · on-device" iris-violet pill on the right.
  • Privacy paragraph collapsed to a <details> chip ("on-device only — what this means").
  • Dedication moved into footer as an "about iris" disclosure (preserved verbatim).
  • Old PrivacyStatement strip removed entirely — was the visual seam between header and search bar.

Unified search bar (new component)

  • Real ARIA tablist with two tabs: "Describe in your own words" (with mono AI · on-device badge) and "Structured form". Only the active panel renders.
  • After NL extraction: understood: chips show what was parsed, search fires automatically. User stays on the NL tab.
  • Textarea is editable while the model downloads. Submit during download queues and auto-fires when status flips to ready.
  • Iris-violet CTAs and focus rings on both inputs; iris checkbox accents on the structured-form phase pickers.

Two-pane triage results layout

  • 400px list pane + 1fr detail pane, collapses to single column under 820px with a tap-to-open bottom sheet.
  • Compact TriageRow: serif title (2-line clamp) + mono 0.1 mi · Phase 3. Selected row gets iris-violet 3px left border + soft shadow.
  • Detail pane: serif h2 title, mono uppercase section labels, mono caption meta, contact block under a parchment-200 top border with iris-violet links.
  • Toolbar: search summary line ("12 trials · near Boston · within 50 mi · recruiting") + sort chips. Sort chips are deliberately disabled with consistent tooltips until sort wiring lands in a follow-up.
  • 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)

  • Checkbox per row (iris-violet accent), max 3 pinned.
  • Sticky bar at bottom shows count + Clear + disabled Compare → button. The dedicated compare view itself is later work per Handoff.

Mobile polish

  • Header tagline + on-device chip hide below sm/md so the brand + LocalAIBadge fit at 375px.
  • Search form, detail pane, and toolbar padding tighten on small screens.
  • Sort group hidden below sm (it was wrapping awkwardly).

CI

  • New .github/workflows/ci.yml: Node 22, runs npm ci → lint (non-blocking) → test:run → build on PRs to main and pushes to main.
  • Concurrency group cancels stale runs on the same ref.
  • Lint is non-blocking because vite.config.js 'process' is not defined predates this branch — the build + tests are the merge gate.

Bug fixes uncovered along the way

  • StrictMode listener bug in useNLP / useSimplifier: cleanup detached the worker listener but left detachRef.current truthy, and hasAutoLoaded.current survived the StrictMode bounce. After the dev double-invoke, no listener was attached — worker's ready broadcast to an empty Set, status sat on downloading forever. Production was unaffected (StrictMode is dev-only). Fixed by nulling detachRef.current after detach and resetting hasAutoLoaded.current in the auto-load cleanup.
  • manifest.json 404: index.html referenced /iris/manifest.json but Vite's base: '/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.
  • WebLLM cache busted by port bouncing: pinned dev server to port 5173 with strictPort: true. WebLLM caches the model in IndexedDB keyed by origin; a port change forces a fresh ~1.3 GB download.

Accessibility

  • Row titles restored to <h3> so screen readers can navigate by heading.
  • MobileSheet: role="dialog", aria-modal, focus management on open/close, Esc to close.
  • Search bar uses role="tablist" / role="tab" / role="tabpanel" with aria-selected.
  • Compare uses real <input type="checkbox"> with descriptive aria-label.
  • Privacy + dedication use native <details>/<summary>.
  • RTL direction auto-detected for Arabic-language presets (used in the harness, but the helper is generic).

Out of scope (deferred)

  • Phase 3 classification (the actual fit meter that reads each trial against the patient description) is a separate PR on phase-3-classification-harness. Builds on top of this branch.
  • Real sort wiring (Distance / Phase / Most recent) — chips are visible but disabled with consistent tooltips. The API supports sort so wiring is straightforward; deferred to its own PR.
  • Compare view itself — placeholder button only.
  • sessionStorage persistence — Phase 6, optional per Handoff.
  • Tests for new components (TriageRow, MobileSheet, UnifiedSearchBar, CompareBar, ResultsToolbar). Existing 194 tests cover the underlying behavior; new chrome is untested.

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 pass
  • npm run build — clean
  • npm run lint — only pre-existing errors/warnings on untouched files
  • Manual: NL search → results render in two-pane layout
  • Manual: structured-form search via the form tab works as before
  • Manual: compare checkboxes respect 3-trial cap; sticky bar shows count
  • Manual: typing during model download → submit queues → auto-fires when ready
  • Manual: resize below 820px → single column, tap row → bottom sheet slides up; Esc closes
  • Re-run Lighthouse a11y to verify iris-violet links meet AA contrast on parchment backgrounds
  • CI workflow goes green on the PR

johnoooh added 17 commits May 6, 2026 19:38
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 johnoooh merged commit 7da9c5a into main May 7, 2026
1 check passed
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant