feat(a11y): contrast tokens, accent-solid, responsive drawer, keyboard menus

- Contrast: --fg-muted reaches 4.5:1 on every surface in all nine themes
  (dark default was 2.55:1); status chips and accent text clear 4.5:1 on
  their own tints. New --accent-solid(/-hover) + tailwind `accent-solid`
  is the fill behind white text (.btn-primary, Switch on, Checkbox tick);
  --accent is now a text colour. Switch off / unchecked Checkbox get a
  >=3:1 edge.
- <html lang> follows the chosen language; a global prefers-reduced-motion
  rule; index.html body uses a token instead of text-gray-200.
- Layout: the sidebar starts collapsed below lg and becomes an off-canvas
  drawer below md (menu button, backdrop, Escape, useDialog).
- ContextMenu: focus moves in, arrows/Home/End, Escape/Tab close, focus
  returns, a keyboard-opened menu anchors to its target. Select is a real
  listbox button (aria-expanded, activedescendant, type-ahead). PageTabs
  implements APG tabs with a tabpanel. New .btn-secondary/.btn-danger/
  .btn-icon and a .select class for native selects, all with focus rings.
- StatusBar announces connection and "N need you" politely; Sidebar's
  connection modal uses useDialog; EmptyState/Skeleton/AiToolIcon a11y.
- api.ts request(): a non-JSON 200 throws a translated EBADRESPONSE
  instead of "Unexpected token '<'".
This commit is contained in:
2026-10-05 13:22:00 +07:00
parent 17a5c7470d
commit 8fb703c6db
26 changed files with 917 additions and 167 deletions
+61 -5
View File
@@ -12,6 +12,12 @@
* written back on every toggle. Failures to access storage are swallowed so a
* private-browsing quota error never breaks the UI.
*
* ## Narrow viewports
* With no stored preference the sidebar starts collapsed below `lg` (see
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
* button in a slim top bar opens it as an off-canvas drawer (a modal dialog —
* backdrop, Escape, focus trapped and restored), which closes on navigation.
*
* ## Sticky descendants
* The inner content wrapper uses `overflow-x-clip` (not `hidden`) so horizontal
* overflow is clipped without creating a scroll container. That keeps `position:
@@ -64,14 +70,29 @@
*
* ----------------------------------------------------------------------------- */
import { useState, useCallback } from "react";
import { Outlet } from "react-router-dom";
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { Sagi } from "./Sagi/Sagi";
import { StatusBar } from "./StatusBar";
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
export const DRAWER_QUERY = "(max-width: 767.98px)";
function useMedia(query: string): boolean {
return useSyncExternalStore(
(cb) => {
const mq = window.matchMedia?.(query);
mq?.addEventListener?.("change", cb);
return () => mq?.removeEventListener?.("change", cb);
},
() => window.matchMedia?.(query).matches ?? false
);
}
/** Props for {@link Layout}. */
interface LayoutProps {
/** Live WebSocket status forwarded to the sidebar connection indicator. */
@@ -87,7 +108,17 @@ export function Layout({ wsConnected }: LayoutProps) {
const [collapsed, setCollapsed] = useState(loadCollapsed);
const [connectionOpen, setConnectionOpen] = useState(false);
const [updateAvailable, setUpdateAvailable] = useState(false);
const sidebarWidth = collapsed ? "4.25rem" : "15rem";
const drawer = useMedia(DRAWER_QUERY);
const [drawerOpen, setDrawerOpen] = useState(false);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
const { pathname, search } = useLocation();
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
// A pick in the drawer navigates; the drawer has done its job.
useEffect(closeDrawer, [pathname, search, closeDrawer]);
useEffect(() => {
if (!drawer) setDrawerOpen(false);
}, [drawer]);
const toggle = useCallback(() => {
setCollapsed((prev) => {
@@ -106,10 +137,19 @@ export function Layout({ wsConnected }: LayoutProps) {
</a>
<UpdateNotifier />
<Sagi />
{drawer && drawerOpen && (
<div
aria-hidden
data-testid="sidebar-backdrop"
onClick={closeDrawer}
className="fixed inset-0 z-40 bg-black/50 animate-fade-in"
/>
)}
<Sidebar
wsConnected={wsConnected}
collapsed={collapsed}
onToggle={toggle}
collapsed={drawer ? false : collapsed}
onToggle={drawer ? closeDrawer : toggle}
drawer={drawer ? { open: drawerOpen, onClose: closeDrawer } : undefined}
connectionOpen={connectionOpen}
onConnectionOpenChange={setConnectionOpen}
onUpdateAvailableChange={setUpdateAvailable}
@@ -123,6 +163,22 @@ export function Layout({ wsConnected }: LayoutProps) {
width: `calc(100% - ${sidebarWidth})`,
}}
>
{drawer && (
<div className="sticky top-0 z-20 flex h-11 items-center gap-2 border-b border-border bg-surface-1 px-2">
<button
type="button"
data-testid="sidebar-open"
onClick={() => setDrawerOpen(true)}
aria-label={t("openMenu")}
aria-expanded={drawerOpen}
title={t("openMenu")}
className="btn-icon"
>
<Menu className="h-5 w-5" aria-hidden />
</button>
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
</div>
)}
{/* overflow-x-clip (not -hidden) clips horizontal overflow without
creating a scroll container, so descendant `position: sticky`
elements (e.g. the Settings page TOC) still pin to the window. */}