Skip to content

fix(rtl): isolate Hebrew course names from the LTR text around them - #143

Merged
itabajah merged 1 commit into
mainfrom
fix/rtl-bidi-isolation
Jul 13, 2026
Merged

fix(rtl): isolate Hebrew course names from the LTR text around them#143
itabajah merged 1 commit into
mainfrom
fix/rtl-bidi-isolation

Conversation

@itabajah

Copy link
Copy Markdown
Owner

Fixes #141.

What was wrong

Course names come from the Technion catalog and are usually Hebrew (RTL), but the UI around them is LTR English (<html lang="en">, no dir). When a Hebrew name is interpolated into app text, the Unicode Bidirectional Algorithm resolves the whole line as one paragraph and reorders across the boundary.

Digits are a weak bidi type: a number that follows Hebrew inherits the RTL run and gets carried to the far side of it — stranding whatever LTR text came after. In the homework sidebar (<hebrew> = a Hebrew course name):

logical    <hebrew> · 8d left
rendered   8 · <hebrew> d left        <-- the "8" is torn off "d left"

That is exactly the issue report. Auditing for the same pattern turned up a second, quieter instance in the same view — a class block's tooltip, where the range is split across the boundary and comes back reversed:

logical    <hebrew> 10:00-12:00
rendered   12:00-10:00 <hebrew>       <-- a 10:00-12:00 class reads as 12:00-10:00

The fix

Isolation: the name is resolved on its own and enters the surrounding paragraph as a single neutral object, so nothing can be reordered across it.

  • In JSX — render the value inside a <bdi>, the HTML element whose entire purpose is this. The UA stylesheet gives it unicode-bidi: isolate + dir="auto" (Tailwind's preflight leaves both alone), so the Hebrew still reads RTL inside its own run without flipping the LTR line around it. No layout or alignment change.
  • In plain text — a title tooltip can't hold markup, so isolate() (new src/lib/bidi.ts) wraps the value in the equivalent U+2068 FSI / U+2069 PDI controls.

The subtitle keeps its existing order (course name, then badge), which matches the rest of the LTR chrome — the bug was purely the scrambling, not the ordering.

CONTRIBUTING.md gains a ground rule so this doesn't get reintroduced.

Testing

pnpm verify green (961 unit tests, bidi.ts at 100%); full Playwright suite green (11 tests).

The important part: jsdom has no layout engine and cannot observe bidi reordering, so unit tests can only assert the DOM/attribute contract. The guard that actually catches a regression is e2e/rtl-bidi.spec.ts, which measures the badge's client rects in Chromium — an inline box torn apart by bidi yields one rect per fragment:

badge.getClientRects().length digit position
before 2 (fragmented) left of the course name
after 1 (one unbroken run) after the course name

I verified the test fails on the unfixed markup (it reports Expected: 1, Received: 2) rather than only passing on the fixed one, and confirmed both renderings visually in a real browser.

Deliberately not changed

  • aria-labels — assistive tech consumes them in logical order and never runs the bidi algorithm, so isolate controls would be noise. Asserted in the tests.
  • ExamRoadmap's Next: <name> · in 3 days and the hidden-exam tray use the same {userText} · {appText} composition, but render correctly today: the countdown always begins with a letter ("Today" / "Tomorrow" / "in N days"), never a digit, so there is no weak type to drag across the name. I left them alone rather than churn code I can't write a failing test for; the new CONTRIBUTING rule covers them if those strings ever change shape.
  • Block-level dir="auto" on course/assignment titles would right-align Hebrew headings. That's a design decision, not a bug fix, so it's out of scope here.

Course names come from the Technion catalog and are usually Hebrew (RTL), but
the UI around them is LTR English. Interpolated unisolated, the Unicode
Bidirectional Algorithm resolves the whole line as one paragraph and reorders
across the boundary: digits are a *weak* type, so a number that follows Hebrew
joins the RTL run and is carried to the far side of it, stranding the LTR text
that came after it.

Two places got this wrong:

1. The homework sidebar subtitle rendered "8 · <hebrew> d left" instead of
   "<hebrew> · 8d left" — the badge's leading digit torn off "d left" and flung
   to the other side of the course name. This is the reported bug (#141).

2. A class block's tooltip silently REVERSED its time range: a 10:00–12:00
   class next to a Hebrew name showed "12:00–10:00", because the range was
   split across the boundary and reordered.

The fix is isolation, so the name resolves on its own and enters the surrounding
paragraph as a single neutral object. In JSX that is a <bdi> element; for a
plain-text `title` there is no markup, so isolate() from the new src/lib/bidi.ts
wraps it in the equivalent U+2068/U+2069 controls. aria-labels are deliberately
left bare — assistive tech reads them logically and never bidi-reorders.

jsdom has no layout engine and cannot observe reordering, so the guard that
actually catches a regression here is e2e/rtl-bidi.spec.ts: it measures the
badge's client rects in Chromium. Unisolated the badge fragments into two boxes
with its digit sitting left of the name; isolated it is one unbroken run to the
right of it. The unit tests cover the DOM/attribute contract underneath.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@itabajah
itabajah merged commit dc789e1 into main Jul 13, 2026
2 checks passed
@itabajah
itabajah deleted the fix/rtl-bidi-isolation branch July 13, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UI Bug: RTL/LTR text formatting issue with "days left" and Hebrew course names in Weekly Schedule

1 participant