# Cowork UI Kit — Accessibility Rules

> Target: WCAG 2.1 AA. Mọi component phải pass 4 nguyên tắc POUR: **P**erceivable · **O**perable · **U**nderstandable · **R**obust.
>
> Cross-reference: [[feedback_ui_visual_proof_required]] (UI task BẮT BUỘC visual proof) · [[checklist-mobile.md]] § A11Y

---

## 1. Color contrast

| Element | Min ratio | Token check |
|---|---|---|
| Body text trên surface | 4.5:1 | `--text` on `--surface` |
| Large text 18pt+ bold (hoặc 24px regular) | 3:1 | display tokens |
| UI component (button border, input border) | 3:1 | `--primary` on `--bg` |
| Focus indicator | 3:1 vs adjacent | `--primary` on `--bg` |
| Disabled element | No requirement | (visual only, vẫn cần đọc được context) |

**Tool verify:**
- Chrome DevTools → Inspect → Accessibility tab → Contrast ratio
- 🌐 https://webaim.org/resources/contrastchecker/
- 🌐 https://www.tpgi.com/color-contrast-checker/ (APCA + WCAG2)

**Common bug (đã xảy ra 2026-05-20):** Body thiếu class `aesthetic-warm` hoặc `aesthetic-dark` → `--text` undefined → fallback `canvastext` (đen mặc định OS) → render đen trên dark bezel = invisible.

**Fix BẮT BUỘC:**
```html
<body class="aesthetic-warm">  <!-- hoặc aesthetic-dark -->
```

---

## 2. Keyboard navigation

**Rule cứng:**
- Mọi interactive element (button, link, input, custom widget) phải focusable qua Tab
- Tab order theo visual flow (top-to-bottom, left-to-right trong LTR)
- KHÔNG dùng `tabindex` > 0 (gây confused order)
- `tabindex="0"` cho custom interactive elements (vd `<div role="button">`)
- `tabindex="-1"` cho elements focusable qua JS only (vd modal escape catch target)

**Keyboard shortcuts chuẩn:**

| Phím | Hành động |
|---|---|
| `Tab` / `Shift+Tab` | Navigate forward / backward |
| `Enter` | Activate button / link / submit form |
| `Space` | Toggle checkbox / switch / expand accordion / activate button |
| `Esc` | Close modal / drawer / popover / tooltip |
| `Arrow keys` | Navigate list / menu / tabs / radio group / slider |
| `Home` / `End` | First / last item trong list hoặc menu |
| `Page Up` / `Page Down` | Scroll lớn trong long list |

---

## 3. Focus management

**Rule cứng:**
- `:focus-visible` always visible (outline 2px + offset 2px tinted primary)
- KHÔNG `outline: none` without replacement (vi phạm WCAG 2.4.7)
- Modal / drawer open → trap focus inside, return focus to trigger on close
- Skip-to-content link cho keyboard users (first focusable element, visually-hidden until focus)

**Implementation chuẩn Cowork:**

```css
.focusable:focus-visible,
button:focus-visible,
a:focus-visible,
input:focus-visible,
[role="button"]:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
  border-radius: var(--radius-md);
}

/* Skip-to-content */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: var(--primary);
  color: var(--on-primary);
  padding: 8px 16px;
  z-index: 100;
}
.skip-link:focus {
  top: 0;
}
```

---

## 4. Touch targets

**Rule cứng:**
- Min **44×44 CSS px** (Apple HIG) / 48×48 dp (Material) — Cowork dùng 44px qua `--touch-min`
- Spacing giữa touch targets ≥ 8px (accidental tap prevention)
- Icon-only button cần `aria-label` mô tả action
- Mobile breakpoint (375px) BẮT BUỘC, desktop có thể nhỏ hơn (32×32) nhưng nên giữ ≥40px cho consistency

**Token:**
```css
:root {
  --touch-min: 44px;
  --touch-spacing-min: 8px;
}

.btn-icon {
  min-width: var(--touch-min);
  min-height: var(--touch-min);
}
```

---

## 5. Screen reader support

**ARIA roles cần thiết per component:**

| Component | Required ARIA |
|---|---|
| Modal | `role="dialog"` + `aria-modal="true"` + `aria-labelledby` (point to title) |
| Drawer | `role="dialog"` + `aria-label` hoặc `aria-labelledby` |
| Toast (info) | `role="status"` + `aria-live="polite"` |
| Toast (urgent / error) | `role="alert"` + `aria-live="assertive"` |
| Tabs | container `role="tablist"` + each tab `role="tab"` + `aria-selected` + panel `role="tabpanel"` |
| Accordion | trigger `aria-expanded` + `aria-controls` pointing to panel ID |
| Tooltip | `role="tooltip"` + trigger có `aria-describedby` pointing to tooltip ID |
| Loading spinner | `role="status"` + `aria-live="polite"` + visually-hidden text "Đang tải" |
| Icon button | `aria-label` mô tả action (vd `aria-label="Đóng modal"`) |
| Breadcrumb | `<nav aria-label="Breadcrumb">` + last item `aria-current="page"` |
| Switch | `role="switch"` + `aria-checked` |
| Progress bar | `role="progressbar"` + `aria-valuenow` + `aria-valuemin` + `aria-valuemax` |

