+
diff --git a/client/src/components/AiToolIcon.tsx b/client/src/components/AiToolIcon.tsx
index e687c1cd..b2fb78af 100644
--- a/client/src/components/AiToolIcon.tsx
+++ b/client/src/components/AiToolIcon.tsx
@@ -47,6 +47,7 @@ export function AiToolIcon({
}
return (
{tool.label.charAt(0).toUpperCase()}
diff --git a/client/src/components/Checkbox.tsx b/client/src/components/Checkbox.tsx
index 5e08219b..920e2c27 100644
--- a/client/src/components/Checkbox.tsx
+++ b/client/src/components/Checkbox.tsx
@@ -82,10 +82,13 @@ export interface CheckboxProps {
title?: string;
}
+// Fill + tick per tone. Accent fills with `accent-solid` under a white tick.
+// The status colours are TEXT shades — light in dark mode, dark in light mode —
+// so their tick takes the page colour (surface-0), which contrasts both ways.
const TONE = {
- accent: "bg-accent border-accent",
- danger: "bg-status-danger border-status-danger",
- warning: "bg-status-warning border-status-warning",
+ accent: "bg-accent-solid border-accent-solid text-white",
+ danger: "bg-status-danger border-status-danger text-surface-0",
+ warning: "bg-status-warning border-status-warning text-surface-0",
} as const;
/**
@@ -114,11 +117,11 @@ export function Checkbox({
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
>
- {checked && }
+ {checked && }
{label != null && (
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
-import type { MouseEvent as ReactMouseEvent } from "react";
+import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
export type MenuPoint = { x: number; y: number };
-/** The pointer of a right-click, the browser's own menu suppressed. */
+/** The pointer of a right-click, the browser's own menu suppressed. A
+ * keyboard-opened menu has no pointer, so it anchors under the element. */
export function menuPoint(e: ReactMouseEvent): MenuPoint {
e.preventDefault();
e.stopPropagation();
+ if (e.clientX === 0 && e.clientY === 0 && e.currentTarget instanceof Element) {
+ const r = e.currentTarget.getBoundingClientRect();
+ return { x: r.left, y: r.bottom };
+ }
return { x: e.clientX, y: e.clientY };
}
@@ -33,6 +45,7 @@ export function useContextMenu() {
const MARGIN = 8;
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
+const ITEMS = '[role="menuitem"]:not([disabled]):not([aria-disabled="true"])';
export function ContextMenu({
x,
@@ -50,6 +63,49 @@ export function ContextMenu({
const ref = useRef(null);
const [pos, setPos] = useState({ left: x, top: y });
+ // Focus in on open, back out on close. Runs once per mount: a menu that
+ // moves to a new point keeps its focus.
+ useEffect(() => {
+ const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ ref.current?.querySelector(ITEMS)?.focus({ preventScroll: true });
+ return () => {
+ // Only when focus would otherwise be lost (it sat on a removed item).
+ const now = document.activeElement;
+ if (!now || now === document.body || ref.current?.contains(now)) previous?.focus?.();
+ };
+ }, []);
+
+ const onKeyDown = (e: ReactKeyboardEvent) => {
+ const inField = (e.target as HTMLElement).closest(
+ "input, textarea, select, [contenteditable='true']"
+ );
+ if (e.key === "Escape" || (e.key === "Tab" && !inField)) {
+ // Handled here so a dialog behind the menu does not close as well.
+ e.preventDefault();
+ e.stopPropagation();
+ onClose();
+ return;
+ }
+ // A field inside a menu (a size slider, a search box) keeps its own keys.
+ if (inField) return;
+ const items = [...(ref.current?.querySelectorAll(ITEMS) ?? [])];
+ if (items.length === 0) return;
+ const i = items.indexOf(document.activeElement as HTMLElement);
+ const next =
+ e.key === "ArrowDown"
+ ? (i + 1) % items.length
+ : e.key === "ArrowUp"
+ ? (i - 1 + items.length) % items.length
+ : e.key === "Home"
+ ? 0
+ : e.key === "End"
+ ? items.length - 1
+ : -1;
+ if (next < 0) return;
+ e.preventDefault();
+ items[next]!.focus();
+ };
+
useLayoutEffect(() => {
const r = ref.current?.getBoundingClientRect();
if (!r) return;
@@ -88,6 +144,7 @@ export function ContextMenu({
onDoubleClick={stop}
onMouseDown={stop}
onPointerDown={stop}
+ onKeyDown={onKeyDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -101,7 +158,7 @@ export function ContextMenu({
}
export const MENU_ITEM =
- "flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:pointer-events-none disabled:opacity-50";
+ "flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary focus:outline-none focus-visible:bg-surface-4 focus-visible:text-fg-primary focus-visible:ring-1 focus-visible:ring-accent/60 disabled:pointer-events-none disabled:opacity-50";
export function MenuItem({
onSelect,
diff --git a/client/src/components/EmptyState.tsx b/client/src/components/EmptyState.tsx
index 7167d999..9d222708 100644
--- a/client/src/components/EmptyState.tsx
+++ b/client/src/components/EmptyState.tsx
@@ -91,7 +91,7 @@ export function EmptyState({ icon: Icon, title, description, action, compact }:
-
+
{title}
{description}
diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx
index 44fc147e..17468b26 100644
--- a/client/src/components/Layout.tsx
+++ b/client/src/components/Layout.tsx
@@ -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) {
+ {drawer && drawerOpen && (
+
+ )}
+ {drawer && (
+
+
+ {t("brand")}
+
+ )}
{/* 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. */}
diff --git a/client/src/components/PageTabs.tsx b/client/src/components/PageTabs.tsx
index 34dfe86d..c40e6377 100644
--- a/client/src/components/PageTabs.tsx
+++ b/client/src/components/PageTabs.tsx
@@ -1,12 +1,20 @@
/**
* @file PageTabs.tsx
* @description Shared top-of-page tab bar used by the pages that merged several
- * former routes into one (Settings hub, Sessions/Events history). Rendering only -
- * the owning page keeps the active tab in its own query param. `actions` sits at
- * the bar's right edge, outside the tablist.
+ * former routes into one (Settings hub, Sessions/Events history). The owning
+ * page keeps the active tab in its own query param. `actions` sits at the bar's
+ * right edge, outside the tablist.
+ *
+ * These are real in-page tabs (one view swapped in place), so they follow the
+ * WAI-ARIA tabs pattern: one tab stop (roving tabindex), Left/Right/Home/End
+ * move AND select. Pass the active view as `children` and it is wrapped in the
+ * `role="tabpanel"` the tabs point at with `aria-controls`; without children no
+ * `aria-controls` is emitted (it would name an element that does not exist).
+ * The selected tab is marked by weight and an underline, not colour alone.
* @author Nguyễn Ngọc Trí Vĩ
*/
-import type { ComponentType, ReactNode } from "react";
+
+import { useId, type ComponentType, type KeyboardEvent, type ReactNode } from "react";
export interface PageTab {
id: string;
@@ -21,18 +29,48 @@ interface PageTabsProps {
ariaLabel: string;
/** Buttons at the right edge of the bar (not tabs). */
actions?: ReactNode;
+ /** The active tab's view, rendered inside its `role="tabpanel"`. */
+ children?: ReactNode;
}
-/**
- * Horizontal tab strip for a page that hosts several panels.
- * @param props - Tabs to render, the active tab id, and the selection callback.
- * @returns The tab bar element.
- */
-export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageTabsProps) {
+export function PageTabs({
+ tabs,
+ activeId,
+ onSelect,
+ ariaLabel,
+ actions,
+ children,
+}: PageTabsProps) {
+ const base = useId();
+ const tabId = (id: string) => `${base}-tab-${id}`;
+ const panelId = `${base}-panel`;
+ const hasPanel = children !== undefined;
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ const i = tabs.findIndex((t) => t.id === activeId);
+ const last = tabs.length - 1;
+ const next =
+ e.key === "ArrowRight"
+ ? (i + 1) % tabs.length
+ : e.key === "ArrowLeft"
+ ? (i - 1 + tabs.length) % tabs.length
+ : e.key === "Home"
+ ? 0
+ : e.key === "End"
+ ? last
+ : -1;
+ const tab = tabs[next];
+ if (!tab) return;
+ e.preventDefault();
+ onSelect(tab.id);
+ document.getElementById(tabId(tab.id))?.focus();
+ };
+
const list = (