Add performance optimizations and accessibility improvements

Dynamic imports for heavy components (Kanban, Gantt, CommandPalette),
skip-to-content link, ARIA landmarks/labels on sidebar, breadcrumbs,
topbar notifications, kanban board, gantt timeline, and pipeline
progress. Focus-visible ring styles for keyboard navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Leivur R. Djurhuus 2026-02-28 22:01:40 -06:00
parent 8691f1a1c2
commit f4e6da9210
16 changed files with 132 additions and 18 deletions

28
package-lock.json generated
View file

@ -16,6 +16,7 @@
"@tailwindcss/postcss": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -4915,6 +4916,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.19",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.19.tgz",
"integrity": "sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.19"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
@ -4928,6 +4946,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.19",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.19.tgz",
"integrity": "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

View file

@ -24,6 +24,7 @@
"@tailwindcss/postcss": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View file

@ -2,7 +2,7 @@ import { Suspense } from "react";
import { Sidebar, MobileSidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
import { QueryProvider } from "@/components/query-provider";
import { CommandPalette } from "@/components/command-palette";
import { LazyCommandPalette } from "@/components/lazy-command-palette";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
@ -11,13 +11,13 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Topbar />
<main className="flex-1 overflow-auto p-4 md:p-6">
<main id="main-content" className="flex-1 overflow-auto p-4 md:p-6">
<Suspense>{children}</Suspense>
</main>
</div>
</div>
<MobileSidebar />
<CommandPalette />
<LazyCommandPalette />
</QueryProvider>
);
}

View file

@ -1,10 +1,15 @@
"use client";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useDeliverables, useUpdateStageStatus } from "@/hooks/use-deliverables";
import { KanbanBoard } from "@/components/views/kanban-board";
import { Skeleton } from "@/components/ui/skeleton";
const KanbanBoard = dynamic(
() => import("@/components/views/kanban-board").then((m) => m.KanbanBoard),
{ ssr: false }
);
export default function BoardViewPage() {
const { projectId } = useParams<{ projectId: string }>();
const { data: deliverables, isLoading } = useDeliverables(projectId);

View file

@ -1,10 +1,15 @@
"use client";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useDeliverables } from "@/hooks/use-deliverables";
import { GanttTimeline } from "@/components/views/gantt-timeline";
import { Skeleton } from "@/components/ui/skeleton";
const GanttTimeline = dynamic(
() => import("@/components/views/gantt-timeline").then((m) => m.GanttTimeline),
{ ssr: false }
);
export default function TimelineViewPage() {
const { projectId } = useParams<{ projectId: string }>();
const { data: deliverables, isLoading } = useDeliverables(projectId);

View file

@ -16,9 +16,9 @@ export default function GlobalError({
}, [error]);
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="flex min-h-screen items-center justify-center p-6" role="alert">
<div className="max-w-md text-center">
<AlertTriangle className="mx-auto mb-4 h-12 w-12 text-[var(--accent)]" />
<AlertTriangle className="mx-auto mb-4 h-12 w-12 text-[var(--accent)]" aria-hidden="true" />
<h1 className="font-heading text-2xl font-bold">
Something went wrong
</h1>

View file

@ -114,4 +114,27 @@
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
}
/* Focus-visible ring for keyboard navigation */
:focus-visible {
@apply outline-2 outline-offset-2 outline-[var(--ring)];
}
/* Remove default focus outlines for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
/* Screen-reader only utility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
}

View file

@ -41,6 +41,12 @@ export default function RootLayout({
suppressHydrationWarning
>
<body>
<a
href="#main-content"
className="fixed left-2 top-2 z-[100] -translate-y-16 rounded-md bg-[var(--primary)] px-4 py-2 text-sm font-medium text-[var(--primary-foreground)] transition-transform focus:translate-y-0"
>
Skip to main content
</a>
<ThemeProvider
attribute="class"
defaultTheme="system"

View file

@ -6,7 +6,7 @@ export default function NotFound() {
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="max-w-md text-center">
<FileQuestion className="mx-auto mb-4 h-12 w-12 text-[var(--muted-foreground)]" />
<FileQuestion className="mx-auto mb-4 h-12 w-12 text-[var(--muted-foreground)]" aria-hidden="true" />
<h1 className="font-heading text-4xl font-bold">404</h1>
<p className="mt-2 text-sm text-[var(--muted-foreground)]">
The page you're looking for doesn't exist.

View file

@ -26,7 +26,14 @@ export function PipelineProgress({ stages }: { stages: Stage[] }) {
).length;
return (
<div className="space-y-1.5">
<div
className="space-y-1.5"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={sorted.length}
aria-label={`Pipeline progress: ${completed} of ${sorted.length} stages complete`}
>
<div className="flex gap-0.5">
{sorted.map((stage) => (
<Tooltip key={stage.id} delayDuration={0}>

View file

@ -32,18 +32,19 @@ export function Breadcrumbs() {
});
return (
<nav className="flex items-center gap-1 text-sm">
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm">
<Link
href="/dashboard"
className="text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
aria-label="Home"
>
<Home className="h-4 w-4" />
</Link>
{crumbs.map((crumb) => (
<Fragment key={crumb.href}>
<ChevronRight className="h-3 w-3 text-[var(--muted-foreground)]" />
<ChevronRight className="h-3 w-3 text-[var(--muted-foreground)]" aria-hidden="true" />
{crumb.isLast ? (
<span className="font-medium text-[var(--foreground)]">
<span className="font-medium text-[var(--foreground)]" aria-current="page">
{crumb.label}
</span>
) : (

View file

@ -41,7 +41,7 @@ function NavLinks({
return (
<>
<nav className="flex-1 space-y-1 p-2">
<nav className="flex-1 space-y-1 p-2" aria-label="Main navigation">
{navItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
@ -79,7 +79,7 @@ function NavLinks({
<Separator />
<nav className="space-y-1 p-2">
<nav className="space-y-1 p-2" aria-label="Settings">
{bottomItems.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
@ -127,6 +127,8 @@ export function Sidebar() {
"hidden h-screen flex-col border-r bg-[var(--muted)] transition-all duration-200 md:flex",
isCollapsed ? "w-16" : "w-64"
)}
role="navigation"
aria-label="Sidebar"
>
{/* Logo / Title */}
<div className="flex h-14 items-center gap-2 border-b px-4">