**Nguyên tắc:** ARIA chỉ dùng khi HTML native không đủ. Ưu tiên `<button>` over `<div role="button">`, `<a href>` over `<div role="link">`.

---

## 6. Form a11y

**Rule cứng:**
- Mọi input có `<label>` visible HOẶC `aria-label` (icon-only / search input)
- Required field: `aria-required="true"` + visual asterisk
- Error state: `aria-invalid="true"` + `aria-describedby` pointing to error message ID
- Helper text via `aria-describedby`
- Radio group: `<fieldset>` + `<legend>` (legend là label cho cả group)
- Submit button pressable via `Enter` key trong bất kỳ input nào của form
- Auto-focus first invalid field on submit failure

**Example chuẩn:**

```html
<div class="field">
  <label for="email">
    Email <span aria-hidden="true">*</span>
    <span class="sr-only">(bắt buộc)</span>
  </label>
  <input
    id="email"
    type="email"
    required
    aria-required="true"
    aria-invalid="false"
    aria-describedby="email-helper email-error"
  >
  <div id="email-helper" class="helper">Em sẽ không spam anh</div>
  <div id="email-error" class="error" role="alert" hidden>
    Email không hợp lệ
  </div>
</div>
```

Khi error xuất hiện: toggle `aria-invalid="true"` + unhide error div → SR đọc ngay vì `role="alert"`.

---

## 7. Image & media

| Trường hợp | Cách markup |
|---|---|
| Image meaningful (content) | `<img alt="Mô tả ngắn nội dung ảnh">` |
| Image decorative | `<img alt="">` (alt rỗng, KHÔNG xoá attribute) |
| Background image decorative | KHÔNG cần markup ARIA |
| Background image meaningful | `<div role="img" aria-label="...">` |
| Logo lặp nhiều lần | First: full alt. Subsequent: `alt=""` (tránh repetition) |
| Icon trong button có text | `<svg aria-hidden="true">` (text đã đủ context) |
| Icon-only button | `<button aria-label="..."><svg aria-hidden="true">` |
| Video | `<track kind="captions">` + transcript link bên cạnh |
| Audio | Transcript bắt buộc |
| Auto-play | DISABLED mặc định (a11y + UX + bandwidth) |

---

## 8. Motion / animation

**Rule cứng — respect user preference:**

```css
@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;
  }
}
```

**Bổ sung:**
- Auto-play animation max 5 giây HOẶC user-controllable (pause button)
- Parallax / heavy motion / 3D scroll effects → opt-in toggle
- Flashing content max 3 flashes/giây (tránh seizure trigger — WCAG 2.3.1)
- Hover-only effects KHÔNG được carry critical info (touch device không có hover)

---

## 9. Language & content

- `<html lang="vi">` (hoặc locale phù hợp: `en`, `vi-VN`)
- `dir="rtl"` nếu cần (Tiếng Việt là LTR — default)
- Heading hierarchy đúng: `h1 → h2 → h3`, KHÔNG skip level (`h1 → h3` = sai)
- **1 `<h1>` per page** (thường là page title)
- Page có `<title>` unique describing content
- Link text descriptive ("Xem báo cáo tháng 5" thay vì "Click here")
- Button label theo action verb ("Lưu thay đổi" thay vì "OK")

---

## 10. Reading order

**Rule cứng:**
- DOM order = visual order (avoid CSS `order:` / `flex-direction: row-reverse` breaking SR sequence)
- Hidden content cách dùng đúng:

| Cách | Hiệu ứng | Khi nào dùng |
|---|---|---|
| `display: none` | Truly hidden (DOM removed visually + SR) | Element không render gì cả |
| `visibility: hidden` | Reserve space, hidden both visual + SR | Cần giữ layout space |
| `aria-hidden="true"` | Visually visible NHƯNG skipped by SR | Decorative icon, duplicate content |
| `.sr-only` (visually-hidden) | Hidden visually NHƯNG SR đọc được | Label cho icon-only button, instructions |
| `hidden` HTML attribute | Equivalent display:none | Semantic hide |

**Class `.sr-only` chuẩn (Cowork tokens):**

```css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Focusable variant — hiện ra khi focus (skip-link pattern) */
.sr-only-focusable:focus,
.sr-only-focusable:active {
  position: static;
  width: auto;
  height: auto;
  clip: auto;
  overflow: visible;
  white-space: normal;
}
```

---

## 11. Per-component a11y checklist

