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
29 changes: 20 additions & 9 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,6 @@
--shadow-lux: inset 0 1px 0 rgb(255 255 255 / 64%), 0 12px 34px rgb(8 16 24 / 7%);
--shadow-inset: inset 0 1px 0 rgb(255 255 255 / 58%);
--shadow-card: var(--shadow-tight);
--ring-focus: 0 0 0 3px color-mix(in srgb, var(--focus) 30%, transparent);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
Expand Down Expand Up @@ -418,7 +417,6 @@
--shadow-elevated: 0 2px 8px rgb(0 0 0 / 46%), 0 12px 30px rgb(0 0 0 / 48%), 0 28px 58px rgb(0 0 0 / 50%);
--shadow-lux: inset 0 1px 0 rgb(255 255 255 / 4%), 0 18px 44px rgb(0 0 0 / 64%);
--shadow-inset: inset 0 1px 0 rgb(255 255 255 / 4%);
--ring-focus: 0 0 0 3px color-mix(in srgb, var(--focus) 30%, transparent);
color-scheme: dark;
}

Expand Down Expand Up @@ -487,17 +485,22 @@ select {
-webkit-tap-highlight-color: transparent;
}

button:focus-visible,
a:focus-visible,
summary:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
box-shadow: var(--ring-focus);
/* One focus owner per interaction. Buttons and links use a single external
outline; form fields draw the same outline inside their own box so scroll
containers and sheets cannot clip it at an edge. Keeping these unlayered is
intentional: it prevents component-level `focus:ring-*` utilities from
stacking a second halo on top of the shared treatment. */
:where(button, a, summary, input[type="checkbox"], input[type="radio"], input[type="range"]):focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}

:where(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), textarea, select):focus-visible {
box-shadow: var(--shadow-inset);
outline: 2px solid var(--focus);
outline-offset: -2px;
}

