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:
+1
-1
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -68,5 +68,9 @@
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}"
|
||||
},
|
||||
"version": "Version"
|
||||
"version": "Version",
|
||||
"openMenu": "Open menu",
|
||||
"closeMenu": "Close menu",
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>)",
|
||||
|
||||
Reference in New Issue
Block a user