Skip to the article

Right to left Published Reading time 14 min

The right-to-left bugs logical properties cannot fix

Eleven hand-written mirror rules cover a site of 15,142 lines, which is the case for logical properties made in numbers rather than in praise. The seven defects that actually reached Arabic readers were all somewhere else — in a transform, in DOM order, in the shaper, in a clip window — and finding them took a different instrument.

In short

You find them by turning every right-to-left defect into a property assertion that runs on every Arabic route, because the two instruments most teams reach for cannot help: a pixel baseline dies the moment the Arabic typeface changes, and axe-core 4.11.0 ships 104 rules of which zero concern text direction. This site's gate makes 37 such assertions at four viewports across all 19 Arabic routes, and the one that earns its keep is a single subtraction — the document's scrollWidth minus the window's innerWidth, tolerated to a pixel — which catches every unmirrored offset and every wrong transform sign at once. Logical properties still do the heavy lifting, but they cannot reach a transform, DOM order, bidi resolution, text splitting or font metrics, and that is where all seven shipped defects lived.

What to take away

  1. Logical properties are worth every line — 78 inline-axis declarations across 15,142 lines of CSS left this site with eleven explicit mirror rules — and they stop at the edge of the CSS box model.
  2. A transform is not direction-aware by specification, so the sign has to become a variable: inlineSign returns plus one on an Arabic document and minus one elsewhere, and it was the difference between ten reachable case studies and zero.
  3. Sequential focus order is DOM order and does not read direction at all, which is how the most expensive defect in this set stayed invisible on screen.
  4. When the layer under test changes, the instrument has to change with it: replacing two Arabic families with one invalidated a byte-exact pixel baseline in a single commit.
  5. A property assertion only asserts the properties somebody thought of — this site's reversed-run check is implemented over element children, and a counter whose parts are anonymous flex items walks straight past it.

42 files · 15,142 lines · 78 declarations

What do logical properties actually buy you?

Give the tooling its due first, and give it in numbers. This site's stylesheets run to 15,142 lines across 42 files, and 78 of those lines are inline-axis logical declarations — padding-inline, margin-inline, inset-inline-start, text-align: start. Against them stand eleven explicit mirror rule blocks scoped on an rtl root, plus nineteen more scoped on the Arabic language, not one of which is a mirror. The cascade handles the direction problem almost by itself, and it handles it in the layer that covers most of a page.

The boundary is documented rather than discovered. MDN's own reference for the module lists logical equivalents for sizing, margins, padding, borders, insets, float and clear, resize, caption-side and text-align, and then enumerates the features that have none and are not getting one: transform, transform-origin, background-position, box-shadow, text-shadow, linear-gradient angles, radial-gradient position and filter. The stated reason is the same for all of them — they operate in a rendered coordinate space rather than in document flow. Logical transforms have been an open CSS working-group issue since 23 October 2018, w3c/fxtf-drafts number 311, and they are still open.

So the interesting question is not what the cascade covers. It is what is left, and on this site what is left turned out to be five layers where logical is not a concept the platform defines at all: the coordinate space a transform lives in, the order the DOM is in, how the bidi algorithm resolves a run, how a text splitter cuts a word up, and what a font's own metrics measure. Seven defects shipped to Arabic readers from this codebase, and every one of them lived in one of those five.

Every physical feature with no logical equivalent that this codebase actually uses, and what each one cost in hand-written rules. Counts from a comment-stripped scan of src/styles.
Feature Logical equivalent Mirror rule blocks
padding-inline, margin-inline, inset-inline, all forms yes — 78 declarations use them 0
translateX and translate3d no 2
scaleX no 2
transform-origin no 1
linear-gradient angle no 2
flex item order of a composite value no — order follows direction 3
sequential focus order no — DOM order, unaffected 1

Support is not the constraint any more either. caniuse puts CSS Logical Properties and Values Level 1 at 96.42 per cent globally, with full support from Chrome 89, Edge 89, Firefox 66 and Safari 15. The gap the W3C's own Arabic Script Gap Analysis still records is narrower and more specific: logical keywords in shorthands are not well supported, which is why the counts above are for longhands.

src/runtime/pages/projects.js:180-201

Why does a transform ignore which way the page reads?

