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
+50 -27
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -51,6 +51,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useDialog } from "../hooks/useDialog";
import {
LayoutDashboard,
FolderOpen,
@@ -103,12 +104,18 @@ interface PersistedStats {
recentEvents: { type: string; at: number }[];
}
/** Below Tailwind's `lg` a full 15rem sidebar eats too much of the page. */
const NARROW_QUERY = "(max-width: 1023.98px)";
/** The stored choice, else collapsed on a narrow viewport. */
function loadCollapsed(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === "true";
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) return stored === "true";
} catch {
return false;
/* storage off: fall through to the viewport */
}
return window.matchMedia?.(NARROW_QUERY).matches ?? false;
}
function loadStats(): PersistedStats {
@@ -161,6 +168,8 @@ interface SidebarProps {
onConnectionOpenChange: (open: boolean) => void;
/** Told whether a newer release is out, for the status bar's dot. */
onUpdateAvailableChange?: (available: boolean) => void;
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
drawer?: { open: boolean; onClose: () => void };
}
export function Sidebar({
@@ -170,8 +179,12 @@ export function Sidebar({
connectionOpen,
onConnectionOpenChange,
onUpdateAvailableChange,
drawer,
}: SidebarProps) {
const { t } = useTranslation();
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
// Track whether nav items are clipped by overflow so we can render
// chevron affordances pointing toward the hidden items. Recomputed on
// scroll, resize, and any structural change (e.g. collapse toggle).
@@ -394,8 +407,21 @@ export function Sidebar({
return (
<aside
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col z-30 overflow-hidden transition-[width] duration-200 ${
collapsed ? "w-[4.25rem]" : "w-60"
ref={asideRef}
aria-label={t("nav:sidebar")}
role={drawerOpen ? "dialog" : undefined}
aria-modal={drawerOpen || undefined}
data-testid="sidebar"
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col overflow-hidden ${
drawer
? // Visibility flips at once on open (a still-hidden panel cannot take
// focus) and only after the slide-out on close.
`z-50 w-60 max-w-[85vw] duration-200 ${
drawer.open
? "visible translate-x-0 shadow-2xl transition-transform"
: "invisible -translate-x-full transition-[transform,visibility]"
}`
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
}`}
>
<div className="px-3 py-4 border-b border-border flex-shrink-0">
@@ -500,8 +526,8 @@ export function Sidebar({
type="button"
onClick={onToggle}
data-testid="sidebar-toggle"
title={collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
}`}
@@ -604,15 +630,8 @@ function ConnectionStatusModal({
}, [open]);
const close = useCallback(() => onClose(), [onClose]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, close]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(open, panelRef, close);
const wsUrl = useMemo(() => {
if (typeof window === "undefined") return "";
@@ -641,14 +660,17 @@ function ConnectionStatusModal({
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="connection-status-title"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]">
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="connection-status-title"
className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]"
>
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<div
@@ -690,9 +712,9 @@ function ConnectionStatusModal({
type="button"
onClick={close}
aria-label={t("nav:close")}
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
className="btn-icon -m-1 flex-shrink-0"
>
<X className="w-4 h-4" />
<X className="w-4 h-4" aria-hidden />
</button>
</div>
@@ -919,6 +941,7 @@ function Sparkline({
connected: boolean;
avgLabel: string;
}) {
const { t } = useTranslation();
const W = 320;
const H = 56;
const max = Math.max(1, ...buckets);
@@ -930,15 +953,15 @@ function Sparkline({
});
const linePath = points.length > 0 ? `M ${points.join(" L ")}` : "";
const areaPath = points.length > 0 ? `M 0,${H} L ${points.join(" L ")} L ${W},${H} Z` : "";
const stroke = connected ? "#34d399" : "#6b7280";
const stroke = connected ? "rgb(var(--status-success))" : "rgb(var(--fg-muted))";
return (
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-14" aria-hidden>
<defs>
<linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
<stop offset="0%" style={{ stopColor: stroke, stopOpacity: 0.35 }} />
<stop offset="100%" style={{ stopColor: stroke, stopOpacity: 0 }} />
</linearGradient>
</defs>
{areaPath && <path d={areaPath} fill="url(#spark-fill)" />}
@@ -946,7 +969,7 @@ function Sparkline({
<path
d={linePath}
fill="none"
stroke={stroke}
style={{ stroke }}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
@@ -957,7 +980,7 @@ function Sparkline({
<div className="flex items-center justify-between mt-1.5 text-[10px] text-fg-muted font-mono">
<span>−60s</span>
<span>{avgLabel}</span>
<span>{"now"}</span>
<span>{t("nav:now")}</span>
</div>
</div>
);