Skip to content

feat(nav): pin a shared ModeNav inside the universal header, wired to Therapy search - #1390

Merged
BigSimmo merged 11 commits into
mainfrom
claude/therapy-nav-redesign-350a0e
Jul 30, 2026
Merged

feat(nav): pin a shared ModeNav inside the universal header, wired to Therapy search#1390
BigSimmo merged 11 commits into
mainfrom
claude/therapy-nav-redesign-350a0e

Conversation

@BigSimmo

Copy link
Copy Markdown
Owner

Summary

  • Adds a shared ModeNav — an underline bar of icon + label slots that pins inside the universal header's collapse row, so it hides and reveals with the header at phone, tablet and desktop. Wired to Therapy only, on /therapy-compass/search only, as a first beachhead.
  • No header edits. The addon slot #phone-header-collapse-addon-slot (master-search-header.tsx:2153) is already rendered ungated inside data-testid="universal-header-collapse"; the phone-only limit lives in PhoneHeaderCollapsePortal's own matchMedia("(max-width: 639px)"), not in the header. ModeNavHeaderPortal is a sibling portal with that gate dropped, so it claims the same slot at every width and inherits the header's grid-template-rows: 1fr → 0fr collapse for free — one scroll source, no second listener, no possibility of desync.
  • Density steps down by container width in rem, never px, never measured at runtime: ≥34rem → 5 slots · 26–34rem → 4 · 16–26rem → 3 (last becomes More) · <16rem → current page + chevron. rem is the mechanism — raising the OS/browser text size grows the root font, so a phone collapses exactly when its labels would stop fitting rather than clipping them (WCAG 1.4.4 Resize Text, 1.4.10 Reflow at 320px). Labels are never abbreviated: a slot shows its real word or moves into More.
  • Therapy items: Search · Compare (n/4) · Recommend · Pathways. Order is load-bearing — at three slots the survivors are the library door and the only stateful destination.
  • The old 7-pill strip is byte-for-byte untouched and still serves compare / recommend / pathways / detail. workspace.tsx changes one line.
  • <button onClick><Link href>, so middle-click, deep links, back and prefetch start working on these destinations.

Files

New: src/components/mode-nav/{mode-nav,mode-nav-portal,mode-nav-bands}.{tsx,ts}, tests/mode-nav-contract.test.ts.
Modified: globals.css (container + density bands + --shadow-lift), therapy-compass/nav.tsx (adds TherapyModeNav alongside the untouched TherapyCompassNav), therapy-compass/workspace.tsx (one line), and five Playwright specs that pinned the old strip on the search route.

Fold planning lives in mode-nav-bands.ts as a pure module specifically so it is unit-tested as real logic rather than asserted by grepping source text.

Verification

