docs/PHASE10_HANDOFF.md:24-46
What actually breaks when you split Arabic into characters?
The Arabic homepage shipped with every Arabic word spelled wrong. Each word carrying a character split rendered as a row of isolated letters with gaps between them, which in a joined script is not a styling flaw but a spelling error — the site's own five-letter word for get in touch came out as five separate shapes. It was live for weeks, and it was reported by the site's owner reading his own pages rather than caught by a gate.
Twenty-two of the 47 split sources in this markup ask for characters, and they are the ones a visitor meets first: all four menu links, every footer link, and five of the home sections. The Phase-10 handoff recorded 23 chars-asking sources and 201 character nodes on the English homepage at the time it was written; today's markup measures 22 sources, and the golden run of 2026-08-24 measures 187 nodes. Both pairs are true of their own date, which is why both carry one.
Two different failures travel together under the same search query, and separating them is most of the work. One is ordering — a Latin word inside the Arabic document rendering backwards, which has a three-line CSS fix. The other is shaping — an Arabic word losing its joins, which has none. Almost every answer online treats them as a single problem and prescribes the fix for the first.
ArabicShaping-17.0.0.txt · 834 entries
Why is this a joining problem and not a right-to-left problem?
An Arabic letter has one code point and up to four shapes. Unicode 17 puts it plainly: each letter receives only one character value in the basic Arabic block no matter how many contextual appearances it exhibits, and a display process must select the glyph according to the letter's immediate joining context. The context is the neighbours. Take the neighbours away and there is no context left to select from, so the shaper falls back to the isolated form every time.
Which letters join, and on which side, is published data rather than folklore. The 17.0.0 edition of ArabicShaping.txt, dated 2025-08-14, carries 834 entries, of which 615 are dual-joining and 153 right-joining; the remainder are left-joining, join-causing, non-joining or transparent. Parsing it locally on 2026-08-26 gives a per-letter answer for any string you ship, and the table below gives it for the word that started this.
Hebrew is the control that settles the argument. Hebrew appears nowhere in ArabicShaping.txt, and the file states that every code point it does not list is non-joining by default — so a Hebrew letter's shape does not depend on its neighbours, and a per-letter box takes nothing away from it. Hebrew runs right to left. It does not break. Direction is not the property that matters.
| Letter | Code point | Joining type | Shapes |
|---|---|---|---|
| ت | U+062A | D — Dual_Joining | 4 |
| و | U+0648 | R — Right_Joining | 2 |
| ا | U+0627 | R — Right_Joining | 2 |
| ص | U+0635 | D — Dual_Joining | 4 |
| ل | U+0644 | D — Dual_Joining | 4 |
| ש | U+05E9 | U — absent from the file | 1 |
The distinction is executable here rather than rhetorical. The predicate that decides whether a document may be split into characters reads the language, not the direction, and the unit test asserts that a Hebrew document declared right-to-left returns false alongside ar, ar-SY and AR all returning true. Keying on direction would strip characters from Hebrew for a reason that is not true of Hebrew, and would miss any joined script set left to right.
CSS Transforms L1 · CR 2019-02-14
Why can't CSS fix it?
The effect has to move each piece, and the only cheap way to move ink is transform. CSS Transforms Level 1 defines a transformable element as everything whose layout is governed by the CSS box model except non-replaced inline boxes, table-column boxes and table-column-group boxes. A character wrapped in a plain inline span is a non-replaced inline box, so the property is not applied at all — dropped on the floor rather than resolved to zero.
This repository has the measurement, because the browser lies about it. Chrome reports the matrix from getComputedStyle whether or not it was ever honoured, and for three development phases every character on the page carried matrix(1, 0, 0, 1, 0, -70) while not one of them had moved a pixel. The only honest instrument was reading the character's bounding rectangle before and after a hover, at a fixed viewport width.
| Rolled host | legacy | rebuild | with the rules |
|---|---|---|---|
| the button label | -24.00 | 0.00 | -20.00 |
| the menu link | -70.00 | 0.00 | -70.00 |
| the text link | -15.00 | 0.00 | -15.00 |
So inline-block is mandatory for the effect, and inline-block is an atomic inline box — a formatting context of its own, and therefore a boundary. CSS Text Level 3 declines to promise anything across it: the rendering of a typographic character unit divided by an element boundary is undefined, and the specification forewarns authors that dividing grapheme clusters or ligatures by element boundaries may give inconsistent or undesired results. The two requirements are mutually exclusive by specification rather than by implementation.
It is worse than a straight choice between them, and this is the part usually left out. The W3C Arabic script gap analysis, updated 2026-03-30 and resting on an issue filed in February 2020, records that WebKit breaks cursive joining as soon as any markup surrounds a character — before any styling is applied at all. Gecko and Blink hold the joins for styling that does not change glyph shape, and all three fail when font-weight, font-style or font-size differ across the boundary. Plain per-letter markup guarantees nothing; inline-block guarantees the break.
fontTools 4.60.2 · zain/*.woff2
What exactly does the shaper lose in a run of one letter?
The loss is countable, so it is worth counting. All three shipped Zain cuts carry identical shaping tables — 610 glyphs and 590 cmap entries each, 161,096 bytes for the set — and their GSUB carries 39 initial-form substitutions, 39 medial, 83 final and 36 required ligatures. Every one of those 161 positional lookups is contextual: it fires only when the letter sits in a run with its neighbours.
A run of one letter satisfies none of them, and the required-ligature feature has nothing to ligate. That is the entire mechanism, and it explains why swapping the Arabic webfont never helps — the tables are present and correct in every competent Arabic face, and a per-letter box makes all of them inert. Subsetting makes it worse rather than better: the dots and marks are separate glyphs composed at render time, and a subsetter that drops what it believes unreachable drops them, which is why these files ship unsubsetted on purpose.
- 161 positional substitutions 39 initial, 39 medial, 83 final — every one contextual
- 36 required ligatures the lam-alef forms, with nothing left to ligate
- 610 glyphs per cut identical across regular, bold and extrabold
- 0 that can fire in a run of one the font is innocent; the box is the defect
Nothing here is specific to the faces this site ships. Run the same measurement over your own and the shape of the answer holds: a three-figure count of contextual lookups, none of which can fire on a run of length one, and a required-ligature feature that a split has already cut in half.
The rest of what these three cuts had to be measured for — the tooth, the alef and the size-adjust that pairs them with the Latin face — is sizing an Arabic typeface when x-height means nothing.
SplitText 3.15.0 · checked 2026-08-26
Has GSAP fixed this, and is a fix coming?
No, and the answer is checkable rather than a matter of belief. GreenSock's position is on the record in a forum reply from Rodrigo, one of its administrators, dated 11 August 2023: SplitText was not designed for right-to-left languages, described there as a limitation, with an open invitation to suggest improvements. That reply is the acknowledgment. The documentation is not — it still says only that SplitText does an excellent job with foreign characters too, and carries no joined-script caveat anywhere.
The 3.13 rewrite, published 29 April 2025 and on npm the following day, added aria handling, autoSplit, onSplit, deepSlice, mask and Intl.Segmenter-based segmentation, and mentioned right-to-left nowhere. Grepping the published source of 3.15.0 — npm-latest since 2026-04-13 — on 2026-08-26 for rtl, direction, arabic, cursive, shap, bidi or joining returns nothing, and the character split is still the same default-granularity call it was in 3.13.0.
// SplitText, unchanged from 3.13.0 through 3.15.0:
// _charSegmenter = new Intl.Segmenter()
const seg = new Intl.Segmenter()
seg.resolvedOptions().granularity
// 'grapheme' — the default, and the whole problem
function pieces(word) {
return [...seg.segment(word)].length
}
pieces('تواصل') // 5 — five isolated forms, one misspelt word
pieces('العربية') // 7
pieces('لا') // 2 — an obligatory ligature, cut in half
pieces('عبّود') // 4 — the shadda stays put: marks survive
// the platform already ships the answer, and no splitter asks for it
const byWord = new Intl.Segmenter('ar', { granularity: 'word' })
It would be unfair to call this a GSAP bug. Any library that wraps every grapheme in a box meets the identical wall, and so does a hand-written split over the characters of a string: the wall is CSS Transforms Level 1 plus CSS Text Level 3, not an implementation. The one library-level fix available is to segment at word granularity on a joined script — which the platform already ships, as the last line of the listing shows — and no popular splitter does it on its own.
Intl.Segmenter is a real improvement over a regular-expression split, and it is worth being precise about what it improved. It keeps combining marks attached to their letter, so a word carrying a shadda stays four segments rather than five. It cannot help with joining, because joining is not a segmentation question — it is a shaping question, decided after segmentation, by the neighbours segmentation has just put in other boxes.
split-registry.js:131-169 · 10 lines
The fix is a different unit, chosen once, in one place
Once it is accepted that the decision cannot live in CSS, the fix is small. A pure function takes the authored split types and the document's script, strips characters when the script joins, and floors the result at words — because a source that asked for characters only would otherwise split into nothing at all, and an effect that finds no pieces does not animate.
The browser fact is passed in rather than read inside, which makes the function a unit test's subject rather than the DOM's. The chain below is the argument it encodes, and each link in it is a citation from one of the sections above.
export const JOINED_SCRIPT_LANGS = Object.freeze(['ar'])
export function isJoinedScript({ lang = '' } = {}) {
const tag = String(lang).slice(0, 2).toLowerCase()
return JOINED_SCRIPT_LANGS.includes(tag)
}
function trimmed(type) { return type.trim() }
function notChars(type) { return type && type !== 'chars' }
export function splitTypesFor(raw, script = {}) {
const types = splitTypesFrom(raw)
if (!isJoinedScript(script)) return types
const kept = types.split(',').map(trimmed).filter(notChars)
// A chars-only source would otherwise split into nothing at all,
// and an effect that finds no pieces does not animate.
return (kept.length ? kept : ['words']).join(', ')
}
- The effect must move each letter Transform is the only cheap way to move ink on the compositor, so every generated piece has to be a transformable box.
- Transform skips inline boxes CSS Transforms Level 1 excludes non-replaced inline boxes, so every piece must be display inline-block. Measured here: -70.00 pixels became 0.00 without that rule.
- An atomic box is a boundary An inline-block establishes a formatting context of its own, so each letter becomes a shaping run exactly one letter long.
- The lookups go inert 161 positional substitutions and 36 required ligatures in the shipped font, none of which can fire on a run of length one.
- The word is spelled wrong Every letter falls back to its isolated form, and the reader sees a row of shapes where a word should be.
- So the decision moves up a layer Undoing step two restores step five and destroys step one. There is no styling fix, so the split itself stops asking for characters on a joined script.
Three effects consume the pieces and each degrades differently. The character roll and the scrubbed hero lanes fall back through a documented order — characters, then words, then lines, then nothing — so on the Arabic document they roll and scrub whole words. The ellipse wipe guards instead of falling back, so the ellipse still opens and the lines still travel, and only the per-character stagger is absent. The stylesheet agrees in one selector rather than two rules that could drift apart, promoting whichever unit is there.
The honest cost is grain. The English homepage carries 187 characters across 28 words, which is 6.68 pieces per word derived from site-wide totals rather than measured link by link, and the roll staggers 0.02 seconds per piece — so an eight-letter Latin label staggers over 0.14 seconds where a one-word Arabic label has a single piece and no stagger at all. The effect survives; its grain coarsens by roughly a factor of seven. Words are what an Arabic reader perceives as the unit anyway.
Changing the unit then exposed a second defect nobody had reported. The roll travelled minus one hundred per cent of a character box, while the Arabic sheet had already opened the label's line box to 1.55 and moved the twin the roll aims at down to 2em — so the incoming word landed 0.45em below the outgoing one, on every Arabic link on the site. The roll now reads the twin's offset out of the computed text-shadow instead of assuming a character box, and keeps the recorded literals only as a floor for elements that paint no twin.
That 1.55 is not a preference, it is a computed floor, and the arithmetic behind it is the measured Arabic line-height floor.
split-registry.js:181-189 · one read
Where does a per-document gate leak?
The gate reads the document's script once, from the root element, and never again. Fifty-five sources on the homepage would otherwise be fifty-five readings of one fact that the build wrote onto the document and that never changes afterwards — and fifty-five chances to read it differently. That is right for cost and right for consistency, and it makes the test a question about the document rather than about the run.
Which is a genuine limitation with consequences in both directions, and it is better stated than hidden.
- one read, not fifty-five
- The language and direction are written onto the document by the build and never change, so reading them per element would be fifty-five readings of one fact.
- over-broad, and safe
- On the Arabic document characters are stripped from all 22 chars-asking sources, the Latin runs among them included. Those runs lose a finer stagger and lose nothing else.
- narrow, and a real hole
- On the English and German documents the gate does nothing — so the one Arabic run those documents contain, the language switcher's own label, would still be split into characters.
- closed in the markup
- That label is authored to split into lines by hand, while its two Latin siblings still ask for words and characters. The markup reached the conclusion first, for the one label somebody noticed.
- the argument for the code
- A conclusion reached by hand, one label at a time, holds until the next label. Ten lines of pure function hold it for every label at once.
A per-run test is possible and this site does not need one. It would mean reading the language from the nearest ancestor that declares it, per source, and it would buy exactly one correct behaviour that a single hand-authored attribute already buys. When a second joined-script locale ships, or a page mixes scripts at length, the reading moves down; today it would be cost with no defect to point at.
That same lone Arabic run inside a Latin document is also what decides how the Arabic face is loaded — capturing one Arabic run with unicode-range.
arabic.css:328-341 · the legacy roster
The other half of the query: why did the Latin word render backwards?
The second failure is real, it is not Arabic's, and it has a fix. CSS Writing Modes Level 4 says that all atomic inline-level boxes other than replaced inline elements are treated as neutral characters always. A character split turns every letter into an atomic inline box, so a Latin word inside a right-to-left paragraph becomes a run of neutrals — and a run of neutrals takes the paragraph's direction. The bidirectional algorithm cannot rescue it, because each letter is now its own run and there is nothing left for the algorithm to keep together.
The legacy site measured a full roster of this shape, all of it on its own Arabic pages, all of it authored in Latin. The English and German documents showed each of these strings the right way round, which is what made the cause so easy to misread as an Arabic problem.
| As authored | As it rendered |
|---|---|
| LoadAbbod | dobbAdaoL |
| 01 / 04 | 04 / 01 |
| +49 155 67042737 | 6704273715549+ |
| #C4552D | C4552D# |
| @alaaabbod | alaaabbod@ |
| 6 KB JS | KB JS 6 |
The fix is three lines, and it is a direction fix scoped to the boxes that hold a Latin label inside the right-to-left document. It works, it is correct, and it does nothing whatsoever for shaping. The nav stylesheet says so in one sentence: shaping is a property of the font and the codepoints and is unaffected by either declaration, so the joins are the split registry's problem and not the rule's.
Hebrew is affected by this one and not by the other, which is the cleanest way to hold the two apart. A Hebrew word split into characters inside a left-to-right container renders backwards, and a direction declaration with an isolation fixes it. An Arabic word split into characters is misspelled in whichever direction you like.
Six more corrections of exactly this shape, and the reason logical properties do not catch them, are in the right-to-left bugs logical properties miss.
golden:rtl · 37/37 · 2026-08-24
How do you keep it fixed once nobody is looking?
The reason this shipped is duller than the defect and more useful. The visual harness had no locale axis: the viewport file declares none, the scenario library has no locale logic, and the capture step reads a scenario's locale when no scenario sets one. Before the gate described here, exactly one tool had ever loaded an Arabic page, and only to photograph a single card. Every defect in this article was invisible to a passing suite and obvious to anyone who opened the page.
The gate that holds it now is candidate-only by design, which is worth defending rather than apologising for. Every other gate in that directory compares the rebuild against the Golden Master; this one cannot, because the reference's Arabic pages ship the two typefaces that were deliberately replaced. A comparison whose baseline has been invalidated on purpose is not coverage — it is a red run behind a permanent excuse row.
The load-bearing assertion is one line: the Arabic document has zero character nodes and the English one has more than zero. Its companion check makes sure the Arabic document still has something to animate, and reports 26 words and 43 lines. The routes come from the published route manifest rather than a hard-coded list, so a route added to the site is a route the gate covers without anyone remembering to add it — the run quoted here surveyed 20 Arabic routes, and the manifest declares 19 today because a project page has since been deleted.
Every figure above is a measurement of one build on one date against a local server, which is exactly what this site's own measurement contract says such a figure is worth. What survives the date is the shape of it: the counts are asserted rather than remembered, the predicate is unit-tested against a Hebrew document declared right-to-left, and a regression is now a red run instead of an email from the person whose name was being misspelled.
Shipping three languages out of one source is one of the four things this site is built around — the four disciplines.