Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
aea5e11
feat(specifiers): streamline map and comparison flows
BigSimmo Aug 13, 2026
174b78d
fix(specifiers): preserve committed file bytes
BigSimmo Aug 13, 2026
ac46ff4
Merge branch 'main' into codex/specifier-map-compare-20260813
BigSimmo Aug 13, 2026
266750a
Merge branch 'main' into codex/specifier-map-compare-20260813
BigSimmo Aug 13, 2026
07dc274
fix(specifiers): sync map navigation with scroll spy
BigSimmo Aug 13, 2026
d4eee8c
style(specifiers): format map navigation fix
BigSimmo Aug 13, 2026
5d46038
test(specifiers): type scroll-spy regression callback
BigSimmo Aug 13, 2026
a5fa328
style(specifiers): format navigation regression
BigSimmo Aug 13, 2026
352b94d
test(specifiers): satisfy scroll-spy mock lint
BigSimmo Aug 13, 2026
50bc220
Merge branch 'main' into codex/specifier-map-compare-20260813
BigSimmo Aug 13, 2026
2152967
test(specifiers): cover map navigation contracts
BigSimmo Aug 13, 2026
035189a
test(specifiers): resolve map anchors in scroll regression
BigSimmo Aug 13, 2026
e7e5417
fix(navigation): retain explicit section selection
BigSimmo Aug 13, 2026
94bc79d
Merge branch 'main' into codex/specifier-map-compare-20260813
BigSimmo Aug 13, 2026
90a731e
Merge branch 'main' into codex/specifier-map-compare-20260813
BigSimmo Aug 13, 2026
aa51b31
Merge branch 'main' into codex/specifier-map-compare-20260813
BigSimmo Aug 13, 2026
10f33d3
fix(navigation): guard deferred section update
BigSimmo Aug 13, 2026
5910cda
chore(review): add PR 1912 record
BigSimmo Aug 13, 2026
3543ad6
ci: correct navigation test typing
BigSimmo Aug 13, 2026
a401eea
chore(review): record CI follow-up
BigSimmo Aug 13, 2026
f2c2aa8
fix(navigation): preserve explicit jump selection
BigSimmo Aug 13, 2026
00a07a1
chore(review): record post-scroll fix
BigSimmo Aug 13, 2026
95529ee
fix review regression state
BigSimmo Aug 13, 2026
7074d65
fix(navigation): preserve explicit section intent
BigSimmo Aug 13, 2026
92f6e96
chore(review): record explicit navigation fix
BigSimmo Aug 13, 2026
77f542d
style(specifiers): use semantic map layout tokens
BigSimmo Aug 13, 2026
ff73bf9
style(specifiers): define semantic map layout tokens
BigSimmo Aug 13, 2026
a1d7adc
Merge main into codex/specifier-map-compare-20260813
BigSimmo Aug 13, 2026
44d44c2
style(test): format merged mode-nav coverage
BigSimmo Aug 13, 2026
12cd9f7
fix(ui): register specifier map spacing tokens
BigSimmo Aug 13, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| 2026-08-13 | codex/specifier-map-compare-20260813 (PR #1912) | f2c2aa8ab9c881a9647e607ab09b1ebdc772e49b | PR #1912 heavy review follow-up: post-scroll active-state repair | Exact-head Production UI trace showed the jump selection was reasserted in the same frame as the physical scroll, then overwritten by the scroll spy on its following geometry frame. Delayed the hash-guarded reassertion by one additional animation frame and added a focused ordering regression. The existing popstate guard remains unchanged. A separate builder Start over failure touched no PR-modified source and is left for the replacement exact-head run to classify. | Production trace: 177 tests passed and the map active state alone reverted after the target scroll; deterministic frame model changed final state from episode-features to course-onset while suppressing stale reassertion after back navigation; TypeScript transpile PASS for hook and focused regression; git diff --check PASS; no manual production or provider-backed gate run. |
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| 2026-08-13 | codex/specifier-map-compare-20260813 (PR #1912) | 7074d65af36ee662450e6dcf340a77d3cc404209 | PR #1912 heavy review follow-up: explicit section intent ownership | Production UI trace proved the phone chrome transition could emit a later geometry result after an explicit jump, so frame-count reassertions were inherently timing-dependent. Replaced the animation-frame workaround with a scoped explicit-fragment override: deliberate click/history navigation remains authoritative through programmatic scrolling, while wheel, touchmove, or non-editable scroll-key intent returns ownership to the geometry spy. History fragment changes replace the override directly. | Fresh Playwright trace reproduced Course and onset briefly becoming active before Episode features overwrote it; deterministic hook regressions cover incidental spy rerenders, user-scroll release, editable keyboard input, and popstate replacement; TypeScript transpile PASS for the hook and both test files; exact-head CI pending; no manual provider-backed or production gate run. |
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| 2026-08-13 | codex/specifier-map-compare-20260813 (PR #1912) | 3543ad63890686ecf3ff57aaae26a7dabaca4060 | PR #1912 heavy review follow-up: regression test type correction | Exact-head Build on 5910cda failed only because the new regression cast a plain function to LucideIcon. Replaced the fake cast with the repository's real lucide-react Circle export; production hook fix is unchanged. This supersedes the earlier review record's incomplete compile confidence for the regression. | GitHub Build log reproduced TS2352 at tests/use-in-page-section-nav.dom.test.tsx:27; replacement blob 45ad89ad5307588cbd0bb28fb3f89511301030e2 verified from GitHub; previous deterministic history-race model and diff checks remain applicable; exact-head CI rerun pending. |
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| 2026-08-13 | codex/specifier-map-compare-20260813 (PR #1912) | 10f33d354beb04834c1429bf3aecdb5c7b4d0bc6 | PR #1912 heavy review: stale section selection repair | Validated and fixed one PR-introduced P2 history race: an obsolete animation-frame selection could override browser back navigation; added a focused popstate regression. No other high-confidence defects found in the nine-file PR delta. The latest main was merged independently at aa51b311de8082bd1bda65d5ee74827a164a905a before publication, with no conflict; no further base sync was performed. | Deterministic old/fixed stale-frame model: reproduced then passed; TypeScript transpile PASS for hook and regression; git diff --check PASS; exact-head CI at 90a731e54092ac9b13fff7d90f8a071ac105d8a8 was green before repair; no provider-backed checks run locally. |
1 change: 1 addition & 0 deletions docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@
"tests/design-sync-visual-exports.test.ts",
"tests/in-page-nav-playwright-contract.test.ts",
"tests/information-page-shell.dom.test.tsx",
"tests/ui-route-coverage.spec.ts",
"tests/ui-specifiers.spec.ts"
],
"baseline": {
Expand Down
7 changes: 7 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
which covers the composer input as well as the icon/send buttons). */
--spacing-tap: 3rem;

/* Specifier-map layout roles: jump target height, numbered step column, and
wide-screen detail rail. These stay together so the page cannot drift back
to unrelated one-off dimensions. */
--spacing-specifier-map-jump: 5.25rem;
--spacing-specifier-map-step-number: 2.25rem;
--spacing-specifier-map-aside: 22rem;

/* Mode-home composer slots reserve their settled portal geometry before the
client host adopts them. Without this structural reserve, Therapy moved the
entire action stack when hydration relocated the composer (CLS 0.248).
Expand Down
57 changes: 51 additions & 6 deletions src/components/in-page-nav/use-in-page-section-nav.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"use client";

import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { jumpToDocumentSection } from "@/components/document-viewer/section-nav";
import { useDocumentSectionSpy } from "@/components/document-viewer/use-section-spy";
import { toDocumentSections, type PageSection } from "@/components/in-page-nav/page-section-index";
import { useResolvedPageSections } from "@/components/in-page-nav/use-resolved-page-sections";

const scrollIntentKeys = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);

function isEditableTarget(target: EventTarget | null): boolean {
return target instanceof Element && Boolean(target.closest('input, textarea, select, [contenteditable="true"]'));
}

/**
* Everything an information page needs to drive `InPageNavHeader`: which
* declared sections are actually on screen, which one the reader is in, and what
Expand All @@ -25,12 +31,45 @@ export function useInPageSectionNav(declared: readonly PageSection[]): {
} {
const sections = useResolvedPageSections(declared);
const spySections = useMemo(() => toDocumentSections(sections), [sections]);
const { activeId, selectSection: markActive } = useDocumentSectionSpy(spySections, sections.length > 0);
const { activeId: observedActiveId, selectSection: markActive } = useDocumentSectionSpy(
spySections,
sections.length > 0,
);
const [explicitFragmentId, setExplicitFragmentId] = useState<string | null>(null);
const explicitSectionId = explicitFragmentId
? (sections.find((section) => (section.fragmentId ?? section.id) === explicitFragmentId)?.id ?? null)
: null;
const activeId = explicitSectionId ?? observedActiveId;

// The hash this hook has already scrolled to, so a re-resolve (the observer
// fires on any `#main-content` mutation) does not yank the reader back.
const alignedHash = useRef<string | null>(null);

// A click or history jump is deliberate navigation, so keep that section
// selected while its programmatic scroll and the phone chrome settle. The raw
// geometry spy takes ownership again only when the reader actually starts
// scrolling, rather than on incidental scroll/resize frames from the jump.
useEffect(() => {
if (explicitFragmentId === null) return;

const releaseExplicitSelection = () => setExplicitFragmentId(null);
const releaseOnScrollKey = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || isEditableTarget(event.target)) {
return;
}
if (scrollIntentKeys.has(event.key)) releaseExplicitSelection();
};

window.addEventListener("wheel", releaseExplicitSelection, { passive: true });
window.addEventListener("touchmove", releaseExplicitSelection, { passive: true });
window.addEventListener("keydown", releaseOnScrollKey);
return () => {
window.removeEventListener("wheel", releaseExplicitSelection);
window.removeEventListener("touchmove", releaseExplicitSelection);
window.removeEventListener("keydown", releaseOnScrollKey);
};
}, [explicitFragmentId]);

const selectSection = useCallback(
(id: string) => {
const fragmentId = sections.find((section) => section.id === id)?.fragmentId ?? id;
Expand All @@ -40,6 +79,7 @@ export function useInPageSectionNav(declared: readonly PageSection[]): {
// where the displayed target id differs.
window.history.pushState(window.history.state, "", `#${fragmentId}`);
alignedHash.current = fragmentId;
setExplicitFragmentId(fragmentId);
markActive(id);
jumpToDocumentSection(id);
},
Expand All @@ -50,17 +90,22 @@ export function useInPageSectionNav(declared: readonly PageSection[]): {
// section renders once per breakpoint, so the browser cannot resolve a deep
// link to one on its own — `#form-source-verification` would simply do
// nothing. Resolve it here instead, against whichever copy is displayed.
// Only jumping, never setting active state: the scroll spy reports where the
// reader ends up.
useEffect(() => {
if (sections.length === 0) return;

const align = () => {
const hash = window.location.hash.slice(1);
if (!hash || alignedHash.current === hash) return;
if (!hash) {
alignedHash.current = null;
setExplicitFragmentId(null);
return;
}
if (alignedHash.current === hash) return;
const section = sections.find((entry) => (entry.fragmentId ?? entry.id) === hash);
if (!section) return;
alignedHash.current = hash;
setExplicitFragmentId(hash);
markActive(section.id);
jumpToDocumentSection(section.id);
};

Expand All @@ -71,7 +116,7 @@ export function useInPageSectionNav(declared: readonly PageSection[]): {
window.removeEventListener("hashchange", align);
window.removeEventListener("popstate", align);
};
}, [sections]);
}, [markActive, sections]);

return { sections, activeId, selectSection };
}
13 changes: 4 additions & 9 deletions src/components/specifiers/specifier-compare-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { ArrowRight, GitCompareArrows, HelpCircle, Repeat2, Tags } from "lucide-
import { useState } from "react";

import {
SpecifierBreadcrumbs,
SpecifierFamilyBadge,
SpecifierPageShell,
SpecifierSafetyNote,
Expand Down Expand Up @@ -94,16 +93,12 @@ export function SpecifierComparePage({ initialLeft, initialRight }: { initialLef

return (
<SpecifierPageShell>
<SpecifierBreadcrumbs current="Compare" />

<header className="grid gap-2 border-b border-[color:var(--border)] pb-5">
<p className={eyebrowText}>Close-call reasoning</p>
<h1 className="text-3xl font-extrabold tracking-tight text-[color:var(--text-heading)] sm:text-4xl">
Compare specifiers
<header className="grid gap-1.5 border-b border-[color:var(--border)] pb-4 sm:pb-5">
<h1 className="text-balance text-2xl font-extrabold leading-tight tracking-tight text-[color:var(--text-heading)] sm:text-3xl">
Compare two specifiers
</h1>
<p className="max-w-3xl text-sm font-medium leading-6 text-[color:var(--text-muted)]">
Clarify the deciding signal, chronology, and common overcalls. This comparison changes as you choose either
specifier.
Find the deciding clinical difference.
</p>
</header>

Expand Down
73 changes: 73 additions & 0 deletions src/components/specifiers/specifier-map-nav-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use client";

import { Clock3, Gauge, Sparkles } from "lucide-react";
import { createContext, useContext, type ReactNode } from "react";

import { InPageNavHeader } from "@/components/in-page-nav/in-page-nav-header";
import type { PageSection } from "@/components/in-page-nav/page-section-index";
import { useInPageSectionNav } from "@/components/in-page-nav/use-in-page-section-nav";
import { appModeHomeHref } from "@/lib/app-modes";

export const specifierMapSteps = [
{
id: "episode-features",
label: "Episode features",
description: "What is present now",
icon: Sparkles,
},
{
id: "course-onset",
label: "Course and onset",
description: "When and how it unfolds",
icon: Clock3,
},
{
id: "severity-remission",
label: "Severity or remission",
description: "Current burden or recovery",
icon: Gauge,
},
] as const;

export const specifierMapSections: readonly PageSection[] = specifierMapSteps.map(({ id, label, icon }) => ({
id,
label,
icon,
}));

type SpecifierMapNavigation = {
activeId: string | null;
selectSection: (id: string) => void;
};

const SpecifierMapNavigationContext = createContext<SpecifierMapNavigation | null>(null);

/**
* Owns the map route's fixed in-page-navigation declaration and its scroll spy.
* The page body consumes this single source so the header and role buttons stay
* synchronized during ordinary scrolling as well as explicit section jumps.
*/
export function SpecifierMapNavHeader({ children }: { children: ReactNode }) {
const { sections, activeId, selectSection } = useInPageSectionNav(specifierMapSections);

return (
<SpecifierMapNavigationContext.Provider value={{ activeId, selectSection }}>
<InPageNavHeader
back={{ href: appModeHomeHref("specifiers"), label: "Specifiers" }}
title="Specifier map"
sectionSheetTitle="Specifier map"
sections={sections}
activeId={activeId}
onSelectSection={selectSection}
testIdPrefix="specifier-map"
/>
{children}
</SpecifierMapNavigationContext.Provider>
);
}

export function useSpecifierMapNavigation(): SpecifierMapNavigation {
const navigation = useContext(SpecifierMapNavigationContext);
if (!navigation) throw new Error("useSpecifierMapNavigation must be used within SpecifierMapNavHeader");
return navigation;
}
Loading
Loading