Run against the merged tree (this branch merged origin/main at 74229001a, which itself reworked phone header reveal — every earlier green run was re-run from scratch after that merge).

  • npm run verify:cheapTest Files 427 passed (427) / Tests 4395 passed | 3 skipped (4398), exit 0
  • npx tsc --noEmit — clean
  • Focused contracts (mode-nav-contract, therapy-compass-responsive-contract, header-scroll-hide-contract, therapy-compass-mode-wiring, ui-overlay-css-contract, verify-phone-chrome) — 6 passed, 76 tests passed
  • npx prettier --check on all changed files — clean
  • Chromium journeys, run-playwright.mjs --project=chromium --grep-invert "@quarantine|@mockup" -g "Therapy|mode nav"11 passed (1.1m), exit 0. Decisive lines:
    • ok 2 tests\ui-chrome-scroll.spec.ts:208 › tablet: top bar hides on scroll down and returns mid-page on shell mode nav (4.8s)
    • ok 4 tests\ui-chrome-scroll.spec.ts:208 › desktop: top bar hides on scroll down and returns mid-page on shell mode nav (5.7s)
    • ok 9 tests\ui-phone-scroll.spec.ts:1561 › phone Therapy mode navigation uses the universal collapse owner (3.8s)
    • ok 11 tests\ui-therapy-nav-scroll.spec.ts:61 › phone Therapy section nav hides and returns with the universal header (3.6s) — the old strip, still green on /compare
  • npm run verify:pr-localVerification not run: the repo run coordinator holds full-Vitest/build leases exclusively across ~40 worktrees; verify:cheap covers its unit surface and the conditional build/RAG-fixture legs are untouched by this diff (no src/lib/rag/**, no manifest, no build config).
  • npm run verify:release — not a release handoff.
  • eval:retrieval:quality / eval:rag / check:production-readiness — not applicable, and provider-backed. No retrieval, ranking, selection, chunking, scoring, answer-generation, source-rendering, ingestion, privacy or production-env surface is touched.

New coverage this adds

  • ui-chrome-scroll.spec.ts gains /therapy-compass/search?q=CBT&run=1tablet 834×1112 and desktop 1440×900 proof for Therapy specifically, which did not exist before this PR.
  • ui-phone-scroll.spec.ts gains the mode-nav surface.
  • mode-nav-contract.test.ts — 14 tests: band planning, the rem-not-px density contract, header anchoring, item contract.

Spec updates (all five visit the route gaining the bar)

SpecWasNow
ui-therapy-nav-scroll.spec.tsold strip on /searchrepointed to /compare, where the old strip still ships
ui-phone-scroll.spec.tsold strip testid at /searchold strip → /compare; new mode-nav surface added
ui-route-coverage.spec.tsnav "Therapy sections", centred ±1px"Therapy pages"; centring → no-inline-overflow (the bar is left-aligned by design)
ui-accessibility.spec.tsgetByRole("button", { name: "Search" })getByRole("link", …) scoped to the nav — genuine role change from the <button><Link> fix
ui-chrome-scroll.spec.tstherapy surface added

therapy-compass-responsive-contract.test.ts is deliberately unchanged: the old strip and its CSS both survive.

Risk and rollout

  • Risk: low and contained. Header, footer and use-hide-on-scroll are untouched — the mechanism is a portal claiming an already-ungated slot. The one accepted cost is that during the beachhead Therapy has two nav styles: navigating search → compare moves the nav from inside the header to the sticky strip below it. That is the direct consequence of "search page only" and disappears as the rollout continues.
  • Rollback: revert one line of workspace.tsx ({isHome ? null : <TherapyCompassNav />}) and the old strip returns everywhere, with the new component left inert. Full revert is the branch revert.
  • Provider or production effects: None. No Supabase, OpenAI, migration, env or deployment surface touched.

Notes

  • ModeNav is built as a shared component, not a Therapy one — Therapy just supplies the first item list. Extending it to another mode is an item array plus one gate line.
  • More reuses the existing Sheet (src/components/ui/sheet.tsx) with portal, so it mounts on document.body and is never inside the collapsing grid; focus trap, Escape, backdrop dismiss, swipe-to-dismiss and focus restore all come with it.
  • One font-weight (600) in every state — bolding an active label changes its width and would shift the rule and every neighbour on each navigation. The rule measures the ink, not the padding box.
  • Hit areas are min-h-12 (48px), clear of expectMinTouchTarget's 44px default and the sub-pixel rounding flake that min-h-11 caused.
  • Out of scope, noted only: use-hide-on-scroll.ts:222-229 and :470-474 carry stale comments claiming wide chrome "sticks and translates"; that path was deleted. Header-adjacent, so not touched here.

🤖 Generated with Claude Code

BigSimmoand others added 7 commits July 28, 2026 15:12
The three design-direction mockups were committed by housekeeping without
their sitemap entries or a formatting pass, so `sitemap:check` and
`format:check` both fail in CI against them.
- add the three `/mockups/therapy-navigation-*` routes to docs/site-map.md
via `npm run sitemap:update`
- give the decorative lucide `<X>` in the compare tray an `aria-hidden`, which
`local/require-lucide-icon-aria` requires even inside design scratch
- run Prettier over the four mockup sources
Design scratch only: these routes 404 in production and are exempt from the
button-wiring and route-reachability gates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduces a reusable mode navigation bar and wires it to Therapy's search
route as a first beachhead. Replaces, for that route only, a strip of seven
pill buttons that flattened three kinds of destination into one visual rank,
carried no state, and needed ~700px in a 390px viewport — leaving four
destinations off-screen with no scroll affordance.
Anchoring, without touching the header
--------------------------------------
`ModeNavHeaderPortal` claims the existing `#phone-header-collapse-addon-slot`
at every width. That slot was never breakpoint-gated; only
`PhoneHeaderCollapsePortal` was, via its own `(max-width: 639px)` query, which
is why the old strip never travelled with the header above phones. Occupying
the slot inherits the header's `grid-template-rows: 1fr -> 0fr` collapse, so
the bar hides and reveals with it on phone, tablet and desktop with no second
scroll listener that could drift out of step. No header or footer file is
modified.
Density
-------
Slot capacity steps with CONTAINER width in `rem`, never px and never runtime
measurement: 3 slots from 16rem, 4 from 26rem, 5 from 34rem, and a single
current-page control below that. The unit is the mechanism — raising the OS or
browser text size grows the root font, so a 320px phone at 200% text resolves
to 10rem and collapses exactly when its labels would stop fitting (WCAG 1.4.4,
1.4.10). Which destinations survive each band comes from the item count alone
(`planModeNavBands`), so the bar's contents can never change under the user
mid-gesture. Overflow only ever fills from the tail, so slots that stay never
move; if the active page has folded into More, that slot takes its label, icon
and rule.
Therapy
-------
Four destinations: Search, Compare (basket fill `n/4`), Recommend, Pathways.
Home duplicated the mode pill directly above it. Brief intervention and Patient
sheet act on a selected therapy and already exist as availability-guarded
buttons on the record page; in the strip they were duplicated *and* unsafe,
silently opening the first catalogue record that had one when nothing was
selected. Every non-search Therapy route keeps the original strip, so this is
reversible by one line of workspace.tsx.
Tests
-----
Five specs asserted the old strip on the search route. `ui-therapy-nav-scroll`
and the `ui-phone-scroll` surface entry move to `/therapy-compass/compare`,
where that strip still ships, preserving their coverage; `ui-route-coverage`
swaps a centred-nav assertion for a no-inline-overflow one; `ui-accessibility`
moves from `getByRole("button")` to `getByRole("link")`, since the bar now
routes with real hrefs. `ui-chrome-scroll` gains the Therapy search surface,
giving tablet and desktop proof for this mode that did not exist before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…design-350a0e
# Conflicts:
#	src/app/mockups/mockups-layout-client.tsx
#	tests/ui-route-coverage.spec.ts
@supabase

supabaseBot commented Jul 29, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project sjrfecxgysukkwxsowpy because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:35 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 921222cc-41f9-4191-904d-28ec30cca60f

📥 Commits

Reviewing files that changed from the base of the PR and between 39f2bce and 67d8fa5.

📒 Files selected for processing (14)
  • docs/branch-review-ledger.md
  • src/app/globals.css
  • src/components/mode-nav/mode-nav-bands.ts
  • src/components/mode-nav/mode-nav-portal.tsx
  • src/components/mode-nav/mode-nav.tsx
  • src/components/therapy-compass/nav.tsx
  • src/components/therapy-compass/workspace.tsx
  • tests/mode-nav-contract.test.ts
  • tests/mode-nav.dom.test.tsx
  • tests/ui-accessibility.spec.ts
  • tests/ui-chrome-scroll.spec.ts
  • tests/ui-phone-scroll.spec.ts
  • tests/ui-route-coverage.spec.ts
  • tests/ui-therapy-nav-scroll.spec.ts

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:dad09bec42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadsrc/app/globals.css
Comment threadsrc/components/mode-nav/mode-nav.tsx Outdated
@BigSimmo

Copy link
Copy Markdown
OwnerAuthor

@codex resolve actionable Codex review findings for this pull request and current head using the repository instructions. This is the pull request's single automatic repair pass: do not perform a fresh review, create new standalone findings, or request another review. Work only the existing unresolved Codex threads on the current head. Always fix P0 and P1 findings. For P2 and lower findings, fix only clear, scoped, low-risk issues; otherwise disposition them with a concise reason. After fixing or dispositioning a thread, reply in that thread with as the first line, followed by a concise summary; that marker authorizes the workflow to close that exact thread. If human input or new authorization is required, do not use the marker and leave the thread open with the blocker. Finish only after every actionable thread is fixed or dispositioned and closed, or explicitly left open for a human decision. Do not update the branch from main, address unrelated reviews, broaden scope, or create more than one scoped fix commit. Do not use external APIs, paid services, credentials, dependency changes, or broad refactors unless explicitly authorized. Add targeted tests where behavior changes and run the narrowest relevant validation.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit:dad09bec42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

claude added 3 commits July 29, 2026 17:24
`ModeNav` has two triggers for one Sheet, and CSS decides which one is
displayed: below a 16rem container only the collapsed control renders, at
16rem and up only the More slot does (`@container mode-nav` in globals.css).
Both stay in the DOM either way.
`returnFocusRef` was pinned to the More slot, so opening the sheet from the
collapsed control handed the Sheet a `display: none` element to restore focus
to. The browser declines to focus it, the 50ms retry declines again, and the
keyboard or screen-reader user is dropped on <body> — losing their place in
the very state this bar was designed around, since a 320px phone collapses to
the single control at 200% text zoom (the WCAG 1.4.4 / 1.4.10 path the CSS
comment describes).
Capture the clicked button instead: `event.currentTarget` is by definition the
displayed one, so the restore target is always focusable. This also removes the
two dedicated refs.
Test pins both bands. It deliberately does not pre-focus the opener — jsdom
leaves focus on <body> through a synthetic click, so the only thing that can
move it is the explicit restore, which makes the wait a real wait rather than a
poll satisfied by the pre-existing focus. jsdom will happily focus a hidden
element and cannot model the browser's focusability rule, so the assertion pins
which opener the Sheet was told to return to. Verified red against the previous
implementation:
× returns focus to the collapsed control below the bar band
AssertionError: expected <button …>…(1)</button> to be <button …>…(4)</button>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvU8z73P6TXUXoYBqN5K1P
@BigSimmoClaude

Copy link
Copy Markdown
OwnerAuthor

Review — one defect found and fixed, in 8a8a8543

Reviewed the whole diff as part of the open-PR sweep. The architecture holds up: the portal claims an already-ungated slot rather than editing the header, planModeNavBands is derived from the item count so nothing measures layout, and TherapyNavSlot reads isSearch from bindings rather than re-deriving the pathname. The one accepted cost — two nav styles inside Therapy during the beachhead — is stated plainly in the PR body.

One real defect, in the accessibility path the PR was explicitly designed around.

ModeNav returned focus to a display: none element half the time.

There is one Sheet with two openers, and CSS decides which one exists:

.mode-nav__bar { display: none; } /* default */@container mode-nav (min-width:16rem) {
.mode-nav__control { display: none; }
.mode-nav__bar { display: grid; }
}

Both buttons stay in the DOM at every width — only their display differs. returnFocusRef was pinned to moreRef, which lives inside .mode-nav__bar. So opening the sheet from the collapsed control handed the Sheet a hidden element as its restore target. sheet.tsx:252 takes explicitReturnElement ?? previousActiveElement, and the explicit ref is non-null, so the fallback never engages; the browser declines to focus a display: none element, the 50 ms retry at sheet.tsx:276 sees activeElement === body and declines again, and the user is left on <body>.

The collapsed state is not an edge case here — it is the state this PR's own CSS comment identifies as the default and as the WCAG 1.4.4 / 1.4.10 destination: "A 320px phone is 20rem at 100% text and keeps three slots; at 200% it resolves to 10rem and collapses to one control." So the reflow path the density bands exist to serve was the one that lost focus.

Fix is to capture the button that was actually clicked, which is by definition the displayed one:

constopenerRef=useRef<HTMLButtonElement|null>(null);constopenSheet=(event: MouseEvent<HTMLButtonElement>)=>{openerRef.current=event.currentTarget;setOpen(true);};

triggerRef and moreRef are gone; returnFocusRef={openerRef}.

On the test.tests/mode-nav.dom.test.tsx pins both bands. Two things about it are deliberate, because my first attempt was worthless in both:

  • It does not pre-focus the opener. jsdom leaves focus on <body> through a synthetic click, so the only thing that can move it is the explicit restore — which turns waitFor(activeElement !== body) into a real wait. My first version focused the opener first, so the poll was satisfied by the pre-existing focus and passed against the unfixed code.
  • It asserts which opener the Sheet was told to return to, not whether the browser would have focused it. jsdom happily focuses a display: none element and cannot model the focusability rule, so an assertion phrased as "focus is not lost" would be untestable here.

Verified red against the previous implementation:

× returns focus to the collapsed control below the bar band
AssertionError: expected <button …>…(1)</button> to be <button …>…(4)</button>

Also in 8a8a8543: merged origin/main (which now carries #1374). verify:cheap exit 0 — Test Files 431 passed (431), Tests 4419 passed | 4 skipped (4423). ESLint, Prettier and tsc --noEmit clean on the changed files.

Not verified by me: the Chromium journeys. The PR's own run (11 passed (1.1m)) predates this merge with main; hosted Production UI on this head is the check that matters and I am waiting on it before merging.


Generated by Claude Code

@BigSimmo
BigSimmo enabled auto-merge (squash) July 30, 2026 02:02
@BigSimmo
BigSimmo merged commit 8f861bb into mainJul 30, 2026
19 checks passed
@BigSimmo
BigSimmo deleted the claude/therapy-nav-redesign-350a0e branch July 30, 2026 02:03
Sign up for freeto 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.

2 participants

@BigSimmo@claude