SHOTid auditfeat/v2 · 38549fa · 2026-05-16
i18n + a11y · §08

Internationalisation & accessibility

Branchfeat/v2 Commit38549fa AudienceJonny, Liam, eng
01

The launch-locale strategy as it stands

SHOTid ships into five locales in December and three more in Phase 2. Right now four of the five launch locales are partly placeholder, the avatar composer is a global-by-aspiration UI built on Anglophone defaults, and the lock-in ceremony — which is irrevocable by trigger — is missing the WCAG-mandated confirmation surface.

Five locales are wired: en-GB, en-WA, pt-BR, es-LatAm, it-IT. The strings dictionary at apps/sporthead-id/lib/strings.ts holds ~110 string keys and every key has an entry for every locale. No silent en-GB-only fallback. Resolver chains exact locale → en-GB → key, with {placeholder} interpolation. Solid v0 plumbing.

Handle vocabulary is the weaker half. sporthead_prefixes and sporthead_suffixes carry a locale column; the RPC routes against it. The migration header is honest: "a working starter set ... enough vocab for the RPC to function without collisions in early founders cohort". The £5–12K-per-locale linguist budget has not been spent; only en-GB has had a curation pass.

The budget is genuinely tight. Professional adult-content + brand screening for handle vocabulary alone typically costs £4–8K per language before UI copy. The Phase 1 plan reads more like "ship at en-GB quality plus best-effort" than "five locales at parity" — defensible, but should be named internally so we don't claim parity at the December event.

String coverage complete v0

All ~110 keys in strings.ts have entries across the five locales. Resolver fallback chain is explicit. Brand nouns ("Sport Head", "SHOT", "WhatsApp") deliberately untranslated — correct call.

Handle vocabulary en-GB curated only

Vocab tables seed all five locales but four non-en-GB sets are placeholders. Risk: @phantom-dodger-22 reads as charming in English and as "why is my app calling me a dodger?" in pt-BR. Linguist review non-optional before launch in any non-English market.

Linguist budget underspecified

£5–12K × 4 locales = £20–48K unspent procurement. No translation-memory tool, no sign-off process, no locale freeze date in the repo. Strategy doc names the budget; the project plan does not own the work.

02

en-WA — the hybrid that doesn't exist anywhere else

West African English as conceived here is a hybrid pulling from Krio (Sierra Leone), Nigerian Pidgin, and Liberian English — three related but distinct creoles with their own grammars, lexica, and prestige hierarchies. No major platform ships a single en-WA register; Facebook split Pidgin (pcm) into its own locale specifically because the hybrid did not work.

The current strings.ts approach is "en-GB sprinkled with Pidgin verbs where it improves the read" ("where you dey?", "we go set ...", "you fit change am"). This is best-effort and shows respect, but has three failure modes:

  • Inconsistent register. step.country.title is Pidgin ("where you dey?") while step.country.fallbackLabel reverts to plain English ("your country"). A Sierra Leonean reader experiences whiplash; a Liberian reader notices the Nigerian-Pidgin tilt and clocks it as foreign.
  • Handle vocabulary is empty. Vocabulary tables have no en-WA-native prefix/suffix corpus seeded. The RPC will either fall through to en-GB or generate nonsense. A West African Sport Head's first sight of their identity will be an en-GB phrase with the en-WA flag next to it.
  • Geo copy mixes registers inside a single sentence. step.geo.subtitle.sl_district reads "we go connect you to sport people for your area" (Krio); step.country.somewhereElse stays as plain en-GB "somewhere else?". The user flips registers every paragraph.

Honest call is one of: (a) treat en-WA as Krio specifically, hire a Krio linguist in Freetown, accept that Nigeria/Ghana/Liberia get a slightly foreign-feeling but coherent register; (b) split into en-WA-SL, en-NG, en-GH, en-LR — expensive but matches reality; (c) keep en-WA as standard West African English (no Pidgin), match BBC Pidgin's editorial line for headlines/CTAs only, use plain English elsewhere. The current path is none of those — it is improvised. That improvisation will be visible to the cohort.

