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
8 changes: 5 additions & 3 deletions apps/website/src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ h1, h2, h3, h4, h5, h6 {
animation-range: entry 0% entry 30%;
}

/* Smooth scroll */
html {
scroll-behavior: smooth;
/* Smooth scroll — but never for users who asked for reduced motion. */
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
41 changes: 35 additions & 6 deletions apps/website/src/components/docs/DocsSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export function DocsSearch({ library }: { library?: LibraryId }) {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const restoreRef = useRef<HTMLElement | null>(null);
const listboxId = 'docs-search-listbox';
const router = useRouter();

const results = query.length > 0
Expand All @@ -80,13 +83,24 @@ export function DocsSearch({ library }: { library?: LibraryId }) {
}, [handleKeyDown]);

useEffect(() => {
if (open) {
setQuery('');
setSelected(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
if (!open) return undefined;
// Dialog pattern: remember where focus came from, restore it on close.
restoreRef.current = document.activeElement as HTMLElement | null;
setQuery('');
setSelected(0);
setTimeout(() => inputRef.current?.focus(), 50);
return () => restoreRef.current?.focus();
}, [open]);

// Keep the keyboard-selected option in view (findings §7 — it scrolled
// out of the 320px results box).
useEffect(() => {
if (!open) return;
listRef.current
?.querySelector('[aria-selected="true"]')
?.scrollIntoView({ block: 'nearest' });
}, [open, selected]);

const navigate = (page: SearchablePage) => {
track(analyticsEvents.docsSearchResultClick, {
surface: 'docs',
Expand All @@ -103,13 +117,20 @@ export function DocsSearch({ library }: { library?: LibraryId }) {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected((s) => Math.min(s + 1, results.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected((s) => Math.max(s - 1, 0)); }
if (e.key === 'Enter' && results[selected]) { navigate(results[selected]); }
// Focus trap: the combobox input is the dialog's only tab stop (options
// are reached with the arrow keys), so Tab must not escape to the page
// behind the modal.
if (e.key === 'Tab') e.preventDefault();
};

if (!open) return null;

return (
<div className="docs-search-overlay" onClick={() => setOpen(false)}>
<div
role="dialog"
aria-modal="true"
aria-label="Search documentation"
onClick={(e) => e.stopPropagation()}
className="docs-search-modal">
<div className="docs-search-input-wrap">
Expand All @@ -129,13 +150,21 @@ export function DocsSearch({ library }: { library?: LibraryId }) {
}}
onKeyDown={handleInputKeyDown}
placeholder="Search documentation..."
role="combobox"
aria-expanded={results.length > 0}
aria-controls={listboxId}
aria-activedescendant={results[selected] ? `docs-search-opt-${selected}` : undefined}
className="docs-search-input"
/>
</div>
<div className="docs-search-results">
<div className="docs-search-results" ref={listRef} id={listboxId} role="listbox" aria-label="Search results">
{results.map((page, i) => (
<button
key={page.href}
id={`docs-search-opt-${i}`}
role="option"
aria-selected={i === selected}
tabIndex={-1}
onClick={() => navigate(page)}
className="w-full text-left docs-search-result"
data-selected={i === selected || undefined}>
Expand Down
24 changes: 24 additions & 0 deletions apps/website/src/components/docs/PageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ export function PageActions({ library, section, slug }: Props) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open) return;
// Menu pattern: focus the first item on open, restore the trigger on close.
const items = menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]');
items?.[0]?.focus();
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
Expand All @@ -31,9 +36,25 @@ export function PageActions({ library, section, slug }: Props) {
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
triggerRef.current?.focus();
};
}, [open]);

// Roving arrow-key navigation over the menu items.
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const items = [...(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
if (items.length === 0) return;
const i = items.indexOf(document.activeElement as HTMLElement);
const move = (n: number) => {
e.preventDefault();
items[(n + items.length) % items.length].focus();
};
if (e.key === 'ArrowDown') move(i + 1);
if (e.key === 'ArrowUp') move(i - 1);
if (e.key === 'Home') move(0);
if (e.key === 'End') move(items.length - 1);
};

const path = `${library}/${section}/${slug}`;
const pageUrl = `${SITE_ORIGIN}/docs/${path}`;
const chatgptUrl = `https://chatgpt.com/?hints=search&q=${encodeURIComponent(
Expand All @@ -60,6 +81,7 @@ export function PageActions({ library, section, slug }: Props) {
<div ref={ref} className="docs-page-actions">
<button
type="button"
ref={triggerRef}
aria-label="Page actions"
aria-haspopup="menu"
aria-expanded={open}
Expand All @@ -71,6 +93,8 @@ export function PageActions({ library, section, slug }: Props) {
{open ? (
<div
role="menu"
ref={menuRef}
onKeyDown={onMenuKeyDown}
className="docs-page-actions-menu"
>
<button type="button" role="menuitem" onClick={copyMarkdown} className="docs-page-actions-item">
Expand Down
32 changes: 29 additions & 3 deletions apps/website/src/components/docs/mdx/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
'use client';
import { useState, Children, isValidElement } from 'react';
import { useId, useRef, useState, Children, isValidElement } from 'react';

interface TabProps {
label?: string;
children: React.ReactNode;
}

/**
* ARIA tabs pattern, mirroring ui/TabGroup: roles, roving tabindex, and
* arrow-key selection. Selection follows focus (the WAI-ARIA "automatic
* activation" flavor) because switching a docs tab is cheap.
*/
export function Tabs({ items, children }: { items?: string[]; children: React.ReactNode }) {
const [active, setActive] = useState(0);
const tabs = Children.toArray(children);
const baseId = useId();
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

// Extract labels: from items prop, from Tab label prop, or fallback
const labels = items ?? tabs.map((child, i) => {
Expand All @@ -18,13 +25,32 @@ export function Tabs({ items, children }: { items?: string[]; children: React.Re
return `Tab ${i + 1}`;
});

const select = (i: number) => {
const next = (i + labels.length) % labels.length;
setActive(next);
tabRefs.current[next]?.focus();
};

const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowRight') { e.preventDefault(); select(active + 1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); select(active - 1); }
if (e.key === 'Home') { e.preventDefault(); select(0); }
if (e.key === 'End') { e.preventDefault(); select(labels.length - 1); }
};

return (
<div className="mdx-tabs">
{/* Tab bar */}
<div className="mdx-tabs-bar">
<div className="mdx-tabs-bar" role="tablist" onKeyDown={onKeyDown}>
{labels.map((label, i) => (
<button
key={label}
ref={(el) => { tabRefs.current[i] = el; }}
id={`${baseId}-tab-${i}`}
role="tab"
aria-selected={active === i}
aria-controls={`${baseId}-panel-${i}`}
tabIndex={active === i ? 0 : -1}
onClick={() => setActive(i)}
className="mdx-tab-button"
data-active={active === i ? '' : undefined}
Expand All @@ -34,7 +60,7 @@ export function Tabs({ items, children }: { items?: string[]; children: React.Re
))}
</div>
{/* Tab body — no wrapper border/background; the inner code block owns its surface */}
<div>
<div id={`${baseId}-panel-${active}`} role="tabpanel" aria-labelledby={`${baseId}-tab-${active}`}>
{tabs[active]}
</div>
</div>
Expand Down
14 changes: 14 additions & 0 deletions apps/website/src/components/shared/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,20 @@ export function Nav() {
{/* Docs content */}
{(mobileTab === 'docs' && isDocsPage && currentLib) && (
<div className="nav-mobile-content-list">
{/* Docs search was ⌘K-only, with its trigger in the
desktop-only sidebar — phones had no way in (findings §7).
DocsSearch is mounted on every docs page and listens for
the same synthetic keydown the sidebar trigger sends. */}
<button
type="button"
className="nav-mobile-item nav-mobile-search"
onClick={() => {
setOpen(false);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
}}
>
Search docs…
</button>
{currentLib.demoUrl && (
<a href={currentLib.demoUrl} target="_blank" rel="noopener noreferrer"
onClick={() => {
Expand Down
15 changes: 15 additions & 0 deletions apps/website/src/styles/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,18 @@
color: #1a7a40;
line-height: 1.5;
}

/* Mobile docs-search entry (polish arc PR 3) — button reset to match the
* .nav-mobile-item link styling it shares. */
.nav-mobile-search {
width: 100%;
text-align: left;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-secondary);
}
.nav-mobile-search:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
66 changes: 63 additions & 3 deletions apps/website/src/styles/docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,15 @@
color: var(--color-accent);
}
@media (max-width: 768px) {
/* On narrow viewports, drop the absolute positioning so the hash doesn't overlap the page edge. */
/* On narrow viewports the absolute-positioned hash would overlap the page
* edge — and display:none left phones with NO way to copy a deep link
* (findings §6). Inline after the heading, faintly visible: touch has no
* hover to reveal it. */
.docs-prose h2 .heading-anchor,
.docs-prose h3 .heading-anchor {
position: static;
margin-right: 6px;
display: none;
margin-left: 8px;
opacity: 0.35;
}
}

Expand Down Expand Up @@ -1490,3 +1493,60 @@
font-weight: 600;
color: var(--color-accent);
}

/* Docs a11y — polish arc PR 3 (findings §6, §7). */

/* One focus ring for the docs chrome. Buttons and links that had no
* :focus-visible affordance at all. */
[data-docs-navlink]:focus-visible,
.docs-crumb-link:focus-visible,
.docs-toc-link:focus-visible,
.docs-sidebar-search-trigger:focus-visible,
.docs-sidebar-lib-trigger:focus-visible,
.docs-sidebar-lib-item:focus-visible,
.docs-sidebar-section-toggle:focus-visible,
.docs-sidebar-demo-link:focus-visible,
.docs-page-actions-trigger:focus-visible,
.docs-page-actions-item:focus-visible,
.docs-search-result:focus-visible,
.mdx-tab-button:focus-visible,
.mdx-pre-copy:focus-visible,
.heading-anchor:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius-sm);
}

/* Menu items had no hover or focus state at all. */
.docs-page-actions-item:hover,
.docs-page-actions-item:focus-visible {
background: var(--color-surface-dim);
}

/* 44px minimum touch targets (WCAG 2.5.8) without visual growth: the
* trigger is 32px and the code-copy button 28px; an inset pseudo expands
* the hit area only. */
.docs-page-actions-trigger,
.mdx-pre-copy {
position: relative;
}
.docs-page-actions-trigger::before {
content: '';
position: absolute;
inset: -6px;
}
.mdx-pre-copy::before {
content: '';
position: absolute;
inset: -8px;
}

/* The tab bar scrolls sideways instead of wrapping or clipping on narrow
* screens. */
.mdx-tabs-bar {
overflow-x: auto;
}
.mdx-tab-button {
white-space: nowrap;
flex-shrink: 0;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# Docs visual review — findings

**Date:** 2026-08-29
**Status:** Findings captured. Fixes deferred to Project 3 of the substrate arc
(see `2026-08-29-design-token-css-var-completion-design.md` for the decomposition).
**Status:** RESOLVED (2026-08-30). The three-project arc completed: tokens
(#845), substrate (#848–#858), polish (#861 structural, #863 details, #865
a11y). Every finding below is fixed except the two explicitly deferred items:
§10 (font unification onto next/font — a sitewide visual decision of its own)
and the AnnouncementToast width note in §6.

This is the evidence log for a visual/usability review of the docs site. Every
item below was measured against a live dev server at 1280px, 768px, 375px, and
Expand Down
Loading