diff --git a/apps/web/app/(app)/(org)/layout.tsx b/apps/web/app/(app)/(org)/layout.tsx index 8a29d680..833ff366 100644 --- a/apps/web/app/(app)/(org)/layout.tsx +++ b/apps/web/app/(app)/(org)/layout.tsx @@ -1,4 +1,5 @@ import { MobileNav, Sidebar } from "@/components/shell/nav"; +import { SkipLink } from "@/components/shell/skip-link"; import { Topbar } from "@/components/shell/topbar"; import { api } from "@/lib/api"; @@ -8,11 +9,14 @@ export default async function OrgLayout({ children }: { children: React.ReactNod return (
+
{me.ok ? : null} -
{children}
+
+ {children} +
); diff --git a/apps/web/app/(app)/projects/[projectId]/layout.tsx b/apps/web/app/(app)/projects/[projectId]/layout.tsx index f57bed47..bdbdccf0 100644 --- a/apps/web/app/(app)/projects/[projectId]/layout.tsx +++ b/apps/web/app/(app)/projects/[projectId]/layout.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import { ErrorNotice, Offline } from "@/components/offline"; import { MobileNav, Sidebar } from "@/components/shell/nav"; import { RememberProject } from "@/components/shell/remember-project"; +import { SkipLink } from "@/components/shell/skip-link"; import { Topbar } from "@/components/shell/topbar"; import { api } from "@/lib/api"; @@ -44,11 +45,14 @@ export default async function ProjectLayout({ // App shell: the viewport is the frame; only the work area (main) scrolls, // so full-height tabs like Product can fill it edge to edge.
+
{me.ok ? : null} -
{children}
+
+ {children} +
diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index c0971d59..8709deba 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -1,13 +1,16 @@ import { ButtonLink } from "@facility/ui"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Sign in" }; export default function LoginPage() { const localDevelopment = process.env.NODE_ENV !== "production"; return ( -
+
- +

facility. - +

One persistent workspace and shared agent conversation for every story.

@@ -29,6 +32,6 @@ export default function LoginPage() { The Agile Monkeys

-
+
); } diff --git a/apps/web/components/shell/skip-link.tsx b/apps/web/components/shell/skip-link.tsx new file mode 100644 index 00000000..155b4c67 --- /dev/null +++ b/apps/web/components/shell/skip-link.tsx @@ -0,0 +1,15 @@ +/** + * First focusable element of both authenticated shells: bypasses the sidebar + * and topbar for keyboard users (WCAG 2.4.1). Visually hidden until focused, + * then fixed top-left so it stays visible over every shell variant. + */ +export function SkipLink() { + return ( + + Skip to main content + + ); +} diff --git a/apps/web/test/a11y-baseline.test.tsx b/apps/web/test/a11y-baseline.test.tsx new file mode 100644 index 00000000..d4951874 --- /dev/null +++ b/apps/web/test/a11y-baseline.test.tsx @@ -0,0 +1,94 @@ +import { VisuallyHidden } from "@facility/ui"; +import type { ReactElement } from "react"; +import { renderToString } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import OrgLayout from "@/app/(app)/(org)/layout"; +import ProjectLayout from "@/app/(app)/projects/[projectId]/layout"; +import LoginPage, { metadata as loginMetadata } from "@/app/login/page"; +import { SkipLink } from "@/components/shell/skip-link"; + +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("notFound"); + }, + redirect: () => { + throw new Error("redirect"); + }, + usePathname: () => "/", +})); +vi.mock("next/link", () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); +vi.mock("@/lib/api", () => ({ + api: { + me: async () => ({ ok: true, data: {} }), + projects: async () => ({ ok: true, data: [] }), + project: async () => ({ ok: true, data: { id: "p_1", slug: "acme", name: "Acme" } }), + }, +})); +vi.mock("@/components/shell/topbar", () => ({ Topbar: () => null })); +vi.mock("@/components/shell/remember-project", () => ({ RememberProject: () => null })); + +function render(element: ReactElement) { + return renderToString(element); +} + +describe("skip link", () => { + it("targets the main content and is keyboard-only visible", () => { + const html = render(); + expect(html).toContain('href="#main-content"'); + expect(html).toContain("Skip to main content"); + expect(html).toContain("sr-only"); + expect(html).toContain("focus:not-sr-only"); + }); + + it("precedes the sidebar and lands on a focusable main in the org shell", async () => { + const html = render(await OrgLayout({ children:

dashboard

})); + const skip = html.indexOf("Skip to main content"); + const sidebar = html.indexOf('aria-label="Primary"'); + expect(skip).toBeGreaterThanOrEqual(0); + expect(skip).toBeLessThan(sidebar); + expect(html).toContain('id="main-content"'); + expect(html).toContain('tabindex="-1"'); + }); + + it("holds for the project shell too", async () => { + const html = render( + await ProjectLayout({ + children:

overview

, + params: Promise.resolve({ projectId: "p_1" }), + }), + ); + const skip = html.indexOf("Skip to main content"); + const sidebar = html.indexOf('aria-label="Primary"'); + expect(skip).toBeGreaterThanOrEqual(0); + expect(skip).toBeLessThan(sidebar); + expect(html).toContain('id="main-content"'); + expect(html).toContain('tabindex="-1"'); + }); +}); + +describe("visually hidden text", () => { + it("names icon-only controls without removing content from the tree", () => { + const html = render( + , + ); + expect(html).toContain("Delete story"); + expect(html).toContain("sr-only"); + expect(html).not.toContain("display:none"); + }); +}); + +describe("login page landmarks", () => { + it("exposes a main landmark, a heading, and a descriptive title", () => { + const html = render(); + expect(html).toContain(") { + return ; +} + /** Section separator — hairline, generous air. */ export function Divider({ className }: { className?: string }) { return
;