The projects index puts ten case studies on a horizontal rail: a track that travels sideways as the page scrolls down. Its stylesheet is entirely logical — the track is inset: 0 with padding-inline: 8vw — so on an Arabic document the track correctly overflows to the left, which is exactly what it should do. Nothing in the CSS was wrong.

The JavaScript then translated it further left. Measured on the Arabic projects route, scrolled to the end of the rail, the custom property the track reads reached −2,531 and pushed a track that already began off the left edge further out again. Zero of the ten cards were inside the viewport, at 1440 and at 390 alike, and the counter in the corner read 10 the whole way down. An Arabic reader could not reach a single case study through the rail.

The cause is in the specification rather than in the browser. CSS Transforms Level 1 defines the coordinate space with two axes: the X axis increases horizontally to the right, the Y axis increases vertically downwards. There is no reading-order term anywhere in that definition, so a translate3d travels the same physical way on both documents no matter what direction says. The magnitude is the same quantity in an Arabic document as in an English one; only the sign is a fact about reading order.

The whole of the fix, in three files. The helper existed, unused in production, before the defect was found.
// src/runtime/core/env/direction.js — 64 lines, zero imports
export function isRtl(doc) {
  return (doc?.documentElement?.dir ?? '') === 'rtl'
}
 
export const MIRROR = Object.freeze({ right: 'left', left: 'right' })
 
export function inlineSign(doc) {
  return isRtl(doc) ? 1 : -1
}
 
// src/runtime/pages/projects.js — the scrub callback
// was:  const x = (-progress * span).toFixed(1)
const sign = inlineSign(doc)
const x = (sign * progress * span).toFixed(1)
 
// tests/unit/motion-primitives.test.mjs
assert.equal(railTravel(4000, 1440, false), -2560)
assert.equal(railTravel(4000, 1440, true), 2560)

The same defect shipped twice in the same shape. The home page's journey rail is padded with padding-inline: 75vw, so its track also overflowed correctly to the left, and its tween also hard-coded a negative travel — dragging an already-left-overflowing track out of its own clip until all four chapter cards were simply gone. Measured after the fix: the track drifts +1,199 pixels on the Arabic home page and −1,199 on the English one, and a unit test asserts that the magnitude is identical and only the sign differs.

This is also where a CSS-flipping build step runs out of reach. RTLCSS mirrors direction-sensitive declarations, transforms included, and its own documentation is candid that some values cannot be mirrored without an author directive. It flips the built version of a CSS file — and the number that broke this rail did not exist at build time. It was computed per frame and written into a custom property, which is a place no stylesheet transform can see. The touch drag added to the same rail this week takes its direction from that identical helper, so a finger moving right advances the Arabic set rather than reversing it.

The rail this happened on is still the way into the ten builds — the projects index, now reachable in three languages.

legacy-forensics/a10-css.md §AA

Why does a counter read backwards when nothing about it is Arabic?

The clearest teaching set for this one was measured on the OLD abbod.de — the pre-rebuild site, the Golden Master this project is compared against — and it is a list of ordinary Latin values rendering backwards on the Arabic pages. A card index, a hex colour, a social handle, a size figure and the loader's own wordmark. None of them contains a single Arabic character.

The forensics file that recorded them had already split the family into the two mechanisms it needs, and the distinction is the useful part. When the whole value sits in one text node, the Unicode bidirectional algorithm is resolving it against the surrounding paragraph and the fix is a bidi declaration on the element. When the value has been split across inline boxes — a separator span, a per-letter wrapper — the algorithm cannot help at all, because each box is its own run and there is nothing left for it to reorder. That case needs the base direction set: direction: ltr.

UAX number 9, revision 51, says so in its own scope. It resolves a paragraph level, explicit embedding levels, weak types, neutrals and implicit levels, and it does all of that within each paragraph of characters. How inline boxes or elements are ordered is outside it. That ordering is the flexbox specification's job, and section 5.1 is unambiguous: a row flex container's main axis has the same orientation as the inline axis of the current writing mode. Three flex items reading 01, a slash and 04 are therefore laid out in reverse on an Arabic document, and every card on this site's journey section claimed to be the fourth of one.

Measured on the OLD abbod.de, not on what ships today. Each value is one thing to a reader and several boxes to the layout engine.
Authored Rendered on the old Arabic page What it needed
01 / 04 04 / 01 direction: ltr — split across boxes
LoadAbbod dobbAdaoL direction: ltr — one box per letter
#C4552D C4552D# unicode-bidi — one text node
@alaaabbod alaaabbod@ unicode-bidi — one text node
6 KB JS KB JS 6 unicode-bidi — one text node