03

The Phase 2 locales — Mandarin, Hindi, Arabic, Swahili

Phase 2 is the architectural cliff. Mandarin alone forces decisions the current schema has not made.

LocaleEngineering surfaceWhere the current build breaks
Mandarin (zh-Hans, zh-Hant) Dual-handle: Latin primary (URL-safe, federation-shareable) + display localised. zh-Hans 1.1B users, zh-Hant 30M, mutually unreadable for many young readers. Pinyin tone marks lost in URLs. Full-width vs half-width punctuation hazard. profiles holds one handle column. Handle CHECK constraint is [a-z][a-z0-9]{1,15} — ASCII-only. No display_handle, no script column, no Latin-fallback rule. Pinyin only? @勇虎-21 displayed with @yong-hu-21 stored? Not decided anywhere.
Arabic, Hebrew, Urdu (Phase 3) RTL bidi layout. CSS logical properties (margin-inline-start, etc). HTML dir="rtl". Mirror of step rail, nav-back/nav-next arrows, lock-in icon. apps/sporthead-id/app/layout.tsx hardcodes with no dir. Repo-wide grep for margin-inline, padding-inline, dir=: zero hits. Every gap/padding is ml-/mr-/pl-/pr-. Back/next arrows baked into string values (nav.back: "← back") — point the wrong way in RTL after layout flips. Canvas LTR-locked at draw time.
Hindi (Devanagari) Combining characters (matras), conjuncts, font fallback chain. ICU plural rules: Hindi has two forms (one, other) — same arity as English but different boundaries (0 is one in Hindi, other in English). Codebase has zero ICU usage. Pluralisation done by hand in English. Cohort-counter strings ("one of the first five thousand") won't pluralise correctly in Russian (3 forms), Polish (3), or Arabic (6) once Phase 2/3 lands.
Swahili (sw) Smallest reservoir of pre-built i18n tooling. ICU rules exist (CLDR fine). Harder surface is the cultural-noun layer: Sport Head in Swahili is Kichwa cha Mchezo — grammatically correct but loses brand-as-noun ceremony. Decision on what stays English. Translation-memory and translator pipeline absent. No Swahili-fluent reviewer on the books.

The dual-handle migration alone is several weeks of schema and RPC work that needs to land before any CJK or Arabic translator starts — otherwise the linguist produces strings the database refuses to store. Sequence matters.

04

Trait curation as a localisation problem

The 99 trait PNGs ship with English-only filenames as primary keys (Sport/Football, Clothing/Hoodie, 4-SHOT.png). The trait registry has no display-name layer. Category strings flow straight through to the UI.

The football case is the canary. Sport/Football in en-GB = association football. In en-WA, same. In pt-BR the sport is futebol and the cultural weight is enormous — picking Sport/Football from an English-labelled picker is the smallest moment the brand can afford to get wrong. In US English (routed to en-GB) football means gridiron and the picker shows a soccer ball. That's a bug.

  • Display names need a translation pass. Fastest path: add a trait_display_names table keyed by (category, filename, locale) with fallback to filename. ADR-0004 already flags trait-registry migration as a hard prerequisite; i18n display-name layer is the same migration's natural sibling.
  • Culturally-specific drops vs rarity-weight. Decision 5 in strategy doc 01 commits to locale-specific drops (a Sierra Leone fight-night helmet, a São Paulo carnival mask). The helmet_rarity migration treats rarity as a global column. If the SL helmet is rare for everyone, it loses meaning at home; if common only for en-WA users, schema needs (filename, locale) → rarity_weight not (filename) → rarity_weight. Current column is single-axis.

One-trait-registry-many-locales is the right answer. Locale-shadow registries (separate catalogs per market) would fragment supply and break founder credentials. But the rarity column needs a locale axis before any culturally-specific drop ships.

05

Accessibility — WCAG 2.2 AA, where the project actually sits

