Skip to content
Closed
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
45 changes: 30 additions & 15 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect } from "react";
import { useEffect, useRef } from "react";

/**
* Last-resort boundary for the App Router. Unlike `app/error.tsx`, this replaces
Expand All @@ -11,8 +11,10 @@ import { useEffect } from "react";
* still renders correctly when the styling/theming system is exactly what failed.
*/
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
console.error("Fatal error captured by global-error boundary:", error);
headingRef.current?.focus();
}, [error]);

return (
Expand All @@ -25,8 +27,8 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "#f4f5f7",
color: "#1a1c1e",
backgroundColor: "Canvas",
color: "CanvasText",
fontFamily:
'system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"',
}}
Expand All @@ -37,27 +39,40 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
maxWidth: "28rem",
boxSizing: "border-box",
borderRadius: "1rem",
border: "1px solid #e2e4e8",
backgroundColor: "#ffffff",
border: "1px solid CanvasText",
backgroundColor: "Canvas",
padding: "1.5rem",
textAlign: "center",
boxShadow: "0 10px 30px rgba(15, 23, 42, 0.12)",
boxShadow: "0 10px 30px color-mix(in srgb, CanvasText 12%, transparent)",
}}
>
<h1 style={{ margin: "0 0 0.5rem", fontSize: "1.125rem", fontWeight: 600 }}>Something went wrong</h1>
<p style={{ margin: "0 0 1.25rem", fontSize: "0.875rem", lineHeight: 1.6, color: "#5b6069" }}>
<h1
ref={headingRef}
tabIndex={-1}
role="alert"
style={{
margin: "0 0 0.5rem",
fontSize: "1.125rem",
fontWeight: 600,
outline: "2px solid Highlight",
outlineOffset: "2px",
}}
>
Something went wrong
</h1>
<p style={{ margin: "0 0 1.25rem", fontSize: "0.875rem", lineHeight: 1.6 }}>
The application failed to load. Please try again, or reload the page if the problem persists.
</p>
{error.digest && (
<div
style={{
margin: "0 0 1.25rem",
borderRadius: "0.5rem",
backgroundColor: "#f4f5f7",
backgroundColor: "Canvas",
padding: "0.5rem",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: "0.75rem",
color: "#5b6069",
color: "CanvasText",
wordBreak: "break-all",
}}
>
Expand All @@ -72,8 +87,8 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
cursor: "pointer",
borderRadius: "0.5rem",
border: "none",
backgroundColor: "#1a1c1e",
color: "#ffffff",
backgroundColor: "ButtonFace",
color: "ButtonText",
padding: "0.625rem 1rem",
fontSize: "0.875rem",
fontWeight: 600,
Expand All @@ -87,9 +102,9 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
style={{
cursor: "pointer",
borderRadius: "0.5rem",
border: "1px solid #e2e4e8",
backgroundColor: "#ffffff",
color: "#1a1c1e",
border: "1px solid ButtonText",
backgroundColor: "ButtonFace",
color: "ButtonText",
padding: "0.625rem 1rem",
fontSize: "0.875rem",
fontWeight: 600,
Expand Down
12 changes: 10 additions & 2 deletions src/components/route-error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { TriangleAlert, RefreshCw } from "lucide-react";

import { cn, primaryControl } from "@/components/ui-primitives";
Expand Down Expand Up @@ -38,8 +38,10 @@ export function RouteErrorBoundary({
showReload = false,
minHeightClass = "min-h-[50vh]",
}: RouteErrorBoundaryProps) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
console.error(logLabel, error);
headingRef.current?.focus();
}, [error, logLabel]);

return (
Expand All @@ -54,7 +56,13 @@ export function RouteErrorBoundary({
<TriangleAlert aria-hidden="true" className="h-6 w-6" />
</div>

<h1 className="mt-4 text-lg font-semibold tracking-tight text-[color:var(--text-heading)]">{title}</h1>
<h1
ref={headingRef}
tabIndex={-1}
className="mt-4 text-lg font-semibold tracking-tight text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus-ring,Highlight)]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the component structure first, then read the relevant slice.
ast-grep outline src/components/route-error-boundary.tsx --view expanded || true
printf '\n--- file slice ---\n'
sed -n '1,180p' src/components/route-error-boundary.tsx | cat -n

Repository: BigSimmo/Database

Length of output: 4976


🌐 Web query:

CSS :focus-visible script focus after pointer interaction heuristics MDN

💡 Result:

The:focus-visible pseudo-class applies to an element when it is focused and the browser's User Agent (UA) determines, through internal heuristics, that a visible focus indicator is necessary [1]. This allows developers to display focus styles only when they would be most helpful—primarily for keyboard users—while avoiding potentially distracting focus rings during pointer interactions [1][2][3]. Heuristics for:focus-visible The specific heuristics used by browsers are not defined normatively in the CSS specification, which grants UAs flexibility in implementation [4][5][6]. However, modern browser engines have converged on a common set of behaviors [2]: 1. Keyboard Interaction: If an element receives focus via a keyboard interaction (e.g., Tab key),:focus-visible will almost always match, as the user is clearly navigating by keyboard [1][2][3]. 2. Pointer Interaction: Generally, when an element is focused via a mouse click, touch, or other pointing device,:focus-visible does not match [1][3]. This prevents focus styles from "sticking" on elements like buttons after they are clicked, which can be confusing for users [7]. 3. Input Elements: Elements that require user input, such as text fields (e.g., or <textarea>), typically match:focus-visible regardless of how focus was received [1][2]. This is because it is essential for the user to know where their typed input will be placed [1][2]. Regarding the:focus-visible polyfill (WICG/focus-visible): Before widespread native support, a polyfill script was commonly used to achieve this behavior [8]. This script used its own set of heuristics to mimic native support [8][5]: - Keyboard Modality: It tracked whether the last interaction was a keyboard event (like Tab) and added a.focus-visible class accordingly [4][8]. - Focus Events: It would trigger focus visibility if an element that required keyboard interaction (like a text field) received focus [8]. - Limitations: Because the polyfill had to rely on events available to JavaScript, it could not perfectly replicate the internal, low-level browser heuristics, leading to slight variations in behavior compared to modern native implementations [8][5]. Today, developers are encouraged to use the native:focus-visible pseudo-class directly, as it is supported by all major browsers and integrates natively with the browser's own focus-tracking logic [1][2][8]. Top Results: [1][2][5][3]

Citations:


🌐 Web query:

MDN :focus-visible keyboard modality pointer programmatic focus

💡 Result:

The:focus-visible pseudo-class is a CSS selector that matches an element only when the User Agent determines, via internal heuristics, that a focus indicator is necessary [1]. Its primary purpose is to distinguish between situations where a focus indicator is helpful (e.g., keyboard navigation) and where it might be considered obtrusive (e.g., clicking a button with a mouse) [1][2]. Regarding programmatic focus and modality: 1. Modality and Heuristics: Browsers use heuristics to determine the input modality [2]. Generally, if the user interacts via the keyboard,:focus-visible matches the focused element [1]. If the user interacts via a pointing device, it typically does not match on elements that do not require text input (like buttons) [1]. 2. Programmatic Focus (element.focus): When focus is moved programmatically, the behavior of:focus-visible depends on the context [3][4]. - Initial focus (e.g., page load or autofocus): Browsers typically trigger:focus-visible by default because there is no prior "modality" context [4]. - Focus transfer: If an element currently matches:focus-visible (e.g., the user is already navigating via keyboard), moving focus programmatically to a new element will often cause the new element to also match:focus-visible [3][4]. Conversely, if the element does not match, the new element typically will not [3][4]. 3. Forcing Visibility: Because browser heuristics for programmatic focus can sometimes lead to inconsistent results for developers, the focus method has been extended in many modern environments to accept an options object that includes a focusVisible property [5][6]. Example: element.focus({ focusVisible: true }); Setting focusVisible: true explicitly instructs the browser to show the focus indicator and ensures:focus-visible matches, regardless of the current inferred input modality [5][6].

Citations:


Use a focus style that always appears on the error heading.

Because this heading is focused in useEffect, focus-visible: can be skipped after pointer-driven failures. Switch these utilities to focus: or apply an explicit focused state so recovery focus is always visible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/route-error-boundary.tsx` at line 62, Update the error
heading’s focus styling in the route error boundary to use focus-triggered
utilities instead of focus-visible, ensuring the recovery focus applied by
useEffect always shows the outline. Preserve the existing outline width, offset,
and focus-ring color values.

>
{title}
</h1>

<p className="mt-2 text-sm leading-relaxed text-[color:var(--text-muted)]">{description}</p>

Expand Down
5 changes: 4 additions & 1 deletion src/components/therapy-compass/bindings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type TcBindings = {
// ---- data -----------------------------------------------------------
loading: boolean;
error: string | null;
retryData: () => void;
therapies: Therapy[];
unreviewedTherapies: Therapy[];
reviewCount: number;
Expand Down Expand Up @@ -207,7 +208,7 @@ export function TcProvider({
initialQuery?: string;
autoRunSearch?: boolean;
}) {
const { data, loading, error } = useTherapyData();
const { data, loading, error, retry } = useTherapyData();
const therapies = useMemo(() => data?.therapies ?? [], [data]);
const pathways = useMemo(() => data?.pathways ?? [], [data]);

Expand Down Expand Up @@ -271,6 +272,7 @@ export function TcProvider({
return {
loading,
error,
retryData: retry,
therapies,
unreviewedTherapies,
reviewCount: unreviewedTherapies.length,
Expand Down Expand Up @@ -429,6 +431,7 @@ export function TcProvider({
}, [
loading,
error,
retry,
data,
therapies,
unreviewedTherapies,
Expand Down
25 changes: 18 additions & 7 deletions src/components/therapy-compass/data/use-therapy-data.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

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

import type { Pathway, ReferenceData, Therapy, TherapyDataset } from "./types";

Expand Down Expand Up @@ -32,28 +32,39 @@ export type TherapyDataState = {
data: TherapyDataset | null;
loading: boolean;
error: string | null;
retry: () => void;
};

export function useTherapyData(): TherapyDataState {
const [state, setState] = useState<TherapyDataState>({ data: null, loading: true, error: null });
const [state, setState] = useState<Omit<TherapyDataState, "retry">>({ data: null, loading: true, error: null });
const [attempt, setAttempt] = useState(0);
const retry = useCallback(() => {
cache = null;
// Keep the prior error message mounted so Retry focus is not lost while the
// next attempt is in flight; success/failure handlers replace it below.
setState((prev) => ({ ...prev, loading: true }));
setAttempt((value) => value + 1);
}, []);

useEffect(() => {
let active = true;
cache ??= loadDataset();
cache
const request = cache;
request
.then((data) => {
if (active) setState({ data, loading: false, error: null });
})
.catch((err: unknown) => {
// Allow a retry on the next mount if the fetch failed.
cache = null;
// Only clear the shared cache when this request is still the active one.
// A newer retry may have already replaced `cache` with a fresh promise.
if (cache === request) cache = null;
if (active)
setState({ data: null, loading: false, error: err instanceof Error ? err.message : "Failed to load" });
});
return () => {
active = false;
};
}, []);
}, [attempt]);

return state;
return { ...state, retry };
}
6 changes: 4 additions & 2 deletions src/components/therapy-compass/therapy-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,11 @@ export function ResultCard({ therapy }: { therapy: Therapy }) {
<button
type="button"
className="tc-btn"
title="Favourite"
disabled
title="Favourites are not available yet"
aria-label="Favourites are coming soon"
style={s(
`display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;border:1px solid var(--border);border-radius:9px;background:var(--surface);color:var(--text-soft);cursor:pointer;`,
`display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;border:1px solid var(--border);border-radius:9px;background:var(--surface);color:var(--text-soft);cursor:not-allowed;opacity:.65;`,
)}
>
<HeartIcon size={17} />
Expand Down
50 changes: 41 additions & 9 deletions src/components/therapy-compass/therapy-compass-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,47 @@ function TherapyCompassShell() {
>
<TherapyCompassNav />
<main className="tc-main" style={s(`min-width:0;padding:32px 40px 40px;`)}>
{b.isHome && <HomeScreen />}
{b.isSearch && <SearchScreen />}
{b.isDetail && <DetailScreen />}
{b.isCompare && <CompareScreen />}
{b.isRecommend && <RecommendScreen />}
{b.isPathways && <PathwaysScreen />}
{b.isBrief && <BriefScreen />}
{b.isSheets && <SheetsScreen />}
{b.isOther && <OtherScreen />}
{b.error ? (
<section
role="alert"
aria-live="assertive"
aria-busy={b.loading}
style={s(
`max-width:42rem;margin:40px auto;padding:24px;border:1px solid var(--danger);border-radius:16px;background:var(--danger-soft);`,
)}
>
<h1 style={s(`margin:0 0 8px;color:var(--text-heading);font-size:20px;`)}>
Therapy Compass could not load
</h1>
<p style={s(`margin:0 0 16px;color:var(--text-muted);line-height:1.5;`)}>
The therapy catalogue is unavailable. No results are being shown as a substitute.
</p>
<button
type="button"
className="tc-btn"
onClick={b.retryData}
disabled={b.loading}
aria-disabled={b.loading}
style={s(
`padding:10px 14px;border:0;border-radius:8px;background:var(--clinical-accent);color:var(--clinical-accent-contrast);font-weight:650;opacity:${b.loading ? "0.7" : "1"};`,
)}
>
{b.loading ? "Retrying…" : "Retry"}
</button>
</section>
) : (
<>
{b.isHome && <HomeScreen />}
{b.isSearch && <SearchScreen />}
{b.isDetail && <DetailScreen />}
{b.isCompare && <CompareScreen />}
{b.isRecommend && <RecommendScreen />}
{b.isPathways && <PathwaysScreen />}
{b.isBrief && <BriefScreen />}
{b.isSheets && <SheetsScreen />}
{b.isOther && <OtherScreen />}
</>
)}
<TherapyCompassFooter />
</main>
</div>
Expand Down