Skip to content

Latest commit

 

History

History
4443 lines (3794 loc) · 262 KB

localization.md

File metadata and controls

4443 lines (3794 loc) · 262 KB

Localization (non-frozen design doc — DRAFT)

Status: settled design — implementation pending. rev 3. Rev 1's direction was accepted; rev 2 closed the five open questions (Q1 TS-module catalogs + satisfies; Q2 intl-messageformat; Q3 user-facing diagnostics only this cycle; Q4 Toolbar + Play bar as the Slice-1 anchor; Q5 a single loop-studio/ui-locale/1 string key) and pinned the catalog loader + atomic-activation contract; rev 3 pins the four pre-implementation boundaries: the fully deterministic locale-decision order (§L5.2), the async activation state machine + race rules (§L4.5), the creation-time extent of "user data is not translated" (§L3.4), and catalog / ICU validation + the slice split (§L12, §L13).

This is a non-frozen design doc — no loop-*/N, no Frozen marker — and it merges as settled design, implementation pending. Implementation starts from Slice 1 (§L13) after that merge; nothing in src/i18n/ and no intl-messageformat dependency lands before it.

A UI-chrome-only layer: it changes the language of the interface, nothing the engine computes, nothing that is serialized, and no wire contract. It carries no loop-*/N id and is revised freely (like docs/visual-language.md, docs/mobile.md, docs/edge-routing.md). §L14 is the decision record; §L12 the verification set; §L13 the slices; §L15 the scope boundary.

The project is an extensible localization base, not a "KO/EN translation". The first two shipped locales are English and Korean; the structure must accept a third (ja, de, fr, …) with no change to existing code or catalogs — only a new locale file plus one registry line. Any if (lang === 'ko') … else … two-way branch is a bug.

Build order: this design doc → merge (settled design, implementation pending); then Slice 1 (base + Toolbar/Play bar) → Slice 2a (model surface) → Slice 2b (app surface) → Slice 3 (acceptance validation); then, on the finished base, the guided first-run tour and contextual inline help as their own slices (§L13). No locale library, string extraction, or language-switch UI is started before this doc settles.

L0. Why

The app already ships a Korean-speaking audience Share links and a Korean team works on it, but every label, hint, error, and accessibility name is English-only. "Onboarding, part 2" needs an i18n base before a guided tour or inline help can exist in more than one language — and it should be built once, for N languages, not retrofitted per language.

L1. The hard boundary — localization is presentation only

A locale switch, at any time, and adding a locale, ever, must move nothing in these lists:

  • Engine / result: SimState, the timeline series, R(t), Monte-Carlo output, RNG draws, fired, stateEvents — a run is byte-identical in every locale.
  • Serialized bytes: the GraphDoc, the Workspace payload (loop-workspace/1), the Share #g1= fragment, proposal / revision files, the loop-revision/* content digest.
  • Editor state a spec or test observes: simulationRev, the undo / redo stack, the React Flow viewport, selection.
  • Wire identifiers: schema id (loop-studio/graph), node kind, edge mode / route, the tool string, every JSON key in every file format, error / diagnostic codes (§L3.4).

The supported-language list is fully decoupled from every product-data format. Registering ja changes what the UI can say, never what any file contains.

L2. The locale registry

L2.1 — a registry, not a pair. src/i18n/registry.ts holds an ordered list of registered locales. en and ko are simply its first two entries. Every part of the system that needs "the set of languages" — the switch UI, the tests, the fallback chain — reads the registry; none of them names en / ko literally (except en as the designated base, §L3.1).

L2.2 — per-locale metadata. Each registry entry is:

{
  code:        'en',            // BCP-47 primary subtag; the catalog key
  englishName: 'English',       // for docs / logs
  nativeName:  'English',       // the endonym — the switch UI's primary label,
                                // stable regardless of the active UI language
  direction:   'ltr',           // 'ltr' | 'rtl' — metadata only in v0.8.0 (§L9)
  numberLocale:'en',            // BCP-47 tag for Intl.* when UI-chrome numbers
                                // are formatted (§L8); never touches stored data
  enabled:     true,            // offered in the switch UI; false = registered
                                // but hidden. All shipped locales are true today.
  catalog:     () => import('./locales/en'),   // see L3.3 (static today)
}

Adding a language = append one entry + add one locales/<code> catalog (either a single locales/<code>.ts file or a locales/<code>/ folder of domain slices, §L3.3). No existing file changes.

L2.3 — the base and fallback locale. en is the base: its catalog defines the canonical key set (§L3.1) and is the final fallback for any key missing from another locale. The fallback chain is active → en; there is no per-region chain in this cycle (ko-KR resolves to ko, not a ko-KR catalog).

L2.4 — Chinese: the script decides, and no browser sends it. zh-Hans (简体中文) and zh-Hant (繁體中文) both ship from 2026-09-21. Simplified and Traditional are separate locales, never a conversion of one another — the terminology differs, not only the glyphs (软件 / 軟體, 默认 / 預設), and one pair is outright inverted: a spreadsheet ROW is 行 in Simplified but 列 in Traditional, where 列 means COLUMN in Simplified and 欄 in Traditional. A mechanical script conversion of either catalog would silently swap the two throughout the data-import wizard, which is why neither is ever generated from the other. The registry code therefore carries the script subtag, which creates the one gap the ordinary §L5.2 order cannot close: a browser reports a REGION (zh-CN, zh-TW, zh-HK), the base-subtag rule looks for a zh entry, finds none, and hands a Chinese reader English. chineseScript() sits between the exact match and the base match:

browser reports resolves to
zh-Hans, zh-Hans-* zh-Hans
zh-Hant, zh-Hant-* zh-Hant
zh-CN, zh-SG, zh-MY zh-Hans
zh-TW, zh-HK, zh-MO zh-Hant
bare zh zh-Hans (the larger population)

It only ever returns a code the registry actually has; an unregistered target falls through to the ordinary rules and, ultimately, to English — the guard that kept Traditional readers on English rather than Simplified before zh-Hant shipped, and that will do the same for the next script that is mapped before it is registered. A stored value is unaffected: it is still accepted only when it is exactly a registered code, is never rewritten, and a value that is not registered (yet, or any more) simply falls through and recovers on its own.

zh-Hant is Taiwan Traditional. The catalog follows Taiwan convention (軟體 · 資料 · 網路 · 專案 · 範本 · 匯入/匯出 · 設定). zh-HK and zh-MO resolve to the same catalog: the divergences from Hong Kong usage in this product's vocabulary are lexical (軟體 / 軟件, 網路 / 網絡, 專案 / 項目, 範本 / 模板) rather than semantic, and no per-region catalog is planned. This is not a Hong Kong localisation, and no claim is made about Hong Kong usability beyond that the terms are legible.

L2.5 — the per-locale font contract. The bundle ships Latin subsets of IBM Plex only; every CJK glyph comes from a system font, and a downloaded CJK face would break the offline and portable contracts. The base --font-sans ends in 'Noto Sans KR', so a locale whose Han characters must NOT be drawn with Korean shapes needs a :lang() override of the custom property — which every font-family: var(--font-sans) consumer then inherits for free. Measured with CDP CSS.getPlatformFontsForNode on 2026-09-21, identically on the dev server, the served PWA build and the portable single file:

lang before after
zh-Hant Malgun Gothic × 8 — every glyph Korean Microsoft JhengHei UI × 8
zh-Hans Microsoft YaHei × 4 + Malgun Gothic × 3 + Noto Sans KR × 1 — wrong mid-word Microsoft YaHei UI × 8

Re-measured on 2026-09-21 when zh-Hant was registered, with a sample that also carries Traditional-only Han, the corner brackets 「」 and full-width punctuation: Microsoft JhengHei UI × 19 on every surface across all three artefacts, 微軟正黑體 in the <input>, all 21 characters resolved, and no Korean, Japanese or Simplified-only family anywhere.

L2.5a — the Chinese em dash needs a face of its own. 'IBM Plex Sans' leads both Chinese stacks and it COVERS U+2014, so until 2026-09-21 a LATIN font drew the Chinese double dash —— while the Han around it came from the CJK font. That is not a "different font was picked" quibble; the shape is wrong. Measured at 22px, identically on the dev server, the served PWA build and the portable single file:

advance ink inked runs
—— on IBM Plex Sans 34.33 px 32.75 px 2 — two short strokes with a gap
—— on the CJK font 47.53 px 47.50 px 1 — one continuous full-width rule

A Chinese dash occupies one full-width slot per character and the pair joins into an unbroken line. The Latin em dash is 0.74 em and leaves a visible gap, so the pair reads as two dashes rather than one. Inside a Han run the same thing is countable: four inked runs instead of three.

The fix is two @font-face rules — LS CJK Punct SC and LS CJK Punct TC, separate so one Chinese locale can never borrow the other's dash — each with unicode-range: U+2014 and local() candidates drawn from the same families as the stack below it. Each is placed at the FRONT of its :lang() stack.

Why the range is exactly one codepoint. unicode-range is the whole mechanism: it is what stops the family being consulted for anything else. Latin letters, digits and code tokens therefore stay on IBM Plex Sans — Ag07 measures 52.13 px, 資CSV源 83.84 px and 資 4900 源 107.19 px before and after, with byte-identical font attribution. Leading the stacks with a whole CJK family instead was measured and rejected: it moves those same runs to 55.36 / 86.30 / 108.63 px, changing Latin glyphs the UI wants to keep.

U+201C / U+201D and U+2026 were audited the same way and deliberately left out. Neither system font draws them full-width either, so switching would have bought a ~2 px narrower quote (10.45 → 8.30 px) and an ellipsis that differs by ≤0.5 px — a different font, not a repaired defect. Every other Chinese mark in the catalogs (,。、:;?()/「」) is a full-width codepoint IBM Plex does not cover, so it already resolved to the CJK font and is untouched. · (U+00B7) and – (U+2013) stay on IBM Plex on purpose: the English source uses the same characters in the same role, and the UI should read the same in both.

Contracts. en, ko and ja never reference these families and are unchanged; an <input> does not inherit --font-sans at all (the UA stylesheet resets it to Arial + the system CJK face), and no affected mark appears in any placeholder or input value. src lists local() candidates only, so no font file is downloaded, nothing is added to the production bundle or the portable single file, and rendering Chinese costs no extra request. On a machine where none of the candidates is installed the face contributes nothing and U+2014 falls straight through to 'IBM Plex Sans' — exactly the previous rendering, no tofu. e2e/cjk-punctuation-font.spec.ts pins all of it by measurement (platform font, advance width, and an off-screen canvas scan that counts the inked runs).

No tofu in either state: the defect is regional-shape substitution, invisible to a "does it render" check, so the contract is verified against the platform font and never against getComputedStyle().fontFamily (which only echoes the declared stack) or document.fonts.check() (which only says a face could be used). --font-mono is a separate variable and stays untouched, and an <input> does not inherit --font-sans at all — the UA stylesheet resets it — so an input is never evidence that the override works.

L2.6 — descriptive copy wraps by the rules of its own language. The two multi-line descriptive surfaces — .menu__blurb (menu descriptions, clamped to two lines) and .palette-tip__desc (palette tooltips) — both carried a bare word-break: keep-all. It was added for Korean: a particle otherwise breaks mid-word and strands a single character on the next line, which six of the eight Korean tooltip descriptions did (measured with a Range per 어절, 2026-09-15). The declaration was never scoped, and keep-all removes every break opportunity inside a space-less CJK sentence — so Japanese and Chinese copy could not use its second line at all and spilled sideways instead. Measured 2026-09-22, before the fix:

locale overflowing elements worst
en 0 —
ko 0 —
ja 5 104 px
zh-Hans 3 31 px
zh-Hant 2 31 px

The line clamp was never the cause: a 232 px box was holding a 250 px single line with the second line free. The text simply had nowhere legal to break.

The rule now states what it means — Korean asks for keep-all, nobody else does:

:lang(ko)  :is(.menu__blurb, .palette-tip__desc) { word-break: keep-all; }
:lang(ja)  :is(...), :lang(zh) :is(...)          { word-break: normal; }
@supports (word-break: auto-phrase) {
  :lang(ja) :is(...), :lang(zh) :is(...)         { word-break: auto-phrase; }
}

That normal-then-auto-phrase shape is the same one .nodef__title already uses (§MML1): plain breaking as the floor, phrase-aware breaking where the engine ships a line-break dictionary, so Chinese and Japanese break on meaning units rather than between arbitrary characters. Scoping it this way also means a future CJK locale is not trapped behind a shared default that was only ever right for Korean. :lang(zh) matches both zh-Hans and zh-Hant — pinned by test, since the script subtag must not stop it.

English keeps the initial value: its computed keep-all was incidental, not a contract, and the rendered line count is unchanged (40 before and after). Korean is unchanged on every axis that matters — keep-all still computed, 38 lines, and zero mid-syllable breaks, all measured. e2e/descriptive-copy-wrapping.spec.ts holds the contracts.

L2.7 — French: two glossary terms split by CONTEXT, not by word. Most of the French glossary is a one-to-one substitution. Two are not, and translating them by word rather than by meaning produces text that is grammatical and wrong:

English French when
step pas a SIMULATION timestep — Pas 12 / 30, Pas écoulés, playback
step étape a STAGE of a procedure — a production line's shipment step, a wizard's step
spreadsheet feuille de calcul the DOCUMENT the user imports
spreadsheet tableur the APPLICATION (Excel, Sheets) that produced it

pas is the unit the engine advances; étape is a position in a sequence a person carries out. Using étape for the simulation would tell a French reader the model has a fixed script, which is the opposite of what a Loop Studio graph is. tableur for the document is the same class of error in the other direction — it names the software where the sentence means the file. données tabulaires stays available for the abstract shape of the data, where neither the file nor the app is meant.

L2.8 — French typography is part of the string, not of the CSS. French inserts a space before some punctuation, and that space must not break:

context character example
before ; ? ! U+202F NARROW NO-BREAK SPACE Charger ce modèle\u202F?
inside « » U+202F «\u202FFichier ▾\u202F»
before : U+00A0 NO-BREAK SPACE remplacé par\u00A0: {name}
between a number and its unit U+00A0 82\u00A0%, 5\u00A0kg
apostrophe U+2019 RIGHT SINGLE QUOTATION MARK d’entrée, l’or

These are codepoints in the catalog, never a CSS or runtime rule: a text-spacing property cannot know whether a ? belongs to French prose or to a code sample, and a runtime pass would have to re-decide on every render. Two consequences follow:

  • a string that is syntax — an expression placeholder, a URL, a filename, a key path, an operator hint — keeps ASCII throughout. Typesetting a @ref : 2 hint would make the hint wrong.
  • an automated check for "an ASCII space before ?" must use a literal ASCII space, not \s: Python's \s and JavaScript's \s both match U+202F and U+00A0, so the naive pattern reports every correctly typeset string as a defect.

e2e/i18n-fr.spec.ts reads the replace-template dialog and asserts the exact codepoints, so a later copy edit cannot quietly downgrade them to ASCII.

L2.9 — French is ONE localisation, and fr-CA is not a second one. The registry entry is fr, with no script and no region. fr-FR, fr-BE, fr-CH, fr-LU and fr-CA all reach it through the ordinary base-subtag step of the §L5.2 order — French needs none of the script mapping Chinese does (§L2.4), because there is no script ambiguity to resolve.

Canadian French is deliberately not split out. The divergences that would justify a separate catalog in a consumer product — courriel vs e-mail, anglicism tolerance, some UI verbs — are lexical, and this product's vocabulary is a closed technical glossary (§L2.7) whose terms are the same on both sides of the Atlantic: réservoir, aiguillage, convertisseur, valeur calculée. A second catalog would double the review surface of every future string for no semantic gain. If a Canadian reviewer later reports a term that genuinely misreads there, the answer is a term change in fr, or a fr-CA entry at that point — not one held open speculatively now.

One consequence is pinned by test: a stored value of fr-CA is not a code. localStorage holds a registry code, so an unregistered value is ignored and the browser list decides, exactly as zh-Hant-XX already is.

L2.10 — French length, and where it actually bit. French runs ~15–20% longer than English. The surface that could not absorb it was the one with a hard two-line clamp, .menu__blurb (§L2.6). Measured 2026-09-22 on the first complete French catalog, at the shipped 272 px / 232 px menu widths:

locale blurbs over the clamp worst
en ko ja zh-Hans zh-Hant 0 —
fr 4 of 12 3 lines shown as 2 — 17 px cut

The fix is the copy, not the box. Widening the menu was already rejected on its own merits (the 300 px measurement in src/index.css) — an open popover covers the palette row beneath it, and every extra 40 px hides another chip — and raising the clamp would relayout all six languages to accommodate one. The four sentences were shortened until each fit two lines at its real width, with the meaning kept:

key change
templates.equilibrium.blurb produits finis en sortie — une ligne qui se stabilise en quelques pas → produits en sortie — stable en quelques pas
templates.mmoProgression.blurb combien de temps faut-il pour atteindre → le temps qu’il faut pour atteindre
modules.rewardSplit.blurb dropped reçues
export.projectRevision.blurb dropped two articles and sa

The guard is differential rather than absolute: e2e/i18n-fr.spec.ts runs the same sweep in English and in French and fails only on what French adds. That is what makes it trustworthy — a glyph span whose ascender exceeds its line box reports a 2 px vertical overflow in all six languages, and an absolute threshold would either flag it forever or be loosened until it stopped catching real defects.

L2.11 — {column} names two different things, and a translation must tell them apart. The placeholder name is an internal contract and never changes; what it MEANS at each call site does. Ten keys carry it, in two groups:

group keys what {column} is
parser position the six error.EXPR_*.message + import.parseError a 1-based CHARACTER offset
table column import.loc.tableColumnHeader, import.loc.tableRowColumn, import.loc.tableRowColumnHeader a 1-based table COLUMN index

The first group is character offsets, in the code's own words:

  • src/model/expr/errors.ts — ExprParseError "carries a 1-based column into the raw text"; tokenize.ts computes it as i + 1 over string indices, and parse.test.ts pins err('@{a\b}').column === 4 — the 4th character is the backslash.
  • src/model/csv.ts — the field's doc comment is "1-based character offset within that row"; the scanner does col++ per character and col = 1 at each newline. csv.test.ts pins "the opening quote is the 3rd char of line 1".

The second group really is a table column: DataImportWizard.tsx passes issue.columnIndex + 1 next to that column's own header.

English is the origin of the drift — it says "at column {column}" for the character offsets too, which is defensible in English (a "column" in a text editor is a character position) but reads as a spreadsheet column in a dialog that is full of real spreadsheet columns. Rather than reword the base and churn six catalogs, each locale says what it means in its own terms:

locale parser position table column
en at column {column} (base, unchanged) column {column}
ko {column}번째 문자 {column}열
ja {column}文字目 {column}列目
zh-Hans 第 {column} 个字符 第 {column} 列
zh-Hant 第 {column} 個字元 第 {column} 欄
fr caractère {column} colonne {column}

fr and zh-Hant already drew the distinction and were the control group; ko / ja / zh-Hans used their table-column word for both and were corrected (2026-09-22, 21 strings). src/i18n/parserLocation.test.ts holds both directions — a parser message must carry the character wording and must not carry the column wording, and import.loc.* must keep the column wording and must not borrow the character one. It also asserts the two groups are disjoint and together are every {column} key, so a new one cannot be added without being classified.

