Rehoboth Builds

Ctrl + Shift + K comes out backwards in Arabic, and nothing throws

Adding dir="rtl" and switching to CSS logical properties is an afternoon, and it is the part that works. What breaks afterwards is any left-to-right run sitting inside a right-to-left sentence — a shortcut, a URL, a version number. It raises nothing, logs nothing, and is invisible in the language you develop in.

10 September 2026

What actually breaks

The Unicode bidirectional algorithm classifies +, :, ., ,, /, - and whitespace as neutral, and resolves each neutral run from the directions on either side of it. Put Ctrl + Shift + K inside an Arabic sentence and the pluses and spaces between the key names belong to the surrounding paragraph, not to the shortcut. The segment is torn at its edges: the pieces are all there, in an order nobody typed.

This is the failure mode worth naming, because it defeats the usual defences. There is no exception to catch, no console warning to grep for, and no test that fails — your own screenshots look correct, because you are reading them left to right. The only people who see it are the ones who cannot report it in your issue tracker in your language.

Two layers, and you need both

The DOM layer: put dir="ltr" on the element. The HTML UA stylesheet gives such an element both direction: ltr and unicode-bidi: isolate, which is what makes it a directional island rather than merely a left-to-right one. This covers anything that owns an element — <kbd>, <code>.

The string layer: the DOM layer cannot help you when the value is substituted into an already-translated sentence, because the value has no element of its own — it arrives in the middle of a string from your message catalogue. Wrap it in isolate characters instead:

const FSI = '\u2068';  // FIRST STRONG ISOLATE — detects the direction of what it wraps
const PDI = '\u2069';  // POP DIRECTIONAL ISOLATE — closes FSI and LRI
const LRI = '\u2066';  // LEFT-TO-RIGHT ISOLATE — does not detect; pins

const isolate    = (v) => (v ? FSI + v + PDI : '');   // direction unknown
const isolateLtr = (v) => (v ? LRI + v + PDI : '');   // direction known to be LTR

Returning the empty string for an empty value rather than a bare FSI + PDI pair matters more than it looks: it keeps isolate(x) || '—' working. A pair of invisible characters is truthy, so the fallback silently stops firing.

Write the control characters as escape sequences, always. Written literally they are invisible characters: invisible in the diff, invisible in review, and removed by any "strip invisible characters" tool as an obvious cleanup. The code keeps running afterwards. The isolation just quietly stops working, in the one language you do not read.

Pin the shortcut, do not detect it

Given both primitives, the shortcut looks like a job for isolate() — auto-detection sounds strictly safer. It is the wrong one, and the reason is not stylistic.

Modifier key names are localised: a German keyboard has Strg printed on it, and showing that user Ctrl asks them to find a key that does not exist. So modifier names go through the message catalogue like everything else. The moment a translator renders one in Arabic script, the first strong character of Ctrl + Shift + K is an Arabic letter, FSI resolves the whole run as right-to-left, and the key order flips. Your correctness now depends on a decision made in a translation file.

LRI removes that dependency. And pinning is right on the merits: physical keyboards do not mirror. An Arabic speaker's Ctrl is still at the bottom left and the number row still runs 1234567890 left to right. Rendering the combination right to left inverts a physical fact and makes the reader mentally reverse it before their fingers can follow. Microsoft and Apple keep key combinations left-to-right in their Arabic interfaces too. This is the correct answer, not the lazy one.

The corollary for translators: do not translate the modifier names. Arabic, Hebrew and Persian keyboards have Ctrl / Alt / Shift printed on the keycaps in Latin letters. Same test as the German case, opposite answer: show what is on the key.

You cannot tell a translation from a fallback at runtime

For a whole string whose language you do not know, the instinct is to check whether the key was translated and pick the direction from that. It cannot be done: chrome.i18n falls back per key to your default_locale, so a missing key returns the English sentence, not an empty string. We measured this on 31 August 2026 — under an Arabic UI, getMessage() for an untranslated key returned the English original. Both cases are non-empty strings. There is nothing to branch on.