Two more were recorded and are awkward to print in an article: a phone number that reversed into nonsense, and a markup sample whose closing tag swapped its angle brackets so the code shown to a reader would not have parsed. Both are the one-text-node case, and both are why this site now asserts on every Arabic route that anything inside a code or pre element computes to direction: ltr.

The counter reverses because of the specification and not because of a browser bug, which is easy to say and worth reproducing. In isolated headless Chromium under an rtl root, an inline-flex counter containing 01, a separator span and 10 renders visually as 10 then the slash then 01, with fragment rectangles at left 748, 768 and 776. The same markup with direction: ltr and unicode-bidi: isolate renders in the order it was authored, at the same three positions.

23 sources · 0 chars on /ar/ · 201 on /

Why does splitting text spell Arabic words wrong?

A character roll needs each character to be a box it can move. CSS Transforms Level 1 defines a transformable element as everything governed by the CSS box model except non-replaced inline boxes, table-column boxes and table-column-group boxes — so a bare inline span per letter is not one. Measured here as a tween that should have travelled −70.00 pixels and travelled 0.00. Making each generated character inline-block is not a style choice; without it the effect does nothing.

An atomic inline box per letter is a box the shaper cannot join across, and a joined script is one where a letter's shape depends on its neighbours. Every Arabic word carrying the split attribute on this site rendered as a row of isolated forms — 23 sources, including all four menu links and every footer link. The word for get in touch came out as five separate letters that spell nothing. It is not a subtle degradation: it is a misspelling, on every navigational label the site has.

There is no styling fix, and that is the whole point of the section. Setting the characters back to inline restores the joins and kills the transform; leaving them inline-block keeps the transform and kills the joins. The two requirements are mutually exclusive by specification, and CSS Text 3 section 7.3 lists the conditions under which shaping must break at an inline box boundary — non-zero inline margin, border or padding, a non-initial vertical-align, a bidi isolation boundary. GreenSock says the same about its own tool in plainer words: SplitText was not designed for right-to-left languages, and no fix is planned.

  1. The effect asks for a box per character A per-letter roll is a transform, and a transform does not apply to a non-replaced inline element. The generated characters have to be inline-block for the animation to move anything at all.
  2. The splitter obliges, at grapheme granularity SplitText 3.13 segments with Intl.Segmenter at grapheme granularity and wraps every grapheme in its own span. That is correct behaviour for the unit it was asked for.
  3. The shaper stops at the boundary Each of those spans is now an atomic inline box, and the joins that make an Arabic word a word cannot cross one. The letters come apart, in the order they were authored, spelling nothing.
  4. The bidi algorithm cannot rescue it It orders characters within a paragraph, not boxes within a line. On a Latin word split the same way the result is the mirror image of the same problem: the letters reverse, because each one is its own run.
  5. So the split changes its unit A request for words and characters becomes a request for words; a request for lines, words and characters becomes lines and words; and a source that asked only for characters falls back to words rather than splitting into nothing.
  6. And it keys on the language, not the direction Hebrew is right-to-left and does not join, so a direction test would break Hebrew for a reason that is not true of it. The check reads the language subtag, and is unit-tested with a Hebrew document in an rtl root returning false.
The chain, one link at a time. تواصل rendered as ت و ا ص ل on 23 sources, and the only place to break the chain is the last step.

The result is asserted in both directions rather than in one, which matters more than it sounds. The gate checks that the Arabic document has zero character nodes and that the English one has 201 of them, and separately that the Arabic document still has words and lines to animate — because a check that only asserted zero characters would pass just as happily on a page where nothing split at all.

A second defect fell out of the same fix, and it had been invisible for a reason worth recording. The roll's travel distance was written as two literals that happen to be correct for the Latin labels: those are line-height 1 with a shadow offset equal to the font size, so minus one hundred per cent of a character box and one em are the same pixel. On the Arabic labels, set at 1.55, they stop being the same pixel — the incoming word landed 0.45 em below the outgoing one, which is the baseline shift the owner had reported on every Arabic link on the site. The fix reads the twin's own resolved offset instead of assuming it.

The splitting half of this has its own piece, including what an Arabic reader perceives as the unit worth animating — splitting text in a joined script.

