diff --git a/client/index.html b/client/index.html index 27569011..d21ae217 100644 --- a/client/index.html +++ b/client/index.html @@ -84,7 +84,7 @@ } - +
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 = (
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 })}
); - if (!actions) return list; - return ( + const bar = actions ? (
{list}
{actions}
+ ) : ( + list + ); + if (!hasPanel) return bar; + return ( + <> + {bar} +
+ {children} +
+ ); } diff --git a/client/src/components/Select.tsx b/client/src/components/Select.tsx index e566ddc4..c79d8e19 100644 --- a/client/src/components/Select.tsx +++ b/client/src/components/Select.tsx @@ -2,9 +2,17 @@ * @file Select.tsx * @description Generic styled dropdown that replaces native `); + 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"); + }); +}); diff --git a/client/src/components/__tests__/StatusBar.test.tsx b/client/src/components/__tests__/StatusBar.test.tsx index 5156e64f..4e6b1437 100644 --- a/client/src/components/__tests__/StatusBar.test.tsx +++ b/client/src/components/__tests__/StatusBar.test.tsx @@ -61,17 +61,20 @@ describe("StatusBar", () => { const user = userEvent.setup(); const onConnectionClick = vi.fn(); at("/", ); - 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("/", {}} />); - 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 () => { diff --git a/client/src/i18n/__tests__/i18n.test.ts b/client/src/i18n/__tests__/i18n.test.ts index 8c7a27c8..f2c743d5 100644 --- a/client/src/i18n/__tests__/i18n.test.ts +++ b/client/src/i18n/__tests__/i18n.test.ts @@ -7,6 +7,17 @@ import { describe, it, expect } from "vitest"; import i18n from "i18next"; +describe("", () => { + 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"); diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index e097beb4..873fdc61 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -16,6 +16,8 @@ * Unknown regional variants (e.g. `en-US`) map to supported base languages via * `nonExplicitSupportedLngs`. * + * `` follows the resolved language on init and on every change. + * * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= @@ -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 `` 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; diff --git a/client/src/i18n/locales/en/errors.json b/client/src/i18n/locales/en/errors.json index 078b2557..60cbe763 100644 --- a/client/src/i18n/locales/en/errors.json +++ b/client/src/i18n/locales/en/errors.json @@ -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." } diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 7a7df941..3d7c5040 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -68,5 +68,9 @@ "workspace": "Workspace {{cost}}", "session": "Session {{cost}}" }, - "version": "Version" + "version": "Version", + "openMenu": "Open menu", + "closeMenu": "Close menu", + "sidebar": "Sidebar", + "now": "now" } diff --git a/client/src/i18n/locales/vi/errors.json b/client/src/i18n/locales/vi/errors.json index 8a0b156b..274919e1 100644 --- a/client/src/i18n/locales/vi/errors.json +++ b/client/src/i18n/locales/vi/errors.json @@ -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." } diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index 6ca0a3e4..f094c6ec 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -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ờ" } diff --git a/client/src/index.css b/client/src/index.css index a0148945..1331769d 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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 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 { diff --git a/client/src/lib/__tests__/apiJsonGuard.test.ts b/client/src/lib/__tests__/apiJsonGuard.test.ts new file mode 100644 index 00000000..e4d78fe6 --- /dev/null +++ b/client/src/lib/__tests__/apiJsonGuard.test.ts @@ -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ĩ + */ + +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", ""); + 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, ""); + 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: [] }); + }); +}); diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 8293cc6e..7dc9cb77 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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(path: string, options?: RequestInit): Promise { 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; diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 1f5b8107..2c7c59bb 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -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) / )", muted: "rgb(var(--accent) / 0.15)", }, + "accent-solid": { + DEFAULT: "rgb(var(--accent-solid) / )", + hover: "rgb(var(--accent-solid-hover) / )", + }, fg: { primary: "rgb(var(--fg-primary) / )", secondary: "rgb(var(--fg-secondary) / )",