So do not branch. Set dir="auto" on the element and let the browser read its own content: an Arabic translation resolves right-to-left, an English fallback resolves left-to-right, and when the translation lands later no code changes.

Its known limit is that dir="auto" looks at the first strong directional character only, so an Arabic sentence opening with a Latin brand name resolves as left-to-right. The fix is not to abandon it — it is to turn the limit into an invariant something checks: reject any message in an RTL catalogue that begins with a strong LTR character, and either rewrite the sentence or prefix it with U+200F RLM. We run that as a build rule; a release that fails it does not get packaged.

One more thing not to hand-roll: do not keep your own list of RTL languages. Any list you write will miss some of ur, ps, sd, ckb, yi, dv, and it will drift from whatever locale the browser actually selected. chrome.i18n already answers this: getMessage('@@bidi_dir') returns ltr or rtl for the locale your strings came from, which is exactly the one you want to agree with.

Two that only show up when you look at it

  • Direction words have to be mirrored in translation, and that feels wrong until you look. Chrome's tab strip mirrors under an RTL locale — we confirmed it on 31 August 2026 by capturing the actual window: tabs opened A, B, C sit right to left, with "+" at the far left. Our implementation is index-based, so "move tab left" is index - 1, which under Arabic moves the tab visually right. The Arabic, Hebrew and Persian strings for those commands are therefore deliberately swapped, with a note in the catalogue telling the next translator not to "fix" them back. A label describes what the user sees, not what the English key is called.
  • chrome.i18n has no plural support, and Arabic has six categories. Select the CLDR category with Intl.PluralRules, look up <key>_<category> and fall back to _other: Arabic needs zero/one/two/few/many/other, Hebrew four. When a form reads better as a word than a number, delete the now-unused placeholders block — the store validator objects to placeholders that no message body uses.

Static rules do not replace looking at it

We do gate releases on static rules — physical direction attributes, a single creation point for directional content, every HTML entry point actually applying the direction, the bidi module's required exports, the first-strong-character rule on RTL catalogues. Every rule is mutation-tested: for each one we break the thing it guards and confirm that rule, and only that rule, goes red. One of those mutations caught the guard matching a neighbour — the "is applyDir() called" rule was satisfied by the export function applyDir( line in the module that defines it, so deleting both real calls still printed green. A guard has to check the thing it guards, not what sits next to it.

And it is still not enough. Every real problem in our first RTL pass was found by rendering the interface in Arabic and looking at it: a sentence's full stop jumping to the wrong end on an English fallback, the tab-strip mirroring above, and a <kbd> element line-breaking in the middle of a key combination. Static rules cannot see the arrangement.

Why this is worth an afternoon

Hundreds of millions of people read Arabic, Hebrew or Persian in a browser, and the extension ecosystem largely does not serve them — not because the market is small, but because getting it right is fiddly and getting it wrong is invisible from inside a left-to-right locale. The work is bounded, and it is done once: our bidi module is one file that imports nothing, copied unchanged into each extension. Both of ours ship 24 locales including ar, he and fa.

Neither of us is claiming a number for what it earned. Store dashboards do not break installs down in a way that would settle it, so anyone quoting you a lift from RTL support is guessing.


We build small Chrome extensions and write these up as we go. Custom Keyboard Shortcuts rebinds browser actions to keys you choose, per site or everywhere, and is free on the Chrome Web Store. Highlight Reader reads a page aloud and highlights each sentence as it speaks; it is also free, and a paid add-on for it — word-level highlighting and higher-quality voices — is described here. The add-on belongs to the reader, not to the shortcut extension.

The API and the code are the easy half. The half that costs days is the store: the slot cap on a new account, the listing text going read-only mid-review, the uninstall URL you cannot change after you ship. The ten that caught us — three of them in full, no signup.