Skip to content
Merged
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
18 changes: 18 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,24 @@ then review the diffs in `e2e/design-system-visual.spec.ts-snapshots/` in your P

When you add a new component to `packages/ui`, add it to the gallery in the same PR — see [`packages/ui/CONTRIBUTING.md`](./packages/ui/CONTRIBUTING.md) for the full checklist.

## Separators and dividers

Borders are the cheapest way to make a layout look organised and the fastest way to make it look noisy. Linear's calm hierarchy comes from *space* and *surface* doing the grouping, with lines reserved for the few places where a relationship genuinely needs marking. [`packages/ui/src/components/separator.tsx`](./packages/ui/src/components/separator.tsx) (DS-080) exists so those few places look the same everywhere.

**Reach for these first, in order:**

1. **Spacing** — a bigger gap between two groups than within them. This is the default answer for stacked form fields, stat rows, and list items.
2. **Surface contrast** — `bg-surface-raised` / `bg-card` against `bg-surface-canvas`. A card edge already says "this is one thing", so an internal separator repeating that boundary is redundant.
3. **A separator** — only when two adjacent blocks are different *kinds* of thing and spacing alone leaves that ambiguous: a panel and its action footer, a menu's items and its destructive item, a table and its summary row.

**Tones.** `subtle` inside an already-grouped surface (card, popover, form), `default` between page or list sections, `strong` for the rare structural cut between regions belonging to different tasks. Anything heavier than `strong` is a sign the layout wants a surface change instead of a line.

**Semantics.** Pass `decorative` whenever the line is purely visual — which is most of the time. Headings, lists, and landmarks usually already carry the structure, and every announced separator is one more thing a screen-reader user listens past. Leave it semantic only when the line is the *only* thing expressing the grouping.

**Labelled dividers.** `<Divider label="or" />` names a break instead of just drawing one — grouped form sections, "or" between auth methods, date breaks in a feed. The rules are decorative and the label is ordinary text, so assistive technology reads the words rather than the geometry. Label contrast comes from `text-text-secondary`, which holds up in light, dark, and high-contrast themes.

Existing page borders were left alone in DS-080; migrate them opportunistically when you're already touching the markup.

## Audit history

- **DS-050** (this pass): found and fixed 268 arbitrary-value violations (229 arbitrary font sizes, 38 raw hex colors, 1 arbitrary radius) across ~40 files. Of these:
Expand Down
113 changes: 112 additions & 1 deletion apps/web/src/features/gallery/components/gallery-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,25 @@ import { Button } from "@workspace/ui/components/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@workspace/ui/components/card"
import { Input } from "@workspace/ui/components/input"
import { KeyboardShortcut } from "@workspace/ui/components/keyboard-shortcut"
import { LiveRegion } from "@workspace/ui/components/live-region"
import { PageHeader } from "@workspace/ui/components/page-header"
import { ProgressIndicator } from "@workspace/ui/components/progress-indicator"
import { Separator } from "@workspace/ui/components/separator"
import { ScrollArea } from "@workspace/ui/components/scroll-area"
import { Divider, Separator } from "@workspace/ui/components/separator"
import { Skeleton } from "@workspace/ui/components/skeleton"
import { Slider } from "@workspace/ui/components/slider"
import { Spinner } from "@workspace/ui/components/spinner"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components/tooltip"
import { VisuallyHidden } from "@workspace/ui/components/visually-hidden"

const BUTTON_VARIANTS = ["default", "outline", "secondary", "ghost", "destructive", "link"] as const
const BUTTON_SIZES = ["xs", "sm", "default", "lg"] as const
const BADGE_VARIANTS = ["default", "secondary", "destructive", "outline", "ghost", "link"] as const
const AVATAR_SIZES = ["xs", "sm", "md", "lg", "xl", "2xl"] as const
const PROGRESS_SIZES = ["sm", "md", "lg"] as const
const PROGRESS_TONES = ["neutral", "accent", "success", "danger"] as const
const SEPARATOR_TONES = ["subtle", "default", "strong"] as const

