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/codebase-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ domain-extracted directory; imported as `@/lib/rag/rag*`). Other modules below r
| `observability/` — `answer-slo.ts`, `cache-metrics.ts`, `spend-metrics.ts`, `error-tracking.ts`, `agent-monitoring.ts` | Deep-health SLO / cache-hit / answer-spend snapshots; privacy-safe Sentry error + DB-span scrubbers and metadata-only OpenAI agent monitoring (`docs/error-tracking.md`) |
| `validation/` | `body.ts`, `query.ts`, `params.ts`, `http.ts`, `form-data.ts` |
| `app-modes.ts`, `document-flow-routes.ts`, `local-project-identity.ts`, `local-server-utils.mjs` | Routing and project identity |
| `tailwind-merge.ts` | The `extendTailwindMerge` config behind `cn()` — declares this repo's custom `@theme` scales so twMerge does not misclassify them (`docs/design-system/TOKENS.md`) |

---

Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"overrides": {
Expand Down
27 changes: 16 additions & 11 deletions src/components/clinical-dashboard/document-search-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,13 +364,14 @@ function DocumentFilterPanel({
onOpenLibrary();
}}
data-testid="document-filter-browse-library"
// `border-0 border-t`, not `border-t` alone. `cn` is a plain join,
// not tailwind-merge, so `floatingControl`'s own `border` (all four
// sides) survives an added `border-t` and the result is a fully
// bordered button whose colour is decided by Tailwind's emission
// order between two competing arbitrary utilities — the exact hazard
// the facet-chip branches in this file are written to avoid. Zeroing
// the box first leaves only the separating rule that was intended.
// `border-0 border-t`, not `border-t` alone. `cn` now runs through
// tailwind-merge (ledger #218), which lifts half of this: the
// competing arbitrary border COLOURS resolve last-wins instead of by
// Tailwind's emission order. The width half is not lifted —
// tailwind-merge scores bare `border` and `border-t` as different
// groups, so `floatingControl`'s all-sides `border` still survives an
// added `border-t` and the button would still be fully bordered.
// Zeroing the box first is still what leaves only the separating rule.
className={cn(
floatingControl,
"min-h-tap justify-start gap-2 rounded-lg border-0 border-t border-[color:var(--border)] bg-transparent px-1 text-xs sm:min-h-10",
Expand Down Expand Up @@ -631,10 +632,14 @@ function DocumentFilterPanel({
"inline-flex min-h-tap max-w-full items-center gap-1.5 rounded-md border px-2.5 text-2xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-9 sm:gap-1 sm:px-2 lg:min-h-8",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
// Three mutually exclusive branches, not a base plus an
// override: `cn` is a plain join, so two competing
// `border-[color:…]` utilities would both reach the DOM
// and the winner would be decided by stylesheet order
// rather than by intent.
// override: `cn` was a plain join, so two competing
// `border-[color:…]` utilities both reached the DOM and
// the winner was decided by stylesheet order rather than
// by intent. That constraint is lifted (ledger #218) —
// `cn` merges, and a later border colour now wins
// deterministically. The branches are kept as they are
// because collapsing them changes which utilities render;
// that is a visual change, not a dependency swap.
selected
? "border-[color:var(--clinical-accent)]/35 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
: deadEnd
Expand Down
7 changes: 5 additions & 2 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2082,10 +2082,13 @@ export function MasterSearchHeader({
onKeyDown={handleModeTriggerKeyDown}
className={cn(
// Size utilities live in the per-variant branch, never the shared
// base: cn() is plain concat (no tailwind-merge), so keeping the
// base: cn() was plain concat (no tailwind-merge), so keeping the
// default h-/w-/min-w- here too made the workflow overrides dead —
// Tailwind v4 emits same-property utilities in canonical order and
// the base won at every breakpoint but lg:.
// the base won at every breakpoint but lg:. That constraint is
// lifted (ledger #218): cn() merges, so a base size would now lose
// to the branch that follows it. The split stays because one place
// per size is still the clearer shape, not because it is forced.
"universal-header-mode-button inline-grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-left 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)]",
isWorkflowHeader
? "h-tap w-[min(11rem,calc(100vw-11rem))] sm:w-[12rem] sm:min-w-0 lg:w-[12.5rem]"
Expand Down
13 changes: 10 additions & 3 deletions src/components/mode-home-template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,17 @@ export function ModeHomeHero({
* shell). That works for short empty homes, but centering a child taller than
* the phone scrollport clips the top — unreachable at scrollTop 0.
*
* Prefer this prop over className `justify-*` overrides: `cn()` concatenates
* and does not resolve Tailwind conflicts, so dual justify utilities are
* non-deterministic. Alignment classes are applied last and any stray
* Prefer this prop over className `justify-*` overrides: `cn()` used to
* concatenate without resolving Tailwind conflicts, so dual justify utilities
* were non-deterministic. Alignment classes are applied last and any stray
* `justify-*` tokens in `className` are stripped.
*
* That constraint is lifted (ledger #218) — `cn()` now merges, and because the
* alignment class is applied last it would win on its own, prefixed variants
* (`sm:justify-*`) included. `withoutJustifyUtilities` is therefore belt-and-
* braces rather than load-bearing. It is kept: dropping it changes which
* utilities reach the DOM on every mode home, which wants its own visual proof,
* and the prop remains the supported way to set this alignment either way.
*/
export type ModeHomeMainAlign = "center" | "start" | "startOnPhone";

Expand Down
12 changes: 11 additions & 1 deletion src/components/ui-primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
validationStatusLabel,
} from "@/lib/source-metadata";
import { classifySourceAuthority } from "@/lib/source-authority-registry";
import { twMergeClinical } from "@/lib/tailwind-merge";
import type { ClinicalSourceMetadata } from "@/lib/types";

/**
Expand All @@ -23,8 +24,17 @@ import type { ClinicalSourceMetadata } from "@/lib/types";
*/
export type SourceMetadataInput = Partial<ClinicalSourceMetadata> | null;

/**
* Compose Tailwind classes, resolving conflicts last-wins.
*
* Falsy arguments are dropped exactly as before; what changed is that the result
* now goes through tailwind-merge, so a later class beats an earlier one instead
* of both being emitted and the generated stylesheet's order deciding. See
* `@/lib/tailwind-merge` for why the merge needs this repo's `@theme` scales
* declared to it, and what it silently deletes without them.
*/
export function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
return twMergeClinical(classes.filter(Boolean).join(" "));
}

export const transitionSurface = "transition-colors transition-shadow motion-reduce:transition-none";
Expand Down
14 changes: 10 additions & 4 deletions src/components/ui/page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,17 @@ export function PageHeader({
page header keeps one treatment.

`max-sm:hidden`, not `hidden sm:grid`: `iconTilePremium` already
carries `grid`, and `cn()` in this repo is a plain join with no
carries `grid`, and `cn()` used to be a plain join with no
tailwind-merge (ledger #218), so `hidden` + `sm:grid` + `grid`
would leave three display utilities to be resolved by stylesheet
order rather than by intent. One base display plus one max-width
override has nothing to race. */}
left three display utilities to be resolved by stylesheet order
rather than by intent. One base display plus one max-width
override had nothing to race.

That constraint is lifted — `cn()` now merges, and `grid hidden
sm:grid` resolves to `hidden sm:grid`. This site keeps
`max-sm:hidden` anyway: swapping it back is a change to which
display utility renders, so it belongs in a change whose Chromium
job is being read, not in the dependency PR. */}
{Icon ? (
<span className={cn(iconTilePremium, "max-sm:hidden")}>
<Icon aria-hidden="true" className="size-icon-lg" />
Expand Down
103 changes: 103 additions & 0 deletions src/lib/tailwind-merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { extendTailwindMerge } from "tailwind-merge";

/**
* tailwind-merge, taught this repo's `@theme`.
*
* `cn()` used to be a plain `join(" ")`, so a later class could not override an
* earlier one and every size/colour override had to be worked around at the call
* site (see the notes in `page-header.tsx`, `document-search-results.tsx`,
* `master-search-header.tsx` and `mode-home-template.tsx`). twMerge fixes that —
* but ONLY if it is told about the scales Tailwind generates from
* `src/app/globals.css`, which is the single source of truth for every name below.
*
* Two distinct failure modes motivate this config; both are silent, with no type
* or lint signal:
*
* 1. **Misclassification (deletes a class).** Stock tailwind-merge falls back to
* "text colour" for any `text-<x>` it does not recognise as a font size. Its
* built-in font-size scale is t-shirt-shaped, so `text-2xs` / `text-3xs`
* happen to survive, but `text-sm-minus`, `text-base-minus`, `text-lg-minus`,
* `text-2xl-minus`, `text-2xl-compact`, `text-3xl-minus` and `text-hero` do
* not — each was measured being **deleted** when it met the sibling
* `text-[color:var(--text-muted)]` in the same `cn()` call. `eyebrowText` is
* exactly that pair. Declaring the scale under `theme.text` moves them into
* the font-size group, where they belong.
*
* 2. **Non-recognition (silently fails to merge).** `size-icon-md`,
* `tracking-label`, `leading-prose`, `ease-out-soft`, `animate-shimmer` and
* `pt-safe` are not in any stock scale, so twMerge passes them through
* untouched and a later `size-icon-lg` does not replace them — the same
* override failure `cn()` is being changed to fix.
*
* Declaring a family is not free: it changes which class wins at any call site
* that already writes two of them. Every family below was measured against all
* 1 409 `cn()` call sites and introduces **zero** new class deletions. The one
* family that did — `--spacing-tap` — is held back, for the reason recorded
* against `spacing` below.
*
* Radius (`--radius-xs … --radius-2xl`) and the font families reuse Tailwind's own
* names, so they need no entry. `--shadow-*` are plain custom properties consumed
* as `shadow-[var(--shadow-tight)]`; they generate no `shadow-<name>` utility and
* stock twMerge already groups the arbitrary form correctly. `--spring-*` sit
* outside a Tailwind namespace and generate nothing.
*
* Adding a token to `@theme` in `globals.css` means adding it here too.
*/
export const CLINICAL_TWMERGE_THEME = {
// globals.css @theme --text-* — size-only steps (no baked leading/tracking).
// `3xs`/`2xs` already resolve as t-shirt sizes; listed so the scale reads
// whole and stays correct if that heuristic ever narrows.
text: ["3xs", "2xs", "sm-minus", "base-minus", "lg-minus", "2xl-minus", "2xl-compact", "3xl-minus", "hero"],

// globals.css @theme --leading-* — the two steps Tailwind's scale cannot
// express. Tailwind still owns tight/snug/normal/relaxed.
leading: ["display", "prose"],

// globals.css @theme --tracking-* — five named roles. `normal` is also a
// stock name and needs no entry, but is listed to keep the role set whole.
tracking: ["display", "normal", "label", "eyebrow", "kicker"],

// globals.css @theme --spacing-* — generates size-icon-* / h-icon-* /
// w-icon-* and the mode-home composer reserves. `safe` / `safe-2` are the
// @utility pt-safe / pb-safe / pb-safe-2 rules further down globals.css,
// which are padding utilities and should conflict with pt-*/pb-* like any
// other.
//
// `tap` (--spacing-tap, the 48px target knob) is DELIBERATELY ABSENT, and
// this is the one omission that is not an oversight. Tailwind emits
// `.min-h-tap` after every numeric `.min-h-*` and `.h-tap` after `.h-4` /
// `.h-10.5`, so at equal specificity the tap token wins today wherever a
// call site pairs the two. Declaring `tap` here would hand the win to the
// later class instead — measured across 22 call sites (document-admin,
// DocumentManagerPanel, favourites-hub, settings-dialog, service-detail-page,
// form-detail-page, clinical-output-helpers, account-setup-dialog), 18 of
// which would drop from 48px to 32/36/40/42px. AGENTS.md and SPEC §4.10 are
// explicit that no production target is ever reduced, so the merge stays off
// for this family until those sites drop the numeric class they already
// cannot apply. Until then `min-h-tap min-h-9` passes through unmerged,
// exactly as it does today; `tests/tailwind-merge-config.test.ts` pins that.
spacing: [
"icon-xs",
"icon-sm",
"icon-md",
"icon-lg",
"icon-xl",
"mode-home-composer-phone",
"mode-home-composer-wide",
"safe",
"safe-2",
],

// globals.css @theme --ease-*.
ease: ["out-soft", "spring"],

// globals.css @theme --animate-*.
animate: ["fade-up", "overlay-in", "sheet-up", "sheet-left", "pop-in", "dialog-rise", "action-tray-in", "shimmer"],
} as const;

export const twMergeClinical = extendTailwindMerge({
// Spread rather than inline so `tests/tailwind-merge-config.test.ts` can compare
// this exact object against the `@theme` block in globals.css. A token added to
// one and not the other is the silent failure this whole module exists to stop.
extend: { theme: { ...CLINICAL_TWMERGE_THEME } },
});
Loading
Loading