derived from Zain hhea · size-adjust 112%

How does a clip window turn one Arabic word into another?

The label roll on this site is three declarations in three places: a line-height in the locale sheet, a clip-path in the same sheet, and a text-shadow offset in the button component. Together they are one number. The clip window is the visible slot; the shadow is the twin copy of the word waiting below it; the line box decides where both of them sit. Change one without the other two and the effect breaks in a way that looks like a font problem.

With the Latin geometry applied to an Arabic label — line-height 1, a window cutting at 94 per cent of the box, a twin one em down — the cut lands 0.153 em below the baseline. A final yeh in the shipped face descends 0.5096 em. So 0.357 em of ink is removed, the two dots under the letter go with it, and the word renders as a different letter entirely. The Golden Master measured exactly this on its own footer link: the site owner's own word for write to me, printed as a misspelling of itself.

Opening the line box alone does not fix it and introduces a second defect. At 1.55 with the window still at 94 per cent the cut sits 0.395 em below the baseline and the yeh is still clipped by 0.115 em — and the twin's ink now begins 0.845 em from the top, inside a window that reaches 1.457 em, so a ghost row of the next word appears under every label. All three numbers have to move together, and the order is the invariant: twin below window, window below ink.

  • 0.153 em Latin cut, below the baseline line-height 1, window 94 per cent, twin 1em
  • 0.5096 em descent of a final ي the deepest ink in the shipped face
  • 0.357 em ink removed by the window راسلني printed as راسلنى
  • 0.705 em shipped cut, below the baseline line-height 1.55, window 114 per cent, twin 2em
Derived from the shipped face's own hhea metrics — ascent 869, descent 459, 800 units per em — at size-adjust 112 per cent. The stylesheet states the last of these figures itself.

The shipped state clears the yeh by 0.196 em, puts the window bottom at 1.767 em and the twin's ink top at 1.845 em, hiding it by 0.078 em. Seventy-eight thousandths of an em is the entire margin between a working effect and a visible ghost row, which is the argument for deriving the three numbers rather than nudging them.

The last turn on this one is about scope rather than geometry. The fix was written for the Arabic document, and the defect was still live on the English and German ones — on the single Arabic word those documents contain, the language switcher's own label, which prints on every page so a reader who cannot read the current one still recognises their own. That word inherited a Latin family with no Arabic in it, body tracking, a one-em line box and a 94 per cent window. A Latin document is not a document with no Arabic in it, and the rule now keys on the language of the run rather than on the direction of the page.

The line-height that opens that window is itself a measurement rather than a ratio — computing the real floor.

And the size-adjust that scales all of it comes from a tooth and an alef, because x-height means nothing here: sizing an Arabic face against a Latin one.

src/styles/components/site-nav.css:68-108

Why does the header keep its Latin arrangement on the Arabic page?

Nobody wrote a right-to-left rule for the header. It mirrored anyway, out of one direction attribute on the root element and one justify-content: space-between, and it kept mirroring through seven nested containers that had never been asked about it. The defect here was created by not writing CSS, which is the failure mode nobody audits for.

What that flip cost was not visual. Sequential focus order is DOM order and does not follow direction at all, so a keyboard visitor on the Arabic pages tabbed left to right through a bar that painted right to left. WCAG 2.4.3 asks that focus order preserve meaning and operability, not that it match visual order, so this is not by itself a conformance failure — the site's reason for pinning the bar is an owner decision that the bar is a brand object, and the DOM and visual orders agreeing again is a real benefit that follows from that decision rather than the justification for it.

The fix turns the cause off rather than the effects. One direction: ltr on the bar and its menu wrapper beats seven row-reverse declarations, and the eighth container somebody adds next year is right by default instead of wrong by omission. Direction is the one property every implicit flip below it reads: the flex main axis, text-align: start, every logical property and inline box order. Then the Arabic runs inside it are restored individually, with unicode-bidi: isolate so that a run cannot reorder its neighbours in the left-to-right box that now contains it.

Turn the cause off, then restore the runs. Both halves are needed: the first alone would leave Arabic text resolving against a left-to-right base.
/* src/styles/components/site-nav.css */
/* one cause, not seven effects */
html[dir='rtl'] .nav,
html[dir='rtl'] .nav-menu-w {
  direction: ltr;
}
 