WCAG 2.2 (W3C Recommendation, October 2023) added nine success criteria over 2.1. Three matter here: 2.4.11 Focus Not Obscured, 2.5.7 Dragging Movements, 2.5.8 Target Size (Minimum) 24 × 24 CSS pixels. The project's own 44px BA brief is stricter — and not enforced.

Avatar composer keyboard support incomplete

SportHeadComposer.tsx uses aria-label on six trait-tile buttons. Only a11y wiring. No role="radiogroup", no roving tabIndex, no arrow-key navigation. A keyboard user has to Tab through every tile in source order. Screen-reader narration is mute on step number, category name, and current selection. The composer is the centrepiece and the least screen-reader-aware screen.

Lock-in ceremony confirmation partial

Lock is enforced by Postgres trigger. UI does two-phase PIN entry (typo guard) but does not present a "this cannot be changed" consequence statement before the user types the confirming PIN. WCAG 3.3.4 (Error Prevention — AA) requires reversibility OR confirmation for irrevocable actions. We are partway. "Lock it in" as button copy is poetic; it is not WCAG 3.3.4 evidence in an audit.

Colour contrast — Founder Gold borderline

--color-gold: #f7b613 on --color-bg: #0a0a0c is ~10.7:1 — passes. But disabled:bg-[rgba(247,182,19,0.35)] drops to ~2.8:1 — below 3:1 large-text. --color-ink-3: #8a8a90 on bg ~4.3:1 — fails AA by a hair. --color-ink-4: #4a4a52 ~1.8:1 — fails everything; used as hint text in PinStep.tsx for recovery-email guidance. Needs a contrast pass per token before launch.

44px touch targets not enforced

BA brief in CLAUDE.md claims 44px touch targets. Reality: avatar composer skin-tone swatches are h-9 w-9 (36 × 36 px) at line 485 of SportHeadComposer.tsx. The colour-swatch picker is the most-touched surface and falls 8 px short of both project bar and Apple HIG. WCAG 2.2 AA 24 × 24 is met; our own commitment is not. No lint rule, no design-token enforcement.

Reduced motion not honoured

Repo-wide grep for prefers-reduced-motion: zero hits. Welcome page carousel auto-advances. PIN auto-advance on 6th digit uses a 250ms timer. Both fail WCAG 2.3.3 for vestibular sensitivity users. One-line CSS fix + JS guard on the carousel.

HTML lang / dir attributes hardcoded

app/layout.tsx hardcodes . Once the user picks pt-BR in step 1, screen readers continue announcing the page as English. Breaks pronunciation in VoiceOver and TalkBack. Needs re-setting on locale change — easy client-side but the static-export model makes it a hydration consideration.

06

The PIN entry surface — under-tested

PIN is the auth primitive. The implementation in app/start/_components/PinStep.tsx gets a lot right and a few important things wrong.

What it gets right: inputMode="numeric" + pattern="[0-9]*" for the mobile numeric keypad; autoComplete="one-time-code" so iOS will offer SMS-OTP autofill (slight category mismatch — not actually an OTP — but the closest semantic and works); paste handler that splits a 6-digit clipboard into six cells; two-phase entry with mismatch detection.

What it gets wrong or skips:

  • Auto-advance and screen readers. Auto-advancing focus between six separate input elements is a documented VoiceOver/TalkBack hazard. User types a digit, focus jumps, screen reader announces a new field, user types again, focus jumps again. Some users will hear nothing of their progress. Fix: single input with letter-spacing styling (GitHub, Stripe, Apple all moved to this) or explicit aria-live on each cell change.
  • No visible-PIN toggle. Cognitive/motor-disabled users need to see typed PIN to confirm. No eye-toggle. Values stored as type="text" (not password) which leaks them visually anyway — toggle half-implemented by accident.
  • The aria-label is unlocalised. aria-label={`PIN digit ${idx + 1}`} is hardcoded English at line 304. Italian, Portuguese, Spanish users get English screen-reader announcement during their most sensitive step.
  • Phase-1 confirm has no button. Auto-advance to phase 2 fires 250ms after the 6th digit. Keyboard-only or screen-reader users have no opportunity to review before commit. Add an explicit "continue" button as alternative path.
  • No PIN strength check. 111111, 123456, 000000, birth-year sequences not blocked. Six-digit PIN with no strength gate ~1M possible values — bruteforce-feasible if rate-limiting fails.
