Skip to content
Open
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
6 changes: 5 additions & 1 deletion apps/web/app/(app)/(org)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -8,11 +9,14 @@ export default async function OrgLayout({ children }: { children: React.ReactNod

return (
<div className="flex min-h-dvh">
<SkipLink />
<Sidebar />
<div className="min-w-0 flex-1">
<MobileNav />
{me.ok ? <Topbar me={me.data} projects={projectList} /> : null}
<main className="px-5 py-8 sm:px-8 lg:px-10">{children}</main>
<main id="main-content" tabIndex={-1} className="px-5 py-8 sm:px-8 lg:px-10">
{children}
</main>
</div>
</div>
);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/app/(app)/projects/[projectId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.
<div className="flex h-dvh">
<SkipLink />
<Sidebar project={navProject} />
<div className="flex min-w-0 flex-1 flex-col">
<MobileNav project={navProject} />
{me.ok ? <Topbar me={me.data} projects={projectList} current={p} /> : null}
<main className="app-main min-h-0 flex-1 overflow-y-auto">{children}</main>
<main id="main-content" tabIndex={-1} className="app-main min-h-0 flex-1 overflow-y-auto">
{children}
</main>
</div>
<RememberProject projectId={p.id} />
</div>
Expand Down
11 changes: 7 additions & 4 deletions apps/web/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto flex min-h-dvh w-full max-w-sm flex-col justify-center gap-10 px-6">
<main className="mx-auto flex min-h-dvh w-full max-w-sm flex-col justify-center gap-10 px-6">
<div className="flex flex-col gap-3">
<span className="font-mono text-[22px] font-semibold tracking-tight">
<h1 className="font-mono text-[22px] font-semibold tracking-tight">
facility<span className="text-(--accent)">.</span>
</span>
</h1>
<p className="text-sm leading-relaxed text-(--mut)">
One persistent workspace and shared agent conversation for every story.
</p>
Expand All @@ -29,6 +32,6 @@ export default function LoginPage() {
The Agile Monkeys
</a>
</p>
</div>
</main>
);
}
15 changes: 15 additions & 0 deletions apps/web/components/shell/skip-link.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-50 rounded-[4px] border border-(--line-strong) bg-(--card) px-4 py-2 text-sm font-medium text-(--ink)"
>
Skip to main content
</a>
);
}
94 changes: 94 additions & 0 deletions apps/web/test/a11y-baseline.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<a href={href}>{children}</a>
),
}));
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(<SkipLink />);
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: <p>dashboard</p> }));
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: <p>overview</p>,
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(
<button type="button">
<svg aria-hidden viewBox="0 0 16 16" />
<VisuallyHidden>Delete story</VisuallyHidden>
</button>,
);
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(<LoginPage />);
expect(html).toContain("<main");
expect(html).toContain("<h1");
expect(loginMetadata.title).toBe("Sign in");
});
});
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
PillTag,
StatusDot,
toneFor,
VisuallyHidden,
} from "./primitives";
export type { TerminalLine } from "./terminal";
export { Terminal } from "./terminal";
5 changes: 5 additions & 0 deletions packages/ui/src/primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ export function toneFor(status: string): Semantic {
}
}

/** Text for assistive tech only: visually removed, never `display: none`. */
export function VisuallyHidden({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return <span className={cx("sr-only", className)} {...props} />;
}

/** Section separator — hairline, generous air. */
export function Divider({ className }: { className?: string }) {
return <div className={cx("border-t border-(--line)", className)} />;
Expand Down