/* and the Arabic inside it is still Arabic */
html[dir='rtl'] :is(.nav, .nav-menu-w)
  :is(.text-nav-link, .text-eyebrow, .nav-lang-label, .btn-text.is-lang-ar) {
  direction: rtl;
  unicode-bidi: isolate;
}

The same device pins the code-and-design diptych on the home page, for a different reason. Its backdrop portraits are positioned physically and were never mirrored, so under the implicit flip each column's argument sat in front of the wrong face — and the alternative text said so out loud. Pinning the layout is what puts them back; the motion layer needed a matching opt-out attribute on six elements so the two line wipes keep facing each other across the centre gap.

One thing to know before reaching for :dir() to write rules like these. It has been widely available since December 2023, and it reads only the semantic direction declared in the document. It does not account for the CSS direction property, so a container that has just opted out with direction: ltr is still, to :dir(), a right-to-left element. Attribute selectors on the root are doing real work here that the pseudo-class cannot.

Letter-spacing belongs in the same section because it is the same kind of decision, and because it is normative rather than stylistic. CSS Text 3 section 7.2.1 says a user agent that cannot expand cursive text without breaking its joins must not apply spacing between any pair of that script's letters at all, and the specification's own illustration labels evenly distributed tracking on Arabic as bad, with the note that it breaks the cursive joins. The reason a site still needs an explicit override is that the tracking is declared on the elements — eyebrows, buttons, nav links, display type — rather than inherited, which is what makes this the one blanket !important the project considers justified.

noise-floor.json · measured 2026-08-19

Why can't a screenshot police any of this?

This project's main visual gate is a pixel comparison against the Golden Master, and it has no locale axis at all. The viewport manifest declares none, the scenario library has no locale logic, the capture step reads a locale field from a scenario and no scenario sets one. The gate documentation says so itself, in a table headed with what this cannot prove: cross-locale layout, including right-to-left, is listed as uncovered, with the note that the root element's direction is compared exactly but has only ever been observed as ltr.

Where it does run, it is close to perfect, and that is the trap. Measured on 19 checkpoints on 2026-08-19, the structural pixel difference ratio was zero at every percentile and 18 of the 19 checkpoints were byte-identical across two runs of the same reference; the one exception is a marquee captured live on purpose. The threshold is set from that measurement at 0.0001, because a threshold is set by what the instrument can prove. Then the Arabic typeface changed from two families to one, at the owner's instruction, and every Arabic pixel on the site differed by construction. An instrument that exact does not degrade gracefully. It goes from proof to a wall of red in one commit.

The accessibility scanner cannot cover the gap either, and it is worth being specific about why, because it is the substitution most teams assume. axe-core 4.11.0 ships 104 rules. Filtering all 104 by rule identifier, description and help text for direction, mirroring, bidi or layout order returns six, of which four validate the presence and syntax of the lang attribute and two are false matches on the word structured. This site runs it over every route at two viewports — 144 scans on 2026-08-24, five violations — and that sweep coexisted with all seven of the defects named here.

  • 18 of 19 checkpoints byte-identical two runs of the same reference, 2026-08-19
  • 0.0001 the pixel diff threshold set by what the instrument can prove
  • 104 rules in axe-core 4.11.0 144 scans across the site on 2026-08-24
  • 0 of those rules about direction none about mirroring, bidi or layout order
The two instruments that were already in place, and what each one could and could not see. Both figures on the right are from a single dated run; the house rule is that a number without its date is a claim.

The precedent for what to do about it was already in the repository. The canvas comparison axis is marked as blocked and not enforced, because its measured noise floor is enormous — a 95th-percentile difference ratio of 0.025829 over 89 samples — and the recorded reasoning is that a gate which cannot distinguish a regression from noise is worse than no gate, since it teaches people to ignore it. A comparison whose baseline has been deliberately invalidated is the same failure with a different cause, which is why the answer here was a new instrument rather than a relaxed threshold or a permanent excuse row.

How that noise floor was measured, and why one axis is enforced and another is not, is the noise floor piece.

The accessibility sweep has its own story about conditions — 707 contrast nodes became five, and publishing five is the point — in the contrast false-positive piece.

37 checks · 4 viewports · 19 Arabic routes

What a property assertion asserts

