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.
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.
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, theloop-revision/*content digest. - Editor state a spec or test observes:
simulationRev, the undo / redo stack, the React Flow viewport, selection. - Wire identifiers:
schemaid (loop-studio/graph), nodekind, edgemode/route, thetoolstring, 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.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 : 2hint would make the hint wrong. - an automated check for "an ASCII space before
?" must use a literal ASCII space, not\s: Python's\sand JavaScript's\sboth 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-basedcolumninto the raw text";tokenize.tscomputes it asi + 1over string indices, andparse.test.tspinserr('@{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 doescol++per character andcol = 1at each newline.csv.test.tspins "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.
deandfrboth usednl-NL, which held only until Dutch shipped; every probe is nowqaa, 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, withpl-PLandit-ITadded 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, neverel gráfico. The same catalog needsgráficosfor English charts (import.qs.notImported.list), and this product has both a node graph and timeline charts — one word cannot carry both.gráficoalso madeAl archivo de gráfico le faltan sus nodosread as the chart file is missing its nodes. 29 strings moved. - One quotation style.
enmixes“…”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 atn = 1;'{n} ocultos'renders1 ocultos. The obvious repair — wrap it in a plural — is refused bycheck:i18n, which requires the ICU argument shape to matchen(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.
seguidosfor tracked reads first as consecutive;Equipofor Staff reads as equipment in a roastery;nombre propiois a proper noun, not a custom name;procesosfor 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.endedisEndedin 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 isFinalizadas:— feminine plural, because what ended isejecuciones, 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, andIntl.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.
- A Cyrillic letter inside a Portuguese word.
import.error.notLoopStudioshippedIsto не parece…— U+043D U+0435 wherenãobelongs.tsc, oxlint andcheck:i18nall accept it: it is a valid string. The guard is a Unicode Script check over thept-BRRUNTIME data (catalog, template labels, module overlay and the registry's display fields), allowing onlyLatin,CommonandInherited. It is a cross-script contamination contract, NOT a homoglyph detector — a Latin look-alike isScript=Latinand passes. Scanning source FILES was tried first and abandoned: the momentknown.generated.tswas regenerated it held every locale's labels and reported 3,780 "violations". - The guard's own tokenizer inventing English.
[A-Za-z]+cuts a Portuguese word at every accent, soatéyieldedat,Papéisyieldedis,inícioyieldedinandorçamentoyieldedor— 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:
completionisProgressoin the MMO template andconclusãoin 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.dropandlootare kept in English in the MMO template, as the Brazilian game-design register uses them. Scoped to that template's keys.workersinmc.cost.parallelstays 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
ustedregister. Spain usesustedfor software too. Switching tovosotroswould be a change of tone, not a correction, and the catalog's impersonal and infinitive command forms are natural in both.esEsCopy.test.tsasserts notúorvosotrosform reaches any surface, so a later edit cannot mix registers on one screen by accident. archivo. Entirely natural in Spain;ficherois not required, and forcing it would be regional display, not translation.coger. Deliberately not introduced.es419Copy.test.tsbans it for Latin America because it is vulgar there — that is not a reason to add it here, where the UI idiom isseleccionar/elegiranyway.- 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:
Presioneat the start of a sentence — four a11y strings. The first screen was case-SENSITIVE, so sentence-initial forms never appeared in it.Entrarfor the Enter key, in those same four strings. Keyboard key names were not on the candidate list at all; a Spanish keyboard is printedIntro.agregarin sixteen strings — the largest single group in the locale, and simply absent from the candidate list. Spain saysanadir.
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.
| 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-BRspeaks tovocêin 21 strings. European Portuguese software does not; it uses an infinitive, an impersonal construction or a null-subject third person.tuandvósare not the European alternative and are never introduced —ptPtCopy.test.tsbans all three, so a later edit cannot mix registers on one screen. - Progressive aspect. Brazilian
estar + GERUNDbecomes Europeanestar 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…anda estimar…moved,incluindodid not.
tela. The candidate-word screen proposedecrã; the meaning check rejected it. All 24 of those keys render Englishcanvas, notscreen—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 provingtelais the best word for Portugal — it stays on the native-review list (open item 1), againstárea de desenhoand the Englishcanvas.- the node-kind glossary —
Reservatório·Fonte·Sumidouro·Distribuidor·Conversor·Fim·Parâmetro·Valor calculado. None of the eight is regional. quadrofor a group frame.moldurawas rejected during thept-BRaudit as the wrong word and is not reintroduced; it stays on the native-review list.template. Kept for the same non-regional reason aspt-BR:modelois 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 sayCarteira, so nothing moves.- the percent strings. MEASURED:
Intl.NumberFormat('pt-PT')emits84%with no space, exactly aspt-BRdoes. Unlikees-ES, this locale adds no NO-BREAK SPACE to any string.
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 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.
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.
No Portugal native-speaker or professional translation review was performed. Five items stay open rather than smoothed over:
telafor the canvas. Rejectingecrãis well founded — those keys rendercanvas, notscreen, soecrãwould be a mistranslation. But ruling out the wrong word does not prove the remaining one is the best word.tela,área de desenhoand the Englishcanvasare 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 ofecrã, not the superiority oftela.quadrofor a group frame — kept frompt-BR, wheremoldurawas rejected as the wrong word.Rolagemfor a loot roll — used in both, but a Portuguese designer may saysorteio.Receçãofor goods intake in the module overlay —Recebimentois understood but is not what a Portuguese warehouse screen says.drop/loot, kept in English exactly aspt-BRkeeps 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.
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.
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.
The catalog is Cyrillic plus Script=Common / Inherited. Two limits are
recorded rather than implied:
- A script check cannot tell Russian from another Cyrillic language.
Ukrainian
ї, Belarusianў, Serbianђand Bulgarian text are allScript=Cyrillicand would pass. So there is a SECOND check against the Russian alphabet by name, which rejects them. - Homoglyphs are invisible.
а е о р с у х А В Е К М Н О Р С Т У Хare identical in shape to their Latin counterparts. A CyrillicСinsideCSVor a Latinainside 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 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:
- Adding
cyrillic-400.cssdoes not work. It declares the same family, weight and style, also with nounicode-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. - The combined
400.css/600.csscarry 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).
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.
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.
| 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 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 isConnection point, a place where things join, not the edge object;выполнение/прогон/запуск— execution, one Monte-Carlo run, andview & run's imperative, three different English senses;шагvsэтап— the simulation timestep vs a production stage, the same splitetapa/passomakes in the Romance catalogs;меткаvsподпись— the Label field vsa device-local label attached to the file, which is a signature;выражениеvsформула—regExpr.op.insertsrenders Englishformula.
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.
| 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.
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.
No Russian native-speaker or professional translation review was performed. Six items stay open rather than smoothed over:
- the node-kind glossary, especially
РаспределительandПреобразователь— both are long and both press on the node box; ёspelling across the 36 pinned words;Гарантvs the kept EnglishPity/Hard pityin the gacha Template;дроп/лут, kept as the Russian game-design register uses them;Кошелёк/Снятия/Приёмкаin the module overlay;- 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,yoursagainsttheirs— 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.
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.
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.
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 #.
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.
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.
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:
- no slot is followed directly by a letter (
{label}i); - 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.
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-BRlesson, where one Cyrillic letter passed every gate that read the file; - the alphabet: Turkish has no
q,worx, 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 aqwertyinjected 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, withYutakandÇıkışrefused there — and bare capitalisedGiderbanned 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.
No Turkish native-speaker or professional translation review was performed.
Kaynakis bothSourceandresource. That ambiguity is Turkish's own and was not worked around with an invented term. The one sentence that would have read "Kaynak … kaynaklar" ispalette.source.description, and it drops the subject — which is also whatfr,deandrudo there, since the palette shows the name directly above the description.Dağıtıcıfor Gate —Kapıis the literal word and names a door.«»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.Pity/Hard pity/Pickupkept in English in the gacha Template, anddrop/lootkept as the Turkish game-design register uses them. The blurb agrees with the labels:templates.gachaBannerZones.blurbsayspity, the wayes-419andpt-BRdo, rather than translating it.Hesaplanan değerfor Register is decided —Kayıtis refused in those ten keys and legal everywhere else — but its length against the node box is worth a look.
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şlemefor "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 saysEşlenmesin.General / Freewas the only frame label of eleven locales left in English.Premium StandardandPremium Pickupstay 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 saysDosya → Paylaş → Web'de yayınla, measured against that page rather than assumed.
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).
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.
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.
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.
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.
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.
@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.
- 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-BRlesson 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 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,ruandtrall 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,zhandtrall 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'sStartwertroute. Composite phrases were translated in context rather than substituted:base seedisค่าตั้งต้นหลัก, 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 againsten'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.
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 whereenstays 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/scrollHeightoverflow 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 thenowraprow is under less pressure, not more.
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.
-
ค่าที่คำนวณ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. -
ทางระบาย(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: fivecanvaskeys 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. -
Pickupkept in English. Measured across all thirteen translated template-label dicts, this is a minority of three — onlyru,trandthkeep it. Eight locales (zh-Hans,zh-Hant,fr,de,es-419,es-ES,pt-BR,pt-PT) converged onUP, andko/jatransliterate (픽업/ピックアップ). 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. -
“เผยแพร่ไปยังเว็บ”inimport.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.csvitem 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, sothCopy.test.tsnow 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.
-
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 withPickup; the two turned out to have different answers, which is why they are now separate. -
“ ”and screen readers — not a Thai item. Measured: 37thkeys use them, 30 of which wrap a{slot}, and 25 of those keys are quoted in every locale includingen; the overlap withruandtris 36 of 37. Twelve of the fourteen locales use paired quotes somewhere (onlyjaandzh-Hantuse「」). 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.captionis 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 canvasTextMetricsat 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 encontrol10.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.messagecarries 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 anindexOfeither.
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).
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).
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.
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, notCổngand no longerBộ phân phối.Cổngis the literal gate and is also what a network port is called, so it was never a candidate. It shipped asBộ phân phối(distributor) until the full read-back found the product contradicting itself: both gate-typed labels it ships saychia—Bộ chia phần thưởngandChia 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, andghiis already the verb in the save and export copy.Sổ ghiis banned outright and the ban is asserted. - seed is
hạt giống. None of the thirteen other locales keepsseed. 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.
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:
vùng/khu vực/khufor "zone". Three renderings:vùngin 27 mmo node labels,khu vựcin the mmo blurb,khuin 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.tử vongfor a game death (3 mmo labels). It is the clinical word; players saychết.Hàng chờ tử vongreads like a mortuary queue.- Bare
Biênfor "margin" inroasted_supply_marginanddessert_prep_margin.Biênalone is an edge; the same template gets it right inprojected_operating_margin(Biên lợi nhuận). These two mean headroom, soDư địamay be the word. Thay đổias the Modifier field name. It is a verb where the field wants a noun (Mức thay đổi), andthay đổiis already the generic word for "change" throughout the catalog.- Second vs third person.
palette.parameter.descriptionsaysdo người dùng đặt("set by the user") where the rest of the catalog addresses the reader asbạn. Chỉ số nhịp lên XPfor "XP pace index" —nhịp lênis awkward.Giá trị tính toánfor 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.Khung vẽvsVùng vẽfor the canvas. Both collide.khungalready does triple duty (khung vẽcanvas,khung nhìnviewport,một khung có nhãnfor the frame box inimport.placement.frameHelp); butvùngis already zone,vùng trống(empty area) andvùng chọn(selection). Kept as it is because moving it trades one collision for another.Pickupkept 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 webis 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.
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.
đ 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.
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:
- every official Vietnamese string the product serves is NFC, with no combining mark left in it;
- 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;
- an NFD query finds the same picker rows as the NFC one;
- a raw USER string is stored and read back byte-for-byte — the product never silently normalises what someone typed;
- 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.
@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
rushipped (§L2.17) — not the Latin one; - loaded under isolated temporary family names, the
vietnameseandcyrillicsubsets 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.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}.tsmerged bylocales/<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 inui.enandkouse 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 koEach 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 MessageCatalogon every non-encatalog makestscfail 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+koare statically imported in v0.8.0 (§L2.1, one bundle); the registry'scatalog: () => 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.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 byIntl.NumberFormat/Intl.DateTimeFormat; - a
t(key, params?)wrapper compiles anIntlMessageFormat(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:
- the active-locale message formatted with the given params;
- if that returns
null→ the same key'senmessage, same params; - if that also returns
null→ a stable localised failure notice carrying the key (i18n.messageError→"text unavailable ({key})"/"문구를 표시할 수 없음 ({key})"); - 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, setsrequestedLocale, setsloading = true, and starts the catalog load. - on load success, and only if the completing request's generation is
still the current
requestGeneration, one commit changesactiveLocale,activeCatalog,<html lang>, and<html dir>together, setsloading = false, and announces once. Nothing about the UI language changes before this commit. - a late success or failure whose generation ≠ the current
requestGenerationis ignored entirely — no state change, no<html>change, no announce. - on load failure of the current request: keep
activeLocale,activeCatalog, and<html lang>/diras 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-enlocale whose catalog load fails, boot proceeds on the embeddedenwith 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
activeLocaleis 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 isCacheFirstruntime-cached (loop-locale-chunks), fetched on firstsetLocale/ boot. Seedocs/pwa.md§P8 for the offline guarantee. The shared name pattern lives inscripts/locale-chunk.mjs(LOCALE_CHUNK_RE) so the Workbox rule andcheck:pwa-closurenever 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-enfallback, no-op re-select, last-request-wins burst. The production-bundle / PWA e2e specs assert the chunk shape (EN first-load fetches nolocale-*; 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.
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 carriesaria-checked="true"and a✓;nativeNameis primary,englishNamea 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.loadingnote and the trigger adata-loadingflag; a failed load leaves the current selection (aria-checkedfollowsactiveLocale, §L4.5); - adding a locale needs no change to
LanguageSwitch.tsx— the menu is a.mapoverLOCALES. The dev-onlyen-XApseudo-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.
-
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. -
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).
-
One Cyrillic letter folds:
ёsearches asе. Added withru(§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. -
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
ıtoi(§L2.18) and Vietnameseđtod(§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
codestring, 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, orsimulationRev. 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):
- stored preference. If
localStorage["loop-studio/ui-locale/1"]is exactly a registeredcode(no normalisation — see below), use it. - Else, walk
navigator.languagesin order. For each entryL, apply every step below before moving to the next entry:- exact match — a registered
codeequal toL(es-419,zh-Hans); - else Chinese script (§L5.2a) —
zh-CN→zh-Hans,zh-TW→zh-Hant; - else BCP-47 base-language match — a registered
codeequal toL's primary subtag (ko-KR→ko,en-GB→en,de-AT→de); - else
baseFallbackForowner — the registered locale that explicitly declaresL's primary subtag (es-MX→es-419). The first entry that resolves wins; move to the nextnavigatorentry only if the current one resolves to nothing.
- exact match — a registered
- 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
esfails; - a declared base is the locale's own language subtag —
es-419cannot 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 (
resolveInitialLocaletakes 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 registeredcodeand are ignored. (A stored value only ever gets there via the switch, which writes an exactcode; anything else is corruption and is not silently fixed.)navigator.languagesentries 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
encatalog is required for boot. It is statically bundled and is never a failable remote resource — if the initially chosen non-enlocale's catalog fails to load, the app boots on the embeddeden(§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.
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, theresourceTypemismatch 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/unknownlabels, Apply / per-hunk copy, conflictbase/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).
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.diagnosticsentries 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>.messageentry) 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.
- the engine hands the UI a
- Out of scope this cycle: structuring internal assertions, developer-only
errors, and every remaining
throw new Error(...)insrc/engine/**into{ code, params }. That is its own follow-up. - Unchanged: the frozen engine results,
report.diagnosticscontents (which conditions produce a diagnostic), and evaluation precedence. Adding acodefield to a diagnostic object is additive metadata, not a semantics change; if any consumer test pins the exact Englishdiagnosticsstrings, it moves to asserting thecodeinstead.
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 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.
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.
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.
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.
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.
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, neverCancello.Cancellois 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, neverRegistro.Registrois a ledger and implies storage, butinspector.register.noStoresays 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, notTerminale. Keeping the source's plain concept beats a word that reads as a console terminal. - Template is
Modelloeven 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 asAvanza 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 disegnoagainstspazio di lavoro, pinned byitCopy.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.
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 ofPull. Translating it whilePullstays 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).lootIS translated, asbottino, 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.
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.
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.
qaareplacesnl-NLas 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-NLwas 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 asvibreakingi18n-th.spec.ts's probe.qaa–qtzis ISO 639-2's permanently RESERVED-FOR-LOCAL-USE range, so no registry can ever assign it. Measured: it canonicalises,Intl.NumberFormataccepts it, and it resolves toenboth today and withitandnlregistered.en-x-probemust never be used — it also resolves toen, but through the step-3 base match onenrather than the fallback, so it would pass while proving nothing.e2e/toolbar-locale-width.spec.tsis new: one test per shipped locale, measuring the real Tier-1 toolbar row.toolbarOverflow.test.tshad carried hand-lifted metrics for EN/KO/JA only, andru(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.
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.blurbhad 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 andlavorazionewent with the padding. Shortening copy is a translation change and has to be re-read as one.lineagewasdiscendenza, which points the wrong way.discendenzais DESCENT — what comes after. The product's lineage is the chain of PARENT revisions, which comes before. Nowcronologia delle revisioni, which keeps the continuity the concept carries. 2 keys.beanwas a barecaffè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 iscaffè in grani; the roasted stock iscaffè tostato, never a baretostatoused as a noun. Same shape as the Vietnamesehạt nhân xanhdefect (§L2.21).export.workspace.omit.confirmhad lost its object. "Save without it" becameSalva 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.frameHelpdefined a frame with a second frame-word (Un riquadro è una **cornice**…), which reads as "a frame is a frame".help.contextual.hint.review.namewasRevisione, colliding with the product nounRevisione del progetto. English distinguishes review from revision and Italian does not, so the hint is nowVerifica 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.
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.
Ripartitorefor Gate — whether it is the most natural Italian for the splitting sense.Deviatoreis shorter but means diverter, which is turning flow rather than dividing it.Fineas a node-kind name. The concept is right and the alternatives are worse; whether a bareFinereads as a label rather than as "The End" is the question.Valore calcolatofor Register — naturalness only. Its length is settled by the width and wrapping tests, not by a reader.- The game vocabulary kept in English —
Pity,Hard pity,Pickup,Banner,Pull, and the two added with reasons rather than assumed,RollandDrop— plusrare_conv. That last one is here rather than repaired because the English isSell rarewith no head noun: in the graph it is a Converter fed bybucket_rare("Rare drops") and feedingitems_soldandgold, sorareis a rare DROP.Vendi il rarois 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 toitaliano (Italia), which is what this locale'sit/it-ITregistration 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.
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.
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.
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.
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.
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.
middelen, notbronnen, for "resources". The obvious Dutch plural isbronnen— 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.deanditmake the same exception (von allen Quellen ziehen,tira da tutte le sorgenti).run, notloop, for one execution of the model. The native Dutch cognate of "run" ISloop— the product name.de Laufandit esecuzionehave 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.bodybeganLoop stap voor stap..., where sentence-initialLoopreads as the product; it is nowGa stap voor stap.
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.
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.
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.
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.blurbbecameMateriaal in, ... eindproduct uit — een lijn die na enkele stappen stabiliseert.erin/eruitbecamein/uit, which is closer to the English barein/out;gereed productbecame the single wordeindproduct;tot rust komtbecamestabiliseert. Every element of "Material in, processing and scrap, finished goods out — a line that settles within a few steps" survives.export.projectRevision.blurbbecamehet 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.blurbends onalles stopt, which is the Englisheverything stopsrather than the longer paraphrase it had.modules.rewardSplit.blurbusesinkomendeforincoming, not the longerbinnenkomende.
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:
Dodenwachtrij/Kosten van doodgaan.sterfgevalandoverlijdenare 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.Bederfforspoilage. The module is a generic buffered production step, andbederfspecifically implies perishables.Uitvalis already Scrap, so the two are kept apart the way English keeps them apart.revisiereeksforlineage. A coinage:reeksis a series, so this reads as "revision sequence".herkomst(provenance) andrevisiehistorieboth fit the box too, and which one a Dutch developer would expect is a native-reader question rather than a width one.Zeldzaam item verkopenforrare_conv. English isSell rarewith 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.bron(nen)in the two pull-mode enums, where the word means an upstream node rather than a Source node.deandithave 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.
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.
- Never reformatted: anything stored, digested, or part of an expression's
canonical form (
loop-expr/1§X8), adata.formatresult, or a userunit— model / wire content, not UI chrome. - Locale-formatted via
Intl.*with the entry'snumberLocale: only plain counts in UI chrome (cell count, run / step counters, byte sizes). Foren/kothe output is identical (1,234,.decimal), so the safe default is leave as-is and addIntl.NumberFormatonly where a specific string reads wrong in a locale. No dates are rendered today; if one appears, format withIntl.DateTimeFormat(entry.numberLocale).
- 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
dirmetadata 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.
<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.
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-XBremains a registered RTL pseudo-locale shippingenverbatim — which is what makes "unchanged" literally assertable across a locale round trip, and also means no catalogue-content rule can apply to it. Untilarshipped, every locale the checker could reach was one that could not fail the mirrored-glyph rule, andsrc/i18n/arrowContract.test.tsexisted to call the rule with a syntheticarand prove that registering one would arm it. It did:check-arrow-directionnow reportsaras 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-onlyregions and three sharedaria-describedbytargets"; there are FOUR live regions. The fourth isRegisterExprField'ssrMsg(: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 withregions.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.tsexercises 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 (arand theencontrol); 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 (
descendantTextfor the four live regions,descriptionfor 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-hiddenwrapper 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
dirattributes 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.needProjectandproposal.dirtyOrigincarry amenu-patharrow that mirrors and adisclosure-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.cyclerenders{name} → … → {name}andimport.qs.sources.sheetsnames 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.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
constwhose 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.
<html lang>(anddir) 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.
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 writelocalStorage, and does not propagate to a Workspace / Share payload. In a production build the query string has no effect — a?lang=kothere still resolves by the stored preference / browser rule.- the
en-XApseudo-locale — a dev-only registry entry (catalog =enverbatim) so the switch, the resolver, and every check exercise an Nth locale without special-casingen/ko. Not shipped. missing-key/format-failedconsole 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.)
- 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, soscripts/check-i18n.mjs(in thechecksjob) loads the whole registry and, for every registered locale, asserts:- every message parses as ICU (no syntax error);
- every
plural/select/selectordinalblock has anotherarm; - the argument-name set of each message equals the
enmessage's; - the argument kind of each shared argument matches
en's — a plain slot vsnumbervsdatevspluralvsselect— not just the name; - no rich-text tag syntax (
<tag>…</tag>) in any message (§L4.1); - no empty-string values;
- after the
active → enfallback, no key referenced by a call site is unresolved (against the full registry). The script is written against the registry loop, never againstko.
- No missing / unused keys (CI). Every
t('…')call-site key exists inen; everyenkey is referenced somewhere (grep, with an allowlist for dynamically-selectederror.<code>.messagekeys). - No hardcoded UI text (lint/CI). Flags English-sentence literals in
src/components/**outside the i18n layer (heuristic + allowlist). - 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.tspattern; parametrized over the registry. - 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. - Initial-language resolution (Playwright).
navigator.languagesmocked per registeredcodewith 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. - Fallback (unit). A key present only in
enrenders theenstring under every other locale; a key absent everywhere renders visibly with a test hook. - "Add a locale" smoke (unit). A throwaway
xxlocale registered in a test with a partial catalog: checks #1 fails loudly on the gaps, the switch UI listsxx,t()falls back toen, and no product-data snapshot moves. - 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. Seedocs/toolbar-responsive.md; tested bye2e/toolbar-responsive.spec.tsat 1920 / 1600 / 1280 / 820 px and across a resize sweep.
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, thet()provider, theintl-messageformatwrapper 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, mobileMobileMoreMenu— with save / restore / browser-locale fallback and immediate (atomic) switching. en+kocatalogs for Toolbar + Play bar only: every button name, tooltip, andaria-label; Play / Pause / Step / Reset; the step-announcement templates inPlaybackAnnouncer(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/3digest, undo / redo, the viewport,simulationRev, andSimState(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+kofor Canvas (ariaLabel), the Inspector (every field label, note, hint, empty state, theresourceTypemismatch note, Parameter / Register advisory notes), the Timeline chrome (timelinecaption,LIVE/DISTRIBUTION, legend show/hide + Register titles, CSV, thestep Naxis 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 toSEMANTICS-*) /palette.addAction, three separate DOM lines, an absolutely-positioned overlay shown on hover and keyboard focus,aria-describedby-linked. §L7 diagnostics: the registerM_REG_*codes and theEXPR_*parse codes are shown verbatim by the caller with the prose fromerror.<code>.message(params carry only atoms — a column number); an unknown code falls back toerror.unknownCode. Not translated (raw model data, shown as-is): every node/edgelabel, 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'sautomatic · pushAnyreadout), and the node-kind chip. A follow-up (feat/i18n-inspector-enum-labels) localizes the displayed label of the Inspector's enum<select>s viaenum.<group>.<token>while keepingvalue= the token (§L3.4a). Deviations: three edit-time hint notes (triggerdelay, Register canonical form, the deprecatednode-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.Closeon 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
busyguard); - 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 sharedConfirmDialoghardening (useIdfor the aria ids,dismissOnBackdrop,dialog.cancelkey), 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;Toolbarhad no confirm, its erroralert()body is keyed), RevisionChip + BootNotice (projectStore.bootNoticenow emits a code,BootNoticelocalizes 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.routeImportwarning strings stay English for now (no{code}— a later pass).2b-2a (
feat/i18n-slice2b2) — implemented. Templates (button + the two template names / blurbs, keyed byidintemplateKeys.ts; the replace confirm →ConfirmDialog,loadGraphonly from Confirm), the whole Export menu (items + blurbs), the Project-revision disclosure and the Workspace-JSON summary (all threeconfirmbranches — save / omit / reject; the download runs inside the Confirm click, the reject stays a keyed nativealert),AuthorDialog, and the mobile More-sheet rows + Templates / Export sheets.PROJECT_REVISION_DISCLOSURE/AUTHOR_DISCLOSUREconsts 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) andReviewOverlay(title + Close, the gate / classification messages, the §R7A whole-apply confirmation copy —confirmationText()retired toreview.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 andelementType, allshortVal()field values, the time / memory / run numbers, the parent revision id, the author name / note, and theinvalid-selectionstructural specifics (res.reasons/res.detailfrom 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 thechecksCI job ascheck:i18n-surface) — a conservative static scan of everysrc/components/**file for a user-facing English string not wrapped int()(a static JSX text node, or anaria-label/title/placeholderliteral), with a small allowlist for format tokens shown verbatim by design. Mop-up it forced:DistributionPanel,TerminationSparkline,BandChart,MobileOpenFileHint, the shareddialog.close(mobile sheet + timeline sheet✕), theLoopEdgestate / playback edge-label cues (clamp/blocked+ theirtitletooltips + the transfer-breakdown tooltip), the orthogonal-routeinvalidWaypoint!badge name, and theReviewOverlay"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-wideen→ko→en(×N, and again with each dialog open — switched throughi18n.setLocalesince a modal scrim covers the menu) that leaves the GraphDoc / digest / undo / redo / viewport /simulationRev/SimStatebyte-identical;forced-colors+reduced-motionKO 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 inindex.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-flowalongsidegame-economy-tools/game-design-tools/machinations(system-dynamicsdeliberately 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 aFuture directionssection (continuous-time / spatial / external-engine) that is explicitly not a committed roadmap.Whyreframes game economies as the representative case. Thepreviewbadge 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.
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.languagesin 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 hoprequested → en; theencatalog 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>/dirchange 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-enload failure boots on embeddeden; no blank / mixed-language / partial-catalog frame; re-select is a no-op; ako→en→koburst 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, thelabela template seeds,defaultData()'s defaultlabel/ 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/selecthas anotherarm; argument name sets matchen; argument kind (slot / number / date / plural / select) matchesen; 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,
directionltr/rtl, number locale,enabled, catalog thunk); the switch UI, tests, and fallback all read the registry.enabledistruefor 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. enis 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 alocales/<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
Intland 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.tsis the canonical shape;type MessageCatalog = Record<keyof typeof en, string>; every other locale is… satisfies MessageCatalog, sotscblocks 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, theotherrule, and per-language CLDR plural categories are not worth re-implementing. Full ICU Message syntax (plural / select / selectordinal /{n, number}/{d, date}viaIntl.*); thet()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 separateerror.<code>.messagekey, not the code itself. An unknown code ⇒ a stable generic localized message with the raw code shown. Structuring every internal assertion / dev error /throwis 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.
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).