From 214a5408d7258661c6b83b28f852ddcbb3b4052b Mon Sep 17 00:00:00 2001 From: Anabelle Handdoek Date: Mon, 7 Sep 2026 16:38:16 -0500 Subject: [PATCH] feat(web): baseline accessibility semantics for the shells and login First slice of #331: a VisuallyHidden primitive in @facility/ui, a keyboard-visible skip link with an explicit main-content target in both authenticated shells (first focusable element, main becomes focusable), and login page semantics (main landmark, h1, descriptive title via the root template). Dependency-free: contracts are tested with server-rendered markup assertions in the default node environment. Refs #331 --- apps/web/app/(app)/(org)/layout.tsx | 6 +- .../app/(app)/projects/[projectId]/layout.tsx | 6 +- apps/web/app/login/page.tsx | 11 ++- apps/web/components/shell/skip-link.tsx | 15 +++ apps/web/test/a11y-baseline.test.tsx | 94 +++++++++++++++++++ packages/ui/src/index.ts | 1 + packages/ui/src/primitives.tsx | 5 + 7 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 apps/web/components/shell/skip-link.tsx create mode 100644 apps/web/test/a11y-baseline.test.tsx 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
;