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
+11
View File
@@ -7,6 +7,17 @@
import { describe, it, expect } from "vitest";
import i18n from "i18next";
describe("<html lang>", () => {
it("follows the resolved language on every change", async () => {
await i18n.changeLanguage("vi-VN");
expect(document.documentElement.lang).toBe("vi");
await i18n.changeLanguage("ko");
expect(document.documentElement.lang).toBe("en");
await i18n.changeLanguage("en");
expect(document.documentElement.lang).toBe("en");
});
});
describe("i18n resources", () => {
it("should provide Vietnamese translations for navigation keys", async () => {
await i18n.changeLanguage("vi");
+13
View File
@@ -16,6 +16,8 @@
* Unknown regional variants (e.g. `en-US`) map to supported base languages via
* `nonExplicitSupportedLngs`.
*
* `<html lang>` follows the resolved language on init and on every change.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -94,6 +96,15 @@ import splash_vi from "./locales/vi/splash.json";
import lanes_en from "./locales/en/lanes.json";
import lanes_vi from "./locales/vi/lanes.json";
/** Keep `<html lang>` on the active language, so a screen reader speaks the
* page with the right voice and the browser hyphenates/translates correctly.
* Registered before `init`, which fires the first `languageChanged`. */
export function syncDocumentLang(): void {
if (typeof document === "undefined") return;
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en";
}
i18n.on("languageChanged", syncDocumentLang);
i18n
.use(LanguageDetector)
.use(initReactI18next)
@@ -169,5 +180,7 @@ i18n
},
});
syncDocumentLang();
/** Configured i18next instance — import side effects run {@link init}. */
export default i18n;
+2 -1
View File
@@ -28,5 +28,6 @@
"glob": "Glob",
"agent": "Agent",
"other": "Other"
}
},
"badResponse": "The server sent an unexpected response instead of data. The dashboard may be restarting, or a proxy is answering in its place — reload in a moment."
}
+5 -1
View File
@@ -68,5 +68,9 @@
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}"
},
"version": "Version"
"version": "Version",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"sidebar": "Sidebar",
"now": "now"
}
+2 -1
View File
@@ -28,5 +28,6 @@
"glob": "Glob",
"agent": "Agent",
"other": "Khác"
}
},
"badResponse": "Máy chủ trả về phản hồi không mong đợi thay vì dữ liệu. Dashboard có thể đang khởi động lại, hoặc một proxy đang trả lời thay nó — hãy tải lại sau giây lát."
}
+5 -1
View File
@@ -68,5 +68,9 @@
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}"
},
"version": "Phiên bản"
"version": "Phiên bản",
"openMenu": "Mở menu",
"closeMenu": "Đóng menu",
"sidebar": "Thanh bên",
"now": "bây giờ"
}