Skip to content

i18n: an adjudication of the existing proposals, with measurements #12345

Open

Description

@linyiru

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. Three 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 fact that decides the runtime question

Nothing in stock qs installs a QTranslator, so a compiled Qt catalog is inert however it was produced. That is what everything else follows from, and it is a property of the runtime rather than of the file formats.

A bridge from gettext to Qt does exist. Qt's own lconvert converts a gettext-originated .po to .ts at rc=0, preserving msgctxt as a disambiguation comment, non-Latin-1 msgids, and plural arity wherever Qt's table agrees with gettext's; lrelease then compiles it with no duplicate-message collapse. This corrects an earlier headline of this section, "There is no off-the-shelf gettext → Qt catalog bridge", which generalised from two failing tools without testing Qt's own. Credit to @komagata for the question that found it; the measurements and the six costs of that route are in the thread. The two stock bridges below do still fail, for opposite reasons, and that is worth keeping because it is why po2ts in particular must not be used.

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 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. For the converter, that is now conceded: lconvert does it, and the Omarchy-owned po→ts emitter is struck from B's bill. What remains is 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 plus a qt6-tools build dependency the tree does not have today. B is cheaper than this document first priced it, and still declined on cost.

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 Proposal for a localization framework: Add the I18n translation primitive #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 Proposal for a localization framework: Add the I18n translation primitive #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: Proposal for a localization framework: Add the I18n translation primitive #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. Qt .ts/.qm via lconvert is a genuine alternative format, but inert until something installs a QTranslator (§1), so JSON is the only route that works on stock qs
.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.

A fourth, which makes the third worse: there are three plural tables to reconcile, not two. Qt keeps its own, and it disagrees with gettext on hu and tr, where gettext declares two forms and Qt allows one. Measured across the 38 locales omarchy-site ships: 24 of the 26 that msginit will give a rule for agree, hu and tr do not, and lconvert drops the second form at rc=0 with a warning on stderr only. -target-language does not help, because the language is recognised and the table is simply shorter. This binds only if a Qt consumer is ever added, but the CLDR-category-to-msgstr[]-index mapping this section has to specify should be written with the Qt column present rather than retrofitted later.

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-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 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; 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 Declare which command output is parsed, and gate it #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 Proposal for a localization framework: Add the I18n translation primitive #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.

