Appearance
CasTyou Accessibility Audit — WCAG 2.1 AA
Audit ID: A11Y-AUDIT-001 Date: 2026-06-15 Scope: @castyou/design-system + apps/app (React 18 / Vite) + apps/landing (Next.js 15) Standard: WCAG 2.1 Level AA Status: Audit-only. No fixes applied. Remediation tracked under DS-A11Y-001 (design-system) and FE-A11Y-001 (app + landing).
Landing accessibility is in scope despite the Epic 15 landing-polish gate — this is legal/compliance, not polish.
All contrast ratios below were computed from the actual hex values in packages/design-system/src/tokens/theme.css and tailwind.config.ts using the WCAG relative-luminance formula (sRGB, 0.2126/0.7152/0.0722). Translucent (rgba(...) / Tailwind /15, /20, /50) tokens were alpha-composited over their opaque surface before measuring.
Severity rubric:
- P0 — Blocks a core flow for keyboard or screen-reader users, or is a clear AA failure on a primary surface (forms, nav, primary buttons).
- P1 — Real AA failure on secondary surfaces, or a degraded-but-not-blocked experience.
- P2 — Edge case, lower-traffic surface, or hardening.
1. Summary
| Severity | Count |
|---|---|
| P0 | 6 |
| P1 | 9 |
| P2 | 5 |
| Total | 20 |
Failing contrast pairs: 17 (across both themes). Worst offenders: --border-default dark (1.26:1), text-disabled dark #3d3060 (1.49:1), --border-default light (1.41:1), text-disabled light on subtle (1.42:1).
Prioritized fix list (P0/P1 headline)
| ID | Severity | One-line |
|---|---|---|
| A11Y-F01 | P0 | DS Input/Textarea/Select/MultiSelect labels not programmatically associated with their controls (1.3.1 / 4.1.2). |
| A11Y-F02 | P0 | Landing WaitlistForm inputs have no label at all — placeholder-only (1.3.1 / 3.3.2 / 4.1.2). |
| A11Y-F03 | P0 | Modal has no focus trap and no focus restore; backdrop close is mouse-only (2.1.2 / 2.4.3). |
| A11Y-F04 | P0 | Placeholder text fails contrast in both themes (2.91:1 / 2.65:1 vs 4.5:1) (1.4.3). |
| A11Y-F05 | P0 | DataTable references non-existent Tailwind tokens → table renders with invisible borders + default-colored text; also no keyboard support on clickable rows (1.4.3 / 2.1.1 / 1.3.1). |
| A11Y-F06 | P0 | No prefers-reduced-motion handling anywhere; all animations/transitions/glows always run (2.3.3). |
| A11Y-F07 | P1 | Form error messages not linked via aria-describedby and not announced; required not surfaced to AT (3.3.1 / 4.1.3). |
| A11Y-F08 | P1 | NotificationBell dropdown: toggle missing aria-expanded/aria-haspopup; panel not a focus-trapped menu (4.1.2 / 2.4.3). |
| A11Y-F09 | P1 | text-content-tertiary used in 6 components but token is undefined → text falls back to inherited/transparent color (1.4.3 / 1.3.1). |
| A11Y-F10 | P1 | Chip category colors use text-*-400/text-*-500 and fail contrast on light card (dance 2.65, music 2.54, comedy 1.92) (1.4.3). |
| A11Y-F11 | P1 | Badge/Chip default (brand) variant fails on dark card (3.50:1) (1.4.3). |
| A11Y-F12 | P1 | Status conveyed by color alone in chips/badges — no icon or text differentiator for several states (1.4.1). |
| A11Y-F13 | P1 | Focus indicator (ring-brand-500/50) fails 3:1 against light page (2.67:1) (1.4.11 / 2.4.7). |
| A11Y-F14 | P1 | secondary/outline/link Button text fails 4.5:1 (white on accent 2.26–3.56; brand-500 link on dark 1.88) (1.4.3). |
| A11Y-F15 | P1 | Mobile bottom-bar / BottomTabBar targets below 44px and no aria-current on active tab (2.5.5 / 4.1.2). |
2. Forms
A11Y-F01 — DS form labels not programmatically associated [P0]
WCAG: 1.3.1 Info & Relationships, 4.1.2 Name/Role/Value Scope: DS-A11Y-001 Confirms the known issue (E2E suite works around it by selecting on placeholder — see memory project_castyou_e2e_gotchas).
The label prop renders a bare <label> as a sibling of the control inside a flex <div>. There is no htmlFor, no generated id, and the label does not wrap the control. Screen readers announce the field as unlabeled.
packages/design-system/src/components/ui/Input.tsx:26-31tsx<div className="flex flex-col gap-1.5"> <label className="text-xs font-medium text-content-secondary">{label}</label> {input} {/* input has no id; label has no htmlFor */} </div>packages/design-system/src/components/ui/Textarea.tsx:26-31— identical pattern.packages/design-system/src/components/ui/Select.tsx:131-136— same; the Radix trigger has noid/aria-labelledbytie to the label.packages/design-system/src/components/ui/MultiSelect.tsx:99-104— same.
Downstream impact: apps/app/src/pages/auth/RegisterPage.tsx:212 & :224 hand-render <label> next to <Input> with the same non-association, so even pages that "add a label" are still broken.
Remediation: generate an id (e.g. useId()), set htmlFor/id, and for Radix Select use aria-labelledby. Then A11Y-F01's axe test (currently it.todo) can be enabled.
A11Y-F02 — Landing waitlist inputs have no label [P0]
WCAG: 1.3.1, 3.3.2 Labels or Instructions, 4.1.2 Scope: FE-A11Y-001 apps/landing/src/components/WaitlistForm.tsx:81-101 — the name, email, and notes fields pass only placeholder, no label/aria-label. Placeholders are not accessible names and vanish on input.
tsx
<Input type="text" required ... placeholder={...'Your full name'} />
<Input type="email" required ... placeholder="Your email address" />
<Textarea ... placeholder={...} />A11Y-F07 — Error messages not linked / required state not announced [P1]
WCAG: 3.3.1 Error Identification, 4.1.3 Status Messages Scope: DS-A11Y-001 (wiring) + FE-A11Y-001 (usage)
- DS
Input/Textareaexpose anerror: booleanprop (Input.tsx:5,Textarea.tsx:5) that only toggles a red border. There is no associated error text node and noaria-describedby/aria-invalid. apps/app/src/pages/auth/RegisterPage.tsx:236—{error && <p className="text-sm text-red-400 ...">...}is a detached paragraph, not linked to the input and not in a live region.apps/landing/src/components/WaitlistForm.tsx:160-162— error<p>likewise unlinked / not announced.requiredis passed as a native attribute on the landing inputs (good) but the DS label has no required indicator and the app register fields rely on JS validation with noaria-required.
3. Keyboard
A11Y-F03 — Modal: no focus trap, no focus restore, mouse-only backdrop [P0]
WCAG: 2.1.2 No Keyboard Trap (inverse — focus escapes the modal), 2.4.3 Focus Order Scope: DS-A11Y-001 packages/design-system/src/components/ui/Modal.tsx:
- Escape and body-scroll-lock are handled (
:22-39) but there is no focus trap — Tab moves into page content behind the dialog. - No initial focus is moved into the dialog and no focus restore to the trigger on close.
- The backdrop close is
onClickon a<div>(:47,:50) — not keyboard reachable (acceptable since Esc works, but the dialog itself never receives focus). ConfirmDialog.tsxand every consumer modal inherit this.
A11Y-F05 — DataTable: phantom tokens + non-keyboard rows [P0]
WCAG: 1.4.3 Contrast, 2.1.1 Keyboard, 1.3.1 Scope: DS-A11Y-001 packages/design-system/src/components/ui/DataTable.tsx:
- Uses Tailwind classes that do not exist in
tailwind.config.ts:border-border(:41,:43),bg-surface-card-alt(:43,:62),text-text-muted(:43),text-text-primary(:61). The real tokens areds-border,surface-subtle,content-secondary,content-primary. Result: borders are invisible and header/body text falls back to the inherited/default color → unverifiable, likely-failing contrast and no visible table structure. - Clickable rows (
onRowClick,:57-63) are on a<tr>withcursor-pointerbut norole,tabIndex, or key handler → not operable by keyboard. - No
aria-sort, no<caption>, noscopeon<th>. - (
FlierAIPromptInput.tsx:96also uses the phantomtext-text-muted.)
A11Y-F08 — NotificationBell dropdown semantics + focus [P1]
WCAG: 4.1.2, 2.4.3 Scope: DS-A11Y-001 packages/design-system/src/components/notifications/NotificationBell.tsx:
- Toggle button (
:75-84) has a goodaria-labelbut is missingaria-expandedandaria-haspopup. - The dropdown panel (
:94-174) is a plain<div>— norole="menu"/dialog, no focus move into it, no focus trap, no focus restore. Outside-click closes viamousedownonly (:64).
A11Y-F16 — No skip-to-content link [P2]
WCAG: 2.4.1 Bypass Blocks Scope: FE-A11Y-001 / DS-A11Y-001 packages/design-system/src/components/layout/AppShell.tsx renders <header>, <aside>, <nav>, <main> (:99-189) but there is no skip link and <main> (:148) has no id to target. Keyboard users must tab through the whole sidebar on every route.
A11Y-F17 — Modal/Drawer/Toast tab order & dropdown nesting [P2]
WCAG: 2.4.3 Scope: DS-A11Y-001 No positive tabindex found anywhere (good — MultiSelect uses tabIndex={-1} only on the honeypot is in landing, not here). However NavDrawer.tsx (:44-55) sets role="dialog"/aria-modal but, like Modal, does not trap focus; when closed it stays in the DOM with aria-hidden={!open} but its buttons remain tabbable on some browsers because visibility is via translate-x (not display:none).
4. Screen reader & semantics
A11Y-F09 — Undefined content-tertiary token [P1]
WCAG: 1.4.3, 1.3.1 Scope: DS-A11Y-001 text-content-tertiary is referenced in 6 components but content.tertiary / --text-tertiary is not defined in tokens/index.ts, theme.css, or tailwind.config.ts. Tailwind emits no rule, so the elements inherit whatever color is in scope (often content-secondary or transparent), making contrast non-deterministic and sometimes invisible. Hits: NotificationBell.tsx:130,155,157, Tabs.tsx, RangeSlider.tsx, SocialHandleInput.tsx, SuggestionCard.tsx, JobFlierCard.tsx.
A11Y-F18 — Portfolio media alt text discarded [P1]
WCAG: 1.1.1 Non-text Content Scope: DS-A11Y-001 + FE-A11Y-001
packages/design-system/src/components/ui/MediaGallery.tsx:72-76hardcodesalt=""on each thumbnail, andMediaGalleryItem(:6-12) has no title/alt field to pass through — so portfolio media (which per the Unified Portfolio model carries atitle) can never be described.packages/design-system/src/components/ui/MediaPlayer.tsx:138— posteralt=""(decorative is defensible for a video poster).PostCard.tsx:229usesalt={post.caption ?? ''}(good when caption present; empty otherwise) and:251alt="".- Avatars/cards are fine:
Avatar.tsx:56alt={alt},TalentCard.tsx:85alt={talent.name},PostCard.tsx:167authoralt.
A11Y-F19 — Toast live-region severity [P2]
WCAG: 4.1.3 Status Messages Scope: DS-A11Y-001 Toast.tsx uses Radix Toast (which provides an aria-live region), so async results announced via toast are OK. But error toasts default to politeness="foreground"/assertive only if configured; current usage relies on Radix defaults and does not raise error variant to assertive (:68-71). Lower priority because the region exists.
A11Y-F20 — Inline async results not announced (non-toast) [P1]
WCAG: 4.1.3 Scope: FE-A11Y-001 apps/landing/src/components/WaitlistForm.tsx:
- Success state (
:64-76) swaps the form for a confirmation block with norole="status"/aria-live, so SR users get silence after submit. - Error
<p>(:160-162) likewise not a live region.
Landmarks / titles (PASS, noted for completeness)
- App:
index.htmlhaslang="en"and a<title>; per-route titles handled byDocumentTitle.tsx+lib/pageTitles.ts(memoryproject_castyou_page_titles).AppShelluses<header>/<aside>/<nav>/<main>and the mobile bottom nav hasaria-label="Mobile navigation"(AppShell.tsx:156). The desktop sidebar<nav>(:126) lacks anaria-label(minor; folded into F16 scope). - Landing:
layout.tsx:99setslang="en", hard-codesclass="dark", and has full metadata. Footer uses<footer>witharia-hiddenseparators (:110-119) — good.
5. Structure & motion
A11Y-F06 — No prefers-reduced-motion support [P0]
WCAG: 2.3.3 Animation from Interactions (AAA target but AA-relevant via 2.2.2 for the auto-dismiss/animate-in patterns) Scope: DS-A11Y-001 + FE-A11Y-001 Grep for prefers-reduced-motion / motion-reduce / motion-safe across the DS and both apps returns zero matches. Affected: every animate-in fade-in slide-in-* (Modal.tsx:57, Select.tsx:102, MultiSelect.tsx:74, Toast.tsx:33), the tailwindcss-animate accordion keyframes, the multi-layer Button glow shadows (Button.tsx:30-35), and NavDrawer slide (NavDrawer.tsx:52). Neither apps/app/src/index.css nor apps/landing/src/app/globals.css contains a reduced-motion block.
A11Y-F15 — Touch targets & active-tab semantics [P1]
WCAG: 2.5.5 Target Size (AAA but tracked), 4.1.2 Scope: DS-A11Y-001
AppShell.tsx:158-168bottom-nav buttons:px-4 py-1.5around a small icon +text-[10px]label. Vertical box ≈ icon(~22px) + gap + 10px text + 2×6px padding — borderline but the hit height is under 44px for the label-only zone; same forBottomTabBar.tsx:31-49(py-1.5).- Active tab uses color only (
text-brand-400) with noaria-current="page"(AppShell.tsx:163,BottomTabBar.tsx:37,NavDrawer.tsx:75).
A11Y-F12 — Status by color alone [P1]
WCAG: 1.4.1 Use of Color Scope: DS-A11Y-001 Badge.tsx:9-21 and Chip.tsx:12-28 differentiate states (success/warning/destructive, and skill categories) primarily by hue. The text label usually carries meaning (good), but the skill-category chips (dance/acting/music/...) and the application-status usage rely on color as a primary signal with no icon. Pair with F10/F11 (those same colors also fail contrast).
A11Y-F21 — Zoom / reflow [P2]
WCAG: 1.4.10 Reflow, 1.4.4 Resize Text Scope: FE-A11Y-001 Many sizes are text-[10px] / text-[11px] fixed pixels (bottom nav, notification meta, chips sm). At 200% zoom these remain proportionally small and several fixed-position bars (AppHeader h-16, bottom nav, landing footer fixed inset-x-0 bottom-0) can occlude content. Needs a manual 200%-zoom pass during remediation.
6. Contrast token sweep
Computed from real hex. Translucent tokens composited over the stated surface. Normal text req 4.5:1; large text / UI components / focus req 3:1.
Light theme
| Pair | Surface | Measured | Required | Result |
|---|---|---|---|---|
text-primary #150d2e | card #fff | 18.60:1 | 4.5 | PASS |
text-secondary #6b5b8a | card #fff | 6.02:1 | 4.5 | PASS |
text-secondary #6b5b8a | page #ede9ff | 5.07:1 | 4.5 | PASS |
text-secondary #6b5b8a | input #e6deff | 4.66:1 | 4.5 | PASS (thin) |
text-placeholder #a090be | input #e6deff | 2.25:1 | 4.5 | FAIL |
text-placeholder #a090be | card #fff | 2.91:1 | 4.5 | FAIL |
text-label #6D10A3 | card #fff | 9.32:1 | 4.5 | PASS |
text-disabled #c5b8f0 | card #fff | 1.83:1 | 4.5 | FAIL |
text-disabled #c5b8f0 | input #e6deff | 1.42:1 | 4.5 | FAIL |
border-strong #c5b8f0 | card #fff | 1.83:1 | 3 (UI) | FAIL |
border-default #ddd4f8 | card #fff | 1.41:1 | 3 (UI) | FAIL |
focus ring brand-500/50 (composited #ad7dd1) | page #ede9ff | 2.67:1 | 3 | FAIL |
badge brand-700 #470a6b | card | 13.78:1 | 4.5 | PASS |
badge green-700 #15803d | card | 5.02:1 | 4.5 | PASS |
badge amber-700 #b45309 | card | 5.02:1 | 4.5 | PASS |
badge red-700 #b91c1c | card | 6.47:1 | 4.5 | PASS |
chip default brand-400 #9b45d4 | card | 5.01:1 | 4.5 | PASS (thin) |
chip dance pink-400 #f472b6 | card | 2.65:1 | 4.5 | FAIL |
chip comedy yellow-500 #eab308 | card | 1.92:1 | 4.5 | FAIL |
chip music blue-400 #60a5fa | card | 2.54:1 | 4.5 | FAIL |
| pagination active brand-700 | brand/20 | 9.44:1 | 4.5 | PASS |
Dark theme
| Pair | Surface | Measured | Required | Result |
|---|---|---|---|---|
text-primary #ede9ff | card #1a1533 | 14.76:1 | 4.5 | PASS |
text-secondary #9d8bb8 | card | 5.69:1 | 4.5 | PASS |
text-secondary #9d8bb8 | input #241d3d | 5.17:1 | 4.5 | PASS |
text-placeholder #6b5b8a | input #241d3d | 2.65:1 | 4.5 | FAIL |
text-placeholder #6b5b8a | card #1a1533 | 2.91:1 | 4.5 | FAIL |
text-label brand-300 #b876f5 | card | 5.79:1 | 4.5 | PASS |
text-disabled #3d3060 | card | 1.49:1 | 4.5 | FAIL |
border-strong (composited #41366b) | card | 1.64:1 | 3 (UI) | FAIL |
border-default (composited #2e264f) | card | 1.26:1 | 3 (UI) | FAIL |
badge/chip default brand-400 #9b45d4 | card | 3.50:1 | 4.5 | FAIL |
| badge success green-400 | card | 10.05:1 | 4.5 | PASS |
| badge warning amber-400 | card | 10.49:1 | 4.5 | PASS |
| badge destructive red-400 | card | 6.33:1 | 4.5 | PASS |
| chip dance/comedy/music/hiphop (-400/-500) | card | 6.6–9.1:1 | 4.5 | PASS |
| notification badge white on brand-500 | brand-500 | 9.32:1 | 4.5 | PASS |
| btn default white on brand-600 | brand-600 | 11.40:1 | 4.5 | PASS |
btn default white on violet-500 #7367F0 | violet-500 | 4.26:1 | 4.5 | FAIL (gradient start) |
btn secondary white on accent-400 #fb923c | accent-400 | 2.26:1 | 4.5 | FAIL |
btn secondary white on accent-600 #ea580c | accent-600 | 3.56:1 | 4.5 | FAIL |
| btn outline brand-500 text | page #0d0a1e | 2.09:1 | 4.5 | FAIL |
| btn link brand-500 text | card #1a1533 | 1.88:1 | 4.5 | FAIL |
Failing pairs: 17. Notes:
- A11Y-F04 (placeholder) and disabled-text failures occur in BOTH themes.
- Button gradient: the start stop of
default(violet-500, 4.26:1) and the wholesecondary/accentgradient fail — white text on the lighter half of these gradients is sub-4.5:1. outline/linkbrand-500 text is unreadable on dark surfaces (the dark theme uses brand-300#b876f5for label text precisely because brand-500 is too dark — but Button still hardcodes brand-500).
7. Remediation routing
| Bucket | Findings |
|---|---|
| DS-A11Y-001 (design-system) | F01, F03, F04, F05, F06(partial), F07(wiring), F08, F09, F10, F11, F12, F13, F14, F15, F16(partial), F17, F18(component), F19 + all contrast token edits |
| FE-A11Y-001 (app + landing) | F02, F06(CSS media query), F07(usage), F16(skip link markup), F18(pass titles), F20, F21 |
8. Test artifact
packages/design-system/src/__tests__/a11y.test.tsx renders each exported component in default/error/disabled states and asserts no jest-axe violations. Known-failing components (F01 unassociated labels, F05 DataTable) are written as it.todo/it.skip with a comment linking the finding, so the suite is green now and the failures are tracked for DS-A11Y-001. See the file header for the runner/config notes.
Remediation — P0 closed (2026-06-15)
Scope: design-system P0s only (DS-A11Y-001). Landing F02 and all P1/P2 items remain open.
| Finding | Fix | File(s) changed |
|---|---|---|
| A11Y-F01 — Unassociated labels | useId() (respects caller id); htmlFor+id on Input/Textarea; aria-labelledby+id on the Radix Select/MultiSelect triggers. Added additive aria-invalid/aria-describedby + optional helperText on Input/Textarea. placeholder props left intact (E2E by-placeholder selection preserved). | src/components/ui/Input.tsx, src/components/ui/Textarea.tsx, src/components/ui/Select.tsx, src/components/ui/MultiSelect.tsx |
| A11Y-F03 — Modal focus trap | Added focus trap (Tab/Shift+Tab cycle within the dialog), initial focus into the panel, focus restore to the previously-focused element on close, Esc close (already present), and unique aria-labelledby/aria-describedby tied to the title/description. role="dialog"/aria-modal="true" retained. NavDrawer shares no primitive with Modal — left for the P1 pass per F17. | src/components/ui/Modal.tsx |
| A11Y-F04 — Placeholder contrast | Shifted --text-placeholder lightness only (brand hue kept). Light #a090be → #6d5c8b: card #fff 5.91:1, input #e6deff 4.57:1. Dark #6b5b8a → #9183ad: card #1a1533 5.05:1, input #241d3d 4.59:1. All ≥ 4.5:1 (recomputed via WCAG sRGB luminance). | src/tokens/theme.css |
| A11Y-F05 — DataTable phantom tokens + keyboard | Replaced non-existent tokens with real ones (border-border→border-ds-border, bg-surface-card-alt→bg-surface-subtle, text-text-muted→text-content-secondary, text-text-primary→text-content-primary, divide-border→divide-ds-border). Clickable rows now role="button" + tabIndex=0 + Enter/Space handler + focus ring. Added scope="col" on <th> and an optional visually-hidden <caption>. | src/components/ui/DataTable.tsx |
| A11Y-F06 — prefers-reduced-motion | Added a global @media (prefers-reduced-motion: reduce) block to the DS global stylesheet that neutralizes animations/transitions/smooth-scroll (0.01ms override so transitionend/animationend still fire). Covers tailwindcss-animate keyframes, Modal/Select/MultiSelect/Toast animate-in, Button glow, accordion. | src/tokens/theme.css |
Tests: src/__tests__/a11y.test.tsx — flipped the F01 it.todos (Input/Textarea/Select/MultiSelect) and the F05 it.todo (keyboard-operable rows + scope) to real toHaveNoViolations()/attribute assertions, and added an F03 Modal dialog-semantics test. F07 left as it.todo (P1). Suite: 23 passed, 1 todo (pnpm --filter @castyou/design-system test -- --run). tsc --noEmit passes clean.
Remediation — P0 closed (2026-06-15): A11Y-F02 — WaitlistForm labels associated (apps/landing/src/components/WaitlistForm.tsx)
Remediation — P1/P2 (2026-06-15)
Scope: design-system P1/P2 only (DS-A11Y-001). App/landing-scoped findings (F02 done; F20/F21, F16 skip-link markup, F18 usage) remain with FE-A11Y-001. All ratios recomputed from the real hex via the WCAG sRGB luminance formula; translucent fills alpha-composited over their surface before measuring. Only lightness was shifted — brand hues preserved.
| Finding | Sev | Fix (file) | New contrast |
|---|---|---|---|
| A11Y-F07 — error not aria-linked / required not announced | P1 | Extended the P0 plumbing to Select + MultiSelect: added error/helperText/required props, aria-invalid, aria-describedby→helper node, and red border. aria-required on Select (combobox); omitted on MultiSelect (menu trigger → aria-allowed-attr) — required surfaced via label *. Added required * indicator to Input/Textarea labels. Error helper text token shifted to AA (see below). (Select.tsx, MultiSelect.tsx, Input.tsx, Textarea.tsx) | error helper text-red-600 dark:text-red-500: light card 4.83 / dark card 4.65 (was red-500 3.76 light) |
A11Y-F09 — undefined content-tertiary token | P1 | Defined --text-tertiary in both themes + registered content.tertiary in tailwind. (tokens/theme.css, tailwind.config.ts) | light #6d5c8b: card 5.91 / page 4.98 / subtle 4.57. dark #9183ad: card 5.05 / page 5.61 / subtle 4.59 (all ≥4.5) |
| A11Y-F10 — Chip category contrast | P1 | Category text → -700/-800 on light, -400 on dark (on the /15 tinted bg). (Chip.tsx) | light min cyan 4.67, max amber 6.31; dark min purple 5.55 — all ≥4.5 both themes |
A11Y-F11 — Chip/Badge brand default on dark | P1 | Chip default → text-brand-700 dark:text-brand-300; Badge default dark brand-400→brand-300. (Chip.tsx, Badge.tsx) | chip default on /15 bg: light 10.41 / dark 5.49 (was 3.50) |
| A11Y-F12 — status by color alone | P1 | Badge semantic variants (success/warning/destructive/default) now render a default decorative (aria-hidden) status glyph; both accept an optional icon. Chip gained an optional icon prop. Label text retained as the name. (Badge.tsx, Chip.tsx) | n/a (non-color signal added) |
| A11Y-F13 — focus ring < 3:1 | P1 | New theme-aware --focus-ring token (ds-focus); replaced all 6 ring-brand-500/50 usages + Badge/Button focus rings. (tokens/theme.css, tailwind.config.ts, Input/Textarea/Select/MultiSelect/SearchBar/DataTable/Button/Badge) | light brand-500: page 7.86 / card 9.32 / subtle 7.22. dark brand-300: page 6.44 / card 5.79 / subtle 5.27 (was 2.67) |
| A11Y-F14 — Button secondary/outline/link contrast | P1 | default gradient start violet-500→violet-600 (+hover violet-700); secondary accent-400→600→accent-700→800 (+hover stays ≥4.5); outline/link text brand-500→text-brand-700 dark:text-brand-300. (Button.tsx) | default white 5.81–11.4; secondary white 5.18–7.31; outline/link light 12.6/13.78, dark 5.69/5.79 — all resting+hover ≥4.5 |
| A11Y-F18 — MediaGallery alt discarded | P1 (DS part) | Added optional title to MediaGalleryItem; thumbnail buttons (icon-only) now derive an aria-label from it. <img alt=""> kept intentional to avoid double-announce. (MediaGallery.tsx) | n/a (named control) |
| A11Y-F16 — skip-to-content | P2 (DS part) | Added id="main" to AppShell <main> as the skip-link target (link markup is FE-A11Y-001). (AppShell.tsx) | n/a |
| A11Y-F19 — toast live-region severity | P2 | error/warning toasts now type="foreground" (Radix → aria-live="assertive"); success/info stay polite. (Toast.tsx) | n/a |
Deferred (noted, not implemented): A11Y-F08 (NotificationBell dropdown semantics/focus-trap) and A11Y-F17 (NavDrawer focus trap) — both are non-trivial focus-management changes parallel to the F03 Modal work and are better done together with a shared focus-trap util in a follow-up; left tracked under DS-A11Y-001. App/landing items (F20, F21, F16 link markup, F18 usage) belong to FE-A11Y-001.
New shared components (FE-SEC-001, exported from src/index.ts):
ExternalLink(src/components/ui/ExternalLink.tsx) — props{ href, newTabHint?, ...anchor };http(s):-only scheme allowlist via exportedsanitizeExternalHref(neutralizesjavascript:/data:/control-char-smuggled/relative/protocol-relative-to-non-host), alwaysrel="noopener noreferrer"+target="_blank", visibleds-focusring, discernible name (falls back to href) + SR "(opens in a new tab)" hint. Unsafe href → inert<span>.PasswordStrengthMeter(src/components/ui/PasswordStrengthMeter.tsx) — props{ password, showLabel? }; dependency-free 0–4 heuristic via exportedscorePassword(length + char-class diversity), 4-segment bar + text label announced viarole="status"aria-live="polite"(not color-only).
Tests: src/__tests__/a11y.test.tsx — flipped the F07 it.todo to four real assertions (Input/Textarea/Select/MultiSelect aria-invalid + aria-describedby + required), and added smoke tests for F12 (Badge/Chip icons), F18 (MediaGallery named buttons), ExternalLink (https hardening + javascript: neutralization + sanitizeExternalHref table), and PasswordStrengthMeter (aria-live + scorePassword). Suite: 34 passed, 0 todo (pnpm --filter @castyou/design-system test -- --run). tsc --noEmit passes clean.
Remediation — P1/P2 (2026-06-15): FE-A11Y-001 (app + landing) + two deferred DS focus traps
Scope: the FE-A11Y app/landing findings + the two DS focus-trap items (A11Y-F08 NotificationBell, A11Y-F17 NavDrawer) that DS-A11Y-001's P1 pass explicitly deferred. pnpm typecheck (all 3 packages) + pnpm --filter @castyou/app lint pass; DS suite 39/39, app suite 555/555.
A11Y-F08 — NotificationBell dropdown semantics + focus (CLOSED)
packages/design-system/src/components/notifications/NotificationBell.tsx — toggle now exposes aria-expanded + aria-haspopup="dialog"; the panel is role="dialog" + aria-label, focus moves into it on open, Tab/Shift+Tab are trapped, and focus is restored to the bell trigger on close (reuses the Modal focus-trap pattern).
A11Y-F17 — NavDrawer focus trap + restore (CLOSED)
packages/design-system/src/components/layout/NavDrawer.tsx — adds the same focus trap + restore + Escape; the panel takes an optional id (wired to the hamburger's aria-controls), nav buttons get aria-current="page" on the active item, and the closed drawer is inert so its off-screen (translate-x) buttons are removed from the tab order — the exact F17 gap. The AppShell hamburger now sets aria-expanded/aria-haspopup="dialog"/aria-controls="app-nav-drawer".
A11Y-F15 — bottom-nav touch targets + active-tab semantics (CLOSED)
packages/design-system/src/components/layout/AppShell.tsx — bottom-nav buttons (and the hamburger) now carry min-h-[44px] min-w-[44px] (WCAG 2.5.5 floor) and aria-current="page" on the active tab. aria-current="page" was also added to the desktop sidebar buttons (+ an aria-label="Primary navigation" on the sidebar <nav>).
A11Y-F16 — skip-to-content link (CLOSED)
AppShell.tsx — added a visually-hidden-until-focused <a href="#main">Skip to content</a> as the first focusable element, targeting the id="main" the DS P1 pass already put on <main>.
Live-region announcements (FE part of F20 / 4.1.3)
- Landing
WaitlistFormsuccess block (apps/landing/src/components/WaitlistForm.tsx) is nowrole="status"aria-live="polite"— SR users hear the confirmation after submit instead of silence (the error<p role="alert">and labels/aria-required/aria-describedbywere already done in the F02 pass). - App nav unread badges (
apps/app/src/components/AppShell.tsxChatIcon/QuestionIcon) are nowrole="status"aria-live="polite"with the numeric glypharia-hiddenand the count voiced via an sr-only, pluralized i18n string (nav.unreadMessages/nav.openTickets, added to en/pt/es). - "Application submitted" and most async mutation results already announce via the DS Toast (Radix
aria-liveregion; error/warning raised to assertive in the DS F19 pass) — no per-page change needed.
Landing structural a11y (compliance, not redesign)
apps/landing/src/app/page.tsx — the landing root <div> became <main> so the page has a primary landmark (privacy/terms already use <main>/<article>/<section>; CastyouLogo carries its own alt). No visual/redesign change.
Per-route titles (verified complete — no change)
Cross-checked every <Route> in App.tsx against lib/pageTitles.ts: the only paths without a registry entry (/, /admin/users, /jobs/manage, /jobs/:id/flier/canvas) are all <Navigate> redirects to routes that DO have titles — the user never lands on them. No hand-rolled document.title was added.
Alt text (feed/portfolio)
Handled at the DS level (DS P1 pass: MediaGallery title→aria-label, PostCard caption alt). The app renders media through those DS components, so no per-usage app change was required.
Tests: packages/design-system/src/__tests__/a11y.test.tsx — added real assertions for NotificationBell (aria-expanded/aria-haspopup, dialog + focus-in) and NavDrawer (modal dialog + focus trap + aria-current + Escape + inert-when-closed), replacing the F08/F17 deferral. Suite 39 passed.
Deferred (noted): A11Y-F21 (200%-zoom reflow / fixed text-[10px] sizes) needs a manual zoom pass and spans many surfaces — not a surgical code change; left for a dedicated pass.