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
+111 -32
View File
@@ -55,9 +55,19 @@
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
fails as a link or a button label. This is 5.3:1 as text on a card and
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
onto a near-black page is a different problem with a different answer. */
--accent: 163 74 40; /* #a34a28 */
--accent-hover: 142 63 32; /* #8e3f20 */
onto a near-black page is a different problem with a different answer.
One more step down (#a34a28 → #924224) so `text-accent` also clears 4.5:1
on its own selected chip (`bg-accent/15–20`), not just on the card. */
--accent: 146 66 36; /* #924224 */
--accent-hover: 122 55 30; /* #7a371e */
/* The FILL behind white text (`.btn-primary`, a Switch's on-track, a ticked
Checkbox, `bg-accent-solid`). In light themes the text accent is already
dark enough to carry white, so it simply IS the accent — resolved per
scope, like `--tint-base`. Dark themes declare their own: there the text
accent must be LIGHT to read on a near-black card, which makes it a fill
white text cannot sit on (3.3:1). */
--accent-solid: var(--accent);
--accent-solid-hover: var(--accent-hover);
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
/* Measured, not eyeballed: this carries real content (timestamps, counts),
@@ -110,11 +120,19 @@
--surface-5: 54 58 63; /* slate-6 #363a3f */
--border: 54 58 63; /* slate-6 #363a3f */
--border-light: 67 72 78; /* slate-7 #43484e */
--accent: 0 144 255; /* blue-9 #0090ff — identical value both themes, Radix's brand anchor */
--accent-hover: 59 158 255; /* blue-10 #3b9eff */
/* Text/indicator accent is Radix blue-11 (its text step): blue-9 #0090ff was
4.4:1 on a card and 3.4:1 on its own selected chip. The solid blue-9 fill
lives on as the hue of `--accent-solid`, darkened until white text on it
clears 4.5:1 (it was 3.3:1). */
--accent: 112 184 255; /* blue-11 #70b8ff */
--accent-hover: 158 206 255; /* #9eceff */
--accent-solid: 0 118 209; /* #0076d1 — white 4.8:1 */
--accent-solid-hover: 0 102 181; /* #0066b5 */
--fg-primary: 237 238 240; /* slate-12 #edeef0 */
--fg-secondary: 176 180 186; /* slate-11 #b0b4ba */
--fg-muted: 105 110 119; /* slate-9 #696e77 */
/* slate-9 #696e77 was 2.8:1 on a card. Raised until it clears 4.5:1 on the
hover lift (surface-4) too, still a clear step below fg-secondary. */
--fg-muted: 150 154 160; /* #969aa0 */
/* Dark surfaces already separate by lightness step; a shadow adds nothing
visible against a dark page and reads as a stray dark smudge. */
--shadow-card: none;
@@ -154,13 +172,15 @@
--surface-5: 60 63 65; /* #3c3f41 */
--border: 63 66 68; /* #3f4244 */
--border-light: 77 81 83; /* #4d5153 */
--accent: 53 146 196; /* #3592c4 */
--accent-hover: 74 163 214; /* #4aa3d6 */
--accent: 132 189 219; /* #84bddb */
--accent-hover: 168 209 230; /* #a8d1e6 */
--accent-solid: 45 124 167; /* #2d7ca7 */
--accent-solid-hover: 38 106 143; /* #266a8f */
--fg-primary: 220 220 220; /* #dcdcdc */
--fg-secondary: 169 183 198; /* #a9b7c6 */
--fg-muted: 140 146 150; /* #8c9296 */
--status-success: 126 194 135; /* #7ec287 */
--status-danger: 255 143 140; /* #ff8f8c */
--fg-muted: 154 159 163; /* #9a9fa3 */
--status-success: 132 200 141; /* #84c88d */
--status-danger: 255 153 150; /* #ff9996 */
--status-warning: 232 191 106; /* #e8bf6a */
--syn-comment: 128 128 128; /* #808080 */
--syn-string: 127 159 105; /* #7f9f69 */
@@ -186,13 +206,16 @@
--surface-5: 58 58 60; /* #3a3a3c */
--border: 56 56 58; /* #38383a */
--border-light: 72 72 74; /* #48484a */
--accent: 10 132 255; /* #0a84ff */
--accent-hover: 64 156 255; /* #409cff */
--accent: 123 189 255; /* #7bbdff */
--accent-hover: 163 209 255; /* #a3d1ff */
--accent-solid: 9 115 222; /* #0973de */
--accent-solid-hover: 7 98 190; /* #0762be */
--fg-primary: 245 245 247; /* #f5f5f7 */
--fg-secondary: 174 174 178; /* #aeaeb2 */
--fg-muted: 142 142 147; /* #8e8e93 */
/* Lifted with fg-muted so the two stay a visible step apart. */
--fg-secondary: 199 199 204; /* #c7c7cc */
--fg-muted: 160 160 164; /* #a0a0a4 */
--status-success: 48 209 88; /* #30d158 */
--status-danger: 255 140 134; /* #ff8c86 */
--status-danger: 255 155 150; /* #ff9b96 */
--status-warning: 255 214 10; /* #ffd60a */
--syn-comment: 127 140 152; /* #7f8c98 */
--syn-string: 252 106 93; /* #fc6a5d */
@@ -211,8 +234,9 @@
/* Orca Dark — Orca's neutral palette (src/renderer/src/assets/main.css `.dark`).
* Accent is NOT Orca's #e5e5e5 primary: AgentHub puts white text on `bg-accent`
* (.btn-primary), which would read 1.3:1 there; #3b82f6 keeps text-accent 4.6:1
* on the card. Syntax colours are Apple Dark's. */
* (.btn-primary), which would read 1.3:1 there. Text accent and the solid fill
* behind white text (`--accent-solid`) are two shades of #3b82f6, each held at
* 4.5:1. Syntax colours are Apple Dark's. */
.dark[data-theme="orca-dark"] {
--surface-0: 10 10 10; /* #0a0a0a */
--surface-1: 17 17 17; /* #111111 */
@@ -222,13 +246,15 @@
--surface-5: 64 64 64; /* #404040 */
--border: 31 31 31; /* #1f1f1f */
--border-light: 46 46 46; /* #2e2e2e */
--accent: 59 130 246; /* #3b82f6 */
--accent-hover: 96 165 250; /* #60a5fa */
--accent: 106 160 248; /* #6aa0f8 */
--accent-hover: 147 187 250; /* #93bbfa */
--accent-solid: 52 114 216; /* #3472d8 */
--accent-solid-hover: 44 97 184; /* #2c61b8 */
--fg-primary: 250 250 250; /* #fafafa */
--fg-secondary: 212 212 212; /* #d4d4d4 */
--fg-muted: 161 161 161; /* #a1a1a1 */
--status-success: 134 239 172; /* #86efac */
--status-danger: 255 101 104; /* #ff6568 */
--status-danger: 255 112 115; /* #ff7073 */
--status-warning: 234 179 8; /* #eab308 */
--syn-comment: 127 140 152; /* #7f8c98 */
--syn-string: 252 106 93; /* #fc6a5d */
@@ -254,11 +280,11 @@
--surface-5: 255 255 255; /* #ffffff */
--border: 209 209 214; /* #d1d1d6 */
--border-light: 229 229 234; /* #e5e5ea */
--accent: 0 102 204; /* #0066cc */
--accent-hover: 0 85 179; /* #0055b3 */
--accent: 0 92 184; /* #005cb8 */
--accent-hover: 0 76 153; /* #004c99 */
--fg-primary: 29 29 31; /* #1d1d1f */
--fg-secondary: 81 81 84; /* #515154 */
--fg-muted: 110 110 115; /* #6e6e73 */
--fg-muted: 103 103 108; /* #67676c */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 6px rgb(0 0 0 / 0.06);
--status-success: 23 107 46; /* #176b2e */
--status-danger: 179 0 14; /* #b3000e */
@@ -286,11 +312,11 @@
--surface-5: 255 255 255; /* #ffffff */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 47 106 227; /* #2f6ae3 */
--accent-hover: 39 88 192; /* #2758c0 */
--accent: 40 90 192; /* #285ac0 */
--accent-hover: 33 74 158; /* #214a9e */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
--fg-muted: 108 112 126; /* #6c707e */
--fg-muted: 103 106 120; /* #676a78 */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
@@ -318,11 +344,11 @@
--surface-5: 250 248 243; /* #faf8f3 */
--border: 203 191 166; /* #cbbfa6 */
--border-light: 220 211 192; /* #dcd3c0 */
--accent: 122 82 48; /* #7a5230 */
--accent: 119 80 47; /* #77502f */
--accent-hover: 102 63 34; /* #663f22 */
--fg-primary: 43 38 32; /* #2b2620 */
--fg-secondary: 94 85 74; /* #5e554a */
--fg-muted: 107 97 85; /* #6b6155 */
--fg-muted: 104 94 82; /* #685e52 */
--status-success: 54 93 36; /* #365d24 */
--status-danger: 147 41 31; /* #93291f */
--status-warning: 109 76 0; /* #6d4c00 */
@@ -357,7 +383,7 @@
--accent-hover: 38 38 38; /* #262626 */
--fg-primary: 10 10 10; /* #0a0a0a */
--fg-secondary: 64 64 64; /* #404040 */
--fg-muted: 115 115 115; /* #737373 */
--fg-muted: 105 105 105; /* #696969 */
--status-success: 22 101 52; /* #166534 */
--status-danger: 168 0 15; /* #a8000f */
--status-warning: 133 77 14; /* #854d0e */
@@ -436,12 +462,30 @@
@apply card transition-all duration-200 hover:border-border-light hover:bg-surface-4;
}
/* Buttons. Every variant shares one keyboard focus ring (`focus-visible`
only, so a mouse click draws none). `.btn-primary` fills with
`accent-solid`, the shade white text clears 4.5:1 on in every theme —
never `bg-accent` behind white text. */
.btn-primary {
@apply inline-flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors duration-150;
@apply inline-flex items-center gap-2 px-4 py-2 bg-accent-solid hover:bg-accent-solid-hover text-white text-sm font-medium rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-secondary {
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-fg-primary bg-surface-2 border border-border-light hover:bg-surface-4 rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-danger {
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-status-danger bg-status-danger/10 border border-status-danger/40 hover:bg-status-danger/20 rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-ghost {
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm text-fg-secondary hover:text-fg-primary hover:bg-surface-4 rounded-lg transition-colors duration-150;
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm text-fg-secondary hover:text-fg-primary hover:bg-surface-4 rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-50;
}
/* An icon-only button: a 28px target (WCAG 2.5.8 asks ≥24px). It has no
text, so give it an `aria-label` (its `title` becomes the tooltip). */
.btn-icon {
@apply inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-50;
}
.badge {
@@ -451,6 +495,41 @@
.input {
@apply bg-surface-2 border border-border rounded-lg px-3 py-2 text-sm text-fg-primary placeholder-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors;
}
/* A native <select>, themed: the browser's arrow is replaced by one drawn in
fg-muted (the native one is near-black, invisible on a dark field), and the
open list follows the mode through `color-scheme`. */
.select {
@apply input appearance-none cursor-pointer pr-8 bg-no-repeat disabled:cursor-not-allowed disabled:opacity-60;
background-position: right 0.6rem center;
background-size: 0.75rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.5l3 3 3-3'/%3E%3C/svg%3E");
}
}
/* The open list of a native <select> and the date picker follow the mode. */
:root,
[data-mode="light"] {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
/* Vestibular safety (WCAG 2.3.3): with the OS asking for reduced motion, every
animation and transition collapses to an instant change — the pulsing
status dots, `animate-ping`, fade/slide-in, spinners included (a spinner
still shows; it just stops turning). Outside any layer so it beats the
utilities. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@layer utilities {