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
+1 -1
View File
@@ -84,7 +84,7 @@
}
</script>
</head>
<body class="bg-surface-0 text-gray-200 antialiased">
<body class="bg-surface-0 text-fg-primary antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+1
View File
@@ -47,6 +47,7 @@ export function AiToolIcon({
}
return (
<span
aria-hidden
className={`${className} flex shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary`}
>
{tool.label.charAt(0).toUpperCase()}
+9 -6
View File
@@ -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 ?? ""}`}
>
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/40 ${
checked ? TONE[tone] : "bg-surface-2 border-border group-hover:border-border-light"
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/60 ${
checked ? TONE[tone] : "bg-surface-2 border-fg-muted group-hover:border-fg-secondary"
}`}
>
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
{checked && <Check className="h-3 w-3" strokeWidth={3} aria-hidden />}
</span>
{label != null && (
<span
+60 -3
View File
@@ -5,19 +5,31 @@
* once measured, closed by Escape, a press outside it, or after an item runs.
* `useContextMenu` keeps "which item, where" for a list of rows; `menuPoint`
* turns a mouse event into that point (and stops the browser's own menu).
*
* Keyboard (WAI-ARIA menu): the first enabled item takes focus on open;
* Up/Down wrap, Home/End jump, Escape and Tab close, and focus goes back to
* what held it before the menu opened — unless an item moved it elsewhere
* (a rename field, a dialog). A menu opened from the keyboard (Shift+F10 or
* the Menu key reports clientX/Y 0) anchors under the element instead of the
* viewport's corner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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<T>() {
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<HTMLDivElement>(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<HTMLElement>(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<HTMLDivElement>) => {
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<HTMLElement>(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,
+1 -1
View File
@@ -91,7 +91,7 @@ export function EmptyState({ icon: Icon, title, description, action, compact }:
<div
className={`rounded-2xl bg-surface-4 flex items-center justify-center ${compact ? "w-10 h-10 mb-3" : "w-14 h-14 mb-5"}`}
>
<Icon className="w-6 h-6 text-fg-muted" />
<Icon className="w-6 h-6 text-fg-muted" aria-hidden />
</div>
<h3 className="text-base font-medium text-fg-secondary mb-2">{title}</h3>
<p className={`text-sm text-fg-muted max-w-md ${compact ? "" : "mb-6"}`}>{description}</p>
+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. */}
+65 -14
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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<HTMLDivElement>) => {
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 = (
<div
role="tablist"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
}`}
@@ -42,13 +80,16 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageT
return (
<button
key={id}
id={tabId(id)}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={hasPanel && isActive ? panelId : undefined}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(id)}
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
isActive
? "bg-accent/15 border-accent/30 text-accent"
? "bg-accent/15 border-accent/30 text-accent font-semibold shadow-[inset_0_-2px_0_rgb(var(--accent))]"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
@@ -59,11 +100,21 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageT
})}
</div>
);
if (!actions) return list;
return (
const bar = actions ? (
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
{list}
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
</div>
) : (
list
);
if (!hasPanel) return bar;
return (
<>
{bar}
<div role="tabpanel" id={panelId} aria-labelledby={tabId(activeId)}>
{children}
</div>
</>
);
}
+105 -50
View File
@@ -2,9 +2,17 @@
* @file Select.tsx
* @description Generic styled dropdown that replaces native `<select>` elements
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
* checkmark space inconsistently; this control aligns labels, supports arrow-key
* navigation, flips above the trigger when viewport space is tight, and marks
* the active option with accent color plus a Lucide check.
* checkmark space inconsistently; this control aligns labels, flips above the
* trigger when viewport space is tight, and marks the chosen option with accent
* color plus a Lucide check.
*
* ## Accessibility — WAI-ARIA "listbox button"
* The trigger is a `<button aria-haspopup="listbox" aria-expanded>`; opening it
* moves focus to a `role="listbox"` popup that tracks the highlighted
* `role="option"` through `aria-activedescendant`. Keys: ArrowUp/Down, Home/End,
* Enter/Space pick, Escape cancels, Tab closes, and a typed character jumps to
* the next option whose label starts with it. Focus returns to the trigger on
* every close except Tab.
*
* ## Consumers
* Used on the Run Claude page and webhook settings form wherever a compact
@@ -63,7 +71,7 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
/** Single option in a {@link Select} list. */
@@ -108,15 +116,14 @@ export function Select<T extends string>({
}: SelectProps<T>) {
const ghost = variant === "ghost";
const [open, setOpen] = useState(false);
const [active, setActive] = useState(() =>
Math.max(
0,
options.findIndex((o) => o.value === value)
)
);
const [active, setActive] = useState(0);
const [openUp, setOpenUp] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const typed = useRef({ text: "", at: 0 });
const id = useId();
const optionId = (idx: number) => `${id}-opt-${idx}`;
useEffect(() => {
if (!open) return;
@@ -128,52 +135,84 @@ export function Select<T extends string>({
return () => document.removeEventListener("mousedown", onClick);
}, [open]);
// When opening, decide whether to render the popover above the trigger if
// the viewport doesn't have room below (common when this select sits at
// the bottom of a form). 288 px = `max-h-72`.
// On open: highlight the chosen option, decide whether the popover goes
// above the trigger (288 px = `max-h-72`), and move focus into the list.
useEffect(() => {
if (!open || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const below = window.innerHeight - rect.bottom;
const above = rect.top;
setOpenUp(below < 288 && above > below);
if (!open) return;
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const below = window.innerHeight - rect.bottom;
setOpenUp(below < 288 && rect.top > below);
}
listRef.current?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
}, [open]);
// Sync active highlight with current value when reopening
useEffect(() => {
if (open) {
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
}
}, [open, value, options]);
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
}, [open, active]);
const close = (refocus = true) => {
setOpen(false);
if (refocus) buttonRef.current?.focus();
};
const choose = (opt: SelectOption<T>) => {
onChange(opt.value);
setOpen(false);
buttonRef.current?.focus();
close();
};
const onKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (
!open &&
(e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ")
) {
const onButtonKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
e.preventDefault();
setOpen(true);
return;
}
if (e.key === "ArrowDown") {
};
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const last = options.length - 1;
const move: Record<string, (a: number) => number> = {
ArrowDown: (a) => Math.min(last, a + 1),
ArrowUp: (a) => Math.max(0, a - 1),
Home: () => 0,
End: () => last,
PageDown: (a) => Math.min(last, a + 10),
PageUp: (a) => Math.max(0, a - 10),
};
if (move[e.key]) {
e.preventDefault();
setActive((a) => Math.min(options.length - 1, a + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
} else if (e.key === "Enter") {
setActive(move[e.key]!);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const opt = options[active];
if (opt) choose(opt);
} else if (e.key === "Escape") {
setOpen(false);
// Stop here: a Select inside a dialog must not close the dialog too.
e.preventDefault();
e.stopPropagation();
close();
} else if (e.key === "Tab") {
close(false);
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
// character cycles through the options that start with it.
const now = Date.now();
const t = typed.current;
t.text = now - t.at > 500 ? e.key.toLowerCase() : t.text + e.key.toLowerCase();
t.at = now;
const cycling = t.text.length > 1 && [...t.text].every((c) => c === t.text[0]);
const prefix = cycling ? t.text[0]! : t.text;
const start = cycling || t.text.length === 1 ? active + 1 : active;
for (let i = 0; i < options.length; i++) {
const idx = (start + i) % options.length;
if (options[idx]!.label.toLowerCase().startsWith(prefix)) {
setActive(idx);
break;
}
}
}
};
@@ -187,24 +226,36 @@ export function Select<T extends string>({
disabled={disabled}
aria-label={label}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? `${id}-list` : undefined}
title={label}
onClick={() => setOpen((v) => !v)}
onKeyDown={onKey}
onClick={() => (open ? close() : setOpen(true))}
onKeyDown={onButtonKey}
className={
ghost
? "flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-accent/50 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
: "w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
? "flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
: "w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
}
>
<span className="flex min-w-0 items-center gap-2">
{current?.icon}
<span className="truncate">{current?.label ?? "-"}</span>
</span>
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" aria-hidden />
</button>
{open && (
<div
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
ref={listRef}
id={`${id}-list`}
role="listbox"
tabIndex={-1}
aria-label={label}
aria-activedescendant={options[active] ? optionId(active) : undefined}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
}}
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
@@ -212,13 +263,15 @@ export function Select<T extends string>({
const isSelected = opt.value === value;
const isActive = idx === active;
return (
<button
<div
key={opt.value || "__default__"}
type="button"
id={optionId(idx)}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(opt)}
onMouseEnter={() => setActive(idx)}
className={`w-full text-left px-3 py-1.5 transition-colors ${
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
}`}
>
@@ -231,12 +284,14 @@ export function Select<T extends string>({
>
{opt.label}
</span>
{isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />}
{isSelected && (
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
)}
</div>
{opt.hint && (
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
)}
</button>
</div>
);
})}
</div>
+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>
);
+12 -11
View File
@@ -65,13 +65,16 @@
* ----------------------------------------------------------------------------- */
import type { CSSProperties } from "react";
import { useTranslation } from "react-i18next";
interface SkeletonProps {
className?: string;
style?: CSSProperties;
/** Rounded shape variant. Defaults to "md". */
rounded?: "sm" | "md" | "lg" | "full";
/** Aria label for screen readers. Defaults to "Loading". */
/** Aria label for screen readers. Defaults to the translated "Loading…";
* `""` makes the block decorative (`aria-hidden`), for every block but one
* in a group, so a reader hears "Loading" once, not once per shape. */
label?: string;
}
@@ -86,17 +89,15 @@ const ROUNDED_CLASS = {
* Base shimmer block. Compose with width/height utilities for any shape:
* <Skeleton className="h-6 w-24" />
*/
export function Skeleton({
className = "",
style,
rounded = "md",
label = "Loading",
}: SkeletonProps) {
export function Skeleton({ className = "", style, rounded = "md", label }: SkeletonProps) {
const { t } = useTranslation();
const hidden = label === "";
return (
<span
role="status"
aria-label={label}
aria-busy="true"
role={hidden ? undefined : "status"}
aria-label={hidden ? undefined : (label ?? t("common:loading"))}
aria-hidden={hidden || undefined}
aria-busy={hidden ? undefined : "true"}
className={`inline-block bg-surface-3/70 animate-pulse ${ROUNDED_CLASS[rounded]} ${className}`}
style={style}
/>
@@ -133,7 +134,7 @@ export function TableRowSkeleton({ columns, widths }: { columns: number; widths?
<tr className="border-b border-border/40">
{Array.from({ length: columns }).map((_, i) => (
<td key={i} className="px-5 py-4">
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} />
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} label={i === 0 ? undefined : ""} />
</td>
))}
</tr>
+16 -3
View File
@@ -102,8 +102,15 @@ export function StatusBar({
<footer
data-testid="status-bar"
style={{ left }}
className="fixed bottom-0 right-0 z-20 flex h-6 items-center gap-4 border-t border-border bg-surface-1 px-3 text-xs text-fg-muted transition-[left] duration-200"
className="fixed bottom-0 right-0 z-20 flex h-6 items-center gap-4 overflow-hidden whitespace-nowrap border-t border-border bg-surface-1 px-3 text-xs text-fg-muted transition-[left] duration-200"
>
{/* What a screen reader is told without looking: the connection dropping
or coming back, and agents waiting on the user. Polite, and nothing
that ticks on its own (costs, running counts) — those stay silent. */}
<span role="status" className="sr-only" data-testid="status-bar-live">
{wsConnected ? t("live") : t("disconnected")}
{needYou > 0 ? ` · ${t("statusBar.needYou", { count: needYou })}` : ""}
</span>
<button
type="button"
onClick={onConnectionClick}
@@ -112,7 +119,11 @@ export function StatusBar({
wsConnected ? "text-status-success" : ""
}`}
>
{wsConnected ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
{wsConnected ? (
<Wifi className="h-3 w-3" aria-hidden />
) : (
<WifiOff className="h-3 w-3" aria-hidden />
)}
{wsConnected ? t("live") : t("disconnected")}
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
{updateAvailable && (
@@ -120,7 +131,9 @@ export function StatusBar({
data-testid="status-bar-update"
title={t("updateAvailable")}
className="h-1.5 w-1.5 rounded-full bg-status-success"
/>
>
<span className="sr-only">{t("updateAvailable")}</span>
</span>
)}
</button>
<span data-testid="status-bar-counts">
+5 -2
View File
@@ -30,8 +30,11 @@ export function Switch({
title={title}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-accent" : "bg-surface-4"
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
}`}
>
<span
@@ -0,0 +1,94 @@
/**
* @file ContextMenu.test.tsx
* @description Keyboard model of the shared context menu: focus lands on the
* first item, arrows/Home/End move (wrapping, skipping disabled items), Escape
* and Tab close and hand focus back to the trigger, and a keyboard-opened menu
* (clientX/Y 0) anchors under its element.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { ContextMenu, MenuItem, useContextMenu } from "../ContextMenu";
function Harness({ onPick = () => {} }: { onPick?: (s: string) => void }) {
const { menu, open, close } = useContextMenu<string>();
return (
<>
<button onContextMenu={(e) => open(e, "row")}>trigger</button>
{menu && (
<ContextMenu x={menu.x} y={menu.y} onClose={close}>
<MenuItem onSelect={() => (onPick("a"), close())}>Alpha</MenuItem>
<MenuItem disabled onSelect={() => {}}>
Beta
</MenuItem>
<MenuItem onSelect={() => (onPick("c"), close())}>Gamma</MenuItem>
</ContextMenu>
)}
</>
);
}
const item = (name: string) => screen.getByRole("menuitem", { name });
describe("ContextMenu keyboard", () => {
it("focuses the first item and moves with arrows, Home and End", () => {
render(<Harness />);
const trigger = screen.getByText("trigger");
trigger.focus();
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
expect(item("Alpha")).toHaveFocus();
fireEvent.keyDown(item("Alpha"), { key: "ArrowDown" });
expect(item("Gamma")).toHaveFocus(); // Beta is disabled
fireEvent.keyDown(item("Gamma"), { key: "ArrowDown" });
expect(item("Alpha")).toHaveFocus(); // wraps
fireEvent.keyDown(item("Alpha"), { key: "ArrowUp" });
expect(item("Gamma")).toHaveFocus();
fireEvent.keyDown(item("Gamma"), { key: "Home" });
expect(item("Alpha")).toHaveFocus();
fireEvent.keyDown(item("Alpha"), { key: "End" });
expect(item("Gamma")).toHaveFocus();
});
it.each(["Escape", "Tab"])("%s closes and returns focus to the trigger", (key) => {
render(<Harness />);
const trigger = screen.getByText("trigger");
trigger.focus();
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
fireEvent.keyDown(item("Alpha"), { key });
expect(screen.queryByRole("menu")).toBeNull();
expect(trigger).toHaveFocus();
});
it("Escape in the menu does not reach a document listener (a dialog behind)", () => {
const behind = vi.fn();
document.addEventListener("keydown", behind);
render(<Harness />);
fireEvent.contextMenu(screen.getByText("trigger"), { clientX: 40, clientY: 50 });
fireEvent.keyDown(item("Alpha"), { key: "Escape" });
document.removeEventListener("keydown", behind);
expect(behind).not.toHaveBeenCalled();
});
it("returns focus to the trigger after an item runs", () => {
const pick = vi.fn();
render(<Harness onPick={pick} />);
const trigger = screen.getByText("trigger");
trigger.focus();
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
fireEvent.click(item("Gamma"));
expect(pick).toHaveBeenCalledWith("c");
expect(trigger).toHaveFocus();
});
it("anchors a keyboard-opened menu (clientX/Y 0) under the element", () => {
render(<Harness />);
const trigger = screen.getByText("trigger");
trigger.getBoundingClientRect = () =>
({ left: 120, bottom: 80, top: 60, right: 200, width: 80, height: 20 }) as DOMRect;
fireEvent.contextMenu(trigger, { clientX: 0, clientY: 0 });
const menu = screen.getByRole("menu");
expect(menu.style.left).toBe("120px");
expect(menu.style.top).toBe("80px");
});
});
@@ -0,0 +1,97 @@
/**
* @file Layout.test.tsx
* @description Responsive shell: with no stored preference the sidebar starts
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
* from a menu button — a modal dialog that Escape and the backdrop close,
* handing focus back to the button.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
vi.mock("../Sagi/Sagi", () => ({ Sagi: () => null }));
vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
import { Layout, DRAWER_QUERY } from "../Layout";
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
function viewport(width: number) {
vi.stubGlobal("matchMedia", (q: string) => {
const max = Number(/max-width:\s*([\d.]+)px/.exec(q)?.[1] ?? Infinity);
return {
matches: width <= max,
media: q,
addEventListener: () => {},
removeEventListener: () => {},
};
});
}
afterEach(() => {
vi.unstubAllGlobals();
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
});
describe("loadCollapsed", () => {
it("starts collapsed below lg when nothing is stored", () => {
viewport(800);
expect(loadCollapsed()).toBe(true);
viewport(1440);
expect(loadCollapsed()).toBe(false);
});
it("a stored choice wins over the viewport", () => {
viewport(800);
localStorage.setItem(SIDEBAR_STORAGE_KEY, "false");
expect(loadCollapsed()).toBe(false);
});
});
describe("Layout drawer", () => {
it("below md: the sidebar is off-canvas until the menu button opens it", async () => {
viewport(600);
expect(window.matchMedia(DRAWER_QUERY).matches).toBe(true);
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
const aside = screen.getByTestId("sidebar");
expect(aside.className).toContain("invisible");
expect(aside).not.toHaveAttribute("role", "dialog");
expect(screen.getByRole("main").style.marginLeft).toBe("0rem");
const open = screen.getByRole("button", { name: "Open menu" });
open.focus();
fireEvent.click(open);
expect(aside).toHaveAttribute("role", "dialog");
expect(aside).toHaveAttribute("aria-modal", "true");
expect(aside.className).not.toContain("invisible");
await waitFor(() => expect(aside.contains(document.activeElement)).toBe(true));
fireEvent.keyDown(document, { key: "Escape" });
expect(aside.className).toContain("invisible");
expect(open).toHaveFocus();
fireEvent.click(open);
fireEvent.click(screen.getByTestId("sidebar-backdrop"));
expect(aside.className).toContain("invisible");
});
it("at md and up: no menu button, the sidebar is a landmark beside the content", () => {
viewport(800);
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
expect(screen.queryByRole("button", { name: "Open menu" })).toBeNull();
expect(screen.getByRole("complementary", { name: "Sidebar" })).toBeInTheDocument();
// Collapsed rail by default below lg, so the content is not covered.
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
});
});
@@ -0,0 +1,93 @@
/**
* @file Select.test.tsx
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
* listbox/option roles with aria-selected, aria-activedescendant tracking,
* Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Select } from "../Select";
const OPTIONS = [
{ value: "a", label: "Apple" },
{ value: "b", label: "Banana" },
{ value: "c", label: "Cherry" },
{ value: "d", label: "Blueberry" },
];
function setup(value = "b") {
const onChange = vi.fn();
render(<Select value={value} onChange={onChange} options={OPTIONS} label="Fruit" />);
const button = screen.getByRole("button", { name: "Fruit" });
return { onChange, button };
}
const activeLabel = () => {
const list = screen.getByRole("listbox");
return document.getElementById(list.getAttribute("aria-activedescendant")!)?.textContent;
};
describe("Select (listbox button)", () => {
it("opens on ArrowDown into a focused listbox on the chosen option", () => {
const { button } = setup();
expect(button).toHaveAttribute("aria-haspopup", "listbox");
expect(button).toHaveAttribute("aria-expanded", "false");
fireEvent.keyDown(button, { key: "ArrowDown" });
expect(button).toHaveAttribute("aria-expanded", "true");
const list = screen.getByRole("listbox", { name: "Fruit" });
expect(list).toHaveFocus();
expect(button).toHaveAttribute("aria-controls", list.id);
expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute("aria-selected", "false");
expect(activeLabel()).toBe("Banana");
});
it("moves with arrows/Home/End and picks with Enter, focus back on the button", () => {
const { button, onChange } = setup();
fireEvent.click(button);
const list = screen.getByRole("listbox");
fireEvent.keyDown(list, { key: "ArrowDown" });
expect(activeLabel()).toBe("Cherry");
fireEvent.keyDown(list, { key: "Home" });
expect(activeLabel()).toBe("Apple");
fireEvent.keyDown(list, { key: "End" });
expect(activeLabel()).toBe("Blueberry");
fireEvent.keyDown(list, { key: "ArrowUp" });
fireEvent.keyDown(list, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("c");
expect(screen.queryByRole("listbox")).toBeNull();
expect(button).toHaveFocus();
});
it("Escape closes without picking and does not bubble", () => {
const { button, onChange } = setup();
const behind = vi.fn();
document.addEventListener("keydown", behind);
fireEvent.click(button);
fireEvent.keyDown(screen.getByRole("listbox"), { key: "Escape" });
document.removeEventListener("keydown", behind);
expect(onChange).not.toHaveBeenCalled();
expect(behind).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox")).toBeNull();
expect(button).toHaveFocus();
});
it("type-ahead jumps to the next option starting with the typed letter", () => {
const { button } = setup("a");
fireEvent.click(button);
const list = screen.getByRole("listbox");
fireEvent.keyDown(list, { key: "b" });
expect(activeLabel()).toBe("Banana");
fireEvent.keyDown(list, { key: "b" });
expect(activeLabel()).toBe("Blueberry"); // a repeated letter cycles
});
it("picks with a click on an option", () => {
const { button, onChange } = setup();
fireEvent.click(button);
fireEvent.click(screen.getByText("Apple"));
expect(onChange).toHaveBeenCalledWith("a");
});
});
@@ -61,17 +61,20 @@ describe("StatusBar", () => {
const user = userEvent.setup();
const onConnectionClick = vi.fn();
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
expect(screen.getByText("Live")).toBeInTheDocument();
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
await user.click(screen.getByText("Live"));
await user.click(conn);
expect(onConnectionClick).toHaveBeenCalled();
// Polite live region: the connection and who is waiting, nothing that ticks.
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
});
it("says Disconnected when the socket is down", () => {
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Disconnected");
});
it("off the Workspace page shows no workspace or session cost", async () => {
+11
View File
@@ -7,6 +7,17 @@
import { describe, it, expect } from "vitest";
import i18n from "i18next";
describe("<html lang>", () => {
it("follows the resolved language on every change", async () => {
await i18n.changeLanguage("vi-VN");
expect(document.documentElement.lang).toBe("vi");
await i18n.changeLanguage("ko");
expect(document.documentElement.lang).toBe("en");
await i18n.changeLanguage("en");
expect(document.documentElement.lang).toBe("en");
});
});
describe("i18n resources", () => {
it("should provide Vietnamese translations for navigation keys", async () => {
await i18n.changeLanguage("vi");
+13
View File
@@ -16,6 +16,8 @@
* Unknown regional variants (e.g. `en-US`) map to supported base languages via
* `nonExplicitSupportedLngs`.
*
* `<html lang>` follows the resolved language on init and on every change.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -94,6 +96,15 @@ import splash_vi from "./locales/vi/splash.json";
import lanes_en from "./locales/en/lanes.json";
import lanes_vi from "./locales/vi/lanes.json";
/** Keep `<html lang>` on the active language, so a screen reader speaks the
* page with the right voice and the browser hyphenates/translates correctly.
* Registered before `init`, which fires the first `languageChanged`. */
export function syncDocumentLang(): void {
if (typeof document === "undefined") return;
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en";
}
i18n.on("languageChanged", syncDocumentLang);
i18n
.use(LanguageDetector)
.use(initReactI18next)
@@ -169,5 +180,7 @@ i18n
},
});
syncDocumentLang();
/** Configured i18next instance — import side effects run {@link init}. */
export default i18n;
+2 -1
View File
@@ -28,5 +28,6 @@
"glob": "Glob",
"agent": "Agent",
"other": "Other"
}
},
"badResponse": "The server sent an unexpected response instead of data. The dashboard may be restarting, or a proxy is answering in its place — reload in a moment."
}
+5 -1
View File
@@ -68,5 +68,9 @@
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}"
},
"version": "Version"
"version": "Version",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"sidebar": "Sidebar",
"now": "now"
}
+2 -1
View File
@@ -28,5 +28,6 @@
"glob": "Glob",
"agent": "Agent",
"other": "Khác"
}
},
"badResponse": "Máy chủ trả về phản hồi không mong đợi thay vì dữ liệu. Dashboard có thể đang khởi động lại, hoặc một proxy đang trả lời thay nó — hãy tải lại sau giây lát."
}
+5 -1
View File
@@ -68,5 +68,9 @@
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}"
},
"version": "Phiên bản"
"version": "Phiên bản",
"openMenu": "Mở menu",
"closeMenu": "Đóng menu",
"sidebar": "Thanh bên",
"now": "bây giờ"
}
+111 -32
View File
@@ -55,9 +55,19 @@
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
fails as a link or a button label. This is 5.3:1 as text on a card and
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
onto a near-black page is a different problem with a different answer. */
--accent: 163 74 40; /* #a34a28 */
--accent-hover: 142 63 32; /* #8e3f20 */
onto a near-black page is a different problem with a different answer.
One more step down (#a34a28 → #924224) so `text-accent` also clears 4.5:1
on its own selected chip (`bg-accent/15–20`), not just on the card. */
--accent: 146 66 36; /* #924224 */
--accent-hover: 122 55 30; /* #7a371e */
/* The FILL behind white text (`.btn-primary`, a Switch's on-track, a ticked
Checkbox, `bg-accent-solid`). In light themes the text accent is already
dark enough to carry white, so it simply IS the accent — resolved per
scope, like `--tint-base`. Dark themes declare their own: there the text
accent must be LIGHT to read on a near-black card, which makes it a fill
white text cannot sit on (3.3:1). */
--accent-solid: var(--accent);
--accent-solid-hover: var(--accent-hover);
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
/* Measured, not eyeballed: this carries real content (timestamps, counts),
@@ -110,11 +120,19 @@
--surface-5: 54 58 63; /* slate-6 #363a3f */
--border: 54 58 63; /* slate-6 #363a3f */
--border-light: 67 72 78; /* slate-7 #43484e */
--accent: 0 144 255; /* blue-9 #0090ff — identical value both themes, Radix's brand anchor */
--accent-hover: 59 158 255; /* blue-10 #3b9eff */
/* Text/indicator accent is Radix blue-11 (its text step): blue-9 #0090ff was
4.4:1 on a card and 3.4:1 on its own selected chip. The solid blue-9 fill
lives on as the hue of `--accent-solid`, darkened until white text on it
clears 4.5:1 (it was 3.3:1). */
--accent: 112 184 255; /* blue-11 #70b8ff */
--accent-hover: 158 206 255; /* #9eceff */
--accent-solid: 0 118 209; /* #0076d1 — white 4.8:1 */
--accent-solid-hover: 0 102 181; /* #0066b5 */
--fg-primary: 237 238 240; /* slate-12 #edeef0 */
--fg-secondary: 176 180 186; /* slate-11 #b0b4ba */
--fg-muted: 105 110 119; /* slate-9 #696e77 */
/* slate-9 #696e77 was 2.8:1 on a card. Raised until it clears 4.5:1 on the
hover lift (surface-4) too, still a clear step below fg-secondary. */
--fg-muted: 150 154 160; /* #969aa0 */
/* Dark surfaces already separate by lightness step; a shadow adds nothing
visible against a dark page and reads as a stray dark smudge. */
--shadow-card: none;
@@ -154,13 +172,15 @@
--surface-5: 60 63 65; /* #3c3f41 */
--border: 63 66 68; /* #3f4244 */
--border-light: 77 81 83; /* #4d5153 */
--accent: 53 146 196; /* #3592c4 */
--accent-hover: 74 163 214; /* #4aa3d6 */
--accent: 132 189 219; /* #84bddb */
--accent-hover: 168 209 230; /* #a8d1e6 */
--accent-solid: 45 124 167; /* #2d7ca7 */
--accent-solid-hover: 38 106 143; /* #266a8f */
--fg-primary: 220 220 220; /* #dcdcdc */
--fg-secondary: 169 183 198; /* #a9b7c6 */
--fg-muted: 140 146 150; /* #8c9296 */
--status-success: 126 194 135; /* #7ec287 */
--status-danger: 255 143 140; /* #ff8f8c */
--fg-muted: 154 159 163; /* #9a9fa3 */
--status-success: 132 200 141; /* #84c88d */
--status-danger: 255 153 150; /* #ff9996 */
--status-warning: 232 191 106; /* #e8bf6a */
--syn-comment: 128 128 128; /* #808080 */
--syn-string: 127 159 105; /* #7f9f69 */
@@ -186,13 +206,16 @@
--surface-5: 58 58 60; /* #3a3a3c */
--border: 56 56 58; /* #38383a */
--border-light: 72 72 74; /* #48484a */
--accent: 10 132 255; /* #0a84ff */
--accent-hover: 64 156 255; /* #409cff */
--accent: 123 189 255; /* #7bbdff */
--accent-hover: 163 209 255; /* #a3d1ff */
--accent-solid: 9 115 222; /* #0973de */
--accent-solid-hover: 7 98 190; /* #0762be */
--fg-primary: 245 245 247; /* #f5f5f7 */
--fg-secondary: 174 174 178; /* #aeaeb2 */
--fg-muted: 142 142 147; /* #8e8e93 */
/* Lifted with fg-muted so the two stay a visible step apart. */
--fg-secondary: 199 199 204; /* #c7c7cc */
--fg-muted: 160 160 164; /* #a0a0a4 */
--status-success: 48 209 88; /* #30d158 */
--status-danger: 255 140 134; /* #ff8c86 */
--status-danger: 255 155 150; /* #ff9b96 */
--status-warning: 255 214 10; /* #ffd60a */
--syn-comment: 127 140 152; /* #7f8c98 */
--syn-string: 252 106 93; /* #fc6a5d */
@@ -211,8 +234,9 @@
/* Orca Dark — Orca's neutral palette (src/renderer/src/assets/main.css `.dark`).
* Accent is NOT Orca's #e5e5e5 primary: AgentHub puts white text on `bg-accent`
* (.btn-primary), which would read 1.3:1 there; #3b82f6 keeps text-accent 4.6:1
* on the card. Syntax colours are Apple Dark's. */
* (.btn-primary), which would read 1.3:1 there. Text accent and the solid fill
* behind white text (`--accent-solid`) are two shades of #3b82f6, each held at
* 4.5:1. Syntax colours are Apple Dark's. */
.dark[data-theme="orca-dark"] {
--surface-0: 10 10 10; /* #0a0a0a */
--surface-1: 17 17 17; /* #111111 */
@@ -222,13 +246,15 @@
--surface-5: 64 64 64; /* #404040 */
--border: 31 31 31; /* #1f1f1f */
--border-light: 46 46 46; /* #2e2e2e */
--accent: 59 130 246; /* #3b82f6 */
--accent-hover: 96 165 250; /* #60a5fa */
--accent: 106 160 248; /* #6aa0f8 */
--accent-hover: 147 187 250; /* #93bbfa */
--accent-solid: 52 114 216; /* #3472d8 */
--accent-solid-hover: 44 97 184; /* #2c61b8 */
--fg-primary: 250 250 250; /* #fafafa */
--fg-secondary: 212 212 212; /* #d4d4d4 */
--fg-muted: 161 161 161; /* #a1a1a1 */
--status-success: 134 239 172; /* #86efac */
--status-danger: 255 101 104; /* #ff6568 */
--status-danger: 255 112 115; /* #ff7073 */
--status-warning: 234 179 8; /* #eab308 */
--syn-comment: 127 140 152; /* #7f8c98 */
--syn-string: 252 106 93; /* #fc6a5d */
@@ -254,11 +280,11 @@
--surface-5: 255 255 255; /* #ffffff */
--border: 209 209 214; /* #d1d1d6 */
--border-light: 229 229 234; /* #e5e5ea */
--accent: 0 102 204; /* #0066cc */
--accent-hover: 0 85 179; /* #0055b3 */
--accent: 0 92 184; /* #005cb8 */
--accent-hover: 0 76 153; /* #004c99 */
--fg-primary: 29 29 31; /* #1d1d1f */
--fg-secondary: 81 81 84; /* #515154 */
--fg-muted: 110 110 115; /* #6e6e73 */
--fg-muted: 103 103 108; /* #67676c */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 6px rgb(0 0 0 / 0.06);
--status-success: 23 107 46; /* #176b2e */
--status-danger: 179 0 14; /* #b3000e */
@@ -286,11 +312,11 @@
--surface-5: 255 255 255; /* #ffffff */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 47 106 227; /* #2f6ae3 */
--accent-hover: 39 88 192; /* #2758c0 */
--accent: 40 90 192; /* #285ac0 */
--accent-hover: 33 74 158; /* #214a9e */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
--fg-muted: 108 112 126; /* #6c707e */
--fg-muted: 103 106 120; /* #676a78 */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
@@ -318,11 +344,11 @@
--surface-5: 250 248 243; /* #faf8f3 */
--border: 203 191 166; /* #cbbfa6 */
--border-light: 220 211 192; /* #dcd3c0 */
--accent: 122 82 48; /* #7a5230 */
--accent: 119 80 47; /* #77502f */
--accent-hover: 102 63 34; /* #663f22 */
--fg-primary: 43 38 32; /* #2b2620 */
--fg-secondary: 94 85 74; /* #5e554a */
--fg-muted: 107 97 85; /* #6b6155 */
--fg-muted: 104 94 82; /* #685e52 */
--status-success: 54 93 36; /* #365d24 */
--status-danger: 147 41 31; /* #93291f */
--status-warning: 109 76 0; /* #6d4c00 */
@@ -357,7 +383,7 @@
--accent-hover: 38 38 38; /* #262626 */
--fg-primary: 10 10 10; /* #0a0a0a */
--fg-secondary: 64 64 64; /* #404040 */
--fg-muted: 115 115 115; /* #737373 */
--fg-muted: 105 105 105; /* #696969 */
--status-success: 22 101 52; /* #166534 */
--status-danger: 168 0 15; /* #a8000f */
--status-warning: 133 77 14; /* #854d0e */
@@ -436,12 +462,30 @@
@apply card transition-all duration-200 hover:border-border-light hover:bg-surface-4;
}
/* Buttons. Every variant shares one keyboard focus ring (`focus-visible`
only, so a mouse click draws none). `.btn-primary` fills with
`accent-solid`, the shade white text clears 4.5:1 on in every theme —
never `bg-accent` behind white text. */
.btn-primary {
@apply inline-flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors duration-150;
@apply inline-flex items-center gap-2 px-4 py-2 bg-accent-solid hover:bg-accent-solid-hover text-white text-sm font-medium rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-secondary {
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-fg-primary bg-surface-2 border border-border-light hover:bg-surface-4 rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-danger {
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-status-danger bg-status-danger/10 border border-status-danger/40 hover:bg-status-danger/20 rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-ghost {
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm text-fg-secondary hover:text-fg-primary hover:bg-surface-4 rounded-lg transition-colors duration-150;
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm text-fg-secondary hover:text-fg-primary hover:bg-surface-4 rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-50;
}
/* An icon-only button: a 28px target (WCAG 2.5.8 asks ≥24px). It has no
text, so give it an `aria-label` (its `title` becomes the tooltip). */
.btn-icon {
@apply inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-50;
}
.badge {
@@ -451,6 +495,41 @@
.input {
@apply bg-surface-2 border border-border rounded-lg px-3 py-2 text-sm text-fg-primary placeholder-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors;
}
/* A native <select>, themed: the browser's arrow is replaced by one drawn in
fg-muted (the native one is near-black, invisible on a dark field), and the
open list follows the mode through `color-scheme`. */
.select {
@apply input appearance-none cursor-pointer pr-8 bg-no-repeat disabled:cursor-not-allowed disabled:opacity-60;
background-position: right 0.6rem center;
background-size: 0.75rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.5l3 3 3-3'/%3E%3C/svg%3E");
}
}
/* The open list of a native <select> and the date picker follow the mode. */
:root,
[data-mode="light"] {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
/* Vestibular safety (WCAG 2.3.3): with the OS asking for reduced motion, every
animation and transition collapses to an instant change — the pulsing
status dots, `animate-ping`, fade/slide-in, spinners included (a spinner
still shows; it just stops turning). Outside any layer so it beats the
utilities. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@layer utilities {
@@ -0,0 +1,44 @@
/**
* @file apiJsonGuard.test.ts
* @description `request()` refuses a 200 that is not JSON (an index.html from a
* proxy or dev server) with a translated error instead of the raw
* "Unexpected token '<'" a bare `res.json()` would throw.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import i18n from "i18next";
import { api } from "../api";
afterEach(() => vi.unstubAllGlobals());
function stub(type: string | null, body: string) {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(body, { status: 200, headers: type ? { "content-type": type } : {} })
)
);
}
describe("request() JSON guard", () => {
it("throws EBADRESPONSE with the translated message on an HTML 200", async () => {
stub("text/html; charset=utf-8", "<!doctype html><html></html>");
await expect(api.aiTools.list()).rejects.toMatchObject({
code: "EBADRESPONSE",
message: i18n.t("errors:badResponse"),
});
});
it("throws the same error when a JSON-less body fails to parse", async () => {
stub(null, "<html>");
await expect(api.aiTools.list()).rejects.toMatchObject({ code: "EBADRESPONSE" });
});
it("still parses a JSON 200", async () => {
stub("application/json", JSON.stringify({ default: "claude", tools: [] }));
await expect(api.aiTools.list()).resolves.toEqual({ default: "claude", tools: [] });
});
});
+37 -4
View File
@@ -426,6 +426,7 @@ import type {
} from "./types";
import { activeSourcesParam } from "./dataScope";
import i18n from "i18next";
// Root path all endpoint paths are appended to. Kept relative (no host) so the
// same client bundle works behind the Vite dev proxy and in same-origin prod.
@@ -538,9 +539,22 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
status: res.status,
// EGITAUTH from a lane git action: which host wants a login.
auth: body?.error?.auth as GitAuthNeeded | undefined,
// ESESSIONLIVE from a workspace start: the pane already holding it.
runId: body?.error?.run_id as string | undefined,
});
}
return res.json();
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
// that missed the route — would otherwise surface as "Unexpected token '<'".
const type = res.headers?.get?.("content-type");
const notJson = () =>
Object.assign(new Error(i18n.t("errors:badResponse")), {
code: "EBADRESPONSE",
status: res.status,
});
if (type && !/json/i.test(type)) throw notJson();
return res.json().catch(() => {
throw notJson();
});
}
/**
@@ -700,6 +714,11 @@ export const api = {
lane?: number;
/** `false` = not archived, `true` = archived only; omitted = both. */
archived?: boolean;
/** Only active sessions waiting on the user (`awaiting=1`). */
awaiting?: boolean;
/** ISO bounds on `started_at`. */
from?: string;
to?: string;
sort_by?: string;
sort_desc?: boolean;
limit?: number;
@@ -713,6 +732,9 @@ export const api = {
if (params?.tool) qs.set("tool", params.tool);
if (params?.lane !== undefined) qs.set("lane", String(params.lane));
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
if (params?.awaiting) qs.set("awaiting", "1");
if (params?.from) qs.set("from", params.from);
if (params?.to) qs.set("to", params.to);
if (params?.sort_by) qs.set("sort_by", params.sort_by);
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
@@ -847,10 +869,18 @@ export const api = {
* @param params.offset Row offset.
* @returns `{ agents }` — the matching agents (note: no `total` here).
*/
list: (params?: { status?: string; session_id?: string; limit?: number; offset?: number }) => {
list: (params?: {
status?: string;
session_id?: string;
/** Every agent of these sessions in one call. */
session_ids?: string[];
limit?: number;
offset?: number;
}) => {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.session_id) qs.set("session_id", params.session_id);
if (params?.session_ids) qs.set("session_ids", params.session_ids.join(","));
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
applyScope(qs); // narrow to the active data scope (source machines)
@@ -1191,13 +1221,16 @@ export const api = {
*
* @param params Retention thresholds.
* @param params.abandon_hours Idle-hours cutoff after which a session is abandoned.
* @param params.purge_days Age-in-days cutoff after which rows are purged.
* @param params.purge_days Age-in-days cutoff after which rows are purged (whole number ≥ 1, else 400).
* @param params.dry_run Count only; nothing is changed.
* @returns `{ ok, abandoned, purged_sessions, purged_events, purged_agents }`
* — how many records each part of the sweep affected.
*/
cleanup: (params: { abandon_hours?: number; purge_days?: number }) =>
cleanup: (params: { abandon_hours?: number; purge_days?: number; dry_run?: boolean }) =>
request<{
ok: boolean;
/** Set when `dry_run` was sent: the counts are what WOULD change. */
dry_run?: boolean;
abandoned: number;
purged_sessions: number;
purged_events: number;
+10
View File
@@ -38,6 +38,12 @@ const tinted = {
hover: tint("--accent-hover"),
muted: "color-mix(in srgb, rgb(var(--accent)) 15%, rgb(var(--tint-base)))",
},
// The fill behind white text (`.btn-primary`, Switch on, Checkbox tick):
// `--accent` is a TEXT colour, too light in dark themes to carry white.
"accent-solid": {
DEFAULT: tint("--accent-solid"),
hover: tint("--accent-solid-hover"),
},
status: {
success: tint("--status-success"),
danger: tint("--status-danger"),
@@ -69,6 +75,10 @@ export default {
hover: "rgb(var(--accent-hover) / <alpha-value>)",
muted: "rgb(var(--accent) / 0.15)",
},
"accent-solid": {
DEFAULT: "rgb(var(--accent-solid) / <alpha-value>)",
hover: "rgb(var(--accent-solid-hover) / <alpha-value>)",
},
fg: {
primary: "rgb(var(--fg-primary) / <alpha-value>)",
secondary: "rgb(var(--fg-secondary) / <alpha-value>)",