diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index eaddabdf45..130a2de3dc 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1301,4 +1301,5 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | cursor/page-anchored-search-composer-30ee | 7ff134ca7f614db527b8d142676640305533669d | branch-cleanup-deletion-pending | DELETION PENDING — content proven fully on main. Merge-base with main is 79d1c879 and tree(merge-base) equals tree(tip): git diff --name-only 79d1c879 7ff134ca reports 0 files, so the tip introduces nothing beyond a state already in main. Backs no open PR. Deletion blocked by HTTP 403; HEAD recorded here so the later branch-cleanup row can be keyed to it after the ref is gone. Supersedes the aggregate main-keyed row at 855aa291, which recorded no candidate HEADs. | local git only for the content proof: git merge-base, git diff --name-only (0 files), git diff --name-only origin/main... (0 files), full history after git fetch --unshallow. PROVIDER-BACKED evidence obtained (GitHub reads, authorised repo-scope): open-PR head cross-check via GitHub API against PRs #1374/#1377/#1384/#1385/#1386/#1387, and GitHub MCP capability inspection. PROVIDER-BACKED MUTATION ATTEMPTED AND REJECTED: git push origin --delete returned HTTP 403; no branch was deleted. No OpenAI/Supabase/hosted-CI calls. | | 2026-07-29 | cursor/pr-1379-babysit-ledger-9365 | be2de03f855cb7fdfccea4bb74d05eb4c9bf6c61 | branch-cleanup-deletion-pending | DELETION PENDING — content proven fully on main. Merge-base with main is b2740480 and tree(merge-base) equals tree(tip): git diff --name-only b2740480 be2de03f reports 0 files, so the tip introduces nothing beyond a state already in main. Backs no open PR. Deletion blocked by HTTP 403; HEAD recorded here so the later branch-cleanup row can be keyed to it after the ref is gone. Supersedes the aggregate main-keyed row at 855aa291, which recorded no candidate HEADs. | local git only for the content proof: git merge-base, git diff --name-only (0 files), git diff --name-only origin/main... (0 files), full history after git fetch --unshallow. PROVIDER-BACKED evidence obtained (GitHub reads, authorised repo-scope): open-PR head cross-check via GitHub API against PRs #1374/#1377/#1384/#1385/#1386/#1387, and GitHub MCP capability inspection. PROVIDER-BACKED MUTATION ATTEMPTED AND REJECTED: git push origin --delete returned HTTP 403; no branch was deleted. No OpenAI/Supabase/hosted-CI calls. | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 9e2ee65ca0bcce45a3cb6a0539e265ec8d961582 | PR #1377 latency findings — #098 stale offline-harness references | Codex P2 confirmed and fixed: the #098 row in docs/outstanding-issues.md still named test-cache-path.mjs and check-rag-fixtures.mjs as the offline fixtures for the round-trip counting harness. Neither exercises a RAG request (cache paths; fixture-manifest validation), so a harness built on them would count nothing. The audit doc carried the retraction at :358 but this row did not - the same local-retraction pattern flagged in two prior rounds. Now names eval-rag-offline.mjs, test-rag-offline.mjs, rag-offline-contract.mjs and the contract fixture, all verified present, with the correction recorded inline. Docs only. | prettier --check clean; docs:check-links 1363; docs:check-scripts 390; grep confirms no stale refs remain | +| 2026-07-29 | 1391 | baecef05cac86c4d52af895d483a33ba3c40cd61 | PR #1391 review | reviewed clean — text-4xs retirement confirmed against globals.css (--text-3xs 0.625rem present, --text-4xs absent); orphan guard proven to fail on a reintroduced class; six Playwright retries all retry action-plus-effect so a genuine regression still fails. Resolved the outstanding-issues #108/#109 double-allocation (renumbered to #110/#111, marker to 112) and recorded #111 done | verify:cheap exit 0 (429 files / 4404 tests); design-token-contract 28 passed; check:branch-review-ledger passed | | 2026-07-29 | 1374 | c14edb9c6f0bdbbfb147752503e016f2543fd803 | PR #1374 review + merge | merged as 3704007c — DocumentViewer identity-bound state clear (P1) implemented and verified red without it; all 12 review threads resolved | verify:cheap exit 0 (429 files / 4403 tests); PR required success; Production UI success | diff --git a/src/app/globals.css b/src/app/globals.css index 3174e6b09a..ae28c2af6b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -339,6 +339,8 @@ /* Active left-rail marker (nav/list "current" indicator). One token so the accent rail is a consistent 2px everywhere instead of hand-inlined at 2px/3px. */ --shadow-rail-active: inset 2px 0 0 var(--clinical-accent); + /* Mode-nav collapsed control: a surface lifted off the header. */ + --shadow-lift: 0 1px 2px rgb(12 24 34 / 8%), 0 2px 6px rgb(12 24 34 / 5%); --safe-area-top: env(safe-area-inset-top, 0px); --safe-area-right: env(safe-area-inset-right, 0px); --safe-area-bottom: env(safe-area-inset-bottom, 0px); @@ -526,6 +528,9 @@ --shadow-lux: var(--e4); --shadow-inset: inset 0 1px 0 rgb(255 255 255 / 4%); --shadow-focus: 0 0 0 3px color-mix(in srgb, var(--focus) 25%, transparent), var(--shadow-soft); + /* A drop shadow does not read on a near-black ground, so the lifted control + is described by an inner top highlight instead. */ + --shadow-lift: inset 0 1px 0 rgb(255 255 255 / 7%), 0 2px 6px rgb(0 0 0 / 45%); color-scheme: dark; } @@ -2308,6 +2313,93 @@ td, } } +/* ── Mode navigation ────────────────────────────────────────────────────── + The bar carrying one mode's own pages. `ModeNavHeaderPortal` moves it into + the universal header's collapse track, so it hides and reveals as one object + with the header at every width and owns no scroll listener of its own. + + Density is chosen by CONTAINER width in `rem` — never by device width, and + never by runtime measurement. The unit is the mechanism: raising the browser + or OS text size grows the root font, so a phone crosses a threshold exactly + when its labels would stop fitting. A 320px phone is 20rem at 100% text and + keeps three slots; at 200% it resolves to 10rem and collapses to one control. + + The collapsed control is the DEFAULT and the bar is the enhancement, so + anything unmeasured, unsupported or mid-load renders the one state that + cannot overflow at any width or text size (WCAG 1.4.4 Resize Text, 1.4.10 + Reflow). + + Budgets, measured against this app's phone type ramp with slot padding + counted: four full labels need 394px and fit from 26rem; three plus More need + 256px and fit from 16rem. + ───────────────────────────────────────────────────────────────────────── */ + +@utility mode-nav { + container-type: inline-size; + container-name: mode-nav; + min-width: 0; +} + +.mode-nav__bar { + display: none; +} + +.mode-nav__control { + display: flex; +} + +/* Which slots survive each band is decided in the component from the item + COUNT alone — a static fact, never a measurement — and encoded as two + attributes that CSS then switches on: + `data-band="3|4|5"` the narrowest band at which this destination appears + `data-until="3|4"` the widest band at which More still appears + Slots are never re-ordered, so the ones that stay do not move as the + container narrows; the overflow only ever fills from the tail. */ +.mode-nav__slot[data-band="4"], +.mode-nav__slot[data-band="5"] { + display: none; +} + +@container mode-nav (min-width: 16rem) { + .mode-nav__control { + display: none; + } + + .mode-nav__bar { + display: grid; + grid-auto-flow: column; + /* Equal columns, so outer margins and inter-slot gaps are identical by + construction rather than by hand-tuned padding. */ + grid-auto-columns: 1fr; + } +} + +@container mode-nav (min-width: 26rem) { + .mode-nav__slot[data-band="4"] { + display: flex; + } + + .mode-nav__slot[data-until="3"] { + display: none; + } +} + +@container mode-nav (min-width: 34rem) { + .mode-nav__slot[data-band="5"] { + display: flex; + } + + .mode-nav__slot[data-until="4"] { + display: none; + } + + /* Wide: slots size to their content and sit left, so the bar reads as a + continuation of the header rather than four columns stretched across it. */ + .mode-nav__bar { + display: flex; + } +} + /* Motion keyframes (suppressed under prefers-reduced-motion below) */ @keyframes fade-up { from { diff --git a/src/components/mode-nav/mode-nav-bands.ts b/src/components/mode-nav/mode-nav-bands.ts new file mode 100644 index 0000000000..04565cbcca --- /dev/null +++ b/src/components/mode-nav/mode-nav-bands.ts @@ -0,0 +1,49 @@ +/** + * Slot capacities for the mode navigation bar, narrowest first. These mirror + * the `@container mode-nav (min-width: …)` bands in `globals.css`; changing one + * without the other is what `tests/mode-nav-contract.test.ts` guards against. + */ +export const MODE_NAV_BANDS = [3, 4, 5] as const; + +export type ModeNavBand = (typeof MODE_NAV_BANDS)[number]; + +/** Below this a bar is a label, not navigation, so nothing renders. */ +export const MODE_NAV_MIN_ITEMS = 2; + +export type ModeNavBandPlan = { + /** Item index → the narrowest band at which it appears. Absent = always folded. */ + firstVisibleBand: Map; + /** The widest band at which More still appears, or null when it is never needed. */ + moreUntil: ModeNavBand | null; +}; + +/** + * Decides which destinations survive each width band. + * + * For a capacity of C slots: if every item fits, all of them show and there is + * no overflow entry; otherwise the first C-1 show and the tail folds into More, + * which occupies the last slot. + * + * Derived from the item COUNT alone — a static fact known at render — so this + * never measures layout. Runtime measurement ("priority plus") would let the + * bar's contents change under the user between screens and orientations, which + * is the one thing navigation must not do. + * + * Because a wider band is always a superset of a narrower one, the slots that + * stay never move as the container narrows; the overflow only fills from the + * tail. + */ +export function planModeNavBands(count: number): ModeNavBandPlan { + const firstVisibleBand = new Map(); + let moreUntil: ModeNavBand | null = null; + + for (const capacity of MODE_NAV_BANDS) { + const visible = count <= capacity ? count : capacity - 1; + for (let index = 0; index < visible; index += 1) { + if (!firstVisibleBand.has(index)) firstVisibleBand.set(index, capacity); + } + if (count > capacity) moreUntil = capacity; + } + + return { firstVisibleBand, moreUntil }; +} diff --git a/src/components/mode-nav/mode-nav-portal.tsx b/src/components/mode-nav/mode-nav-portal.tsx new file mode 100644 index 0000000000..63913ca14b --- /dev/null +++ b/src/components/mode-nav/mode-nav-portal.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useLayoutEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +import { phoneHeaderCollapseAddonSlotId } from "@/lib/mode-home-composer"; + +/** + * Moves a mode's navigation bar into the universal header's collapse track at + * *every* width. + * + * This is a deliberate sibling of `PhoneHeaderCollapsePortal`, which resolves + * the same host only below the phone breakpoint. That gate is why the Therapy + * strip never travelled with the header on a tablet or desktop: it stayed in + * page flow as `position: sticky; top: 0` instead. + * + * The header needs no change for this to work. Its addon slot is rendered with + * no breakpoint prefix (`master-search-header.tsx`, `empty:hidden` inside + * `universal-header-collapse`), and for the `GlobalSearchShell` host — + * `{ strategy: "collapse", wide: "sticky" }` — the collapsing wrapper takes its + * ungated `grid-template-rows: 1fr -> 0fr` branch at every width. Occupying the + * slot from here therefore inherits the header's hide and reveal exactly, with + * no second scroll listener that could drift out of step with it. + * + * Falls back to normal flow when no host exists — routes rendered without the + * universal header, and the server pass — so navigation is never lost. + * + * Slot ownership: the addon slot holds ONE page-owned header. `DocumentViewer` + * and the differentials detail page already claim it on phones, so a mode whose + * routes include those pages must not also mount a bar there. `ModeNav` renders + * nothing below two destinations, which is what keeps those modes clear today; + * `tests/mode-nav-contract.test.ts` fails if that stops being true. + */ +export function ModeNavHeaderPortal({ children }: { children: ReactNode }) { + const [host, setHost] = useState(null); + + useLayoutEffect(() => { + const sync = () => { + const next = document.getElementById(phoneHeaderCollapseAddonSlotId); + setHost((current) => (current === next ? current : next)); + }; + + sync(); + // The shell remounts its header across mode switches, so the host element's + // identity is not stable for this component's lifetime. + const observer = new MutationObserver(sync); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => observer.disconnect(); + }, []); + + return host ? createPortal(children, host) : children; +} diff --git a/src/components/mode-nav/mode-nav.tsx b/src/components/mode-nav/mode-nav.tsx new file mode 100644 index 0000000000..8f45a61a41 --- /dev/null +++ b/src/components/mode-nav/mode-nav.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { ChevronDown, type LucideIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useMemo, useRef, useState, type MouseEvent } from "react"; + +import { cn } from "@/components/ui-primitives"; +import { Sheet } from "@/components/ui/sheet"; + +import { MODE_NAV_MIN_ITEMS, planModeNavBands } from "./mode-nav-bands"; +import { ModeNavHeaderPortal } from "./mode-nav-portal"; + +export type ModeNavItem = { + id: string; + /** The only label. Never abbreviated — a slot shows its real word or folds into More. */ + label: string; + /** A real URL. Never an onClick-only control: deep links, back and prefetch must work. */ + href: string; + icon: LucideIcon; + /** + * State, not size: "3/4" for a basket that holds four, never "205" for a + * catalogue. A total is noise on every screen; a fill is worth a glance. + */ + count?: string; +}; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[color:var(--focus)]"; + +/** 48px hit area — clear of `expectMinTouchTarget`'s 44px and its rounding flake. */ +const slotBase = + "mode-nav__slot relative flex min-h-12 min-w-0 items-center justify-center px-3 no-underline transition-colors"; + +function SlotInk({ + icon: Icon, + label, + count, + state, + trailing, +}: { + icon?: LucideIcon; + label: string; + count?: string; + state: "on" | "trail" | "off"; + trailing?: boolean; +}) { + return ( + + {Icon ? ( +