For German, when de ships: Zeichen {column} where the position sits next to the thing at it („(" an Zeichen {column} wird nie geschlossen), and an Zeichenposition {column} where the sentence wants a position phrase of its own (Syntaxfehler an Zeichenposition {column}). The CSV message reads … in Zeile {line}, Zeichen {column}. The table-column group takes Spalte {column} — never Zeichen.

L2.11a — the checklist for adding a language. A catalog is not the whole job. zh-Hans, zh-Hant and fr each shipped with every catalog gate green and still inserted bundled modules whose node labels were English, because the module overlay is a separate data source that nothing tied to the registry (docs/bundled-module-label-localization.md §MLS4.5). Everything a new locale needs, in one place:

# what where enforced by
1 registry entry src/i18n/registry.ts registry.test.ts
2 5 catalog slices src/i18n/locales/<code>/ tsc + check:i18n
3 language.<name> in every catalog all locales/*/ui.ts check:i18n dead-key guard
4 Template label dict + DICT_LOADERS entry templateLabels/<code>.ts, dicts.ts check:template-labels
5 regenerated known labels templateLabels/known.generated.ts gen:known-labels + check:template-labels
6 bundled-module overlay src/i18n/moduleLabels.ts moduleLabels.test.ts (registry-derived)
7 production locale count + list e2e/dist.spec.ts npm run e2e:dist
8 dev picker option count and every earlier locale's spec e2e/i18n.spec.ts + e2e/i18n-<code>.spec.ts the default e2e run
9 rendered-string guards icuEscaping.test.ts, parserLocation.test.ts the unit suite
10 the English-form marker on the feedback link locales/<code>/ui.ts — tour.help.feedback and tour.help.feedbackAria localeSurfaceCopy.test.ts (registry-derived, exhaustive)

Items 6 and 9 are the ones a locale PR forgets, because nothing about writing a catalog points at them. Each is now derived from the shipped-locale set rather than listed by hand, so the suite goes red on the omission instead of the product going half-English.

Item 10 is a different shape, and es-419 is what exposed it (#266, fixed in #267). The linked feedback form exists only in English, so every non-English catalog says so inside the link — 영문 양식, 英語フォーム, 英文表单, 英文表單, formulaire en anglais, englisches Formular, formulario en inglés — and en says nothing, because en is the form. That makes it invisible to every check we had: check:i18n compares key sets and ICU argument shapes, and a translation review reads the catalog against en. A locale that drops the marker matches the English source perfectly and still misinforms its reader. A rule that lives in the translations and not in the source needs a test that compares locales to EACH OTHER, which is what src/i18n/localeSurfaceCopy.test.ts is for. Its marker map is asserted exhaustive over the registry's translated locales, so language N+1 fails until it has decided how it says "English form" and "new tab".

pt-PT is the case that proves the map cannot be inherited. It is the same LANGUAGE as pt-BR, so the obvious move was to copy its row. That would have been wrong: a browser tab is a separador in Portugal, and aba there is a flap or a brim. A sibling locale of an existing language still has to decide its own markers — the only row in the map so far that had to differ from its own language's other row.

The same file holds the other member of that class. Item 3 makes check:i18n prove the language.<name> key EXISTS in every catalog; nothing proves it is right, and es-419 shipped into the Korean picker as 스페인어(라턴아메리카) — one stroke off a word that does not exist, in the one catalog whose readers are least likely to recognise the language being named. A display name is read by people who do not read the language it names, so it gets an explicit expectation rather than a shape check.

Item 8 is the one that bites in the opposite direction, and de proved it (#262, CI red on the first push). A per-locale spec written at language N records what was true then: i18n-fr.spec.ts asserted de-DE reaches en ("still unregistered — French must not have widened this") and counted 7 picker options. Both were correct when French shipped and both were wrong the moment German did — the product was right and the test was stale. So language N+1 must revisit every earlier i18n-<code>.spec.ts, not just i18n.spec.ts. Two rules make that cheap:

  • an "unregistered" probe tag must be a code the product can never register — not merely one that is off today's roadmap. de and fr both used nl-NL, which held only until Dutch shipped; every probe is now qaa, because picking the next language to ship guarantees the row rots;
  • the picker option count is written as <shipped> + the dev pseudo-locale, so a stale number is visible in the comment rather than only in the failure.

The count still lives in three specs by hand. Deriving it from LOCALES would need the runner to import product code, which no e2e file does today; that is a recorded follow-up, not something this checklist pretends is solved.

The shipping order, so "not on the roadmap" means something. Twelve languages ship today: en ko ja zh-Hans zh-Hant fr de es-419 pt-BR es-ES pt-PT ru. The remaining order, set 2026-09-23, is

tr -> th -> vi, with pl-PL and it-IT added after them

Three changes are worth recording rather than absorbing silently. ru moved ahead of th / vi; tr (Turkish) is new — it appears in no earlier roadmap, so it is written down here to stop it being dropped again; and pl-PL and it-IT joined the tail, Polish first on market size and Italian after it on translation cost. Both mainland variants came directly after their base locale because each is a regional audit over a finished catalog rather than a new translation, and because a Spain reader could already read es-419 while a Portuguese reader fell all the way to English until pt-BR shipped.

Both regional-variant probes are now spent. es-ES stopped being a safe "unregistered" tag in #270 and pt-PT stops being one here. The Portuguese rows that never rot are pt-AO / pt-MZ / pt-CV / pt-TL, and i18n-pt-br.spec.ts now uses pt-AO for its stored-value probe as well as its navigator probe — a registered code cannot stand in for an unregistered stored value.

Arabic stays out of this line. It is a separate stage gated on an RTL-infrastructure audit: the registry carries direction and <html dir> is set from it, but no layout, canvas or baseline has ever been exercised RTL.

A probe tag must therefore avoid all of the above. nl-NL is no longer safe — Dutch shipped as §L2.23, exactly as this section predicted, and the replacement is qaa from ISO 639-2's permanently reserved local-use range, which no roadmap can ever claim; pt-PT is not (and es-ES stopped being a safe probe the moment it registered — i18n-es-419.spec.ts had asserted it reached es-419, and that row flipped exactly as the file predicted it would) — i18n-pt-br.spec.ts uses pt-AO / pt-MZ and says in the file which of its rows are expected to flip when pt-PT registers.

L2.12 — German is ONE catalog, in Germany Standard German, and says so. The registry entry is de, with no region. de-DE, de-AT, de-CH, de-LI, de-LU and every other de-* reach it through the ordinary base-subtag step of the §L5.2 order; German needs no mapping of its own, and a stored de-AT is not a code, so it is ignored like any unregistered value.

The trade-off is stated rather than hidden: Swiss German does not use ß, writing ss everywhere this catalog writes ß, so a de-CH reader sees spelling that is not theirs. Splitting de-CH off would not be a mechanical ß→ss pass either — the vocabulary diverges too — so it stays one catalog until someone reports a term that genuinely misreads there.

German plural is one / other, and 0 takes other. Intl.PluralRules('de') has exactly two categories, no many, and select(0) is other — the opposite of French, where 0 takes one (§L4.6). So German writes 0 Zeilen, not 0 Zeile. All 19 plural messages are rendered at 0, 1, 2 and 1,000,000 by parserLocation-style tests and by the checkpoint measurement.

Schritt covers both senses of "step". French needed pas for a simulation timestep and étape for a stage of a procedure (§L2.7). German does not: Schritt 12 von 30 and Produktionsschritt are both idiomatic, and Schritt carries none of the "fixed position in a sequence" connotation that made étape wrong for the engine. Where a procedural sense needs naming, the compound does it — Arbeitsschritt, Produktionsschritt.

The spreadsheet vocabulary splits three ways, one more than French needed:

English German what it is
spreadsheet (the file) Tabellenblatt the document the user imports
spreadsheet (the app) Tabellenkalkulation Excel, Google Sheets
table Tabelle the data table inside the import wizard

Collapsing any two would make Tabellenname ambiguous in a dialog that shows all three at once.

Core glossary. Speicher Pool · Quelle Source · Senke Drain · Verteiler Gate · Konverter Converter · Ende End · Parameter · Berechneter Wert Register · Knoten node · Verbindung connection · Wegpunkt waypoint · Gruppenrahmen group frame · Linienführung route · Lauf run · Schritt step · Auslöser trigger · Aktivator activator · Kapazität capacity · Rückstau backpressure · Startwert seed · Verteilung distribution · Ausdruck expression · Zeitverlauf timeline · Eigenschaften Inspector · Vorlage template · Modul module · Zuordnung mapping · Vorschlag proposal · Projektstand project revision · Zeile row · Spalte column · Zeichen character position.

Quelle/Senke is the German flow-theory pair, so the two read as a pair. Verteiler says what a Gate does — Gatter would be a logic gate. Berechneter Wert is spelled out rather than compressed to Rechenwert so a first-time reader knows what the node is; Register in German means an index or a ledger and would mislead. Linienführung names how a connection is routed and keeps Verlauf free for Zeitverlauf. Projektstand says "the project as it stood", which is what the file is; Projektversion was rejected as confusable with the app version.

Gacha follows the fr ruling: the mechanism names stay in the English players use — Pity, Hard Pity, UP, and the rarity letters SSR / SR / R — while the actions and rates are German (Ziehung, Getätigte Ziehungen, Ziehungsrate). Trefferquote was rejected: it reads as an accuracy or hit-to-miss ratio, not as the chance of drawing a rarity.

Style. Impersonal or infinitive wherever it reads naturally (Vorlage laden, Modul einfügen), formal Sie/Ihr when a sentence needs a subject, never du. German noun capitalisation only — English Title Case is not copied. Quotation marks are „…“. Numbers, decimals and units come from the locale formatter (numberLocale: 'de' → 1.234.567,89), never hardcoded into a string. Compounds are written naturally; where one does not fit, the SENTENCE is shortened rather than the compound hyphenated.

Fonts need no work. ä ö ü Ä Ö Ü ß all resolve from the bundled IBM Plex Sans Latin subset — measured 7/7 glyphs with CSS.getPlatformFontsForNode in dev, in the production bundle and in the portable single file, with zero third-party font requests in all three. No @font-face and no unicode-range work, unlike Chinese (§L2.5a).

The bundled-module overlay (§L2.11a item 6) takes the dict word three times and refuses it three times. Verarbeitung / Versand / Ausschuss come straight from the production-line Template dict, and Eingangspuffer / Ausgangspuffer are the exact words the German module blurb already uses. The three refusals are the interesting ones:

node German why not the obvious word
planned_run Geplante Produktion Lauf is the app's own word for a simulation run, so Geplanter Lauf would read as a scheduled run
savings Ersparnisse the blurb's Sparen is an infinitive inside a verb phrase; a Pool holds a stock, so it needs the noun. Sparziel keeps the stem
supply Nachschub the dict's Materialnachschub asserts the contents are material, and the bundled module is generic

Abhebungen for withdrawals is the German banking usage, the same reasoning that gave zh-Hant its 提領.

L2.13 — Spanish is es-419, and it is the first locale whose CODE is not its own base subtag. Every earlier Latin locale (fr, de) was reached by the ordinary base-subtag step because its code was the subtag a browser sends. No browser sends es-419: it sends es-MX, es-AR, es, es-ES. Registering es-419 and nothing else therefore hands every Spanish reader English — and, through navigator.languages, sometimes an unrelated language that merely sits later in their list. Measured before the fix, with the real resolver: all fourteen probed Spanish tags returned en, and ["es-MX", "de-DE"] returned de.

So the registry gained baseFallbackFor and §L5.2 gained a fourth step. See §L5.2 for the order and the three structural guards.

One catalog, and the code says which one. es-ES and es-GQ land here too. Peninsular and Latin American Spanish are mutually intelligible — a Spain reader sees computadora and ustedes where they would write ordenador and vosotros, which is a stated trade-off, not a hidden one, and English would be strictly worse. Naming the code es-419 rather than a bare es is what keeps that honest: the picker reads Español (Latinoamérica), so a Spain reader knows what they are getting, and a later es-ES can be registered without renaming this locale or migrating anyone's stored value — step 1 gives it its own tag automatically.

Plural is one / other / many, and 0 takes other. Same shape as French, same zero as German. many IS reachable — select(1_000_000) returns it — so every one of the 19 plural messages writes an explicit many arm even where it matches other: leaving it out would be a silent gap rather than a decision. All 19 were rendered at 0, 1, 2 and 1,000,000 with the real formatter.

Numbers follow the code, not the majority. Intl.NumberFormat('es-419') gives 1,234,567.89 and 83%. Latin America is not uniform here — Argentina, Colombia, Chile and Peru write 1.234.567,89, and between them they outnumber Mexico. The formatter still stays es-419, because a catalog registered under that code must not quietly format under a different one; the divergence is recorded here rather than papered over with numberLocale: 'es'.

The spreadsheet vocabulary splits four ways, one more than German:

English Spanish what it is
spreadsheet (the file) archivo de hoja de cálculo what the user exports
spreadsheet (the sheet) hoja de cálculo / hoja the sheet itself
spreadsheet (the app) aplicación de hojas de cálculo Excel, Google Sheets
table tabla the rows and columns once imported

Microsoft's own Spanish Excel documentation draws the same line — libro for the workbook, hoja de cálculo for a sheet inside it, fila / columna for the structure — so this is the vocabulary a reader already has.

paso and etapa split by context. paso is the simulation timestep and its commands (Avanzar un paso, paso 12); etapa is a stage of a production process (Etapa de producción con búferes). German needed only Schritt; Spanish reads wrong if a production stage is called a paso.

Core glossary. Depósito Pool · Fuente Source · Sumidero Drain · Distribuidor Gate · Convertidor Converter · Fin End · Parámetro · Valor calculado Register · grafo graph · nodo node · conexión connection · Trazado route · ejecución run · paso step · disparador trigger · activador activator · capacidad capacity · Contrapresión backpressure · semilla seed · Línea de tiempo timeline · Propiedades Inspector · plantilla template · módulo module · Estado del proyecto project revision · fila row · columna column · carácter character position.

Fuente/Sumidero is the Spanish flow-theory pair. Distribuidor says what a Gate does, where Compuerta is also the word for a logic gate. Valor calculado is spelled out rather than Registro, which in Spanish means a record or a ledger and would mislead — the same trap German avoided with Register. Propiedades over Inspector: the surface edits the properties of the current selection, which is how Unity's own Spanish manual describes an inspector window.

§L2.11 is honoured: a parser position is carácter {column}, a real table column is columna {column}, and the two never borrow each other's word.

Style. Neutral Latin American Spanish: ustedes, never vosotros. Impersonal infinitives for commands (Cargar plantilla, Insertar módulo), which sidesteps the tú/usted choice entirely; usted where a sentence needs a subject. Sentence case, never English Title Case. ¿ and ¡ opening marks. Quotation marks are “…”. Numbers and units come from the formatter, never hardcoded. Code, ids, file extensions and key names stay verbatim. ordenador, fichero and coger are avoided as peninsular; móvil is not — it is ordinary in Latin America, and dispositivo móvil is used where a sentence would otherwise be ambiguous.

Gacha follows the fr and de ruling: the mechanism names stay in the English players use — Pity, Hard Pity, UP, and the rarity letters SSR / SR / R — while the actions and rates are Spanish (Tirada for both a roll and a pull, Tiradas realizadas, Tasa de aciertos for hit rate — the rate of hits, not the rate of pulls).

Fonts needed no work. ¿ ¡ ñ Ñ á í ó ú are all Latin-1 Supplement, the same block fr and de already proved, and the bundled IBM Plex Sans Latin subset carries no unicode-range restriction. Measured all the same, in dev, the production bundle and the portable single file.

A second pass read all 840 strings against en alone, with no other translation in view, and changed 78 of them, plus 31 of the bundled-Template node labels. Five of its findings are rules, not one-off wordings:

  • The graph is el grafo, never el gráfico. The same catalog needs gráficos for English charts (import.qs.notImported.list), and this product has both a node graph and timeline charts — one word cannot carry both. gráfico also made Al archivo de gráfico le faltan sus nodos read as the chart file is missing its nodes. 29 strings moved.
  • One quotation style. en mixes “…” and "…", and the first draft inherited the mix, curling the quotes around UI labels and leaving them straight around interpolated values. Both are user-facing prose here, so every one is now “…”.
  • Spanish agrees where English does not. '{n} hidden' is fine at n = 1; '{n} ocultos' renders 1 ocultos. The obvious repair — wrap it in a plural — is refused by check:i18n, which requires the ICU argument shape to match en (a plain slot cannot become a plural). The fix is an invariable phrase, {n} sin mostrar. Any locale with adjective agreement will hit this class of gap on a base key that has no plural.
  • Watch the words a term collides with in Spanish, not in English. seguidos for tracked reads first as consecutive; Equipo for Staff reads as equipment in a roastery; nombre propio is a proper noun, not a custom name; procesos for Web Workers claimed OS processes. All four parse fine and all four say something else.
  • Read a label in the box it is rendered into, not in the catalog. dist.ended is Ended in English and sits in front of a figure the component supplies: {label} <b>43%</b>. English gets away with a bare participle there; Spanish needs agreement and a colon, so the label is Finalizadas: — feminine plural, because what ended is ejecuciones, and the sparkline caption right below shows the same number the other way round (43% finalizadas) and now uses the same word. Measured in the real row at 0 %, 43 % and 100 %: no overflow, 78–92 px. The % is glued to the figure by the component, and Intl.NumberFormat('es-419', {style: 'percent'}) agrees — 83%, no space — so the catalog does not add one.

The wording fixes with a measurement behind them: templates.equilibrium.blurb was re-written from a noun-phrase list into an action sentence and re-measured in the real 272 px box (line-height 16.875, clamp 2) — 2 lines, 0 px of overflow, menu not widened, clamp not raised.

src/i18n/es419Copy.test.ts holds the mechanical half of that review so it cannot rot — control characters, newline parity with en, {name} slot parity, markup tags, ¿ on every question and ¡ on every exclamation (0 today, so the rule is asserted over the empty set and the count is pinned).

Each of its guards is scoped to where the word would actually be wrong. A catalog-wide banned-word list is the wrong instrument: it would forbid registro for a log, puntuación for punctuation, gráfico for a chart — all of which this product may legitimately need later.

guard scope why
vosotros vuestro ordenador fichero coger global no surface in this product makes an Iberian form right
piscina compuerta desagüe registro only where en names that node kind outside that surface each word has an ordinary, correct meaning
cartera reintegro puntuación only the three ruled labels asserted as equalities on wallet, withdrawals and gear_score
grafo vs gráfico decided per key by the English source en says graph or it says chart; the format name Graph JSON is stripped first
node-kind glossary derived from en if English names a kind, Spanish must use the glossary term — so a new string is covered the day it is written

Source and End are deliberately not derived: English uses source for an edge endpoint (origen) and end for the end of a phase, so those two rest on the central contract — the three keys that NAME each of the eight kinds.

The English allowlist is scoped the same way. Tokens that are obvious anywhere (Loop Studio, Graph JSON, CSV, Monte Carlo, and the Spanish/English homographs like material, normal, local) are allowed globally. Everything else is pinned to the exact keys that own it: the sample CSV's headers (item name price drop rate) to import.qs.mapping, the resource-type tokens (Gold Energy XP Player Item) to inspector.resourceType.placeholder, the gacha vocabulary (gacha banner pity) to the gacha Template's two keys, and so on. A global token list would have hidden an untranslated sentence elsewhere; the scoped one catches it — verified by leaving import.qs.result in English and watching the guard name Number, Parameters, columns, rows. The raw wire edge kind is checked separately: inspector.edge.kindLink must keep the {kind} slot and must never hardcode resource or state.

móvil is on none of these lists, deliberately.

No Latin American native-speaker or professional translation review was performed. The low-confidence terms are listed in the PR body rather than hidden.

L2.14 — Brazilian Portuguese is pt-BR, the second locale to own a base subtag, and the first whose PLURAL rules differ from its own mainland variant. Shipped as the ninth language. What it decided, and why.

The resolver. Like es-419, the code is not its own base subtag: a browser sends pt, pt-BR, pt-PT, pt-AO. Measured with the real resolveInitialLocale before the entry existed, all of pt, pt-BR, pt-PT, pt-AO, pt-MZ, pt-CV, pt-GW, pt-ST, pt-TL, pt-MO resolved to en — and two multi-tag lists resolved to an unrelated language outright: ["pt-AO","de-DE"] gave German and ["pt","es-MX"] gave Spanish. The entry therefore declares baseFallbackFor: 'pt' (§L5.2 step 4). No resolver code changed; the field and the per-TAG walk already existed for es-419.

pt-PT USED TO land here too, and that trade-off was bigger than the Spanish one. European Portuguese differs in vocabulary (ficheiro / guardar / partilhar / carácter) and in grammar: CLDR gives pt-BR 0 -> one and pt-PT 0 -> other, so a Portugal reader saw a plural form their own variant would not use. Brazilian Portuguese was still far closer to them than English, which was the only other option at the time.

That trade-off is now closed: pt-PT shipped as its own locale (§L2.16), and registering it made §L5.2 step 1 (exact code) win its own tag with no resolver change, exactly as predicted here and exactly as es-ES did beside es-419. The pt-PT row in e2e/i18n-pt-br.spec.ts flipped by design; the rows that never rot are pt-AO, pt-MZ, pt-CV and pt-TL, and that spec's stored-value probe moved to pt-AO as well, because a registered code cannot stand in for an unregistered stored value.

One prediction in this section did NOT survive contact: ecrã was listed above as a European word this catalog lacks. It is — but it is not the European word for anything pt-BR says tela for. Every one of those keys renders English canvas, not screen, so pt-PT keeps tela too (§L2.16).

Plural: one / other / many, with 0 in one. Measured in node and in the production Chromium runtime, identically. That is the FRENCH shape, the opposite of es-419 and de, so copying the Spanish plural arms would have mis-rendered every zero. many is reachable at 1e6, and Portuguese puts de before the noun there — 1.000.000 de linhas — so all 19 plural keys write an explicit many arm. The inverse constraint also applies: check:i18n requires the ICU argument shape to match en, so a key whose base has a plain {n} slot cannot be given a plural. canvas.filter.hiddenCount and regExpr.pick.more are therefore invariable phrases.

Numbers: 1.234.567,89. The first shipped locale whose decimal separator is a comma and whose group separator is a period. This turned out to be a much smaller risk than it looks, and the audit is worth not repeating: the product has no Intl.NumberFormat call site at all (§L8 leaves numberLocale for strings that read wrong, and none have), so the only live number path is ICU #, which takes the locale CODE and renders integer counts — the group separator can appear, the decimal separator cannot. A user-typed number goes through <input type="number">, whose .value is always the standard . form, and the CSV rule (dataImportValidate.ts) explicitly rejects a thousands separator. The expression grammar's . is product syntax and is unchanged (§L8: never reformat stored, digested or canonical content).

Glossary, and the one term that had to be an anglicism. Pool Reservatório (not depósito, which in Brazil is also a bank deposit and would collide with the reward-split module's Carteira / Poupança), Source Fonte, Drain Sumidouro (the standard Portuguese term for a flow-network SINK; ralo and escoadouro are plumbing), Gate Distribuidor, Converter Conversor, End Fim, Parameter Parâmetro, Register Valor calculado.

A group frame is a quadro: the product defines one as "a labelled box that groups nodes on the canvas", which is what Miro's Brazilian UI calls a quadro. moldura is a picture frame in Portuguese and would name the border rather than the grouping; área and grupo are already taken by this feature's own default labels. The canvas is the tela, which is unambiguous here because the English catalog never says "screen".

A Template is a template, deliberately not a modelo. Modelo is the standard Brazilian word for a document template — and modelo is already this product's word for the simulation MODEL, in 10 keys and 14 occurrences ("run the model", "a v2 model", "model versions"). One word for both would make modules.promote.* read as though loading a Template changed the model version. It is an ordinary common noun, not a proper name: Template / Templates only where UI context capitalises the first word, template / templates inside a sentence.

Register has ONE Portuguese surface. English shows Register / Registers on exactly 10 keys (3 that name the kind, 7 prose); Computed value is not an English UI string anywhere in the catalog. All 10 read Valor calculado / Valores calculados. Registrador is never used — registro stays free for its ordinary log / record senses, and nothing bans it elsewhere.

Guard scope — the rule that keeps being relearned. A word is rejected only where it would be wrong. src/i18n/ptBrCopy.test.ts splits them:

scope forms
global (never right in Brazil) ficheiro · ecrã · utilizador · carácter · registo · telemóvel · planeado
parser character-offset keys only caráter — ordinary Brazilian for a nature or quality (de caráter permanente)
keys where English says stock / inventory existências — also the plain plural of existência
keys where English says team / staff equipa — also the third person of equipar, which this product uses
never banned registro, guardar — palette.pool.description legitimately says "Guarda recursos"

The parser key list is derived from en (the error.EXPR_* and import.parseError keys carrying {column}) and its size is asserted, so it cannot drift from §L2.11's 7-and-3 split.

Two defects the guards caught that no other gate could.

  1. A Cyrillic letter inside a Portuguese word. import.error.notLoopStudio shipped Isto не parece… — U+043D U+0435 where não belongs. tsc, oxlint and check:i18n all accept it: it is a valid string. The guard is a Unicode Script check over the pt-BR RUNTIME data (catalog, template labels, module overlay and the registry's display fields), allowing only Latin, Common and Inherited. It is a cross-script contamination contract, NOT a homoglyph detector — a Latin look-alike is Script=Latin and passes. Scanning source FILES was tried first and abandoned: the moment known.generated.ts was regenerated it held every locale's labels and reported 3,780 "violations".
  2. The guard's own tokenizer inventing English. [A-Za-z]+ cuts a Portuguese word at every accent, so até yielded at, Papéis yielded is, início yielded in and orçamento yielded or — 219 false positives. The word pattern is now \p{Script=Latin} plus combining marks, and the English-residue list drops every Portuguese homograph (a, no, for, converter, remove).

Measured overflow: 3 blurbs, fixed by wording. In the real 272 px .menu__blurb box (line-height 16.875, clamp 2, max height 34 px), templates.equilibrium.blurb, templates.coffeeRoastery.blurb and modules.rewardSplit.blurb each needed a third line. Candidates were measured in that box rather than estimated — Portuguese does not fit where Spanish does at the same character count — and the chosen wordings land at 2 lines with ovY = 0 while keeping every beat of the English. The full shipped-locale sweep is then 0.

The second review changed 29 strings, none of them in the template-label dictionary or the module overlay. Five were gender/number agreement that {label} and {name} make structurally impossible — a frame called Área 1 rendered as Área 1 movido, and Parênteses as Parênteses inserido — and were fixed by choosing a phrasing that needs no agreement at all. One was a term collision the guards could not see: the mobile timeline sheet had been called an aba, which is this catalog's word for a BROWSER tab and the marker localeSurfaceCopy.test.ts looks for. One was a dangling de in a many arm with no noun after it (… e mais # de) — the plural rule applied mechanically where it does not hold.

No Brazilian native-speaker or professional translation review was performed. The low-confidence terms are listed in the PR body rather than hidden. Three items stay open and are stated rather than buried:

  1. completion is Progresso in the MMO template and conclusão in the gacha one. English uses one word; the two templates mean different things (progress toward the level goal vs the run finishing), so the split is deliberate — but it wants a native reader's confirmation.
  2. drop and loot are kept in English in the MMO template, as the Brazilian game-design register uses them. Scoped to that template's keys.
  3. workers in mc.cost.parallel stays English: it names Web Workers.

L2.15 — Spain Spanish is es-ES, and it is the first locale that is a REGION AUDIT rather than a translation. Shipped as the tenth language, over the finished es-419 catalog. 38 strings differ out of 1,064 (842 catalog + 203 template labels + 19 module labels). That number is the point: a locale that mostly agrees with its sibling is the correct outcome, not an unfinished one, and nothing was changed to make the difference look larger.

The resolver needed no new machinery. Unlike es-419 and pt-BR, this code IS a tag browsers send, so it declares no baseFallbackFor. §L5.2 step 1 (exact code) runs before step 4 (base owner), which is exactly the property the es-419 work asserted against a hypothetical registry — that test now runs against the real entry, and the prediction held.

navigator resolves to
es-ES, ES-es es-ES
es, es-419, es-MX, es-AR, es-CO, es-CL, es-PE, es-US es-419
es-GQ es-419 — see below
ca-ES, eu-ES, gl-ES en (Catalan, Basque and Galician are not Spanish)

es-GQ stays with es-419, and that is a stated trade-off. Equatorial Guinea's usage is historically closer to Spain, so the tempting move is to point it here. Nothing in this catalog is written for it either way, and re-pointing it would be a guess dressed up as a decision — it is left where it already resolved, and written down so the next person finds a choice rather than an oversight.

What differs, in full.

area n what
catalog 4 the device is an ordenador, not a computadora
catalog 8 you pulsa a key or button (presionar is Latin American); the same four a11y strings relabel the Enter key Intro, which is what a Spanish keyboard is printed with
catalog 2 you escribes into a field; ingresar is Latin American
catalog 16 you anades something — agregar reads Latin American
catalog 2 a NO-BREAK SPACE before the percent sign
template labels 1 Puntuación de equipo — puntuación is the Iberian score, puntaje the Latin American one
template labels 2 suministro for supply; abasto survives in Spain mainly in the idiom no dar abasto
template labels 1 a business forecast is a previsión, not a pronóstico
module labels 2 Cartera for a wallet and Retiradas for withdrawals — both were flagged as deliberate regional markers when es-419 shipped

What deliberately does NOT differ, and why each was considered and left:

  • the usted register. Spain uses usted for software too. Switching to vosotros would be a change of tone, not a correction, and the catalog's impersonal and infinitive command forms are natural in both. esEsCopy.test.ts asserts no tú or vosotros form reaches any surface, so a later edit cannot mix registers on one screen by accident.
  • archivo. Entirely natural in Spain; fichero is not required, and forcing it would be regional display, not translation.
  • coger. Deliberately not introduced. es419Copy.test.ts bans it for Latin America because it is vulgar there — that is not a reason to add it here, where the UI idiom is seleccionar / elegir anyway.
  • the whole node-kind glossary, and Botín, Tirada, Vender al mercader, Tickets, Merma, Reveses: identical in both.

The percent gap is a NO-BREAK SPACE (U+00A0), on two keys. Intl.NumberFormat('es-ES') emits one before the sign and es-419 emits none — the Latin American catalog wrote Monte Carlo {pct}% precisely to match its own formatter, so Spain has to move with its own. The two keys are playbar.mc.progress and runbar.mc.cancel. Everything else that shows a % is left alone: inspector.edge.flowPlaceholder's 25% is what a USER types, and import.issue.invalid-number names the SYMBOL.

The character is invisible in review, so it is pinned three ways and never written as an escape: esEsCopy.test.ts asserts the code point before % is 160 and that no ordinary space is used, that exactly those two strings contain it, and e2e/i18n-es-es.spec.ts renders the string into a deliberately narrow box and reads per-character line boxes — the text wraps, and the number and the % stay on the same line. The accessible-name string is checked for the same gap.

Two Spanish locales, two guards, neither banning the other's word. es419Copy.test.ts is untouched and still pins Puntaje de equipo; esEsCopy.test.ts pins Puntuación de equipo and asserts the es-419 value alongside it, so the split is visible from either side. esEsCopy.test.ts also asserts the delta LISTS themselves — which catalog keys, which template labels, which module labels differ — so a future edit that quietly diverges the two catalogs has to say so.

What the independent second review added, and how it found it. The first pass screened for a candidate list of Latin American words and found 12 catalog strings. The second pass did something different: it extracted the LEADING WORD of all 842 values (390 distinct) and read the vocabulary itself. That found three groups the first pass could not:

  1. Presione at the start of a sentence — four a11y strings. The first screen was case-SENSITIVE, so sentence-initial forms never appeared in it.
  2. Entrar for the Enter key, in those same four strings. Keyboard key names were not on the candidate list at all; a Spanish keyboard is printed Intro.
  3. agregar in sixteen strings — the largest single group in the locale, and simply absent from the candidate list. Spain says anadir.

The lesson is recorded because it generalises: a candidate-word screen finds what you already suspected, and a region audit needs at least one pass that reads the locale's own vocabulary back to you.

esEsCopy.test.ts asserts the resulting delta LISTS — which catalog keys, which template labels, which module labels differ — so a later edit that quietly diverges the two Spanish catalogs has to say so in the diff.

No Spain native-speaker or professional translation review was performed. Two items stay open rather than being smoothed over: tomar cualquiera / tomar todo for the flow modes (understood in Spain, but extraer may be the more idiomatic UI verb there — not a regional error, so it was left), and Vender al mercader (a fantasy register that reads the same in both).

A coverage gap this work closed. icuEscaping.test.ts (§L2.11a item 9) keeps a hand-written CATALOGS map, because its rendering has to be synchronous while the registry's catalogs are lazy import() chunks. That made it the one item-9 guard a locale could join the product without: pt-BR shipped in #268 and was simply absent from it, so its ICU quoting and plural fallback went unchecked. Adding it showed no defect — a coverage gap, not a bug — and the map is now asserted exhaustive over the registry, which caught es-ES immediately on its first run.

L2.16 — European Portuguese is pt-PT, the second REGION AUDIT, and the first one where the two catalogs differ in GRAMMAR. Shipped as the eleventh language, over the finished pt-BR catalog. 191 strings differ out of 1,065 (843 catalog + 203 template labels + 19 module labels): 162 catalog, 24 template labels, 5 module labels. Five times what es-ES moved, and that is the finding rather than a failure of restraint — European and Brazilian Portuguese diverge further than Spain and Latin America do.

The resolver needed no new machinery, again. Like es-ES and unlike es-419 / pt-BR, this code IS a tag browsers send, so it declares no baseFallbackFor: §L5.2 step 1 (exact code) runs before step 4 (base owner), so pt-BR keeps owning pt. Measured with the real resolver before the entry existed and again after:

navigator tag resolves to
pt-PT, PT-pt, pt-pt pt-PT
pt, pt-BR, pt-AO, pt-MZ, pt-CV, pt-GW, pt-ST, pt-TL, pt-MO, pt-CH, pt-LU pt-BR
pt-Latn-PT, pt-PT-u-ca-gregory pt-BR — see the limit below

A known resolver limit, recorded rather than fixed here. A tag carrying a SCRIPT or an EXTENSION subtag misses step 1 and falls to the base owner. BCP 47 permits both and navigator.languages returns BCP 47 tags, so this is not "a tag no browser sends" — it is a normalisation the resolver does not do. es-ES has the identical limit. Fixing it means changing tag matching for every locale at once, which does not belong in a locale PR; registry.test.ts and i18n-pt-pt.spec.ts both pin the current behaviour so the next reader finds a decision instead of a surprise.

§L5.1 stays deliberately stricter than §L5.2. pt-pt as a NAVIGATOR tag reaches pt-PT because step 1 lowercases both sides; as a STORED value it does not, because a stored value is matched exactly with no case repair. Both directions are asserted.

What differs

area n what
catalog 41 a file is a ficheiro, not an arquivo
catalog 24 a connection is a ligação, not a conexão
catalog 16 spreadsheets: folha de cálculo, not planilha
catalog 14 sharing is partilhar, not compartilhar
catalog 13 saving is guardar, not salvar
catalog 10 you prima/premir a key, not pressione/pressionar
catalog 8 the parser offset is a carácter, not a caractere
catalog 7 deleting is eliminar, not excluir
catalog 21 the address register — see below
catalog 15 progressive aspect — see below
catalog rest separador (browser tab) not aba; existências not estoque; controlo not controle; aplicação not aplicativo; transferir not baixar; gerir/gestão not gerenciar; contacto not contato; detetar not detectar; por isso not então; num/numa contractions; the article before a possessive
template labels 24 Existências · Equipa · planeado · online · treino · reparação · preparação · procura · encomendas · por grosso · retalho
module labels 5 Receção · Produção planeada · Levantamentos · Património líquido · Progresso até à meta

The two that are not vocabulary.

  • Address register. pt-BR speaks to você in 21 strings. European Portuguese software does not; it uses an infinitive, an impersonal construction or a null-subject third person. tu and vós are not the European alternative and are never introduced — ptPtCopy.test.ts bans all three, so a later edit cannot mix registers on one screen.
  • Progressive aspect. Brazilian estar + GERUND becomes European estar a + INFINITIVE — está a bloquear, não estão a ser guardadas, estava a editar. 15 occurrences across 12 keys. Standalone gerunds were judged one at a time rather than swept: a carregar… and a estimar… moved, incluindo did not.

What deliberately does NOT differ

  • tela. The candidate-word screen proposed ecrã; the meaning check rejected it. All 24 of those keys render English canvas, not screen — ecrã is a physical display, so applying it would have been a mistranslation dressed up as a regionalisation. The test asserts the REJECTION, key by key against the English original, so a later well-meaning sweep cannot quietly apply it. This is not the same as proving tela is the best word for Portugal — it stays on the native-review list (open item 1), against área de desenho and the English canvas.
  • the node-kind glossary — Reservatório · Fonte · Sumidouro · Distribuidor · Conversor · Fim · Parâmetro · Valor calculado. None of the eight is regional.
  • quadro for a group frame. moldura was rejected during the pt-BR audit as the wrong word and is not reintroduced; it stays on the native-review list.
  • template. Kept for the same non-regional reason as pt-BR: modelo is already this product's word for the simulation MODEL in 10 keys.
  • Carteira. Unlike the Spanish pair, there is no wallet split — Portugal and Brazil both say Carteira, so nothing moves.
  • the percent strings. MEASURED: Intl.NumberFormat('pt-PT') emits 84% with no space, exactly as pt-BR does. Unlike es-ES, this locale adds no NO-BREAK SPACE to any string.

Two measured behaviours, and where each one comes from

Plural at zero. CLDR gives pt-BR 0 → one and pt-PT 0 → other: "0 linha" against "0 linhas". Both keep the same three categories (one / many / other), so no key gains or loses an arm — only the selection moves. All 19 plural keys already carried many, which Portuguese reaches at 1e6 and writes with de before the noun.

The group separator is a NO-BREAK SPACE. pt-PT groups with U+00A0 (1 234 567) where pt-BR uses a period (1.234.567), and CLDR's minimumGroupingDigits: 2 means a bare 1000 carries no separator at all. Nothing calls Intl.NumberFormat (§L8); the live path is ICU #, which delegates to exactly that formatter with the locale CODE. So this locale's NBSP reaches the DOM from the formatter, never from the catalog — and ptPtCopy.test.ts asserts the catalog contains none, which is the opposite of the contract es-ES needed.

An honest limit on how far that is tested. No product surface can show a four-digit count today: every # key counts nodes, rows or tables, and reaching 1000 of any of them is not a state a spec can set up cheaply. The e2e therefore renders the same formatter the ICU path uses, in the same page, for the live <html lang>, and reads the result back out of the DOM. It does not claim to exercise a product call site, and it says so.

The method, and the same lesson twice

The candidate-word screen found the first four groups. Reading the catalog's leading word back — 412 distinct words over 843 values — found compartilhar (14 strings), which was not on the list at all. That is the es-ES lesson repeating.

It then repeated a second time on a surface small enough to feel safe. The template labels — 203 slots: 196 node labels + 7 frame titles — had been screened for the same candidate list and looked done at 10 deltas; reading their 206 distinct words back found seven more groups — treino, reparação, preparação, procura, encomendas, por grosso, retalho — and took the count to 24. A small surface does not earn an exemption from the vocabulary read-back.

How the delta is pinned

ptPtCopy.test.ts pins the count (162 / 24 / 5) plus two DIRECTIONAL contracts, rather than a hand-written list of 162 key names: nothing Brazilian survives in pt-PT, and every pt-BR string carrying a Brazilian marker actually changed. A flat list would restate the count without proving either. ptBrCopy.test.ts is untouched.

Open items

No Portugal native-speaker or professional translation review was performed. Five items stay open rather than smoothed over:

  1. tela for the canvas. Rejecting ecrã is well founded — those keys render canvas, not screen, so ecrã would be a mistranslation. But ruling out the wrong word does not prove the remaining one is the best word. tela, área de desenho and the English canvas are all plausible in Portugal, and which reads most naturally there is exactly the kind of question this audit cannot answer from the outside. The guard pins the rejection of ecrã, not the superiority of tela.
  2. quadro for a group frame — kept from pt-BR, where moldura was rejected as the wrong word.
  3. Rolagem for a loot roll — used in both, but a Portuguese designer may say sorteio.
  4. Receção for goods intake in the module overlay — Recebimento is understood but is not what a Portuguese warehouse screen says.
  5. drop / loot, kept in English exactly as pt-BR keeps them because the game-design register is the same on both sides of the Atlantic.

L2.17 — Russian is ru, the first Cyrillic catalog and the first four-arm plural. Shipped as the twelfth language, translated from en rather than audited over a sibling. 844 keys, plus 203 template-label slots and 19 module labels.

The resolver got SIMPLER, not harder. ru IS its own base subtag, so §L5.2 step 3 (a registered code that IS the base subtag) carries every ru-* tag and no baseFallbackFor is needed. Measured with the real resolver before the entry existed:

navigator tag resolves to
ru, ru-RU, ru-BY, ru-KZ, ru-KG, ru-MD, ru-UA, RU-ru, ru-ru ru
ru-Cyrl, ru-Cyrl-RU, ru-RU-u-ca-gregory ru
uk, be, bg, kk, sr, mk en — not Russian, not captured

That third row is the interesting one. §L2.15 and §L2.16 both record a known limit: a tag carrying a script or extension subtag misses es-ES / pt-PT and falls to the base owner. ru does not have that limit, and the reason is worth stating because the two cases look identical from outside: the limit only exists for a locale whose CODE IS NOT ITS OWN BASE SUBTAG. Those can only be reached by step 1, which matches the whole tag; ru is reached by step 3, which splits on the first subtag and therefore ignores everything after it. Nothing was changed in the resolver to get this.

§L5.1 stays stricter than §L5.2: ru-RU as a NAVIGATOR tag reaches ru, but as a STORED value it is not a registered code and is ignored outright.

Plural: four arms, and other is unreachable from an integer

Intl.PluralRules('ru') has one / few / many / other. Measured:

n 0 1 2 5 11 20 21 22 25 101 111 1e6
many one few many many many one few many one many many

Two consequences that shaped every one of the 19 plural keys.

one is not "one". It selects 1, 21, 101 and 1 000 001. An arm that spelled the numeral out — one {одна строка} — would render "21 одна строка". Every arm keeps #, and ruCopy.test.ts asserts it arm by arm.

other is unreachable from a non-negative integer. Sweeping 0..3000 plus 1e6 reaches only one, few and many. It is kept because ICU requires it and because a DECIMAL reaches it (1.5 → other), so its wording is the decimal-agreeing form and not a copy of few. This is the first locale where an arm that can never render in today's product still had to be written correctly.

Numbers: U+00A0 twice over

MEASURED: Intl.NumberFormat('ru') groups with a NO-BREAK SPACE and uses a comma for the decimal mark — 1 234 567,89 — and {style:'percent'} puts U+00A0 before the sign (84 %).

So this locale needs both halves of what the earlier ones needed separately: the formatter's grouping character, like pt-PT, AND the two {pct} catalog strings carrying U+00A0, like es-ES. Unlike pt-PT, a bare 1000 does keep its separator. The character is never written as an escape; the guard asserts the code point before % is 160 and that exactly two strings contain it.

Script: what the check catches, and what it cannot

The catalog is Cyrillic plus Script=Common / Inherited. Two limits are recorded rather than implied:

  1. A script check cannot tell Russian from another Cyrillic language. Ukrainian ї, Belarusian ў, Serbian ђ and Bulgarian text are all Script=Cyrillic and would pass. So there is a SECOND check against the Russian alphabet by name, which rejects them.
  2. Homoglyphs are invisible. а е о р с у х А В Е К М Н О Р С Т У Х are identical in shape to their Latin counterparts. A Cyrillic С inside CSV or a Latin a inside a Russian word cannot be seen. So Latin runs are compared as EXACT strings against the English source of the SAME key: a homoglyph changes the bytes and fails.

The Latin allowlist is derived, not written. An early attempt listed 47 tokens by hand (CSV, JSON, Enter, p50, item_id, Machinations.io, Alex, …) after measuring what ko, ja and zh-Hans — already non-Latin — all keep. That list was thrown away: the rule that actually holds is a Latin run may stay only where the English original of that key contains the same run, which needs no list, cannot rot, and is what shipped. A separate check catches the complement — a value that is all Latin and no Cyrillic is an untranslated sentence even if every token appears in en.

The font was already wrong, and the fix had two traps

The app imported only @fontsource/ibm-plex-sans/latin-400.css and its 600 sibling. Those files declare the family with no unicode-range at all, so the browser considered IBM Plex Sans a candidate for every code point — including Cyrillic — while the woff2 behind it carried only Latin glyphs. Measured on production before the change: document.fonts.check(…, 'Привет') returned true and Cyrillic rendered 617.25px against 633.89px for the bare system stack. The family claimed the glyphs; the engine then fell back per glyph to whatever the OS had. Same class as the :lang(ja) Han-unification defect.

Two traps, both measured:

  1. Adding cyrillic-400.css does not work. It declares the same family, weight and style, also with no unicode-range, so the last rule wins for every code point — importing it after the Latin file moves Latin onto a Cyrillic-only woff2 and breaks the whole UI. The per-subset files cannot be combined.
  2. The combined 400.css / 600.css carry correct ranges but pull greek, vietnamese, latin-ext and cyrillic-ext too — built and counted at 13 woff2 files / 208 KB against today's 3 / ~60 KB, every one of which the PWA precache glob would then precache.

So index.css declares two faces itself, with the range fontsource uses for its own cyrillic subset (U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116 — it covers ё and №), sourced from the subset woff2. cyrillic-ext is historic and Slavic-extended and is deliberately excluded. Result: 5 woff2 / 96 KB, +29.2 KB, and a production-build measurement confirming Latin still comes from Plex (352.66 vs 363.05 system) while Cyrillic now does too (708.25 vs 732.22 at 400, 729.75 vs 773.67 at 600).

ё was a search defect too, and it was measured before it was fixed

The same letter the font work had to cover turned out to break the language picker. MEASURED on foldForSearch before any change:

fold input fold output
ё (U+0451) U+0451
е (U+0435) U+0435 not equal
Ё (U+0401) U+0451 case folds, spelling does not

So the two were different strings. That matters for exactly one row, and the row is not the Russian one: ru is Русский / Russian / ru, none of which carries ё, so Russian was always findable. The entry that was not findable is Simplified Chinese, which the Russian UI spells Китайский (упрощённый) — typing упрощенный, which is how the word is normally typed, returned nothing. It was the only one of the twelve language.* values in the ru catalog with ё in it.

The fix is in §L5.5 item 3 and is one letter wide. It was falsified twice: with the rule removed the two new tests go red, and with it widened to all Cyrillic the й/и guard and the untouched-Cyrillic test go red instead. The contract is pinned in both places it can break — languageOptions.test.ts on the predicate, and e2e/i18n-ru.spec.ts against the real picker, where the e2e case first asserts the row really is spelled with ё so the search assertion cannot pass for the wrong reason.

Three blurbs did not fit, and the budget is a measurement not a rule

The shipped-locale sweep in descriptive-copy-wrapping.spec.ts caught three .menu__blurb values at three lines in a two-line clamp: templates.equilibrium, modules.rewardSplit and export.projectRevision. Russian is wider per character than the Romance catalogs, and the English source fits at 95 characters where Russian did not fit at 91. The budget was read off the ru blurbs that already passed — the longest is templates.deadlock at 79 characters — and the three were rewritten to 71, 60 and 61. Nothing was loosened; the box is the same box every other locale passes in.

Decisions, and what stays open

item decision
node kinds Накопитель · Источник · Сток · Распределитель · Преобразователь · Конец · Параметр · Вычисляемое значение. Регистр is never used — it reads as a CPU register, and in another sense as letter case
keyboard keys Latin keycaps kept: Enter, Space, Delete, Backspace, Esc, Shift, Alt, Ctrl, Cmd+Z. A Russian keyboard is printed with them, and ko / ja / zh make the same call
ё written where the standard spelling has it, not flattened to е
register impersonal or infinitive first; lowercase polite вы where a sentence must address the reader. ты / твой never introduced, and a capitalised mid-sentence Вы is rejected
Template / model Шаблон and Модель. Russian has two words and neither is taken, so the anglicism the Romance catalogs had to keep is not needed here

ё is pinned, not automated. Nothing can prove a catalog spells every ё-word correctly without a dictionary. What the guard pins is the EXPLICIT LIST of the 36 words that carry it today, so flattening one shows up in the diff and has to be argued for. That is a smaller claim than "the catalog is checked", and it is the true one.

The read-back, and a second review that changed nothing

The vocabulary read-back was run twice, separately — 844 catalog values (429 distinct leading words, 1 394 distinct words) and 203 template-label slots (95 leading, 187 distinct) — because §L2.16 recorded that skipping it on the smaller surface is exactly how seven groups were missed there.

A concept-consistency pass then asked whether one English term had picked up two Russian words. Five splits were flagged and all five turned out to be driven by the English source, so nothing was changed:

  • связь (27) vs соединение (1) — the one is Connection point, a place where things join, not the edge object;
  • выполнение / прогон / запуск — execution, one Monte-Carlo run, and view & run's imperative, three different English senses;
  • шаг vs этап — the simulation timestep vs a production stage, the same split etapa / passo makes in the Romance catalogs;
  • метка vs подпись — the Label field vs a device-local label attached to the file, which is a signature;
  • выражение vs формула — regExpr.op.inserts renders English formula.

That pass finding nothing was a measurement of the pass, not of the catalog. A vocabulary read-back and a concept-consistency screen both ask one question — has one English term picked up two Russian words — and Russian breaks in ways that question cannot see. A second, grammatical pass over all 844 pairs, reading the English beside the Russian, found 28 defects across 28 keys, and a quote convention that needed applying to 12 more.

The grammar pass, and the seven things it asked

dimension found
case government after a preposition or a governing noun 12
a slot whose gender or number is unknown 2
a transitive verb left without an object 4
a count with no agreement ({n} столбцов is wrong for 2–4) 4
term and style agreement between button, title and description 3
meaning dropped or blurred against the English 3
quote convention (separate from the 28) 12 keys
aspect (perfective vs imperfective imperative) 0
вы register 0

The biggest group has one shape: a slot cannot inflect. {label}, {name}, {header}, {param} and {table} carry user text, so any Russian that governs a case around them is wrong — Значение {label} needs the genitive, поток через {param} the accusative, создаст цикл с {name} the instrumental. All 39 slot occurrences were listed with the word on each side and read one by one; ten needed the fix, and the fix is the standard one: guillemets license the nominative, so Значение «{label}» is correct for any label. Where a nominative classifier noun already precedes the slot — Таблица {table}, Рамка {label} — nothing is needed, and that is why the rule is stated as a condition rather than applied everywhere.

Two arms that could never agree. import.summary read будет создано {parameters, plural, …}, with the verb OUTSIDE the block, so the one arm rendered будет создано 1 Параметр — neuter verb, masculine noun. The sibling key import.placement.framePerTableResult already put its verb inside the arms; that is what made the defect visible. regExpr.op. inserted was {name} вставлен, and {name} can be Скобки (plural) or an infinitive, so it became Вставлено: {name}.

Three counts had no plural block, and could not be given one. check:i18n requires the argument KIND to match en (§L12 #2), so a locale cannot turn a plain {n} into a plural where English does not need one. German, French and Spanish never hit this because their plural is uniform; Russian needs столбца for 2–4 and столбцов for 5+. The answer is to reword so the number never governs a noun: отмечено больше одного столбца ({n}), ячеек: {actual}, предел таблиц: {max}. The third one was not hypothetical — DI_TABLES_MAX is 64, so предел в 64 таблиц was always wrong.

Quotes. Russian quotes with « », and the catalog already did in 16 places while 12 others kept the ASCII quotes of the English source. fr quotes the same keys with « », ja with 「」 and zh with “ ”, so the convention is per-locale and the mixture was simply a gap. The three parser messages keep their “ ” deliberately: they quote a literal syntax character and they carry ICU apostrophe escapes (§L4.1) that are not worth disturbing for typography.

Two dimensions came back clean, and that is a result too. Aspect is principled throughout: imperfective only where the action is durative (тяните, Удерживайте Alt, продолжайте вводить, and the tour bodies that describe habitual work), perfective for every one-off command. And a mechanical check — same English value, different Russian value — returned four groups, all of them senses English merges and Russian must split: Import as a menu noun vs a button verb, Add as a hunk action vs the + operator, and Label as a node's caption vs a spreadsheet column's role.

The second coverage gap of the same shape

parserLocation.test.ts is the other item-9 guard with a hand-written CATALOGS map, for the same reason: render() is synchronous and the registry's catalogs are lazy chunks. The gap icuEscaping.test.ts closed for pt-BR (§L2.15) was still open here, and this was measured rather than assumed — with ru's import, its CATALOGS entry and its VOCAB row all removed, tsc -b exits 0 and the file passes 23/23.

The type system does not help, and it is worth saying why, because the map looks type-checked: VOCAB is keyed on Exclude<Loc, 'en'> and Loc is keyof typeof CATALOGS, so a locale absent from CATALOGS is also absent from the type it would have to satisfy. Every test in the file then iterates LOCS, which is derived from that same map, so a missing locale is not a failure — it is simply not tested.

Both maps are now asserted exhaustive over the registry, CATALOGS including the base locale and VOCAB excluding it, plus an assertion that LOCS really is the full set. Removing ru again turns those two green tests red.

Open items

No Russian native-speaker or professional translation review was performed. Six items stay open rather than smoothed over:

  1. the node-kind glossary, especially Распределитель and Преобразователь — both are long and both press on the node box;
  2. ё spelling across the 36 pinned words;
  3. Гарант vs the kept English Pity / Hard pity in the gacha Template;
  4. дроп / лут, kept as the Russian game-design register uses them;
  5. Кошелёк / Снятия / Приёмка in the module overlay;
  6. whether вы should appear at all, or every such sentence be rewritten impersonally. The grammar pass counted them: 9 keys, every one a sentence where English distinguishes the reader's side from the other party's — your graph against the module file, your changes against the collaborator's, yours against theirs — and all lowercase. That is why they survived; a native reviewer may still prefer full impersonality.

L2.18 — Turkish is tr, the first locale to put the percent sign FIRST and the first agglutinative one. Shipped as the thirteenth language, translated from en. 845 keys (the 844 of v0.13.0 plus language.turkish), plus 203 template-label slots and 19 module labels.

Counting, stated once so it is not re-derived three ways. Each of the twelve existing locale dicts carries 203 slots; the new tr dict carries 203; after registration all thirteen carry the same 196 nodes + 7 frames, derived from examples/*.json and checked by check:template-labels for every locale including tr.

The resolver, again simpler than the region pairs

tr IS its own base subtag, so §L5.2 step 3 carries every tr-* tag and no baseFallbackFor is needed — the same shape as ru. MEASURED before the entry existed, when every one of these fell to en:

navigator tag resolves to
tr, tr-TR, tr-CY, tr-Latn, tr-Latn-TR, tr-TR-u-ca-gregory, TR-tr tr
az, az-Latn, kk, uz en — close, but different languages

A STORED value stays stricter: only the exact code tr is registered, so a stored tr-TR is ignored and the app opens in English.

The percent sign comes first, and nothing sits between

MEASURED: Intl.NumberFormat('tr', {style:'percent'}).format(0.84) is %84 — U+0025 U+0038 U+0034. Numbers group with a PERIOD and take a comma decimal mark (1.234.567,89). There is no no-break space anywhere in this catalog.

Every other shipped locale puts the sign after the number, four of them with U+00A0. That made the old guard — "this catalog has U+00A0 before % in exactly two keys", repeated inside esEsCopy, ptPtCopy and ruCopy — unable to express Turkish at all, so it was replaced by percentContract.test.ts: a registry-derived map of {position, gap} per locale, asserted exhaustive, then checked against Intl itself and against the two catalog strings.

That generalisation found a real defect in two shipped locales. fr and de had a PLAIN space between {pct} and % while their own formatters produce U+00A0, so the number and the sign could land on different lines. Four characters fixed it. e2e/percent-affix.spec.ts now measures the rendered result rather than the string: it forces a wrap in a 58px box and asserts, by client rect, that the digit and the sign stay on one line for fr / de / es-ES / ru, and that tr puts the sign immediately before the number with no space of any kind.

An earlier draft of the shared contract also pinned how many catalog values carry U+00A0 anywhere. That tied a percent test to French punctuation — fr sets a no-break space in ordinary prose (27 keys today) and a NARROW one (U+202F) before : ; ! ? (64 keys) — so one new French sentence would have failed it. A whole-catalog character budget, where a locale wants one, belongs in that locale's own copy test.

Plural has two arms, and both carry the same noun

Intl.PluralRules('tr') has one and other; 1 is one and everything else measured (0, 2, 5, 11, 21, 100, 1.5, 1e6) is other. The ICU shape is therefore identical to English.

The trap is in the words, not the shape: Turkish does not pluralise a noun after a numeral — 3 satır, never 3 satırlar — so the two arms of a counted message usually read the SAME. That looks like a copy-paste slip and is not one. trCopy.test.ts pins what actually matters: both arms exist, and every arm keeps #.

The font was broken for Turkish before Turkish existed

Same class as the Cyrillic defect, found the same way. The app imports only @fontsource/ibm-plex-sans/latin-400.css and its 600 sibling; those declare the family with no unicode-range, so the browser treats IBM Plex Sans as a candidate for every code point while the woff2 carries the latin subset only. Measured at 64px over 20 repetitions, three ways — the Plex stack, the system stack, and a deliberately absent family that forces the fallback:

before after
Ç ö ü (Latin-1), dotless ı Plex Plex
İ Ş ş Ğ ğ byte-identical to the forced fallback Plex

document.fonts.check() answered true for all five both before and after, which is why the width comparison is the method and the check is not.

The fix declares two faces in index.css over U+011E-011F, U+0130, U+015E-015F, sourced from the latin-ext subset woff2. The fontsource latin-ext CSS is not imported, for the same reason its cyrillic sibling is not: it redeclares the same family with no range and the last such rule wins for every code point.

That range is a measurement of today's content, not an invariant. Enumerating every code point outside the latin subset across the 13 catalogs, the template labels and the module overlay currently yields exactly those five, all Turkish. Polish alone would add U+0104-0107, U+0118-0119, U+0141-0144, U+015A-015B and U+0179-017C; when a locale like that arrives, re-run the enumeration rather than assuming five.

Cost, measured against the same tree with the faces removed: +32,838 bytes (+32.1 KB), woff2 files 5 → 7. Both files land in dist, both are in the PWA precache manifest, and both are inlined as base64 in the portable single-file build.

On the pixel baselines. This change IS a visual change — a Turkish UI and two new font faces both alter what the product paints. The baselines did not move anyway, and the reason is a property of the baseline set rather than of the change: 0 of the 52 baseline scenarios changed, because none of them renders a Turkish string, none contains a character in the new U+011E-011F, U+0130, U+015E-015F range, and none captures the language picker in its open state. A scenario that did any of those three would have to be re-baselined, so this is a fact to re-check when adding one, not a guarantee that a locale never moves pixels.

ı folds to i for SEARCH, and for nothing else

MEASURED: İ already folded to i (it decomposes to I plus a combining dot, which the Latin-mark rule drops), but ı has no decomposition, so with the UI in Turkish the French row — Fransızca — could not be reached by typing fransizca. That was the only failing case; Ç ç Ö ö Ü ü already fold.

The fix is one character wide and search-only. It is deliberately NOT toLocaleLowerCase('tr'), which would also map I to ı and break every other language's search. Turkish treats ı and i as different letters — the same reason й is not folded to и for Russian — so nothing here changes rendering, stored values or a catalog string.

A slot cannot take a suffix

Turkish is agglutinative: a case suffix attaches to the word, and its vowel harmony and buffer consonant both depend on the last sound of that word. A slot carrying user text is unknown at authoring time, so {label}'i sil would be wrong for half the labels a user can type.

The rule applied is that a CLASSIFIER NOUN takes the suffix instead, chosen per slot kind and never globally: «{label}» çerçevesini for a frame, «{name}» düğümüne for a node, «{label}» parametresinin for a parameter, «{header}» sütununun for a column, «{table}» tablosunun for a table.

The contract is two invariants, and neither is a count:

  1. no slot is followed directly by a letter ({label}i);
  2. no slot is followed by an apostrophe and a letter ({label}'in, {label}’ın).

trCopy.test.ts fails on either form, and falsifies both inside the test so the guards cannot go quietly vacuous.

As a MEASUREMENT OF THIS AUDIT — a description of today's catalog, not a budget and not something to keep in step — the catalog held 182 plain slots across 127 keys, 67 of them followed by a word, and 0 violations of either invariant. Those three numbers will move with any new string; the two invariants will not. An earlier draft quoted 59 slot occurrences of which 38 were mid-sentence, which could not be re-derived under any stated definition and is the reason this section now states what it counted.

What the guards lock, and what they deliberately do not

trCopy.test.ts holds the contracts a search cannot:

  • cross-script, at runtime, over the catalog AND the template labels AND the module overlay — the pt-BR lesson, where one Cyrillic letter passed every gate that read the file;
  • the alphabet: Turkish has no q, w or x, so a word carrying one must be a token the English value of the SAME key also contains. ICU slot names and plural keywords are stripped from both sides first, but each arm's sentence is not — an earlier version stripped whole {...} groups and a qwerty injected inside a plural arm passed;
  • untranslated values: the set identical to English must be exactly the declared fourteen, all product names, placeholders or symbol-only strings;
  • the node kinds, as a shape rather than a spelling: the three surfaces that name a kind must agree with each other, AND every prose key whose English mentions that kind must use the same word. The prose keys are derived from English, so a new sentence joins the check by itself;
  • Drain is Gider, on the five keys derived from the English originals that name Drain, with Yutak and Çıkış refused there — and bare capitalised Gider banned everywhere else, in the catalog and in the template and module overlays, so an expense label can never read as the node kind (see below);
  • a slot never carries a suffix, neither attached directly ({label}i) nor behind an apostrophe ({label}'in, {label}’ın). Both patterns are falsified inside the test itself, and a third assertion keeps the guards from going vacuous if the catalog ever stopped using slots.

One thing is deliberately NOT locked, because it is not decided: «» as the quotation mark.

Open items

No Turkish native-speaker or professional translation review was performed.

  1. Kaynak is both Source and resource. That ambiguity is Turkish's own and was not worked around with an invented term. The one sentence that would have read "Kaynak … kaynaklar" is palette.source.description, and it drops the subject — which is also what fr, de and ru do there, since the palette shows the name directly above the description.
  2. Dağıtıcı for Gate — Kapı is the literal word and names a door.
  3. «» as the quotation mark: whether it reads naturally in the accessibility strings and what a screen reader does with the guillemets at its default punctuation level, which was NOT measured here. The system itself is consistent and was audited: 34 keys use «», 29 of them around a runtime slot and 5 around a static label that matches a real product or external string exactly; “ ” appears only in the three expression-grammar keys, byte-identical to English; there are no ASCII double quotes, and the only ASCII apostrophes are the two ICU escapes.
  4. Pity / Hard pity / Pickup kept in English in the gacha Template, and drop / loot kept as the Turkish game-design register uses them. The blurb agrees with the labels: templates.gachaBannerZones.blurb says pity, the way es-419 and pt-BR do, rather than translating it.
  5. Hesaplanan değer for Register is decided — Kayıt is refused in those ten keys and legal everywhere else — but its length against the node box is worth a look.

The second review

The first pass shipped a catalog that every gate accepted. A second pass read all 845 en↔tr pairs in order — not a term search, because the ru arc had already shown that a vocabulary screen measures the screen rather than the catalog — and every suspicion was then checked against the other twelve locales. That check killed four of the findings: Play / Replay diverging at the root (ru does the same), iş parçacığı for workers (so do es, ru and zh), Ad for the Label column role (so do fr, de and ru), and kendi adınız for a custom name (so does ru).

What survived was 20 fixes over 32 keys — the two numbers differ because one fix can span a run of keys: 10 vocabulary, 1 external-UI string, 15 template and module labels, 6 meaning and register. The widest single fix is Etek arazi → Dağ eteği for the Foothills zone, which is 10 keys, z2_enc_src z2_enc z2_combat z2_win z2_winamp z2_lootroll z2_loot z2_xp_meter z2_xp2lvl z2_training, all in templateLabels/tr.ts under mmo-progression. Three of the fixes are worth recording, because each is a kind of defect a guard could not have found:

  • Eşleme for "Don't map" is the negative imperative AND the noun "mapping". As a dropdown option it reads as the opposite of what it does. All six comparable locales use an explicit negative; this now says Eşlenmesin.
  • General / Free was the only frame label of eleven locales left in English. Premium Standard and Premium Pickup stay English on purpose — they are the mechanism names — but this one is a description.
  • «Web'de yayımla» quoted Google Sheets' menu item with the wrong verb. Google's own Turkish documentation says Dosya → Paylaş → Web'de yayınla, measured against that page rather than assumed.

Drain is Gider

TDK gives gider two senses: the channel a liquid flows away through, and an expense. That is the same pair English Drain carries, and it matches the register English chose when it picked Drain over Sink. Yutak is the engineering rendering of Sink and pairs with Kaynak the way Turkish engineering pairs source/sink, but its everyday sense is the pharynx. Çıkış was refused by measurement: it already means the output buffer, in modules.bufferedStep.blurb and in the module overlay's Çıkış kuyruğu.

The cost of Gider is that gider is an ordinary noun as well, and three template labels use it that way — Toplam gider, Su gideri, Yiyecek gideri. The measured distinction is case plus a modifier: the node kind is always the bare capitalised Gider (5 keys), an expense is always lowercase and modified (3 labels). trCopy.test.ts pins both halves, and also pins that the 5 keys are exactly the ones whose English original names Drain — so a new English sentence about Drain cannot quietly skip the term.

L2.19 — Thai is th, the first script that writes without spaces between words and the first locale with ONE plural category. Shipped as the fourteenth language, translated from en. 846 keys (the 845 of tr plus language.thai), plus 203 template-label slots and 19 module labels.

Counting, stated once. Each of the thirteen existing locale dicts carries 203 slots; the new th dict carries 203; after registration all fourteen carry the same 196 nodes + 7 frames, derived from examples/*.json and checked by check:template-labels for every locale including th. In the picker Thai sorts between Spanish (Spain) and Turkish — displayLocaleOrder keys on englishName, and Thai < Turkish (§L5.6).

One plural category, measured before 846 keys were written

new Intl.PluralRules('th').resolvedOptions().pluralCategories is ['other'] — the first locale here for which that list has a single entry. 0, 1, 2 and a million all select other.

That is a reason to check more, not less. A stray one {…} in a Thai message parses, validates and renders: ICU simply never selects the arm, so every gate that reads the message as a whole stays green and the arm sits there as dead copy. Only a check on the SELECTORS can see it, which is what thCopy.test.ts does — every block in the catalog must have exactly one arm and that arm must be other. The walk it uses is §L2.20.

No spaces between words, so no "words" to split on

Thai runs a sentence together with no inter-word whitespace. Every technique the Latin-script copy guards lean on — split on whitespace, count tokens, look at "words" — is meaningless against a single space-free run.

The untranslated check is therefore built on Unicode script runs: a character is judged by the script it belongs to, never by where a space is. Thai, Common (digits, punctuation, spaces) and Inherited (the combining marks that sit on a Thai base) are allowed; Latin is allowed but separately governed, below.

The Turkish slot-suffix contract (§L2.18) is deliberately NOT carried over. Thai is not agglutinative and has no case suffixes, so {label} needs no classifier noun in front of a suffix. What it needs is a visible BOUNDARY — with no spaces, a runtime label runs straight into the sentence around it — so a slot holding user text is wrapped in “…”. That is the Royal Institute's Thai quotation mark; «», which tr uses, is not Thai punctuation and is banned across all three runtime surfaces.

Latin in a Thai value: two closed sets, then the derived rule

A Latin run inside a Thai string is sometimes right — CSV, Ctrl, Monte Carlo, JSON — and the first version of this contract derived the permission: a Latin word must appear in the English original of the same key. That is a real rule and it is kept, but on its own it has a hole wide enough to drive an untranslated sentence through. Copy any fragment of the English original into the Thai value and it passes, BECAUSE those words are in the English original. Partial non-translation is exactly what the contract exists to catch, and the derived rule is blind to it. MEASURED: replacing part of about.notAffiliated with is an independent project passed.

So the primary contract is two closed sets, both measured then declared:

  • which strings may carry a Latin run at all — 146 of them, as surface:id, across the catalog, the template labels, the frame labels and the module overlay. A string not on the list must be pure Thai, so a sentence left in English announces itself before any vocabulary question is asked;
  • which words those runs may be — 72, grouped by the reason each is allowed: 13 brand and product names, 10 file formats and the export commands named after them, 11 keyboard keys as printed on the key, 23 wire tokens (column names, model-version tags, expression syntax, share-URL fragments, the axis letters in the a11y messages), 12 placeholder examples and gacha proper nouns, and 3 held per-key.

The three per-key ones are Pity, Hard pity and Pickup. They are the terms the genre uses in Thai as well, but they are the vocabulary of ONE template; letting them float catalog-wide would make Pickup legal in a toolbar tooltip. Each is pinned to exactly the keys that carry it, in both directions. drop and loot go the other way and are transliterated — ดรอป, ลูท.

A long hardcoded list is the right shape here: it is a translation policy, not a cache of something computable. The derived rule stays behind it as a second contract, and it is the one that fires when an ALLOWED word appears in the wrong allowed key — MEASURED: adding Standard to mc.title is caught by the derived rule and by nothing else.

Numbers, and why numberLocale is spelled out

Thai formats numbers exactly as en does: Latin digits (latn), 1,234,567.89, and the percent sign AFTER the number with no gap — 84%, the same shape en uses and the opposite of tr (§L2.18). percentContract records th as { position: 'after', gap: 'none' }.

numberLocale is 'th-TH'. Bare th was MEASURED to produce identical output, so this is not a bug fix and not future-proofing — it is explicitness. Every other entry in the registry names a region here, and a reader should not have to know that th and th-TH happen to agree today.

The resolver needs nothing new

th is a locale whose code IS its own base subtag, like ru and tr. §L5.2 step 3 (exact base-subtag match) therefore carries th-TH, th-Thai, th-TH-u-nu-thai and every other tag whose base is th, for free. No baseFallbackFor entry, and no resolver change.

The font is a different typeface, lent to one script

@fontsource/ibm-plex-sans has NO Thai subset — its files are latin, latin-ext, cyrillic, cyrillic-ext, greek and vietnamese — so before this change Thai was entirely system-rendered. IBM Plex Sans Thai is a separate package with its own family name, which is why it is declared under 'IBM Plex Sans' with a unicode-range rather than added to a stack: --font-sans and every stack below it are untouched.

document.fonts.check() lied again — the third locale in a row. It answered TRUE for glyphs the browser did not have. The measurement that works is a three-way WIDTH comparison (the Plex stack, a forced system stack, and a deliberately absent family): six Thai samples at 64px over 10 repetitions — bare consonants, the endonym, a tone mark above, two marks stacked above, marks above and below, and a real word — were byte-identical to the forced fallback at both weights.

The range is wider than today's catalog, on purpose. Everywhere else in index.css a unicode-range is a measurement of today's content. That rule is about SHIPPED STRINGS, and this product is an editor: a user types Thai node labels no catalog contains. So the range covers what INPUT needs — U+0E01-0E5B (the Thai block), U+200C-200D (ZWNJ / ZWJ, which control shaping in typed text) and U+25CC (dotted circle, which carries an isolated combining mark while a syllable is half-typed). fontsource's own thai subset range also lists U+02D7, U+0303 and U+0331; those are generic combining marks Latin text can use, and including them would hand Latin glyphs to a Thai typeface — the same defect shape as importing a fontsource subset CSS wholesale. They are excluded, and the Latin / Turkish / Cyrillic control widths are unchanged to the hundredth of a pixel with these faces installed.

Cost: dist +23,986 bytes, portable +31,802 bytes, both woff2 in the PWA precache manifest. Line height is the open risk, not width: Thai ink is 19 above + 6 below at 18px against Latin's 13 + 4, so any box with line-height <= 1.0 has less room than the marks need.

What thCopy.test.ts pins

  • the key set and the three surfaces — 846 catalog keys, 196 template labels, 7 frame labels, 19 module labels, no empty value anywhere. All three surfaces are walked together, because the pt-BR lesson was that a guard reading only the catalog misses what the overlays carry;
  • script runs — no character from a script outside Thai / Common / Inherited / Latin, with a non-vacuity floor so the check cannot pass by finding no Thai at all;
  • Latin — the two closed sets above, the per-key words, and the derived rule behind them;
  • quotation — no guillemet on any runtime surface, and a floor proving labels really are wrapped;
  • the eight node kinds — palette, canvas and default label agree kind by kind, and the eight names are distinct;
  • Drain — every key whose English original names Drain carries the one Thai term, derived from English rather than listed by hand. The WORD is not locked (see open items), only the agreement, so a later rename moves all the surfaces together and this file keeps passing;
  • Register — the nine keys that name it use one term and none of them says บันทึก, which is both "a record" and "save"; the ban is scoped, and a separate assertion proves บันทึก is still legal elsewhere;
  • plurals — one arm, other, # preserved, over §L2.20's walk;
  • parser position vs table column — the §L2.11 split, in Thai (ตำแหน่งอักขระ against คอลัมน์).

The second review

The first pass shipped a catalog every gate accepted. A second pass read all 846 en↔th pairs in order — not a term search, because the ru arc had already shown that a vocabulary screen measures the screen rather than the catalog — and every suspicion was then checked against the other thirteen locales before it was called a defect. That check killed four of them:

  • ปุ่มพอดีจอ naming a shortened "Fit" control — fr, de, ru and tr all name it the same shortened way (« Ajuster », „Einpassen", «Вписать», Sığdır düğmesi);
  • มอดูล for module — the Royal Institute transliteration, and consistent with โปรเจกต์, เทมเพลต and บัฟเฟอร์. A policy, not an inconsistency;
  • สเปน (สเปน) for Spanish (Spain) — every locale has the same shape (Español (España)), and Thai has no adjective form to break it with;
  • เธรด for {workers} — es, ru, zh and tr all say "thread" too.

What survived was 5 fixes over 12 keys, and the widest was a term:

  • trigger ตัวกระตุ้นจังหวะ → ทริกเกอร์ (3 keys). The defect is structural, not stylistic: activator was ตัวกระตุ้น, a strict SUBSTRING of trigger, and the two sit next to each other in one dropdown. Thai was the only one of the fourteen locales where that was true — the other thirteen all use two unrelated words (déclencheur/activateur, триггер/активатор, 트리거/액티베이터). Transliterating trigger restores the separation and leaves การกระตุ้น (Activation) beside ตัวกระตุ้น (activator), which is exactly the kinship English has;
  • seed ซีด → ค่าตั้งต้น (6 keys). ซีด is the phonetic transliteration and also the everyday word for pale, so the runs/seed/pools column list read as "run, pale, pool". Keeping the English would have been the outlier — every other locale localises it — so this takes German's Startwert route. Composite phrases were translated in context rather than substituted: base seed is ค่าตั้งต้นหลัก, and the tooltip's second mention of "seed" becomes the bare ค่าเดิม under its own antecedent. MEASURED afterwards in the real render: the label is 37.4px against en's 25.9 and German's already-shipping 58.3, with no truncation;
  • ขนาดขั้นที่เพิ่มทีละ → ขนาดขั้น (1 key) — a dangling ทีละ with no object, and the Inspector's longest field label;
  • เพิ่มตารางอีกหนึ่ง → เพิ่มอีกหนึ่งตาราง (1 key) — a missing classifier;
  • ขนาน, {workers} → ขนาน {workers} (1 key) — Thai sets a list with a space, not a comma.

The mobile render, measured

docs/mobile.md §MV4's top bar sets line-height: 1.2 at 11px on the caption, which §L2.19's font work had already flagged as the real risk. MEASURED at 390×844 with the translated UI, against en as the control:

  • .toolbar--mobile .toolbar__vr — Thai ink is 15px tall (11 above + 4 below) in a 13.19px box, so it paints 2.1px above its own box where en stays 0.9px inside. It is NOT clipped: the nearest ancestor that clips has 19.3px of room, and no sibling is within range. The cost is that the marks eat into the header's top padding, ~6px → ~3.9px;
  • .sheet__title — the same shape, 1.0px above its box, also unclipped;
  • .btn.mob-more — renders ⋯, not Thai, and has 15.4px of vertical room. No overlap with the caption or the revision chip in either language;
  • no truncation anywhere — scrollWidth/scrollHeight overflow is 0 for the top bar, all 26 sheet rows and all 30 language-menu rows, in both languages. The Thai caption is in fact NARROWER than the English one (131.8px against 142.2), so the nowrap row is under less pressure, not more.

Open items

No Thai native-speaker or professional translation review was performed.

Triaged 2026-09-26 against measurement, which closed some of the original list, split one item whose halves turned out to have different answers, and corrected another's reason. No runtime string was changed — the catalog, the template labels and the module labels are exactly as shipped. What follows is what a Thai reader still has to judge, and what measurement already settled.

Still open — needs a native reader

  1. ค่าที่คำนวณ for Register is decided against บันทึก, but its naturalness as a node-kind name is unreviewed. Consistency is not the question: measured, the term appears in 11 keys and there are 0 keys where the English says Register and Thai says something else. A reviewer only has to judge whether the phrase reads naturally as a node kind and in the Inspector's own sentences.

  2. ทางระบาย (Drain), ถังพัก (Pool), ตัวกระจาย (Gate), ผืนผ้าใบ (canvas) are provisional. The guards pin agreement, not spelling, for exactly this reason. Measured, each maps 1:1 onto its English concept — Drain 5 keys, Pool 26, Gate 5, canvas 19, with 0 keys where the English uses the concept and Thai uses a different word. Sub-question, not a separate item: five canvas keys render English empty canvas as พื้นที่ว่าง ("empty area") rather than ผืนผ้าใบ — canvas.panMode.off, canvas.regionSelect.off / .on, canvas.frame.draw / .drawing. The English there is an interaction phrase, not a standalone term, so a contextual translation is plausibly deliberate. It belongs to ผืนผ้าใบ's review, not to a list of its own.

  3. Pickup kept in English. Measured across all thirteen translated template-label dicts, this is a minority of three — only ru, tr and th keep it. Eight locales (zh-Hans, zh-Hant, fr, de, es-419, es-ES, pt-BR, pt-PT) converged on UP, and ko / ja transliterate (픽업 / ピックアップ). That is not a reason to change it. What Thai gacha players actually say outranks a majority across unrelated languages, and no Thai evidence has been gathered. The current string stays until a native reader supplies one.

  4. “เผยแพร่ไปยังเว็บ” in import.qs.sources.privacy. A different Sheets menu from the Download path below, and still resting on published help text only.

    The Google Sheets DOWNLOAD path is CLOSED, and it was WRONG (2026-09-27). Checked against the REAL Google Sheets product UI in Thai, the first two levels matched (ไฟล์, ดาวน์โหลด) but the .csv item did not: this catalog shipped ค่าที่คั่นด้วยเครื่องหมายจุลภาค, while Sheets shows ค่าที่คั่นด้วยคอมมา. The help pages carry Google's AI-translation notice and could not confirm a product string — exactly the risk this item was opened for, and the one time in four locales that the risk was real.

    The dictionary word was the trap. เครื่องหมายจุลภาค is the correct Thai term for a comma and reads more careful than the loanword คอมมา the product actually uses. A later reviewer polishing the Thai would restore it, so thCopy.test.ts now pins the value by key rather than relying on any vocabulary rule.

Microsoft Excel's own UI is OUT OF SCOPE, not an open review item. Reading it would mean switching the Windows / Office display language, and that cost is not worth the two strings in import.qs.sources.excel. They are left exactly as they are — not guess-edited toward what Excel might say — and revisited only if a screenshot of Excel in this language turns up on its own. The Microsoft Excel (.xlsx) row inside the Sheets Download submenu is a download FORMAT name and says nothing about Excel's own menus.

Closed on measurement

  • Pity / Hard pity — reviewed, kept. Measured across the thirteen translated dicts: nine keep the English (fr, de, es-419, es-ES, pt-BR, pt-PT, ru, tr, th) and only the four CJK locales translate it (천장, 天井, 保底). Thai is with the majority and needs no review. This was originally bundled with Pickup; the two turned out to have different answers, which is why they are now separate.

  • “ ” and screen readers — not a Thai item. Measured: 37 th keys use them, 30 of which wrap a {slot}, and 25 of those keys are quoted in every locale including en; the overlap with ru and tr is 36 of 37. Twelve of the fourteen locales use paired quotes somewhere (only ja and zh-Hant use 「」). The quotes delimit a slot rather than carry meaning — a reader that drops them at its default punctuation level still reads “Value of parameter X” correctly, just less verbosely. So whatever a screen reader does here is a product-wide question about twelve locales, not a Thai copy defect, and it is not worth a backlog entry before anyone reports it.

  • Line height against the combining marks — no runtime risk. mobile.topbar.caption is a single fixed key with no {slot}, so the "a longer Thai string in that slot" worry cannot materialise at runtime; and string length is the wrong axis anyway — vertical ink is set by the tallest mark stack, not by length. MEASURED with canvas TextMetrics at 390×844 (11px, line-height: 13.2px), ink above the line box:

    sample ink height above the line box
    the shipped Thai caption 15.0px 0.4px
    tone over an upper vowel (ปุ๊ปั๊ปื้) 17.0px 2.4px
    sara am + tone (น้ำ ค่ำ) 13.4px 2.84px — the worst measured
    en control 10.0px −2.6px (inside the box)

    So the shipped string is not the worst case, and the worst case is 2.4px taller — against 19.3px of clearance in the nearest clipping ancestor, measured above. Nothing clips in either case. (The 2.1px figure above is the same overhang measured from the element box; 0.4px here is from the line box.)

L2.20 — the ICU plural walker, and a guard that was green because it was empty. src/i18n/icuPlural.ts, fixtures in icuPlural.test.ts.

Every per-locale copy guard has to look inside {n, plural, …}, and the obvious way to find those blocks is wrong in a way that goes GREEN:

message.split(/(?=\{\s*\w+\s*,\s*plural\s*,)/).slice(1)

A zero-width match at index 0 does not split in JavaScript — 'abc'.split( /(?=a)/) is ['abc'] — so .slice(1) throws the only block away whenever the message STARTS with its plural. MEASURED: 17 of this catalog's 19 plural messages do. trCopy.test.ts shipped with that idiom and was examining 6 blocks across 19 keys; the other 14 keys were examined as nothing, and the test passed because there was nothing left to fail. It was found while falsifying the same idiom in thCopy.test.ts: a deliberately broken Thai plural did not go red.

The repair has its own two traps, and both were reached by fixture rather than by reasoning:

  • a bare depth counter cannot read '{', ICU's quoted literal brace (error.EXPR_UNCLOSED_REF.message carries one). It counts an opener that never closes, runs off the end, and DROPS the block — the same silent vacuity in a different disguise;
  • '' is an escaped apostrophe whatever follows it. MEASURED against the first repair: with only the "a quote opens before {, } or #" rule, ''{label} made the SECOND apostrophe an opener and the block was dropped again. Inside a quoted literal '' likewise does not close the quote, so the close cannot be an indexOf either.

The module is shared rather than copied into each copy test, because a copied idiom is how the defect spread in the first place. Both error paths throw instead of returning less. Fixtures cover a block at index 0, leading prose, two and three sibling blocks, a {slot} inside an arm, a quoted brace, '' before ordinary text and before a brace, '' nested inside a quote, an ordinary apostrophe (d'un, a Turkish suffix), select not matching, offset:, an explicit =0 arm, and an unbalanced block.

The non-vacuity assertion is a COUNT, not a floor. blocks >= keys is not enough: three keys carry more than one block (import.status.counts has three, import.issueSummary and import.summary two each), so a walker that found only the first block of a multi-block message would still clear a per-key floor. Each locale therefore compares its block count per key against the English original and asserts the totals — today 19 keys / 23 blocks — so a walk that stops seeing blocks fails loudly instead of passing over nothing.

L2.21 — Vietnamese is vi, the first locale whose English cannot be seen. Catalog src/i18n/locales/vi/, template overlay templateLabels/vi.ts, module overlay in moduleLabels.ts, guards viCopy.test.ts and e2e/i18n-vi.spec.ts. 849 keys · 196 node labels · 7 frame titles · 19 module labels.

Fifteenth language. vi is its own base subtag, so §L5.2 step 3 carries vi-VN, vi-Latn, vi-Latn-VN and every extension for free — no resolver change, unlike es-419 (§L2.13).

The thing that is new

Every locale before this one is legible as a locale at a glance: Thai is Thai script, Russian is Cyrillic, Turkish has ı and ğ. Vietnamese is Latin. Monte Carlo and Pan mode off — drag empty canvas to pan are written in the same alphabet as Bể chứa, so no character class, no script run and nothing that asks "which alphabet is this" can tell a finished translation from one that was never started.

The primary guard is therefore an exact key-and-value set: every runtime string equal to its English original is declared in viCopy.test.ts, with the value written out. 28 entries, each one of four things — a product name (Monte Carlo) or a file's name (Graph JSON); a placeholder the user types back verbatim; a rarity letter or mechanism name the genre writes in English; or a banner's proper name (Premium Standard), as in ru, tr and th.

It found nineteen. The catalog's first pass shipped enum.activation.*, enum.flowMode.*, enum.distribution.*, enum.format.*, enum.stateMode.*, both seed labels and import.delimiterTab in English — every one of them a dropdown value a user reads, and every one of them translated by all thirteen other locales. A count would have hidden that behind a number; the pairs did not. Five more were partial rather than whole — activator — bật / tắt đích, an English head word in front of a translated clause — and two more were on the overlay surfaces (General / Free, Gold), which a catalog-only guard never sees. Twenty-six in all, none of which any previous locale's guard shape would have caught.

Behind the exact set sit two derived contracts, both measured then declared: the exact set of strings keeping an ASCII word their English original also has (167), and the exact vocabulary those words may come from (77, in eight disjoint groups).

The tokenizer, which is the guard's own failure mode

Every earlier copy guard splits words on [A-Za-z]+, and in those scripts that is equivalent to splitting on letters. Here it is not: it cuts a Vietnamese word at every diacritic, so sửa becomes s and a. MEASURED on this catalog, that idiom invented 51 phantom hits and 7 phantom words, a alone appearing in 42 strings. The fix is to tokenize on \p{L}\p{M} and keep only the runs that are entirely ASCII, and it is asserted as its own test so the cheaper idiom cannot come back.

What survives the fix is still not all English. Vietnamese writes a syllable in plain ASCII whenever it carries no diacritic — thu, cho, khi, sang, to, so — so the thCopy rule "no Latin word may appear that English did not put there" reports 688 correct Vietnamese words here. That rule is therefore scoped to the declared vocabulary instead. Two ASCII Vietnamese words (so in so sánh, to in phóng to) are declared as such rather than as kept English.

Glossary

Bể chứa Pool · Nguồn Source · Điểm xả Drain · Bộ chia Gate · Bộ chuyển đổi Converter · Điểm kết thúc End · Tham số Parameter · Giá trị tính toán Register · khung vẽ canvas · Nhóm frame · Mẫu Template · Mô-đun Module · hạt giống seed.

Three are decisions rather than lookups:

  • Gate is Bộ chia, not Cổng and no longer Bộ phân phối. Cổng is the literal gate and is also what a network port is called, so it was never a candidate. It shipped as Bộ phân phối (distributor) until the full read-back found the product contradicting itself: both gate-typed labels it ships say chia — Bộ chia phần thưởng and Chia luồng sản xuất. The kind name follows its own labels, and it is two syllables instead of four.
  • Register is Giá trị tính toán — a calculated value. The node stores nothing, so any name built on "record" or "ledger" would describe the opposite of what it does, and ghi is already the verb in the save and export copy. Sổ ghi is banned outright and the ban is asserted.
  • seed is hạt giống. None of the thirteen other locales keeps seed. What is pinned is not the word but that all six places naming it agree — the label was translated and the tooltip beside it was not, and both render at once, so the mismatch was visible in the product before any guard saw it.

XP, SSR/SR/R, Pity, Hard pity and Pickup stay English; Gold does not — eleven of the thirteen translate it, so the mmo resource reads Vàng. It still appears verbatim in inspector.resourceType.placeholder, which lists the canonical resourceType tokens a user may type rather than UI copy. Pickup is retained as established product terminology. No native-speaker review was performed, and a cross-locale majority is not evidence of what Vietnamese gacha players write.

Five status strings say vùng trống — the empty AREA — where English says "empty canvas". Thai compresses the same five. They are listed in the guard rather than waived, so a sixth cannot join them quietly.

The full EN↔VI read-back, and what it left open

All 1,071 runtime pairs were read side by side — 849 catalog keys, 196 node labels, 7 frame titles, 19 module labels. The guards in viCopy.test.ts cannot do this: type checking, NFC, the key set and the English-leak contracts all pass over a wrong-but-Vietnamese string without noticing. Eleven items were repaired as a result; the rest are recorded here rather than closed, because closing them needs a native reader and a passing test is not that reader.

Repaired. Five were wrong rather than debatable: hạt nhân xanh for green coffee (hạt nhân is the atomic nucleus; the trade term is cà phê nhân xanh, 4 labels); Xuất bản dự án for an export button (Xuất bản is the verb to publish, and this same catalog uses it that way in import.qs.sources.privacy — now Xuất tệp Bản dự án); sang {direction} in rf.node.moved (sang is lateral only, so sang trên / sang dưới are not Vietnamese — now theo hướng {direction}); phải đi kèm for "must be followed by" in the escape-error message, which dropped the ordering that is the whole content of the message; and a missing object in the Drain description. Six more were the product contradicting itself and are described under the glossary above and in viCopy.test.ts.

Still open — needs a native reader. Each carries the evidence that makes it a question rather than a defect:

  1. vùng / khu vực / khu for "zone". Three renderings: vùng in 27 mmo node labels, khu vực in the mmo blurb, khu in 2 gacha labels. It may be a real register difference between an RPG map zone and a gacha banner zone, which is why it was not flattened.
  2. tử vong for a game death (3 mmo labels). It is the clinical word; players say chết. Hàng chờ tử vong reads like a mortuary queue.
  3. Bare Biên for "margin" in roasted_supply_margin and dessert_prep_margin. Biên alone is an edge; the same template gets it right in projected_operating_margin (Biên lợi nhuận). These two mean headroom, so Dư địa may be the word.
  4. Thay đổi as the Modifier field name. It is a verb where the field wants a noun (Mức thay đổi), and thay đổi is already the generic word for "change" throughout the catalog.
  5. Second vs third person. palette.parameter.description says do người dùng đặt ("set by the user") where the rest of the catalog addresses the reader as bạn.
  6. Chỉ số nhịp lên XP for "XP pace index" — nhịp lên is awkward.
  7. Giá trị tính toán for Register. Consistent across all 10 keys and free of collisions, but four syllables — the longest node-kind name in the palette. It passes the vertical-metrics test; the question is whether a Vietnamese reader would shorten it.
  8. Khung vẽ vs Vùng vẽ for the canvas. Both collide. khung already does triple duty (khung vẽ canvas, khung nhìn viewport, một khung có nhãn for the frame box in import.placement.frameHelp); but vùng is already zone, vùng trống (empty area) and vùng chọn (selection). Kept as it is because moving it trades one collision for another.
  9. Pickup kept in English. A cross-locale majority is not evidence of what Vietnamese gacha players write. The Google Sheets Download path is CLOSED (2026-09-27), checked against the REAL Google Sheets product UI in Vietnamese. All three menu texts match on screen: Tệp, Tải xuống, Giá trị được phân tách bằng dấu phẩy (.csv). A screenshot settles the wording, not the bytes. Xuất bản lên web is a DIFFERENT menu and is not covered by this evidence.

Microsoft Excel's own UI is OUT OF SCOPE, not an open review item. Reading it would mean switching the Windows / Office display language, and that cost is not worth the two strings in import.qs.sources.excel. They are left exactly as they are — not guess-edited toward what Excel might say — and revisited only if a screenshot of Excel in this language turns up on its own. The Microsoft Excel (.xlsx) row inside the Sheets Download submenu is a download FORMAT name and says nothing about Excel's own menus.

Not treated as defects, and deliberately not unified. Some strings wrap a runtime slot in “…” where English does not, and some do not. Per-sentence judgement is allowed here; no catalog-wide quoting policy was invented, because inventing one would be a rule about this document rather than about Vietnamese.

Plurals

Intl.PluralRules('vi') declares one category. Every plural block is a single other arm with # preserved — 19 keys / 23 blocks, walked by §L2.20 and compared per key against English.

Search: the đ → d fold (§L5.5)

đ has no decomposition — it is a letter with a stroke, not a base plus a mark — so the Latin-mark rule leaves it alone. MEASURED before the fold existed: with the UI in Vietnamese, three picker rows were unreachable from an ASCII keyboard (Tiếng Đức for German, Tiếng Bồ Đào Nha for both Portuguese entries). Typing duc, bo dao nha or the Telex spelling dduc found nothing. The other 72 of Vietnamese's 74 letters already fold, because their tone marks are combining marks on a Latin base.

Measured for damage as well as benefit: across all fifteen shipped languages the fold creates zero new collisions between picker rows. It is SEARCH ONLY — Vietnamese treats d and đ as different letters, and nothing about rendering, stored values or a catalog string changes.

NFC, and the one limitation

Every runtime string on all four surfaces is NFC and carries no combining mark at all — stronger than NFC, and true because every Vietnamese letter this catalog uses has a precomposed form. The e2e spec asserts the same of what reaches the DOM, so the ICU formatter and React are covered too. Search folds NFC and NFD identically, including a bare o + U+031B and u + U+031B.

U+031B COMBINING HORN is in no IBM Plex Sans subset — not vietnamese, latin, latin-ext or cyrillic — and is not in fontsource's declared range either. A range entry cannot conjure a glyph, so it is not added.

The consequence, recorded here and nowhere else. A DECOMPOSED ơ or ư — o/u plus U+031B — draws its horn from the system fallback, so it looks slightly different from the precomposed letter. MEASURED: at 64px, 20 repetitions, the decomposed pair renders at the same width as a stack with no IBM Plex in it at all, while the precomposed letter does not. Everything the catalog, an IME and NFC actually produce is precomposed and is covered by the face, so this is an accepted limitation, not a defect.

It is deliberately NOT asserted. An earlier version of e2e/i18n-vi.spec.ts pinned it — "the decomposed form must keep falling back" — and that was wrong: it turns a limitation into a permanent contract, so the day IBM Plex ships the horn the product improves and the test goes red. A test may not stand in the way of its own subject being fixed. What the spec pins instead are five properties that hold either way:

  1. every official Vietnamese string the product serves is NFC, with no combining mark left in it;
  2. the CSS claims no code point the face cannot draw — derived from the live stylesheet and the live font file, so widening the range later is fine as long as the face backs it. Falsified by adding U+031B to the declared range, which reds it;
  3. an NFD query finds the same picker rows as the NFC one;
  4. a raw USER string is stored and read back byte-for-byte — the product never silently normalises what someone typed;
  5. an NFD label renders with ink, is not tofu, is not clipped and does not overlap its siblings. Its appearance is not compared with the NFC twin's in either direction.

The font, and the one mark it took from another locale

@fontsource/ibm-plex-sans has a vietnamese subset (8.2 KB at 400) whose cmap covers all 44 characters that measurably fell back. document.fonts.check() answered TRUE for Đ ế ệ ơ ư ă — the fourth locale in a row it has lied about a font the browser does not have (ru, tr, th, vi). The range declared in index.css is fontsource's own rather than a census of today's strings, because this face also has to render USER labels; U+0329 is dropped from it, being in fontsource's range but not in the file's cmap.

The review question was sharper than "does the face have the glyph": the Vietnamese face declares U+0300-0301, U+0303-0304, U+0308-0309, U+0323 — combining marks other locales write with — and is declared last, so for those it now wins the cascade. A cmap entry and a matching advance do not prove two faces draw the same glyph, and a combining mark's advance is zero, so comparing widths compares nothing.

The e2e test therefore derives the overlap from the live stylesheet and compares INK. Two findings:

  • the overlap is exactly one code point, U+0301, and its other claimant is the Cyrillic face, which has carried it since ru shipped (§L2.17) — not the Latin one;
  • loaded under isolated temporary family names, the vietnamese and cyrillic subsets draw U+0301 with identical ink bounds, pixel count and coverage, so moving it changes nothing for Russian.

The first version of that test compared vietnamese against latin on ế and they differed — which was the TEST being wrong, not the font. latin-400.css (imported by main.tsx) declares the family with no unicode-range, so the browser treats it as a candidate for every code point and, for a precomposed letter it does not have, draws the base from Plex and stacks the marks from elsewhere: same advance, different ink. That is the concrete reason the comparison has to be against the RANGED faces, and the reason the test asserts non-vacuity on both sides before comparing anything.

L3. The string catalog

L3.1 — one key set, defined by en. Every locale's catalog has exactly the keys that en has — no missing, no extra. This is enforced in CI for every registered locale (§L12 #1), not just ko.

L3.2 — keys. Flat, dotted, namespaced by surface: toolbar.export, inspector.node.capacity, timeline.trackAll, error.M_REG_EVAL.message, a11y.playback.status.stepN. Keys are ASCII and never built from user data or file content at a call site.

L3.3 — per-language catalog, TS module + satisfies (Q1 — decided). src/i18n/locales/<code> — a TypeScript module default-exporting a plain object literal. Two forms, both valid and detected by the CI script (§L12):

  • single file — locales/<code>.ts;
  • domain folder — locales/<code>/{ui,canvas,inspector,templates}.ts merged by locales/<code>/index.ts (spread in that order). Split is by the first key namespace (toolbar.* → ui, canvas.* → canvas, inspector.* / enum.* → inspector, templates.* / modules.* → templates); a genuinely shared string lives in ui. en and ko use this form. The four slices merge to exactly the flat key set — a key in two slices, or dropped from all four, fails CI (§L12 #1).

en is authored first and is the canonical shape:

// locales/en/ui.ts
const ui = { 'toolbar.export': 'Export', /* … */ } as const
export type UiKey = keyof typeof ui
export default ui

// locales/en/index.ts
import ui from './ui'; import canvas from './canvas'
import inspector from './inspector'; import templates from './templates'
const en = { ...ui, ...canvas, ...inspector, ...templates } as const
export type MessageKey = keyof typeof en
export type MessageCatalog = Record<MessageKey, string>
export default en
// locales/ko/ui.ts — one slice; the same for canvas / inspector / templates
import type { UiKey } from '../en/ui'
const ui = { 'toolbar.export': '내보내기', /* … */ } satisfies Record<UiKey, string>
export default ui

// locales/ko/index.ts
import type { MessageCatalog } from '../en'
import ui from './ui' /* … */
const ko = { ...ui, ...canvas, ...inspector, ...templates } satisfies MessageCatalog
export default ko

Each ko slice satisfies Record<<Domain>Key, string> against its ../en/<domain> counterpart, so tsc fails on a per-slice missing / extra key; the merged ko also satisfies MessageCatalog as a whole.

  • compile-time key parity: satisfies MessageCatalog on every non-en catalog makes tsc fail the build on a missing or an extra key in the object literal — the CI script (§L12 #1) then adds the runtime / cross-locale checks (empty strings, placeholder-set match, "add a locale" smoke).
  • catalog keys are flat, stable IDs (§L3.2) — never the English text, never a key derived from user data. Translated message text never becomes a type identifier or a lookup key in product code.
  • catalogs are code, not JSON, this cycle. A JSON extraction / import pipeline is added only when a real translation-management tool needs it — a separate, later decision.
  • en + ko are statically imported in v0.8.0 (§L2.1, one bundle); the registry's catalog: () => import(...) thunk (§L2.2) is the seam for later dynamic loading (§L4.5) — a loader change only, touching no call site, no key, and no GraphDoc / Workspace / Share boundary.

L3.4 — what is not in the catalog — and the GraphDoc is locale-independent at creation, not just across a switch. A task done in the Korean UI and the same task done in the English UI must produce a byte-identical GraphDoc. The UI language never leaks into model data — not on switch, and not on create.

category in the catalog? note
UI chrome — buttons, menus, labels, tooltips, hints, dialog copy, empty states yes the whole of §L6
accessibility names + live-region text yes §L10
error / warning message text (+ the "how to fix" line) yes via a error.<code>.message key, §L7
error / diagnostic codes (M_REG_EVAL, EXPR_SYNTAX, M_REG_CYCLE, …) no — permanently stable data, not text
node / edge label no — verbatim round-trips unchanged; never reformatted or machine-translated
a Register / edge expression and any format string no loop-expr/1 content
unit and resourceType strings no advisory model data
the document title and any user description no user prose
the raw model value shown in the Inspector (a number, an expression result) no it is data being displayed, not chrome — only its label is keyed
a wire enum's <option value> (automatic, pushAny, deterministic, int, …) no — verbatim token GraphDoc / digest unchanged; a locale switch fires no change
a wire enum's OPTION LABEL — the human-readable text of that <select> yes enum.<group>.<token> — 자동, 아무 경로로 보내기, … (§L3.4a)
the label a template writes into the GraphDoc (Templates.tsx) no a template's menu name / description is chrome (keyed); the labels it seeds into nodes are model defaults. One bounded exception: docs/template-label-overlay.md §TLO11 — a bundled template's OFFICIAL node labels (an exact string match against the shipped-locale dictionaries) follow a later UI-language change; a user rename never does
the default label / value defaultData() produces on "add node" (src/model/factory.ts) no — a fixed English/ASCII default ("Source", "Pool", …) in the src/model/ layer, independent of the UI locale; the user renames it if they want
example / fixture GraphDoc strings (examples/*.json labels) no a locale switch never rewrites "Ore Stock"
schema id, kind, mode, wire keys, file metadata, the tool string no ASCII / English forever

Palette display name vs model default are separate. The Toolbar's "Source" button can read 소스 under a Korean UI (it is t('toolbar.node.source')), but pressing it still creates a node whose stored label is the locale-independent defaultData() default — not 소스. The palette label and the seeded model label come from two different places on purpose.

The line: anything the user or a file author wrote — or that Loop Studio seeds as model data — stays as written / as a fixed default; only what Loop Studio's own chrome says about it gets a key. The lone bounded exception is a bundled template's own OFFICIAL node labels, which follow a UI-language change by exact string match (template-label-overlay.md §TLO11); a user's own label — even inside a template graph — never does.

L3.4a — wire enum: stored value vs displayed label. A wire enum's stored value, its code, and any raw display of it (the raw-data fallback textarea, a diagnostic {code}, the Canvas node's automatic · pushAny state readout) never change — those are the frozen token. But the human-readable label of the <select> a person reads and picks from is UI chrome and is localized, via an enum.<group>.<token> key, with the <option value> left as the bare token:

<option value="automatic">{t('enum.activation.automatic')}</option>  // → "자동"
<option value="pushAny">{t('enum.flowMode.pushAny')}</option>        // → "아무 경로로 보내기"

So: value = automatic / pushAny (GraphDoc + digest identical, a locale switch fires no change and no edit); displayed label = translated; the current selection, undo, and simulationRev are unaffected by the switch. A <select> that already shows token — localized description (edge Type, state Mode) is already value-separated and stays as it is.

L4. Message format & the catalog runtime

L4.1 — intl-messageformat (FormatJS) is the formatter (Q2 — decided). No hand-rolled ICU subset — nested plural/select, escaping, the other rule, and per-language CLDR plural categories are not something to re-implement and keep correct long-term. Use FormatJS's intl-messageformat directly:

  • full ICU Message syntax — {name}, {n, plural, …}, {g, select, …}, {n, selectordinal, …}, {n, number}, {d, date, …} backed by Intl.NumberFormat / Intl.DateTimeFormat;
  • a t(key, params?) wrapper compiles an IntlMessageFormat(message, locale) and caches the compiled formatter keyed by (locale, key, message);
  • the dependency version is pinned in the lockfile;
  • rich-text tag syntax (<b>…</b> callbacks) is forbidden in Slice 1 — string output only. If a surface genuinely needs inline markup, that is a separate decision, not a Slice-1 freedom.

L4.2 — no concatenation. Components call t('playback.status.stepN', { n }). Assembling a sentence from translatable fragments in a component is forbidden — "Step " + n, t('a') + t('b'), <>{t('x')} {value}</> where order matters — because word order, particles, and spacing differ by language. A string with a runtime value is one ICU message with a named slot: "Step {n}" / "{n}단계".

L4.3 — placeholder validation. Every catalog entry's placeholder set must match the en entry's (same names, no extras); CI checks this for every registered locale (§L12 #1). A call site passing an unknown param, or omitting a required one, is a dev-time throw + a test.

L4.4 — the fallback chain, applied per t() call, and it NEVER exposes a raw ICU pattern. format.ts's tryFormat returns null on any failure — a bad pattern or a missing / wrong-shape runtime parameter (CI §L12 #1 blocks bad patterns and cross-catalog argument drift, but cannot see what a call site passes). render() then walks:

  1. the active-locale message formatted with the given params;
  2. if that returns null → the same key's en message, same params;
  3. if that also returns null → a stable localised failure notice carrying the key (i18n.messageError → "text unavailable ({key})" / "문구를 표시할 수 없음 ({key})");
  4. last resort (the notice key itself unusable) → the bare key text.

Never a throw, never an empty string, and never the raw ICU message or the user parameters in the rendered output, an error UI, or a production log. The only diagnostic is a dev-mode console.warn naming the key, the locale, and the error class — not err.message (FormatJS embeds the pattern in it), not the params.

L4.5 — catalog loading & atomic locale activation (shipped, now async). Only the base (en) catalog is statically bundled — it is the fallback and must never fail to load. Every other locale is a dynamic import() of its own chunk: the UI message catalog (assets/locale-<code>-<hash>.js, from registry.ts's catalog()) and the template-label dictionary (assets/tmpl-labels-<code>-<hash>.js, templateLabels/dicts.ts ensureTemplateLabelDict). Adding FR / zh is "add the locale files" — the loader map + the codegen pick them up, no cache-structure change.

A switch loads BOTH halves before it commits. setLocale(code) waits on Promise.all([entry.catalog(), ensureTemplateLabelDict(code)]), so when activeLocale flips, the §TLO11 relabel (which runs synchronously off the useI18n.subscribe in graphStore.ts) always finds the target dictionary resident. initI18n() does the same before createRoot().render(). The one import('./<code>') now yields BOTH the node-label map and the group-frame title map (<code> + <code>Frames; dicts.ts composes { nodes, frames }) — so §TLO12 (frame-title overlay) adds no new chunk and rides the same atomic load.

Provenance-agnostic §TLO11 under lazy dicts. relabelNodesForLocale needs, synchronously, "is this current label an official string in some shipped locale?". That classification set comes from a build-time seed, src/i18n/templateLabels/known.generated.ts (npm run gen:known-labels; check:template-labels fails on drift, so a newly-registered locale forces a regen). Only the target string is read from the lazily-loaded dictionary. The known set therefore stays complete no matter which chunks have loaded — an imported graph carrying a never-visited locale's official labels still relabels.

preloadLocale(code) is a seam for future eager pre-loading (FR / zh) — it calls the real loaders and only fetches for a not-yet-resident locale; nothing calls it today.

Failure notice. A failed catalog or dict load raises loadError on the i18n store; src/components/LocaleLoadNotice.tsx shows a dismissible role="status" banner (mirrors BootNotice) so a language that will not load does not read as a silent bug. Cleared on dismissLoadError() and on the next successful switch. The failure contract is otherwise unchanged (see Rules).

State (the minimum the provider holds):

{
  activeLocale,        // the code whose strings are on screen right now
  activeCatalog,       // its loaded catalog object (never null after boot)
  requestedLocale,     // the code the user last asked for (may === activeLocale)
  requestGeneration,   // monotonic counter, bumped on every switch request
  loading,             // bool — a request's catalog load is in flight
  loadError,           // { code, current } | null — a failed load, for the notice
}

Rules:

  • selecting a locale = (a) persist the preference (§L5.1) + (b) start an activation request — two distinct steps. (a) is synchronous and unconditional (it records intent); (b) may take time and may fail.
  • an activation request bumps requestGeneration, sets requestedLocale, sets loading = true, and starts the catalog load.
  • on load success, and only if the completing request's generation is still the current requestGeneration, one commit changes activeLocale, activeCatalog, <html lang>, and <html dir> together, sets loading = false, and announces once. Nothing about the UI language changes before this commit.
  • a late success or failure whose generation ≠ the current requestGeneration is ignored entirely — no state change, no <html> change, no announce.
  • on load failure of the current request: keep activeLocale, activeCatalog, and <html lang> / dir as they are; loading = false; a one-time non-blocking notice; the persisted preference is left as the user set it (§L5.2) — a retry / reload can still honour it.
  • initial boot: the resolver (§L5.2) picks a locale; if it is en, boot is synchronous on the embedded catalog. If it is a non-en locale whose catalog load fails, boot proceeds on the embedded en with the notice; the preference is untouched.
  • during loading: the previous locale's screen stays fully rendered — no blank screen, no spinner over the whole app, no mixed-language frame, no partial catalog. (A tiny "switching…" affordance on the language control itself is fine.)
  • re-selecting activeLocale is a no-op — no generation bump, no load, no announce.
  • a fast ko → en → ko (or any burst) settles on the last request: each bumps the generation, only the newest can commit, earlier completions are dropped by the generation check. The end state is exactly as if only the last selection happened.
  • the data boundary is unchanged regardless of load strategy: no GraphDoc, Workspace, Share, revision bytes depend on which catalogs are loaded or when. The PWA precaches only EN + the app shell; each non-EN locale-* / tmpl-labels-* chunk is CacheFirst runtime-cached (loop-locale-chunks), fetched on first setLocale / boot. See docs/pwa.md §P8 for the offline guarantee. The shared name pattern lives in scripts/locale-chunk.mjs (LOCALE_CHUNK_RE) so the Workbox rule and check:pwa-closure never drift.
  • a test drives a deferred catalog loader (a controllable promise) to assert every rule above — atomic commit (catalog and dict), generation-drop of a stale completion, failure keeps the screen + raises loadError, a later success clears it, boot-on-en fallback, no-op re-select, last-request-wins burst. The production-bundle / PWA e2e specs assert the chunk shape (EN first-load fetches no locale-*; a switch fetches exactly its chunk once; runtime cache; offline reboot) — dev-server module URLs differ from the Production output, so those live outside the unit + dev-e2e layer.

L4.6 — an omitted plural arm is a decision about wording, not about reach. French cardinal plural rules can select many for exact multiples such as 1,000,000 — Intl.PluralRules('fr').select(1_000_000) === 'many', and so does 2,000,000, while 1,000,001 is other. The French catalog intentionally omits an explicit many arm because its wording would be identical to other; the ICU fallback to other is part of the tested contract, not an accident. src/i18n/icuEscaping.test.ts renders every French plural at 1,000,000 and asserts it comes out as the other arm with no ICU error, so if a future message ever does need a distinct many the guard is already in place to notice that the two arms have to differ.

The consequence worth stating is the one that differs from English: French puts 0 in one. {n, plural, one {# ligne sera ajoutée} other {# lignes seront ajoutées}} renders 0 ligne sera ajoutée — singular noun, singular verb — where English renders 0 rows. Every French plural was formatted at 0, 1, 2 and 1,000,000 with the real intl-messageformat and read back, because gender and past-participle agreement ride on that same one arm: 0 valeur a été supprimée, 0 paramètre ajouté, 0 clé étrangère a changé. A checker that only diffs placeholder names cannot see any of that.

L5. The language switch

Auto-generated from the registry. The control is driven entirely by LOCALES and needs no edit when a locale is added. It sits next to the theme toggle — desktop Toolbar, mobile MobileMoreMenu. Activating it kicks off the atomic activation (§L4.5), persists the new code (§L5.1), sets <html lang> / <html dir> from the entry's metadata on activation, and announces the change once. No reload, no run interruption, no viewport change.

Form — a trigger button + a registry-driven overlay menu. The Toolbar control is a plain .btn (identical height to the sibling controls, so it shifts no committed visual baseline) showing the active locale's nativeName. Pressing it — mouse, Enter, Space, or ↓ — opens an absolutely-positioned popover (.lang-menu__pop) that lists every registered locale in registry order; the popover is an overlay, so it changes neither the Toolbar height nor any Canvas geometry. On mobile the same component is rendered inside MobileMoreMenu.

  • each item is a role="menuitemradio"; the active locale carries aria-checked="true" and a ✓; nativeName is primary, englishName a secondary line when it differs;
  • the trigger carries aria-haspopup="menu" + aria-expanded;
  • keyboard: Enter / Space / ↓ open; ↑ / ↓ move; Home / End jump; Escape closes and returns focus to the trigger; selecting an item closes and returns focus;
  • while the chosen catalog loads, the item shows a lang.loading note and the trigger a data-loading flag; a failed load leaves the current selection (aria-checked follows activeLocale, §L4.5);
  • adding a locale needs no change to LanguageSwitch.tsx — the menu is a .map over LOCALES. The dev-only en-XA pseudo-locale (§L11) exercises the ≥3-locale menu today.

A dedicated Settings screen (theme + motion + language + …) is deferred until there are enough preferences to justify the information-architecture work; it is not part of this cycle.

L5.4 — the search box counts LANGUAGES, not options. The picker renders a search field from LANGUAGE_SEARCH_THRESHOLD (6) locales up, via shouldShowLanguageSearch(). That predicate counts entries with no pseudo flag, so the dev-only en-XA (§L11) never pushes the count over the line. Counting raw options instead would have made the box appear in dev — and only in dev — one real language early, pinning its layout and keyboard model in a release that did not ship it.

French is that sixth language, so the box now renders in production: six options and a search box there, seven options and the same one search box in dev. It is the same control either way — en-XA is searchable but never counts. The threshold has not moved and nothing about the predicate changed to ship it; the count simply reached 6.

L5.5 — the search folds diacritics, and only Latin ones. With the box now shipped (§L5.4), the first language behind it is one most keyboards cannot type: Français needs a ç. foldForSearch() therefore normalises both sides of the comparison, over four fields — code, English name, endonym, and the name in the active UI language — so fr, French, Français, francais and franc all find it, from any of the six UI languages.

The folding is deliberately narrow, in four ways.

  1. A combining mark is dropped only when it sits on a LATIN letter. The obvious one-liner is wrong here:

    value.normalize('NFD').replace(/\p{M}/gu, '')   // NEVER

    Japanese dakuten and handakuten are combining marks. That line folds ポ → ホ and が → か, so one query would match two different kana and a Japanese reader's search would return the wrong language. Hangul jamo, Thai and Arabic marks are left alone for the same reason, and so is every Cyrillic mark but one (item 3). The guard is a test, not a comment: searching ポ must return zero results.

  2. Every kind of space folds to one ASCII space — U+00A0 and U+202F included — so a typed space matches the typeset one French names may carry (§L2.8).

  3. One Cyrillic letter folds: ё searches as е. Added with ru (§L2.17) and MEASURED first — before it, the two were different strings, so with the UI in Russian the Simplified Chinese row, Китайский (упрощённый), could not be found by typing упрощенный. Russian substitutes the two letters freely in running text and ё is a separate key, so the spelling in the catalog is not the spelling that gets typed. It is exactly one letter wide on purpose: under NFD й is also и plus a combining mark, and й is a distinct letter no Russian reader substitutes, so the rule rejected for dakuten in item 1 would be wrong here too. Both guards are tests — folding ё/е together, and keeping й/и apart — and both were falsified, by removing the rule and by widening it to all Cyrillic.

  4. Two Latin letters fold that no mark rule can reach, each added with the locale that needed it and each measured against a real row a reader could not otherwise find: Turkish dotless ı to i (§L2.18) and Vietnamese đ to d (§L2.21). Neither has a decomposition — they are letters in their own right, not a base plus a mark — so neither can be handled by item 1 and both are plain substitutions after the case fold. Both are SEARCH ONLY: Turkish and Vietnamese each treat their letter as distinct from its ASCII lookalike, and nothing about rendering or stored values changes. Both were measured for damage as well as benefit — across the shipped set neither creates a new collision between two picker rows.

A ligature that decomposition does not reduce to ASCII (œ, æ) is left as it is. Transliterating it would be a rule about French orthography rather than about search, and the cheaper answer if it is ever needed is an explicit alias on the one entry that needs it.

The box is a combobox, so aria-activedescendant moves to the input when the input owns focus, and the listbox must not also claim it. Below the threshold the listbox itself is focused and carries the attribute. Both arrangements are pinned in e2e/i18n.spec.ts.

The popover is sized by the language names, never by the box: an <input> carries an intrinsic size=20 width, which measured 206 px against the menu's own 150 px minimum, so the field is min-width: 0; width: 100%; box-sizing: border-box and a long placeholder wraps inside it instead of pushing the menu open.

L5.1 — persistence: one named string key (Q5 — decided).

localStorage["loop-studio/ui-locale/1"] = "ko"
  • the value is a bare locale code string, not a JSON blob — no premature preferences schema, no migration.
  • on read it is validated against the registry; a corrupt, empty, or unregistered value is ignored (fall through to browser locale → en).
  • reading a bad value never throws and never rewrites stored data — a garbage value is simply not honoured; it is left untouched or overwritten only by the next explicit switch.
  • a locale change updates only this one key.
  • it is never copied into the GraphDoc, the Workspace / Share payload, a revision / proposal file, the loop-revision/* digest, undo, or simulationRev. Per-browser, like the theme choice.
  • future UI settings (if any) each get their own loop-studio/<thing>/<n> key — this doc introduces exactly one.

L5.2 — the locale-decision order is fully deterministic (run once at first paint, before React mounts, to avoid a flash — and reused verbatim by the switch's "browser default" path):

  1. stored preference. If localStorage["loop-studio/ui-locale/1"] is exactly a registered code (no normalisation — see below), use it.
  2. Else, walk navigator.languages in order. For each entry L, apply every step below before moving to the next entry:
    1. exact match — a registered code equal to L (es-419, zh-Hans);
    2. else Chinese script (§L5.2a) — zh-CN → zh-Hans, zh-TW → zh-Hant;
    3. else BCP-47 base-language match — a registered code equal to L's primary subtag (ko-KR → ko, en-GB → en, de-AT → de);
    4. else baseFallbackFor owner — the registered locale that explicitly declares L's primary subtag (es-MX → es-419). The first entry that resolves wins; move to the next navigator entry only if the current one resolves to nothing.
  3. Else, the canonical fallback en, only after every entry is exhausted.

The walk is per ENTRY, not per step. Sweeping the whole array once per step would let a later tag's exact match beat an earlier tag's base fallback — ["es-MX", "de-DE"] would resolve to German. The user put their first tag first, so ["es-MX", "de-DE"] is es-419 and ["de-DE", "es-MX"] is de.

baseFallbackFor (step 4) exists because a locale's code is not always the subtag a browser sends. fr, de, ko and ja are reached by step 3 and declare nothing; es-419 declares 'es', and a later pt-BR would declare 'pt'. It is deliberately after the exact match, so registering es-ES later gives es-ES its own tag with no change to the resolver.

Three structural guards in registry.test.ts keep it from becoming a second, competing resolver:

  • at most one locale owns a base subtag — two owners of es fails;
  • a declared base is the locale's own language subtag — es-419 cannot declare 'pt';
  • an exact code always beats an owner, including one registered later. That last one is checked by calling the production function itself with a hypothetical registry list (resolveInitialLocale takes the locale array as a defaulted parameter), never a test-only copy of the algorithm.

Additional rules:

  • no input repair. Case and _/- are not normalised on the stored value: "KO", "ko_KR", " ko " are not a registered code and are ignored. (A stored value only ever gets there via the switch, which writes an exact code; anything else is corruption and is not silently fixed.) navigator.languages entries are matched case-insensitively on the base subtag only, per BCP-47, because the browser controls their shape.
  • an unregistered / corrupt stored value is ignored on read only — it is not deleted, rewritten, or "corrected"; it is simply skipped, and the next explicit switch overwrites it.
  • a catalog load failure does not touch the stored preference — the preference records intent; a failed load is a runtime condition, not a reason to forget the user's choice (§L4.5).
  • the fallback is always requested locale → en — one hop, no chain of intermediate locales.
  • the en catalog is required for boot. It is statically bundled and is never a failable remote resource — if the initially chosen non-en locale's catalog fails to load, the app boots on the embedded en (§L4.5). There is always a working catalog.

L5.6 — the picker's display order, and why the registry array is not it. The list is sorted at the PRESENTATION layer, over a copy (languageOptions.ts's displayLocaleOrder). The registry array is DATA: its order is the order languages happened to ship.

Sorting LOCALES itself would have been a silent defect. BASE_ENTRY was LOCALES[0]. Alphabetically the first entry is zh-Hans, so an ordered registry would have made store.ts's getEntry(code) ?? BASE_ENTRY hand an unknown locale a CHINESE entry — its direction, its numberLocale — while BASE_CATALOG stayed en. Nothing asserted that pairing, and nothing would have reported it. BASE_ENTRY is therefore now looked up by BASE_LOCALE, and the array is read as an unordered set.

The key is englishName, under Intl.Collator('en'), with code as a deterministic tiebreak. It is the one label that does not move when the UI language changes. The alternatives were rejected on exactly that ground:

candidate key why not
the localized display name the list would re-order under the user at the moment they are changing language — the one moment the picker is open
the endonym cross-script collation groups by script (Latin, then Cyrillic, then CJK); nobody asked for that grouping, and 한국어 vs 日本語 is still arbitrary within it
registry order (the status quo) every new language lands at the bottom, and regional variants never sit beside their base

Its weakness is stated rather than hidden: the sort key is a third string that is not on screen — the rows show the endonym and the display name. That is the price of an order that never moves.

Shipped order today:

Chinese (Simplified) · Chinese (Traditional) · English · French · German · Japanese · Korean · Portuguese (Brazil) · Spanish (Latin America)

A DEV pseudo-locale stays selectable, out of the sorted set, and last. It is not a language a user has (§L5.4), so it must not sort in among them — en-XA under English would put a QA entry between two shipped languages. Production ships none, which e2e/dist.spec.ts asserts as an exact ordered list rather than a set.

The contracts live in src/i18n/localeOrder.test.ts: the shipped order, the pseudo tail, purity over five shuffles of the input, non-mutation of the caller's array, the code tiebreak, and no drops or duplicates.

L6. Surface inventory (the sweep)

Every string-bearing surface, enumerated key-by-key in the base + conversion slices:

  • Toolbar (Toolbar.tsx) — node-add buttons, Export menu (ExportMenu.tsx), Share (ShareButton.tsx), Import, theme + language controls, the build stamp.
  • Inspector (Inspector.tsx) — every field label, unit label, activation / mode option, validation hint, state-edge mode copy, the resourceType mismatch note.
  • Timeline (TimelineChart.tsx) — "track all", per-pool legend controls, the Register dashed-line note, axis affordances.
  • Play / run (PlayBar.tsx, MobileRunBar.tsx) — Play / Pause / Step / Reset labels + titles, speed control, seed field.
  • Monte Carlo (MonteCarloDialog.tsx, DistributionPanel.tsx, BandChart.tsx, TerminationSparkline.tsx) — dialog copy, run-config labels, LIVE / DISTRIBUTION, percentile legend, the cell-limit error (§L7).
  • Import / Export / Workspace (ExportMenu.tsx, ConfirmDialog.tsx) — menu items, the Workspace summary confirm, size-cap prompts.
  • Revision UI (ReviewOverlay.tsx, RevisionChip.tsx, AuthorDialog.tsx) — Review panel, exact / divergent / unknown labels, Apply / per-hunk copy, conflict base / yours / theirs, the unverified-author note.
  • PWA update bar (PwaUpdateBar.tsx) — "a new version is ready", Update / Dismiss, the "run in progress" re-confirm.
  • Boot / share loading (BootNotice.tsx, ShareLoader.tsx, MobileOpenFileHint.tsx).
  • Templates (Templates.tsx) — starter-template names / descriptions are Loop Studio's copy → translated; a template's GraphDoc labels are model data (§L3.4) → not.
  • Shortcuts (Shortcuts.tsx) — the keyboard-help sheet.
  • Errors / warnings / empty states — everywhere (§L7).
  • Accessibility — every aria-label, title, role="status" text, PlaybackAnnouncer.tsx, dialog titles (§L10).

L7. Errors, warnings, diagnostics (Q3 — decided: user-facing only this cycle)

This cycle does not restructure the whole engine error system. It touches only the diagnostics that reach the UI.

  • UI-raised (validation hints, import failures, size-cap prompts, Share too big) — keyed message text, straightforwardly.
  • Engine diagnostics that surface in the UI (report.diagnostics entries shown to the user, the Monte-Carlo cell-limit message, import-refusal reasons). The engine is deliberately dependency-free and UI-agnostic (SEMANTICS.md) — it must not import an i18n runtime. So:
    • the engine hands the UI a { code, params } boundary object ({ code: 'MC_CELL_LIMIT', limit, cells, mb }); the pre-formatted English string stays as an untranslated developer-facing fallback.
    • the diagnostic codes (M_REG_EVAL, EXPR_SYNTAX, M_REG_CYCLE, MC_CELL_LIMIT, …) are unchanged and stay stable.
    • the translation catalog is keyed by a separate message key (error.MC_CELL_LIMIT.message), derived from but not equal to the code — the code is data, the message key is a catalog lookup. A renamed catalog key never implies a renamed engine code and vice-versa.
    • an unrecognised code (an engine version ahead of the catalog, or a code with no error.<code>.message entry) renders a stable generic localized message ("Something went wrong while running the model.") with the raw code shown alongside for a bug report — never a blank, never the English dev string as the primary text.
  • Out of scope this cycle: structuring internal assertions, developer-only errors, and every remaining throw new Error(...) in src/engine/** into { code, params }. That is its own follow-up.
  • Unchanged: the frozen engine results, report.diagnostics contents (which conditions produce a diagnostic), and evaluation precedence. Adding a code field to a diagnostic object is additive metadata, not a semantics change; if any consumer test pins the exact English diagnostics strings, it moves to asserting the code instead.

L2.22 — Italian is it, and the Vietnamese guard shape does not transfer. Catalog src/i18n/locales/it/, template overlay templateLabels/it.ts, module overlay in moduleLabels.ts, guards itCopy.test.ts and e2e/i18n-it.spec.ts. 850 keys · 196 node labels · 7 frame titles · 19 module labels.

Sixteenth language.

The code is the BARE subtag, and that was measured three ways

The roadmap said it-IT. Running this file's own resolveInitialLocale against three hypothetical registries said otherwise:

registered as it it-IT it-CH it-Latn-IT
it-IT alone en it-IT en en
it-IT + baseFallbackFor: 'it' it-IT it-IT it-IT it-IT
bare it it it it it

Registering it-IT alone strands a bare it tag — which browsers do send — in English, and takes Switzerland and San Marino with it. Bare it behaves like ru, tr, th and vi: §L5.2 step 3 carries every it-* with no baseFallbackFor at all, and it has none of the es-ES / pt-PT script-subtag limit, because step 3 splits on - and matches the base directly. Adding it moved zero existing tag resolutions. Numbers and percent stay pinned to it-IT through numberLocale, the way th-TH and vi-VN do.

Three plural arms, and a fixture set that nearly missed one

Intl.PluralRules('it').resolvedOptions().pluralCategories is ['one', 'many', 'other']. Measured through the product's own tryFormat:

0 → other · 1 → one · 1.5 → other · 999999 → other
1000000 → MANY · 1000000.5 → other · 2000000 → many

many selects a non-zero integer multiple of 1,000,000 and nothing else. English takes other there, so an English-shaped two-arm message loses the category silently — and the first draft of the fixture set stopped at 1,000 and would never have reached it. Every plural message in this catalog writes all three arms; for a row or table count many is unreachable through the product's own limits, and the arm is written anyway because a missing one is a silent fallthrough.

A measurement that measured nothing, recorded so it is not repeated. An earlier note here claimed "1.0" selects one in Italian "unlike English". PluralRules.prototype.select runs ToNumber on its argument, so a visible trailing zero never reaches the rule: 1, "1", "1.0" and "1.00" all select one in en, it, ru and pt-BR alike. Do not probe a visible-fraction distinction with select(). The many result stands because it was measured on integers.

The guard is a WORD INTERSECTION, not an ASCII vocabulary

Vietnamese (§L2.21) was the first Latin-script locale, and its guard declares every ASCII word the translation keeps — workable because Vietnamese is diacritic-dense, so a pure-ASCII run is unusual enough to enumerate.

Italian is written almost entirely in ASCII. Measured on this catalog: 1,292 distinct ASCII words. Declaring them would mean declaring the language, and a guard that lists the whole vocabulary asserts nothing.

What works between two Latin languages is a per-key word intersection with the English source. A word that survives translation completely unchanged is one of four things — a proper noun, a unit or format token, a deliberate loanword, or an untranslated leak — and the first three are finite while the fourth is the defect. Measured: 78 distinct words over 340 (word, key) pairs, a set a reader can actually check, declared in itCopy.test.ts in eight groups that are asserted disjoint and asserted non-dead.

Kept from the Vietnamese arc unchanged: the exact key-AND-value set of everything identical to English (38 pairs — a count would hide a swap), and the vacuity assertion that every row's English side is non-empty before anything is compared with it.

Falsified, not assumed. Three deliberate leaks were injected and each was caught by a different contract: a whole value left in English (untyped) by the identical-to-English set, an English head word with an Italian clause (Hide la minimappa) by the word intersection, and a glossary reversal (Cancello for Gate) by the glossary. The catalog was restored byte-for-byte afterwards.

it collides with vitest's own it()

The first locale whose CODE is a test-framework identifier. icuEscaping.test.ts and parserLocation.test.ts import the catalog as itCat and key it as it: itCat; every other catalog keeps its bare name. Caught by a parse error, not by a reviewer.

No font work, and that is measured rather than assumed

Every code point Italian uses — à è é ì í î ò ó ù ú, their capitals, and € — is already in the unranged latin face's cmap, checked against the real woff2 rather than against the declared unicode-range. src/index.css is untouched by this locale, and e2e/i18n-it.spec.ts asserts the page fetches no new font file. The ink probe there compares against the PLATFORM FALLBACK rather than calling document.fonts.check(), which has now lied for four locales running (ru, tr, th, vi).

One gap found while measuring, recorded because it belongs to Dutch rather than to Italian: ij/IJ (U+0132–0133) are present in the latin-ext FILE but outside the range src/index.css declares for it (U+011E-011F, U+0130, U+015E-015F, Turkish only), so the unranged latin face wins and has no glyph. It only matters if a future Dutch catalog uses the ligature rather than the two letters ij.

Glossary

Serbatoio Pool · Sorgente Source · Scarico Drain · Ripartitore Gate · Convertitore Converter · Fine End · Parametro Parameter · Valore calcolato Register · Attivatore Activator · Riquadro frame · Riquadro di gruppo group frame · Gruppo {n} the default frame name · Zona zone · Area di disegno canvas · Modello Template · Modulo module · Spazio di lavoro workspace.

  • Gate is Ripartitore, never Cancello. Cancello is the literal gate and is also a network port. This product's Gate SPLITS flow, and both gate-typed labels it ships say so in English ("Reward router", "Split production flow"), so the kind name follows its own labels — the contradiction Vietnamese shipped and had to repair.
  • Register is Valore calcolato, never Registro. Registro is a ledger and implies storage, but inspector.register.noStore says it "accumulates nothing, stores nothing and has no ports". Its LENGTH is a UI-width question, answered by the wrapping and toolbar tests, not a wording one.
  • End is Fine, not Terminale. Keeping the source's plain concept beats a word that reads as a console terminal.
  • Template is Modello even though the product also talks about "the model". The ONE English sentence where they collide, tour.desktop.playback.body ("Run the model one step at a time"), is translated contextually as Avanza la simulazione di un passo alla volta. A single awkward sentence is not a reason to mis-name a product noun.
  • Canvas and Workspace never share a word — area di disegno against spazio di lavoro, pinned by itCopy.test.ts.

STEP is three different words, measured: 41 uses split into the simulation tick (passo, ~35), a stage in a process (fase, 3 — "Buffered production step", "no shipment step"), and a movement increment, which takes no fixed noun at all and is written per sentence (spostamento maggiore). Unifying them would say the production line is missing a simulation tick.

{column} is a CHARACTER offset in every error.EXPR_* message (carattere) and a TABLE column only in import.loc.* (colonna) — §L2.16. The guard caught one real defect here: import.parseError is the CSV parser's character offset, and English calls it "column" for both senses.

Kept in English, on purpose

Pity · Hard pity · Pickup · Banner · Pull — no authoritative Italian games source was found, and a cross-locale majority is not evidence about Italian. Two more were added to that list with reasons rather than assumed:

  • Roll, the gacha action and a near-synonym of Pull. Translating it while Pull stays English would make one screen contradict itself, which is the defect the Vietnamese read-back found.
  • Drop, a settled loanword in Italian games writing, used as the head of the compounds (Drop da equipaggiare). loot IS translated, as bottino, because Italian has the word.

Also untranslated: Loop Studio, Monte Carlo, every wire enum token, raw Inspector values, user labels, R/SR/SSR, XP, CSV/JSON, kg. Gold IS translated (Oro); it survives in English only in inspector.resourceType.placeholder, which lists canonical resourceType tokens rather than UI copy.

The enum.* values ARE translated — they are the DISPLAY labels for the wire tokens, not the tokens. Vietnamese left nineteen of them in English and that was the single largest leak in that catalog.

Style

Second-person singular imperative with no explicit tu (Seleziona, Immetti, Sposta), never Lei, never the manual-style infinitive (Selezionare). Descriptions are subjectless and short. The source's slot quoting is followed exactly and no catalog-wide quoting rule was invented — inventing one would be a rule about this document rather than about Italian.

Two strings shortened because a test measured them — and one re-read

templates.equilibrium.blurb and modules.rewardSplit.blurb were 115 and 79 characters and wrapped to a third line in a menu blurb box that fits two. descriptive-copy-wrapping.spec.ts caught both.

The first shortening then lost a concept, which only the full read-back found: English names three stages and the trimmed Italian named two. The lesson is the general one — shortening copy to fit a box is a translation change and has to be re-read as one, not treated as layout work. It now keeps all three stages and still fits.

Test hygiene that came with this locale

  • qaa replaces nl-NL as the unregistered-locale probe in four places (e2e/i18n-es-419.spec.ts, e2e/i18n-fr.spec.ts, e2e/i18n-pt-br.spec.ts, src/i18n/registry.test.ts). nl-NL was a probe for "must fall back to English" AND the next locale on the roadmap, so it would have gone stale the day Dutch shipped — the same shape as vi breaking i18n-th.spec.ts's probe. qaa–qtz is ISO 639-2's permanently RESERVED-FOR-LOCAL-USE range, so no registry can ever assign it. Measured: it canonicalises, Intl.NumberFormat accepts it, and it resolves to en both today and with it and nl registered. en-x-probe must never be used — it also resolves to en, but through the step-3 base match on en rather than the fallback, so it would pass while proving nothing.
  • e2e/toolbar-locale-width.spec.ts is new: one test per shipped locale, measuring the real Tier-1 toolbar row. toolbarOverflow.test.ts had carried hand-lifted metrics for EN/KO/JA only, and ru (707px at 1280) is wider than any of them. Inventing a fourth row of numbers there would have looked like coverage without measuring anything, so the per-locale coverage reads the DOM instead. It is a DIFFERENTIAL against English measured in the same test, because the absolute row count depends on the viewport.

The full EN↔IT read-back, and what it left open

All 1,072 runtime pairs were read side by side — 850 catalog keys, 196 node labels, 7 frame titles, 19 module labels. (850, not 849: registering Italian adds language.italian to every catalog.) The guards in itCopy.test.ts cannot do this. Type checking, NFC, the key set, the identical-to-English set and the word intersection all pass over a string that is Italian and wrong, and the read-back found fifteen that were.

Repaired — meaning had changed. Four of them:

  • templates.equilibrium.blurb had lost a stage. English names three — material in, processing and scrap, finished goods out — and the Italian named two. Self-inflicted: the sentence had been shortened to fix a three-line wrap and lavorazione went with the padding. Shortening copy is a translation change and has to be re-read as one.
  • lineage was discendenza, which points the wrong way. discendenza is DESCENT — what comes after. The product's lineage is the chain of PARENT revisions, which comes before. Now cronologia delle revisioni, which keeps the continuity the concept carries. 2 keys.
  • bean was a bare caffè in four labels, and this template sells a beverage and desserts as well as beans, so the word was ambiguous exactly where precision matters. The trade term is caffè in grani; the roasted stock is caffè tostato, never a bare tostato used as a noun. Same shape as the Vietnamese hạt nhân xanh defect (§L2.21).
  • export.workspace.omit.confirm had lost its object. "Save without it" became Salva senza, which cannot end an Italian sentence. The restored object avoids gender agreement so it cannot drift.

Repaired — the product contradicting itself. Two:

  • import.placement.frameHelp defined a frame with a second frame-word (Un riquadro è una **cornice**…), which reads as "a frame is a frame".
  • help.contextual.hint.review.name was Revisione, colliding with the product noun Revisione del progetto. English distinguishes review from revision and Italian does not, so the hint is now Verifica della proposta.

Repaired — grammar and clarity. Two: a masculine clitic after a feminine nearest antecedent in inspector.empty.title, and carattere … carattere twice in one short sentence in error.EXPR_BAD_TOKEN.message.

Repaired — register. Three: Principianti (people) named the first zone beside Colline and Altopiani (places), so it is now Zona iniziale, on the same axis; si contendono le stesse risorse asserted competition where English says only "pull against each other", now attingono alle stesse risorse; and a word-order slip in canvas.regionSelect.off.

Considered and deliberately NOT changed. review.hunk.cantRemove says la tua parte where the column label review.field.yours says tuo. They are different grammatical roles — a possessive label and a noun phrase — and without evidence that English means the same product concept in both places, unifying them mechanically would be a rule about this document rather than about Italian.

Still open — needs a native reader

Four categories. Each carries the evidence that makes it a question rather than a defect, and none of them is closed by a passing test.

  1. Ripartitore for Gate — whether it is the most natural Italian for the splitting sense. Deviatore is shorter but means diverter, which is turning flow rather than dividing it.
  2. Fine as a node-kind name. The concept is right and the alternatives are worse; whether a bare Fine reads as a label rather than as "The End" is the question.
  3. Valore calcolato for Register — naturalness only. Its length is settled by the width and wrapping tests, not by a reader.
  4. The game vocabulary kept in English — Pity, Hard pity, Pickup, Banner, Pull, and the two added with reasons rather than assumed, Roll and Drop — plus rare_conv. That last one is here rather than repaired because the English is Sell rare with no head noun: in the graph it is a Converter fed by bucket_rare ("Rare drops") and feeding items_sold and gold, so rare is a rare DROP. Vendi il raro is thin, and whether the Italian should name the drop (Vendi i drop rari) depends on the same games usage the rest of this item waits on, so it is decided with them. The Google Sheets menu path is CLOSED (2026-09-27). It was checked against the REAL Google Sheets product UI in Italian — the Sheets language set to italiano (Italia), which is what this locale's it / it-IT registration targets — and all three menu texts match on screen: File, Scarica, Valori separati da virgola (.csv). The authority is the product UI, not whether an account was signed in, and not the published help pages.

Note the limit of that evidence: a screenshot is GLYPH evidence, so it settles the wording, not the bytes — it cannot separate NFC from NFD or U+0020 from U+00A0. (Our source is NFC with no non-breaking spaces, checked separately.) The → between the levels is this document's connector, not a product string, and is not compared.

Microsoft Excel's own UI is OUT OF SCOPE, not an open review item. Reading it would mean switching the Windows / Office display language, and that cost is not worth the two strings in import.qs.sources.excel. They are left exactly as they are — not guess-edited toward what Excel might say — and revisited only if a screenshot of Excel in this language turns up on its own. The Microsoft Excel (.xlsx) row inside the Sheets Download submenu is a download FORMAT name and says nothing about Excel's own menus.

Not open, and why. Area di disegno is confirmed against Microsoft's own Italian UI. Modello for Template is the standard technical translation, and the single sentence where it collides with "model" is handled contextually. The imperative style matches Microsoft's Italian UI (Seleziona, Immetti, Sposta). The three node-kind terms above are open on NATURALNESS only — their consistency across all four surfaces is pinned by itCopy.test.ts, so settling on a different word later moves every surface together and the guard keeps passing without being edited to bless a new string.

One residual observation, recorded rather than acted on. Five surfaces name the proposal-review activity with esaminare (review.title, review.err.targetMovedList, hint.review.body, help.contextual.hint.review.desc, and the button copy) while the hint NAME is now Verifica della proposta. In Italian a section titled with the noun verifica whose body uses the verb esaminare is not a contradiction, so this was left alone rather than widened into a rename nobody asked for. L2.23 — Dutch is nl, the first locale the picker puts AHEAD of English. Catalog src/i18n/locales/nl/, template overlay templateLabels/nl.ts, module overlay in moduleLabels.ts, guards nlCopy.test.ts and e2e/i18n-nl.spec.ts. 851 keys · 196 node labels · 7 frame titles · 19 module labels.

Seventeenth language.

The picker order changes shape for the first time

displayLocaleOrder sorts on englishName under Intl.Collator('en'), and Dutch sorts before English. Measured against the real comparator, nl lands third: zh-Hans, zh-Hant, nl, en, fr, ... Sixteen locales had slotted in after the base language; this is the first one in front of it, so localeOrder.test.ts gained a row above en rather than below. vi keeps the last position, so i18n-tr.spec.ts's last-row assertion is untouched.

The code is bare nl, measured the same three ways Italian was

Run through resolveInitialLocale against three hypothetical registries: registering nl-NL alone sends six of seven probe tags to ENGLISH (nl, nl-BE, nl-Latn-NL, nl-NL-u-ca-gregory, nl-AW, nl-SR); adding baseFallbackFor: 'nl' rescues them; bare nl carries all seven through §L5.2 step 3 with no baseFallbackFor at all. Adding it moved ZERO existing resolutions. numberLocale is nl-NL, pinning the region against a future Intl difference rather than buying a change today.

Two plural arms, and grouping that does not predict spacing

nl has one and other only, and one is the integer 1 alone — the same shape as en and de. Italian's many at multiples of 1e6 has no Dutch counterpart, so an Italian-shaped three-arm message would declare an arm Intl.PluralRules('nl') can never select. nl-BE resolves to the same two categories, which is one more reason the catalog is not split by country. Ordinal is a single other, and the base catalog uses selectordinal zero times, so no key can reach it.

Numbers group like de: 1.234.567,89. Percent does NOT: 84%, measured as U+0038 U+0034 U+0025, the sign touching the digits. Grouping like one arm and spacing like the other is exactly the combination an assumption would have got wrong, so nl joins the no-gap arm of percent-affix.spec.ts with it rather than the U+00A0 arm de sits in.

The word-intersection guard is weaker here, and the file says so

Italian needed a per-key word intersection with English because Italian is written almost entirely in ASCII. Dutch has that problem and one more: Dutch and English are both West Germanic, so a word surviving translation unchanged is much weaker evidence. MEASURED: 141 distinct words over 531 (word, key) pairs, against Italian's 78 over 340 — and on review every one of the 141 is a product token, a proper noun, a declared loanword, or an ordinary Dutch word spelled the way English spells it (water, moment, per, open, rest). That last group is kept as its own reason group precisely to make the weakness visible. Sixty whole values are identical to English, against Italian's 38.

The guard was falsified before being trusted: seven deliberate defects — a whole value reverted to English, an English head word inside a sentence, a reversed glossary entry, the formal u, the IJ ligature, an Italian-style many arm, and a merged distinction — were each caught by a DIFFERENT contract, and all three files restored byte-for-byte (SHA verified).

What the guard did not find, the read-back did. See below.

Dutch is not pro-drop, so the style rule had to change

The approved style was "informal singular, and do not expose the pronoun" — carried over from Italian, where the verb ending holds the person. Dutch cannot do that: de verhouding die je instelt has no pronoun-free form. The rule is therefore use je, never u, and drop the pronoun only where Dutch allows it (imperatives). nlCopy.test.ts asserts zero u/uw across all 1,073 strings and that je actually appears, so the rule cannot pass vacuously.

Two product nouns that would have collided

  • middelen, not bronnen, for "resources". The obvious Dutch plural is bronnen — which is the Source node's own name, Bron, pluralised. The two flow-mode enums are the one place the word legitimately means an upstream node, and they are listed by key so the exception cannot spread. de and it make the same exception (von allen Quellen ziehen, tira da tutte le sorgenti).
  • run, not loop, for one execution of the model. The native Dutch cognate of "run" IS loop — the product name. de Lauf and it esecuzione have no usable Dutch counterpart here, so the English loan Dutch technical writing already uses is kept and declared. The same collision caught one sentence: tour.mobile.run.body began Loop stap voor stap..., where sentence-initial Loop reads as the product; it is now Ga stap voor stap.

Grammar forced one reordering

node.unreadable.title is unreadable {kind} in English. Dutch inflects an attributive adjective by the noun's GENDER, and {kind} holds a mix: Einde is a het-word while Voorraad, Bron, Afvoer, Verdeler, Omzetter, Parameter and Berekende waarde are de-words. No single attributive form is correct for every value the slot can take, so the phrase is reordered to put the adjective in PREDICATIVE position, where Dutch leaves it uninflected: {kind} — onleesbaar.

No font work, but the judgement is about CONTENT, not the file

IJ/ij (U+0132-0133) are present in the latin-ext FILE and OUTSIDE the range src/index.css declares for it (U+011E-011F, U+0130, U+015E-015F, Turkish only), so the unranged latin face wins and has no glyph. The criterion is not "the file has the glyph" but whether a real Dutch string uses the ligature code point, and modern Dutch writes the digraph as the two letters i + j. So no font change, and nlCopy.test.ts asserts zero U+0132/U+0133 across all 1,073 runtime strings instead.

Checked against the REAL cmap rather than the declared unicode-range, by inflating the .woff sibling @fontsource ships from the same subset build: the unranged latin face (232 glyphs, 400 and 600) carries every code point in the Dutch inventory except those two. Re-running the §L2.18 enumeration over all 46 shipped string groups plus the pseudo locale still yields exactly the Turkish five, the Vietnamese set and fr's U+202F — Dutch adds nothing.

The read-back found what the guards could not

All 1,073 EN↔NL pairs were read. Eight issues, twelve Dutch keys. The one that matters:

inspector.resourceType.placeholder lists CANONICAL TOKENS, not vocabulary. BUILTIN_RESOURCE_TYPES is ['Gold', 'Energy', 'XP', 'Player', 'Item'] and sameResourceType compares with ===. A reader who types the translated word gets a custom type with the generic swatch and no built-in colour, so a translated placeholder is not a style choice — it is wrong instructions. The first Dutch draft translated them.

Four shipped locales had already made the same mistake: fr had 4 of 5 wrong, zh-Hans and zh-Hant all 5, de 3 (Gold and XP happen to be spelled the same in German). Eleven other locales already kept all five, which is why this became a contract in localeSurfaceCopy.test.ts rather than a preference — it now holds every catalog, including en, to the model's tokens.

The other seven: error.EXPR_BAD_TOKEN.message had gained a word the source does not have and repeated teken; export.workspace.omit.confirm was Opslaan zonder, a Dutch phrase that ends on a dangling preposition; the four loot buckets mixed phrases with a compound where English uses one pattern (now Uitrustingsdrops · Verkoopdrops · Verbruiksdrops · Zeldzame drops); standard_count_pickup was an adjective with no head noun; and two +{n} more affordances had lost their +. Negation: zero. Direction: zero.

Width was measured, never guessed, and the boxes are not one box

The toolbar row and the palette were measured against the proposed wording BEFORE the catalog was written, with measureText at the real computed font: the six Tier-1 labels total 483.2 px against ru's 506.8 high-water mark, and the widest palette name, Berekende waarde, is 113.3 px against ru's 149.4. Dutch is nowhere near a new high-water mark, so nothing was shortened for the toolbar or the palette.

descriptive-copy-wrapping.spec.ts then found four blurbs at three lines. Measuring them exposed an assumption worth recording: .menu__blurb is not one width. At the e2e viewport the Templates menu renders it at 272 px and the Export menu at 232 px, both two lines of 16.875 px. A simulation run at 272 px for both called the Export blurb safe when it was not.

All four were rewritten WITHOUT dropping a semantic element, which is the rule Italian's §L2.22 arc arrived at the hard way:

  • templates.equilibrium.blurb became Materiaal in, ... eindproduct uit — een lijn die na enkele stappen stabiliseert. erin/eruit became in/uit, which is closer to the English bare in/out; gereed product became the single word eindproduct; tot rust komt became stabiliseert. Every element of "Material in, processing and scrap, finished goods out — a line that settles within a few steps" survives.
  • export.projectRevision.blurb became het diagram plus project-id en revisiereeks, voor offline samenwerking — both the revision lineage and the offline collaboration kept, which is exactly what the Italian arc lost twice while trimming this same key.
  • templates.deadlock.blurb ends on alles stopt, which is the English everything stops rather than the longer paraphrase it had.
  • modules.rewardSplit.blurb uses inkomende for incoming, not the longer binnenkomende.

Still open — needs a native reader

Seven from the approved contract — Verdeler (Gate), Berekende waarde (Register), Kader (frame), run (the loan), Tekengebied (canvas), Activator, and the games vocabulary kept English (Pity, Hard pity, Pickup, Pull/Pulls, Roll, Banner, plus the drop / quest / item loans) — and five more from the read-back:

  1. Dodenwachtrij / Kosten van doodgaan. sterfgeval and overlijden are the register a death certificate uses and this is a combat model, so the plain words were taken. Italian made the same call for the same reason.
  2. Bederf for spoilage. The module is a generic buffered production step, and bederf specifically implies perishables. Uitval is already Scrap, so the two are kept apart the way English keeps them apart.
  3. revisiereeks for lineage. A coinage: reeks is a series, so this reads as "revision sequence". herkomst (provenance) and revisiehistorie both fit the box too, and which one a Dutch developer would expect is a native-reader question rather than a width one.
  4. Zeldzaam item verkopen for rare_conv. English is Sell rare with no head noun; Dutch needs one, and whether it should name the drop depends on the same games usage the rest of the list waits on.
  5. bron(nen) in the two pull-mode enums, where the word means an upstream node rather than a Source node. de and it have the identical overlap.

The Google Sheets menu path is CLOSED (2026-09-27), checked against the REAL Google Sheets product UI in Dutch. All three menu texts match on screen: Bestand, Downloaden, Door komma's gescheiden waarden (.csv).

Recorded evidence limit, NOT an open item: a screenshot is GLYPH evidence, so it did not settle whether the apostrophe in komma's is U+0027 or U+2019 — the two are indistinguishable at that size, and this catalog ships U+2019. That is deliberately left alone. This sentence tells a reader where the menu item is; it is not a byte contract with Google Sheets, both forms point at the same menu unambiguously, and neither changes what the product does. No DOM extraction was done for it and none is owed.

Microsoft Excel's own UI is OUT OF SCOPE, not an open review item. Reading it would mean switching the Windows / Office display language, and that cost is not worth the two strings in import.qs.sources.excel. They are left exactly as they are — not guess-edited toward what Excel might say — and revisited only if a screenshot of Excel in this language turns up on its own. The Microsoft Excel (.xlsx) row inside the Sheets Download submenu is a download FORMAT name and says nothing about Excel's own menus.

Not open, and why. Tekengebied is a PRODUCT choice, not a standards claim: Microsoft's own Dutch material uses canvas in Whiteboard and both tekenpapier and tekengebied in the .NET MAUI graphics docs, so there is no single Microsoft translation to appeal to. Level over Niveau is a REGISTER decision — Dutch players say "level" and the graph's own Lv 1-5 abbreviations already assume it. It happens to remove a string collision with fr's Niveau, but that was never a reason to choose it: relabelNodesForLocale keys on the node ID, so two locales sharing a string for the SAME id is a no-op either way. What WOULD be a hazard is a Dutch label landing on a DIFFERENT id's official string, and nlCopy.test.ts asserts zero of those — along with zero distinctions merged that English keeps apart.

The probe tag this locale finally retired

nl-NL was the "unregistered language" probe in five places, and every one of them would have flipped the day Dutch shipped: i18n-de.spec.ts, i18n-es-419.spec.ts, i18n-fr.spec.ts, i18n-pt-br.spec.ts and registry.test.ts. Four were converted to qaa while Italian shipped; the de spec was MISSED and is converted here, together with relabel.test.ts's "unknown target locale" row, which passed 'nl'. A probe tag must be a code the product can never register — not merely one that is off today's roadmap.

L8. Numbers, dates, units

  • Never reformatted: anything stored, digested, or part of an expression's canonical form (loop-expr/1 §X8), a data.format result, or a user unit — model / wire content, not UI chrome.
  • Locale-formatted via Intl.* with the entry's numberLocale: only plain counts in UI chrome (cell count, run / step counters, byte sizes). For en / ko the output is identical (1,234, . decimal), so the safe default is leave as-is and add Intl.NumberFormat only where a specific string reads wrong in a locale. No dates are rendered today; if one appears, format with Intl.DateTimeFormat(entry.numberLocale).

L9. Layout, mobile, and RTL

  • Every converted surface is checked in every shipped locale at the mobile breakpoint and the desktop Inspector's 300 px column, light and dark — Korean and future languages run longer or wrap differently. Buttons use min-width + text-overflow, not fixed widths, where a translation would clip.
  • RTL: the dir metadata field exists so a future RTL locale can set <html dir="rtl">, but v0.8.0 does not promise an RTL-correct layout — no bidi CSS audit, no logical-property sweep in this cycle. Registering an RTL locale before that work lands is explicitly unsupported.

§L9.2 — the canvas is a space, not a paragraph

<html dir> follows the UI language (src/i18n/store.ts), so an RTL locale mirrors the app chrome. The React Flow subtree must not mirror with it. Node positions, pan, zoom, edge direction and the source/drain sides of a node face are graph coordinates: a diagram does not change meaning with the reader's script, and a mirrored one would contradict the stored x of every node.

@xyflow/react/dist/style.css declares direction: ltr on .react-flow, so the split already holds. MEASURED under dir="rtl", the break lands exactly on that element — .canvas computes rtl, .react-flow computes ltr. That is a vendor guarantee for a behaviour the product depends on, one line deep in a file this repo does not control, so src/index.css declares it too; src/main.tsx imports the app stylesheet after the vendor one, so it wins at equal specificity.

e2e/rtl-canvas-contract.spec.ts asserts the split at runtime and reads the cascade, not the computed value — on the vendor rule alone the computed value is still ltr, so a computed-style check would attest to someone else's decision. It identifies the app's rule by the --ls-canvas-dir marker rather than by stylesheet URL: measured, the URL form passed vacuously, because Vite serves CSS as an inline <style> in dev and every sheet reports href === null.

§L9.3 — direction inside and above the canvas

Implementation status. This section was written before any of it existed. All of it is now code, and every rule below has a guard that fails when it stops being true:

rule guard
the canvas geometry pin e2e/rtl-canvas-contract.spec.ts (§L9.2)
the mirroring chrome declarations, as logical properties the 17-locale LTR geometry baseline
the playbar's physical control order (--ls-pstrip-order) e2e/rtl-playbar-pin.spec.ts
every text-carrying form control declares a direction scripts/check-form-direction.mjs, e2e/rtl-form-direction.spec.ts
a shared component takes its direction from the CALLER, required and with no default scripts/check-direction-props.mjs
every other ruled element declares the direction its content decides scripts/check-content-direction.mjs, e2e/rtl-canvas-content-direction.spec.ts
the two mixed shapes — per-fragment isolation and a branch-computed direction e2e/rtl-mixed-direction.spec.ts
every arrow follows its meaning unit, and none is a flipped box scripts/check-arrow-direction.mjs, e2e/rtl-arrow-direction.spec.ts

What is NOT here, stated so a green run is not mistaken for more than it is:

  • The Arabic catalogue landed in PR C, and the arrow contract is now armed by a real locale. ar-XB remains a registered RTL pseudo-locale shipping en verbatim — which is what makes "unchanged" literally assertable across a locale round trip, and also means no catalogue-content rule can apply to it. Until ar shipped, every locale the checker could reach was one that could not fail the mirrored-glyph rule, and src/i18n/arrowContract.test.ts existed to call the rule with a synthetic ar and prove that registering one would arm it. It did: check-arrow-direction now reports ar as a real RTL catalogue and evaluates the mirrored-glyph rule against it. That test stays, because the same argument applies to the NEXT RTL locale before it exists.

  • Assistive technology behaviour. The DOM and accessibility-API tier is closed (e2e/a11y-direction-invisible.spec.ts); how a screen reader pronounces, segments or brailles any of this is a tier above what a browser can report, and any later result is recorded as the stack it was measured on — a finding on Windows + Chrome + NVDA is a finding for that combination. The site count in that spec's own header is wrong, and C3.5 is why it matters. It says "three .sr-only regions and three shared aria-describedby targets"; there are FOUR live regions. The fourth is RegisterExprField's srMsg (:775), and it is exactly the region C3.5 put isolates into (regexpr-insert-done, regexpr-armhint-cycle-name). The spec is not wrong today — it locates the steady-state region with regions.find(el => !frame && !playback) and no register is selected in that test, so it picks the PlayBar one — but it asserts no COUNT, which is how a fourth region came to exist unrecorded. The AT review therefore has seven sites, not six. The seven sites are now a permanent contract. e2e/a11y-announcement-contract.spec.ts exercises all seven across 26 product paths and holds two tiers to account: the DOM string the app renders, and Chrome's own accessibility tree read over CDP. It is locale-parameterized (ar and the en control); adding a locale is adding a row, and re-running it over the other shipped locales is deliberately out of scope.

    What it asserts is structural, not a snapshot, so it survives a catalogue edit: each path produces a non-empty string; the isolated user value survives whole inside exactly one FSI/PDI pair with zero other bidi controls; the string appears at the AX property that site is supposed to carry it at (descendantText for the four live regions, description for the three frame descriptions); a state change REPLACES the phrasing rather than appending to it; and the sweep covers exactly 26 unique paths across exactly seven sites, so a path that stops being exercised fails rather than disappearing.

    Two behaviours are separate contracts because they assert the ABSENCE and the TIMING of a text change rather than its content. Both are stated at the tier the harness actually measures - observable changes of the live region's text state, sampled by a MutationObserver that ignores a sample equal to the previous one: a multi-step jump produces zero of them, and a single increment produces exactly one, deferred by the 900 ms throttle within a named tolerance.

    That is deliberately weaker than "rewritten once": a re-set of the same text, and intermediate writes coalesced into a single microtask, are not measured by this contract. It is weaker again than any claim about SPEECH - coalescing, interruption and suppression happen above the DOM, and an utterance count is not derivable from a text-state change.

    The three frame descriptions are measured the only way an assistive technology can reach them - by focusing the frame that REFERENCES them, and reading its name, role and description together. The targets themselves live inside an aria-hidden wrapper and can never be read directly. All three are pinned in EVERY state, not once at the end: a description that changes while the role or the accessible name silently drops is the failure that would otherwise pass.

    What a green run does NOT mean. The Windows UI Automation payload is not reachable from this harness, so nothing here is evidence about NVDA, speech or braille. That tier is issue #290: manual, non-blocking, and explicitly not a release gate.

  • The dir attributes on the 115 attribute-only sites (title, aria-label) are unchanged: markup cannot reach an attribute.

How exhaustive each layer is. The source checks say "all of them" for the elements they list, and their manifests say so in their own $limit fields: they defend what is in them and do not discover a text element added later. Only the §L9.3 census does that, and it needed human adjudication of 550 candidate rows. Re-running it after the work found 8 elements the work itself had created — seven declared isolates and one pinned-number span that nothing had ruled — which is the reason the census is re-run rather than trusted to stay valid.

Inside the canvas:

surface direction
graph coordinate space ltr
user labels, node titles, frame titles dir="auto"
localised description text the reader's direction, from useLocaleDirection() — never auto
edge labels that are flow syntax / expression / id / number only ltr
port positions, source/drain shape physical

Above the canvas, the floating chrome keeps its physical position and button order — minimap placement and internal coordinates, zoom controls, the playbar including the direction time runs and its button order, and the filter panel's anchor. Only the language UI inside those panels mirrors: their text, alignment and menus follow the reader. Tooltips and accessible names take the active language's direction.

The playbar's transport controls keep their physical orientation with the physical time axis: ▶ plays forward, ⏭ advances one step, ⟲ resets to step 0, and ⟳ replays forward from step 0. Reset and replay are distinct actions and intentionally use opposite rotations.

MEASURED: none of these glyphs mirrors on its own. Unicode mirrors only the characters carrying Bidi_Mirrored — brackets and relational operators — so an arrow keeps pointing the same way while the layout mirrors around it. Every arrow in the chrome is therefore a product decision, not something the renderer settles. Each one is decided by what its meaning is relative to: an arrow whose sense is defined by the reading flow mirrors, and one that names a relation in the model graph does not, because the canvas that draws that graph is itself pinned ltr.

The units live in src/i18n/arrowGlyph.ts and a site reads one through useArrowGlyph(unit). A direction-aware character, never transform: scaleX(-1) — the opposite characters were measured to exist in the shipping font stack, and a transform flips the box rather than the character: it takes anything else on the element with it, never reaches the accessible name, and leaves nothing a test can read back as text. undo and redo swap into each other and come out of one table for that reason, because two literals in two files is how the pair that must stay opposite quietly becomes equal.

scripts/arrow-units.json records every unit, the JSX sites, the literals that must STAY (without which "everything mirrors" would satisfy the check), the catalogue keys each unit governs, and how many of that unit's glyphs each key carries.

Two totals, and they are different units. They were being reported as one number, which is how the gap below survived:

total unit
18 × 22 + 2 = 398 CONTRACT PAIRS — (locale, unit, key) triples the rule is evaluated on
18 × 24 + 2 = 434 GLYPH OCCURRENCES — arrow characters actually inside those values

434 is a global census, not a sum over the manifest. A per-key contract cannot see an arrow added to a key nobody ruled on — that is guaranteed by its shape, not an oversight — so arrowCensus walks EVERY string in all eighteen catalogues and attributes each occurrence to exactly one meaning unit. The checker prints unclaimed 0 / multiply claimed 0 beside the totals, which is what makes the two numbers a PARTITION of the catalogues' arrows. multiply claimed is not hypothetical bookkeeping: it would fire if two units of one key ever required the same glyph, and the only reason they do not today is that share.tooLarge takes ← from menu-path and ▾ from disclosure-vertical.

The 18 is derived from the registry: entries with a catalogue file of their own. The + 2 in both is the conditional key import.qs.sources.excel, which ja and ko each give one arrow and English and Arabic give none — a locale-local choice the contract rules only if a translation makes it, and Arabic's zero is now asserted rather than observed.

24 exceeds 22 for two separate reasons, kept apart because they are not the same thing:

  • four keys are governed by two units each — share.tooLarge, revision.export.tooLarge, proposal.needProject and proposal.dirtyOrigin carry a menu-path arrow that mirrors and a disclosure-vertical ▾ that does not. Those are already two pairs, so they add nothing to the gap.
  • two keys carry the SAME unit's glyph twice — regExpr.row.cycle renders {name} → … → {name} and import.qs.sources.sheets names a two-step menu path (ملف ← تنزيل ←). Each is one pair and two occurrences, which is the whole gap.

The gap, and how it was found. The rule was "does the value contain the glyph this unit requires". MEASURED on the shipping tree: flipping only the SECOND → of regExpr.row.cycle to ← left the entire check green. Presence cannot see a second glyph. The rule counts now — exactly n of the required character, zero of the same arrow pointing the other way, and zero tracked glyphs the contract does not account for. For a KEEPING unit the "other way" cannot come from the unit itself (both its glyphs are the same character), so it comes from a mirror-partner table: →↔←, ↗↔↖, ▸↔◂, ↶↔↷.

Falsified against the real Arabic catalogue, twelve single-glyph edits every one of which the presence rule passed: on each duplicate key the second glyph flipped, one removed and one added; an arrow of a unit that governs nothing there; each half of a two-unit key broken on its own; the conditional key given an arrow, and given a wrong-way one; and the declared occurrence count edited down to hide a glyph.

The two keys PR C held open are settled, on three separate axes. Holding the axes together is what made them look like one question:

key translation direction arrows
tour.nav.position keep {n} / {total} verbatim — nothing to translate ltr, newly ruled. MEASURED under ar: the two number runs swapped and step 2 of 6 rendered 6 / 2. A counter is a pinned numeric pair, so .tour-popover__pos declares dir="ltr" and is recorded in scripts/content-direction.json none — not governed
import.qs.mapping keep the four column names verbatim — they are the header row of EXAMPLE_CSV, which the guide's own button pastes, so translating one would describe a column the example does not have inherits none — not governed

The held question about the slash order was the right question: the value needed no translation and the ELEMENT was the defect. Nothing in the text changed, which is why only a geometric assertion could catch it — e2e/i18n-ar.spec.ts measures the two number runs' rectangles. scripts/example-columns.test.ts pins the column names against the component source so the list exists once, and asserts both keys carry zero tracked arrows so the two axes stay apart.

Input fields take a direction from what the user will type, not from what the placeholder happens to look like:

content dir
flow syntax, expressions, ids, file names, numeric input ltr
free text, labels, notes, resource names auto
read-only localised prose the reader's direction

inspector.resourceType.placeholder is the trap: its placeholder starts with Gold and therefore renders correctly under RTL with no help at all, but the field accepts a user-typed resource name in any script, so it is auto.

§L9.4 — the value markup cannot reach

§L9.3 gave every text-carrying ELEMENT the direction its content decides. That closes the question wherever the value is its own JSX child, because there is an element to put dir on. It does not close it where a user value enters a localized sentence through an ICU argument: the sentence and the value become one string before any element exists, so there is nothing to mark up and nothing for dir="auto" to see inside.

PR B recorded 11 such decision rows and deferred them. They are implemented here. Three counts, kept apart, because two of them being 11 is a coincidence:

count what it is
11 decision rows what the §L9.3 census recorded and deferred
8 obligations one per PRODUCER — what is implemented
11 AST call sites what the edit touches; a closed key set is more than one

Two rows collapse (issueText is called at three render sites and holds one catalog call; groupLine is one call painted at two JSX sites) and three expand (a closed key set of two).

The isolate goes into the render ARGUMENT and nowhere else. Every shipped catalogue carries zero bidi controls and src/i18n/bidiControls.test.ts keeps it that way; a control that reached a catalog value would be a translated string the translator cannot see. The stored, digested, exported and shared value is the raw one — a control in the document would move its digest and travel into other people's copies.

The kind is part of the obligation. isolateAuto (FSI) is the counterpart of dir="auto", for a value whose direction is not known in advance: a spreadsheet cell, a column header, a frame name. isolateLtr (LRI) is the counterpart of dir="ltr", for a technical token whose direction IS known: a node id, a generated revision id. LRI and not FSI there because an id that happens to start with a digit or a symbol has no strong character of its own, so a first-strong isolate would hand it the surrounding paragraph's direction.

The value is stripped before it is wrapped, and the three families behave differently, so lumping them together gets all three wrong:

family characters how the scope ends
strong marks LRM RLM ALM no scope at all — they act by BEING strong
explicit formatting LRE RLE LRO RLO PDF, not PDI; unterminated, they run to the end of the paragraph
isolates LRI RLI FSI PDI

What matters for this rule is the conclusion they share: a bidi formatting or control character inside a VALUE can change how the SENTENCE around it resolves — by supplying a strong character the algorithm then uses, or by opening or closing a scope the code did not intend. A value's own PDI can close the isolate placed around it, after which the rest of the sentence sits outside the bounding that was the point. User data can contain any of them, so they are removed at the display boundary. ZWJ, ZWNJ and Arabic combining marks are not removed: they shape and compose the text rather than steer it.

One site is per-fragment. hint.importFirstCommit.body interpolates a comma-joined list of the user's own table labels. A single isolate around the joined string would give the whole list one paragraph direction, taken from whichever table the user happened to name first — MEASURED in PR B as two of six cases breaking identically with and without it. Each label is isolated on its own, and the quotes and commas stay OUTSIDE, because they are the sentence's punctuation rather than the label's.

layer guard
what the helpers produce — per-mode idempotence, the strip, exactly one outer pair, zero inner controls src/i18n/bidiIsolate.test.ts
every obligation implemented with its declared kind, on its declared argument, at every call site, and no isolate outside a declared row scripts/check-isolate-arguments.mjs + scripts/isolate-obligations.json
every ICU argument in shipped source classified, exactly once, with evidence — so a new interpolation cannot appear unexamined scripts/check-icu-arguments.mjs + scripts/icu-argument-disposition.json
the isolate never reaches the stored document src/i18n/isolateStorageBoundary.test.ts
the sentence LAYS OUT that way in a browser, in real Arabic e2e/i18n-ar-isolation.spec.ts

The browser layer is geometric on purpose. textContent equality is what the isolate is designed not to change, so a text comparison is green whether or not the isolate is there; and a screenshot cannot separate "the value sits in the wrong place in the sentence" from "the characters inside the value are in the wrong order". So it measures Range rectangles inside the one string ICU produced — the value's placement relative to the sentence's head and tail, and separately the visual order of the characters within the value, compared against the same value rendered alone in the same container. The necessity is measured too: for the shapes the census recorded as breaking, the same value interpolated RAW must come out in a different order.

Exhaustiveness — how the population was closed (C3.5). scripts/isolate-obligations.json defends the call sites listed in it, exactly and in both directions, and it does not by itself discover an ICU argument that starts carrying a user value later. The first attempt at finding those was an argument-NAME grep ({header}, {label}, {name}…), which is a guess about vocabulary rather than a measurement: it matched 34 arguments. Re-run from the TypeScript type checker the population is 205.

scripts/check-icu-arguments.mjs enumerates all 205 and derives four classes from the type and the syntax, so they are evidence rather than a claim someone has to keep true: number (the type is number-like), enum (a closed union of string literals, whose members it prints), catalog (a t(…) call, or a conditional whose every leaf is one) and isolated. Every argument it cannot derive needs a row in scripts/icu-argument-disposition.json stating its class and the evidence. An argument with neither is a STOP — that is what makes a newly added interpolation fail closed instead of joining an unexamined majority.

class count
number 96 derived from the type
closed-value 40 a catalog string, an engine phrase, a formatted number, a build constant, a model-vocabulary token
producer 1 bounded where it is PRODUCED, not in the argument list — only some of rhsDescribeText's branches make a token that needs it
isolated 37 wrapped in the argument list; every one declared in isolate-obligations.json
catalog 13 derived
attribute 12 PR B's separate axis. Two different numbers: 12 rows carry this CLASS, 18 arguments have an attribute SINK — 8 attribute-sink arguments are mechanically classified and need no row, and 2 rows are attribute by TRACING, because the value is built in a helper whose return value is an aria-label and the sink detector cannot follow a value through a function
enum 6 derived, members printed
auto / ltr / per-fragment 0 none left: an argument whose evidence says it needs bounding is bounded
unreachable 0 the class exists and nothing qualified: every argument enumerated is reachable
total 205

The sweep found 27 arguments beyond PR B's eleven. Eight were fixed in C3.5's first pass — import.refresh.columnEvents.unrecognized, whose header is the direct peer of the two events already covered and differed only in which side of the refresh it came from, and the five import.loc.* calls, whose table name and column header are concatenated with an already-isolated {value} inside issueText, so one rendered sentence carried one bounded argument and two unbounded ones, the two unbounded ones first.

The other 19 were first left classed auto / ltr / per-fragment and recorded as open. That was not a disposition: those class names state the treatment the value needs, and a checker asserting they are not yet wrapped preserves the gap rather than closing it. They are implemented. The checker now asserts that set is empty, so a row reappearing in one of those classes is red, and so is implementing one without moving its row.

Three of them brought a shape the address model could not express, and the checker was generalised rather than given a special case each:

  • a key held in a local const whose initializer is a nested conditional (inspector.activator.preview.*),
  • a key that is an inline conditional (review.foot.*),
  • a key that is a map lookup with a ?? literal fallback (regExpr.row.*).

resolveKeys now resolves a literal, a conditional, a ??, a local const and a map lookup to a closed set, and returns null — a STOP — for anything else. That replaced the one keyAuthority: "dynamic:ISSUE_KEY" special case the first version carried.

The accounting, with each number's unit stated, because several are close together and none is a restatement of another:

number unit
38 rows — 11 from the PR B census, 27 from the C3.5 sweep
25 obligations — one per PRODUCER
34 AST call sites the edit touches
37 ICU arguments wrapped in the argument list, plus 1 bounded in its producer
18 browser tests in e2e/i18n-ar-isolation.spec.ts
33 obligation and manifest mutations, each RED and named by check-isolate-arguments.mjs
16 isolate mutations, each RED with only the browser test that covers it run
10 population mutations, each RED and named by check-icu-arguments.mjs

Two shapes bound the value away from the argument list, and both are real rather than a convenience. wizard-issue-value prepares its object before the call, so there is no argument expression to wrap. inspector-activator-describe has three returns and only two of them make a token that needs bounding — the third is a bare number. Wrapping that one too was the first attempt, and it put two invisible characters around every plain number in that preview, in every language, for nothing; eleven existing tests went red saying so. The manifest declares the producing function and both return counts, so a return added later is a branch nobody classified and a wrap removed is the defect.

What the browser layer covers, and what it does not. The eighteen tests prove the LAYOUT contract — where the value sits in the sentence, and the order of the characters inside it — across both isolate kinds, four value shapes, hostile input, two per-fragment shapes and an endonym whose direction is opposite to the sentence. They do not render every one of the 34 call sites: the remaining ones are further instances of shapes already measured, and what decides whether each is wired is its kind, argument slot and call site, which check-isolate-arguments.mjs asserts exactly and which is falsified per obligation.

What a green run here does not say. It closes the isolation contract up to browser layout. How a screen reader pronounces, segments or brailles any of it is the named-AT-stack review, and whether the Arabic reads naturally is a native review. Neither is a browser measurement.

L10. Accessibility

  • <html lang> (and dir) follow the active registry entry.
  • Every aria-label / title / role="status" string is a key.
  • PlaybackAnnouncer.tsx — "Step N" / "Paused at step N" / "Reset to step 0" / "Ended at step N" become keyed templates; the throttle / latest-wins / generation-guard logic (Slice 3c-a) is unchanged.
  • The language switch has an accessible name per locale and announces the change once.

L11. Development affordances — strictly dev-only, byte-gated

Every item here is read only inside import.meta.env.DEV, which is statically false in the production and portable builds, so the code, the query key, and the identifiers are tree-shaken out entirely. e2e/portable-file.spec.ts asserts their absence at the byte level (devLocaleOverride, devPseudoLocales, en-XA, __formatCacheSize, ?lang).

  • ?lang=<code> — a debugging / e2e convenience, not a product feature. It forces a registered locale for the session and then stops: it does not enter the §L5.2 order, does not write localStorage, and does not propagate to a Workspace / Share payload. In a production build the query string has no effect — a ?lang=ko there still resolves by the stored preference / browser rule.
  • the en-XA pseudo-locale — a dev-only registry entry (catalog = en verbatim) so the switch, the resolver, and every check exercise an Nth locale without special-casing en / ko. Not shipped.
  • missing-key / format-failed console warnings in dev (both are hard CI failures, §L12 #1, so they never reach production).
  • (a later key-visibility mode — render every t() as its bare key to spot an un-keyed string — is noted for Slice 2/3, not built here.)

L12. Verification — every check iterates the whole registry

  1. Catalog + ICU integrity (CI, hard fail). tsc + satisfies (§L3.3) already blocks a missing/extra key; TS key parity is not enough to make the ICU messages safe, so scripts/check-i18n.mjs (in the checks job) loads the whole registry and, for every registered locale, asserts:
    • every message parses as ICU (no syntax error);
    • every plural / select / selectordinal block has an other arm;
    • the argument-name set of each message equals the en message's;
    • the argument kind of each shared argument matches en's — a plain slot vs number vs date vs plural vs select — not just the name;
    • no rich-text tag syntax (<tag>…</tag>) in any message (§L4.1);
    • no empty-string values;
    • after the active → en fallback, no key referenced by a call site is unresolved (against the full registry). The script is written against the registry loop, never against ko.
  2. No missing / unused keys (CI). Every t('…') call-site key exists in en; every en key is referenced somewhere (grep, with an allowlist for dynamically-selected error.<code>.message keys).
  3. No hardcoded UI text (lint/CI). Flags English-sentence literals in src/components/** outside the i18n layer (heuristic + allowlist).
  4. Per-locale visual matrix (Playwright). Key surfaces (Toolbar, Inspector, Timeline, Play bar, one dialog, one error, one empty state) captured for every shipped locale × light / dark × desktop / mobile. Extends the *-visual.spec.ts pattern; parametrized over the registry.
  5. Locale-switch invariance (Playwright). Import, run several steps, snapshot { GraphDoc bytes, loop-revision/3 digest, canUndo/canRedo, viewport, simulationRev, values, stepIndex }; cycle through every registered locale twice; the snapshot is byte-for-byte equal. A Workspace / Share round-trip is identical across a switch.
  6. Initial-language resolution (Playwright). navigator.languages mocked per registered code with no stored key ⇒ that locale; an unknown tag ⇒ base; a stored value wins over the navigator; a stored value not in the registry is ignored ⇒ base.
  7. Fallback (unit). A key present only in en renders the en string under every other locale; a key absent everywhere renders visibly with a test hook.
  8. "Add a locale" smoke (unit). A throwaway xx locale registered in a test with a partial catalog: checks #1 fails loudly on the gaps, the switch UI lists xx, t() falls back to en, and no product-data snapshot moves.
  9. Locale-independent toolbar layout (Playwright). At a fixed viewport the desktop toolbar's row count and the Canvas top are identical for every shipped locale — a longer katakana palette collapses more controls into the ⋯ overflow menu, but does not add a row. See docs/toolbar-responsive.md; tested by e2e/toolbar-responsive.spec.ts at 1920 / 1600 / 1280 / 820 px and across a resize sweep.

L13. Slices

Slice 1 — the base + the Toolbar + Play bar anchor (Q4 — decided). The smallest surface that exercises the whole mechanism end to end. It lands:

  • src/i18n/ — the locale registry + metadata, the t() provider, the intl-messageformat wrapper with the (locale, key, message) compiled-formatter cache, the atomic-activation loader (§L4.5, synchronous today, async-shaped), the initial-language resolver (§L5.2), <html lang> / <html dir>.
  • the auto-generated language-select control — desktop Toolbar, mobile MobileMoreMenu — with save / restore / browser-locale fallback and immediate (atomic) switching.
  • en + ko catalogs for Toolbar + Play bar only: every button name, tooltip, and aria-label; Play / Pause / Step / Reset; the step-announcement templates in PlaybackAnnouncer (logic unchanged).
  • the registry / ICU / invariance CI guards — checks #1–#3 and #5–#8 wired; the visual matrix (check #4) for just these two surfaces.
  • the invariance assertion (§L12 #5): switching KO↔EN before, during, and after a run leaves the GraphDoc bytes, the loop-revision/3 digest, undo / redo, the viewport, simulationRev, and SimState (values / stepIndex) byte-for-byte unchanged.
  • no behaviour change beyond the switch; the Canvas / Inspector / Timeline, dialogs, revision UI, etc. stay English until Slice 2a / 2b.

Slice 2a — the model work surface. Canvas, Inspector, Timeline — every field label, unit label, activation / mode option, validation hint, the resourceType mismatch note, node-kind captions, the Register dashed-line note, the timeline legend controls. This is where the user-data non-translation boundary (§L3.4) is enforced in code — palette label vs defaultData() default kept separate, a byte-identical-GraphDoc-across-locales test — and where the §L7 user-facing engine-diagnostic { code, params } mapping lands (the Inspector / Timeline are where those diagnostics show).

Implemented (feat/i18n-slice2a). en + ko for Canvas (ariaLabel), the Inspector (every field label, note, hint, empty state, the resourceType mismatch note, Parameter / Register advisory notes), the Timeline chrome (timeline caption, LIVE / DISTRIBUTION, legend show/hide + Register titles, CSV, the step N axis text), the mobile read-only Inspector sheet, and the node-face unreadable / invalid synthetic cues. The node-palette two-layer tip — palette.<kind>.name (also the button's accessible name) / palette.<kind>.description (semantic, matched to SEMANTICS-*) / palette.addAction, three separate DOM lines, an absolutely-positioned overlay shown on hover and keyboard focus, aria-describedby-linked. §L7 diagnostics: the register M_REG_* codes and the EXPR_* parse codes are shown verbatim by the caller with the prose from error.<code>.message (params carry only atoms — a column number); an unknown code falls back to error.unknownCode. Not translated (raw model data, shown as-is): every node/edge label, expression text, unit, resourceType, an Inspector raw value, a wire enum's <option value> and its raw display (the raw-data textarea, a {code}, the Canvas node's automatic · pushAny readout), and the node-kind chip. A follow-up (feat/i18n-inspector-enum-labels) localizes the displayed label of the Inspector's enum <select>s via enum.<group>.<token> while keeping value = the token (§L3.4a). Deviations: three edit-time hint notes (trigger delay, Register canonical form, the deprecated node-mode note) lose a decorative inline <code> box — an ICU message cannot carry markup (§L4.1); text and values are preserved. The palette has no mobile surface (editing is desktop-only), so the "mobile inline description instead of a tooltip" clause has no target this slice. Close on the mobile Timeline / sheets and React Flow's own zoom controls stay English — a cross-cutting concern for Slice 2b.

Slice 2b — the app work surface. Import / Export / Workspace, revision UI, the Templates picker UI, the PWA update bar, every remaining empty / error state, and the accessibility names + live-region text app-wide. Each surface with its en + ko catalog additions. Also: retire window.confirm() — every confirm becomes the shared in-app ConfirmDialog (one system, desktop + mobile), under this contract:

  • no external effect (share URL build, clipboard, download, import, GraphDoc swap) runs before Confirm; Cancel / Escape / backdrop are one identical cancel path (backdrop-dismiss is allowed — every Slice-2b cancel is safe);
  • focus is trapped while open and returns to the trigger on close; initial focus is Cancel so Enter never fires a destructive confirm;
  • a double-click on Confirm runs the effect once (caller busy guard);
  • a locale switch while a dialog is open re-renders it in the new locale;
  • opening + closing a dialog leaves digest / undo / viewport / SimState unchanged; user-activation work (clipboard, download) runs inside the Confirm button's click handler.

Split into two PRs (size / review):

2b-1 (feat/i18n-slice2b) — implemented. The shared ConfirmDialog hardening (useId for the aria ids, dismissOnBackdrop, dialog.cancel key), Share (ShareButton + the mobile More sheet: the §U4 disclosure is now the dialog; export + link build + clipboard run only from Confirm), PWA update bar (pwa.* strings + the "run in progress" confirm → dialog), import replace-confirm (MobileTopBar → dialog; Toolbar had no confirm, its error alert() body is keyed), RevisionChip + BootNotice (projectStore.bootNotice now emits a code, BootNotice localizes it), and the React Flow a11y layer (<ReactFlow ariaLabelConfig> — Controls buttons, the node / edge keyboard hints, the handle label). <strong> in the PWA-bar text and <code> in the legacy note become plain text (ICU has no markup, §L4.1). Still native, deferred to 2b-2: the Templates replace-confirm and the Export project-revision disclosure. routeImport warning strings stay English for now (no {code} — a later pass).

2b-2a (feat/i18n-slice2b2) — implemented. Templates (button + the two template names / blurbs, keyed by id in templateKeys.ts; the replace confirm → ConfirmDialog, loadGraph only from Confirm), the whole Export menu (items + blurbs), the Project-revision disclosure and the Workspace-JSON summary (all three confirm branches — save / omit / reject; the download runs inside the Confirm click, the reject stays a keyed native alert), AuthorDialog, and the mobile More-sheet rows + Templates / Export sheets. PROJECT_REVISION_DISCLOSURE / AUTHOR_DISCLOSURE consts removed. One E2E per destructive flow proves nothing runs before Confirm (confirm-dialog.spec.ts).

2b-2b (feat/i18n-slice2b2b) — implemented. MonteCarloDialog (title, field labels, tracked-pool head / select-all / empty state, the cost readout labels — Measured (last run) / Local benchmark / Execution / Parallel, N workers / Memory / over-limit note, the run / cancel / close buttons) and ReviewOverlay (title + Close, the gate / classification messages, the §R7A whole-apply confirmation copy — confirmationText() retired to review.confirm.default / .unknown, the apply-fail messages, the diff summary labels Nodes / Edges / run config / No graph changes, the hunk verbs Add / Remove / Change + tags, the field-choice labels take theirs / keep mine / base / yours / theirs, every action button, the footer). Not translated (shown verbatim): every diff hunk id and elementType, all shortVal() field values, the time / memory / run numbers, the parent revision id, the author name / note, and the invalid-selection structural specifics (res.reasons / res.detail from the model layer). Catalogs → 343 keys.

Slice 3 — acceptance validation. The full string inventory reconciled (every surface accounted for); the KO / EN × desktop / mobile visual matrix completed across every §L6 surface; long-Korean / overflow / LOD / forced-colors / reduced-motion all exercised; and the GraphDoc / digest / undo / viewport / simulation invariance asserted across the whole app, not just Slice 1's two surfaces.

Implemented (feat/i18n-slice3). Form: functional / DOM tests as the gate + a few KO reference screenshots (chosen over a ~30-image KO pixel matrix — that is brittle and high-maintenance; the EN visual baselines are untouched, so whole-design regression cover is not lost).

  • Inventory reconcile. scripts/check-i18n-surface.mjs (new; wired into the checks CI job as check:i18n-surface) — a conservative static scan of every src/components/** file for a user-facing English string not wrapped in t() (a static JSX text node, or an aria-label / title / placeholder literal), with a small allowlist for format tokens shown verbatim by design. Mop-up it forced: DistributionPanel, TerminationSparkline, BandChart, MobileOpenFileHint, the shared dialog.close (mobile sheet + timeline sheet ✕), the LoopEdge state / playback edge-label cues (clamp / blocked + their title tooltips + the transfer-breakdown tooltip), the orthogonal-route invalidWaypoint ! badge name, and the ReviewOverlay "nothing new to apply" stamp. Catalogs → 374 keys.
  • Functional KO acceptance (e2e/i18n-acceptance.spec.ts) — per-surface, in KO: no document x-scroll, the container inside the viewport, a 60-char Korean label ellipsizes without widening the Inspector; every dialog / menu / overlay contained; the Review overlay; the mobile (390px) app + More sheet + Monte Carlo; an app-wide en→ko→en (×N, and again with each dialog open — switched through i18n.setLocale since a modal scrim covers the menu) that leaves the GraphDoc / digest / undo / redo / viewport / simulationRev / SimState byte-identical; forced-colors + reduced-motion KO spot checks.
  • KO typography of the small-caps semantic labels (§L13 fix). The EN UI sets eyebrow / field / metric labels in uppercase + a small monospace face
    • wide letter-spacing; on Hangul that shrinks stroke-dense glyphs and splits a word into 실 행 횟 수. A :lang(ko)-scoped block in index.css (so no EN pixel moves) gives those specific labels — Monte Carlo head / field / pools-head / cost labels, Review head / field labels / tags, the mobile sheet title, the play-strip + timeline-head labels — the sans face, letter-spacing: 0, text-transform: none, and (dialogs / overlays / sheet only) a 11–12px size bump. Numbers, seeds, memory values, raw enum tokens, the toolbar, and the Inspector body are untouched.
  • Representative KO screenshots (e2e/i18n-visual.spec.ts, chromium + an inline-mobile block) — desktop full screen, mobile full screen, Inspector (node selected), Monte Carlo (metric labels shown, machine-specific values masked), Review overlay, a long Korean node label + a palette tooltip, the Export menu open. The build stamp / minimap / attribution are masked; the canvas transform is pinned so a fitView frame can't shift the shot.
  • README / positioning — separate, non-localization, bundled here per Lumi. The repo's GitHub About + Topics were updated first (by the maintainer): About → "Browser-based visual systems editor and simulator for resource flows, state changes, probabilistic rules, and feedback loops."; Topics now carry systems-modeling / discrete-simulation / resource-flow alongside game-economy-tools / game-design-tools / machinations (system-dynamics deliberately left off — not a current capability). The README intro is aligned to that: first sentence mirrors the About, then "designed primarily for game economies, while the same step-based model can represent inventory / supply chains / production / queues / cash flows / energy / other resource-flow systems", then a one-line scope boundary ("deterministic, discrete-step … continuous-time equations and spatial physics are not directly supported") and a Future directions section (continuous-time / spatial / external-engine) that is explicitly not a committed roadmap. Why reframes game economies as the representative case. The preview badge is kept as-is (it reads as "pre-stable", not "demo").

Follow-up slices — guided first-run tour, then contextual inline help, each on the finished base with its own design pass.

L14. Decision record

No open questions. rev 3 pinned the four pre-implementation boundaries; Lumi pre-approved merging this doc as settled design / implementation pending (no Frozen marker — it is not a semantic wire spec) once CI is green. Implementation begins at Slice 1 (§L13) after that merge.

Decided in rev 3 (the four pre-implementation boundaries):

  • deterministic locale-decision order (§L5.2) — stored exact code → navigator.languages in order (exact, then BCP-47 base) → en. No case / separator repair; an unregistered stored value is ignored on read only, never deleted or corrected; a load failure never rewrites the preference; fallback is always one hop requested → en; the en catalog is a mandatory bundled resource, never a failable remote fetch.
  • async activation state machine (§L4.5) — { activeLocale, activeCatalog, requestedLocale, requestGeneration, loading }; persist-preference and activate-catalog are separate steps; activeLocale / catalog / <html lang> / dir change in one commit only after the target catalog is ready; late completions with a stale generation are dropped; a failure keeps the current screen and <html lang>; initial non-en load failure boots on embedded en; no blank / mixed-language / partial-catalog frame; re-select is a no-op; a ko→en→ko burst settles on the last request.
  • creation-time non-translation (§L3.4) — a task done in a KO UI vs an EN UI produces a byte-identical GraphDoc. Not translated: node/edge label, expression / format, unit / resourceType, document title / user description, the raw model value shown in the Inspector, the label a template seeds, defaultData()'s default label / value, and every wire identifier / diagnostic code / file-metadata key. Palette display name and model default are separate code paths.
  • catalog + ICU validation + the slice split (§L12 #1, §L13) — CI over the whole registry checks: all messages parse; every plural / select has an other arm; argument name sets match en; argument kind (slot / number / date / plural / select) matches en; no rich-text tags; no empty strings; no unresolved key after fallback. Slices: 1 base + Toolbar/Play bar; 2a model surface (Canvas / Inspector / Timeline + the non-translation boundary + diagnostic mapping); 2b app surface (Import/Export, revision UI, templates UI, PWA bar, empty/error states, a11y/live region); 3 acceptance validation (full inventory + the KO/EN × device visual matrix + long-Korean/overflow/LOD/forced-colors/ reduced-motion + app-wide invariance); then guided tour, then inline help.

Decided (Lumi, cycle kickoff + the N-language clarification):

  • an extensible localization base; EN + KO are the first two shipped locales, not the scope. No two-way if (lang === 'ko') anywhere.
  • a locale registry with per-entry metadata (code, English + native name, direction ltr/rtl, number locale, enabled, catalog thunk); the switch UI, tests, and fallback all read the registry. enabled is true for every shipped locale today — the selector work is what consumes it. The language's name in the active UI language is a separate, later addition alongside that selector work.
  • en is the base — canonical key set and final fallback.
  • one key set for all locales, CI-enforced for every registered locale.
  • per-language catalog (src/i18n/locales/<code>.ts, or a locales/<code>/ domain folder — §L3.3); a new language = one catalog + one registry line, zero edits elsewhere.
  • message format with plural / select / named slots from day one (the formatter itself is decided in rev 2, Q2); string concatenation of translatable fragments is banned.
  • tests iterate the whole registry — missing keys, extra keys, empty translations, placeholder mismatch, fallback.
  • language switch auto-generated from the registry.
  • <html lang> + dir + a11y text track the active locale.
  • RTL: metadata field open; no RTL layout promise in v0.8.0.
  • one bundle for EN + KO; the registry's catalog thunk keeps the door open for later dynamic per-locale loading with no effect on call sites or the GraphDoc / Workspace / Share boundary.
  • chosen locale is localStorage-only, never serialized (§L5).
  • model data / example strings / wire identifiers / error codes not translated (§L3.4).
  • stored / digested / canonical-form numbers never reformatted; only UI-chrome numbers via Intl and only where needed (§L8).
  • scope surfaces (§L6); CI blocks missing and unused keys; per-locale visual matrix; locale switch + locale addition leave viewport / sim state / undo invariant (§L12) — a switch's one write is the bounded §TLO11 official- bundled-template-label re-seed, which is label-only and touches no sim / undo step (template-label-overlay.md).
  • guided tour + inline help are later slices on the finished base (§L13).

Decided in rev 2 (Lumi — the five questions + the loader):

  • Q1 — catalog module shape → TS module + satisfies (§L3.3). en.ts is the canonical shape; type MessageCatalog = Record<keyof typeof en, string>; every other locale is … satisfies MessageCatalog, so tsc blocks a missing or an extra key at compile time. Keys are flat, stable IDs — translated text is never a type identifier or a lookup key. A JSON extraction pipeline comes only when a real translation-management tool needs it (separate, later).
  • Q2 — the formatter → intl-messageformat (FormatJS), used directly (§L4.1). No hand-rolled ICU subset — nested plural/select, escaping, the other rule, and per-language CLDR plural categories are not worth re-implementing. Full ICU Message syntax (plural / select / selectordinal / {n, number} / {d, date} via Intl.*); the t() wrapper caches the compiled formatter keyed by (locale, key, message); the dependency version is pinned in the lockfile; rich-text tag callbacks are forbidden in Slice 1 (string output only — a separate decision if a surface needs markup).
  • Q3 — engine diagnostics → user-facing only this cycle (§L7). Only UI-exposed diagnostics get the { code, params } boundary. Codes (M_REG_EVAL, EXPR_SYNTAX, …) stay stable. The catalog is keyed by a separate error.<code>.message key, not the code itself. An unknown code ⇒ a stable generic localized message with the raw code shown. Structuring every internal assertion / dev error / throw is a separate follow-up. The frozen engine results and evaluation precedence do not change.
  • Q4 — Slice-1 anchor → Toolbar + Play bar (§L13). The Inspector — the largest string / field / validation surface — is the start of Slice 2's full surface conversion.
  • Q5 — persistence key → one named bare string (§L5.1): localStorage["loop-studio/ui-locale/1"] = "ko". Validated against the registry on read; a corrupt / unregistered value is ignored (→ browser locale → en) and reading it never throws or mutates stored data; a switch updates only this key; never copied into Workspace / Share / GraphDoc; future UI settings each get their own key. No preferences schema or migration now.
  • Catalog loader & atomic activation (§L4.5). EN + KO ship in one app deploy; activation is atomic and happens only after the target catalog is fully loaded — never a partially-translated frame. The contract is written for the async case from the start: the previous locale's screen stays up while the target loads; stale late-arriving requests are ignored; a load failure keeps the current locale; and no GraphDoc / Workspace / Share / PWA-data boundary depends on which catalogs are loaded or when, whatever the load strategy becomes.

L15. Scope boundary

In: the src/i18n/ registry + t() (intl-messageformat + compiled-formatter cache) + the initial-language resolver + <html lang>/dir; the atomic-activation loader (§L4.5, synchronous today, async-shaped); the auto-generated switch + the single loop-studio/ui-locale/1 key; en + ko catalogs for every §L6 surface; the §L7 user-facing engine-diagnostic { code, params } rework; the §L12 registry-wide verification set + the per-locale visual matrix. Then the guided tour and inline help as separate slices.

Out (this cycle): any RTL-correct layout (dir metadata only); rich-text tag callbacks in messages; machine translation; translating model data, example GraphDoc strings, wire identifiers, or diagnostic codes; changing any serialized format, digest, or loop-*/N; a per-locale bundle or deploy; actually dynamic-loading catalogs (the thunk seam is built, real chunks are not); restructuring internal / developer-only engine errors; reformatting stored / digested / canonical-form numbers; locale-aware date rendering beyond a single Intl.DateTimeFormat call if a date ever surfaces; a translation-management service or crowd workflow; a third shipped locale (the base must accept one; this cycle does not ship one).