Skip to content

Commit 29f92e0

Browse files
polish: accessibility, consistency, error boundary, title casing
- Add ErrorBoundary wrapping all views (graceful crash recovery) - Fix document title casing: "Tls" to "TLS", "Dns" to "DNS" (proper acronyms) - Remove em dash from titles (use pipe separator per style guide) - Add aria-label to CopyButton, status bar dots, loading spinner - Add role="progressbar" with aria-valuenow/min/max to server resource bars - Add disabled + spinner state to health check button during mutation - Add loading text to Trust CA ("Trusting...") and Rotate ("Rotating...") buttons - Fix status bar: show "No services" instead of "0/0 up" when empty - Standardize hover states: brightness-110 for colored buttons, bg-primary/90 for primary - Clean up stale .js build artifacts from ui/src/ - Add *.tsbuildinfo to .gitignore
1 parent d3cf588 commit 29f92e0

10 files changed

Lines changed: 87 additions & 20 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,4 @@ ui/dist/
5959
# TypeScript/Vite build artifacts
6060
ui/src/**/*.js
6161
!ui/src/**/*.config.js
62+
*.tsbuildinfo

ui/src/app.tsx

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AppShell } from "@/components/layout/app-shell";
22
import { useAppStore } from "@/stores/app-store";
3-
import { lazy, Suspense } from "react";
3+
import { Component, lazy, Suspense, type ReactNode, type ErrorInfo } from "react";
44

