feat(a11y): contrast tokens, accent-solid, responsive drawer, keyboard menus

- Contrast: --fg-muted reaches 4.5:1 on every surface in all nine themes
  (dark default was 2.55:1); status chips and accent text clear 4.5:1 on
  their own tints. New --accent-solid(/-hover) + tailwind `accent-solid`
  is the fill behind white text (.btn-primary, Switch on, Checkbox tick);
  --accent is now a text colour. Switch off / unchecked Checkbox get a
  >=3:1 edge.
- <html lang> follows the chosen language; a global prefers-reduced-motion
  rule; index.html body uses a token instead of text-gray-200.
- Layout: the sidebar starts collapsed below lg and becomes an off-canvas
  drawer below md (menu button, backdrop, Escape, useDialog).
- ContextMenu: focus moves in, arrows/Home/End, Escape/Tab close, focus
  returns, a keyboard-opened menu anchors to its target. Select is a real
  listbox button (aria-expanded, activedescendant, type-ahead). PageTabs
  implements APG tabs with a tabpanel. New .btn-secondary/.btn-danger/
  .btn-icon and a .select class for native selects, all with focus rings.
- StatusBar announces connection and "N need you" politely; Sidebar's
  connection modal uses useDialog; EmptyState/Skeleton/AiToolIcon a11y.
- api.ts request(): a non-JSON 200 throws a translated EBADRESPONSE
  instead of "Unexpected token '<'".
This commit is contained in:
2026-10-05 13:22:00 +07:00
parent 17a5c7470d
commit 8fb703c6db
26 changed files with 917 additions and 167 deletions
+37 -4
View File
@@ -426,6 +426,7 @@ import type {
} from "./types";
import { activeSourcesParam } from "./dataScope";
import i18n from "i18next";
// Root path all endpoint paths are appended to. Kept relative (no host) so the
// same client bundle works behind the Vite dev proxy and in same-origin prod.
@@ -538,9 +539,22 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
status: res.status,
// EGITAUTH from a lane git action: which host wants a login.
auth: body?.error?.auth as GitAuthNeeded | undefined,
// ESESSIONLIVE from a workspace start: the pane already holding it.
runId: body?.error?.run_id as string | undefined,
});
}
return res.json();
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
// that missed the route — would otherwise surface as "Unexpected token '<'".
const type = res.headers?.get?.("content-type");
const notJson = () =>
Object.assign(new Error(i18n.t("errors:badResponse")), {
code: "EBADRESPONSE",
status: res.status,
});
if (type && !/json/i.test(type)) throw notJson();
return res.json().catch(() => {
throw notJson();
});
}
/**
@@ -700,6 +714,11 @@ export const api = {
lane?: number;
/** `false` = not archived, `true` = archived only; omitted = both. */
archived?: boolean;
/** Only active sessions waiting on the user (`awaiting=1`). */
awaiting?: boolean;
/** ISO bounds on `started_at`. */
from?: string;
to?: string;
sort_by?: string;
sort_desc?: boolean;
limit?: number;
@@ -713,6 +732,9 @@ export const api = {
if (params?.tool) qs.set("tool", params.tool);
if (params?.lane !== undefined) qs.set("lane", String(params.lane));
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
if (params?.awaiting) qs.set("awaiting", "1");
if (params?.from) qs.set("from", params.from);
if (params?.to) qs.set("to", params.to);
if (params?.sort_by) qs.set("sort_by", params.sort_by);
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
@@ -847,10 +869,18 @@ export const api = {
* @param params.offset Row offset.
* @returns `{ agents }` — the matching agents (note: no `total` here).
*/
list: (params?: { status?: string; session_id?: string; limit?: number; offset?: number }) => {
list: (params?: {
status?: string;
session_id?: string;
/** Every agent of these sessions in one call. */
session_ids?: string[];
limit?: number;
offset?: number;
}) => {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.session_id) qs.set("session_id", params.session_id);
if (params?.session_ids) qs.set("session_ids", params.session_ids.join(","));
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
applyScope(qs); // narrow to the active data scope (source machines)
@@ -1191,13 +1221,16 @@ export const api = {
*
* @param params Retention thresholds.
* @param params.abandon_hours Idle-hours cutoff after which a session is abandoned.
* @param params.purge_days Age-in-days cutoff after which rows are purged.
* @param params.purge_days Age-in-days cutoff after which rows are purged (whole number ≥ 1, else 400).
* @param params.dry_run Count only; nothing is changed.
* @returns `{ ok, abandoned, purged_sessions, purged_events, purged_agents }`
* — how many records each part of the sweep affected.
*/
cleanup: (params: { abandon_hours?: number; purge_days?: number }) =>
cleanup: (params: { abandon_hours?: number; purge_days?: number; dry_run?: boolean }) =>
request<{
ok: boolean;
/** Set when `dry_run` was sent: the counts are what WOULD change. */
dry_run?: boolean;
abandoned: number;
purged_sessions: number;
purged_events: number;