| Component | ARIA | Keyboard | Focus | Note |
|---|---|---|---|---|
| Button | — (native) | Enter / Space | visible ring | `<button>` not `<div>` |
| Link | — (native) | Enter | visible ring | Descriptive text |
| Input | `<label for>` | Tab | visible ring | + aria-describedby cho helper |
| Textarea | `<label for>` | Tab | visible ring | Auto-resize OK, không trap Tab |
| Select | `<label for>` | Tab + Arrow + Enter | visible ring | Native `<select>` ưu tiên |
| Checkbox | `<label>` wrap | Tab + Space | visible ring | `aria-checked` nếu custom |
| Radio | `<fieldset><legend>` | Tab + Arrow | visible ring | Arrow trong group |
| Switch | `role="switch"` + aria-checked | Space | visible ring | Khác checkbox: binary state |
| Modal | `role="dialog"` + aria-modal + aria-labelledby | Esc close, Tab trap | trap inside, return on close | Body scroll lock |
| Drawer | `role="dialog"` + aria-label | Esc close, Tab trap | trap inside | Same as modal |
| Tabs | `role="tablist"` + tab + tabpanel + aria-selected | Tab to tablist, Arrow within | within active tab | Home/End for first/last |
| Accordion | `aria-expanded` + `aria-controls` | Space / Enter | header keeps | Multi-expand OK |
| Dropdown / Menu | `aria-expanded` + role="menu" + role="menuitem" | Arrow + Esc + Enter | first item on open | Esc close + return focus |
| Tooltip | `role="tooltip"` + `aria-describedby` from trigger | Esc dismiss | trigger keeps focus | Show on focus AND hover |
| Toast (info) | `role="status"` + aria-live="polite" | — | — | Đừng steal focus |
| Toast (error) | `role="alert"` + aria-live="assertive" | — | — | Interrupt SR speech |
| Loading spinner | `role="status"` + aria-live="polite" | — | — | + sr-only "Đang tải" |
| Breadcrumb | `<nav aria-label="Breadcrumb">` + `aria-current="page"` | Tab | visible ring | Last item not link |
| Progress bar | `role="progressbar"` + aria-valuenow/min/max | — | — | + aria-label |
| Pagination | `<nav aria-label="Pagination">` + `aria-current="page"` | Tab | visible ring | — |

---

## 12. Testing checklist

Trước khi báo done UI task, manual run-through:

```
[ ] Tab navigate tất cả interactive elements theo visual order
[ ] Shift+Tab navigate ngược lại đúng order
[ ] Esc đóng modal / drawer / popover / tooltip
[ ] Enter activate button / submit form
[ ] Space toggle checkbox / switch
[ ] Arrow keys navigate trong list / menu / tabs / radio group
[ ] Color contrast pass DevTools Accessibility tab (4.5:1 body, 3:1 large)
[ ] Image alts present (rỗng cho decorative, có meaningful description nếu content)
[ ] Heading hierarchy linear, không h1→h3 skip
[ ] Form labels associated với input qua for/id hoặc wrap
[ ] Error messages announced (aria-describedby + role="alert" khi xuất hiện)
[ ] Focus visible always (KHÔNG outline:none without replacement)
[ ] prefers-reduced-motion respected
[ ] Touch targets ≥ 44×44 trên mobile (375px breakpoint test)
[ ] Screen reader test (VoiceOver iOS hoặc NVDA Windows) trên critical flows
[ ] Zoom 200% browser → layout không break, không horizontal scroll
[ ] Keyboard-only test: tay không touch trackpad/mouse trong 60 giây
```

---

## 13. Tools

| Tool | Mục đích | Link |
|---|---|---|
| Chrome DevTools Lighthouse | Accessibility audit tự động | DevTools → Lighthouse tab |
| axe DevTools extension | Deep a11y scan + ARIA validation | 🌐 https://www.deque.com/axe/devtools/ |
| WebAIM Contrast Checker | Color contrast ratio | 🌐 https://webaim.org/resources/contrastchecker/ |
| WAVE | Visual a11y overlay trong browser | 🌐 https://wave.webaim.org/ |
| VoiceOver | SR test Mac / iOS | Cmd+F5 (Mac), Settings → Accessibility (iOS) |
| NVDA | SR test Windows | 🌐 https://www.nvaccess.org/ |
| Keyboard-only test | Manual, không tool | Unplug mouse 60s |
| Polypane | Multi-breakpoint + a11y emulator | 🌐 https://polypane.app/ |

---

## 14. Reference

- 🌐 [WCAG 2.1 AA Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/?versions=2.1&levels=aa)
- 🌐 [ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/) — patterns cho mọi widget
- 🌐 [WebAIM Articles](https://webaim.org/articles/) — practical guides
- 🌐 [Inclusive Components](https://inclusive-components.design/) — Heydon Pickering deep dives

---

> **Enforcement:** Agent `ui-visual-verifier` (user-level) auto-spawn sau UI edit sẽ scan rules 1, 2, 3, 5, 8, 9 qua axe-core. Rules còn lại cần manual checklist § 12.