View file

@ -52,7 +52,7 @@ export function Topbar() {
const items = (notifications as any[]) ?? [];
return (
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)] px-4 md:px-6">
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)] px-4 md:px-6" role="banner">
<div className="flex items-center gap-2">
<MobileMenuButton />
<Breadcrumbs />
@ -63,16 +63,29 @@ export function Topbar() {
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative h-8 w-8">
<Button
variant="ghost"
size="icon"
className="relative h-8 w-8"
aria-label={
unreadCount > 0
? `Notifications, ${unreadCount} unread`
: "Notifications"
}
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -right-1 -top-1 h-4 min-w-4 rounded-full p-0 text-[10px] leading-4"
aria-hidden="true"
>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
<span className="sr-only" aria-live="polite">
{unreadCount > 0 ? `${unreadCount} unread notifications` : ""}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">

View file

@ -0,0 +1,12 @@
"use client";
import dynamic from "next/dynamic";
const CommandPalette = dynamic(
() => import("@/components/command-palette").then((m) => m.CommandPalette),
{ ssr: false }
);
export function LazyCommandPalette() {
return <CommandPalette />;
}

View file

@ -131,7 +131,11 @@ export function GanttTimeline({
const chartWidth = totalDays * DAY_WIDTH;
return (
<div className="overflow-auto rounded-md border">
<div
className="overflow-auto rounded-md border"
role="region"
aria-label="Gantt timeline chart"
>
<div className="flex" style={{ minWidth: LABEL_WIDTH + chartWidth }}>
{/* Labels column */}
<div

View file

@ -105,7 +105,11 @@ export function KanbanBoard({
return (
<DragDropContext onDragEnd={onDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4">
<div
className="flex gap-4 overflow-x-auto pb-4"
role="region"
aria-label="Kanban board"
>
{COLUMNS.map((column) => {
const items = grouped.get(column.id) ?? [];
@ -113,12 +117,15 @@ export function KanbanBoard({
<div
key={column.id}
className="w-72 shrink-0 rounded-lg bg-[var(--muted)] p-3"
role="region"
aria-label={`${column.label} column, ${items.length} item${items.length !== 1 ? "s" : ""}`}
>
{/* Column header */}
<div className="mb-3 flex items-center gap-2">
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: column.color }}
aria-hidden="true"
/>
<span className="text-sm font-semibold">{column.label}</span>
<span className="ml-auto text-xs text-[var(--muted-foreground)]">