button,
a,
summary {
Expand Down Expand Up @@ -1106,6 +1109,14 @@ summary::-webkit-details-marker {
letter-spacing: 0;
}

/* The universal composer form is the visible control and already owns its
focus-within border + halo. Suppress the nested text field's global focus
paint so the pill never renders competing or clipped concentric rings. */
.answer-footer-search-input:focus-visible {
box-shadow: none;
outline: none;
}

.answer-footer-search-input::placeholder {
color: color-mix(in srgb, var(--text-muted) 72%, var(--text-soft));
font-weight: 560;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -455,25 +455,23 @@ function FavouriteMobileCard({
}) {
return (
<article
role="button"
tabIndex={0}
onClick={() => onSelect(item.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(item.id);
}
}}
aria-pressed={selected}
data-testid={`favourite-mobile-card-${item.id}`}
className={cn(
"min-w-0 max-w-full rounded-lg border bg-[color:var(--surface)] p-3 shadow-[var(--shadow-tight)]",
"relative min-w-0 max-w-full rounded-lg border bg-[color:var(--surface)] p-3 shadow-[var(--shadow-tight)]",
selected
? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]/35 shadow-[inset_3px_0_0_var(--clinical-accent)]"
: "border-[color:var(--border)]",
focusRing,
)}
>
<div className="min-w-0">
<button
type="button"
onClick={() => onSelect(item.id)}
aria-pressed={selected}
aria-label={`Select ${item.title}`}
className={cn("absolute inset-0 cursor-pointer rounded-lg", focusRing)}
/>

<div className="pointer-events-none relative min-w-0">
<h3 className="line-clamp-2 text-sm-minus font-bold leading-5 text-[color:var(--text-heading)]">
{item.title}
</h3>
Expand All @@ -490,7 +488,7 @@ function FavouriteMobileCard({
</div>
</div>

<dl className="mt-3 grid gap-2 border-t border-[color:var(--border)] pt-3 text-2xs font-semibold">
<dl className="pointer-events-none relative mt-3 grid gap-2 border-t border-[color:var(--border)] pt-3 text-2xs font-semibold">
<div className="flex min-w-0 items-center justify-between gap-3">
<dt className="inline-flex items-center gap-1.5 text-[color:var(--text-muted)]">
<Folder className="h-3.5 w-3.5" aria-hidden />
Expand All @@ -504,11 +502,11 @@ function FavouriteMobileCard({
</div>
</dl>

<div className="mt-3 grid grid-cols-[minmax(0,1fr)_2.5rem] gap-2" onClick={(event) => event.stopPropagation()}>
<div className="relative z-[1] mt-3 grid grid-cols-[minmax(0,1fr)_2.75rem] gap-2">
<Link
href={item.href}
className={cn(
"inline-flex h-10 min-w-0 items-center justify-center rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--surface)] px-3 text-sm-minus font-bold text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)]",
"inline-flex h-tap min-w-0 items-center justify-center rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--surface)] px-3 text-sm-minus font-bold text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)]",
focusRing,
)}
>
Expand Down
2 changes: 1 addition & 1 deletion src/components/therapy-compass/bindings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ export function TcProvider({
sheetClinician,
toggleClinician: () => setSheetClinician((prev) => !prev),
clinicianTrack: s(
"position:relative;width:42px;height:24px;border-radius:12px;flex:none;cursor:pointer;transition:background .15s ease;background:" +
"position:relative;width:42px;height:24px;border:0;padding:0;border-radius:12px;flex:none;cursor:pointer;transition:background .15s ease;background:" +
(sheetClinician ? "var(--clinical-accent)" : "var(--border-strong)") +
";",
),
Expand Down
5 changes: 3 additions & 2 deletions src/components/therapy-compass/screens/sheets-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,10 @@ export function SheetsScreen() {
</span>
<button
type="button"
role="switch"
onClick={b.toggleClinician}
aria-pressed={b.sheetClinician}
aria-label="Toggle clinician footer"
aria-checked={b.sheetClinician}
aria-label="Show clinician footer"
style={b.clinicianTrack}
>
<span style={b.clinicianKnob} />
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui-primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const toolbarButton =
export const eyebrowText = "text-2xs font-semibold uppercase leading-4 tracking-[0.06em] text-[color:var(--text-soft)]";
export const fieldLabel = `mb-1.5 block ${eyebrowText}`;
export const fieldControl =
"h-tap w-full rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] text-sm text-[color:var(--text)] shadow-[var(--shadow-inset)] outline-none transition placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/25";
"h-tap w-full rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] text-sm text-[color:var(--text)] shadow-[var(--shadow-inset)] outline-none transition placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)]";
export const fieldControlWithIcon = `${fieldControl} pl-9 pr-3`;
export const fieldControlPlain = `${fieldControl} px-3`;
export const fieldIcon =
Expand Down Expand Up @@ -66,7 +66,7 @@ export const proseMeasure = "max-w-[68ch]";
// chunk numbers, guideline versions, document IDs. Pairs with tabular figures.
export const codeText = "font-mono tabular-nums tracking-tight";
export const commandInput =
"min-h-12 w-full rounded-lg border border-[color:var(--border)]/70 bg-[color:var(--surface)] pl-12 pr-12 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-soft),var(--shadow-inset)] outline-none transition placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/25 motion-safe:transition sm:text-base";
"min-h-12 w-full rounded-lg border border-[color:var(--border)]/70 bg-[color:var(--surface)] pl-12 pr-12 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-soft),var(--shadow-inset)] outline-none transition placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)] motion-safe:transition sm:text-base";

export const chatAnswerText =
"max-w-[68ch] text-base-minus font-medium leading-[1.56] text-[color:var(--text-heading)] sm:text-base sm:leading-[1.62]";
Expand Down
93 changes: 93 additions & 0 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,76 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByTestId("global-search-input")).toBeEnabled();
});

test("mobile search focus is singular, visible, and contained at clipped edges", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockPrivateUnauthenticatedApi(page);
await gotoApp(page, "/?mode=answer");
await waitForDemoDashboardReady(page);

const universalInput = visibleQuestionInput(page);
const restingPillBorder = await universalInput.evaluate((element) => {
const pill = element.closest(".answer-footer-search-pill");
return pill ? getComputedStyle(pill).borderColor : null;
});
await universalInput.focus();
const universalFocus = await universalInput.evaluate((element) => {
const inputStyle = getComputedStyle(element);
const pill = element.closest(".answer-footer-search-pill");
const pillStyle = pill ? getComputedStyle(pill) : null;
return {
inputOutline: inputStyle.outlineStyle,
inputShadow: inputStyle.boxShadow,
pillBorder: pillStyle?.borderColor ?? null,
pillShadow: pillStyle?.boxShadow ?? null,
};
});
expect(universalFocus.inputOutline).toBe("none");
expect(universalFocus.inputShadow).toBe("none");
expect(universalFocus.pillBorder).not.toBe(restingPillBorder);
expect(universalFocus.pillShadow).not.toBe("none");

const menu = await openMobileClinicalGuideMenu(page);
const closeMenu = menu.getByRole("button", { name: "Close Clinical Guide menu" });
const newChat = menu.getByRole("button", { name: "New chat" });
const restingButtonShadow = await newChat.evaluate((element) => getComputedStyle(element).boxShadow);
await closeMenu.focus();
await page.keyboard.press("Tab");
await expect(newChat).toBeFocused();
const buttonFocus = await newChat.evaluate((element) => {
const style = getComputedStyle(element);
return { outlineStyle: style.outlineStyle, boxShadow: style.boxShadow };
});
expect(buttonFocus.outlineStyle).toBe("solid");
expect(buttonFocus.boxShadow).toBe(restingButtonShadow);

const chatSearch = menu.getByRole("searchbox", { name: "Search recent chats" });
await chatSearch.focus();
const fieldFocus = await chatSearch.evaluate((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const outlineWidth = Number.parseFloat(style.outlineWidth);
const outlineOffset = Number.parseFloat(style.outlineOffset);
return {
outlineStyle: style.outlineStyle,
outlineWidth,
outlineOffset,
paintedTop: rect.top - outlineOffset - outlineWidth,
paintedRight: rect.right + outlineOffset + outlineWidth,
paintedBottom: rect.bottom + outlineOffset + outlineWidth,
paintedLeft: rect.left - outlineOffset - outlineWidth,
rect: { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left },
};
});
expect(fieldFocus.outlineStyle).toBe("solid");
expect(fieldFocus.outlineWidth).toBeGreaterThanOrEqual(2);
expect(fieldFocus.outlineOffset).toBeLessThan(0);
expect(fieldFocus.paintedTop).toBeGreaterThanOrEqual(fieldFocus.rect.top);
expect(fieldFocus.paintedRight).toBeLessThanOrEqual(fieldFocus.rect.right);
expect(fieldFocus.paintedBottom).toBeLessThanOrEqual(fieldFocus.rect.bottom);
expect(fieldFocus.paintedLeft).toBeGreaterThanOrEqual(fieldFocus.rect.left);
await expectNoPageHorizontalOverflow(page);
});

test("desktop sidebar defaults to the labelled state for new users", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
Expand Down Expand Up @@ -2573,6 +2643,29 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(workspace.getByRole("heading", { name: "Acamprosate renal screen", level: 3 })).toBeVisible();
});

test("mobile favourite cards keep selection and actions as independent touch controls", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockDemoApi(page);
await gotoApp(page, "/favourites");

const card = page.getByTestId("favourite-mobile-card-acamprosate-renal-screen");
const selectCard = card.getByRole("button", { name: "Select Acamprosate renal screen" });
const openItem = card.getByRole("link", { name: "Open", exact: true });
const moreActions = card.getByRole("button", { name: "More actions for Acamprosate renal screen" });

await expect(card).toBeVisible();
await expect(selectCard).toHaveAttribute("aria-pressed", "false");
await expectMinTouchTarget(selectCard);
await expectMinTouchTarget(openItem);
await expectMinTouchTarget(moreActions);

await selectCard.click();
await expect(selectCard).toHaveAttribute("aria-pressed", "true");
await expect(openItem).toBeVisible();
await expect(moreActions).toBeVisible();
await expectNoPageHorizontalOverflow(page);
});

test("app mode menu supports keyboard navigation without removed prototype modes", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
Expand Down