Skip to content

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

SeverityCount
P06
P19
P25
Total20

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)

IDSeverityOne-line
A11Y-F01P0DS Input/Textarea/Select/MultiSelect labels not programmatically associated with their controls (1.3.1 / 4.1.2).
A11Y-F02P0Landing WaitlistForm inputs have no label at all — placeholder-only (1.3.1 / 3.3.2 / 4.1.2).
A11Y-F03P0Modal has no focus trap and no focus restore; backdrop close is mouse-only (2.1.2 / 2.4.3).
A11Y-F04P0Placeholder text fails contrast in both themes (2.91:1 / 2.65:1 vs 4.5:1) (1.4.3).
A11Y-F05P0DataTable 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-F06P0No prefers-reduced-motion handling anywhere; all animations/transitions/glows always run (2.3.3).
A11Y-F07P1Form error messages not linked via aria-describedby and not announced; required not surfaced to AT (3.3.1 / 4.1.3).
A11Y-F08P1NotificationBell dropdown: toggle missing aria-expanded/aria-haspopup; panel not a focus-trapped menu (4.1.2 / 2.4.3).
A11Y-F09P1text-content-tertiary used in 6 components but token is undefined → text falls back to inherited/transparent color (1.4.3 / 1.3.1).
A11Y-F10P1Chip 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-F11P1Badge/Chip default (brand) variant fails on dark card (3.50:1) (1.4.3).
A11Y-F12P1Status conveyed by color alone in chips/badges — no icon or text differentiator for several states (1.4.1).
A11Y-F13P1Focus indicator (ring-brand-500/50) fails 3:1 against light page (2.67:1) (1.4.11 / 2.4.7).
A11Y-F14P1secondary/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-F15P1Mobile 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-31
    tsx
    <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 no id/aria-labelledby tie 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/Textarea expose an error: boolean prop (Input.tsx:5, Textarea.tsx:5) that only toggles a red border. There is no associated error text node and no aria-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.
  • required is 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 no aria-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 onClick on a <div> (:47, :50) — not keyboard reachable (acceptable since Esc works, but the dialog itself never receives focus).
  • ConfirmDialog.tsx and 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 are ds-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> with cursor-pointer but no role, tabIndex, or key handler → not operable by keyboard.
  • No aria-sort, no <caption>, no scope on <th>.
  • (FlierAIPromptInput.tsx:96 also uses the phantom text-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 good aria-label but is missing aria-expanded and aria-haspopup.
  • The dropdown panel (:94-174) is a plain <div> — no role="menu"/dialog, no focus move into it, no focus trap, no focus restore. Outside-click closes via mousedown only (:64).

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-76 hardcodes alt="" on each thumbnail, and MediaGalleryItem (:6-12) has no title/alt field to pass through — so portfolio media (which per the Unified Portfolio model carries a title) can never be described.
  • packages/design-system/src/components/ui/MediaPlayer.tsx:138 — poster alt="" (decorative is defensible for a video poster).
  • PostCard.tsx:229 uses alt={post.caption ?? ''} (good when caption present; empty otherwise) and :251 alt="".
  • Avatars/cards are fine: Avatar.tsx:56 alt={alt}, TalentCard.tsx:85 alt={talent.name}, PostCard.tsx:167 author alt.

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 no role="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.html has lang="en" and a <title>; per-route titles handled by DocumentTitle.tsx + lib/pageTitles.ts (memory project_castyou_page_titles). AppShell uses <header>/<aside>/<nav>/<main> and the mobile bottom nav has aria-label="Mobile navigation" (AppShell.tsx:156). The desktop sidebar <nav> (:126) lacks an aria-label (minor; folded into F16 scope).
  • Landing: layout.tsx:99 sets lang="en", hard-codes class="dark", and has full metadata. Footer uses <footer> with aria-hidden separators (: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-168 bottom-nav buttons: px-4 py-1.5 around 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 for BottomTabBar.tsx:31-49 (py-1.5).
  • Active tab uses color only (text-brand-400) with no aria-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

PairSurfaceMeasuredRequiredResult
text-primary #150d2ecard #fff18.60:14.5PASS
text-secondary #6b5b8acard #fff6.02:14.5PASS
text-secondary #6b5b8apage #ede9ff5.07:14.5PASS
text-secondary #6b5b8ainput #e6deff4.66:14.5PASS (thin)
text-placeholder #a090beinput #e6deff2.25:14.5FAIL
text-placeholder #a090becard #fff2.91:14.5FAIL
text-label #6D10A3card #fff9.32:14.5PASS
text-disabled #c5b8f0card #fff1.83:14.5FAIL
text-disabled #c5b8f0input #e6deff1.42:14.5FAIL
border-strong #c5b8f0card #fff1.83:13 (UI)FAIL
border-default #ddd4f8card #fff1.41:13 (UI)FAIL
focus ring brand-500/50 (composited #ad7dd1)page #ede9ff2.67:13FAIL
badge brand-700 #470a6bcard13.78:14.5PASS
badge green-700 #15803dcard5.02:14.5PASS
badge amber-700 #b45309card5.02:14.5PASS
badge red-700 #b91c1ccard6.47:14.5PASS
chip default brand-400 #9b45d4card5.01:14.5PASS (thin)
chip dance pink-400 #f472b6card2.65:14.5FAIL
chip comedy yellow-500 #eab308card1.92:14.5FAIL
chip music blue-400 #60a5facard2.54:14.5FAIL
pagination active brand-700brand/209.44:14.5PASS

Dark theme

PairSurfaceMeasuredRequiredResult
text-primary #ede9ffcard #1a153314.76:14.5PASS
text-secondary #9d8bb8card5.69:14.5PASS
text-secondary #9d8bb8input #241d3d5.17:14.5PASS
text-placeholder #6b5b8ainput #241d3d2.65:14.5FAIL
text-placeholder #6b5b8acard #1a15332.91:14.5FAIL
text-label brand-300 #b876f5card5.79:14.5PASS
text-disabled #3d3060card1.49:14.5FAIL
border-strong (composited #41366b)card1.64:13 (UI)FAIL
border-default (composited #2e264f)card1.26:13 (UI)FAIL
badge/chip default brand-400 #9b45d4card3.50:14.5FAIL
badge success green-400card10.05:14.5PASS
badge warning amber-400card10.49:14.5PASS
badge destructive red-400card6.33:14.5PASS
chip dance/comedy/music/hiphop (-400/-500)card6.6–9.1:14.5PASS
notification badge white on brand-500brand-5009.32:14.5PASS
btn default white on brand-600brand-60011.40:14.5PASS
btn default white on violet-500 #7367F0violet-5004.26:14.5FAIL (gradient start)
btn secondary white on accent-400 #fb923caccent-4002.26:14.5FAIL
btn secondary white on accent-600 #ea580caccent-6003.56:14.5FAIL
btn outline brand-500 textpage #0d0a1e2.09:14.5FAIL
btn link brand-500 textcard #1a15331.88:14.5FAIL

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 whole secondary/accent gradient fail — white text on the lighter half of these gradients is sub-4.5:1.
  • outline/link brand-500 text is unreadable on dark surfaces (the dark theme uses brand-300 #b876f5 for label text precisely because brand-500 is too dark — but Button still hardcodes brand-500).

7. Remediation routing

BucketFindings
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.

FindingFixFile(s) changed
A11Y-F01 — Unassociated labelsuseId() (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 trapAdded 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 contrastShifted --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 + keyboardReplaced 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-motionAdded 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.

FindingSevFix (file)New contrast
A11Y-F07 — error not aria-linked / required not announcedP1Extended 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 tokenP1Defined --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 contrastP1Category 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 darkP1Chip default → text-brand-700 dark:text-brand-300; Badge default dark brand-400brand-300. (Chip.tsx, Badge.tsx)chip default on /15 bg: light 10.41 / dark 5.49 (was 3.50)
A11Y-F12 — status by color aloneP1Badge 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:1P1New 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 contrastP1default gradient start violet-500violet-600 (+hover violet-700); secondary accent-400→600accent-700→800 (+hover stays ≥4.5); outline/link text brand-500text-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 discardedP1 (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-contentP2 (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 severityP2error/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 exported sanitizeExternalHref (neutralizes javascript:/data:/control-char-smuggled/relative/protocol-relative-to-non-host), always rel="noopener noreferrer" + target="_blank", visible ds-focus ring, 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 exported scorePassword (length + char-class diversity), 4-segment bar + text label announced via role="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>).

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 WaitlistForm success block (apps/landing/src/components/WaitlistForm.tsx) is now role="status" aria-live="polite" — SR users hear the confirmation after submit instead of silence (the error <p role="alert"> and labels/aria-required/aria-describedby were already done in the F02 pass).
  • App nav unread badges (apps/app/src/components/AppShell.tsx ChatIcon/QuestionIcon) are now role="status" aria-live="polite" with the numeric glyph aria-hidden and 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-live region; 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.