Three "invisible-until-they-break" correctness disciplines. None of them shows up in a demo — all three blow up at the platform scale, across many locales and many teams. The Lead move is to encode them once into the platform.
Don't treat these as three unrelated chores. They're the same kind of problem: quality that is invisible in the happy-path demo but mandatory at scale. Each one answers "is the UI still correct for…":
These aren't features you sprinkle on — they're guarantees you build into the platform. Right for every body, every locale, every refactor. Encode it once so 90 engineers don't each re-derive it.
For a travel platform this is the biggest of the three. The single most important mental shift: i18n is not "translate the strings." It's "stop hard-coding assumptions about language, format, and direction into the UI." Translation is one slice; formatting, pluralization, direction, and routing are the rest.
Every browser ships a full Unicode/CLDR locale database behind the Intl namespace. Never format numbers, dates, or currency by hand — you will get it wrong for some locale. The pattern is always the same: construct a formatter with a locale + options, then call it.
// Price: ₿ vs $ vs € — symbol, decimal/grouping separators, position all differ new Intl.NumberFormat('de-DE', {style:'currency', currency:'EUR'}).format(1234.5) // → "1.234,50 €" (German: dot groups, comma decimal, symbol after) Intl.NumberFormat('en-US', {style:'currency', currency:'USD'}).format(1234.5) // → "$1,234.50" // Dates, relative time, lists, plural category — all locale-aware, zero deps new Intl.DateTimeFormat('ja-JP', {dateStyle:'long'}).format(new Date()) // 2026年6月13日 new Intl.PluralRules('ru').select(2) // "few" — Russian has 4 plural forms, not 2
count + " hotels" assumes English word order and a single plural rule. Word order differs by language, and plural categories vary — Arabic has six, Russian four, Japanese one. The fix is ICU MessageFormat: a translatable template ({count, plural, one {# hotel} other {# hotels}}) where each locale's translators define their own plural branches. Your job is to pass the variable; theirs is to phrase it.
margin-left — use CSS logical properties (margin-inline-start, padding-block) and set dir="rtl" on <html>; the browser mirrors layout for free.str.split(' ') for truncation or word-count breaks. Intl.Segmenter does locale-correct word/grapheme breaking — directly relevant to the SEA market.This is where i18n meets the rendering/SEO work from earlier lessons. Each locale must be a crawlable URL (/en/…, /th/… or a per-country domain), rendered server-side, with hreflang alternate tags so Google serves the right language version and doesn't treat them as duplicate content. Lazy-load only the active locale's message bundle — never ship all 40 languages to every user.
i18n isn't translating strings — it's removing English assumptions. Use the native Intl API for format, ICU MessageFormat for plurals, logical CSS for direction, and a locale-in-URL + hreflang for SEO.
The single highest-leverage idea: most accessibility is free if you use the right HTML element. A real <button> is focusable, keyboard-operable, and announced as a button by every screen reader — automatically. A <div onClick> is none of those and forces you to re-implement all of it (tabindex, role, keydown handlers) by hand, badly.
1. Semantic HTML (button, a, nav, main, label, headings) → 2. native attributes (alt, for) → 3. ARIA only to fill genuine gaps.
ARIA only changes how AT announces an element — it adds zero behavior. role="button" on a div doesn't make it focusable or clickable by keyboard. Wrong ARIA actively misleads screen-reader users. (W3C ARIA APG)
outline:none with no replacement). Manage focus on route change and modal open — trap focus in the dialog, return it on close. This is the SPA-specific gotcha: a client-side route change doesn't move focus or announce the new page like a real navigation does.alt (empty alt="" for decorative), inputs need <label>, one <h1> + logical heading order, landmarks (main/nav) so SR users can jump around.Wire axe-core (via jest-axe / Playwright / Storybook a11y addon) into CI — it catches programmatic issues (missing alt, contrast, bad ARIA) automatically. But automation can't tell you if the tab order makes sense or the screen-reader narrative is coherent — that needs manual keyboard + VoiceOver/NVDA passes. Treat axe as the floor, not the ceiling.
Reach for the right element before reaching for ARIA. No ARIA beats bad ARIA. Automated axe is the floor; a keyboard-and-screen-reader pass is the real test.
At Staff level the question is never "do you use TypeScript." It's "how do you keep types trustworthy across many packages, many teams, and a runtime that erases them." Three ideas matter.
any as a hole in the contractWithout "strict": true (especially strictNullChecks), TypeScript misses the bug class that actually crashes production — the undefined is not a function family. Every any is a place where the type system silently switches off; prefer unknown + narrowing, and lint any to a warning. (tsconfig strict)
The most common senior misconception: that a TypeScript interface protects you from a bad API response. It does not — types are erased at compile time; at runtime an as User cast is a lie the compiler can't check. At every boundary where data enters your app from outside (network, localStorage, URL params, postMessage), validate it at runtime with a schema library like Zod, then derive the type from the schema so there's one source of truth.
import { z } from 'zod'; const Hotel = z.object({ id: z.string(), price: z.number(), name: z.string() }); type Hotel = z.infer<typeof Hotel>; // type derived FROM the validator const res = await fetch('/api/hotels/1'); const hotel = Hotel.parse(await res.json()); // throws if the API lied — caught HERE, not 3 components deep
Across teams (and micro-frontends — see L14), the API contract between producer and consumer should live in a shared, versioned types package, not be copy-pasted. This is the direct fix for the "lost types across the Module Federation boundary" cost from L14. To keep the typechecker fast as the monorepo grows, use TypeScript project references: they enforce explicit project boundaries (you can't import a package you didn't declare) and enable incremental builds so CI only re-checks affected projects. (Nx on project references)
strict codebase with as any casts at the network edge gives false confidence — green CI, runtime crashes. The Lead-level position: strict mode catches refactor bugs inside the trusted core; runtime validation (Zod) guards the edges where data is untrusted. You need both — they cover different threats.
TypeScript protects you from your own refactors, not from the network. Strict mode inside the trusted core, runtime validation at every boundary, shared types as the cross-team contract.
The thread through all three: a single engineer can try to be accessible, localized, and type-safe — but at the platform scale, "try" loses. The Lead answer is to make the correct path the default and the incorrect path hard, by pushing each guarantee down into shared infrastructure:
| Guarantee | Don't leave it to each team — encode it in… |
|---|---|
| Accessibility | A design system of accessible-by-default components (headless cores like Radix/React Aria — see L15) + axe + visual-regression gates in CI. Teams compose correct primitives instead of re-rolling a div-button. |
| i18n | A shared <FormattedMessage>/t() layer over Intl + ICU, an extraction pipeline to the translation vendor, and a lint rule that fails the build on a hard-coded string. |
| TypeScript | A strict shared tsconfig, a published types/contracts package, project references, and Zod schemas at every API boundary — checked in CI. |
You can't review your way to quality at this scale — you have to build it into the platform. Make the accessible, localized, type-safe path the easy default, and the wrong path fail CI.
0 / 8 correct
1. A teammate writes `${count} hotels found` and sends it to translators. Why will this break in production?
2. You need to display a price to users in Germany, Japan, and the US. What's the right approach?
3. A developer ships a custom dropdown built as <div role="button" onClick={...}>. A screen-reader user reports they can't open it with the keyboard. What's the core lesson?
4. Your SPA passes axe-core with zero violations, but blind users say the app is confusing to navigate. What's the most likely gap?
Automated checks are green across the whole app.
5. An API sometimes returns price: null. The code does const hotel = await res.json() as Hotel and later crashes on hotel.price.toFixed(2). The interface says price: number. Why didn't TypeScript catch it?
6. Supporting Arabic. Beyond translating text, what's the key UI concern, and the modern way to handle it?
7. Two micro-frontend teams keep breaking each other when the hotel API shape changes — the consumer only finds out at runtime, in production. What's the structural fix?
8. You're the FE Lead. With 90 engineers across many teams, how do you actually guarantee accessibility and localization — not just hope for them?
In 90 seconds: "How would you make the hotel-card component correct for every user and every market?" Hit one point from each axis — a11y (semantic element + focus/target size), i18n (Intl currency + ICU plural + RTL logical props + hreflang URL), TS (strict + Zod at the price API boundary) — and finish with the Lead move: encode it in the design system + CI, don't leave it to each team.
Good follow-up topics: