Skip to content

Commit d624bbd

Browse files
morethanwordsclaude
andcommitted
Lay the client out right to left for RTL languages, by the common bidi guidelines
Arabic, Persian, Hebrew and the other right-to-left languages mirror the whole interface. The direction comes with the language pack: the server's word on the language (langPackLanguage.rtl, kept with the pack), or its code for a pack without it. It switches live, without a reload; ?rtl=1 and I18n.forceRTL force it. - CSS is logical; movement along the line reflects through --reflect, and the icons that point along it flip (iconsReverse). - Keys, swipes and drags follow the line: ArrowLeft goes on right to left, value sliders included (WebKit steps a native range by the keys' sides, so onRangeInlineArrowKey takes them). - Galleries, carousels, stories and the media viewer page in reading order, and outgoing messages sit on the left. Media playback, charts, clocks, check marks, digits, phone numbers and URLs stay left to right. - Text of unknown direction reads by its own (dir=auto); an English string an RTL pack lacks is isolated left to right; the composer lays a draft out in the direction it is sent in. - e2e/popupSandboxRtl.spec.ts opens every popup story in both directions, mediaViewerApp pages right to left, and AGENTS.md has the RTL section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f1a0b65 commit d624bbd

277 files changed

Lines changed: 2700 additions & 916 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pnpm lint # oxlint on the whole repo (config: .oxlintrc.json)
3535
pnpm lint:fix # Same, with auto-fix
3636
```
3737

38-
Debug query params: `?test=1` (test DCs), `?debug=1` (verbose logging), `?noSharedWorker=1` (disable shared worker), `?pfs=1` (Perfect Forward Secrecy — temporary auth keys, `src/lib/mtproto/tempAuthKeys.ts`; off by default), `?a11y=1` (the keyboard and screen-reader layer — see "Accessibility"; off by default).
38+
Debug query params: `?test=1` (test DCs), `?debug=1` (verbose logging), `?noSharedWorker=1` (disable shared worker), `?pfs=1` (Perfect Forward Secrecy — temporary auth keys, `src/lib/mtproto/tempAuthKeys.ts`; off by default), `?a11y=1` (the keyboard and screen-reader layer — see "Accessibility"; off by default), `?rtl=1` (lays the app out right to left in whatever language is loaded — see "Right-to-left").
3939

4040
### Preview
4141

@@ -107,6 +107,8 @@ Either way every `rootScope.managers` call is answered from
107107
- The panel's theme select switches between `system` and every shipped theme
108108
(`day` / `night` / `light` / `tinted`) and survives a reload — the mock
109109
managers discard settings writes, so it is kept in `localStorage` instead.
110+
The direction select beside it (`ltr` / `rtl`) is kept the same way and lays
111+
every story out right to left; `?rtl=1` in the URL does the same.
110112
- Going through 200+ stories takes more than one sitting, so each row has a
111113
checkbox for "I have looked at this one" and the list can be sliced by it —
112114
**All / Unchecked / Checked**, counted within whatever the search box is
@@ -486,6 +488,9 @@ import {Message, Chat, User, InputPeer} from '@layer';
486488
it, and with many items changing at once. A cost that matters gets a
487489
regression check (e.g. `e2e/dialogUnreadCounter.spec.ts` counts the
488490
layouts read while the counts of many rows change).
491+
- **Every final review also checks the change in RTL** — mirrored, next to
492+
LTR, as "Right-to-left (RTL)" below describes. A screen that only works
493+
left to right is not finished.
489494
- **After every context compaction, reread this entire `AGENTS.md` before
490495
continuing work.** A compacted context or summary does not replace the
491496
canonical instructions in this file.
@@ -597,6 +602,92 @@ unit test of the layer imports `@/tests/helpers/a11yLayer`.
597602
custom interactions when available, and explicitly record untested cases.
598603
Passing Axe or source review alone does not establish full accessibility.
599604
605+
## Right-to-left (RTL) is part of every UI feature
606+
607+
Arabic, Persian, Hebrew and the other right-to-left languages lay the whole
608+
client out mirrored. **Every new or changed interface must work right to left**:
609+
design it, build it and check it that way before calling it done — the same as
610+
accessibility, never a follow-up. What mirrors and what does not follows the
611+
common bidirectional guidelines (Material Design's Bidirectionality, Apple's
612+
Right to left, the W3C's internationalization notes) — never another Telegram
613+
client, whose RTL support is incomplete.
614+
615+
- **How the direction is set.** `I18n.applyDirection()` (`@lib/langPack`) puts
616+
`dir`, `lang` and `.is-rtl` on `<html>` on every language apply, by the
617+
server's word on the language (`langPackLanguage.rtl`, kept with the pack as
618+
`rtl`), or by its code (`isRTLLangCode`) for a pack without it. A switch in Settings → Language flips the
619+
running app without a reload, so nothing may decide the direction once, at
620+
load or at construction: read `I18n.isRTL()` (a signal; `I18n.getIsRTL()`
621+
outside a reactive scope) when laying out. `?rtl=1` forces RTL in any
622+
language, `I18n.forceRTL(true | false | undefined)` flips it live from the
623+
console, and the popup sandbox has a direction switch next to its theme.
624+
- **CSS is logical, not physical.** `margin-inline-*`, `padding-inline-*`,
625+
`inset-inline-*`, `border-inline-*`, `text-align: start | end`, logical corner
626+
radii (`border-start-start-radius` …). Physical `left` / `right` only for a
627+
coordinate that is physical: a position JS measured, `left: 50%` centring,
628+
full width. Movement along the line multiplies by `var(--reflect)` (`1` / `-1`):
629+
`translateX(calc(100% * var(--reflect)))`, gradient angles
630+
`calc(90deg * var(--reflect))`, a shimmer or loading sweep running from the
631+
inline start; origins use `--transform-origin-inline-start` / `-end`. Where nothing logical exists (`background-position`, `clip-path`,
632+
shadows), override under `.is-rtl &`. Never set a logical property and its
633+
physical twin for the same side in one block: production's lightningcss
634+
emits the lowered logical rule after the block with a higher specificity, and
635+
the physical one silently loses in the build only (logical radii become
636+
`:lang()` rules — one reason `<html lang>` must be right).
637+
- **Icons.** Glyphs that point along the line (arrows, back, send, reply,
638+
forward, undo, list bullets) are listed in `src/iconsReverse.ts`; `Icon()` and
639+
`<IconTsx>` give them `.icon-mirror`, which the stylesheet flips in RTL (with
640+
`scale`, so it composes with a `transform` animation). A new directional icon
641+
goes into that list; a glyph that points along the line in one place only (the
642+
logo as a send plane) takes the class `icon-mirror` there — never a hand-made
643+
`scaleX(-1)`.
644+
- **JS geometry and input.** `getBoundingClientRect()`, `clientX`, `offsetLeft`
645+
and `style.left` are physical: mirror whatever is measured from "the start" of
646+
a row, and prefer writing logical styles (`style.insetInlineStart`) or a CSS
647+
variable the stylesheet applies logically. `scrollLeft` is `0` at the start
648+
and negative toward the end in RTL. In a horizontal widget ArrowRight means
649+
"previous" and ArrowLeft "next" (`getInlineArrowKeys()`); horizontal swipes
650+
and drags (swipe-to-reply, swipe-back, tab swipes) flip their sign
651+
(`handleTabSwipe` already does).
652+
- **Sequences page in reading order.** The media viewer, stories, the profile
653+
photo carousel, slideshows and any other gallery or carousel put the next item
654+
at the inline end — on the left in RTL: its arrow, the side a click goes on by,
655+
ArrowLeft, a swipe to the right and the slide all point that way, and a
656+
progress that counts steps (the stories strip) fills from the right. One
657+
inside content of its own direction (a page's or a rich message's slideshow)
658+
pages that way. Outgoing messages sit on the left.
659+
- **Text.** Content whose direction is unknown — names, messages, captions —
660+
gets `dir="auto"` (`setDirection()` / `setInnerHTML()`); strings that are
661+
always left to right — phone numbers, codes, card numbers, URLs, standalone
662+
@usernames, signed amounts, code — get `dir="ltr"`; parts joined into one line
663+
are isolated (`unicode-bidi: isolate`) so one RTL part does not reorder its
664+
neighbours. A string an RTL pack lacks falls back to English, which `I18n`
665+
puts in a left-to-right isolate inside its element (`untranslatedKeys`), the
666+
spaces that join it to its neighbours left outside. The composer lays a draft
667+
out in the direction it is sent in (`editorDocumentRtl`, the first block with
668+
text), not the interface's.
669+
- **What is not mirrored.** Media playback — seek and progress bars, voice
670+
waveforms, video timelines, play / rewind / forward — runs left to right: it
671+
follows the tape, not the reading direction. Put the attribute `dir="ltr"` on
672+
the control (icons inside a `[dir="ltr"]` island are not mirrored) and keep its
673+
geometry physical. Media content, stickers, illustrations, avatars, QR codes,
674+
maps, charts and graphs, clocks and anything that turns clockwise, check marks
675+
and digits stay as they are. Value sliders (volume, sizes, colours) do mirror;
676+
a native `<input type="range">` takes its arrow keys through
677+
`onRangeInlineArrowKey` (WebKit steps a right-to-left range by the keys'
678+
sides, not by its direction).
679+
- **Verify it.** Look at the change in the preview with `?rtl=1` (or flip
680+
`I18n.forceRTL()` and back) side by side with LTR: layout, open/close and
681+
slide animations, menus and tooltips opening toward the right side, scrolling
682+
strips, keyboard and swipe directions. For an RTL language's own text, switch
683+
the language to Arabic or Persian in Settings — on the preview's built bundle
684+
(`?static=1`): the dev server lays the local English strings over the
685+
server's pack. The direction switch
686+
itself is covered by `src/tests/langDirection.test.ts`, and
687+
`e2e/popupSandboxRtl.spec.ts` (part of `test:popups`) opens every popup story
688+
in both directions and fails on a box that does not stand mirrored, and checks
689+
that a value slider runs from the right.
690+
600691
## What NOT to Do
601692
602693
(Style rules are in "Code Style"; the import-alias, `invokeApi`-from-UI, and
@@ -637,7 +728,7 @@ pnpm test:focus:app # the same, for the signed-in client (needs PLAYWRIGHT_BASE
637728
pnpm test:a11y:app # Axe past the login screen (needs PLAYWRIGHT_BASE_URL)
638729
pnpm test:idle:app # the profile sidebar goes idle once open (needs PLAYWRIGHT_BASE_URL)
639730
pnpm test:peek:app # holding a photo peeks at it: chat, rich message, shared media (needs PLAYWRIGHT_BASE_URL)
640-
pnpm test:viewer:app # the media viewer: its place and header, a step pressed mid-step, a dead picture URL, a video's stand-in (needs PLAYWRIGHT_BASE_URL)
731+
pnpm test:viewer:app # the media viewer: its place and header, a step pressed mid-step, paging right to left, a dead picture URL, a video's stand-in (needs PLAYWRIGHT_BASE_URL)
641732
pnpm test:calls # the calls' noise suppressor: RNNoise in its AudioWorklet, in Chromium (~30 s)
642733
```
643734

