fix(rtl): isolate Hebrew course names from the LTR text around them - #143
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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">, nodir). 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):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:
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.
<bdi>, the HTML element whose entire purpose is this. The UA stylesheet gives itunicode-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.titletooltip can't hold markup, soisolate()(newsrc/lib/bidi.ts) wraps the value in the equivalentU+2068 FSI/U+2069 PDIcontrols.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.mdgains a ground rule so this doesn't get reintroduced.Testing
pnpm verifygreen (961 unit tests,bidi.tsat 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().lengthI 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'sNext: <name> · in 3 daysand 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.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.