{
  "about": "An entry of UpFork's ledger. Check it by hand as https://upfork.dev/docs/ledger says, or at https://upfork.dev/verify/E-86",
  "entry": {
    "seq": 86,
    "kind": "article-version",
    "record": "{\"ai\":{\"apps\":[\"Claude\"],\"models\":[\"Claude Opus 5.5\"]},\"article\":\"adding-persian-right-to-left-to-a-next-js-admin-panel-what-broke-and-how-we\",\"author\":{\"account\":\"a_9d470b566acab5f65bdc\",\"handle\":\"ramin-valadan\"},\"body\":\"## What it is\\n\\nSponsor Bot is a self-hosted platform for Telegram uploader bots with a forced-join gate, sponsor campaigns and an ad shop (its engine, the queues and the TRON payments, are in [an article of their own](https://upfork.dev/articles/telegram-bots-on-postgresql-alone-sponsor-bot-s-queues-forced-join-gate-and)). Its panel, about 23,500 lines over 30 pages of Next.js, Tailwind CSS and Radix, was built in English and then made bilingual: English left to right, Persian right to left, each person choosing theirs. The work went page by page in nine phases, and each was checked in both languages at desktop and phone width before the next. The [live demo](https://sponsorbot-demo.upfork.dev/) has the language switch on its sign-in page and in the user menu.\\n\\nThis article is for anyone, person or AI, adding a right-to-left language to an existing React app: how the language is chosen and carried, what changes for layout, type, digits and dates, what must stay left to right, how the server and a Telegram bot speak the language too, and nine things that went wrong on the way, each with its fix.\\n\\n## Choosing and carrying the language\\n\\nThe choice is a cookie, `sb_lang` (`en` or `fa`), set from the user menu, Settings → Appearance and the sign-in pages. The server draws `<html lang dir>` from it, so a change reloads the page, and Radix's direction provider gets it too, so menus, selects and submenus open the right way. Until someone picks, the panel is English whatever the browser's language: the first version guessed from the browser, and Ramin chose English for everyone who hasn't picked. The addresses don't change: it's an admin panel, nothing for search engines.\\n\\nWords sit next to the markup that shows them, typed so a missing Persian string fails the build:\\n\\n```ts\\nexport type Copy<T> = Record<Lang, T>;\\n\\n/** For words kept in pairs where they're used: `const t = inLang(lang)`, then `t(\\\"Bots\\\", \\\"ربات‌ها\\\")`. */\\nexport function inLang(lang: Lang) {\\n  return <T>(en: T, fa: T): T => (lang === \\\"fa\\\" ? fa : en);\\n}\\n```\\n\\nSentences with a number in them are functions, because the two languages build them differently. English makes the noun plural; Persian keeps it singular after a number and puts the words around it in another order:\\n\\n```ts\\nslotsFrom: (patterns) => `From ${plural(patterns, \\\"weekly pattern\\\", undefined, \\\"en\\\")} and your own`,\\nslotsFrom: (patterns) => `از ${count(patterns, \\\"fa\\\")} الگوی هفتگی و جایگاه‌های خودتان`,\\n```\\n\\n## Right to left: logical sides, and one rule for arrows\\n\\nEvery physical side class became a logical one: `ml-`/`mr-` to `ms-`/`me-`, `pl-`/`pr-` to `ps-`/`pe-`, `left-`/`right-` insets to `inset-s-`/`inset-e-`, `text-left` to `text-start`, borders and rounded corners likewise. That was 262 classes in 57 files, and English renders exactly as before. Centring (`left-1/2 -translate-x-1/2`) stays physical, since it is symmetric anyway.\\n\\nArrows and chevrons that mean \\\"onward\\\" (next, continue, open, sign out) have to point the other way. Rather than a class on every icon, one rule at the end of the stylesheet turns them all:\\n\\n```css\\n[dir=\\\"rtl\\\"]\\n  :is(\\n    .lucide-arrow-right,\\n    .lucide-arrow-left,\\n    .lucide-chevron-right,\\n    .lucide-chevron-left,\\n    .lucide-chevrons-right,\\n    .lucide-chevrons-left,\\n    .lucide-arrow-up-right,\\n    .lucide-log-out,\\n    .lucide-log-in\\n  ):not([dir=\\\"ltr\\\"] *) {\\n  transform: scaleX(-1);\\n}\\n```\\n\\nAdding Tailwind's `rtl:-scale-x-100` to one of these icons on top of it would turn it back again.\\n\\n:::lesson{title=\\\"Next.js's CSS build rewrites :dir(rtl) into a list of RTL languages, which can't see a dir=ltr part inside the page\\\" kind=\\\"tools\\\" by=\\\"ai\\\" packages=\\\"next@16\\\"}\\n**I assumed:** the arrows rule could use the `:dir(rtl)` pseudo-class, and a part of a Persian page kept left to right (a chat preview laid out like Telegram) would fall outside it.\\n**What happened:** the CSS build (Lightning CSS, under Next.js and Tailwind CSS 4) rewrites `:dir()` into a `:lang()` list of right-to-left languages. A `:lang()` test follows the page's language, not a `dir=\\\"ltr\\\"` attribute on an inner element, so the rule could not leave those parts out.\\n**The fix:** attribute selectors, which the build leaves alone: `[dir=\\\"rtl\\\"] :is(…):not([dir=\\\"ltr\\\"] *)`.\\n**Evidence:** phase 2, commit 71ee7a0 (\\\"the RTL arrows rule skips left-to-right parts (attribute selectors: the CSS build rewrites :dir())\\\").\\n:::\\n\\n## What stays left to right\\n\\nSome things read left to right whatever the page: e-mail, password, URL and number fields (the shared `Input` sets `dir=\\\"ltr\\\"` for those types), codes and keys, @usernames, addresses, transaction hashes, versions and file names. Charts and the day's time track stay left to right too, since time runs left to right like a clock's numbers; their Persian labels are isolated so they keep their own word order. Inside a sentence, two helpers wrap a piece of text in Unicode's directional isolates:\\n\\n```ts\\nexport function ltr(text: string): string {\\n  return `${String.fromCharCode(0x2066)}${text}${String.fromCharCode(0x2069)}`;\\n}\\n\\n/** The other way round: Persian words inside something laid out left to right (a chart's axis), kept in their order. */\\nexport function rtl(text: string): string {\\n  return `${String.fromCharCode(0x2067)}${text}${String.fromCharCode(0x2069)}`;\\n}\\n```\\n\\nU+2066 (left-to-right isolate) and U+2067 (right-to-left isolate) open a run that the surrounding text can't reorder, and U+2069 closes it, so \\\"@sponsor_bot\\\" in a Persian sentence keeps its \\\"@\\\" in front.\\n\\nOn elements, `dir=\\\"ltr\\\"` does the same, and that is where the first trap was:\\n\\n:::lesson{title=\\\"A margin on an element that is itself dir=ltr lands on the wrong side in a right-to-left line\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** `<span dir=\\\"ltr\\\" className=\\\"ms-2\\\">@username</span>` gives the username a gap before it in both languages, since `ms-` (margin-inline-start) follows the direction.\\n**What happened:** `margin-inline-start` follows the direction of the element it is on, and this element was left to right, so in a Persian line the gap went to the username's left, the far side from the words before it.\\n**The fix:** the margin stays on an outer element in the page's direction, and `dir=\\\"ltr\\\"` goes on an inner span around the text alone.\\n**Evidence:** phase 3, commit 04c127e (\\\"usernames keep their gap on the page's side (dir=ltr on an inner span)\\\").\\n:::\\n\\n:::lesson{title=\\\"A Latin unit after a number on a Persian page joins the number's run, and its margin lands on the far side\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** a stat written as a big number with a small unit after it (`<span className=\\\"ms-1.5\\\">USDT</span>`) would read \\\"15 USDT\\\" on a Persian page, with the gap between them.\\n**What happened:** the number and the Latin unit form one left-to-right run inside the right-to-left line, so they were laid out together and the unit's start margin ended up on its outer side: the unit ran into the number.\\n**The fix:** the unit is an `inline-block`, which the line treats as one piece, so it keeps its own margin where the line's direction puts it: `<span className=\\\"ms-1.5 inline-block …\\\">{stat.unit}</span>` on the Overview, Campaigns, Rate cards, Calendar and Orders.\\n**Evidence:** phase 4, commit 14f166e (\\\"Stat units after a number no longer run into it on Persian pages\\\").\\n:::\\n\\n:::lesson{title=\\\"English that users type into a Persian page needs bdi where it wraps and dir=auto where it is cut short\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** names and descriptions people type (a bot's name, a rate card's description) could sit in the page's direction like any other text.\\n**What happened:** English text on a Persian page takes the page's direction, so its full stop shows at its start; and in a cell cut short with an ellipsis, the cut falls at the English text's beginning instead of its end.\\n**The fix:** what people type keeps its own direction. A `<bdi>` around it where it wraps, so its punctuation stays at its own end; `dir=\\\"auto\\\"` on an element cut short with an ellipsis, so the cut falls at its end whichever language it is in. The panel has 46 `<bdi>` and 28 `dir=\\\"auto\\\"`.\\n**Evidence:** phase 5, commit c28f921 (the API's messages in a `<bdi>`), and docs/languages.md, \\\"What people type keeps its own direction\\\".\\n:::\\n\\n## Type: Vazirmatn, and what Persian can't do\\n\\nThe Persian face is Vazirmatn, placed after the Latin faces in every font stack: Latin letters keep their own faces on Persian pages, and thanks to the fonts' `unicode-range`, only the Arabic-script file is ever fetched, and only for a page with Persian on it. Persian letters join one to the next, so the rules differ:\\n\\n```css\\nhtml:lang(fa) * {\\n  letter-spacing: normal;\\n}\\nhtml:lang(fa) :is(em, i, .italic) {\\n  font-style: normal;\\n}\\nhtml:lang(fa) .font-display {\\n  line-height: 1.25;\\n}\\n```\\n\\nNo letter-spacing (the tracked labels would pull joined letters apart), no italics (a slanted Persian word is only a distorted one), and a little more line height for titles set at 1, which would clip Persian's dots.\\n\\n:::lesson{title=\\\"Monospace fonts spread Persian letters apart: give Persian placeholders in mono fields the sans face\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** the transaction-hash and API-key fields could stay entirely in the mono face, placeholder included, on both pages.\\n**What happened:** a mono face gives every character the same width, so the Persian placeholder's letters were spread apart and no longer read as joined words.\\n**The fix:** the field stays mono for what people paste, and on Persian pages its placeholder uses the sans face: `className={cn(\\\"font-mono text-[13px]\\\", lang === \\\"fa\\\" && \\\"placeholder:font-sans\\\")}`.\\n**Evidence:** phase 5, commit c28f921; the order page's refund field and the payment settings' key field.\\n:::\\n\\n## Digits and dates\\n\\nBoth are one line each, and both were Ramin's calls after seeing the pages:\\n\\n```ts\\nexport const CALENDAR: Record<Lang, \\\"gregory\\\" | \\\"persian\\\"> = { en: \\\"gregory\\\", fa: \\\"gregory\\\" };\\nexport const NUMERALS: Record<Lang, \\\"latn\\\" | \\\"arabext\\\"> = { en: \\\"latn\\\", fa: \\\"latn\\\" };\\n```\\n\\nPersian pages use Western digits (0–9): Persian ones broke the panel's rhythm and its alignment. Dates are Gregorian with Persian month names (\\\"11 اکتبر 2026\\\"). Every formatter reads its locale from these, `fa-IR-u-ca-gregory-nu-latn`, and setting the Persian entries to `\\\"persian\\\"` and `\\\"arabext\\\"` brings the Solar Hijri calendar (مهر ۱۴۰۵) and Persian digits back everywhere; that code stays, and the browser's own `persian` calendar data was checked for every month of 1390–1420, leap years included. Month grids start on Saturday and run right to left in Persian, the week grid reads clicks and drags from its right edge so a drag picks the days under the pointer, and weekdays stay ISO numbers (1 Monday … 7 Sunday) in the data: only their order on screen changes.\\n\\n:::lesson{title=\\\"Beside Persian digits, a middle dot used as a separator reads as the Persian zero\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** the \\\"·\\\" the panel puts between items was neutral punctuation in any script.\\n**What happened:** while the panel still used Persian digits, \\\"۲۰۲۶ · ۳۰\\\" read as \\\"۲۰۲۶ ۰ ۳۰\\\": the Persian zero (۰) is a small dot, and a middle dot beside Persian digits looks exactly like it.\\n**The fix:** first, the dot was drawn as a rounder bullet from a one-glyph font on Persian pages. When Persian pages moved to Western digits (Ramin's call, for rhythm and alignment), the confusion was gone and so was the font: \\\"·\\\" is \\\"·\\\" again in both languages. With Persian digits back on, the separator needs the same care.\\n**Evidence:** phase 6, commit 43649ef (the bullet font); phase 7, commit b436598 (Western digits, the font removed).\\n:::\\n\\n## The server and the bot speak it too\\n\\nThe API answers in the panel's language: a small middleware first in the pipeline reads the same cookie into an `AsyncLocal`, so the error handler after it follows too, and every message the panel can show, about 330 sentences, is written as a pair. Sentences the server stores and shows later (why an ad's time failed, a payment check's note) are stored in English whoever's request made them, and translated when they are read, so a Persian owner and an English admin each read the same note in their own language.\\n\\nEach bot has a language of its own, chosen when it is connected, with Persian defaults for all 80 of its texts. A Persian bot writes dates (\\\"چهارشنبه 14 اکتبر · 10:00\\\"), durations and lists in Persian. Telegram brought two more things to handle:\\n\\n:::lesson{title=\\\"Telegram sets each paragraph's direction from its first strong letter: start Persian lines with a Persian word\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\"}\\n**I assumed:** a Persian bot message would read right to left as a whole.\\n**What happened:** Telegram gives each paragraph the direction of its first strong letter, so a Persian line that begins with a price (\\\"15 USDT …\\\") is laid out as a left-to-right paragraph, its Persian words in the wrong order around the number.\\n**The fix:** every Persian default text starts its lines with a Persian word where it can: \\\"قیمت هر جایگاه: 15 USDT\\\", not \\\"15 USDT …\\\". The panel's chat preview does what Telegram does with `unicode-bidi: plaintext` on each bubble, so the owner sees the real result while rewriting a text.\\n**Evidence:** phase 8, commit 78eddd6; `MessageTexts.Fa.cs` and the panel's chat preview.\\n:::\\n\\n:::lesson{title=\\\"In a Persian Telegram message, a @username or /command after Persian words shows its sign on the wrong side\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** \\\"@sponsor_bot\\\" and \\\"/ads\\\" could be written into a Persian message as they are.\\n**What happened:** after Persian words, the \\\"@\\\" or \\\"/\\\" (neutral characters) took the right-to-left direction around them and showed at the far end, as \\\"bot@\\\". A value that starts in Latin letters, like \\\"Interstellar (2014)\\\", lost its bracket to the far side of the sentence in the same way.\\n**The fix:** a left-to-right mark (U+200E) goes before a @ or / that starts a mention or command, and around a value that begins in Latin letters; only in Persian bots' messages, so English bots' messages are byte for byte as before.\\n**Evidence:** phase 8, commit 78eddd6 (`SettleText` and `SettleValue` in MessageTexts.Fa.cs).\\n:::\\n\\n```csharp\\nprivate const char Lrm = '\\\\u200E';\\n\\n[GeneratedRegex(@\\\"(?<![A-Za-z0-9_/:.@<\\\\u200E])([@/])(?=[A-Za-z0-9_])\\\")]\\nprivate static partial Regex MentionOrCommand();\\n\\npublic static string SettleText(string text) => MentionOrCommand().Replace(text, $\\\"{Lrm}$1\\\");\\n```\\n\\nThe lookbehind skips a @ or / that is part of a word, an address or a link, and one that already has a mark.\\n\\n## Checking it, and a trap in the checker\\n\\nEach phase was checked in headless Chrome at 1440 px and 390 px, in both languages, before it was committed; the typed copy means a missing Persian string fails `tsc`; and 19 server tests hold the API to answering in Persian with the cookie and in English without, and a Persian bot to greeting, gating and answering its uploader in Persian.\\n\\n:::lesson{title=\\\"Puppeteer's full-page screenshot of a short right-to-left page comes out shifted sideways\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** a full-page screenshot was the safest way to check a whole page, short or long.\\n**What happened:** Persian pages shorter than the window came out shifted sideways in Puppeteer's full-page screenshots, the sidebar cut off at the edge, while a viewport screenshot of the same page was right.\\n**The fix:** short pages are checked with a viewport screenshot; the page was never the problem.\\n**Evidence:** phase 5, commit c28f921 (docs/languages.md, \\\"A screenshot trap\\\").\\n:::\\n\\n## If you're doing the same\\n\\nThe order that worked: make the language a cookie the server reads, so `<html lang dir>` is right in the first byte; sweep every physical side class to a logical one before translating anything; decide early what stays left to right, and give it `dir=\\\"ltr\\\"` on the innermost element or an isolate; type the copy so a missing translation can't ship; decide digits and calendar with the people who will read it, in one place; and check every page in both directions at phone width, with a viewport screenshot.\",\"bodySha256\":\"fc22a784d46334f9696f5e3bff717bdf5f67c23c18d4557f51a929b12d874b4c\",\"kind\":\"tips-and-fixes\",\"product\":\"telegram-uploader-bot-forced-join\",\"productRevision\":null,\"publishedAt\":\"2026-10-26T08:51:46.466Z\",\"revision\":1,\"site\":\"https://upfork.dev\",\"summary\":\"Sponsor Bot's panel in English and Persian: logical sides, one rule for arrows, digits and dates, text that keeps its own direction, Telegram's bidi, and nine things that broke right to left.\",\"title\":\"Adding Persian, right to left, to a Next.js admin panel: what broke and how we fixed it\",\"type\":\"article-version\",\"url\":\"https://upfork.dev/articles/adding-persian-right-to-left-to-a-next-js-admin-panel-what-broke-and-how-we\"}",
    "recordHash": "8b4d80f08baee5293fd82cb16de5b5cacf97f981b5addfea71fff795b43afaed",
    "prevHash": "6474da73a7dcd3571e01adbe7ec98b6e9dd5cc6b59e26d0e8acf10c180a7b797",
    "entryHash": "731492d65aafaaee7bb4aa035b86944218cf6c0d1ca5f3eb8545a1cfc0a7f44e",
    "keyId": "k1",
    "signature": "KU4IMysT6qTHyo7YiH/EnaFlK8nyoQ7Qn8dgQ8wf5PqjRXonDivdhTHAgLjY+b4PwybBqF8Tyq4KEBo8xx96DA==",
    "recordedAt": "2026-10-26T08:52:08.779121+00:00",
    "publicKey": "VuHuHXAtO3XAqgnwvMenp0f/3vTV061hnAIizYvwh40=",
    "algorithm": "Ed25519"
  },
  "path": [],
  "checkpoint": null,
  "keys": "https://upfork.dev/ledger/keys.json"
}