‎e2e/fixtures/richTextStyles.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ const article: JSONContent = {type: 'doc', content: [
4949
{type: 'blockMath', attrs: {source: 'x^2+y^2'}}
5050
]};
5151

52+
function withRightToLeftOpening(document: JSONContent): JSONContent {
53+
const [first, ...rest] = document.content;
54+
return {...document, content: [{...first, content: [text('פסקה '), ...first.content.slice(1)]}, ...rest]};
55+
}
56+
5257
export function mountRichTextStyles(options: {fontSize: number, width: number, rtl: boolean, legacyHeadings: boolean}) {
5358
const grid = window.document.createElement('div');
5459
grid.style.cssText = 'position:absolute;inset:0;z-index:2;display:flex;align-items:flex-start;gap:20px;padding:20px;background:white;';
@@ -66,7 +71,9 @@ export function mountRichTextStyles(options: {fontSize: number, width: number, r
6671
input.style.cssText = 'width:100%;max-height:none;overflow:visible;';
6772
host('editor').append(input);
6873
const editor = createEditor(input);
69-
editor.setDocument(article);
74+
// * an article lays itself out in its own direction, read off its first block with text, in the
75+
// * editor as in the message: the right-to-left pass opens it with a Hebrew word
76+
editor.setDocument(options.rtl ? withRightToLeftOpening(article) : article);
7077
editor.setExpanded(true);
7178
const page = richMessageToPage(editor.getRichMessage().output);
7279
if(options.legacyHeadings) {

‎e2e/mediaViewerApp.spec.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,34 @@ test.describe('the media viewer', () => {
165165
expect(await page.evaluate(() => window.appMediaViewer.listLoader.next[1]?.mid)).toBe(opened);
166166
});
167167

168+
// * right to left the viewer pages in reading order: the step ArrowLeft takes left to right is
169+
// * ArrowRight's, and the button back stands on the right, the one forward on the left
170+
test('pages in reading order right to left', async({page}) => {
171+
test.setTimeout(4 * 60_000);
172+
await page.goto('/?noSharedWorker=1&rtl=1');
173+
await page.waitForFunction(() => document.querySelectorAll('.chatlist-chat').length > 0, null, {timeout: 180_000});
174+
expect(await page.evaluate(() => document.documentElement.dir)).toBe('rtl');
175+
const found = await findMessage(page, 'photo', {photos: 3});
176+
test.skip(!found, 'no chat with three photos in the recent chats of this account');
177+
const photo = await openMessage(page, found);
178+
await page.mouse.click(photo.x, photo.y);
179+
await expect(page.locator(`${VIEWER} .media-viewer-mover.center`)).toHaveCount(1);
180+
await page.waitForTimeout(1000);
181+
const opened = await page.evaluate(() => window.appMediaViewer.target.mid);
182+
183+
await page.keyboard.press('ArrowRight');
184+
await expect.poll(() => page.evaluate(() => window.appMediaViewer.listLoader.next[0]?.mid), {
185+
message: 'ArrowRight stepped back from the photo opened'
186+
}).toBe(opened);
187+
await page.waitForTimeout(1000);
188+
189+
const middle = page.viewportSize().width / 2;
190+
const previous = await page.locator(`${VIEWER} .media-viewer-switcher-prev`).boundingBox();
191+
const next = await page.locator(`${VIEWER} .media-viewer-switcher-next`).boundingBox();
192+
expect(previous.x, 'the button back stands on the right').toBeGreaterThanOrEqual(middle);
193+
expect(next.x + next.width, 'the button forward stands on the left').toBeLessThanOrEqual(middle);
194+
});
195+
168196
// * The URL of a picture can be gone by the time the viewer shows it: revoked, or its cache entry
169197
// * evicted. Such a picture loads nothing - it must not replace the thumbnail with an empty box, but be
170198
// * asked for anew. The worker is made to hand out a dead URL for one photo, every size of it, until

‎e2e/popupSandbox.helpers.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ declare global {
1212
list(): Story[],
1313
open(id: string): Promise<void>,
1414
closePopups(): void,
15+
setDirection(dir: 'ltr' | 'rtl'): void,
1516
calls(): ManagerCall[],
1617
unhandled(): ManagerCall[]
1718
};

‎e2e/popupSandboxRichTables.spec.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ test('a table draws its header cells, stripes, border and alignment the way it i
195195
}
196196
expect(styles[1][0].tag, surface).toBe('TD');
197197
expect(Number(styles[1][0].fontWeight), surface).toBeLessThan(500);
198-
expect(styles[1].map((style) => style.textAlign), surface).toEqual(['left', 'center', 'right']);
198+
// a cell with no alignment follows the line (left here), an aligned one keeps its side
199+
expect(styles[1].map((style) => style.textAlign), surface).toEqual(['start', 'center', 'right']);
199200
// every other row is shaded
200201
expect(styles[2][0].background, surface).not.toBe(styles[1][0].background);
201202
expect(await page.evaluate((surface) => window.richTables.hasBorder(surface), surface), surface).toBe(true);

0 commit comments

Comments
 (0)