From 0022c8121463669f222de38f642089a87e2ea932 Mon Sep 17 00:00:00 2001 From: Joshua Simpson Date: Sat, 17 Oct 2026 21:11:23 +0000 Subject: [PATCH 1/9] Resolve audited design-system and app interaction issues --- .../2026-10-17-design-audit-resolution.md | 11 + docs/design-system/COMPONENTS.md | 17 +- docs/design-system/DECISIONS.md | 40 +- docs/design-system/GATES.md | 24 +- docs/design-system/README.md | 2 + docs/design-system/TOKENS.md | 21 +- docs/design-system/adoption-manifest.json | 1 + .../audit-resolution-2026-10-17.md | 95 ++++ .../token-layer-divergences.json | 225 +-------- docs/organisation/retired-rules.json | 16 + docs/search-chrome-behaviour.md | 14 +- .../plans/2026-09-26-first-nations-mode.md | 2 + .../2026-09-26-first-nations-mode-design.md | 2 +- scripts/check-type-scale.mjs | 134 ++++-- scripts/design-system-contract-baseline.json | 108 +---- src/app/ckb-v2-tokens.css | 7 +- src/app/clinical-mode.css | 25 +- src/app/globals.css | 225 ++++----- src/app/layout.tsx | 2 +- src/app/mode-band.css | 37 +- src/app/not-found.tsx | 4 +- src/app/work-mode.css | 105 +++-- src/components/ClinicalDashboard.tsx | 11 +- src/components/DocumentTagCloud.tsx | 100 +--- src/components/calculators/bedside-sheet.tsx | 5 +- src/components/calculators/calculator-ui.tsx | 8 +- .../calculators/clinical-console.tsx | 5 +- src/components/calculators/guided-flow.tsx | 5 +- src/components/calculators/search-detail.tsx | 2 +- src/components/calculators/search-page.tsx | 40 +- .../answer-follow-up-suggestions.tsx | 5 +- .../answer-source-drawer.tsx | 8 +- .../clinical-dashboard/auth-panel.tsx | 10 +- .../clinical-dashboard/dashboard-nav.tsx | 4 +- .../clinical-dashboard/differentials-home.tsx | 7 +- .../document-search-results.tsx | 47 +- .../clinical-dashboard/email-auth-form.tsx | 4 +- .../clinical-dashboard/evidence-panels.tsx | 8 +- .../favourites-command-library-page.tsx | 8 +- .../global-search-shell.tsx | 15 +- .../clinical-dashboard/guide-dialog.tsx | 12 +- .../clinical-dashboard/image-lightbox.tsx | 12 +- .../master-search-header.tsx | 43 +- .../clinical-dashboard/mode-action-popup.tsx | 4 +- .../password-recovery-form.tsx | 6 +- .../patient-profile-panel.tsx | 7 +- .../clinical-dashboard/search-pins-menu.tsx | 26 +- .../search-results-header-band.tsx | 200 ++++---- .../clinical-dashboard/settings-dialog.tsx | 35 +- .../clinical-dashboard/signed-image.tsx | 7 +- .../source-preview-popover.tsx | 2 +- .../universal-search-command-surface.tsx | 26 +- .../use-phone-overlay-chrome-reserve.ts | 14 +- .../compare/compare-catalog-picker.tsx | 22 +- .../developer-area/developer-gate-screen.tsx | 11 +- .../differentials/diagnosis-map-panel.tsx | 12 +- .../differential-detail-page.tsx | 2 +- .../document-image-filmstrip.tsx | 7 +- .../factsheets/factsheet-detail-page.tsx | 8 +- .../factsheets/factsheets-search-page.tsx | 2 +- src/components/first-nations/primer.tsx | 63 +-- .../forms/forms-search-results-page.tsx | 1 + .../formulation/formulation-builder-page.tsx | 13 +- .../in-page-nav/in-page-nav-classes.ts | 5 +- .../in-page-nav/in-page-nav-header.tsx | 21 +- .../in-page-nav/in-page-section-rail.tsx | 6 +- src/components/medicines/medicines-home.tsx | 17 +- src/components/mode-home-page-skeleton.tsx | 2 +- src/components/mode-home-template.tsx | 9 +- src/components/mode-nav/header-addon-slot.ts | 2 + src/components/primitive-recipes/clinical.tsx | 2 +- src/components/primitive-recipes/recipes.ts | 14 +- src/components/route-error-boundary.tsx | 6 +- .../services/services-navigator-page.tsx | 435 +++++++++++------- src/components/show-all-chip.tsx | 4 + src/components/therapy-compass/controls.ts | 5 +- .../recommend-scenario-control.tsx | 4 +- .../screens/recommend-screen.tsx | 15 +- .../therapy-compass/screens/sheets-screen.tsx | 2 +- .../therapy-compass/therapy-card.tsx | 9 +- src/components/therapy-compass/workspace.tsx | 1 - src/components/tools/tool-local-search.tsx | 22 +- .../tools/tools-search-results-page.tsx | 10 +- src/components/ui/button-face.ts | 5 +- src/components/ui/button.tsx | 10 +- src/components/ui/pagination.tsx | 2 +- src/components/ui/sheet.tsx | 2 +- src/components/ui/toast.tsx | 2 +- src/components/ui/tooltip.tsx | 2 +- .../work-frame/use-sticky-work-tabs.ts | 8 +- src/components/work-frame/work-rail.tsx | 5 +- .../work-search/work-search-button.tsx | 116 +---- src/lib/search-navigation-context.ts | 12 + src/lib/tailwind-merge.ts | 2 +- tests/answer-provider-notice.dom.test.tsx | 11 +- ...audit-content-services-regressions.test.ts | 9 +- tests/calculators-mode.dom.test.tsx | 38 +- tests/challenger-m4-2-empirical.dom.test.tsx | 94 ++-- tests/challenger-m4-stress.dom.test.tsx | 27 +- tests/challenger-m5-2-empirical.dom.test.tsx | 56 ++- tests/compare-picker.dom.test.tsx | 1 + tests/design-token-contract.test.ts | 11 +- tests/design-type-scale.test.ts | 60 +++ .../document-search-record-fault.dom.test.tsx | 20 +- ...ent-search-scope-zero-results.dom.test.tsx | 80 +++- tests/document-tag-cloud.dom.test.tsx | 55 +++ tests/empirical-challenger-m2-2.dom.test.tsx | 46 +- tests/empirical-challenger-m2.dom.test.tsx | 83 ++-- ...irst-nations-before-you-go-in.dom.test.tsx | 9 + tests/first-nations-primer-menu.dom.test.tsx | 40 +- .../first-nations-reading-trust.dom.test.tsx | 4 - .../forms-search-registry-states.dom.test.tsx | 3 + tests/global-v2-activation.test.ts | 18 +- tests/header-scroll-hide-contract.test.ts | 13 +- tests/in-page-nav-header.dom.test.tsx | 33 +- tests/medicines-home.dom.test.tsx | 8 + tests/mode-menu-prefetch.dom.test.tsx | 5 +- tests/mode-nav-addon-slot.dom.test.tsx | 12 +- tests/mode-pages-sheet-classes.test.ts | 21 +- .../phone-overlay-chrome-reserve.dom.test.ts | 71 +-- ...overlay-reserve-portal-wiring.dom.test.tsx | 41 +- tests/playwright-settlement-contract.test.ts | 4 +- tests/search-navigation-context.test.ts | 22 + tests/search-results-header-band.dom.test.tsx | 18 + tests/services-call-button.dom.test.tsx | 193 +++++++- tests/tailwind-merge-config.test.ts | 3 + ...herapy-compass-responsive-contract.test.ts | 3 +- tests/therapy-favourite.dom.test.tsx | 19 +- ...erapy-recommend-announcements.dom.test.tsx | 92 ++++ tests/tools-truncation.dom.test.tsx | 29 +- tests/ui-accessibility.spec.ts | 30 +- tests/ui-on-call-boards.spec.ts | 6 +- tests/ui-overlap.spec.ts | 16 +- tests/ui-primitives.dom.test.tsx | 43 ++ tests/ui-smoke.spec.ts | 59 ++- tests/ui-tools.spec.ts | 74 ++- tests/ui-universal-search.spec.ts | 2 +- tests/ui-visual-artifacts.spec.ts | 13 - tests/use-sticky-work-tabs.dom.test.tsx | 31 ++ tests/work-rail-clinical.dom.test.tsx | 32 +- tests/work-search-coach.dom.test.tsx | 106 +++-- 141 files changed, 2692 insertions(+), 1673 deletions(-) create mode 100644 docs/decisions/2026-10-17-design-audit-resolution.md create mode 100644 docs/design-system/audit-resolution-2026-10-17.md create mode 100644 tests/design-type-scale.test.ts create mode 100644 tests/document-tag-cloud.dom.test.tsx create mode 100644 tests/therapy-recommend-announcements.dom.test.tsx diff --git a/docs/decisions/2026-10-17-design-audit-resolution.md b/docs/decisions/2026-10-17-design-audit-resolution.md new file mode 100644 index 0000000000..1e3200d2f7 --- /dev/null +++ b/docs/decisions/2026-10-17-design-audit-resolution.md @@ -0,0 +1,11 @@ +# Decision: Resolve the design audit with persistent notices and shared action roles + +- **Status:** decided 2026-10-17 +- **Source:** `docs/design-system/DECISIONS.md`, find "C10 · Design-audit resolution" +- **Retires:** `first-nations-automatic-primer`, `work-search-automatic-coachmark` + +The owner chose persistent inline First Nations reference/cultural-governance context with an optional About sheet, removed automatic Work search coaching and promotional halos, and chose mobile watermark hiding with desktop insetting. The audit resolution preserves intentional mode identities, meaningful focus/help, exact accessible counts, effective 48px targets, and Work reading/wide layouts while aligning shared action, type and geometry roles. Area primary actions use the shared action mapping; account/system commands retain an explicit neutral role. + +The [implementation handoff](../design-system/audit-resolution-2026-10-17.md) keeps all 32 original findings and eight assurance tasks visible with evidence limits and outstanding acceptance. This decision does not close them all, alter clinical/cultural approvals, restore the bedside calculator panel, authorize provider work, or imply deployment. + +If this summary and the source ever differ, the source wins. diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 0349f48ec8..22f1654e1e 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -678,25 +678,26 @@ Two carriers, never a third: ### 9.1 `Button` **Purpose.** The one action primitive. `AsyncButton` and hand-rolled ` ); })} @@ -523,7 +532,8 @@ export function SearchPinsMenu({ disabled={pins.length >= maximumSearchPins} title={pins.length >= maximumSearchPins ? `Maximum ${maximumSearchPins} pins reached` : undefined} className={cn( - "inline-flex min-h-12 items-center gap-1 rounded-lg px-2 text-xs font-extrabold text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)] disabled:cursor-not-allowed disabled:opacity-45", + controlDisabled, + "inline-flex min-h-12 items-center gap-1 rounded-lg px-2 text-xs font-extrabold text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)] disabled:cursor-not-allowed", focusRing, )} > diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 643c271f6b..5c4625000a 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -140,6 +140,7 @@ export function SearchResultsHeaderBand({ query, matchCount, status, + summaryPlacement = "ribbon", loading = false, faultTitle, faultBody, @@ -171,6 +172,8 @@ export function SearchResultsHeaderBand({ /** Result trustworthiness. Defaults to `ready`, or `loading` when the legacy `loading` prop is set. A faulted status never renders a number. */ status?: SearchResultsBandStatus; + /** A body recovery panel owns the summary and announcement; keep this ribbon's query and controls. */ + summaryPlacement?: "ribbon" | "body"; /** @deprecated Pass `status="loading"`. Retained so the pages with no async source need no edit; ignored whenever `status` is supplied. */ loading?: boolean; @@ -282,6 +285,7 @@ export function SearchResultsHeaderBand({ // search reads as "there are no crisis services" rather than "we could not check". const faulted = resolvedStatus === "error" || resolvedStatus === "unauthorized"; const partial = resolvedStatus === "partial"; + const showSummary = summaryPlacement === "ribbon"; const busy = resolvedStatus === "loading" || resolvedStatus === "refetching"; // "Service matches" -> "Services", leaving already-plural headings ("Favourites", // "DSM diagnoses") untouched. Phrasing the title as " could not be loaded" @@ -385,7 +389,9 @@ export function SearchResultsHeaderBand({ or partial one keeps a double stroke before the count, so state is never carried by colour alone. Rendered in flow rather than absolutely positioned, so the band's corner can never clip it. */} - {faulted || partial ? : null} + {showSummary && (faulted || partial) ? ( + + ) : null} {/* Count first, query second. The count is the answer to the search; the query is what you already typed, and the composer above still shows it. Leading with the query spent the band's only heading slot on @@ -396,24 +402,24 @@ export function SearchResultsHeaderBand({ finds it. */} {/* Neutral, not a success pill: a count is not a state that was achieved, and green has to keep meaning something where it does appear. */} - {/* One unconditional `role="status"` in every state. Playwright asserts it - is visible on every search route, so it must never be swapped out or - wrapped in a state branch. While faulted the live region is silenced + {/* The ribbon owns the status unless a body recovery panel owns it. + While faulted the live region is silenced (`aria-live="off"`) and the freshly-mounted fault `role="alert"` below makes the single announcement, rather than both speaking. */} - - {/* The state tile is gone, but state must not fall back to hue alone: + {showSummary ? ( + + {/* The state tile is gone, but state must not fall back to hue alone: the tile carried it as shape, and a failed search that is merely a different colour is invisible to a reader who cannot separate the two. The glyph renders only for the states that are not `ready`, @@ -421,79 +427,80 @@ export function SearchResultsHeaderBand({ survives forced colors, where a tinted tile background does not. Shape, glyph presence and the absence of a number are three independent non-chromatic channels. */} - {resolvedStatus === "loading" ? ( - - ) : faulted || partial ? ( - - ) : null} - {resolvedStatus === "loading" ? ( - "Searching…" - ) : resolvedStatus === "error" ? ( - "Couldn’t search" - ) : resolvedStatus === "unauthorized" ? ( - "Sign in to search" - ) : ( - // `refetching` keeps text content identical to `ready` so the atomic - // live region does not re-announce an unchanged count. The pulsing - // dot is the whole signal and it is decorative. - // - // This comment used to add "and the dimming is CSS via - // `data-status`". There is no `[data-status="refetching"]` rule in - // globals.css and there is no evidence there ever was, so the - // sentence described an intention rather than the code. Adding the - // rule is a visual change across twelve modes and belongs to - // whoever decides a background refresh should look different; - // asserting it here while it does not exist is what let it go - // unnoticed. - <> - {resolvedStatus === "refetching" ? ( - - ) : null} - - - {matchCount} - {" "} - {/* The count names what it counted. "12 matches" matched what? + {resolvedStatus === "loading" ? ( + + ) : faulted || partial ? ( + + ) : null} + {resolvedStatus === "loading" ? ( + "Searching…" + ) : resolvedStatus === "error" ? ( + "Couldn’t search" + ) : resolvedStatus === "unauthorized" ? ( + "Sign in to search" + ) : ( + // `refetching` keeps text content identical to `ready` so the atomic + // live region does not re-announce an unchanged count. The pulsing + // dot is the whole signal and it is decorative. + // + // This comment used to add "and the dimming is CSS via + // `data-status`". There is no `[data-status="refetching"]` rule in + // globals.css and there is no evidence there ever was, so the + // sentence described an intention rather than the code. Adding the + // rule is a visual change across twelve modes and belongs to + // whoever decides a background refresh should look different; + // asserting it here while it does not exist is what let it go + // unnoticed. + <> + {resolvedStatus === "refetching" ? ( + + ) : null} + + + {matchCount} + {" "} + {/* The count names what it counted. "12 matches" matched what? Once the query stops being the heading, an unlabelled number is unanchored on a deep link or below the fold — and the mode registry already carries the noun. */} - {/* An override is already correct for the count it was given — + {/* An override is already correct for the count it was given — a caller that knows "abbreviation" is the singular of "abbreviations" also knows which one this count needs, and `singularNoun` would turn a supplied "abbreviation" into itself only by luck of the suffix rules. */} - {resultNounOverride ?? - (matchCount === 1 ? singularNoun(inlineNoun(resultNoun)) : inlineNoun(resultNoun))} - {/* Nested in the count phrase, not a sibling flex item: the + {resultNounOverride ?? + (matchCount === 1 ? singularNoun(inlineNoun(resultNoun)) : inlineNoun(resultNoun))} + {/* Nested in the count phrase, not a sibling flex item: the status row is `flex … gap-1.5`, so a separate span with a leading space doubled the gap before the middot and still left the live-region textContent missing that space. */} - {partial ? ( - - {" · some sources unavailable"} - - ) : null} - {/* Same nesting rule as `partial` above: inside the count phrase, not a + {partial ? ( + + {" · some sources unavailable"} + + ) : null} + {/* Same nesting rule as `partial` above: inside the count phrase, not a sibling flex item, so the live region's textContent keeps the space before the middot. */} - {catalogueDegraded ? ( - - {` · ${catalogueDegradedNotice}`} - - ) : null} - - - )} - + {catalogueDegraded ? ( + + {` · ${catalogueDegradedNotice}`} + + ) : null} + + + )} + + ) : null} {/* The query, stated quietly after the count. The composer holds it too, but phone chrome scroll-hides and this band pins, so while you read results nothing on screen would otherwise say what was asked. It is @@ -502,7 +509,7 @@ export function SearchResultsHeaderBand({ part that truncates when the line runs out, never the number. */} {querySubjectDrawn ? ( <> - + {showSummary ? : null} void | Promise; + /** Override announcement ownership when the surrounding results band already announces this state. */ + announce?: boolean; }) { const config = searchCommandSurfaceConfig(modeId); const crossModes = (config?.crossModes ?? []).filter((target) => canAccessFavourites || target !== "favourites"); @@ -963,6 +973,7 @@ export function SearchResultsEmptyState({ // calling it the most recent would be a claim the data cannot support. const lastFilter = appliedFilters.at(-1); const filtered = appliedFilters.length > 0; + const shouldAnnounce = announce ?? !filtered; const Title = headingLevel ? (`h${headingLevel}` as "h2" | "h3" | "h4" | "h5" | "h6") : "p"; // What the panel can actually offer, decided before the copy describes it. // Both halves need a handler as well as data: most hosts render this panel @@ -986,7 +997,7 @@ export function SearchResultsEmptyState({ : `No ${resultNoun} match all ${appliedFilters.length} filters` : `No matches for “${query.trim() || "your search"}”`; const emptyBody = degraded - ? "Part of the search index did not respond, so this is not a reliable “nothing found”. Retry shortly, and do not read it as an absence of guidance." + ? "The search did not complete, so this is not a reliable “nothing found”. Retry shortly, and do not read it as an absence of guidance." : filtered ? "The search itself ran fine — the filters above excluded everything. Remove one to widen it." : hasExample && hasCrossMode @@ -997,10 +1008,9 @@ export function SearchResultsEmptyState({ ? "Try another mode." : "Check the spelling, or try a broader term."; // Live regions that mount already populated are silent in most screen readers. - // Deferred population makes the query-only path announce. Filtered-to-zero - // suppresses the region entirely: the band's `role="status"` already - // re-announced the zero count for that interaction, and a second polite - // region would double-speak. + // Deferred population makes the query-only path announce. By default a + // filtered zero leaves that announcement to the band's count. A caller can + // explicitly hand ownership here when this card is the sole failure summary. const [liveMessage, setLiveMessage] = useState(""); useEffect(() => { // The population happens in the frame callback, never in the effect body. @@ -1008,14 +1018,12 @@ export function SearchResultsEmptyState({ // to: the deferred render is the entire mechanism here, so doing it // synchronously would both trip the rule and defeat the purpose. // - // Neither clearing call this replaced was doing anything. The region is - // rendered only when `!filtered`, so the filtered branch had nothing mounted - // to clear; and a re-populating region is already unmounted-then-mounted by - // that same gate, so it starts empty on its own. - if (filtered) return; - const frame = requestAnimationFrame(() => setLiveMessage(`${emptyTitle}. ${emptyBody}`)); + // The live region is omitted whenever another surface owns the announcement. + if (!shouldAnnounce) return; + const reason = degraded && degradedReason?.trim() ? ` Reason: ${degradedReason.trim()}` : ""; + const frame = requestAnimationFrame(() => setLiveMessage(`${emptyTitle}. ${emptyBody}${reason}`)); return () => cancelAnimationFrame(frame); - }, [filtered, emptyTitle, emptyBody]); + }, [shouldAnnounce, emptyTitle, emptyBody, degraded, degradedReason]); const [retrying, setRetrying] = useState(false); const retry = async () => { if (!onRetry) return; @@ -1117,9 +1125,9 @@ export function SearchResultsEmptyState({ {/* Visible copy is never inside the live region: a region that mounts already populated is silent in most screen readers, and wrapping the visible tree would flash empty for a frame. The sr-only live region - below is populated after mount (query-only) or omitted (filtered). - NOT `role="status"`: the band already owns that role on every search - route and a second one makes singular `getByRole("status")` ambiguous. */} + below is populated after mount only when this panel owns the + announcement. It uses bare `aria-live` so a panel beside the shared + band's status never introduces a second status role. */} {/* The glyph sits beside the heading rather than inside it: a 48px tinted disc above centred copy is a container the icon vocabulary does not have, and putting the SVG inside `Title` would put a node inside the @@ -1151,7 +1159,7 @@ export function SearchResultsEmptyState({ ) : null} - {!filtered ? ( + {shouldAnnounce ? (
{liveMessage}
diff --git a/src/components/clinical-dashboard/settings-dialog.tsx b/src/components/clinical-dashboard/settings-dialog.tsx index 449a7dc2f6..c5b1cddc2f 100644 --- a/src/components/clinical-dashboard/settings-dialog.tsx +++ b/src/components/clinical-dashboard/settings-dialog.tsx @@ -81,10 +81,11 @@ import { import { clearRecentQueries, countRecentQueries } from "@/lib/recent-query-storage"; import { cn, + controlDisabled, fieldControl, floatingControl, InlineNotice, - primaryControl, + commandControl, toggleThumbSurface, } from "@/components/ui-primitives"; import { ProviderBrandMark, type SsoProvider } from "@/components/clinical-dashboard/provider-brand-icons"; @@ -817,7 +818,8 @@ export function SettingsDialog({ disabled={!reachable} aria-current={active && reachable ? "true" : undefined} className={cn( - "flex min-h-tap items-center gap-3 rounded-lg px-3 text-sm font-medium leading-5 transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-default disabled:opacity-40", + "flex min-h-tap items-center gap-3 rounded-lg px-3 text-sm font-medium leading-5 transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-default ", + controlDisabled, active && reachable ? "bg-[color:var(--surface-lux)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)] ring-1 ring-[color:var(--clinical-accent)]/12" : "text-[color:var(--text-muted)] enabled:hover:bg-[color:var(--surface-lux)]/80 enabled:hover:text-[color:var(--text-heading)]", @@ -865,7 +867,7 @@ export function SettingsDialog({ // partner controls with it. Between md and lg the panel is still // fullscreen, so the pinned bar needs the page background rather // than the floating panel's surface. - "edge-glass-header sticky top-0 z-30 border-b border-[color:var(--border)]/70 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] transition-transform duration-[var(--duration-deliberate)] motion-reduce:transition-none md:translate-y-0 md:border-b-0 md:bg-[color:var(--background)] md:px-0 md:pb-3 md:pt-6 lg:bg-[color:var(--surface-lux)]", + "edge-glass-header sticky top-0 z-30 border-b border-[color:var(--border)]/70 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] transition-transform duration-[var(--duration-deliberate)] motion-reduce:transition-none md:translate-y-0 bg-[color:var(--surface)] md:-mx-6 md:px-6 md:pb-3 md:pt-6 lg:-mx-7 lg:px-7", headerHidden && "-translate-y-full", )} > @@ -1083,7 +1085,7 @@ export function SettingsDialog({ ); } @@ -2125,7 +2135,7 @@ function AccountEntryModeButton({ type="button" onClick={() => onSelect(mode)} aria-pressed={active} - className={cn(active ? primaryControl : floatingControl, className)} + className={cn(active ? commandControl : floatingControl, className)} > {mode === "create" ? "Create account" : "Sign in"} @@ -2159,7 +2169,10 @@ function SettingsProviderRow({ onClick={onClick} disabled={busy} aria-label={label} - className="flex min-h-12 w-full items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] px-3 text-left text-sm font-semibold text-[color:var(--text-heading)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-65 disabled:hover:border-[color:var(--border)] disabled:hover:bg-[color:var(--surface-raised)]" + className={cn( + "flex min-h-12 w-full items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] px-3 text-left text-sm font-semibold text-[color:var(--text-heading)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:hover:border-[color:var(--border)] disabled:hover:bg-[color:var(--surface-raised)]", + controlDisabled, + )} > {pending ? ( diff --git a/src/components/clinical-dashboard/signed-image.tsx b/src/components/clinical-dashboard/signed-image.tsx index 5982d51c33..d6d6770a8e 100644 --- a/src/components/clinical-dashboard/signed-image.tsx +++ b/src/components/clinical-dashboard/signed-image.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import { memo, useEffect, useRef, useState } from "react"; import { CircleAlert, Maximize2 } from "lucide-react"; -import { cn, Skeleton } from "@/components/ui-primitives"; +import { controlDisabled, cn, Skeleton } from "@/components/ui-primitives"; import { Button } from "@/components/ui/button"; import { getCachedSignedUrl } from "@/lib/signed-url-cache"; import { type SignedImageFailure, useSignedImageUrl } from "@/components/clinical-dashboard/use-signed-image-url"; @@ -235,7 +235,10 @@ export const SignedImage = memo(function SignedImage({ type="button" onClick={retryImage} disabled={retryDisabled} - className="mt-3 inline-flex min-h-tap items-center rounded-lg border border-[color:var(--warning)]/30 bg-[color:var(--surface)] px-3 text-[color:var(--warning)] disabled:opacity-50 disabled:cursor-not-allowed transition" + className={cn( + controlDisabled, + "mt-3 inline-flex min-h-tap items-center rounded-lg border border-[color:var(--warning)]/30 bg-[color:var(--surface)] px-3 text-[color:var(--warning)] disabled:cursor-not-allowed transition", + )} > {retryDisabled ? "Retrying…" : retryLabel} diff --git a/src/components/clinical-dashboard/source-preview-popover.tsx b/src/components/clinical-dashboard/source-preview-popover.tsx index 4448c69f1c..ec528772af 100644 --- a/src/components/clinical-dashboard/source-preview-popover.tsx +++ b/src/components/clinical-dashboard/source-preview-popover.tsx @@ -163,7 +163,7 @@ export function SourcePreviewPopover({ data-popover-placement={layout?.placement ?? "below"} style={style} className={cn( - "fixed z-[95] min-w-[min(100vw-1.5rem,20rem)] max-w-[calc(100vw-1.5rem)] overflow-y-auto overscroll-contain rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-lux)] p-3 shadow-[var(--shadow-elevated)] motion-safe:animate-pop-in motion-reduce:animate-none", + "fixed z-[95] min-w-[min(100vw-1.5rem,20rem)] max-w-[calc(100vw-1.5rem)] overflow-y-auto overscroll-contain rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-lux)] p-3 shadow-[var(--e4)] motion-safe:animate-pop-in motion-reduce:animate-none", )} > {children} diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index e8e48f8213..941fa648c9 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -364,7 +364,7 @@ function CommandDropdown({ className={cn( // text-left: the hero composer slot sits inside the centred mode-home // template, so without it the section headings inherit text-center. - "universal-command-dropdown absolute left-0 right-0 z-30 overflow-hidden rounded-2xl border border-[color:var(--border-strong)] bg-[color:var(--surface)] text-left shadow-[var(--shadow-elevated)]", + "universal-command-dropdown absolute left-0 right-0 z-30 overflow-hidden rounded-2xl border border-[color:var(--border-strong)] bg-[color:var(--surface)] text-left shadow-[var(--e4)]", opensUpward ? "bottom-[calc(100%+0.5rem)] top-auto" : "top-[calc(100%+0.5rem)]", placement === "bottom-dock" ? "hidden sm:block" : "hidden lg:block", )} @@ -1201,18 +1201,6 @@ export function UniversalSearchCommandSurface({ )} > - {smartNaturalSearch ? null : ( - // The phone ticker demonstrates an ordinary example search. It does - // not depend on Clinical Ask or a provider flag, and it is home-only. - { - onQueryChange(example); - onFocusSearchInput?.(); - }} - /> - )}
{ @@ -1273,6 +1261,18 @@ export function UniversalSearchCommandSurface({ /> ) : null}
+ {smartNaturalSearch ? null : ( + // The phone ticker demonstrates an ordinary example search. It does + // not depend on Clinical Ask or a provider flag, and it is home-only. + { + onQueryChange(example); + onFocusSearchInput?.(); + }} + /> + )} {showHomeSuggestions ? ( (collapseSelector); const stack = collapse?.closest(headerStackSelector) ?? null; - // Prefer `||` over `??`: a present stack with offsetHeight 0 (display:contents + // Heights are fractional border-box CSS pixels throughout measurement, + // claiming and release. Integer offsetHeight rounded a 79.875px addon to + // 80px, moving page content when the portal replaced its in-flow box. + // The overlay only translates, so its rect height is unchanged while hidden. + // Prefer `||` over `??`: a present stack with height 0 (display:contents // / empty mid-unmount) must fall through to the collapse row, and a 0 from // both must stay 0 so the publisher can refuse to clobber the CSS seed. - return Math.round(stack?.offsetHeight || collapse?.offsetHeight || 0); + return stack?.getBoundingClientRect().height || collapse?.getBoundingClientRect().height || 0; } /** @@ -116,12 +120,12 @@ export function publishPhoneOverlayChromeReserveNow(): void { export function claimPhoneOverlayAddonReserve(host: HTMLElement): () => void { if (typeof window === "undefined") return () => {}; if (!window.matchMedia(phoneMediaQuery).matches) return () => {}; - const rowPx = Math.round(host.offsetHeight); + const rowPx = host.getBoundingClientRect().height; if (rowPx <= 0) return () => {}; const root = document.documentElement; // The row's height when it is released, falling back to the claimed height // if the host no longer measures (see the release note above). - const releasedRowPx = () => Math.round(host.offsetHeight) || rowPx; + const releasedRowPx = () => host.getBoundingClientRect().height || rowPx; if (!hasSettledPhoneOverlayReserve) { const previousInline = root.style.getPropertyValue(reserveProperty); @@ -183,7 +187,7 @@ export function __setPhoneOverlayReserveSettledForTests(settled: boolean): void * scroller no layout and content does not shift. * * The measurement is safe to take in either state: hiding translates the stack - * (`-translate-y-full`) and `offsetHeight` ignores transforms, so the value is + * (`-translate-y-full`) without changing its border-box rect height, so the value is * the revealed height whether or not the chrome is currently away. That is why * this hook deliberately does *not* read `data-scroll-hidden` — unlike * `use-document-chrome-metrics`, which zeroes its anchor offset while hidden diff --git a/src/components/compare/compare-catalog-picker.tsx b/src/components/compare/compare-catalog-picker.tsx index cceebc3b32..06042880cb 100644 --- a/src/components/compare/compare-catalog-picker.tsx +++ b/src/components/compare/compare-catalog-picker.tsx @@ -7,7 +7,7 @@ import { ArrowRight, Search } from "lucide-react"; import { filterCompareCatalog, padCompareIds, slotLetters } from "@/components/compare/filter-catalog"; import type { CompareCatalogItem, CompareStarterChip } from "@/components/compare/types"; import { buttonFaceClass } from "@/components/ui/button-face"; -import { cn, searchShell, searchShellInput } from "@/components/ui-primitives"; +import { cn, controlDisabled, searchShell, searchShellInput } from "@/components/ui-primitives"; export function CompareCatalogPicker({ items, @@ -207,16 +207,25 @@ export function CompareCatalogPicker({ type="button" role="option" aria-selected={highlight === index} + aria-label={[item.title, item.tag, item.snippet].filter(Boolean).join(" ")} onClick={() => choose(item.id)} disabled={duplicate} className={cn( - "group grid min-h-[4.5rem] w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[color:var(--border)] px-3 py-3 text-left transition-colors last:border-b-0 hover:bg-[color:var(--surface-subtle)] disabled:cursor-not-allowed disabled:opacity-45", + "group grid min-h-[4.5rem] w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[color:var(--border)] px-3 py-3 text-left transition-colors last:border-b-0 hover:bg-[color:var(--surface-subtle)]", + controlDisabled, highlight === index && !duplicate ? "bg-[color:var(--surface-subtle)]" : null, )} > - {item.title} + + {item.title} + {item.tag ? ( {item.tag} @@ -224,7 +233,12 @@ export function CompareCatalogPicker({ ) : null} {item.snippet ? ( - + {item.snippet} ) : null} diff --git a/src/components/developer-area/developer-gate-screen.tsx b/src/components/developer-area/developer-gate-screen.tsx index 9d0b006afe..a8514f09f9 100644 --- a/src/components/developer-area/developer-gate-screen.tsx +++ b/src/components/developer-area/developer-gate-screen.tsx @@ -3,7 +3,7 @@ import { type FormEvent, useState } from "react"; import { KeyRound, Loader2, LockKeyhole, Mail, ShieldAlert } from "lucide-react"; -import { cn, floatingControl, InlineNotice, primaryControl } from "@/components/ui-primitives"; +import { controlDisabled, cn, floatingControl, InlineNotice, commandControl } from "@/components/ui-primitives"; import { ProviderBrandMark, type SsoProvider } from "@/components/clinical-dashboard/provider-brand-icons"; import { TextField } from "@/components/ui/text-field"; import { type OAuthProvider, useAuthSession } from "@/lib/supabase/client"; @@ -164,7 +164,7 @@ export function DeveloperGateScreen({ type="submit" disabled={keyBusy || !developerKey.trim()} data-testid="developer-gate-key-submit" - className={cn(primaryControl, "w-full")} + className={cn(commandControl, "w-full")} > {unlocking ? (