From 7ad94a9e51633fd983a2961b59ed6ef0f98232ca Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 18:51:09 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(website):=20docs=20polish=203/3=20?= =?UTF-8?q?=E2=80=94=20a11y=20and=20interaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final third of the docs polish arc (findings §6, §7). Every behavior verified interactively in a live browser. - DocsSearch becomes a real dialog: role=dialog + aria-modal, focus remembered on open and restored on close, Tab trapped (the combobox input is the only tab stop; options are arrow-key territory), input is a combobox with aria-activedescendant over a listbox of options, and the keyboard-selected option scrolls into view. Phones finally get a way in: a Search docs entry in the mobile nav's docs panel (it was Cmd-K-only, with the trigger in the desktop-only sidebar). - PageActions is a real menu: first item focused on open, ArrowUp/Down/ Home/End rove, Escape closes and restores the trigger, and items gain hover/focus states (they had none). - mdx Tabs gets the full ARIA tabs pattern mirroring ui/TabGroup (which already had it): roles, roving tabindex, arrow-key selection, labelled panel — plus a horizontally scrollable tab bar on narrow screens. - One shared :focus-visible ring across the docs chrome (nav links, crumbs, TOC, sidebar controls, menu, search results, tabs, copy buttons, heading anchors) — none had any focus affordance. - Heading anchors are visible on touch (inline after the heading at 0.35 opacity) instead of display:none — phones could not copy a deep link. - 44px touch targets for the 32px actions trigger and 28px code-copy button via inset pseudo hit areas, no visual growth (WCAG 2.5.8). - scroll-behavior:smooth now respects prefers-reduced-motion. Co-Authored-By: Claude Opus 5 --- apps/website/src/app/global.css | 8 ++- .../src/components/docs/DocsSearch.tsx | 41 ++++++++++-- .../src/components/docs/PageActions.tsx | 24 +++++++ apps/website/src/components/docs/mdx/Tabs.tsx | 32 ++++++++- apps/website/src/components/shared/Nav.tsx | 14 ++++ apps/website/src/styles/chrome.css | 15 +++++ apps/website/src/styles/docs.css | 66 ++++++++++++++++++- 7 files changed, 185 insertions(+), 15 deletions(-) diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index 208b05d53..ccda4df2b 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -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; + } } diff --git a/apps/website/src/components/docs/DocsSearch.tsx b/apps/website/src/components/docs/DocsSearch.tsx index 7df650bac..2a34a51a2 100644 --- a/apps/website/src/components/docs/DocsSearch.tsx +++ b/apps/website/src/components/docs/DocsSearch.tsx @@ -60,6 +60,9 @@ export function DocsSearch({ library }: { library?: LibraryId }) { const [query, setQuery] = useState(''); const [selected, setSelected] = useState(0); const inputRef = useRef(null); + const listRef = useRef(null); + const restoreRef = useRef(null); + const listboxId = 'docs-search-listbox'; const router = useRouter(); const results = query.length > 0 @@ -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', @@ -103,6 +117,10 @@ 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; @@ -110,6 +128,9 @@ export function DocsSearch({ library }: { library?: LibraryId }) { return (
setOpen(false)}>
e.stopPropagation()} className="docs-search-modal">
@@ -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" />
-
+
{results.map((page, i) => (
{/* Tab body — no wrapper border/background; the inner code block owns its surface */} -
+
{tabs[active]}
diff --git a/apps/website/src/components/shared/Nav.tsx b/apps/website/src/components/shared/Nav.tsx index 4509be2b5..b2a0655ee 100644 --- a/apps/website/src/components/shared/Nav.tsx +++ b/apps/website/src/components/shared/Nav.tsx @@ -242,6 +242,20 @@ export function Nav() { {/* Docs content */} {(mobileTab === 'docs' && isDocsPage && currentLib) && (
+ {/* 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. */} + {currentLib.demoUrl && ( { diff --git a/apps/website/src/styles/chrome.css b/apps/website/src/styles/chrome.css index afaa16276..fe1c56454 100644 --- a/apps/website/src/styles/chrome.css +++ b/apps/website/src/styles/chrome.css @@ -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); +} diff --git a/apps/website/src/styles/docs.css b/apps/website/src/styles/docs.css index 5c05f19b2..b67981647 100644 --- a/apps/website/src/styles/docs.css +++ b/apps/website/src/styles/docs.css @@ -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; } } @@ -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; +} From 365ed837b514a3851ae9187b2dacd994b81e1bf5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 18:52:32 -0700 Subject: [PATCH 2/2] docs(audits): mark the visual-review findings resolved by the polish arc --- .../audits/2026-08-29-docs-visual-review-findings.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md b/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md index 64f25018c..857a7b27a 100644 --- a/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md +++ b/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md @@ -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