Your RTL build passes every test you have, and it is still broken
Screenshot diffs and visual regression suites are structurally blind to right-to-left bugs. Here is why, and the checks that actually catch them.
There are two kinds of right-to-left bug. The first kind is loud: the text runs the wrong way, the layout collapses, and you find it in the first five seconds. The second kind throws no error, looks entirely plausible in a screenshot, and ships. This post is about the second kind, and specifically about why the testing tools you already own cannot see it.
The short version: right-to-left failures are semantic rather than visual. Every tool in the standard front-end testing stack compares pixels or asserts on the DOM, and this class of bug produces correct pixels arranged in a wrong meaning. That is not a gap in your test coverage. It is a gap in what pixel comparison can express. If you are earlier than this and still cataloguing the failures themselves, the list of what quietly stays pointing the wrong way comes first.
Why a screenshot diff cannot see a mirrored logo
A common RTL stylesheet contains a blanket mirror rule, something that flips icons so arrows point the correct way. Written broadly enough, it catches the brand logo too. The logo is now backwards on every Arabic page.
Now run a visual regression suite against it. The baseline is the English page, the candidate is the Arabic page, and the two were never going to match anyway: the text is different, the direction is different, the whole column reverses. So you baseline the Arabic page against itself. From that moment the mirrored logo is the expected state, and every future run confirms it.
A mirrored logo is still a logo-shaped object sitting in a logo-shaped space. Nothing about the image is anomalous. Only the meaning is wrong, and meaning is not what a diff measures.
Why a reordered numeral passes review
Put the string +180% inside an Arabic paragraph and the Unicode bidirectional algorithm goes to work. The digits are strongly left-to-right, so 180 keeps its order. The plus and the percent sign are neutral characters, which means the algorithm resolves their position from the surrounding paragraph direction rather than from the order you typed. Both migrate to the wrong side. You get %180+.
Every glyph in that output is correct. There are four of them, they are the four you asked for, they are rendered cleanly at the right size in the right font. A pixel comparison sees four correct glyphs in a plausible arrangement. A DOM assertion sees textContent equal to +180%, because the DOM holds the logical string and the reordering happens at paint time. Both pass.
The half that automation genuinely owns
Machines are good at one part of this, and it is worth taking seriously because it is cheap and it runs on every commit. Physical CSS properties never flip. Logical ones always do. So scan the stylesheet for the physical set and make the build tell you about them.
# Every physical property that will not flip under direction: rtl.
# Run it in CI and read the output as a list of candidates, not verdicts:
# a physical value is sometimes exactly what you meant.
rg -n --type css \
-e '\b(margin|padding|border|inset)-(left|right)\b' \
-e '^\s*(left|right)\s*:' \
-e '\b(text-align|float|clear)\s*:\s*(left|right)\b' \
-e '\bborder-(top|bottom)-(left|right)-radius\b' \
src/Be honest about what this does not reach. It will not find a keyframe that translates by a negative percentage, an image cropped with object-position, an icon drawn pointing right in its own SVG, or a mirror rule scoped too broadly. Those are not spelled left or right anywhere in the file. The scan is a floor, not a ceiling.
Isolating a numeral, and why the computed style is not proof
The fix for the numeral is to tell the algorithm that the value is its own small island, with a direction of its own that the surrounding paragraph cannot reach into.
.stat__value {
direction: ltr;
unicode-bidi: isolate;
/* pair it with tabular figures when the value counts up, or the
digits jitter as their widths change during the animation */
font-variant-numeric: tabular-nums;
}Now the trap. Having written that rule, the obvious way to verify it is to read the computed style back and confirm it says isolate. That proves the declaration applied. It does not prove the characters paint in the order you intended, because the computed value tells you what the browser was told, not what the browser drew. Those come apart more often than you would like, particularly once a font fallback or a wrapping element gets involved.
Reading where each character actually painted
A Range can be collapsed around a single character, and its bounding rectangle tells you where that character landed on screen. Walk a text node one character at a time, record each left edge, then sort by it. That sorted string is the visual order. Compare it to the logical string and you have proof rather than inference.
/**
* The visual order of a text node, read from the rendered rects rather
* than from the string. Paste into DevTools on the real page.
*/
function visualOrder(node, paragraphDir = "ltr") {
const text = node.textContent;
const range = document.createRange();
const chars = [];
for (let i = 0; i < text.length; i++) {
range.setStart(node, i);
range.setEnd(node, i + 1);
const rect = range.getBoundingClientRect();
if (rect.width === 0) continue; // collapsed whitespace
chars.push({ char: text[i], logical: i, x: rect.left });
}
// Read the way the paragraph reads.
chars.sort((a, b) =>
paragraphDir === "rtl" ? b.x - a.x : a.x - b.x,
);
return chars.map((c) => c.char).join("");
}
const el = document.querySelector(".stat__value");
visualOrder(el.firstChild, "rtl");
// "%180+" before the isolate -> the bug, visible as a string
// "+180%" after -> proof, not inferenceThis is worth wiring into a real test rather than running by hand. It is fast, it is deterministic, and it is the only check in this article that produces a verdict instead of a candidate list.
The part that has to be a person
Read it in the real locale, on a real device, with somebody who reads the language. Everything short of that is a partial check. Automated tooling earns its place on the mechanical half of the job, catching hard-coded physical properties before they ship, and the bidi check above closes one specific hole with certainty. The failures that survive into production are semantic, and a person who reads Arabic finds them in about a minute.
The practical conclusion is a budgeting one rather than a technical one. That review is not a favour you ask for after the build is done. It is a line item, it belongs in the estimate, and on a trilingual project it belongs in the estimate three times. It is how we scope multilingual and RTL builds.