What replaced the screenshot is a set of properties, one per defect. The gate makes 37 assertions: eight per viewport at 390, 991, 992 and 1440 pixels wide, plus five paired checks that run the Arabic and English documents against each other. Each of the eight was a shipped defect before it was a check, which is the only entry criterion the file recognises. Every figure quoted here is read from that source rather than from a run — the gate needs two local servers and neither was started for this article.

Its route list is read rather than written. The Arabic routes come from the published route manifest, filtered on locale, and the file throws if that filter returns nothing. That decision paid a dividend this week: the manifest went from 72 documents and 20 Arabic routes to 69 and 19 when a project page was retired, and the gate absorbed the deletion with no edit, because the eight per-viewport checks aggregate over whatever routes the manifest names.

The single most valuable of the 37 is a subtraction. On every Arabic route it takes the document element's scrollWidth, subtracts the window's innerWidth, and tolerates one pixel. That one line catches every unmirrored physical offset, every hard-capped box that Arabic overflows and a rail travelling the wrong way, all at once — and it is what turns audit the whole site in Arabic from a reading task into a measurement.

typography rules in the locale sheet 16 blocks
mirror rules in section sheets 7 blocks
JavaScript modules owning a direction 6 modules
mirror rules in component sheets 3 blocks
language rules outside the locale sheet 3 blocks
mirror rule in the locale sheet 1 block
Where a direction or language decision is actually made in this codebase, counted as rule blocks and as modules.Bars are to scale against the 16 typography rules in the locale sheet. For scale: the 78 inline-axis logical declarations needed no decision at all.

The rest of the eight are deliberately boring, and the boring ones are the ones that would catch a build regression. Every Arabic route must declare itself right-to-left and Arabic on the root element. Nothing may cross an inline edge without a clipping ancestor. Every code or pre element must compute to direction: ltr, because a snippet that inherits the paragraph's direction reorders its own punctuation and stops being valid markup. And across all 19 routes there must be zero console errors, zero failed requests and no response of 400 or worse.

One check states its own limit, which is the habit worth copying. It walks every text node in the body and asserts that every Arabic run asks for the Arabic family first and that every Latin run keeps a Latin or monospace fallback somewhere in its stack. It cannot assert more than that: a Latin-only run inside an Arabic document may legitimately ask for the Arabic family first, because a unicode-range descriptor is what routes it onward — and a computed style cannot see whether that routing fired. What the check can prove is that no run asks for a family with nothing behind it.

That routing is a story in itself, because the family names promised something the font files never delivered — when an Arabic family captures your Latin.

src/styles/sections/projects-index.css:841-853

A check only asserts the properties somebody thought of

The reversed-run check generalises correctly. Its stated rule is that an element which is one composite value must lay its parts out in the order they were authored, and the discriminator it uses for composite is whitespace: a card index and a total are one value whose parts happen to be three elements, while a project name beside its number is a layout row that should mirror. That distinction was learned the honest way — the first run reported six false positives on one route and would have argued for un-mirroring a row that was correct.

The implementation collects an element's children, drops the out-of-flow ones and skips anything with fewer than two left. The counter on the projects rail cards is an inline-flex containing the text 01, a separator span and the text 10 — three flex items of which exactly one is an element. The check walks past it. Its parent is skipped as well, by the whitespace rule, because the chips beside it contain spaces. So the defect the check was written from is still on the page it was written on, ten cards over, and the phase handoff that says both counters were fixed is wrong about the second one.

Precision about status, because this is a finding and not a report. The absence is static and checkable: that stylesheet contains no direction declaration at all, at the last commit and in the working tree. The reversal mechanism is reproduced exactly in isolated headless Chromium. It has not been observed on the running Arabic projects page in this session — a gate run would confirm it there. The same file also uses a physical margin-left: auto where the fixed counter uses margin-inline-start: auto, and in a 400-pixel right-to-left row spanning 398 to 800 that difference lands the pill at 724 instead of 399, jammed against the chips instead of at the far end.

The generalisation was right
One composite value, laid out in the order it was authored. That covers a counter, a hex colour, a phone number and a code snippet in one sentence.
The implementation was narrower
It compares element children, because the two instances it was written from — a code snippet and the journey counter — both had them.
The markup was anonymous
Two of the projects counter's three flex items are bare text with no element around them, so the browser makes anonymous items the DOM cannot enumerate.
The parent was excluded on purpose
The whitespace discriminator that stops the check un-mirroring genuine layout rows also stops it from reaching down into this one.
Four things had to be true at once for a check to miss the defect it was written for. Only the last one is visible to a reader.

