Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <merge-base> <tip> (0 files), git diff --name-only origin/main...<tip> (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 <merge-base> <tip> (0 files), git diff --name-only origin/main...<tip> (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 |
92 changes: 92 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Comment thread
BigSimmo marked this conversation as resolved.
}
}

@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 {
Expand Down
49 changes: 49 additions & 0 deletions src/components/mode-nav/mode-nav-bands.ts
Original file line number Diff line number Diff line change
@@ -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<number, ModeNavBand>;
/** 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<number, ModeNavBand>();
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 };
}
53 changes: 53 additions & 0 deletions src/components/mode-nav/mode-nav-portal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(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;
}
Loading
Loading