07

Language switcher and locale lock politics

Locale resolution today (per lib/locale.ts:detectLaunchRegion) uses navigator.language as v0. Both signals are wrong sometimes. A Sierra Leonean whose phone OS is set to en-GB by their carrier will be defaulted to UK; a UK-based Brazilian on holiday in Lagos will be served en-WA.

User gets an override — step 1 has an explicit "somewhere else?" path surfacing 24 fallback countries. Good. Once picked, locale is written to localStorage and that becomes the journey source of truth.

The harder question is post-lock. The profiles.locale column is one of the fields the enforce_identity_lock trigger does not currently lock. If locale is mutable, a user can move from Sierra Leone to Italy and switch their display register — desirable. If locked, migrants are stuck. Needs a deliberate decision in an ADR: locale follows the person, not the identity. The handle's locale-derived vocabulary stays fixed regardless (you don't lose @phantom-dodger because you moved to Milan), but UI strings should follow you.

08

The localisation production pipeline that doesn't exist yet

To get from "starter strings + en-GB-only vocab" to "launch-quality across five locales" you need, in order:

  • Translation memory tool. Crowdin, Lokalise, or Phrase. £50–250/month. Without this, every string change becomes a four-way email thread.
  • Linguist procurement. Four locales × £5–12K = £20–48K. Krio linguist (Freetown), pt-BR sport-culture writer (São Paulo/Rio), es-LatAm — note: in practice we need either a neutral MX-Spanish writer or two writers (MX, AR/CL) and merge — Italian sport-writer. None on contract yet.
  • Sign-off process. Who decides "this Krio is right"? Not the founders. Need a named cultural reviewer per locale with veto on launch copy.
  • Locale freeze date. Six weeks before the December event. After freeze, only hotfix-grade string changes.
  • pt-BR vs pt-PT. detectLaunchRegion() routes all pt-* browsers to pt-BR. Comment in lib/locale.ts:185 acknowledges this. Currently no pt-PT brief. Portuguese Sport Heads get Brazilian copy. Acceptable for v0; needs a deliberate decision before Portugal market is prioritised.
09

Verdict

i18n + a11y verdict

Shippable for the cohort, not yet shippable for the brand promise

The five launch locales are functionally wired and en-GB is well-crafted, but four of the five are placeholder-quality and en-WA is improvising a register no major platform ships as a single locale. For a 5,000-founder cohort event in December, this is acceptable if named — internally and to founders — as "v0 register, professional polish by Q2 2026". It is not acceptable if the December narrative claims native parity across five cultures.

Must-have a11y fixes before launch: (1) trait composer keyboard nav + screen-reader narrative; (2) localise the PIN-digit aria-label and add a consequence statement before lock-in for WCAG 3.3.4; (3) honour prefers-reduced-motion on carousel + PIN auto-advance; (4) enforce 44px touch targets — swatches at h-9 w-9 is the clearest single fail; (5) re-set html lang on locale change; (6) measure contrast on --color-ink-3 / --color-ink-4 and lift to AA.

Phase 2 effort is meaningful: dual-handle schema (~2 weeks engineering + migration of locked founders), RTL pass on the entire app (~3 weeks of CSS-logical-property conversion + directional-icon audit), ICU plural integration (~1 week), culturally-specific trait pipeline (~1 week schema + ongoing curation). Realistically Phase 2 is a one-quarter project, not a sprint, and needs to land before any cohort beyond the first 5K is opened.

Strongest single move now: write the £20–48K linguist procurement into the December budget this week, get a Krio reviewer named in the next fortnight, add display_handle + locale_mutable to the profiles schema before lock semantics calcify in production data.