Proof Portal
Omarchy
ProbeLabs65 findings · 95 requirementsA proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.
i18n: an adjudication of the existing proposals, with measurements
Reported by linyiru · filed Sep 18, 2026 · open at source · 2 revisions
At a glance
- 1 · What is claimed?
- Only the report text below — no validator has restated it as checkable claims yet.
- 2 · What does it affect?
- 3 · Is it reproducible?
- No validation has been run yet.
- 4 · What remains unclear?
- Not validated yet: no claims, no reproducer.
- 5 · What happens next?
- Our staff decide whether the report is valid. Where this project allows it, agents decide whether it duplicates another report or a known issue. So far: partly in the audited area (menu) — decided by agents · verified; matches no known issue — decided by arbitration. The outcome (valid, duplicate, not a bug, …) is not decided yet. See the decision.
Original report
This is a design proposal for #7284, not a duplicate. It is also not a ninth mechanism proposal: there are already eight, and what the tree is short of is not another opinion but an adjudication between the ones it has. So this is written as a verdict with the measurements attached, and every claim below has a command you can run to check it.
I have spent the last stretch running the three candidate runtimes end to end rather than reading them, and testing the surrounding pieces against real glibc gettext on Arch. Two of my own earlier conclusions turned out to be wrong and are retracted here by name.
Prior art this depends on, all of it other people's: #8765 (@xavivars, the I18n primitive), #10051 (@komagata, the compiled Qt module), #10776 (@yatstudio, the Simplified Chinese pack), #10949 and #10955 (@sbelcl, language at setup and locale-driven clock names), #9634 (@komagata, one-step Japanese input), #9969 (fontconfig), and @WokoFlipper's Russian pack. I have posted per-PR technical reviews on #8765, #10776 and #10949 with the measurements that belong to each; this document only carries what is architectural.
The paired artifact is #12344, which is green on the tree as it stands today. It is test-only: both files land under test/, so it costs the running desktop nothing. §11 is the runtime bill for the option this document adopts.
1. The single fact that decides the runtime question
There is no off-the-shelf gettext → Qt catalog bridge. This is measured, not inferred, and it is the fact everything else follows from.
msgfmt --qt exits 1 and writes no file on Omarchy's real strings, for two independent reasons: one msgid_plural, and msgids outside ISO-8859-1 (the actual culprits are the U+2026 in SCANNING WI-FI… and the U+2019 in …this network's terms…):
msgfmt --qt -o /tmp/out.qm zh_TW.po; echo $? # 1, no fileThe other candidate, po2ts (translate-toolkit 3.19.10), exits 0 and silently corrupts the key space. The lookups below were not run against stock qs (see the correction at the end of this section); each is reproducible from the emitted files alone:
- ASCII control hits, and so do two non-Latin-1 msgids, so the `.qm` format is innocent; only msgfmt's Qt backend is broken.
qsTranslate('', 'Installing %d package')misses, whileInstalling %d package(s)(a string po2ts invented, which exists nowhere in the tree) hits. The real call site goes untranslated.qsTranslate('omarchy', 'Connect')misses andqsTranslate('', 'Connect')hits, because po2ts pours everymsgctxtinto the empty context.- Two entries differing only by
msgctxttherefore merge:lreleaseprintsdropping duplicate messages… Context: (empty) Source: Openand reportsGenerated 1 translation(s)from a 2-message.po: exit 0, warning only.
So the two stock bridges fail for opposite reasons. po2ts accepts everything and destroys the key space silently, as above. msgfmt --qt keys (msgctxt, msgid) correctly: three entries sharing msgid Open under different msgctxt compile to three distinct messages, exit 0, but it refuses plurals categorically ("the Qt message catalog format doesn't support plural handling", exit 1, and pure ASCII is enough to trigger it) and refuses any non-Latin-1 character in an untranslated string, Unicode being permitted in the translation and forbidden in the msgid. Only the plural refusal closes the route; the character rule is a permanent authoring constraint that one lint would enforce, at the price of rewriting ~42 English display strings in the tree and every future one after them.
Correcting my own method here. I earlier described these as "seven lookups against stock qs 0.3.1". That cannot have happened: quickshell v0.3.1 (1a4716cde794a59928d9d9fc15f2afc7a95de360) contains zero references to QTranslator, installTranslator, retranslate or uiLanguage in any .cpp, .hpp or .qml file, so no catalog can be installed and no lookup can hit: a valid .qm placed in four candidate autoload locations with LANG, LANGUAGE and LC_ALL set produces zero hits. That experiment ran against some other binary, most likely qt6-declarative's qml, which uses QQmlApplicationEngine and does autoload. It also contradicted my own §3. Every claim in this section is established from the emitted files without a running process, so the conclusion is unaffected, but the sentence was false and I am striking it rather than patching it.
2. The verdict
| Approach | Verdict | |
|---|---|---|
| A | Pure-QML I18n singleton reading plain text at runtime, #8765 | Adopt, with five amendments |
| B | Ship a compiled C++ QML module, #10051 | Declined on cost. Not on safety, not on feasibility |
| C | Wait for a translator hook in quickshell (quickshell#1081) | Cannot be the critical path; keep as a free dividend later |
B's headline argument is that Qt already provides this so Omarchy would not have to maintain it. Given §1, that is false for the part that matters. B's real cost is an Omarchy-owned po→ts emitter plus a compiled artifact in the repo plus an optional-import bridge plus the install rules the PoC lacks plus $ORIGIN RPATH plus a rebuild coordinated with every Qt minor.
3. Two corrections to the record, one of them mine
Both sides of this argument have something wrong in it, including me.
B is viable. It translates end to end inside stock qs 0.3.1 through @komagata's own build-release module (BEFORE English, AFTER translated). This is not a paper design.
B is safe. The objection @komagata raised against their own PR, that a missing module bricks the desktop, is fully answered by about ten lines of QML. Four-arm control: module present + direct import works; module absent + direct import kills the desktop (Failed to load configuration / module "Omarchy.I18n" is not installed); module absent + Qt.createComponent bridge yields a status code, the shell stays up and shows English; module present + bridge translates. The bridge is not a dead end. B loses on cost, not on risk. That distinction matters and I want it on the record.
And I am striking my own earlier verdict that "the .qm / `qsTr()` route is closed and should be removed from future proposals." Its control only established that /usr/bin/quickshell references no QTranslator (nm -D -u); the jump from there to "even a perfect .qm cannot be loaded" had no control and is independently falsified three ways: a 16-line LD_PRELOAD, @komagata's module, and a re-run. Structurally it was the same error it warned others about: a correct null measured against the wrong subject.
Two related pieces of cargo cult worth clearing out: QQmlEngine::retranslate() and QCoreApplication::installTranslator() are both load-bearing and neither is sufficient alone: removing the retranslate() line leaves labels in English whether or not Qt.uiLanguage is set, and calling installTranslator alone changes nothing because retranslate()'s whole body is a binding-notify: it re-evaluates observers and supplies no translations. (I previously wrote that retranslate() was the only load-bearing call. That is wrong, and Qt's own autoload path shows the pairing: _q_loadTranslations calls removeTranslator and installTranslator together. quickshell never calls retranslate() at all, so route B or C must supply both.) So B and C are the same mechanism differing only in who owns the binary, and setting Qt.uiLanguage under Quickshell 0.3.1 is inert. Separately, zero-C++ Qt translation is impossible under 0.3.1 for a reason that is not qrc-vs-filesystem: Qt's auto-load is QQmlApplicationEnginePrivate::_q_loadTranslations, keyed on the main QML file's directory, and quickshell constructs a plain new QQmlEngine() at src/core/generation.cpp:42. Six file locations and env knobs, none translated.
4. Adopting #8765, with five amendments
To be explicit about intent: this proposes no competing runtime. #8765 is the runtime. Two of these amendments are itemized with measurements in my review on that PR; three are new here.
- Make the catalog read actually eager.
blockLoading: truedoes not force an eager read:FileViewpopulates ononLoaded, measured atrevision0 @ 92 ms and 1 @ 106 ms against a 165 ms first frame. Anything that captures a translated value rather than binding to it keeps English. Nothing in the tree depends on this today, which is exactly why it is cheap to close now: onevoid cacheFile.text()inComponent.onCompleted, about +8 ms on a small fixture and free at census scale (§11), plus a lint that a translated value may not be captured into a module-level JS var or an imperatively appendedListModelrow. - Delete the runtime Plural-Forms expression parser and generate a per-language form table at build time. Qt's numerus selection agrees with gettext's plural rule index for index: ru over n = [0,1,2,4,5,11,21,22,25,100,101,102,111] selects [2,0,1,1,2,2,0,1,2,2,0,1,2], identical to what #8765's parser derives from the catalog header, and pl correctly diverges from ru at n=21. Correcting myself on what this buys. I previously called this a deletion rather than a trade and claimed it closed a
nplurals=2; plural=n; extra=$(rm -rf /)header-passthrough hole. There is no such hole:I18nModel.jsmatches/plural\s*=\s*([^;]+)/, so that header yields the capturenand the tail is never read; there is noevalorFunctionin #8765; and the PR already ships regression tests asserting that a hostile plural rule and a hostile count run no commands. The framing was unfair to that code and I have retracted it on the PR. It is also a trade, not a deletion: a.tsdeclaring six numerus forms per language yields ru 3 / pl 3 / fr 2 / ja 1 with no diagnostic, which is the index-shift failure §9 describes and which a header-derived rule clamps. The real argument for a build-time table is narrower:pluralRuleTextcompares rule strings, not semantics, soplural=(n != 1)andplural=n != 1are judged incompatible and a fallback catalog's plurals are dropped silently. - ~~Key verbatim on gettext's `(msgctxt, English msgid)`, never on a QML filename.~~ Withdrawn: #8765 already does this.
CONTEXT_SEPARATORis U+0004,contextKey()builds the key, anddocs/i18n.mddocuments it as the gettext convention; the domain is the plugin id, not a filename. I was asking for what had already been built. The property it guarantees still matters to §2: it is what keeps a later move to B or C a mechanical re-emit instead of a re-key, and the thing that would key on filenames isqsTr(), i.e. the route this document declines. - Recognise a 4-letter script subtag in `normalizeLocale`, and in `localeCandidates` and `precedenceFor` with it. This is the one amendment with a behavioural defect behind it rather than an architectural preference, and it was missing from this list.
normalizeLocaleuppercasesparts[1]unconditionally and dropsparts[2], so five spellings collide:zh_Hant,zh-Hant,zh_HANT,zh_Hant_TWandzh-Hant-TWall becomezh_HANT, andzh_Hant_TWloses its territory. Same class forsr_Latn_RS. Azh_Hantpack and azh_Hant_TWpack therefore cannot coexist. Recognising the script subtag is necessary but not sufficient, which I understated on the PR:localeCandidatesstill splits on_, sozh_Hant_TWyields["zh_Hant_TW", "zh"], andprecedenceForgiveszh_Hansandzh_Hantequal precedence where later registration wins, so the wrong-script hazard survives a fix tonormalizeLocalealone. - Add an explicit interpolation escape, and specify how
undefinedand objects render (today:"undefined"and"[object Object]")./%([1-9][0-9]*)/gis greedy, so"%1024 bytes"renders literally and a translator has no way to write%1followed by a digit. I originally proposed capping at%9; withdrawing that half, because it would delete two shipped assertions ("%10 is not %1 followed by 0"in bothi18n-test.shandi18n-bash-test.sh) and it conflicts with the re-emit property amendment 3 names, since Qt's placeholder range is%1-%99and a capped catalog could not be re-emitted as-is.
One ordering correction that matters more than it looks: the unlock task is not wrapping call sites in `qsTr()`. Wrapping is provably free and reversible (~150 ns per string per instantiation, ~25 µs for all 173 shell strings; startup p50 47 ms vs 50 ms; RSS 69,688 vs 69,716 kB; render AE 0.063 against a noise floor of 0.084 between two identical runs), but under this verdict it is the wrong unlock, because it binds the key scheme to Qt while the catalog cannot reach Qt. What needs marking is the key scheme, which all three options share.
5. Catalog architecture: one source, several generated consumers
`.po` as the single source, decisively over JSON. A plain-text catalog read at runtime is already Omarchy's own doctrine: .jsonc menus, .toml themes, manifest.json plugins, fc-match deciding the shell font. Nothing is compiled and nothing is coupled to a Qt ABI.
The reason to prefer .po over hand-written JSON is that gettext already ships the staleness machinery: msgmerge --previous rewrites the catalog when the English changes, marks the old translation #, fuzzy with the previous msgid attached as #| msgid, and msgfmt refuses to compile fuzzy entries by default, so a changed English string falls back to English instead of showing a stale translation, with no hashing sidecar to write, test or maintain. msgcmp gives a completeness gate, and #. comments carry the do-not-translate doctrine for cases like bin/omarchy-system-factory-reset:127, where the word the user must type is compared against a literal ([[ $typed == "reset" ]]): translate the prompt without the comparison, or either without the other, and the confirmation can no longer be given.
One catalog compiling to five usable consumers measures 52 ms; the whole CI pipeline (build + extract + gate) 148 ms, 22.6 MB RSS, pure stdlib, no network. But it is one source, not one format, and two surfaces surrender outright:
| Surface | Status |
|---|---|
bash (.mo) | native msgfmt |
| QML (JSON) | Omarchy-owned emitter, ~120 lines, the only QML route available today |
.desktop | msgfmt --desktop writes Name[zh_TW]= itself, but needs a context-free derived view |
| menu overlay | Omarchy-owned emitter |
| Lua keybinding descriptions | a fifth surface, see §7 |
~~systemd Description=~~ | impossible. systemd logs Unknown key 'Description[zh_TW]' in section [Unit], ignoring. The 11 unit descriptions stay English permanently, and this proposal names that as a scope boundary rather than an oversight |
Two structural findings that will bite whoever builds this: a .po #, flag cannot carry custom metadata (gettext's parser silently drops unknown flags, which cost me one debug round and a 0-row, 0-error overlay), so surface must be derived from the #: path instead; and content-keying deduplicates across surfaces (6 strings shared between keybindings and .desktop names), which is the real dividend of one catalog but also means per-surface coverage is not independent and must not be reported as separate numbers.
A third, found by reading an existing pack rather than by derivation, and it is a gap in this section rather than in the pack. @WokoFlipper's Russian pack keys plurals by CLDR category, and all three of its plural entries carry four (one, few, many, other), while gettext Russian has three msgstr[] slots, so an emitter has nowhere to put other. What makes it dangerous rather than merely wrong is that in all three entries other is byte-identical to many, so the colliding pair is precisely the harmless one and the pack's own data cannot reveal the mistake. Russian other is the fraction category (1.5), so the first translator who writes a distinct other loses a form with no diagnostic on either side. This section therefore has to specify the CLDR-category-to-msgstr[]-index mapping per language and lint that other is either absent or equal to whichever category gettext would select for the same n. That was missing, and it is the concrete cost of "one source" that I had not written down.
6. What the census actually says
I owe a correction here too. Two earlier counts of unmarked strings disagreed by 2.6× (1,439 vs 548) purely because they used different extraction predicates: one excluded every string containing $ or a backtick, and the other counted shell/plugins/dev-gallery/GalleryPanel.qml's developer documentation as UI strings (81 of 167 QML hits, 48.5%, live in that one file). Correcting two claims I made about this. I called the $-or-backtick class "the largest and most expensive"; it is 395 sites against bash-plain's 1,204, the second-smallest of the five strata. And I wrote that excluding dev-gallery makes the two counts converge "to within 4%": they do not. Granting both stated differences at this table's own magnitudes leaves 1,342 against 1,039, still 22.6% apart, and neither figure reconciles with the 2,230 this table publishes. The 2.6x ratio is the only number in that comparison that reproduces. The lesson is the one below about predicates, and it applies to my own arithmetic first.
One exclusion these counts do not apply, raised by @WokoFlipper from the pack side and correct: product and app names. Their live menu is 335 rows of which roughly 100 are names deliberately left in English, and coverage figures that count those will report every locale as permanently incomplete. That exclusion now has a predicate: a row in leaf position under install., remove. or setup.default.. At 9c5482c5 that is 162 rows and 95 distinct labels, of which 86 appear nowhere else in the menu, which reproduces @WokoFlipper's estimate and leaves 144 of the 230 distinct labels as the translatable surface. The residue is the part that constrains the metric: 9 labels sit in both populations (Edge, Neovim, Theme, Background, Font, Fingerprint, Fido2, SSHD, Sudoless, Docker), so the exclusion has to be keyed per row rather than as a label blocklist, which would silently drop Theme, Background and Font, three ordinary words that need translating. The menu-data row below still counts all 340 labels with no exclusion, so read it as an upper bound on the work rather than a translation target.
The lesson is that a single number was the wrong answer. The population stratifies by cost-per-string across an order of magnitude, with the caveat, stated up front this time, that a stratum without a published predicate is a number no reviewer can confirm or attack:
| stratum | sites | unique | files | mechanism |
|---|---|---|---|---|
| bash-plain | 1204 | 1085 | 395 | direct $"…" marking |
| bash-interpolating | 395 | 360 | 155 | must be rewritten to printf first ($"…" cannot contain $var) |
| bash-heredoc-block | 96 | 89 | 75 | $"…" cannot reach; rewrite or externalize |
| bash-metadata | 458 | 458 | 458 | data read from disk, needs its own mechanism |
| qml | 75 | 73 | 21 | display property bound to a bare literal; one cell of a published 3x3 grid spanning 41-84 sites (predicate-sensitive, see below) |
| menu-data | 340 | 230 | 1 | already localizable by construction |
| keybind-data | 186 | 173 | 6 | blocked, see §7 |
| to mark | 2228 | 2065 | ||
| already data | 526 | 388 |
On the predicates, because six of these figures were wrong or unreproducible when this document first published them. Corrected above: bash-metadata was 460/460/457, which is impossible: every bin/ file carries exactly 0 or 1 # omarchy:summary=, so sites cannot exceed files; it is 458/458/458. menu-data was 321/213 and is 340/230 label sites by a JSON walk. keybind-data was 182/169, which no predicate reproduces; 186/173 counts pure string literals across the o.bind family in default/hypr/bindings/*.lua, and o.bind alone gives 180/167, so say which and the row becomes checkable. already data unique was 367 against its own rows' 382; I closed that gap in the wrong direction. 367 was a union and 382 a sum, and the union is right, because a string that is both a menu label and a keybinding description is one catalog entry. Fifteen strings are both, so the figure is 388 after the two corrections, not the 403 I published by adding the rows together. The qml row is the honest failure, and worse than I described. Those three figures came from three different tools rather than three stated predicates, and nine defensible predicates over the same tree (three property sets by three prose strictnesses) reproduce neither 58 nor 86. The published grid spans 41-84 sites, which moves to mark between 2194 and 2237; the row above reports the mid x prose cell. Only bash-interpolating and bash-heredoc-block reproduce to the exact file count under independent re-measurement. The extraction script was described here as "an argument tool, not a product". That was the mistake, because the argument is the product, so it is now published with the per-stratum predicate, as the next paragraph records.
The census script is published: 549 lines of stdlib Python at `census/omarchy-i18n-census.py`, with --predicates to print what each stratum counts. It pins 17 cases of the prose heuristic and 19 count triples against 9c5482c5 and exits non-zero if any of them moves, so a predicate cannot be edited into a different number under the same stratum name. It is not proposed for merge and there is no PR behind it; the reason to publish it is that the argument is the product, so it is code you can review rather than figures you have to take on trust. Running it is what found the two errors corrected above.
Measured churn is ~10-12 new bash strings/day over the 37-day window of the last 500 commits (2026-08-11 to 2026-09-17), by two independent methods: diff-based 450 distinct added-not-removed, population-based +381 unique; normalising for my broader predicate gives ~7.6/day. I earlier published ~4.5/day with no window stated, which was not a measurement. Of the same 500 commits, 199 touch bin/ and 50 touch shell/, a 4:1 split. That split, not a preference, is what forces two different SLAs: shell display copy is nearly frozen and can be reviewed by hand; bash notification and gum copy churns hard and must be machine-filled with #, fuzzy as the work queue and msgcmp as the definition of done.
And this is the honest answer to the standing requirement that a solution be comprehensive before it is worth landing. Comprehensive translation cannot be promised: 31 languages against 1.8 blocked merges/day is roughly 56 translation tasks/day, which dies immediately. What can be delivered is a machine that makes partial localization unable to ship: English merges immediately, edited strings go #, fuzzy, English shows until the fill catches up, and a marking gate enforced on new code from day one is what makes the promise mechanical rather than aspirational.
7. The guardrails are the deliverable
The most useful thing measured in this whole exercise is that translating Omarchy today breaks things silently, and where.
#12344 (the paired PR) records which command output is a protocol and which is prose, and derives that list from the tree so it cannot rot. The motivating case: omarchy-dns prints Custom, and default/omarchy/omarchy-menu.jsonc decides which DNS provider shows as selected by comparing against that word. Translate it (and a translator will, it is an ordinary English adjective) and the DNS menu shows nothing selected on a machine whose DNS is fine. No test fails, nothing exits non-zero, nothing appears in a log.
The largest and most invisible exposure is `bin/omarchy-menu-keybindings`. Translating the 186 shipped bind descriptions silently destroys 4/4 alternative-chord merges and reorders most of the ~228 rendered rows, with zero errors and zero test failures. (Correcting a figure of mine here. It said 184, which agreed with neither §6's keybind-data row nor the census script pinning it. default/hypr/bindings/*.lua has 193 o.bind/o.bind_toggle call sites across 6 files: 186 pass a literal description, 173 of them distinct, 180 on o.bind and 6 on o.bind_toggle; 2 pass none; and 5 build both key and description in a loop, which is why the rendered list is roughly 230 rows rather than 186.) The descriptions are simultaneously the display string, the join key on both sides of an awk join, the input to 52 precedence regexes, and the round-trip key after selection, and the user's own binds enter the same stream from ~/.config/hypr/bindings.lua. A second, smaller class is reproducible with a stated predicate and has now been hit in the field. Of the 340 rows default/omarchy/omarchy-menu.jsonc declares, 180 carry at least one guard (44 checked, 75 when, 65 disabled, 4 rows carrying two), and 8 of those compare a shell literal that is also a label in the same file, and in all 8 the label and the literal are the same string: low, mid, high, DHCP, Cloudflare, Google, Custom, omp. @WokoFlipper reports Custom translated to Пользовательский in a live Russian pack (#12345 comment), which is one of these 8 and low/mid/high are three more. This replaces an "8 bash sites" figure I published with no predicate and could not reproduce. This cannot be fixed with a catalog. It needs a real schema change (a third field: stable id plus translatable label), and it is now its own project: #12404. That issue also records a fifth coupling not listed above, which is the one that bites hardest: for the 4 webapp + focus binds, command_from passes the description to omarchy-launch-or-focus-webapp, which matches it against the window's own class and title. Those come from the application and no locale changes them, so a translated description stops focusing the existing window and launches a new one on every press.
A second trap with the same symptom, in the merge path rather than in a guard. @sbelcl reports that a Russian contributor's translated Custom row failed on a machine whose DNS was fine, and that the cause was not the guard above: their generator rewrites whole rows, so checked stays byte-identical. What breaks is a user override that declares only a label, because normalizeItem completes every absent field with "" and mergeMenuSources copies every key, against a documented promise in docs/menu.md that an override replaces only the fields it declares. Measured, it is worse than a lost checkmark: kind is inferred from action, so the row becomes a childless submenu, isVisible hides it, and the DNS submenu lists 3 of its 4 rows. The same override on setup.network.dns empties aliases: ["dns"], so omarchy menu summon dns resolves to an id nothing answers to. This was filed three times independently before I looked (#12236, #11509, #11211) and is already fixed by #12255, which parses sources raw and normalizes once after the merge. It belongs here because a translated menu makes the one-field override the normal case, and because #12344 does not cover it: two traps, one symptom, two separate fixes.
The negative control that changed the gate's design: a wrap-only marker moved only 2 rows, because match(line, /Keybindings/) still matches the wrapped form. A wrap-only pseudo-locale reports a false all-clear on the biggest site in the tree, which is why the gate has to transliterate, not just wrap.
Existing tests provide almost no cover here: keybindings-menu-test.sh pins 4 descriptions and, incidentally, 1 of the 52 precedence literals: the Calculator-above-/Reveal active/ assertion fails if that regex is removed. Of roughly 230 coupled sites, 4 are defended and only one of those defences is deliberate. "The tests would catch it" is not available as an argument.
Three more that must be fixed before any catalog ships, each small and each independently sensible:
- `bin/omarchy-menu-select` returns the label verbatim (documented at :10-13), which is why 8 bash sites are fragile. Give it the shape
omarchy-menu-images --print-namealready has (return a stable id, display a derived label) and the transcode, dns and toggle classes stop being translatable at all. The theme picker is already immune for exactly this reason and is the template to copy. - The cheatsheet cache key is locale-blind: measured, same SHA-256 (
08eb0572…) across three locales, while changing a description does move it (c0267d39…). Adding the active locale is one line and must ship in the same commit as any lookup. - `config/omarchy/hooks/post-boot.d/weather.sample:8` compares against `'Weather unavailable'` and gets copied into `~/.config`, out of reach of any later fix.
omarchy-weather-statusalreadyexit 1s on that path, so the comparison can just go.
One directional rule makes this whole bug class disappear instead of needing an audit: translate downward only: a command another program reads emits ids; the reader renders the text. Every safe site in the tree has that shape; every broken one has the opposite.
8. Documentation, and a note on filenames
#8765 already occupies docs/i18n.md (92 lines), default/bash/i18n (167) and shell/Commons/I18n{.qml,Model.js}, so this proposal deliberately claims none of them. Following AGENTS.md's split (task procedure in agents/skills/, system shape in docs/), the deliverables are two files:
- `docs/i18n-catalog.md`: how the layer composes, the five consumers, and the two concessions (systemd impossible;
footis the one surface where font choice rather than language decides the glyph). - `agents/skills/i18n.md`: what to do when adding a string. The rules an agent has to hold in mind must be short, and there are only four: translate downward only / a command that is read emits ids / no
$varinside$"…"/ a translated value never enters a module-level var or an appendedListModelrow.
9. Unexamined and out of scope
Named rather than omitted, because a proposal that hides its gaps is worth less than one that lists them:
- RTL has zero measurements. Arabic's six plural forms parse, but nobody has looked at what the bar and menu do under
layoutDirection: RightToLeft. There are 104 QML files and not one carries mirroring. This needs independent work and this proposal does not cover it. - The inbound half of the parse boundary. Omarchy also parses the gettext output of external tools:
findmnt,nmcli,systemctl,dell-xps-touchpad-haptics. Same failure mode, but the fix is a pinned locale rather than a classification, so #12344 leaves it out. - Qt's per-language plural table is verified only for ru and pl. Amendment 2 asks Omarchy to own that table permanently, and every language beyond those is an unverified row. Worse, Qt keys the form count on the language, not on the header, so a
.podeclaring a differentnpluralssilently shiftsmsgstr[]indices with no diagnostic on either side. - `%9` as an interpolation cap rests on the untested assumption that no Omarchy string needs ten placeholders.
- Whether the po→JSON emitter carries `msgctxt` and plurals losslessly into #8765's singleton. The one-catalog experiment's own emitter dropped 1→0 plurals and 43→0 translator comments; that is an emitter bug rather than a format limit, but nobody has run it back through the real singleton.
- Three earlier claims I am marking as uncontrolled, do not cite them: a
Qt.exit(42) returned 42result (falsified: rc=124, ran the full 12 s timeout,Signal QQmlEngine::exit() emitted, but no receivers connected, which means every exit-code-based assertion in that report needs re-verification); the Korean cost figure for lang-free font rules; and three mutually contradictory attributions of one reboot.
I also want to retract three "locale blockers" I previously believed and would have put in this document. All three dissolved on inspection: default/bash/envs sources /etc/locale.conf two lines above the C.UTF-8 fallback, so that default is a fallback of a fallback rather than a deletion of the layer; install/ genuinely never runs locale-gen, but #10949 already routes around it via localectl, and Arch's systemd-localed does have the integration compiled in (verified non-destructively: locale_gen_enable_locale, locale_gen_process_locale, referencing /etc/locale.gen and /usr/bin/locale-gen, both present); and the claim that zh_TW must pin LANGUAGE=zh_Hant:zh_TW is wrong, because real gettext resolves to bare zh. That last one has a genuine consequence which survives: never ship a language-only catalog, because zh_HK/zh_MO (Traditional) and zh_SG (Simplified) all truncate to bare zh, so whichever script it holds is wrong for someone. Same shape for a bare pt against pt_BR/pt_PT.
10. Suggested order
Each of the first items stands on its own merits and none requires agreeing to this architecture first. That is deliberate.
| # | Item | Why it can go now |
|---|---|---|
| 1 | #12338, Make the menu search line a real text input | open, not merged. A prerequisite, not a follow-up: translating every menu label while leaving a Latin-only search box is worse than not translating |
| 2 | #12344, parse-boundary ledger + gate | ✅ open. Green on today's tree, lands as a no-op. It is a guardrail, not a translation |
| 3 | #12363, omarchy-menu-select returns a stable id | Pure robustness fix with an in-tree precedent; immunizes three classes of site |
| 4 | #12362, cheatsheet cache key + locale | One line |
| 5 | bash CJK column arithmetic | Correction: this row said the router list is off by 2 columns today. It is not. In bash printf '%-*s' pads by bytes while ${#var} counts characters, and neither is display width, but every value padded anywhere in the tree is a name Omarchy generates for itself, all of it ASCII, where the three measures agree: omarchy commands prints 392 rows and no non-ASCII byte reaches its padded column. So this one is latent and preventative, and belongs after a catalog exists rather than before one. omarchy-launch-about already measures display width with LC_ALL=C.UTF-8 wc -L |
| 6 | #8765 with the five amendments | The runtime |
| 7 | catalog pipeline + agents/skills/i18n.md | Once there is something to be a pipeline for |
| 8 | #12404, keybinding schema (stable id + label) | Its own project, now open |
What I am asking for is a decision on §2 and §4 (whether A-with-amendments is the direction) so that the people already carrying language packs (#10776, and @WokoFlipper's Russian pack) know which key scheme to target. Everything in §7 I will send as separate small PRs regardless of how §2 goes. One of them, the cheatsheet cache key, is a defect on today's tree; the others are guardrails a catalog needs in place first rather than translation work.
11. What the adopted option costs at runtime
Everything above argues correctness and cost-to-build. This section is the runtime bill on its own, because it is the question I would ask first and this document buried it.
All figures: Arch, Ryzen 7 8745HS, Quickshell 0.3.1, Wayland, qs -p on a 320x48 PanelWindow, measured 2026-09-17. The fixture is 2,067 entries, at the top of the census range in §6, and deliberately the whole-tree count rather than the shell's own share, serialized exactly as I18n writes it: 155,185 bytes of JSON at $XDG_CACHE_HOME/omarchy/i18n/zh.json.
| what | measured | against |
|---|---|---|
I18n.tr(), key present | 1.65 us | qsTr() at 150 ns, 11x slower |
I18n.tr(), key absent | 2.01 us | falls through to the English literal |
| language switch, 400 bindings | 400 re-evaluations in 2 ms; revert 1 ms | a switch, not a frame |
blocking read + JSON.parse + install, 2,067 entries | revision 1 at 97.6 ms (median of 5) | the lazy path reaches it at 101.7 ms |
| RSS, catalog resident | 210,308 kB (median of 9) | no catalog, same harness: 205,448 kB (median of 9) |
| RSS delta | +4.7 MB, 2.4% | 7x the baseline's own spread (696 kB), so this one is signal |
| first frame | 143 ms loaded, 173 ms baseline | no signal: the baseline alone spans 134-186 ms |
In one line: translation costs the bar about 4.7 MB and nothing measurable on the clock. The 11x on tr() is real, but the units decide it. shell/ has 173 strings, so re-translating every visible string costs 0.29 ms, once, on a language change. Nothing in a frame calls tr(): QML caches the binding.
Where it stops being free is scale, and that bounds the design rather than the implementation. Installing a catalog in memory measures 2 ms at 400 entries, 4 ms at 1,500 and 11 ms at 5,000: superlinear, and 5,000 is not far above where a translated Omarchy lands if bin/ and shell/ share one catalog. That is an argument for the per-surface split this document already proposes, on grounds it did not previously have.
Two corrections to my own text above, both falling out of these numbers:
- The "+8 ms" I attributed to the eager read in §4 is pessimistic. It came from a smaller fixture. At census scale the eager path reaches
revision1 four milliseconds earlier than the lazy one, and the first-frame difference is inside the noise floor. The amendment is free. - §4 justifies the eager read as "catalogs before the bar's first frame", and that is not the failure it prevents. Measured, the lazy read already lands before the first frame:
revision1 at ~100 ms, frame at ~142 ms. What it does not land before isComponent.onCompletedreturning, which is the exact moment a captured value freezes. The trap is narrower than I published, and the lint half of that amendment is carrying more of the weight than the eager read is.
Not measured, so nobody should assume it: RSS under the real shell rather than a probe window; anything on the bin/ side, where the cost is a gettext call per string in a process that already forks; and RTL, which §9 already flags.
The harness and fixtures are reproducible and I am happy to hand over the scripts.
Happy to be wrong about any of it; every number above has a command attached, and two of the corrections in this document are to my own earlier conclusions.
Edited 2026-09-17 to add §11, the runtime measurements. The original text scattered them across §4 and §5 as supporting evidence for other arguments instead of stating the cost as a claim, and never measured the option it adopts. Two figures in §4 are corrected in §11 rather than rewritten in place, so the original claim stays readable: the "+8 ms" for the eager read is pessimistic, and the failure that amendment prevents is a capture in `Component.onCompleted`, not a late first frame.
Edited again 2026-09-18 after five adversarial reviews of this document, each asked to hold it to the standard it asks of others. The corrections are inline and labelled rather than appended, so the claim and its retraction sit together. The substantive ones: §1's method sentence was false and is struck; §1's conclusion mis-attributed `po2ts`'s key-space destruction to the whole toolchain; §3 overstated `retranslate()` as the only load-bearing call; amendment 2's security claim was wrong and unfair to #8765's author, and is retracted there too; amendment 3 asked for what #8765 had already built; amendment 4's `%9` cap would have deleted two shipped assertions; a fifth amendment with the only real behavioural defect had been dropped from the list and is restored; and six census figures in §6/§7 were wrong or unreproducible. My earlier note claimed "nothing else in the original text changed" while that same edit had rewritten a §4 figure in place. That was untrue and this note replaces it.
View raw text
This is a design proposal for #7284, not a duplicate. It is also not a ninth mechanism proposal: there are already eight, and what the tree is short of is not another opinion but an adjudication between the ones it has. So this is written as a verdict with the measurements attached, and every claim below has a command you can run to check it.
I have spent the last stretch running the three candidate runtimes end to end rather than reading them, and testing the surrounding pieces against real glibc `gettext` on Arch. Two of my own earlier conclusions turned out to be wrong and are retracted here by name.
Prior art this depends on, all of it other people's: #8765 (@xavivars, the I18n primitive), #10051 (@komagata, the compiled Qt module), #10776 (@yatstudio, the Simplified Chinese pack), #10949 and #10955 (@sbelcl, language at setup and locale-driven clock names), #9634 (@komagata, one-step Japanese input), #9969 (fontconfig), and @WokoFlipper's Russian pack. I have posted per-PR technical reviews on #8765, #10776 and #10949 with the measurements that belong to each; this document only carries what is architectural.
The paired artifact is #12344, which is green on the tree as it stands today. It is test-only: both files land under `test/`, so it costs the running desktop nothing. §11 is the runtime bill for the option this document adopts.
---
## 1. The single fact that decides the runtime question
**There is no off-the-shelf gettext → Qt catalog bridge.** This is measured, not inferred, and it is the fact everything else follows from.
`msgfmt --qt` exits 1 and writes no file on Omarchy's *real* strings, for two independent reasons: one `msgid_plural`, and msgids outside ISO-8859-1 (the actual culprits are the U+2026 in `SCANNING WI-FI…` and the U+2019 in `…this network's terms…`):
```bash
msgfmt --qt -o /tmp/out.qm zh_TW.po; echo $? # 1, no file
```
The other candidate, `po2ts` (translate-toolkit 3.19.10), **exits 0 and silently corrupts the key space**. The lookups below were **not** run against stock `qs` (see the correction at the end of this section); each is reproducible from the emitted files alone:
- ASCII control hits, and so do two non-Latin-1 msgids, **so the `.qm` format is innocent; only msgfmt's Qt backend is broken**.
- `qsTranslate('', 'Installing %d package')` **misses**, while `Installing %d package(s)` (a string po2ts invented, which exists nowhere in the tree) hits. The real call site goes untranslated.
- `qsTranslate('omarchy', 'Connect')` misses and `qsTranslate('', 'Connect')` hits, because po2ts pours every `msgctxt` into the empty context.
- Two entries differing only by `msgctxt` therefore **merge**: `lrelease` prints `dropping duplicate messages… Context: (empty) Source: Open` and reports `Generated 1 translation(s)` from a 2-message `.po`: **exit 0, warning only**.
So the two stock bridges fail for opposite reasons. `po2ts` accepts everything and destroys the key space silently, as above. `msgfmt --qt` keys `(msgctxt, msgid)` **correctly**: three entries sharing msgid `Open` under different `msgctxt` compile to three distinct messages, exit 0, but it refuses plurals **categorically** (*"the Qt message catalog format doesn't support plural handling"*, exit 1, and pure ASCII is enough to trigger it) and refuses any non-Latin-1 character in an *untranslated* string, Unicode being permitted in the translation and forbidden in the msgid. Only the plural refusal closes the route; the character rule is a permanent authoring constraint that one lint would enforce, at the price of rewriting ~42 English display strings in the tree and every future one after them.
**Correcting my own method here.** I earlier described these as "seven lookups against stock `qs` 0.3.1". That cannot have happened: quickshell v0.3.1 (`1a4716cde794a59928d9d9fc15f2afc7a95de360`) contains zero references to `QTranslator`, `installTranslator`, `retranslate` or `uiLanguage` in any `.cpp`, `.hpp` or `.qml` file, so no catalog can be installed and no lookup can hit: a valid `.qm` placed in four candidate autoload locations with `LANG`, `LANGUAGE` and `LC_ALL` set produces zero hits. That experiment ran against some other binary, most likely qt6-declarative's `qml`, which uses `QQmlApplicationEngine` and does autoload. It also contradicted my own §3. Every claim in this section is established from the emitted files without a running process, so the conclusion is unaffected, but the sentence was false and I am striking it rather than patching it.
## 2. The verdict
| | Approach | Verdict |
|---|---|---|
| **A** | Pure-QML `I18n` singleton reading plain text at runtime, **#8765** | **Adopt**, with five amendments |
| **B** | Ship a compiled C++ QML module, **#10051** | Declined on **cost**. Not on safety, not on feasibility |
| **C** | Wait for a translator hook in quickshell (quickshell#1081) | Cannot be the critical path; keep as a free dividend later |
B's headline argument is that Qt already provides this so Omarchy would not have to maintain it. Given §1, that is false for the part that matters. B's real cost is an Omarchy-owned po→ts emitter **plus** a compiled artifact in the repo **plus** an optional-import bridge **plus** the install rules the PoC lacks **plus** `$ORIGIN` RPATH **plus** a rebuild coordinated with every Qt minor.
## 3. Two corrections to the record, one of them mine
Both sides of this argument have something wrong in it, including me.
**B is viable.** It translates end to end inside stock `qs` 0.3.1 through @komagata's own build-release module (BEFORE English, AFTER translated). This is not a paper design.
**B is safe.** The objection @komagata raised against their own PR, that a missing module bricks the desktop, is fully answered by about ten lines of QML. Four-arm control: module present + direct import works; module absent + direct import kills the desktop (`Failed to load configuration / module "Omarchy.I18n" is not installed`); module absent + `Qt.createComponent` bridge yields a status code, the shell stays up and shows English; module present + bridge translates. The bridge is not a dead end. **B loses on cost, not on risk**. That distinction matters and I want it on the record.
**And I am striking my own earlier verdict that "the .qm / `qsTr()` route is closed and should be removed from future proposals."** Its control only established that `/usr/bin/quickshell` references no QTranslator (`nm -D -u`); the jump from there to "even a perfect .qm cannot be loaded" had **no control** and is independently falsified three ways: a 16-line `LD_PRELOAD`, @komagata's module, and a re-run. Structurally it was the same error it warned others about: a correct null measured against the wrong subject.
Two related pieces of cargo cult worth clearing out: `QQmlEngine::retranslate()` and `QCoreApplication::installTranslator()` are **both** load-bearing and neither is sufficient alone: removing the `retranslate()` line leaves labels in English whether or not `Qt.uiLanguage` is set, and calling `installTranslator` alone changes nothing because `retranslate()`'s whole body is a binding-notify: it re-evaluates observers and supplies no translations. (I previously wrote that `retranslate()` was the *only* load-bearing call. That is wrong, and Qt's own autoload path shows the pairing: `_q_loadTranslations` calls `removeTranslator` and `installTranslator` together. quickshell never calls `retranslate()` at all, so route B or C must supply both.) So B and C are the same mechanism differing only in who owns the binary, and setting `Qt.uiLanguage` under Quickshell 0.3.1 is inert. Separately, zero-C++ Qt translation is impossible under 0.3.1 for a reason that is not qrc-vs-filesystem: Qt's auto-load is `QQmlApplicationEnginePrivate::_q_loadTranslations`, keyed on the main QML file's directory, and quickshell constructs a plain `new QQmlEngine()` at `src/core/generation.cpp:42`. Six file locations and env knobs, none translated.
## 4. Adopting #8765, with five amendments
To be explicit about intent: this proposes **no competing runtime**. #8765 is the runtime. Two of these amendments are itemized with measurements in my review on that PR; three are new here.
1. **Make the catalog read actually eager.** `blockLoading: true` does not force an eager read: `FileView` populates on `onLoaded`, measured at `revision` 0 @ 92 ms and 1 @ 106 ms against a 165 ms first frame. Anything that *captures* a translated value rather than binding to it keeps English. Nothing in the tree depends on this today, which is exactly why it is cheap to close now: one `void cacheFile.text()` in `Component.onCompleted`, about +8 ms on a small fixture and free at census scale (§11), plus a lint that a translated value may not be captured into a module-level JS var or an imperatively appended `ListModel` row.
2. **Delete the runtime Plural-Forms expression parser** and generate a per-language form table at build time. Qt's numerus selection agrees with gettext's plural rule **index for index**: ru over n = [0,1,2,4,5,11,21,22,25,100,101,102,111] selects [2,0,1,1,2,2,0,1,2,2,0,1,2], identical to what #8765's parser derives from the catalog header, and pl correctly diverges from ru at n=21. **Correcting myself on what this buys.** I previously called this a deletion rather than a trade and claimed it closed a `nplurals=2; plural=n; extra=$(rm -rf /)` header-passthrough hole. There is no such hole: `I18nModel.js` matches `/plural\s*=\s*([^;]+)/`, so that header yields the capture `n` and the tail is never read; there is no `eval` or `Function` in #8765; and the PR already ships regression tests asserting that a hostile plural rule and a hostile count run no commands. The framing was unfair to that code and I have retracted it on the PR. It is also a trade, not a deletion: a `.ts` declaring six numerus forms per language yields ru 3 / pl 3 / fr 2 / ja 1 with **no diagnostic**, which is the index-shift failure §9 describes and which a header-derived rule clamps. The real argument for a build-time table is narrower: `pluralRuleText` compares rule *strings*, not semantics, so `plural=(n != 1)` and `plural=n != 1` are judged incompatible and a fallback catalog's plurals are dropped silently.
3. ~~**Key verbatim on gettext's `(msgctxt, English msgid)`, never on a QML filename.**~~ **Withdrawn: #8765 already does this.** `CONTEXT_SEPARATOR` is U+0004, `contextKey()` builds the key, and `docs/i18n.md` documents it as the gettext convention; the domain is the plugin id, not a filename. I was asking for what had already been built. The property it guarantees still matters to §2: it is what keeps a later move to B or C a mechanical re-emit instead of a re-key, and the thing that *would* key on filenames is `qsTr()`, i.e. the route this document declines.
4. **Recognise a 4-letter script subtag in `normalizeLocale`, and in `localeCandidates` and `precedenceFor` with it.** This is the one amendment with a behavioural defect behind it rather than an architectural preference, and it was missing from this list. `normalizeLocale` uppercases `parts[1]` unconditionally and drops `parts[2]`, so five spellings collide: `zh_Hant`, `zh-Hant`, `zh_HANT`, `zh_Hant_TW` and `zh-Hant-TW` all become `zh_HANT`, and `zh_Hant_TW` loses its territory. Same class for `sr_Latn_RS`. A `zh_Hant` pack and a `zh_Hant_TW` pack therefore cannot coexist. Recognising the script subtag is necessary but not sufficient, which I understated on the PR: `localeCandidates` still splits on `_`, so `zh_Hant_TW` yields `["zh_Hant_TW", "zh"]`, and `precedenceFor` gives `zh_Hans` and `zh_Hant` equal precedence where later registration wins, so the wrong-script hazard survives a fix to `normalizeLocale` alone.
5. **Add an explicit interpolation escape**, and specify how `undefined` and objects render (today: `"undefined"` and `"[object Object]"`). `/%([1-9][0-9]*)/g` is greedy, so `"%1024 bytes"` renders literally and a translator has no way to write `%1` followed by a digit. I originally proposed capping at `%9`; withdrawing that half, because it would delete two shipped assertions (`"%10 is not %1 followed by 0"` in both `i18n-test.sh` and `i18n-bash-test.sh`) and it conflicts with the re-emit property amendment 3 names, since Qt's placeholder range is `%1`-`%99` and a capped catalog could not be re-emitted as-is.
One ordering correction that matters more than it looks: **the unlock task is not wrapping call sites in `qsTr()`.** Wrapping is provably free and reversible (~150 ns per string per instantiation, ~25 µs for all 173 shell strings; startup p50 47 ms vs 50 ms; RSS 69,688 vs 69,716 kB; render AE 0.063 against a noise floor of 0.084 between two identical runs), but under this verdict it is the *wrong* unlock, because it binds the key scheme to Qt while the catalog cannot reach Qt. What needs marking is the **key scheme**, which all three options share.
## 5. Catalog architecture: one source, several generated consumers
**`.po` as the single source, decisively over JSON.** A plain-text catalog read at runtime is already Omarchy's own doctrine: `.jsonc` menus, `.toml` themes, `manifest.json` plugins, `fc-match` deciding the shell font. Nothing is compiled and nothing is coupled to a Qt ABI.
The reason to prefer `.po` over hand-written JSON is that gettext already ships the staleness machinery: `msgmerge --previous` rewrites the catalog when the English changes, marks the old translation `#, fuzzy` with the previous msgid attached as `#| msgid`, and `msgfmt` refuses to compile fuzzy entries by default, so a changed English string falls back to English instead of showing a stale translation, with no hashing sidecar to write, test or maintain. `msgcmp` gives a completeness gate, and `#.` comments carry the do-not-translate doctrine for cases like `bin/omarchy-system-factory-reset:127`, where the word the user must type is compared against a literal (`[[ $typed == "reset" ]]`): translate the prompt without the comparison, or either without the other, and the confirmation can no longer be given.
One catalog compiling to five usable consumers measures **52 ms**; the whole CI pipeline (build + extract + gate) **148 ms**, 22.6 MB RSS, pure stdlib, no network. But it is one *source*, not one *format*, and two surfaces surrender outright:
| Surface | Status |
|---|---|
| bash (`.mo`) | native `msgfmt` |
| QML (JSON) | Omarchy-owned emitter, ~120 lines, the only QML route available today |
| `.desktop` | `msgfmt --desktop` writes `Name[zh_TW]=` itself, but needs a context-free derived view |
| menu overlay | Omarchy-owned emitter |
| Lua keybinding descriptions | a fifth surface, see §7 |
| ~~systemd `Description=`~~ | **impossible.** systemd logs `Unknown key 'Description[zh_TW]' in section [Unit], ignoring`. The 11 unit descriptions stay English permanently, and this proposal names that as a scope boundary rather than an oversight |
Two structural findings that will bite whoever builds this: a `.po` `#,` flag **cannot** carry custom metadata (gettext's parser silently drops unknown flags, which cost me one debug round and a 0-row, 0-error overlay), so surface must be derived from the `#:` path instead; and content-keying **deduplicates across surfaces** (6 strings shared between keybindings and `.desktop` names), which is the real dividend of one catalog but also means per-surface coverage is not independent and must not be reported as separate numbers.
A third, found by reading an existing pack rather than by derivation, and it is a gap in this section rather than in the pack. @WokoFlipper's Russian pack keys plurals by CLDR category, and all three of its plural entries carry four (`one`, `few`, `many`, `other`), while gettext Russian has three `msgstr[]` slots, so an emitter has nowhere to put `other`. What makes it dangerous rather than merely wrong is that in all three entries `other` is byte-identical to `many`, so the colliding pair is precisely the harmless one and the pack's own data cannot reveal the mistake. Russian `other` is the fraction category (1.5), so the first translator who writes a distinct `other` loses a form with no diagnostic on either side. This section therefore has to specify the CLDR-category-to-`msgstr[]`-index mapping per language and lint that `other` is either absent or equal to whichever category gettext would select for the same `n`. That was missing, and it is the concrete cost of "one source" that I had not written down.
## 6. What the census actually says
I owe a correction here too. Two earlier counts of unmarked strings disagreed by 2.6× (1,439 vs 548) purely because they used different extraction predicates: one excluded every string containing `$` or a backtick, and the other counted `shell/plugins/dev-gallery/GalleryPanel.qml`'s **developer documentation** as UI strings (81 of 167 QML hits, 48.5%, live in that one file). **Correcting two claims I made about this.** I called the `$`-or-backtick class "the largest and most expensive"; it is 395 sites against bash-plain's 1,204, the second-smallest of the five strata. And I wrote that excluding dev-gallery makes the two counts converge "to within 4%": they do not. Granting both stated differences at this table's own magnitudes leaves 1,342 against 1,039, still 22.6% apart, and neither figure reconciles with the 2,230 this table publishes. The 2.6x ratio is the only number in that comparison that reproduces. The lesson is the one below about predicates, and it applies to my own arithmetic first.
One exclusion these counts do **not** apply, raised by @WokoFlipper from the pack side and correct: product and app names. Their live menu is 335 rows of which roughly 100 are names deliberately left in English, and coverage figures that count those will report every locale as permanently incomplete. That exclusion now has a predicate: a row in leaf position under `install.`, `remove.` or `setup.default.`. At `9c5482c5` that is 162 rows and 95 distinct labels, of which 86 appear nowhere else in the menu, which reproduces @WokoFlipper's estimate and leaves **144 of the 230 distinct labels** as the translatable surface. The residue is the part that constrains the metric: 9 labels sit in both populations (`Edge`, `Neovim`, `Theme`, `Background`, `Font`, `Fingerprint`, `Fido2`, `SSHD`, `Sudoless`, `Docker`), so the exclusion has to be keyed per row rather than as a label blocklist, which would silently drop `Theme`, `Background` and `Font`, three ordinary words that need translating. The `menu-data` row below still counts all 340 labels with no exclusion, so read it as an upper bound on the work rather than a translation target.
The lesson is that a single number was the wrong answer. The population stratifies by cost-per-string across an order of magnitude, with the caveat, stated up front this time, that a stratum without a published predicate is a number no reviewer can confirm or attack:
| stratum | sites | unique | files | mechanism |
|---|---|---|---|---|
| bash-plain | 1204 | 1085 | 395 | direct `$"…"` marking |
| bash-interpolating | 395 | 360 | 155 | must be rewritten to `printf` first (`$"…"` cannot contain `$var`) |
| bash-heredoc-block | 96 | 89 | 75 | `$"…"` cannot reach; rewrite or externalize |
| bash-metadata | 458 | 458 | 458 | data read from disk, needs its own mechanism |
| qml | 75 | 73 | 21 | display property bound to a bare literal; one cell of a published 3x3 grid spanning 41-84 sites (**predicate-sensitive**, see below) |
| menu-data | 340 | 230 | 1 | already localizable by construction |
| keybind-data | 186 | 173 | 6 | **blocked**, see §7 |
| **to mark** | **2228** | **2065** | | |
| **already data** | **526** | **388** | | |
**On the predicates, because six of these figures were wrong or unreproducible when this document first published them.** Corrected above: `bash-metadata` was 460/460/457, which is impossible: every `bin/` file carries exactly 0 or 1 `# omarchy:summary=`, so sites cannot exceed files; it is 458/458/458. `menu-data` was 321/213 and is 340/230 label sites by a JSON walk. `keybind-data` was 182/169, which no predicate reproduces; 186/173 counts pure string literals across the `o.bind` family in `default/hypr/bindings/*.lua`, and `o.bind` alone gives 180/167, so say which and the row becomes checkable. `already data` unique was 367 against its own rows' 382; I closed that gap in the wrong direction. 367 was a union and 382 a sum, and the union is right, because a string that is both a menu label and a keybinding description is one catalog entry. Fifteen strings are both, so the figure is 388 after the two corrections, not the 403 I published by adding the rows together. The `qml` row is the honest failure, and worse than I described. Those three figures came from three different tools rather than three stated predicates, and nine defensible predicates over the same tree (three property sets by three prose strictnesses) reproduce neither 58 nor 86. The published grid spans 41-84 sites, which moves `to mark` between 2194 and 2237; the row above reports the `mid x prose` cell. Only `bash-interpolating` and `bash-heredoc-block` reproduce to the exact file count under independent re-measurement. The extraction script was described here as "an argument tool, not a product". That was the mistake, because the argument *is* the product, so it is now published with the per-stratum predicate, as the next paragraph records.
The census script is published: 549 lines of stdlib Python at [`census/omarchy-i18n-census.py`](https://github.com/linyiru/omarchy/blob/3f7bb777/census/omarchy-i18n-census.py), with `--predicates` to print what each stratum counts. It pins 17 cases of the prose heuristic and 19 count triples against `9c5482c5` and exits non-zero if any of them moves, so a predicate cannot be edited into a different number under the same stratum name. It is not proposed for merge and there is no PR behind it; the reason to publish it is that the argument *is* the product, so it is code you can review rather than figures you have to take on trust. Running it is what found the two errors corrected above.
Measured churn is ~10-12 new bash strings/day over the 37-day window of the last 500 commits (2026-08-11 to 2026-09-17), by two independent methods: diff-based 450 distinct added-not-removed, population-based +381 unique; normalising for my broader predicate gives ~7.6/day. I earlier published ~4.5/day with no window stated, which was not a measurement. Of the same 500 commits, 199 touch `bin/` and 50 touch `shell/`, a 4:1 split. That split, not a preference, is what forces two different SLAs: shell display copy is nearly frozen and can be reviewed by hand; bash notification and `gum` copy churns hard and must be machine-filled with `#, fuzzy` as the work queue and `msgcmp` as the definition of done.
**And this is the honest answer to the standing requirement that a solution be comprehensive before it is worth landing.** Comprehensive *translation* cannot be promised: 31 languages against 1.8 blocked merges/day is roughly 56 translation tasks/day, which dies immediately. What *can* be delivered is a machine that makes partial localization unable to ship: English merges immediately, edited strings go `#, fuzzy`, English shows until the fill catches up, and a marking gate enforced on new code from day one is what makes the promise mechanical rather than aspirational.
## 7. The guardrails are the deliverable
The most useful thing measured in this whole exercise is that translating Omarchy today breaks things **silently**, and where.
**#12344** (the paired PR) records which command output is a protocol and which is prose, and derives that list from the tree so it cannot rot. The motivating case: `omarchy-dns` prints `Custom`, and `default/omarchy/omarchy-menu.jsonc` decides which DNS provider shows as selected by comparing against that word. Translate it (and a translator will, it is an ordinary English adjective) and the DNS menu shows nothing selected on a machine whose DNS is fine. No test fails, nothing exits non-zero, nothing appears in a log.
**The largest and most invisible exposure is `bin/omarchy-menu-keybindings`.** Translating the 186 shipped bind descriptions **silently destroys 4/4 alternative-chord merges and reorders most of the ~228 rendered rows**, with zero errors and zero test failures. (**Correcting a figure of mine here.** It said 184, which agreed with neither §6's `keybind-data` row nor the census script pinning it. `default/hypr/bindings/*.lua` has 193 `o.bind`/`o.bind_toggle` call sites across 6 files: 186 pass a literal description, 173 of them distinct, 180 on `o.bind` and 6 on `o.bind_toggle`; 2 pass none; and 5 build both key and description in a loop, which is why the rendered list is roughly 230 rows rather than 186.) The descriptions are simultaneously the display string, the join key on both sides of an awk join, the input to 52 precedence regexes, and the round-trip key after selection, and the user's own binds enter the same stream from `~/.config/hypr/bindings.lua`. A second, smaller class is reproducible with a stated predicate and has now been hit in the field. Of the 340 rows `default/omarchy/omarchy-menu.jsonc` declares, 180 carry at least one guard (44 `checked`, 75 `when`, 65 `disabled`, 4 rows carrying two), and **8** of those compare a shell literal that is also a `label` in the same file, and in all 8 the label and the literal are the *same string*: `low`, `mid`, `high`, `DHCP`, `Cloudflare`, `Google`, `Custom`, `omp`. @WokoFlipper reports `Custom` translated to `Пользовательский` in a live Russian pack (#12345 comment), which is one of these 8 and `low`/`mid`/`high` are three more. This replaces an "8 bash sites" figure I published with no predicate and could not reproduce. **This cannot be fixed with a catalog.** It needs a real schema change (a third field: stable id plus translatable label), and it is now its own project: #12404. That issue also records a fifth coupling not listed above, which is the one that bites hardest: for the 4 `webapp + focus` binds, `command_from` passes the description to `omarchy-launch-or-focus-webapp`, which matches it against the window's own `class` and `title`. Those come from the application and no locale changes them, so a translated description stops focusing the existing window and launches a new one on every press.
**A second trap with the same symptom, in the merge path rather than in a guard.** @sbelcl reports that a Russian contributor's translated `Custom` row failed on a machine whose DNS was fine, and that the cause was not the guard above: their generator rewrites whole rows, so `checked` stays byte-identical. What breaks is a user override that declares only a `label`, because `normalizeItem` completes every absent field with `""` and `mergeMenuSources` copies every key, against a documented promise in `docs/menu.md` that an override replaces only the fields it declares. Measured, it is worse than a lost checkmark: `kind` is inferred from `action`, so the row becomes a childless submenu, `isVisible` hides it, and the DNS submenu lists 3 of its 4 rows. The same override on `setup.network.dns` empties `aliases: ["dns"]`, so `omarchy menu summon dns` resolves to an id nothing answers to. This was filed three times independently before I looked (#12236, #11509, #11211) and is already fixed by #12255, which parses sources raw and normalizes once after the merge. It belongs here because a translated menu makes the one-field override the normal case, and because #12344 does not cover it: two traps, one symptom, two separate fixes.
The negative control that changed the gate's design: a wrap-only marker moved only **2 rows**, because `match(line, /Keybindings/)` still matches the wrapped form. **A wrap-only pseudo-locale reports a false all-clear on the biggest site in the tree**, which is why the gate has to transliterate, not just wrap.
Existing tests provide almost no cover here: `keybindings-menu-test.sh` pins 4 descriptions and, incidentally, 1 of the 52 precedence literals: the Calculator-above-`/Reveal active/` assertion fails if that regex is removed. Of roughly 230 coupled sites, 4 are defended and only one of those defences is deliberate. "The tests would catch it" is not available as an argument.
Three more that must be fixed before any catalog ships, each small and each independently sensible:
- **`bin/omarchy-menu-select` returns the label verbatim** (documented at :10-13), which is why 8 bash sites are fragile. Give it the shape `omarchy-menu-images --print-name` already has (return a stable id, display a derived label) and the transcode, dns and toggle classes stop being translatable at all. The theme picker is already immune for exactly this reason and is the template to copy.
- **The cheatsheet cache key is locale-blind**: measured, same SHA-256 (`08eb0572…`) across three locales, while changing a description does move it (`c0267d39…`). Adding the active locale is one line and must ship in the same commit as any lookup.
- **`config/omarchy/hooks/post-boot.d/weather.sample:8` compares against `'Weather unavailable'` and gets copied into `~/.config`**, out of reach of any later fix. `omarchy-weather-status` already `exit 1`s on that path, so the comparison can just go.
One directional rule makes this whole bug class disappear instead of needing an audit: **translate downward only: a command another program reads emits ids; the reader renders the text.** Every safe site in the tree has that shape; every broken one has the opposite.
## 8. Documentation, and a note on filenames
#8765 already occupies `docs/i18n.md` (92 lines), `default/bash/i18n` (167) and `shell/Commons/I18n{.qml,Model.js}`, so this proposal deliberately claims none of them. Following `AGENTS.md`'s split (task procedure in `agents/skills/`, system shape in `docs/`), the deliverables are two files:
- **`docs/i18n-catalog.md`**: how the layer composes, the five consumers, and the two concessions (systemd impossible; `foot` is the one surface where font choice rather than language decides the glyph).
- **`agents/skills/i18n.md`**: what to do when adding a string. The rules an agent has to hold in mind must be short, and there are only four: translate downward only / a command that is read emits ids / no `$var` inside `$"…"` / a translated value never enters a module-level var or an appended `ListModel` row.
## 9. Unexamined and out of scope
Named rather than omitted, because a proposal that hides its gaps is worth less than one that lists them:
- **RTL has zero measurements.** Arabic's six plural forms parse, but nobody has looked at what the bar and menu do under `layoutDirection: RightToLeft`. There are 104 QML files and not one carries mirroring. This needs independent work and this proposal does not cover it.
- **The inbound half of the parse boundary.** Omarchy also parses the *gettext output of external tools*: `findmnt`, `nmcli`, `systemctl`, `dell-xps-touchpad-haptics`. Same failure mode, but the fix is a pinned locale rather than a classification, so #12344 leaves it out.
- **Qt's per-language plural table is verified only for ru and pl.** Amendment 2 asks Omarchy to own that table permanently, and every language beyond those is an unverified row. Worse, Qt keys the form *count* on the language, not on the header, so a `.po` declaring a different `nplurals` silently shifts `msgstr[]` indices with no diagnostic on either side.
- **`%9` as an interpolation cap** rests on the untested assumption that no Omarchy string needs ten placeholders.
- **Whether the po→JSON emitter carries `msgctxt` and plurals losslessly into #8765's singleton.** The one-catalog experiment's own emitter dropped 1→0 plurals and 43→0 translator comments; that is an emitter bug rather than a format limit, but nobody has run it back through the real singleton.
- **Three earlier claims I am marking as uncontrolled, do not cite them:** a `Qt.exit(42) returned 42` result (falsified: rc=124, ran the full 12 s timeout, `Signal QQmlEngine::exit() emitted, but no receivers connected`, which means every exit-code-based assertion in that report needs re-verification); the Korean cost figure for lang-free font rules; and three mutually contradictory attributions of one reboot.
I also want to retract three "locale blockers" I previously believed and would have put in this document. All three dissolved on inspection: `default/bash/envs` sources `/etc/locale.conf` two lines *above* the `C.UTF-8` fallback, so that default is a fallback of a fallback rather than a deletion of the layer; `install/` genuinely never runs `locale-gen`, but #10949 already routes around it via `localectl`, and Arch's `systemd-localed` does have the integration compiled in (verified non-destructively: `locale_gen_enable_locale`, `locale_gen_process_locale`, referencing `/etc/locale.gen` and `/usr/bin/locale-gen`, both present); and the claim that zh_TW must pin `LANGUAGE=zh_Hant:zh_TW` is wrong, because real gettext resolves to bare `zh`. That last one has a genuine consequence which survives: **never ship a language-only catalog**, because `zh_HK`/`zh_MO` (Traditional) and `zh_SG` (Simplified) all truncate to bare `zh`, so whichever script it holds is wrong for someone. Same shape for a bare `pt` against `pt_BR`/`pt_PT`.
## 10. Suggested order
Each of the first items stands on its own merits and none requires agreeing to this architecture first. That is deliberate.
| # | Item | Why it can go now |
|---|---|---|
| 1 | #12338, *Make the menu search line a real text input* | open, not merged. A prerequisite, not a follow-up: translating every menu label while leaving a Latin-only search box is worse than not translating |
| 2 | **#12344**, parse-boundary ledger + gate | ✅ open. Green on today's tree, lands as a no-op. It is a guardrail, not a translation |
| 3 | **#12363**, `omarchy-menu-select` returns a stable id | Pure robustness fix with an in-tree precedent; immunizes three classes of site |
| 4 | **#12362**, cheatsheet cache key + locale | One line |
| 5 | bash CJK column arithmetic | **Correction: this row said the router list is off by 2 columns today. It is not.** In bash `printf '%-*s'` pads by bytes while `${#var}` counts characters, and neither is display width, but every value padded anywhere in the tree is a name Omarchy generates for itself, all of it ASCII, where the three measures agree: `omarchy commands` prints 392 rows and no non-ASCII byte reaches its padded column. So this one is latent and preventative, and belongs after a catalog exists rather than before one. `omarchy-launch-about` already measures display width with `LC_ALL=C.UTF-8 wc -L` |
| 6 | #8765 with the five amendments | The runtime |
| 7 | catalog pipeline + `agents/skills/i18n.md` | Once there is something to be a pipeline for |
| 8 | **#12404**, keybinding schema (stable id + label) | Its own project, now open |
What I am asking for is a decision on §2 and §4 (whether A-with-amendments is the direction) so that the people already carrying language packs (#10776, and @WokoFlipper's Russian pack) know which key scheme to target. Everything in §7 I will send as separate small PRs regardless of how §2 goes. One of them, the cheatsheet cache key, is a defect on today's tree; the others are guardrails a catalog needs in place first rather than translation work.
## 11. What the adopted option costs at runtime
Everything above argues correctness and cost-to-build. This section is the runtime bill on its own, because it is the question I would ask first and this document buried it.
All figures: Arch, Ryzen 7 8745HS, Quickshell 0.3.1, Wayland, `qs -p` on a 320x48 `PanelWindow`, measured 2026-09-17. The fixture is 2,067 entries, at the top of the census range in §6, and deliberately the whole-tree count rather than the shell's own share, serialized exactly as `I18n` writes it: **155,185 bytes** of JSON at `$XDG_CACHE_HOME/omarchy/i18n/zh.json`.
| what | measured | against |
|---|---|---|
| `I18n.tr()`, key present | **1.65 us** | `qsTr()` at 150 ns, 11x slower |
| `I18n.tr()`, key absent | 2.01 us | falls through to the English literal |
| language switch, 400 bindings | 400 re-evaluations in **2 ms**; revert 1 ms | a switch, not a frame |
| blocking read + `JSON.parse` + install, 2,067 entries | `revision` 1 at **97.6 ms** (median of 5) | the lazy path reaches it at 101.7 ms |
| RSS, catalog resident | **210,308 kB** (median of 9) | no catalog, same harness: 205,448 kB (median of 9) |
| **RSS delta** | **+4.7 MB, 2.4%** | 7x the baseline's own spread (696 kB), so this one is signal |
| first frame | 143 ms loaded, 173 ms baseline | **no signal**: the baseline alone spans 134-186 ms |
In one line: **translation costs the bar about 4.7 MB and nothing measurable on the clock.** The 11x on `tr()` is real, but the units decide it. `shell/` has 173 strings, so re-translating every visible string costs 0.29 ms, once, on a language change. Nothing in a frame calls `tr()`: QML caches the binding.
Where it stops being free is scale, and that bounds the design rather than the implementation. Installing a catalog in memory measures 2 ms at 400 entries, 4 ms at 1,500 and **11 ms at 5,000**: superlinear, and 5,000 is not far above where a translated Omarchy lands if `bin/` and `shell/` share one catalog. That is an argument for the per-surface split this document already proposes, on grounds it did not previously have.
Two corrections to my own text above, both falling out of these numbers:
- **The "+8 ms" I attributed to the eager read in §4 is pessimistic.** It came from a smaller fixture. At census scale the eager path reaches `revision` 1 four milliseconds *earlier* than the lazy one, and the first-frame difference is inside the noise floor. The amendment is free.
- **§4 justifies the eager read as "catalogs before the bar's first frame", and that is not the failure it prevents.** Measured, the lazy read already lands before the first frame: `revision` 1 at ~100 ms, frame at ~142 ms. What it does not land before is `Component.onCompleted` *returning*, which is the exact moment a captured value freezes. The trap is narrower than I published, and the lint half of that amendment is carrying more of the weight than the eager read is.
Not measured, so nobody should assume it: RSS under the real shell rather than a probe window; anything on the `bin/` side, where the cost is a `gettext` call per string in a process that already forks; and RTL, which §9 already flags.
The harness and fixtures are reproducible and I am happy to hand over the scripts.
Happy to be wrong about any of it; every number above has a command attached, and two of the corrections in this document are to my own earlier conclusions.
---
*Edited 2026-09-17 to add §11, the runtime measurements. The original text scattered them across §4 and §5 as supporting evidence for other arguments instead of stating the cost as a claim, and never measured the option it adopts. Two figures in §4 are corrected in §11 rather than rewritten in place, so the original claim stays readable: the "+8 ms" for the eager read is pessimistic, and the failure that amendment prevents is a capture in `Component.onCompleted`, not a late first frame. *
*Edited again 2026-09-18 after five adversarial reviews of this document, each asked to hold it to the standard it asks of others. The corrections are inline and labelled rather than appended, so the claim and its retraction sit together. The substantive ones: §1's method sentence was false and is struck; §1's conclusion mis-attributed `po2ts`'s key-space destruction to the whole toolchain; §3 overstated `retranslate()` as the only load-bearing call; amendment 2's security claim was wrong and unfair to #8765's author, and is retracted there too; amendment 3 asked for what #8765 had already built; amendment 4's `%9` cap would have deleted two shipped assertions; a fifth amendment with the only real behavioural defect had been dropped from the list and is restored; and six census figures in §6/§7 were wrong or unreproducible. My earlier note claimed "nothing else in the original text changed" while that same edit had rewritten a §4 figure in place. That was untrue and this note replaces it.*
Same defect, also reported as
1 other report describesnormalize-before-merge wipes unspecified defaults: a label-only user entry erases action/aliases/guards KI-MENU-MERGE-OVERRIDE-WIPES-DEFAULTS
Earliest report: R-711 (filed Sep 10, 2026).
Open upstream.
Validation
Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.
Linked
- Related PRpr/12362· confirmed
- Related PRpr/12633· confirmed
- Related PRpr/11116· confirmed
- Related PRpr/10776· confirmed
- Related PRpr/11010· confirmed
- Related PRpr/7434· confirmed
- Related PRpr/12363· confirmed
- Known issuenormalize-before-merge wipes unspecified defaults: a label-only user entry erases action/aliases/guards KI-MENU-MERGE-OVERRIDE-WIPES-DEFAULTS· decided by arbitration
- Related toR-357Bind descriptions are load-bearing: give binds a stable id· decided by arbitration
- Related toR-598omarchy-menu search field ignores IME composition (Korean/CJK via Fcitx5) — Keys.onPressed path bypasses Qt input method· decided by arbitration
- Related PRpr/14423· confirmed
Decision
Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.
- Decided by agents · verified: partly in the audited area (menu)
- Decided by arbitration: matches no known issue
The outcome (valid, duplicate, not a bug, …) is not decided yet.