The practical shape of all of this is short. Use logical properties everywhere they reach, then make a written list of what they do not reach in your codebase — transforms, gradient angles, transform-origin, shadows, the order of a composite value, focus order, shaping, font metrics — and check that list by hand, once, on the Arabic pages. Turn every defect you find into an assertion about a property rather than about pixels. Then treat over-mirroring as a defect too: this site's project deck deliberately leaves one z-index formula unmirrored, because its input is already logical, and mirroring it a second time hid nine of the ten badges.

If you are planning a build in more than one script and would rather settle the direction decisions before the layout than after it, that is a short conversation worth having early.

Questions

Do CSS logical properties handle right-to-left on their own?

For the layer they cover, very nearly — this site writes 78 inline-axis logical declarations across 15,142 lines of CSS and needs eleven explicit mirror rule blocks. MDN's own reference then lists the features that have no logical equivalent and are not getting one: transform, transform-origin, background-position, box-shadow, text-shadow, linear-gradient angles, radial-gradient position and filter, because they operate in a rendered coordinate space rather than in document flow. Logical transforms have been an open CSS working-group issue since October 2018 and are still unresolved.

Why did the projects rail break in Arabic when its CSS was already logical?

Because the stylesheet and the script disagreed about who owned the inline axis. padding-inline and inset: 0 correctly made the track overflow to the left on an Arabic document, and then a scrub callback translated it further left, because translate3d reads the X axis, which the Transforms specification defines as increasing to the right regardless of direction. Measured: the track's offset property reached −2,531 and zero of the ten cards were in the viewport, at 1440 and at 390 alike. The fix is a signed helper applied at the one place the number becomes a transform.

Should you test on direction or on language?

On whichever one the question is actually about, because they are different questions. Direction decides which way a transform travels and which inline edge counts as behind you. Joining is a property of the script: Hebrew is right-to-left and does not join, so keying the no-characters rule on direction would break Hebrew for a reason that is not true of it. Typography is a language question too — nineteen of this site's thirty direction-or-locale rule blocks are scoped on the Arabic language, and not one of them is a mirror.

Does an accessibility scanner catch right-to-left problems?

No. axe-core 4.11.0 ships 104 rules, and filtering all of them for direction, mirroring, bidi or layout order returns four that only validate the presence and syntax of the lang attribute, plus two false matches on the word structured. This site runs axe over every route at two viewports — 144 scans on 2026-08-24 — and it cannot see a reversed counter, a rail travelling the wrong way, a clipped descender or a keyboard order that disagrees with the visual one. It is a necessary check that answers a different question.

What is the difference between direction: ltr and unicode-bidi: isolate?

direction sets the base direction, which decides flex item order, text-align: start, every logical property and inline box order inside the element. unicode-bidi: isolate makes the element behave, to its container, like a single object-replacement character, so its content cannot reorder its neighbours. You need direction when a Latin value has been split across inline boxes, because the bidi algorithm cannot help there. You need isolate when you have just put a run of one direction inside a box of the other, and neutral characters at the boundary would otherwise resolve against the wrong side.

Sources

Measured in this repository

  • tools/golden/rtl.mjs The 37 property assertions, the four viewports, the manifest-derived route list, and the two narrowings the reversed-run check needed after six false positives.
  • src/runtime/core/env/direction.js Sixty-four lines with no imports, exposing isRtl, the MIRROR map and inlineSign — the three facts about reading order the runtime is allowed to know.
  • src/styles/locale/arabic.css The clip-window geometry, the tracking blanket, the unicode-range faces, and the one rule in the file deliberately not scoped to the Arabic document.
  • src/styles/components/site-nav.css The header un-mirror and its paired bidi restore, with the reasoning for turning one cause off rather than seven effects.
  • src/styles/sections/projects-index.css The deck that mirrors by negating one custom property, the z-index formula deliberately left alone, and the counter that never got its rule.

Checked against

Alaa Abbod

Written by

Alaa Abbod

Creative Developer — Herne, Germany

Designer and developer who builds accessible websites, mobile apps, online stores and visual identities as one job, by hand. This site is published in English, German and Arabic from one source, which is where most of these questions came from.

Please rotate your device,
This is a vertical build.