diff --git a/DESIGN.md b/DESIGN.md
index 9824488..fb237bd 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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.** `` 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:
diff --git a/apps/web/src/features/gallery/components/gallery-page.tsx b/apps/web/src/features/gallery/components/gallery-page.tsx
index 64e4c05..200367d 100644
--- a/apps/web/src/features/gallery/components/gallery-page.tsx
+++ b/apps/web/src/features/gallery/components/gallery-page.tsx
@@ -6,14 +6,17 @@ 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
@@ -21,6 +24,7 @@ const BADGE_VARIANTS = ["default", "secondary", "destructive", "outline", "ghost
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 (
@@ -45,6 +49,7 @@ function Section({ title, children }: { title: string; children: React.ReactNode
*/
export function GalleryPage() {
const [sliderValue, setSliderValue] = useState>([40])
+ const [announceCount, setAnnounceCount] = useState(0)
return (
@@ -139,6 +144,106 @@ export function GalleryPage() {
Above the separator
Below the separator
+
+
+ {SEPARATOR_TONES.map((tone) => (
+
+
{tone}
+
+
+ ))}
+
+
+
+ Vertical
+
+ in a flex row
+
+ without a fixed height
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Vertical — edge shadows follow scroll position
+
+ 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.
+
@@ -159,6 +264,10 @@ export function GalleryPage() {
landmark
+ // and the skip-link target, and neither may be duplicated.
+ landmark={false}
+ skipLink={false}
navbar={
Navbar slot
@@ -182,6 +291,8 @@ export function GalleryPage() {
Navbar slot
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index e1b7bde..5903761 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -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.
@@ -134,9 +141,21 @@ export const Route = createRootRoute({
),
+ 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 (
+ <>
+
+
+ >
+ )
+}
+
// Minified blocking script — runs synchronously before first paint.
// Reads localStorage and sets dark/light class on so CSS variables
// resolve correctly before React hydrates. Prevents the flash of wrong theme.
diff --git a/apps/web/src/routes/routes.test.tsx b/apps/web/src/routes/routes.test.tsx
index 1536a3a..5d7c271 100644
--- a/apps/web/src/routes/routes.test.tsx
+++ b/apps/web/src/routes/routes.test.tsx
@@ -50,6 +50,10 @@ vi.mock("@tanstack/react-router", () => {
useLoaderData: () => ({}),
useParams: () => ({}),
}),
+ // __root now renders the route announcer above (DS-078)
+ Outlet: () => null,
+ useLocation: (opts?: { select?: (location: { pathname: string }) => unknown }) =>
+ opts?.select ? opts.select({ pathname: "/" }) : { pathname: "/" },
}
})
diff --git a/apps/web/src/shared/components/RouteAnnouncer.test.tsx b/apps/web/src/shared/components/RouteAnnouncer.test.tsx
new file mode 100644
index 0000000..940c433
--- /dev/null
+++ b/apps/web/src/shared/components/RouteAnnouncer.test.tsx
@@ -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 (
+ }>
+
{title}
+
+
+ )
+}
+
+function renderApp() {
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/",
+ component: () => ,
+ })
+ const poolsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/pools",
+ validateSearch: (search: Record) => ({
+ tab: typeof search.tab === "string" ? search.tab : undefined,
+ }),
+ component: () => ,
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, poolsRoute]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ })
+
+ const view = render()
+ 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"))
+ })
+})
diff --git a/apps/web/src/shared/components/RouteAnnouncer.tsx b/apps/web/src/shared/components/RouteAnnouncer.tsx
new file mode 100644
index 0000000..e955ac4
--- /dev/null
+++ b/apps/web/src/shared/components/RouteAnnouncer.tsx
@@ -0,0 +1,97 @@
+"use client"
+
+import * as React from "react"
+import { useLocation } from "@tanstack/react-router"
+import { LiveRegion, useAnnouncer } from "@workspace/ui/components/live-region"
+import { MAIN_CONTENT_ID } from "@workspace/ui/components/skip-link"
+
+// ---------------------------------------------------------------------------
+// RouteAnnouncer (DS-078) — focus + announcement handoff after navigation
+// ---------------------------------------------------------------------------
+
+export interface RouteAnnouncerProps {
+ /**
+ * Id of the shell's main region.
+ * @default "main-content"
+ */
+ targetId?: string
+}
+
+/**
+ * Client-side navigation replaces the page without the browser's own "new
+ * document" handling: focus stays on whatever link was clicked, and nothing
+ * tells a screen reader that the page changed. This restores both.
+ *
+ * Mounted once, at the root route. On every *pathname* change it moves focus to
+ * the new page's heading (falling back to the main region) and announces the
+ * page name in a polite live region.
+ *
+ * Search-param and hash changes are deliberately ignored — filters, tab state,
+ * and sort order all live in the query string, and yanking focus out of a
+ * control the user is still operating would be far worse than saying nothing.
+ * `useLocation`'s selector means those updates do not even re-render this
+ * component.
+ */
+export function RouteAnnouncer({
+ targetId = MAIN_CONTENT_ID,
+}: RouteAnnouncerProps) {
+ const pathname = useLocation({ select: (location) => location.pathname })
+ const { message, announcementKey, announce } = useAnnouncer()
+ const isInitialRender = React.useRef(true)
+
+ React.useEffect(() => {
+ // The first paint is a real document load — the browser already announced
+ // the title, and stealing focus on arrival is hostile.
+ if (isInitialRender.current) {
+ isInitialRender.current = false
+ return
+ }
+
+ // The heading and for the new route commit after this effect, so
+ // read them a frame later or the announcement describes the old page.
+ return afterNextFrame(() => {
+ const label = focusMainRegion(targetId)
+ announce(label)
+ })
+ }, [pathname, targetId, announce])
+
+ return
+}
+
+/**
+ * Moves focus into the new page's content and returns the label that describes
+ * it. Prefers the page heading so a screen reader starts reading where a
+ * sighted user starts looking.
+ */
+function focusMainRegion(targetId: string): string {
+ const main = document.getElementById(targetId)
+ const heading = main?.querySelector("h1") ?? null
+ const target = heading ?? main
+
+ if (target) {
+ // Focusable on demand only — the region must not become a tab stop.
+ if (!target.hasAttribute("tabindex")) {
+ target.setAttribute("tabindex", "-1")
+ }
+ // preventScroll: the router owns scroll restoration, and a pointer user
+ // clicking a nav link should not be jerked around. Programmatic focus also
+ // does not match :focus-visible, so no ring is drawn.
+ target.focus({ preventScroll: true })
+ }
+
+ return (
+ heading?.textContent?.trim() ||
+ document.title.trim() ||
+ "New page"
+ )
+}
+
+/** rAF where it exists (browsers, jsdom in visual mode), a task otherwise. */
+function afterNextFrame(run: () => void): () => void {
+ if (typeof requestAnimationFrame === "function") {
+ const frame = requestAnimationFrame(run)
+ return () => cancelAnimationFrame(frame)
+ }
+ const timeout = setTimeout(run, 0)
+ return () => clearTimeout(timeout)
+}
diff --git a/e2e/skip-link.spec.ts b/e2e/skip-link.spec.ts
new file mode 100644
index 0000000..71c9b00
--- /dev/null
+++ b/e2e/skip-link.spec.ts
@@ -0,0 +1,83 @@
+import { expect, test } from "@playwright/test"
+
+// Skip link + post-navigation focus (DS-078).
+//
+// Prerequisites for local runs outside CI:
+// npx playwright install --with-deps chromium
+
+const MAIN = "#main-content"
+
+test("skip link is the first tab stop and becomes visible on focus", async ({ page }) => {
+ await page.goto("/pools")
+
+ const skipLink = page.getByRole("link", { name: "Skip to main content" })
+ await expect(skipLink).toBeAttached()
+
+ // `sr-only` clips the link to 1x1px until it takes focus, so the box size is
+ // what actually distinguishes "hidden" from "revealed".
+ const clipped = await skipLink.boundingBox()
+ expect(clipped?.width ?? 0).toBeLessThanOrEqual(2)
+
+ await page.keyboard.press("Tab")
+ await expect(skipLink).toBeFocused()
+
+ const revealed = await skipLink.boundingBox()
+ expect(revealed?.width ?? 0).toBeGreaterThan(50)
+ await expect(skipLink).toBeInViewport()
+})
+
+test("activating the skip link focuses the main region without touching the URL", async ({
+ page,
+}) => {
+ await page.goto("/pools")
+ const url = page.url()
+
+ await page.keyboard.press("Tab")
+ await page.keyboard.press("Enter")
+
+ await expect(page.locator(MAIN)).toBeFocused()
+ expect(page.url()).toBe(url)
+
+ // The next tab stop is inside the page content, not back in the navbar.
+ await page.keyboard.press("Tab")
+ const insideMain = await page.evaluate(() => {
+ const active = document.activeElement
+ const main = document.querySelector("#main-content")
+ return Boolean(active && main && main.contains(active))
+ })
+ expect(insideMain).toBe(true)
+})
+
+test("route navigation moves focus to the new page heading and announces it", async ({
+ page,
+}) => {
+ await page.goto("/pools")
+ await expect(page.getByRole("heading", { level: 1, name: /pools/i })).toBeVisible()
+
+ await page.getByRole("link", { name: "Earn", exact: true }).first().click()
+
+ const heading = page.getByRole("heading", { level: 1, name: /earn/i })
+ await expect(heading).toBeFocused()
+
+ // Announced once, in the single polite region the announcer owns.
+ const region = page.locator("[data-slot='live-region']")
+ await expect(region).toHaveCount(1)
+ await expect(region).toHaveAttribute("aria-live", "polite")
+ await expect(region).toHaveText(/earn/i)
+})
+
+test("pointer navigation does not draw a focus ring on the new page", async ({
+ page,
+}) => {
+ await page.goto("/pools")
+
+ await page.getByRole("link", { name: "Referrals", exact: true }).first().click()
+ await expect(page.getByRole("heading", { level: 1, name: /referrals/i })).toBeFocused()
+
+ // Focus is handed over programmatically, which never matches :focus-visible —
+ // so a mouse user sees no outline even though the heading holds focus.
+ const focusVisible = await page.evaluate(
+ () => document.activeElement?.matches(":focus-visible") ?? false
+ )
+ expect(focusVisible).toBe(false)
+})
diff --git a/packages/ui/ACCESSIBILITY_PRIMITIVES.md b/packages/ui/ACCESSIBILITY_PRIMITIVES.md
new file mode 100644
index 0000000..98b26b7
--- /dev/null
+++ b/packages/ui/ACCESSIBILITY_PRIMITIVES.md
@@ -0,0 +1,128 @@
+# Accessibility primitives
+
+The shared building blocks for screen-reader-only content, status announcements,
+and keyboard bypass. Added in DS-077 (`VisuallyHidden`, `LiveRegion`) and
+DS-078 (`SkipLink`, post-navigation focus).
+
+Before this pass, `sr-only` markup and `aria-live` regions were hand-rolled at
+each call site — a dozen slightly different spellings, a few of them silent in
+practice. These primitives are the one spelling.
+
+---
+
+## `VisuallyHidden`
+
+`packages/ui/src/components/visually-hidden.tsx`
+
+Content for assistive technology only. Clipped by `sr-only`, which is
+absolutely positioned, so it can never shift the layout around it.
+
+```tsx
+
+
+{/* Semantic elements survive via `render`, so the outline stays intact */}
+}>Open positions
+
+{/* Hidden interactive content must be visible once focused */}
+
+ Jump to orders
+
+```
+
+### Use it for
+
+- The verb behind an icon-only control, when `aria-label` would lose markup.
+- Table column meaning a sighted user reads from position.
+- Units and qualifiers a sighted user reads from a nearby heading.
+- A heading that structures a region for screen readers where visible design
+ already makes the grouping obvious.
+
+### Do **not** use it for
+
+- **Anything the user must act on**: form labels, validation errors, prices,
+ balances, confirmation copy. Hiding those helps screen readers and hurts
+ low-vision, cognitive-load, and translation users.
+- **Replacing a visible label** on a control that would otherwise be
+ ambiguous on screen. A hidden label supplements visible context; it does not
+ substitute for it.
+- **Pointer-reachable content.** Hidden content is 1×1px — only keyboard focus
+ can reach it, which is what `focusable` is for.
+
+---
+
+## `LiveRegion`
+
+`packages/ui/src/components/live-region.tsx`
+
+Status text that reaches screen readers without stealing focus.
+
+```tsx
+ // polite → role=status
+ // assertive → role=alert
+ // mounted, silent
+ // also rendered on screen
+```
+
+| Prop | Effect |
+| --- | --- |
+| `mode` | `polite` (default) waits its turn, `assertive` interrupts, `off` stays silent |
+| `atomic` | `true` (default) announces the whole region; `false` for append-only logs |
+| `relevant` | maps to `aria-relevant` |
+| `visible` | also render the message on screen |
+| `announcementKey` | change it to re-announce identical text |
+
+### Two rules that decide whether it works at all
+
+1. **Mount the region before the message exists.** An empty region that later
+ fills in is announced; a region inserted together with its text is
+ frequently missed, because the assistive technology never observed a change.
+2. **Identical text is silent.** "3 results" after "3 results" is not a change.
+ To repeat it deliberately, bump `announcementKey` — or use `useAnnouncer`,
+ which keeps the counter for you:
+
+```tsx
+const { message, announcementKey, announce } = useAnnouncer()
+
+
+
+```
+
+Under the hood a zero-width space is toggled onto the end of the string, which
+changes the text node without changing what the user hears.
+
+Reserve `assertive` for messages whose loss costs money or data — order
+rejected, transaction failed, session expired. Everything else is `polite`.
+
+This is not a notification queue: each region owns one message. Toasts remain
+the job of the toast layer.
+
+---
+
+## `SkipLink` and post-navigation focus
+
+`packages/ui/src/components/skip-link.tsx`,
+`apps/web/src/shared/components/RouteAnnouncer.tsx`
+
+`AppShell` renders the skip link as its first focusable element and marks its
+content area as ``. Both are on by
+default; pass `skipLink={false}` / `landmark={false}` only for a shell nested
+inside another one (gallery previews), where a second landmark or a duplicate
+id would be invalid.
+
+- The link is clipped until focused, then pins itself above the sticky navbar.
+- Activating it moves focus into the main region **without** navigating to
+ `#main-content`, so the hash never lands in the history entry.
+- `RouteAnnouncer`, mounted once at the root route, moves focus to the new
+ page's `
` after each **pathname** change and announces its title in a
+ polite region.
+- Search-param and hash changes are ignored on purpose: filters, tabs, and sort
+ order live in the query string, and pulling focus out of a control the user is
+ still operating is worse than saying nothing.
+- Focus is handed over with `preventScroll` and lands on a `tabindex="-1"`
+ target, so a pointer user gets neither a jump nor a focus ring
+ (programmatic focus does not match `:focus-visible`).
+
+Focus *inside* dialogs, menus, and other overlays stays with those components.
diff --git a/packages/ui/FORCED_COLORS_CHECKLIST.md b/packages/ui/FORCED_COLORS_CHECKLIST.md
index 491b6d2..00b7d70 100644
--- a/packages/ui/FORCED_COLORS_CHECKLIST.md
+++ b/packages/ui/FORCED_COLORS_CHECKLIST.md
@@ -200,10 +200,30 @@ This checklist helps verify that SO4's interface remains operable when Windows H
- [ ] Horizontal rules (``) are visible
- [ ] Section dividers are visible
- [ ] Card borders are visible
+- [ ] `Separator` subtle/default/strong tones are all still visible (all three
+ collapse to `CanvasText` — that is expected; the *presence* of the line is
+ what matters, not the tone)
+- [ ] `Divider` labels remain readable against `Canvas`
**Test locations:**
- Between major sections
- Within complex forms
+
+### ✅ Scroll Areas (DS-079)
+
+The gradient edge affordances are dropped in forced-colors mode (background
+images are removed), so the scrollbar itself has to carry the "there is more
+content" signal.
+
+- [ ] Scrollbars are visible on every `ScrollArea` that overflows
+- [ ] Scrollbar thumb (`CanvasText`) is distinguishable from its track (`Canvas`)
+- [ ] Scroll position is still discoverable without the edge shadows
+- [ ] Keyboard scrolling (arrows, Page Up/Down) still works on focusable areas
+
+**Test locations:**
+- Command menu (`Cmd/Ctrl+K`) — long, filtered result list
+- `/trade` — bottom tabs and trade panel
+- Any horizontally overflowing tab row
- Card components
## Additional Checks
diff --git a/packages/ui/README.md b/packages/ui/README.md
index b70ab50..91d6186 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -16,7 +16,10 @@ All components are located in `src/components/` and exported through the package
- **Tooltip** - Contextual information on hover
- **Sheet** - Side panel component
- **Skeleton** - Content placeholder loaders
-- **Separator** - Divider component
+- **Separator / Divider** - Rules in `subtle`/`default`/`strong` tones, semantic or decorative, plus a labelled divider (see [DESIGN.md](../../DESIGN.md#separators-and-dividers) for when *not* to use one)
+- **ScrollArea** - Native overflow container with scroll-position edge shadows and themed scrollbars
+- **VisuallyHidden / LiveRegion** - Screen-reader-only content and polite/assertive announcements ([ACCESSIBILITY_PRIMITIVES.md](./ACCESSIBILITY_PRIMITIVES.md))
+- **SkipLink** - Keyboard bypass for global chrome; wired into `AppShell`'s `` target
- **TokenAvatar** - Token icon with fallback initials
- **TokenPair** - Overlapping token pair visual
- **TransactionStatus** - Transaction state indicator with actions
diff --git a/packages/ui/src/components/app-shell.test.tsx b/packages/ui/src/components/app-shell.test.tsx
index a02f2f6..50131f2 100644
--- a/packages/ui/src/components/app-shell.test.tsx
+++ b/packages/ui/src/components/app-shell.test.tsx
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"
import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
import { axe } from "vitest-axe"
import { AppShell } from "./app-shell"
@@ -53,10 +54,56 @@ describe("AppShell", () => {
expect(content).toBeTruthy()
})
+ it("renders the content area as the main landmark", () => {
+ render(
+ }>
+
Pools
+
+ )
+ const main = screen.getByRole("main")
+ expect(main).toHaveAttribute("id", "main-content")
+ // Programmatically focusable for the skip link and post-navigation focus,
+ // without becoming a tab stop.
+ expect(main).toHaveAttribute("tabindex", "-1")
+ })
+
+ it("accepts a custom main id", () => {
+ render(
+ } mainId="page-content">
+ Content
+
+ )
+ expect(screen.getByRole("main")).toHaveAttribute("id", "page-content")
+ })
+
+ it("renders the skip link ahead of the navbar", async () => {
+ const user = userEvent.setup()
+ render(
+ Pools}>
+
Pools
+
+ )
+
+ await user.tab()
+ expect(
+ screen.getByRole("link", { name: "Skip to main content" })
+ ).toHaveFocus()
+ })
+
+ it("skips the skip link and landmark when nested", () => {
+ render(
+ } skipLink={false} landmark={false}>
+ Content
+
+ )
+ expect(screen.queryByRole("link", { name: /skip to main content/i })).toBeNull()
+ expect(screen.queryByRole("main")).toBeNull()
+ })
+
it("has no accessibility violations", async () => {
const { container } = render(
}>
- Content
+
Content
)
const results = await axe(container)
diff --git a/packages/ui/src/components/app-shell.tsx b/packages/ui/src/components/app-shell.tsx
index 008f120..5024ffb 100644
--- a/packages/ui/src/components/app-shell.tsx
+++ b/packages/ui/src/components/app-shell.tsx
@@ -1,6 +1,7 @@
import * as React from "react"
import { cva } from "class-variance-authority"
+import { MAIN_CONTENT_ID, SkipLink } from "@workspace/ui/components/skip-link"
import { cn } from "@workspace/ui/lib/utils"
import type { VariantProps } from "class-variance-authority"
@@ -60,6 +61,25 @@ export interface AppShellProps
* @default "320"
*/
maxWidth?: MaxWidthKey
+ /**
+ * Render the skip link as the shell's first focusable element (DS-078).
+ * Turn it off only when the shell is nested inside another one — a demo or a
+ * gallery preview — where a second skip link would duplicate the target id.
+ * @default true
+ */
+ skipLink?: boolean
+ /**
+ * Render the content area as the page's `` landmark. Turn it off when
+ * the page already owns its own ``, since two are invalid.
+ * @default true
+ */
+ landmark?: boolean
+ /**
+ * Id of the content area — the stable target for the skip link and for
+ * post-navigation focus.
+ * @default "main-content"
+ */
+ mainId?: string
}
function AppShell({
@@ -67,10 +87,15 @@ function AppShell({
maxWidth = "320",
navbar,
banner,
+ skipLink = true,
+ landmark = true,
+ mainId = MAIN_CONTENT_ID,
className,
children,
...props
}: AppShellProps) {
+ const Content = landmark ? "main" : "div"
+
return (
-
+
{filteredGroups.map((group) => {
const groupItems = group.items.map((item) => {
diff --git a/packages/ui/src/components/live-region.test.tsx b/packages/ui/src/components/live-region.test.tsx
new file mode 100644
index 0000000..527a635
--- /dev/null
+++ b/packages/ui/src/components/live-region.test.tsx
@@ -0,0 +1,166 @@
+import { describe, expect, it } from "vitest"
+import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { axe } from "vitest-axe"
+
+import { LiveRegion, ZERO_WIDTH_SPACE, useAnnouncer } from "./live-region"
+
+function getRegion(container: HTMLElement) {
+ const region = container.querySelector("[data-slot='live-region']")
+ if (!region) throw new Error("live region not rendered")
+ return region
+}
+
+/** Drops the re-announcement marker so assertions can compare the words. */
+function strip(text: string) {
+ return text.split(ZERO_WIDTH_SPACE).join("")
+}
+
+describe("LiveRegion", () => {
+ it("announces politely by default", () => {
+ const { container } = render()
+ const region = getRegion(container)
+
+ expect(region).toHaveAttribute("aria-live", "polite")
+ expect(region).toHaveAttribute("role", "status")
+ expect(region).toHaveAttribute("aria-atomic", "true")
+ expect(region).toHaveTextContent("3 positions")
+ })
+
+ it("uses the alert role in assertive mode", () => {
+ const { container } = render(
+
+ )
+ const region = getRegion(container)
+
+ expect(region).toHaveAttribute("aria-live", "assertive")
+ expect(region).toHaveAttribute("role", "alert")
+ })
+
+ it("stays mounted but silent in off mode", () => {
+ const { container } = render()
+ const region = getRegion(container)
+
+ expect(region).toHaveAttribute("aria-live", "off")
+ expect(region).not.toHaveAttribute("role")
+ expect(region).toHaveTextContent("Idle")
+ })
+
+ it("supports non-atomic append-only regions", () => {
+ const { container } = render(
+
+ )
+ const region = getRegion(container)
+
+ expect(region).toHaveAttribute("aria-atomic", "false")
+ expect(region).toHaveAttribute("aria-relevant", "additions")
+ })
+
+ it("allows an explicit role override", () => {
+ const { container } = render()
+ expect(getRegion(container)).toHaveAttribute("role", "log")
+ })
+
+ it("is visually hidden unless a visible fallback is requested", () => {
+ const { container, rerender } = render()
+ expect(getRegion(container)).toHaveClass("sr-only")
+
+ rerender()
+ expect(getRegion(container)).not.toHaveClass("sr-only")
+ })
+
+ it("renders children when no message is given", () => {
+ const { container } = render(
+
+ Custom content
+
+ )
+ expect(getRegion(container)).toHaveTextContent("Custom content")
+ })
+
+ it("updates its text when the message changes", () => {
+ const { container, rerender } = render()
+ expect(getRegion(container)).toHaveTextContent("1 result")
+
+ rerender()
+ expect(getRegion(container)).toHaveTextContent("2 results")
+ })
+
+ it("leaves identical messages untouched without a new announcement key", () => {
+ const { container, rerender } = render(
+
+ )
+ const before = getRegion(container).textContent
+
+ rerender()
+ expect(getRegion(container).textContent).toBe(before)
+ })
+
+ it("re-announces an identical message when the announcement key changes", () => {
+ const { container, rerender } = render(
+
+ )
+ expect(getRegion(container).textContent).toBe("2 results")
+
+ rerender()
+ expect(getRegion(container).textContent).toBe(
+ `2 results${ZERO_WIDTH_SPACE}`
+ )
+
+ rerender()
+ expect(getRegion(container).textContent).toBe("2 results")
+ })
+})
+
+describe("useAnnouncer", () => {
+ function Harness() {
+ const { message, announcementKey, announce, clear } = useAnnouncer()
+ return (
+ <>
+
+
+
+ >
+ )
+ }
+
+ it("starts empty", () => {
+ const { container } = render()
+ expect(getRegion(container).textContent).toBe("")
+ })
+
+ it("announces and re-announces the same message", async () => {
+ const user = userEvent.setup()
+ const { container } = render()
+
+ await user.click(screen.getByRole("button", { name: "Submit" }))
+ const first = getRegion(container).textContent ?? ""
+ expect(strip(first)).toBe("Order submitted")
+
+ await user.click(screen.getByRole("button", { name: "Submit" }))
+ const second = getRegion(container).textContent ?? ""
+ expect(strip(second)).toBe("Order submitted")
+ // Same words, different text node — which is what makes a screen reader
+ // speak the message a second time.
+ expect(second).not.toBe(first)
+ })
+
+ it("clears the region", async () => {
+ const user = userEvent.setup()
+ const { container } = render()
+
+ await user.click(screen.getByRole("button", { name: "Submit" }))
+ await user.click(screen.getByRole("button", { name: "Clear" }))
+ expect(strip(getRegion(container).textContent ?? "")).toBe("")
+ })
+
+ it("has no accessibility violations", async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/live-region.tsx b/packages/ui/src/components/live-region.tsx
new file mode 100644
index 0000000..0c00837
--- /dev/null
+++ b/packages/ui/src/components/live-region.tsx
@@ -0,0 +1,191 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@workspace/ui/lib/utils"
+
+// ---------------------------------------------------------------------------
+// LiveRegion (DS-077) — non-disruptive status announcements
+// ---------------------------------------------------------------------------
+
+/**
+ * `polite` waits for the screen reader to finish what it is saying, `assertive`
+ * interrupts immediately, `off` keeps the node mounted but silent.
+ *
+ * Reach for `assertive` only when ignoring the message costs the user money or
+ * data (order rejected, transaction failed, session expired). Everything else
+ * — "copied", "filters applied", "3 results" — is `polite`.
+ */
+type LiveRegionMode = "polite" | "assertive" | "off"
+
+/**
+ * A zero-width space is appended to force a text-node change when the same
+ * message is announced twice. It is invisible, adds no layout, and is skipped
+ * by every mainstream screen reader — but it makes the region's text differ
+ * from its previous value, which is what actually triggers re-announcement.
+ */
+const ZERO_WIDTH_SPACE = "\u200B"
+
+const ROLE_FOR_MODE: Record = {
+ polite: "status",
+ assertive: "alert",
+ off: undefined,
+}
+
+interface LiveRegionProps extends React.ComponentProps<"div"> {
+ /**
+ * The text to announce. Prefer this over `children` — it is a plain string,
+ * so the region can guarantee a real text-node change on re-announcement.
+ */
+ message?: string
+ /** @default "polite" */
+ mode?: LiveRegionMode
+ /**
+ * Announce the region as a whole rather than just the changed words.
+ * Keep this on for short status sentences; turn it off for append-only logs.
+ * @default true
+ */
+ atomic?: boolean
+ /** Which mutations are announced. Maps to `aria-relevant`. */
+ relevant?: React.AriaAttributes["aria-relevant"]
+ /**
+ * Also render the message on screen. Off by default — a live region is a
+ * companion to visible UI, not a substitute for it. Turn it on when the
+ * region *is* the visible status text (a form's "Saving…" line, say), so
+ * sighted and screen-reader users read the same string.
+ * @default false
+ */
+ visible?: boolean
+ /**
+ * Change this to re-announce the current `message` even when the text is
+ * identical to what was announced before. Screen readers only speak a live
+ * region when its content changes, so "3 results" after "3 results" is
+ * silent unless the announcement is explicitly re-keyed.
+ *
+ * Pass a counter you bump per event, or use {@link useAnnouncer} which keeps
+ * one for you.
+ */
+ announcementKey?: number | string
+}
+
+/**
+ * An ARIA live region for status text that must reach screen readers without
+ * stealing focus.
+ *
+ * ```tsx
+ *
+ *
+ * ```
+ *
+ * Mount the region *before* the message exists (empty string is fine) and then
+ * fill it in. A region that is inserted into the DOM together with its text is
+ * frequently missed, because the assistive technology never observed a change.
+ *
+ * The node is never a focus target and, unless `visible` is set, occupies no
+ * space, so it cannot affect the layout it sits in.
+ */
+function LiveRegion({
+ message,
+ mode = "polite",
+ atomic = true,
+ relevant,
+ visible = false,
+ announcementKey,
+ className,
+ children,
+ role,
+ ...props
+}: LiveRegionProps) {
+ const repeats = useRepeatCount(announcementKey)
+
+ // Only the string form can be safely re-keyed; arbitrary children are
+ // rendered as-is and re-announce on their own DOM changes.
+ const content =
+ message == null
+ ? children
+ : repeats % 2 === 1
+ ? `${message}${ZERO_WIDTH_SPACE}`
+ : message
+
+ return (
+
+ {content}
+
+ )
+}
+
+/**
+ * Counts how many times `key` has changed. Derived during render (rather than
+ * in an effect) so the re-announcement lands in the same commit as the message
+ * it belongs to.
+ */
+function useRepeatCount(key: number | string | undefined) {
+ const [count, setCount] = React.useState(0)
+ const [seen, setSeen] = React.useState(key)
+
+ if (key !== seen) {
+ setSeen(key)
+ setCount((current) => current + 1)
+ }
+
+ return count
+}
+
+interface Announcer {
+ /** Current message. Spread into ``. */
+ message: string
+ /** Spread into ``. */
+ announcementKey: number
+ /** Announce `message`, re-announcing it even if the text is unchanged. */
+ announce: (message: string) => void
+ /** Empty the region so a later identical message reads as new. */
+ clear: () => void
+}
+
+/**
+ * Message state for a {@link LiveRegion}, with re-announcement handled.
+ *
+ * ```tsx
+ * const { message, announcementKey, announce } = useAnnouncer()
+ * // …
+ *
+ *
+ * ```
+ *
+ * Calling `announce` with the same string twice announces it twice — that is
+ * the documented way to repeat an identical message.
+ */
+function useAnnouncer(initialMessage = ""): Announcer {
+ const [state, setState] = React.useState({
+ message: initialMessage,
+ announcementKey: 0,
+ })
+
+ const announce = React.useCallback((message: string) => {
+ setState((current) => ({
+ message,
+ announcementKey: current.announcementKey + 1,
+ }))
+ }, [])
+
+ const clear = React.useCallback(() => {
+ setState((current) => ({
+ message: "",
+ announcementKey: current.announcementKey + 1,
+ }))
+ }, [])
+
+ return { ...state, announce, clear }
+}
+
+export { LiveRegion, useAnnouncer, ZERO_WIDTH_SPACE }
+export type { LiveRegionMode, LiveRegionProps, Announcer }
diff --git a/packages/ui/src/components/scroll-area.test.tsx b/packages/ui/src/components/scroll-area.test.tsx
new file mode 100644
index 0000000..04e8e9e
--- /dev/null
+++ b/packages/ui/src/components/scroll-area.test.tsx
@@ -0,0 +1,320 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { act, render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { axe } from "vitest-axe"
+
+import { ScrollArea } from "./scroll-area"
+
+/**
+ * jsdom has no layout engine, so scroll metrics have to be supplied. These
+ * helpers stand in for "content taller/wider than the viewport".
+ */
+function setMetrics(
+ element: HTMLElement,
+ metrics: Partial<
+ Record<
+ | "scrollTop"
+ | "scrollLeft"
+ | "scrollHeight"
+ | "clientHeight"
+ | "scrollWidth"
+ | "clientWidth",
+ number
+ >
+ >
+) {
+ for (const [key, value] of Object.entries(metrics)) {
+ Object.defineProperty(element, key, {
+ configurable: true,
+ writable: true,
+ value,
+ })
+ }
+}
+
+function getRoot() {
+ const root = document.querySelector("[data-slot='scroll-area']")
+ if (!root) throw new Error("scroll area not rendered")
+ return root
+}
+
+function getViewport() {
+ const viewport = document.querySelector(
+ "[data-slot='scroll-area-viewport']"
+ )
+ if (!viewport) throw new Error("scroll area viewport not rendered")
+ return viewport
+}
+
+async function scrollTo(viewport: HTMLElement, metrics: Parameters[1]) {
+ setMetrics(viewport, metrics)
+ await act(async () => {
+ viewport.dispatchEvent(new Event("scroll"))
+ })
+}
+
+/** Records observers so tests can fire them the way a real resize would. */
+class ResizeObserverMock {
+ static instances: Array = []
+ constructor(private callback: () => void) {
+ ResizeObserverMock.instances.push(this)
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ static triggerAll() {
+ for (const instance of ResizeObserverMock.instances) instance.callback()
+ }
+}
+
+beforeEach(() => {
+ ResizeObserverMock.instances = []
+ vi.stubGlobal("ResizeObserver", ResizeObserverMock)
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe("ScrollArea", () => {
+ it("renders content in a native scroll container", () => {
+ render(
+
+
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/scroll-area.tsx b/packages/ui/src/components/scroll-area.tsx
index b4c96a6..37f653a 100644
--- a/packages/ui/src/components/scroll-area.tsx
+++ b/packages/ui/src/components/scroll-area.tsx
@@ -1,55 +1,196 @@
"use client"
import * as React from "react"
-import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+import { cva } from "class-variance-authority"
+import { useOverflowEdges } from "@workspace/ui/hooks/use-overflow-edges"
import { cn } from "@workspace/ui/lib/utils"
+import type {
+ OverflowEdges,
+ ScrollAxis,
+} from "@workspace/ui/hooks/use-overflow-edges"
+import type { VariantProps } from "class-variance-authority"
+// ---------------------------------------------------------------------------
+// ScrollArea (DS-079) — overflow container with edge affordances
+// ---------------------------------------------------------------------------
+
+const viewportVariants = cva("min-h-0 min-w-0 flex-1 scroll-area-scrollbars", {
+ variants: {
+ orientation: {
+ vertical: "overflow-x-hidden overflow-y-auto",
+ horizontal: "overflow-x-auto overflow-y-hidden",
+ both: "overflow-auto",
+ },
+ },
+ defaultVariants: { orientation: "vertical" },
+})
+
+/**
+ * Edge shadows are driven entirely by the `data-overflow-*` attributes the
+ * hook writes, so they cost no renders. The gradient fades to the surface the
+ * area sits on — pick the one that matches the container, or the shadow reads
+ * as a grey smear.
+ */
+const edgeVariants = cva(
+ "pointer-events-none absolute z-10 opacity-0 transition-opacity duration-150 motion-reduce:transition-none",
+ {
+ variants: {
+ tone: {
+ canvas: "from-surface-canvas",
+ raised: "from-surface-raised",
+ overlay: "from-surface-overlay",
+ card: "from-card",
+ },
+ edge: {
+ top: "inset-x-0 top-0 h-4 bg-gradient-to-b to-transparent group-data-[overflow-top=true]/scroll-area:opacity-100",
+ bottom:
+ "inset-x-0 bottom-0 h-4 bg-gradient-to-t to-transparent group-data-[overflow-bottom=true]/scroll-area:opacity-100",
+ left: "inset-y-0 left-0 w-4 bg-gradient-to-r to-transparent group-data-[overflow-left=true]/scroll-area:opacity-100",
+ right:
+ "inset-y-0 right-0 w-4 bg-gradient-to-l to-transparent group-data-[overflow-right=true]/scroll-area:opacity-100",
+ },
+ },
+ defaultVariants: { tone: "canvas", edge: "top" },
+ }
+)
+
+export interface ScrollAreaProps
+ extends React.ComponentProps<"div">,
+ Pick, "tone"> {
+ /**
+ * Which axes may overflow. The cross axis is clipped, so a vertical list
+ * cannot be dragged sideways by a stray trackpad gesture.
+ * @default "vertical"
+ */
+ orientation?: ScrollAxis
+ /**
+ * Fade the edges that still have content behind them.
+ * @default true
+ */
+ edgeShadows?: boolean
+ /**
+ * Make the viewport itself a tab stop so arrow keys and Page Up/Down can
+ * scroll it. Needed when the content has no focusable children of its own
+ * (a long block of text, a read-only log); pair it with `aria-label` so the
+ * stop is announced. Content that already contains links, rows, or inputs is
+ * reachable without it — the browser scrolls focused elements into view.
+ * @default false
+ */
+ focusable?: boolean
+ /** Classes for the scrolling element rather than the outer wrapper. */
+ viewportClassName?: string
+ /**
+ * Notified when the set of overflowing edges changes. Opt-in: subscribing
+ * moves the edge state into React and re-renders on it, which the CSS-driven
+ * default deliberately avoids.
+ */
+ onEdgesChange?: (edges: OverflowEdges) => void
+}
+
+/**
+ * A native scroll container with optional edge shadows that appear only when
+ * there is more content in that direction.
+ *
+ * Scrolling stays native — momentum, overscroll, wheel, trackpad, and the
+ * user's own scrollbar preference are all untouched; only the scrollbar's
+ * colours are themed (see the `scroll-area-scrollbars` utility). Nothing is
+ * virtualized: long tables should still be windowed by the table layer.
+ *
+ * ```tsx
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
function ScrollArea({
+ orientation = "vertical",
+ edgeShadows = true,
+ tone = "canvas",
+ focusable = false,
className,
+ viewportClassName,
+ onEdgesChange,
children,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledBy,
...props
-}: ScrollAreaPrimitive.Root.Props) {
+}: ScrollAreaProps) {
+ const { rootRef, viewportRef } = useOverflowEdges(orientation, onEdgesChange)
+
+ const showVertical = edgeShadows && orientation !== "horizontal"
+ const showHorizontal = edgeShadows && orientation !== "vertical"
+ const labelled = Boolean(ariaLabel || ariaLabelledBy)
+
return (
-
-
{children}
-
-
-
-
- )
-}
+
-function ScrollBar({
- className,
- orientation = "vertical",
- ...props
-}: ScrollAreaPrimitive.Scrollbar.Props) {
- return (
-
+
+
+ >
)}
- {...props}
- >
-
-
+
+ {showHorizontal && (
+ <>
+
+
+ >
+ )}
+
)
}
-export { ScrollArea, ScrollBar }
+export { ScrollArea, edgeVariants, viewportVariants }
diff --git a/packages/ui/src/components/separator.test.tsx b/packages/ui/src/components/separator.test.tsx
new file mode 100644
index 0000000..b9c555d
--- /dev/null
+++ b/packages/ui/src/components/separator.test.tsx
@@ -0,0 +1,143 @@
+import { describe, expect, it } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { axe } from "vitest-axe"
+
+import { Divider, Separator, separatorVariants } from "./separator"
+
+describe("Separator", () => {
+ it("exposes the separator role and horizontal orientation by default", () => {
+ render()
+ const separator = screen.getByRole("separator")
+ expect(separator).toHaveAttribute("aria-orientation", "horizontal")
+ expect(separator).toHaveAttribute("data-orientation", "horizontal")
+ })
+
+ it("exposes vertical orientation", () => {
+ render()
+ expect(screen.getByRole("separator")).toHaveAttribute(
+ "aria-orientation",
+ "vertical"
+ )
+ })
+
+ it("sizes itself per orientation", () => {
+ const { rerender } = render()
+ expect(screen.getByRole("separator")).toHaveClass("h-px", "w-full")
+
+ rerender()
+ const vertical = screen.getByRole("separator")
+ // Stretches in a flex row, and stays visible when the parent has no height.
+ expect(vertical).toHaveClass("w-px", "self-stretch", "min-h-4")
+ })
+
+ it("hides decorative separators from assistive technology", () => {
+ const { container } = render()
+ expect(screen.queryByRole("separator")).toBeNull()
+
+ const separator = container.querySelector("[data-slot='separator']")
+ expect(separator).toHaveAttribute("role", "none")
+ expect(separator).toHaveAttribute("aria-hidden", "true")
+ expect(separator).toHaveAttribute("data-decorative", "true")
+ expect(separator).not.toHaveAttribute("aria-orientation")
+ })
+
+ it("applies each tone", () => {
+ const { rerender } = render()
+ expect(screen.getByRole("separator")).toHaveClass("bg-border/50")
+
+ rerender()
+ expect(screen.getByRole("separator")).toHaveClass("bg-border")
+
+ rerender()
+ expect(screen.getByRole("separator")).toHaveClass("bg-text-tertiary")
+ })
+
+ it("merges caller classes", () => {
+ render()
+ expect(screen.getByRole("separator")).toHaveClass("my-4")
+ })
+
+ it("produces classes for every tone/orientation pair", () => {
+ for (const tone of ["subtle", "default", "strong"] as const) {
+ for (const orientation of ["horizontal", "vertical"] as const) {
+ expect(separatorVariants({ tone, orientation })).toBeTruthy()
+ }
+ }
+ })
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+
+
Above
+
+
Below
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
+
+describe("Divider", () => {
+ it("renders its label as text", () => {
+ render()
+ expect(screen.getByText("or")).toBeInTheDocument()
+ })
+
+ it("splits the rule around a centered label", () => {
+ const { container } = render()
+ const divider = container.querySelector("[data-slot='divider']")
+ expect(divider).toHaveAttribute("data-align", "center")
+ expect(container.querySelectorAll("[data-slot='separator']")).toHaveLength(2)
+ })
+
+ it("keeps a single rule for start and end alignment", () => {
+ const { container, rerender } = render()
+ expect(container.querySelectorAll("[data-slot='separator']")).toHaveLength(1)
+ expect(container.querySelector("[data-slot='divider']")).toHaveAttribute(
+ "data-align",
+ "start"
+ )
+
+ rerender()
+ expect(container.querySelectorAll("[data-slot='separator']")).toHaveLength(1)
+ })
+
+ it("keeps its rules out of the accessibility tree", () => {
+ render()
+ expect(screen.queryByRole("separator")).toBeNull()
+ })
+
+ it("uses a contrast-checked label token", () => {
+ const { container } = render()
+ expect(container.querySelector("[data-slot='divider-label']")).toHaveClass(
+ "text-text-secondary"
+ )
+ })
+
+ it("falls back to a plain decorative separator without a label", () => {
+ const { container } = render()
+ expect(container.querySelector("[data-slot='divider']")).toBeNull()
+ expect(container.querySelector("[data-slot='separator']")).toHaveAttribute(
+ "aria-hidden",
+ "true"
+ )
+ })
+
+ it("passes the tone through to its rules", () => {
+ const { container } = render()
+ expect(container.querySelector("[data-slot='separator']")).toHaveClass(
+ "bg-border/50"
+ )
+ })
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/separator.tsx b/packages/ui/src/components/separator.tsx
index eae1e67..d393a58 100644
--- a/packages/ui/src/components/separator.tsx
+++ b/packages/ui/src/components/separator.tsx
@@ -1,23 +1,157 @@
-import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+import * as React from "react"
+import { cva } from "class-variance-authority"
import { cn } from "@workspace/ui/lib/utils"
+import type { VariantProps } from "class-variance-authority"
+// ---------------------------------------------------------------------------
+// Separator / Divider (DS-080)
+// ---------------------------------------------------------------------------
+
+/**
+ * A separator earns its place only when two adjacent blocks are genuinely
+ * different kinds of thing and spacing alone leaves that ambiguous. Reach for
+ * space or a surface change first — see DESIGN.md, "Separators and dividers".
+ *
+ * - `subtle` — inside a card or a form, where the surface already groups things
+ * and the line is only a hint.
+ * - `default` — between sections of a page or rows of a list.
+ * - `strong` — the rare structural cut between two regions that belong to
+ * different tasks (a panel and its footer of destructive actions).
+ */
+const separatorVariants = cva("shrink-0", {
+ variants: {
+ tone: {
+ subtle: "bg-border/50",
+ default: "bg-border",
+ strong: "bg-text-tertiary",
+ },
+ orientation: {
+ horizontal: "h-px w-full",
+ // `self-stretch` handles the common flex row; `min-h-4` keeps the line
+ // visible when the parent has no height of its own and stretch does not
+ // apply (align-items other than stretch, or a grid cell).
+ vertical: "w-px min-h-4 self-stretch",
+ },
+ },
+ defaultVariants: {
+ tone: "default",
+ orientation: "horizontal",
+ },
+})
+
+/** Label styling for {@link Divider}. Contrast-checked in all three themes. */
+const dividerLabelClass =
+ "shrink-0 text-11 font-medium tracking-wide text-text-secondary uppercase"
+
+type SeparatorOrientation = "horizontal" | "vertical"
+
+export interface SeparatorProps
+ extends Omit, "children">,
+ Omit, "orientation"> {
+ /** @default "horizontal" */
+ orientation?: SeparatorOrientation
+ /**
+ * Hide the separator from assistive technology. Use it whenever the line is
+ * purely visual — which is most of the time. A semantic separator is only
+ * warranted when the grouping it expresses is not already carried by
+ * headings, lists, or landmarks, since every announced separator is one more
+ * thing a screen-reader user has to listen past.
+ * @default false
+ */
+ decorative?: boolean
+}
+
+/**
+ * A one-pixel rule between groups of content.
+ *
+ * ```tsx
+ * // semantic, default tone
+ * // visual only
+ * // in a flex row
+ * ```
+ *
+ * Rendered as a `div` rather than through a primitive: the whole component is
+ * its role and its ARIA orientation, and both are worth having stated in one
+ * place instead of inherited from a library default.
+ */
function Separator({
className,
orientation = "horizontal",
+ tone = "default",
+ decorative = false,
...props
-}: SeparatorPrimitive.Props) {
+}: SeparatorProps) {
return (
-
)
}
-export { Separator }
+export interface DividerProps
+ extends Omit, "children">,
+ Omit, "orientation"> {
+ /**
+ * Text shown in the rule. Without it a `Divider` is just a horizontal
+ * decorative `Separator`.
+ */
+ label?: React.ReactNode
+ /**
+ * Where the label sits. `center` splits the rule in two; `start` and `end`
+ * keep a single rule beside the label.
+ * @default "center"
+ */
+ align?: "start" | "center" | "end"
+}
+
+/**
+ * A horizontal rule with a label in it — "or", "Advanced", "Yesterday" — for
+ * naming a break in a form or a list instead of just drawing one.
+ *
+ * The rules are decorative and the label is ordinary text, so assistive
+ * technology reads the words rather than a pile of separator announcements.
+ *
+ * ```tsx
+ *
+ *
+ * ```
+ */
+function Divider({
+ label,
+ align = "center",
+ tone = "default",
+ className,
+ ...props
+}: DividerProps) {
+ if (label == null) {
+ return
+ }
+
+ const rule =
+
+ return (
+