function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
Expand All @@ -45,6 +49,7 @@ function Section({ title, children }: { title: string; children: React.ReactNode
*/
export function GalleryPage() {
const [sliderValue, setSliderValue] = useState<Array<number>>([40])
const [announceCount, setAnnounceCount] = useState(0)

return (
<main className="mx-auto max-w-4xl space-y-10 p-6">
Expand Down Expand Up @@ -139,6 +144,106 @@ export function GalleryPage() {
<p className="text-sm text-foreground">Above the separator</p>
<Separator className="my-3" />
<p className="text-sm text-foreground">Below the separator</p>

<div className="mt-6 space-y-3">
{SEPARATOR_TONES.map((tone) => (
<div key={tone} className="space-y-2">
<p className="text-11 uppercase text-muted-foreground">{tone}</p>
<Separator tone={tone} decorative />
</div>
))}
</div>

<div className="mt-6 flex h-10 items-center gap-3 text-sm text-foreground">
<span>Vertical</span>
<Separator orientation="vertical" decorative />
<span>in a flex row</span>
<Separator orientation="vertical" tone="strong" decorative />
<span>without a fixed height</span>
</div>
</Section>

<Section title="Divider">
<div className="space-y-6">
<Divider label="or" />
<Divider label="Advanced" align="start" tone="subtle" />
<Divider label="Yesterday" align="end" tone="strong" />
</div>
</Section>

<Section title="ScrollArea">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-11 uppercase text-muted-foreground">
Vertical — edge shadows follow scroll position
</p>
<ScrollArea className="max-h-40 rounded-md border border-border" tone="card">
<div className="space-y-1 p-3">
{Array.from({ length: 20 }, (_, i) => (
<p key={i} className="text-13 text-foreground">
Row {i + 1}
</p>
))}
</div>
</ScrollArea>
</div>

<div className="space-y-2">
<p className="text-11 uppercase text-muted-foreground">Horizontal</p>
<ScrollArea
orientation="horizontal"
className="rounded-md border border-border"
tone="card"
>
<div className="flex w-max gap-2 p-3">
{Array.from({ length: 16 }, (_, i) => (
<span
key={i}
className="rounded-md border border-border px-3 py-1 text-13 text-foreground"
>
Market {i + 1}
</span>
))}
</div>
</ScrollArea>
</div>
</div>
</Section>

<Section title="Accessibility primitives">
<div className="space-y-4">
<p className="text-13 text-muted-foreground">
VisuallyHidden and LiveRegion render nothing visible by default — the
button below carries a hidden label, and the counter announces itself
politely. See packages/ui/ACCESSIBILITY_PRIMITIVES.md.
</p>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => setAnnounceCount((n) => n + 1)}>
Announce
<VisuallyHidden> a polite status message</VisuallyHidden>
</Button>
<LiveRegion
visible
message={
announceCount === 0
? "Nothing announced yet"
: `Announced ${announceCount} time(s)`
}
announcementKey={announceCount}
className="text-13 text-muted-foreground"
/>
</div>
<div className="flex items-center gap-3">
<span className="text-13 text-muted-foreground">
Focusable hidden content (tab to reveal):
</span>
<VisuallyHidden focusable>
<button type="button" className="text-13 text-primary underline">
Hidden until focused
</button>
</VisuallyHidden>
</div>
</div>
</Section>

<Section title="Tooltip">
Expand All @@ -159,6 +264,10 @@ export function GalleryPage() {
<AppShell
variant="constrained"
maxWidth="full"
// Preview only: the real page already owns the <main> landmark
// and the skip-link target, and neither may be duplicated.
landmark={false}
skipLink={false}
navbar={
<div className="flex h-10 items-center border-b border-border bg-muted/30 px-4">
<span className="text-13 font-medium text-foreground">Navbar slot</span>
Expand All @@ -182,6 +291,8 @@ export function GalleryPage() {
<div className="overflow-hidden rounded-lg border border-border">
<AppShell
variant="full"
landmark={false}
skipLink={false}
navbar={
<div className="flex h-10 items-center border-b border-border bg-muted/30 px-4">
<span className="text-13 font-medium text-foreground">Navbar slot</span>
Expand Down
21 changes: 20 additions & 1 deletion apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { HeadContent, Scripts, createRootRoute, Link } from "@tanstack/react-router"
import {
HeadContent,
Link,
Outlet,
Scripts,
createRootRoute,
} from "@tanstack/react-router"
import { Toaster } from "sonner"
import appCss from "@workspace/ui/globals.css?url"
import { AppProviders } from "../app/providers"
import { RouteAnnouncer } from "../shared/components/RouteAnnouncer"
import { useTheme } from "../ui/theme-provider"

// Update this to your production domain before going live.
Expand Down Expand Up @@ -134,9 +141,21 @@ export const Route = createRootRoute({
</div>
</main>
),
component: RootComponent,
shellComponent: RootDocument,
})

// Sits above every route so the post-navigation focus/announcement handoff
// (DS-078) is installed exactly once, inside router context.
function RootComponent() {
return (
<>
<RouteAnnouncer />
<Outlet />
</>
)
}

// Minified blocking script — runs synchronously before first paint.
// Reads localStorage and sets dark/light class on <html> so CSS variables
// resolve correctly before React hydrates. Prevents the flash of wrong theme.
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/routes/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ vi.mock("@tanstack/react-router", () => {
useLoaderData: () => ({}),
useParams: () => ({}),
}),
// __root now renders the route announcer above <Outlet /> (DS-078)
Outlet: () => null,
useLocation: (opts?: { select?: (location: { pathname: string }) => unknown }) =>
opts?.select ? opts.select({ pathname: "/" }) : { pathname: "/" },
}
})

Expand Down
145 changes: 145 additions & 0 deletions apps/web/src/shared/components/RouteAnnouncer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest"
import { act, render, screen, waitFor } from "@testing-library/react"
import {
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from "@tanstack/react-router"
import { AppShell } from "@workspace/ui/components/app-shell"

import { RouteAnnouncer } from "./RouteAnnouncer"

function Page({ title }: { title: string }) {
return (
<AppShell navbar={<nav aria-label="Main" />}>
<h1>{title}</h1>
<button type="button">Filter</button>
</AppShell>
)
}

function renderApp() {
const rootRoute = createRootRoute({
component: () => (
<>
<RouteAnnouncer />
<Outlet />
</>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: () => <Page title="Home" />,
})
const poolsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/pools",
validateSearch: (search: Record<string, unknown>) => ({
tab: typeof search.tab === "string" ? search.tab : undefined,
}),
component: () => <Page title="Pools" />,
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, poolsRoute]),
history: createMemoryHistory({ initialEntries: ["/"] }),
})

const view = render(<RouterProvider router={router} />)
return { router, view }
}

/** Live-region text with the re-announcement marker stripped. */
function announcement() {
return document
.querySelector("[data-slot='live-region']")
?.textContent?.replace(/\u200B/g, "")
}

describe("RouteAnnouncer", () => {
it("mounts an empty polite live region", async () => {
renderApp()
await waitFor(() => expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument())

const region = document.querySelector("[data-slot='live-region']")
expect(region).toHaveAttribute("aria-live", "polite")
expect(announcement()).toBe("")
})

it("does not steal focus on the initial page load", async () => {
renderApp()
await waitFor(() => expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument())

expect(document.body).toHaveFocus()
})

it("announces the new page title after navigation", async () => {
const { router } = renderApp()
await waitFor(() => expect(screen.getByText("Home")).toBeInTheDocument())

await act(async () => {
await router.navigate({ to: "/pools" })
})

await waitFor(() => expect(announcement()).toBe("Pools"))
})

it("moves focus to the new page heading after navigation", async () => {
const { router } = renderApp()
await waitFor(() => expect(screen.getByText("Home")).toBeInTheDocument())

await act(async () => {
await router.navigate({ to: "/pools" })
})

await waitFor(() =>
expect(screen.getByRole("heading", { level: 1, name: "Pools" })).toHaveFocus()
)
expect(screen.getByRole("heading", { level: 1 })).toHaveAttribute(
"tabindex",
"-1"
)
})

it("leaves focus alone on query-only changes", async () => {
const { router } = renderApp()
await act(async () => {
await router.navigate({ to: "/pools" })
})
await waitFor(() => expect(announcement()).toBe("Pools"))

const filter = screen.getByRole("button", { name: "Filter" })
act(() => filter.focus())

await act(async () => {
await router.navigate({ to: "/pools", search: { tab: "orders" } })
})

// Same pathname: the tab/filter state changed, not the page.
expect(filter).toHaveFocus()
expect(announcement()).toBe("Pools")
})

it("announces every pathname change", async () => {
const { router } = renderApp()

await act(async () => {
await router.navigate({ to: "/pools" })
})
await waitFor(() => expect(announcement()).toBe("Pools"))

await act(async () => {
await router.navigate({ to: "/" })
})
await waitFor(() => expect(announcement()).toBe("Home"))

await act(async () => {
await router.navigate({ to: "/pools" })
})
await waitFor(() => expect(announcement()).toBe("Pools"))
})
})
Loading
Loading