55
const DashboardView = lazy(
66
() => import("@/components/views/dashboard/dashboard-view"),
@@ -28,21 +28,63 @@ const views = {
2828

2929
function Loading() {
3030
return (
31-
<div className="flex h-full items-center justify-center">
31+
<div className="flex h-full items-center justify-center" role="status">
3232
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-primary" />
33+
<span className="sr-only">Loading...</span>
3334
</div>
3435
);
3536
}
3637

38+
interface ErrorBoundaryState {
39+
error: Error | null;
40+
}
41+
42+
class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
43+
state: ErrorBoundaryState = { error: null };
44+
45+
static getDerivedStateFromError(error: Error) {
46+
return { error };
47+
}
48+
49+
componentDidCatch(error: Error, info: ErrorInfo) {
50+
console.error("View crashed:", error, info.componentStack);
51+
}
52+
53+
render() {
54+
if (this.state.error) {
55+
return (
56+
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
57+
<h2 className="text-lg font-semibold text-text-1">Something went wrong</h2>
58+
<p className="max-w-sm text-sm text-text-3">
59+
The view encountered an error. Try refreshing or switching to a different view.
60+
</p>
61+
<button
62+
onClick={() => {
63+
this.setState({ error: null });
64+
useAppStore.getState().setView("dashboard");
65+
}}
66+
className="mt-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
67+
>
68+
Back to Dashboard
69+
</button>
70+
</div>
71+
);
72+
}
73+
return this.props.children;
74+
}
75+
}
76+
3777
export function App() {
3878
const view = useAppStore((s) => s.view);
3979
const View = views[view];
4080

4181
return (
4282
<AppShell>
43-
<Suspense fallback={<Loading />}>
44-
<View />
45-
</Suspense>
83+
<ErrorBoundary>
84+
<Suspense fallback={<Loading />}>
85+
<View />
86+
</Suspense>
87+
</ErrorBoundary>
4688
</AppShell>
4789
);
4890
}

ui/src/components/layout/status-bar.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface StatusBarProps {
1313
}
1414

1515
function Dot({ ok, loading }: { ok?: boolean; loading?: boolean }) {
16+
const label = loading ? "checking" : ok ? "healthy" : "unhealthy";
1617
return (
1718
<span
1819
className={cn(
@@ -21,6 +22,8 @@ function Dot({ ok, loading }: { ok?: boolean; loading?: boolean }) {
2122
!loading && ok && "bg-success shadow-[0_0_4px_var(--color-success)]",
2223
!loading && !ok && "bg-error",
2324
)}
25+
role="img"
26+
aria-label={label}
2427
/>
2528
);
2629
}
@@ -67,10 +70,14 @@ export function StatusBar({
6770
<Pill><Dot ok={dnsOk} /> DNS</Pill>
6871
<Pill><Dot ok={caddyOk} /> Caddy</Pill>
6972
<Sep />
70-
<Pill>
71-
<Dot ok={allServicesUp} loading={!allServicesUp && servicesUp > 0} />
72-
{servicesUp}/{servicesTotal} up
73-
</Pill>
73+
{servicesTotal > 0 ? (
74+
<Pill>
75+
<Dot ok={allServicesUp} loading={!allServicesUp && servicesUp > 0} />
76+
{servicesUp}/{servicesTotal} up
77+
</Pill>
78+
) : (
79+
<Pill><Dot ok={false} /> No services</Pill>
80+
)}
7481
<Pill>
7582
<Dot ok={certsValid > 0} />
7683
{certsValid} cert{certsValid !== 1 ? "s" : ""} valid

ui/src/components/shared/copy-button.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function CopyButton({ text, label, className }: CopyButtonProps) {
2828
className,
2929
)}
3030
title="Copy"
31+
aria-label={copied ? "Copied" : "Copy to clipboard"}
3132
>
3233
{copied ? <Check size={11} /> : <Copy size={11} />}
3334
{label && <span>{copied ? "Copied!" : label}</span>}

ui/src/components/views/dashboard/blank-slate.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ function CapabilityCard({
338338
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold transition-all",
339339
cap.bgColor,
340340
cap.color,
341-
"hover:brightness-125",
341+
"hover:brightness-110",
342342
)}
343343
>
344344
Open {cap.label}
@@ -450,7 +450,7 @@ export function BlankSlate() {
450450
<div>
451451
<button
452452
onClick={() => setView("services")}
453-
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white transition-all hover:brightness-110"
453+
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary/90"
454454
>
455455
<Layers size={14} />
456456
Register a Service

ui/src/components/views/servers/servers-view.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,14 @@ function ResourceCard({
260260
<p className="text-lg font-bold tabular-nums text-text-1">{main}</p>
261261
{sub && <p className="mt-0.5 text-[11px] text-text-muted">{sub}</p>}
262262
{percent != null && (
263-
<div className="mt-2 h-1.5 rounded-full bg-surface-3">
263+
<div
264+
className="mt-2 h-1.5 rounded-full bg-surface-3"
265+
role="progressbar"
266+
aria-valuenow={Math.round(percent)}
267+
aria-valuemin={0}
268+
aria-valuemax={100}
269+
aria-label={`${label} ${Math.round(percent)}%`}
270+
>
264271
<div
265272
className={cn(
266273
"h-full rounded-full transition-all",

ui/src/components/views/services/services-view.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,10 @@ export default function ServicesView() {
208208
<div className="flex items-center gap-2 border-t border-border pt-3">
209209
<button
210210
onClick={() => healthCheckMut.mutate(svc.name)}
211-
className="flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11px] font-medium text-text-3 transition-colors hover:bg-surface-2"
211+
disabled={healthCheckMut.isPending}
212+
className="flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11px] font-medium text-text-3 transition-colors hover:bg-surface-2 disabled:opacity-50"
212213
>
213-
<RefreshCw size={11} /> Check
214+
<RefreshCw size={11} className={cn(healthCheckMut.isPending && "animate-spin")} /> Check
214215
</button>
215216
<button
216217
onClick={() => handleDeregister(svc)}

ui/src/components/views/tls/tls-view.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export default function TlsView() {
6565
disabled={trustCaMut.isPending}
6666
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:opacity-50"
6767
>
68-
<ShieldCheck size={14} /> Trust CA
68+
<ShieldCheck size={14} /> {trustCaMut.isPending ? "Trusting..." : "Trust CA"}
6969
</button>
7070
</div>
7171

@@ -253,7 +253,7 @@ function CertCard({
253253
disabled={rotating}
254254
className="ml-auto flex items-center gap-1 rounded-md border border-primary/30 px-2 py-1 text-[11px] font-medium text-primary transition-colors hover:bg-primary/10 disabled:opacity-50"
255255
>
256-
<RefreshCw size={11} /> Rotate
256+
<RefreshCw size={11} className={cn(rotating && "animate-spin")} /> {rotating ? "Rotating..." : "Rotate"}
257257
</button>
258258
</div>
259259
</div>

ui/src/stores/app-store.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@ describe("app-store", () => {
6262

6363
it("updates document title for non-dashboard views", () => {
6464
useAppStore.getState().setView("tls");
65-
expect(document.title).toContain("Tls");
66-
expect(document.title).toContain("QP Conduit");
65+
expect(document.title).toBe("TLS | QP Conduit");
6766
});
6867

6968
it("sets title to 'QP Conduit' for dashboard", () => {

ui/src/stores/app-store.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ const VIEW_TO_PATH: Record<View, string> = {
5050
routing: "/routing",
5151
};
5252

53+
const VIEW_TITLES: Record<View, string> = {
54+
dashboard: "Dashboard",
55+
services: "Services",
56+
dns: "DNS",
57+
tls: "TLS",
58+
servers: "Servers",
59+
routing: "Routing",
60+
};
61+
5362
function viewFromPath(): View {
5463
return PATH_TO_VIEW[window.location.pathname] ?? "dashboard";
5564
}
@@ -73,7 +82,7 @@ export const useAppStore = create<AppState>((set) => ({
7382
if (window.location.pathname !== path) {
7483
window.history.pushState(null, "", path);
7584
}
76-
document.title = view === "dashboard" ? "QP Conduit" : `${view.charAt(0).toUpperCase() + view.slice(1)} QP Conduit`;
85+
document.title = view === "dashboard" ? "QP Conduit" : `${VIEW_TITLES[view]} | QP Conduit`;
7786
set({ view });
7887
},
7988

@@ -98,6 +107,6 @@ export const useAppStore = create<AppState>((set) => ({
98107
// Listen for browser back/forward (set state without pushing history again)
99108
window.addEventListener("popstate", () => {
100109
const view = viewFromPath();
101-
document.title = view === "dashboard" ? "QP Conduit" : `${view.charAt(0).toUpperCase() + view.slice(1)} QP Conduit`;
110+
document.title = view === "dashboard" ? "QP Conduit" : `${VIEW_TITLES[view]} | QP Conduit`;
102111
useAppStore.setState({ view });
103112
});

0 commit comments

Comments
 (0)