Adds right-to-left (RTL) language support to the helpdesk frontend, covering Araβ¦bic, Hebrew, Farsi,
and Urdu users. Closes the gap between the backend (which already exposes is_rtl()) and the SPA
(which previously rendered LTR-only).
Approach adapted from frappe/lms#2310: wire dynamic direction from boot, sweep the codebase
replacing physical Tailwind utilities with logical equivalents, add direction-aware fixes for the
few cases logical classes can't reach.
What changed
## Frappe Helpdesk β UI Screenshots
### Agent Portal
| | | |
|---|---|---|
|  |  |  |
| Home | Tickets | Ticket Detail |
|  |  |  |
| New Ticket | Knowledge Base | Article |
|  |  |  |
| Search | Customers | Contacts |
|  |  | |
| Dashboard | Call Logs | |
### Customer Portal
| | | |
|---|---|---|
|  |  |  |
| My Tickets | New Ticket | Knowledge Base (Public) |
### Tiptap Editor
| | |
|---|---|
|  |  |
| Empty State | With Content |
Infrastructure
- <html lang dir> now reads from the boot context ({{ boot.lang }} / {{ boot.dir }}) β automatic
per user language preference.
- helpdesk/www/helpdesk/index.py boot dict exposes lang and dir (rtl/ltr via is_rtl()).
- desk/src/main.js dev mode also applies window.dir / window.lang to document.documentElement
post-boot-fetch, since Vite serves index.html raw without Jinja substitution.
Tailwind sweep β physical β logical, ~80 files touched
- Margins / padding: ml/mr β ms/me, pl/pr β ps/pe.
- Positioning: left/right β start/end.
- Borders / radii: border-l/r β border-s/e, rounded-l/r β rounded-s/e, rounded-tl/tr/bl/br β
rounded-ss/se/es/ee.
- Text align: text-left/right β text-start/end.
- space-x-* β gap-x-* on flex containers; space-x-* rtl:space-x-reverse retained for inline-<span>
parents.
- translate-x-* paired with rtl:-translate-x-* (Sidebar notification dot, Home centering
transform); Headless UI mobile-drawer transitions mirrored with rtl:translate-x-full.
Directional icons
- rtl:rotate-180 on Lucide chevrons / arrows / send icons throughout.
- Conditional-rotation icons (e.g. open/close chevrons) layer rtl:rotate-180 only on the
non-rotated state.
- Sidebar collapse icon uses XOR pattern: isExpanded !== isRtl to pick LucideArrowLeftFromLine vs
LucideArrowRightFromLine.
- Ticket navigation prev/next chevrons rewrapped with <template #icon> slot plus rtl:rotate-180,
parent gets rtl:flex-row-reverse so positions also swap.
Guide added β RTL.md documents the conventions and the agent-driven sweep workflow used here, so
future contributors can extend the work without re-deriving the rules.
Known limitations
- frappe-ui Button :icon prop callsites (TicketNavigation excluded β already fixed via slot rewrap)
β several settings views (TeamEdit, HolidayView, TwilioSettings, ExotelSettings, NewTeam,
SavedReplyView, FieldDependency, Telephony) pass icon-left="chevron-left" strings to Button. These
can't be flipped from the caller without restructuring to the #icon slot. Best resolved upstream in
frappe-ui.
- frappe-ui Input / TextEditor β Input hardcodes pl-8 (physical) and absolute-positions icon-left
at the physical left. TextEditor doesn't pass dir to TipTap's editorProps. Needs an upstream PR.
- ECharts β left LTR per pragmatic conclusion in RTL.md Β§4.2 (axis labels stay in English digits).
- Color tokens on conflict lines β 28 files had merge conflicts vs upstream develop; took our side
to preserve RTL. The token migration develop applied on those exact lines (text-gray-X β
text-ink-gray-Y, bg-white β bg-surface-white) is missing in those specific lines. Trivial follow-up
sed pass; out of scope for the RTL PR.
Test plan
- [x] Set a test user's language to Arabic (or Hebrew/Farsi/Urdu) in Frappe.
- [x] Reload /helpdesk β confirm <html dir="rtl" lang="ar">.
- [x] Sidebar renders on the right; collapse icon points the right way collapsed and expanded.
- [x] Chevrons / next-prev arrows in ticket navigation, breadcrumbs, dropdowns all point the right
direction.
- [x] Mobile sidebar slides in from the right.
- [ ] Tooltips, dropdowns, modals open from the correct anchor edge.
- [x] No horizontal scroll bleed; no element clips off either edge.
- [ ] Tables: header alignment matches column content.
- [x] Switch user language back to English; confirm no LTR regressions.
---
Appendix β RTL.md
The full guide that drove this PR is committed at RTL.md. Reproduced here for reviewer convenience:
# Making Helpdesk RTL-friendly
A practical guide for converting the helpdesk frontend (Vue + Tailwind + frappe-ui) to render
correctly under right-to-left languages (Arabic, Hebrew, Farsi, Urdu). Adapted from the lessons
learned in [frappe/lms#2310](https://github.com/frappe/lms/pull/2310).
The shape of the work is: do the infrastructure once, then sweep the codebase replacing physical
layout primitives with logical ones, with a few targeted fixes for things logical classes can't
reach (transforms, directional icons, libraries like TipTap and ECharts).
---
## 1. Infrastructure β set this up once
### 1.1 Tell the browser the direction
`desk/index.html` should pull the direction from the boot context so the user's language decides,
not a hardcoded value:
```html
<html lang="{{ boot.lang }}" dir="{{ boot.dir }}">
```
The boot exposes `dir` from `helpdesk/www/helpdesk/index.py`:
```python
from frappe.utils.jinja_globals import is_rtl
def get_boot():
return frappe._dict({
...
"lang": frappe.local.lang,
"dir": "rtl" if is_rtl() else "ltr",
})
```
`is_rtl()` is the canonical Frappe helper β it inspects the user's language preference and returns
`True` for `ar`, `he`, `fa`, `ur`.
### 1.2 Tailwind
Helpdesk is on Tailwind 3.4, which natively supports **logical properties** (`ms-*`, `me-*`,
`ps-*`, `pe-*`, `start-*`, `end-*`, β¦) and the `rtl:` variant.
Logical classes are the preferred tool β they flip themselves at runtime based on `dir`, so you
don't need to maintain two parallel class sets. The `tailwindcss-rtl` plugin is also in
`tailwind.config.js` for utilities Tailwind 3.4 doesn't cover natively, but **prefer logical
classes for any new or rewritten code**.
### 1.3 dayjs locales
Add the RTL locales to `desk/src/dayjs.ts` so dates render in the user's script:
```ts
import "dayjs/locale/ar";
import "dayjs/locale/he";
import "dayjs/locale/fa";
import "dayjs/locale/ur";
const dir = document.documentElement.dir;
const lang = document.documentElement.lang;
if (dir === "rtl" && ["ar", "he", "fa", "ur"].includes(lang)) {
d.locale(lang);
}
```
### 1.4 frappe-ui version
Several frappe-ui components (Tabs, Switch, Dialog, TextInput, Charts) only became RTL-safe in
**v0.1.276**. Confirm `desk/package.json` is at or above this.
### 1.5 A reusable `isRtl` ref
```ts
const isRtl = document.documentElement.dir === "rtl";
```
The direction is set on the root before any component mounts, so reading it once at component setup
is enough β no reactive watcher needed.
---
## 2. The Tailwind sweep β physical β logical
| Concept | Physical (LTR-only) | Logical (RTL-safe) |
| ------------------ | ------------------------------ | ------------------------------- |
| Margin | `ml-*`, `mr-*` | `ms-*`, `me-*` |
| Padding | `pl-*`, `pr-*` | `ps-*`, `pe-*` |
| Position | `left-*`, `right-*` | `start-*`, `end-*` |
| Text align | `text-left`, `text-right` | `text-start`, `text-end` |
| Border side | `border-l-*`, `border-r-*` | `border-s-*`, `border-e-*` |
| Rounded corners | `rounded-l-*`, `rounded-r-*` | `rounded-s-*`, `rounded-e-*` |
| Rounded β top | `rounded-tl-*`, `rounded-tr-*` | `rounded-ss-*`, `rounded-se-*` |
| Rounded β bottom | `rounded-bl-*`, `rounded-br-*` | `rounded-es-*`, `rounded-ee-*` |
| Float | `float-left`, `float-right` | `float-start`, `float-end` |
| Scroll margin/pad | `scroll-ml-*`, `scroll-mr-*` | `scroll-ms-*`, `scroll-me-*` |
### 2.1 The `space-x-*` trap
`space-x-*` does not flip in RTL. Replace with `gap-x-*` on a flex container, or fall back to
`space-x-* rtl:space-x-reverse` for inline `<span>` parents.
### 2.2 The `px-N` + `ps-M` / `pe-M` cascade trap
Combining `px-N` (sets both physical sides) with `ps-M` or `pe-M` (logical, one side) creates
unreliable behavior β the cascade depends on Tailwind source order and whether the
`tailwindcss-rtl` plugin overrides the logical CSS with physical equivalents. Split the shorthand
into explicit logical pairs:
```html
<div class="px-5 ps-6">
<div class="ps-6 pe-5">
```
Same applies to `mx-N` + `ms-M` / `me-M`.
### 2.3 Inside `<style>` blocks β CSS logical properties
| Physical | Logical |
| -------------------------- | -------------------------------- |
| `margin-left/right` | `margin-inline-start/end` |
| `padding-left/right` | `padding-inline-start/end` |
| `left` / `right` | `inset-inline-start/end` |
| `text-align: left/right` | `text-align: start/end` |
| `border-left/right` | `border-inline-start/end` |
---
## 3. Cases logical classes can't reach
### 3.1 Transforms
```html
<div class="translate-x-4 rtl:-translate-x-4" />
```
### 3.2 `divide-x-*`
```html
<div class="divide-x divide-outline-gray-2 rtl:divide-x-reverse">
```
### 3.3 Directional icons
```vue
<LucideChevronRight class="size-4 rtl:rotate-180" />
<LucideArrowRight class="size-4 rtl:rotate-180" />
```
### 3.4 Sidebar collapse-icon XOR
```vue
<CollapseSidebar :style="{ transform: isRtl !== isSidebarCollapsed ? 'rotateY(180deg)' : '' }" />
```
| `isRtl` | `isCollapsed` | Result |
| ------- | ------------- | -------- |
| false | false | default |
| false | true | flipped |
| true | false | flipped |
| true | true | default |
### 3.5 Conditional rotations layered with `rtl:rotate-180`
```vue
<ChevronRight :class="{ 'rotate-90': open, 'rtl:rotate-180': !open }" />
```
### 3.6 frappe-ui Button with `:icon` prop
The `:icon` prop renders the icon inside frappe-ui's Button template with no caller-side class
hook. Switch to the `<template #icon>` slot and apply rotation/sizing manually:
```vue
<Button variant="ghost" @click="...">
<template #icon>
<LucideChevronLeft class="size-4 rtl:rotate-180" />
</template>
</Button>
```
Pair with `rtl:flex-row-reverse` on the parent if button positions also need to swap (prev/next
navigation).
---
## 4. Library-specific fixes
### 4.1 TipTap (rich-text editor in tickets, comments, KB)
frappe-ui's `TextEditor` wraps TipTap but doesn't currently expose `dir` to the editor. Needs an
upstream PR to `TextEditor.vue`'s `editorProps.attributes.dir`.
### 4.2 ECharts
No clean RTL switch. Leave chart canvases LTR (axis labels in English digits) β pragmatic call
after the LMS team tried and gave up.
### 4.3 frappe-ui components
If a frappe-ui component looks wrong in RTL, first check version (β₯0.1.276 ships RTL-fixed Tabs,
Switch, Dialog, TextInput, Charts). Older versions need patching upstream rather than wrapping
locally.
---
## 5. CI β semgrep rule
Located at `.github/semgrep/tailwind-rtl.yml`. Run locally with `semgrep scan --config
.github/semgrep/tailwind-rtl.yml`. Catches reintroduced
`ml-/mr-/pl-/pr-/left-/right-/text-left/text-right/border-l/r/rounded-l/r/float-left/right`,
unguarded `space-x-*`, and unguarded `divide-x-*`.
---
## 6. Manual QA checklist
- [x] All text reads right-to-left; cursor jumps to the correct side of inputs
- [x] Sidebar on the right; collapse icon points the right way in both states
- [x] Chevrons, arrows, navigation icons all point inward
- [ ] Modals, dropdowns, tooltips open from the correct anchor edge
- [x] No element clips off the right edge or pushes horizontal scroll
- [x] Tables: header alignment matches column content
- [x] Avatar groups, badges, overflow indicators stay on the correct side
- [x] Numerals and Latin-script names stay LTR inside RTL flow
- [x] Toggle to `dir="ltr"` once more and confirm nothing regressed
---
## 7. Workflow β agent-driven sweep
The conversion is mechanical enough to parallelize across subagents. Pattern: **one conversion
agent + one verification agent per area.** RTL.md Β§7 in the repo has the full prompts and the
area-split table that drove this PR.
---
## TL;DR
1. Boot exposes `dir` and `lang` β `<html>` reads them.
2. Sweep `ml-/mr- β ms-/me-`, `pl-/pr- β ps-/pe-`, `left/right- β start/end-`, `text-left/right β
text-start/end`, `border-l/r β border-s/e`, `rounded-l/r β rounded-s/e`, `space-x β gap-x`.
3. Watch for `px-N` + `ps-M` cascade conflicts β split into `ps-M pe-N`.
4. Add `rtl:rotate-180` to directional icons; XOR pattern for sidebar collapse.
5. Inside `<style>` blocks, use CSS logical properties.
6. dayjs locales for `ar`/`he`/`fa`/`ur`; TipTap needs upstream fix.
7. Semgrep keeps physical classes from creeping back.
π€ Generated with Claude Code