The full stop that moved to the wrong end of the sentence
Switch the UI to Arabic and English text renders as ".Photosynthesis is". The text isn't reversed. Only the neutral characters move, and no CSS can fix it.
By Meet ModiPunctuation lands on the wrong side in a right-to-left layout because the Unicode bidirectional algorithm resolves neutral characters against the paragraph direction, not against the words next to them. Setting dir="rtl" on the document makes RTL the paragraph direction for every string inside it, including English ones. The letters stay in order. The trailing full stop, question mark or colon jumps to the visual left.
I found this the way everyone finds it. A QA report with two screenshots and the words "text is broken in Arabic".
What the bug actually looked like
We had just shipped Arabic across a learning app. The language core sets dir on the html element, which is correct and is what every RTL guide tells you to do. Then the bug reports came in for content that was still in English, because content language and UI language are independent: a student can read an English course in an Arabic app.
What the backend sent:
"Which aspect of a JEE Crash Course interests you?"
"Targeted conceptual clarity sessions."
What the screen showed:
"?Which aspect of a JEE Crash Course interests you"
".Targeted conceptual clarity sessions"Read those carefully. Every word is in the right place. Every letter inside every word is in the right place. One character moved.
Why the question mark moves and the letters don't
Unicode sorts characters into three bidi classes. Strong left-to-right characters, like Latin letters. Strong right-to-left characters, like Arabic and Hebrew letters. And neutrals, which have no direction of their own: spaces, digits in some contexts, and punctuation.
A strong character always renders in its own direction. That is why the English words stayed readable. A neutral character has no opinion, so the algorithm resolves it from its surroundings, and a neutral at the very end of a string has only one surrounding left: the paragraph direction. The paragraph direction was RTL. So the full stop went to the RTL end of the line, which is the visual left.
Why this is not the logical-properties problem
We were in the middle of a physical-to-logical CSS sweep at the time, replacing ml-4 with ms-4 and padding-left with padding-inline-start across four packages. That work is real and necessary and it is a different problem. Logical properties mirror boxes. They decide which side a margin sits on. They have nothing to say about which end of a string a full stop lands on, because that decision happens inside the text engine after layout has handed it a box.
Confusing the two costs you a day. A mirrored layout with correctly placed punctuation and a correct layout with punctuation on the wrong end look equally broken in a screenshot, and they have no fix in common.
The fix: dir="auto" at the content boundary
dir="auto" tells the browser to resolve direction from the first strong character in the string. An English sentence starts with a Latin letter, so it renders as a left-to-right paragraph and its trailing punctuation resolves to the right. An Arabic sentence starts with an Arabic letter and renders right-to-left. Each string gets the direction it deserves, whatever shell it sits in.
// Before: inherits the UI's paragraph direction, so neutrals follow the UI.
<span className="question-stem">{question.text}</span>
// After: the string's own first strong character decides.
<span className="question-stem" dir="auto">{question.text}</span>The important part is where you apply it. Not globally, and not on UI chrome. Only at the places where backend or model content enters the DOM. For us that was four: the two math-and-markdown text renderers that every quiz and card surface goes through, the plain-span branches in the quiz renderers that skip markdown entirely, and the chat answer renderer, where the language is whatever the student asked in.
UI chrome is deliberately left alone. A button label should follow the interface language, because it belongs to the interface. Content should follow its own.
Does it regress the cases that already worked?
No, and it is worth checking all four combinations rather than assuming, because this is the kind of fix that quietly trades one bug for another.
- English content in an RTL shell: was broken, now fixed.
- Arabic content in an RTL shell: first strong character is Arabic, resolves RTL, unchanged.
- Arabic content in an LTR shell: had the same bug mirrored, now fixed too.
- English content in an LTR shell: resolves LTR, unchanged.
The part I got wrong
This fix was correct for the string and wrong for the box it sat in, and it created a second bug I had to go back and fix a week later: resolving a text node's own direction also changes what text-align: start means for that node. On every multiple-choice option, the letter badge and the option text flew to opposite ends of the row. I wrote that one up separately in the row that split in half.
Two lessons, and the second one is the one I keep relearning. Bidi problems are text-engine problems, so reach for dir and bdi before you reach for CSS. And a fix scoped to a string is not automatically scoped to its layout.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.