Activity

  1. WokoFlipper commented on Sep 18, 2026

    @WokoFlipper

    Supporting this adjudication from the pack-maintainer side — @WokoFlipper/Omarchy-ru-locale ended up in the same shape independently, and §7 bit us live:

    • Bindings: we carry bindings-ru.tsv (204 entries) as archive/reference only and explicitly do NOT apply it locally — learned the hard way that translated descriptions fight the merge machinery. The stable-id-plus-label schema can't come soon enough.
    • Custom: linyiru's DNS case caught a live mine in my own table — Custom → Пользовательский sat in ru_RU.tsv while checked compares $(omarchy-dns) == "Custom". Removed on my side (commit) and proposed upstream in Drop Custom: protocol word, breaks DNS selection sbelcl/omarchy-language#5. A translator absolutely will translate that adjective; only the schema (or the lint) saves them.
    • .po as single source: our repo is already .po + TSV overlays + bindings archive, so the emitter architecture (§5) gives our packs a home without restructuring. The msgctxt key scheme is welcome — it turns a future runtime move into a re-emit.
    • Plurals: agree on build-time tables over the expression parser. Our ru.json already ships CLDR one/few/many for the same reason.

    One data point for the census: the live menu here is 335 rows, of which ~100 labels are product/app names we deliberately leave English — per-surface coverage numbers should exclude those, or every locale will look equally "incomplete" forever.

  2. linyiru commented on Sep 18, 2026

    @linyiru
    ContributorAuthor

    Thank you. The Custom incident is now the best-evidenced claim in the whole document, because it is the only one that happened to somebody rather than being derived. Three things back, one of which is a correction to your description and one of which is a gap in my §5 rather than in your pack.

    Your incident generalizes, and it is exactly 8 rows. With a stated predicate: of the 282 entries in default/omarchy/omarchy-menu.jsonc carrying a checked/when guard, 8 compare a literal that is also a label somewhere in the same file. In all 8 the label and the literal are the same string:

    low   mid   high   DHCP   Cloudflare   Google   Custom   omp
    

    Your Custom is one of them and low/mid/high are three more, so a Russian pack keyed on bare English hits half of this set. This replaces a figure in §7 that I could not reproduce with a predicate anyone can re-run.

    One correction to the mechanism, and it makes it worse rather than better. Translating the label alone does not break checked: the guard is [[ "$(omarchy-dns)" == "Custom" ]], and neither side of that comparison is the label. action also passes the literal Custom. What breaks it is an overlay that substitutes bare English strings across the whole entry, including the shell text in checked and action. That is a defect in how overlays are applied, not in translating a label, and it means the blast radius is bigger than one row: all four DNS rows have label identical to the protocol word.

    Your plural data is fine; my §5 is underspecified. I pulled ru.json and ru-part2.json: all three plural entries carry four CLDR keys (one/few/many/other) while gettext Russian has three msgstr[] slots, so an emitter has nowhere to put other. What makes this my problem rather than yours is that in all three entries other is byte-identical to many:

    %1 prompt   one=%1 промпт   few=%1 промпта   many=%1 промптов   other=%1 промптов
    

    The colliding pair is precisely the harmless one, so your data cannot reveal the mapping error. Russian other is also the fraction category (1.5), so the first translator who writes a distinct other loses a form with no diagnostic on either side. §5 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 ".po as single source" that I had not written down.

    On the census point: agreed, and it is a real defect in §6. My counts include product and app names with no exclusion, which inflates every "translatable" figure and would make every locale look permanently incomplete, exactly as you say. The predicate for each stratum was never published, which is why several of those numbers cannot be checked or attacked; I am fixing that rather than defending the numbers.

  3. linyiru commented on Sep 18, 2026

    @linyiru
    ContributorAuthor

    §6 committed to publishing the census script with each stratum's predicate before these figures are asked to carry a decision. Here it is, run against a clean git archive export of a pinned revision. Doing that turned up one more error of mine and killed one figure I published as a range.

    Script: census/omarchy-i18n-census.py on branch i18n-census-audit. Stdlib Python 3, no dependencies, not proposed for merge and there is no PR behind it. --predicates prints what each stratum counts and nothing else.

    git -C omarchy archive 9c5482c5 | tar -x -C /tmp/pinned
    python3 census/omarchy-i18n-census.py /tmp/pinned
    

    What reproduces. All seven strata in the §6 table come out exactly, against 9c5482c5 (branch quattro, 2026-09-16). The script pins 19 (sites, unique, files) triples and exits 3 if any of them moves, so a predicate cannot be edited into a different number under the same stratum name without the script saying so.

    Correction: already data unique is 388, not the 403 I published. I produced 403 by adding menu-data 230 to keybind-data 173. The correct operation is a union, because a string that is both a menu label and a keybinding description is one catalog entry, not two. Fifteen strings are both:

    Agent  Audio  Bluetooth  Browser  Editor  Grok  Herdr  Keybindings
    Network  Screenshot  Share  Signal  Terminal  Tmux  Transcode
    

    Worse than a slip: §6 records that this figure "was 367 against its own rows' 382", notices the gap, and resolves it in the wrong direction. 367 was a union and was right; 403 is a sum and is not. The union is 388 after the menu and keybind corrections. This is also a small argument for one catalog rather than against it, since those fifteen strings are reached from two unrelated subsystems and should resolve to one entry.

    Correction: the qml range 58-86 has no reproducible endpoints. §6 published it on the strength of three tools that disagreed, mine and two reviewers', rather than three stated predicates. Nine defensible predicates over the same tree (three property sets by three prose strictnesses) give this, and neither 58 nor 86 is among them:

    property set multiword prose letters
    core (4 props) 41 / 40 / 15 73 / 71 / 19 79 / 76 / 21
    mid (10 props) 43 / 42 / 17 75 / 73 / 21 81 / 78 / 23
    wide (22 props) 44 / 43 / 18 78 / 75 / 24 84 / 80 / 26

    Bold is the shipped cell, the one the §6 row reports. The honest spread is 41 to 84 sites, and it is a grid rather than a range because the two axes move independently. Every cell is pinned, so the grid is checkable rather than asserted.

    The one principle behind the four earlier corrections. They were not four separate slips. is_prose() is a heuristic for guessing which of many arbitrary literals in code reaches a human, and it was being applied to fields that are human-facing by construction. A menu label, a keybinding description and a # omarchy:summary= value need no heuristic; every value counts. Gating them did not add caution, it invented false negatives. The script now separates sift strata, where the guess is unavoidable and is_prose() runs, from schema strata, where it must not. That single change produces 458/458/458, 340/230 and 186/173 without any hand adjustment.

    Two populations I had been dropping silently, now counted and named.

    • menu-title, 16 sites / 7 unique. The "title" values in omarchy-menu.jsonc are Default Agent, Default Browser, Default Editor, Default Terminal, Remove (nine times), Reset to default and Restart (twice). Those are user-facing, and the label-only predicate omits them, so the published menu-data figure understates by this much.
    • bash-metadata-args, 184 sites / 163 unique. # omarchy:args= values, held out because they are usage syntax rather than sentences (<app-name>, [application-args...], <chrome|brave|edge>); only 3 of the 184 pass the prose test. Their metavariable names are still English, so a maximal localization would touch them.

    What the self-tests actually cover, since I overstated this. §6 says "17 pinned self-tests" and that is true, but all 17 test is_prose() and none tests a stratum count, which is not what a reader would assume. Counts are now pinned too, at the 19 figures above. The script is also 549 lines rather than the 265 §6 quotes, most of the growth being the predicate statements and the pinned tables.

    The table as it now stands, every figure reproducible at 9c5482c5:

    stratum sites unique files kind
    bash-plain 1204 1085 395 sift
    bash-interpolating 395 360 155 sift
    bash-heredoc-block 96 89 75 sift
    bash-metadata 458 458 458 schema
    qml 75 73 21 sift (grid cell mid x prose)
    menu-data 340 230 1 schema
    keybind-data 186 173 6 schema
    to mark 2228 2065
    already data 526 388

    to mark was published as ~2210-2240 / ~2050-2075; 2228 and 2065 fall inside those ranges, and are now exact for the shipped qml cell rather than approximate. Across the whole qml grid the sites figure moves between 2194 and 2237.

    Two figures in the §6 table were therefore wrong as published, and the body is now corrected: already data unique 403 is 388, and the qml range 58-86 is replaced by the grid above. Everything else stands and now has provenance.

  4. linyiru commented on Sep 18, 2026

    @linyiru
    ContributorAuthor

    Correcting §10 row 5, which said the CLI router's command list is off by 2 columns today. It is not. I went to write that patch, measured first, and the premise did not survive.

    The three-way disagreement itself is real. In a UTF-8 locale:

    string characters (${#s}, gawk length()) bytes display columns (wc -L)
    字型設定 4 12 8
    󰇖 DNS 5 8 5
    Пользовательский 16 32 16

    The two padding implementations in the tree are wrong in opposite directions:

    $ LC_ALL=C.UTF-8 bash -c 'printf "[%-8s]\n" 字型設定'
    [字型設定]          # 12 bytes is already past 8, so bash pads nothing
    $ LC_ALL=C.UTF-8 gawk 'BEGIN { printf "[%-8s]\n", "字型設定" }'
    [字型設定    ]      # 4 characters plus 4 spaces, which renders 12 columns wide
    

    What I got wrong was asserting that some site in the tree is fed a value where the three measures disagree. Nine commands do column arithmetic, in three languages that each count differently:

    • Four in bash, where %-*s pads by bytes while ${#var} counts characters, so the two disagree for any non-ASCII byte at all: bin/omarchy:514,581, omarchy-dev-theme-preview:215,303, omarchy-dev-benchmark-cli, omarchy-dev-benchmark-theme-switcher.
    • Four in gawk, where printf and length() both count characters and therefore agree with each other, which leaves them wrong only for wide or zero-width characters: omarchy-plugin-list:44, omarchy-menu-keybindings:429, omarchy-menu-tmux-keybindings, omarchy-menu-herdr-keybindings.
    • One in Python, omarchy-dev-font:625, which counts characters like gawk.

    That distinction was missing from §7 and §10, which treated all the sites as one bug.

    Every value any of them pads is a name Omarchy generates for itself: command names, group names, colour keys, chords, tmux and herdr key combos, benchmark labels. All ASCII, and for ASCII all three measures agree. omarchy commands prints 392 rows and exactly one of them contains a non-ASCII byte anywhere, in a description, which is the column after the padded one. The group list is 92 rows and entirely ASCII. The one padded value that comes from a third party, the plugin id in omarchy-plugin-list, is validated against ^[A-Za-z0-9][A-Za-z0-9._-]*$ in omarchy-plugin-validate:51, and that rejects 字型 and é-plug alike.

    So nothing misaligns today, and the row now reads as latent rather than shippable. It is preventative work that belongs after a catalog exists rather than before one, and omarchy-launch-about:63 is still the model, with the comment already there explaining why LC_ALL=C.UTF-8 wc -L is the only one of the three that measures what a terminal draws.

    Two smaller notes while correcting the ordering:

    • Row 3 (omarchy-menu-select returns a stable id) is also not a defect today, and §10 closes by calling everything in §7 "bugs today". Row 3 is fragility: every caller that recovers an id by cutting up the row it displayed is correct right now and wrong the first time the row is written differently. Sent as Let the menu selector return an option's index #12363 anyway, since the mechanism is what makes the rest possible.
    • Row 4 (cheatsheet cache key) is the one that is a real defect on today's tree, and it has nothing to do with translation. prioritize_entries ends with sort -k1,1n -k2,2; key 2 is the second blank-separated field, which across the shipped binds takes only 25 distinct values and leaves most rows tied, and every one of those ties is settled by sort's last-resort whole-line comparison, which follows LC_COLLATE. Of the 185 binds default/hypr/bindings/*.lua ships, 31 sit at a different index under en_US.utf8 than under C, and splicing -s into that sort, which is what removes the last resort, makes the two orders identical. The cache key covered only the keymap and hyprctl binds, so changing locale served back the previous one's order until a bind changed. Sent as Pin the keybindings row order to one collation #12362.

    That accounts for all four of §7: #12361 for the weather hook's string comparison, #12362 for the cache key, #12363 for the menu selector, and this row, which is not being sent.

    This is the third figure in this issue that survived reasoning and died on measurement, after the qml range and the already data union. All three were claims about what the tree does, made from reading it instead of running it, which is a specific enough failure to be worth naming.

  5. sbelcl commented on Sep 18, 2026

    @sbelcl

    Thank you for doing this as measurements rather than a ninth opinion, and for
    retracting two of your own conclusions by name. That is rarer than it should be.

    Two data points from carrying translations outside the tree, offered because
    they are evidence rather than preference:

    The English-string key scheme survives contact. I ship catalogs keyed on the
    English source for ten forked bar widgets and a menu table, in Slovenian and (as
    of today) Russian, maintained by two people who have never had to coordinate a
    key. What that costs is the thing you would expect: editing an English string
    orphans its translation. What it buys is that a missing key degrades to correct
    English rather than to a blank or a visible power.profile.header, and that a
    translator can read the key. Your (msgctxt, English msgid) recommendation is
    the same shape with the disambiguation I lack — %1M means one thing in a token
    count and would mean another somewhere else, and I have no way to say so.

    #12344's boundary is real, and the failure is exactly as quiet as you say.
    It surfaced in my repo the same day you filed it: a Russian contributor sent a
    PR translating Custom, having hit "no DNS provider selected" on their own
    machine. Worth adding, though, that the menu's label is not itself parsed —
    my generator copies whole rows, so checked keeps [[ "$(omarchy-dns)" == "Custom" ]] byte-identical while the label is translated, and the radio button
    still lights up. Their actual cause was almost certainly a label-only override,
    which parseMenuJsonc silently completes with empty-string defaults, dropping
    checked along with the icon and action. Two different traps with one symptom;
    your list would have caught the first, and the second is a reason to generate
    overrides rather than hand-write them.

    On the runtime verdict I have no standing to adjudicate — I have not run the
    three candidates. I will say that #10955 and #10949 are deliberately independent
    of whichever lands: one fixes Qt.formatDate ignoring the locale, the other
    sets the system locale and translates nothing.

  6. WokoFlipper commented on Sep 18, 2026

    @WokoFlipper

    Offering our pair as a testbed for the four amendments: sbelcl's runtime + my ru pack (menu 137 rows live, panel catalog 179/179 keys, CLDR plurals in production). Real sessions, two locales (sl + ru, including dual and three-form plurals), daily-driven on ru_RU.UTF-8.

    Concretely we can validate: (1) eager catalog read — watch first-frame behavior on a filled catalog; (2) build-time plural tables — ru/pl divergence at n=21 is checkable live; (3) (msgctxt, msgid) keys — our packs re-emit mechanically; (4) %9 cap/escapes.

    Say which amendment needs hands and we'll measure it.

  7. WokoFlipper commented on Sep 18, 2026

    @WokoFlipper

    On the bindings stable-id schema (§7 "own project"): we want in. We carry 204 translated binding descriptions as an archive that explicitly must not be applied — the day a third field (stable id + translatable label) lands, that archive becomes a real catalog overnight.

    Happy to co-design or pilot: our pack is the largest ready-made label set, and our machine runs the merge machinery daily. Where should that project live — separate issue, or a section of #12345?

  8. linyiru commented on Sep 18, 2026

    @linyiru
    ContributorAuthor

    Three replies to answer, one figure of mine to correct, and a prior-art failure of my own to report, which is the more useful of the two.

    @sbelcl is right about the mechanism, the measurement makes it worse, and three people had already filed it. A label-only override does not merely drop checked. normalizeItem completes every absent field with "", mergeMenuSources copies every key, and kind is inferred from action, so the row becomes a childless submenu and isVisible hides it. Overriding one shipped row with a label alone:

    kind isVisible checked rows listed under DNS
    quattro menu false "" 3 of 4
    with #12255 action true byte-identical to shipped 4 of 4

    The same override on setup.network.dns empties aliases: ["dns"], so omarchy menu summon dns resolves to an id nothing answers to while the menu looks fine. So your Russian contributor's symptom was not a radio button that stopped lighting up, it was a row that left the menu. At 9c5482c5 all 340 declared rows carry an icon, 180 carry at least one guard and 20 carry a summon alias, so there is no row where a one-field override was safe.

    What matters more than the measurement: this was filed three times before I looked, by three people independently, each with the correct root cause, and @z23 has already fixed it. #12236 (@jenseralmeida), #11509 (@ahmadtv), #11211 (@silvioscs), fixed in #12255 by parsing sources raw and normalizing once after the merge, which is a better shape than the patch I had already written, because it keeps "absent" and "explicitly empty" distinguishable. I found all four after writing mine. In a thread whose subject is checking claims instead of asserting them, and after #12312 turned out to have two siblings I had not credited, searching the issue tracker before writing the patch is the habit I keep failing to apply. Review with the measurements above and three regression tests against the shipped menu: #12255 (comment).

    Two traps with one symptom, as you said, and both now have a fix in flight: #12344 for a translated command output, #12255 for the merge path. §7 will name them separately rather than as one row.

    Correcting my own figure in this comment. I wrote "of the 282 entries in default/omarchy/omarchy-menu.jsonc carrying a checked/when guard". 282 is the number of action rows, not the number of guarded rows. At 9c5482c5: 340 rows declared, of which 180 carry at least one guard (44 checked, 75 when, 65 disabled, 4 rows carrying two). The result that mattered is unchanged and still reproduces: 8 guard literals are also a shipped label, low mid high DHCP Cloudflare Google Custom omp, at the ids I listed.

    @WokoFlipper, the amendment that needs hands is the plural tables, and specifically the case your data cannot show. Your published ru entries have other byte-identical to many, so they are silent on the mapping error. The concrete ask:

    1. In one ru entry and one pl entry, make other genuinely distinct from every other form (Russian other is the fraction category, so 1.5 is the case that reaches it).
    2. Render n = 1, 2, 5, 21, 101 and 1.5 in a real session for both languages.
    3. Report which form appears for each n, and whether anything at all warned during the build or at load.

    What I expect to fail: gettext Russian has three msgstr[] slots, so a distinct other has nowhere to go, the emitter drops it, and nothing says so. If something does warn, my §5 gap is smaller than I claimed and that is equally worth knowing. Only you can run this: my check is build-time, yours is a daily-driven ru_RU.UTF-8 session with a second language whose table diverges at the same n.

    If there is capacity for a second, take the eager catalog read, reported as a frame count: on a filled catalog, does the first painted frame ever show English, and which surface flashes, the bar or the menu. That is the measurement that decides whether the amendment is required or merely tidy. I am not asking for (3) or (4): your packs re-emitting mechanically is already the evidence for the key scheme, and the %9 cap is a lint I can pin without a session.

    On where the bindings project lives: a separate issue, and I will open it and link it from §7. Three reasons. The change is a schema addition to default/hypr/bindings/*.lua plus the cheatsheet reader, and it lands with no runtime and no catalog, so it should not wait behind this. Its audience is whoever owns the cheatsheet rather than whoever decides about translation. And #12345's figures are pinned to a revision, which a live schema discussion would churn.

    What each side brings, so the first post can say it: your 204-entry ru archive is the only ready-made label set and the largest, and your machine runs the merge machinery daily, so it is the first real consumer. I bring the third-field schema and the reader change. Two mechanism pieces are already in flight and belong in that post: #12363, so a caller stops recovering an id by cutting up the row it displayed, and #12362, because the cheatsheet's row order currently depends on the active collation, which a translated description would reorder.

    On the census denominator, with the predicate you asked for. At 9c5482c5 the shipped menu declares 340 rows and 230 distinct labels, and 2 of those rows are provider hosts filled at runtime (apps, style.font). 75 rows carry a when guard, so any live count is machine-dependent: your 335 and your 137 are both live numbers from real sessions, which is exactly why neither can be the denominator. The declared count can be.

    Your product-name exclusion reproduces at 86. Predicate: a row in leaf position under install., remove. or setup.default.. That is 162 rows and 95 distinct labels, of which 86 appear nowhere else in the menu, leaving 144 distinct labels as the translatable surface. Your "~100" was close.

    The residue is the interesting part. 9 labels appear both in a product-name position and elsewhere: Edge Neovim Theme Background Font Fingerprint Fido2 SSHD Sudoless Docker. So the exclusion cannot be a label blocklist, or a coverage metric silently drops Theme, Background and Font, which are ordinary words that badly need translating. It has to be keyed per row. That is a constraint on the coverage number itself, not just on the census, and it is going into §6 with the predicate stated.

  9. komagata commented on Sep 18, 2026

    @komagata

    Thanks for the investigation and corrections. A clarification from my tests: Qt's lconvert preserved contexts and plurals in a Qt-originated TS → PO → TS → QM round trip across five languages. Arbitrary PO files can still change plural semantics, so this is not a lossless bridge for every gettext catalog.

    If a single PO source is optional, using standard Qt TS/QM for QML and gettext for Bash avoids an Omarchy-owned PO → TS converter. I think this alternative deserves a separate maintenance-cost comparison.

    I've also updated quickshell-mirror/quickshell#1081 with a source-language fallback catalog for plurals and translator cleanup at shutdown. Upstream acceptance and independent plugin catalogs remain open, but this route would avoid distributing an Omarchy-specific C++ module.

  10. linyiru commented on Sep 30, 2026

    @linyiru
    ContributorAuthor

    @komagata You were right that this deserves its own comparison, and testing your tool before writing it falsified my own §1. Retraction first, then the comparison.

    §1's headline is wrong

    §1 opens with "There is no off-the-shelf gettext → Qt catalog bridge. This is measured, not inferred, and it is the fact everything else follows from." It was measured from two tools, msgfmt --qt and po2ts, and I turned two failures into a negative existence claim without testing Qt's own converter. lconvert is that bridge, and on gettext-originated POs it does better than a Qt-originated round trip could have shown.

    Disposable Arch container: qt6-tools 6.11.2-1, lrelease 6.11.2, gettext-tools 1.0, PySide6 Qt 6.11.2 for the runtime lookups. Input is the same zh_TW.po §1 was written against: one msgid_plural, the real U+2026 in SCANNING WI-FI…, a U+2019 in Accept this network’s terms, and four Open entries differing only by msgctxt (menu, file, network, and one with none).

    step result
    msgfmt --qt -o zh_TW.qm zh_TW.po rc=1, no file: "message catalog has plural form translations but the Qt message catalog format doesn't support plural handling"
    lconvert -i zh_TW.po -o zh_TW.ts rc=0. U+2026 and U+2019 intact, msgctxt becomes Qt's <comment>, msgid_plural kept as <extra-po-msgid_plural>, #, c-format as <extra-po-flags>, #. as <extracomment>
    lrelease zh_TW.ts -qm zh_TW.qm rc=0, Generated 7 translation(s) (7 finished and 0 unfinished). No dropping duplicate messages, which is exactly where po2ts collapses the key space

    Through a real QTranslator (QCoreApplication::translate, the same lookup qsTranslate performs):

    translate("", "Open", "menu")    -> '開啟'
    translate("", "Open", "file")    -> '開啟檔案'
    translate("", "Open", "network") -> '開放網路'
    translate("", "Open")            -> '開(無 context)'
    translate("", "SCANNING WI-FI…") -> '正在搜尋 WI-FI…'
    

    Four Open variants all distinct, non-Latin-1 msgids fine. Russian form selection matches gettext's rule exactly (n=0 to form 2, 1 to 0, 2 to 1, 5 to 2, 21 to 0, 22 to 1, 101 to 0), and a Polish catalog diverges from Russian at n=21 the way it should, so Qt holds genuinely per-language rules rather than one approximation.

    So the fact §1 calls "the single fact that decides the runtime question" is not a fact. That is my third retraction on this issue, and the method failure is the same as the other two: I stopped searching once the tools I already knew had failed.

    The comparison you asked for

    Six costs, each measured. Two are one-line build rules, two are authoring constraints, two are silent-data-loss risks.

    1. A build dependency the tree does not have

    lconvert and lrelease come from qt6-tools, and they install to /usr/lib/qt6/bin/, which is not on PATH. grep -ro 'qt6-[a-z0-9-]*' install/ on omarchy today returns qt6-wayland and qt6-imageformats, one occurrence each, and nothing anywhere in the tree names qt6-tools, lconvert or lrelease.

    2. Everything from a PO lands in the empty Qt context

    msgctxt maps to Qt's disambiguation comment, not to the context name:

    translate("",    "Open", "menu") -> '開啟'
    translate("Foo", "Open", "menu") -> 'Open'      (miss)
    

    So every call site is qsTranslate("", source, msgctxt), and the Qt context field cannot carry a plugin or domain id. That bears directly on your note that independent plugin catalogs remain open: with a PO source they can only be separated by which .qm you install, not by context.

    3. %d is not a Qt placeholder

    Qt substitutes %n; gettext catalogs carry %d. Measured: translate("", "Installing %d package", "", 5) returns '正在安裝 %d 個套件', with a literal %d. So either every plural call site does its own .arg(n), or the catalog is authored with %n and the Bash consumer can no longer share the msgid. This is the one cost a Qt-originated round trip structurally cannot surface, because a Qt-originated catalog already uses %n.

    4. The target language comes from the filename, not from the catalog

    The one I would not have predicted, and it contradicts lconvert -help, which says the target language "is guessed from the file name if this option is not specified and the file contents name no language yet". For PO input the Language: header is not treated as naming it, so the guess always runs. Identical bytes, different filenames:

    ru.po        Language: ru     ->  ru_RU,  3 numerusforms
    ru.po        Language: xx     ->  ru_RU,  3 numerusforms
    ru.po        (no Language:)   ->  ru_RU,  3 numerusforms
    xx.po        Language: ru     ->  NONE,   1 numerusform    + warning
    messages.po  Language: ru     ->  NONE,   1 numerusform    + warning
    omarchy.po   Language: ru     ->  NONE,   1 numerusform    + warning
    m-sl_SI.po   Language: sl_SI  ->  si_LK,  2 numerusforms   + warning
    sl_SI.po     Language: sl_SI  ->  sl_SI,  4 numerusforms
    

    The header never wins. When the filename does not parse, the catalog is truncated to one plural form at rc=0 with "Removed plural forms as the target language has less forms" on stderr only, so a Russian catalog routed through any tmp.po or messages.po step loses two forms silently. The second-to-last row is the sharp one: a Slovenian catalog in a file named m-sl_SI.po is read as Sinhala and cut from four forms to two, still rc=0. -target-language sl restores all four, so the mitigation is one build rule, but it has to be explicit rather than assumed.

    5. Qt's plural table is a third table, and it disagrees with gettext on two locales the site already ships

    §5 already has to reconcile CLDR against gettext, since @WokoFlipper's Russian pack keys four categories where gettext Russian has three msgstr[] slots. Qt is a third. Measured across omarchy-site's 38 locales, both filename-driven and cross-checked with -target-language, against gettext's own arity from msginit:

    agree 24 of the 26 locales msginit would give a rule for
    hu gettext 2 forms, Qt 1. The second is dropped, rc=0, stderr warning
    tr gettext 2 forms, Qt 1. Same
    ar Qt 6 forms. msginit gave no rule, so I cannot cross-check this one
    zh-TW.po, es-MX.po hyphenated names parse the same as underscored. No cost here

    -target-language does not help hu and tr: the language is recognised and Qt's table simply says one form, so at runtime every n returns the first. msginit would not give me a rule for 12 of the 38 locales, so two is a floor and not a bound.

    6. #, fuzzy stops protecting users

    §5's argument for .po over hand-written JSON is that msgfmt refuses fuzzy entries by default, so a changed English string falls back to English instead of showing a stale translation. Through this bridge, by default, it does not: lconvert maps #, fuzzy to type="unfinished" and lrelease ships unfinished translations.

    lrelease fuzzy.ts -qm f.qm                -> "Shut down" -> '關機(陳舊)'   the stale text
    lrelease fuzzy.ts -qm f.qm -nounfinished  -> "Shut down" -> 'Shut down'      English, as gettext does
    

    -nounfinished restores gettext's semantics exactly. Another one-line rule, and another thing that is wrong by default.

    What moves in the verdict, and what does not

    Struck, and yours:

    • §1's headline. A bridge exists.
    • §2's "Given §1, that is false for the part that matters", and with it the first item in B's cost list, "an Omarchy-owned po→ts emitter". lconvert does that job, so B is measurably cheaper than §2 priced it.

    What survives, for a different reason than the one I gave. §5's QML row reads "Omarchy-owned emitter, ~120 lines, the only QML route available today", and it still is, but not because no converter exists. It is because nothing in stock qs installs a QTranslator: quickshell v0.3.1 has zero references to QTranslator, installTranslator, retranslate or uiLanguage in any .cpp, .hpp or .qml file, which is already in §1's own correction. A .qm is inert without one. So the converter was never the gate:

    route converter runtime installation critical path
    A, adopted ~120-line emitter to JSON none, QML reads the file none
    B, #10051 none now, lconvert Omarchy-owned compiled C++ module none, but §2's other five costs stand
    your route none, lconvert quickshell#1081 upstream acceptance

    Dropping the single-PO-source requirement removes the converter, which was never the binding constraint, and leaves the runtime installation, which is. Your route is B's authoring side with C's runtime, so its critical path is #1081 landing, which you name as open yourself. That is the same reason §2 keeps C as a dividend rather than the plan, and on that reading the verdict does not move. It is a better B, not a fourth option.

    Two edits to the document rather than leaving this in the thread: §1's headline goes, replaced by what actually decides the question, and §5 gains the third-plural-table finding with hu and tr named. If the project ever does take the Qt-native route, the converter is lconvert with -target-language and -nounfinished set explicitly, not a hand-written emitter.

    🤖 Generated with Claude Code

  11. WokoFlipper commented on Oct 5, 2026

    @WokoFlipper

    Data point for the adjudication: first complete locale manual just landed as #14292 — 51/51 pages, structure manual/ru/*.md + manual/ru/images/, filenames 1:1 with stock. Stealable as a pattern if the framework blesses per-locale manual dirs.

  12. WokoFlipper commented on Oct 12, 2026

    @WokoFlipper

    Small nudge with fresh data, in my own name: today's #15151 adds a third in-tree engine next to the proposals already here, plus #15117 wires language into the installer. The pile keeps growing while the decision waits. My RU packs (#14292 manual, #14481 in-tree) are already written to rebase onto whichever engine gets picked — but each new engine PR raises the rebase cost for everyone. Could we get even a directional call (external catalog like sbelcl's vs in-tree shell/locales/ vs installer-first), so authors stop multiplying frameworks?

  13. WokoFlipper commented on Oct 16, 2026

    @WokoFlipper

    A note on maintenance, in my own name: the RU catalog behind #14481 isn't a one-shot translation — it's carried by a native speaker. Current state: 243 keys, key-parity checked against the actively maintained sl catalog (the only gap found this week was km/h, fixed the same day), strings verified live on my own desktop, which runs them daily. If the team picks any engine, I'll keep carrying the RU side — that's the offer, not just the files.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions