# RTL & Hebrew UI Layouts — Implementation Plan

**Spec:** docs/specs/2026-05-31-rtl-hebrew-ui.md  ·  **Slug:** rtl-hebrew-ui  ·  **Wave:** 4
**Depends on:** app-shell, foundation-auth-rbac, foundation-design-system

## Goal
Deliver full right-to-left (RTL) layout support so the Hebrew (`he`) locale renders correctly across `zync-app` (React) and `zync-www` (Astro). This binds the `dir` attribute on `<html>` to the user's locale, enforces CSS logical properties (banning physical `left`/`right` properties via a new ESLint rule), mirrors directional icons, forces numeric inputs/columns to LTR reading order, and tags mixed-language user-generated content with the correct `dir`/`lang` for screen readers. No new database tables — this spec is purely presentation-layer and consumes the existing `user_preferences.locale` value and the i18n direction binding.

## Architecture
- **Direction source of truth:** the locale is a per-user preference already stored in `user_preferences.locale` (owned upstream by `system-i18n` / `foundation-auth-rbac`). The i18n layer already exposes the `useDirection` hook and `LocaleProvider`, and already sets `document.documentElement.dir` on locale change. This spec hardens and centralizes that binding at the app root and the Astro layout, and adds the static-render `dir`/`lang` attributes so the first paint is correct (no FOUC/flash of LTR).
- **App root:** `apps/zync-app/src/root.tsx` renders `<html lang={locale} dir={dir}>` from the session-claim locale so SSR/first paint is already RTL. The runtime `useDirection` hook keeps it in sync on locale switch.
- **Marketing site:** `apps/zync-www` Astro layout sets `<html lang dir>` from the request/locale at build/render time.
- **Design-system layer:** logical-property utilities and the `.mirror-rtl` rule live in the shared UI package and Tailwind preset (`packages/config/tailwind.preset.ts`) so every consumer inherits them. Hebrew typography uses **Heebo** (`'Heebo Variable', 'Heebo', Arial, sans-serif`) — the canonical Hebrew stack already defined by `foundation-design-system` and `system-i18n` (the spec's prose mention of "Rubik" is superseded by the upstream Heebo binding; see Architecture Decisions). `--font-sans` already switches to Heebo under `lang="he"`.
- **Enforcement:** a new ESLint rule `prefer-logical-properties` (added alongside the existing `@zync/eslint-plugin` rules such as `no-hardcoded-colors`, `no-hardcoded-spacing`, `no-radius-ladder`) bans physical directional CSS in `apps/**` and `packages/ui/**`.
- **Consumes upstream exports (exact names):** `useDirection`, `LocaleProvider`, `Locale`, `SUPPORTED_LOCALES`, `supportedLocales`, `useTheme`, `cn`, the design-system tokens, `packages/config/tailwind.preset.ts`, and the app-shell `Sidebar`.

## Tech Stack
- **Apps:** `apps/zync-app` (Vite + React on Cloudflare Workers), `apps/zync-www` (Astro on Cloudflare Workers).
- **Packages:** `packages/ui` (shared components, icon registry, `lang.ts` helper, `.mirror-rtl` CSS), `packages/config` (Tailwind preset + logical-utility plugin), `packages/eslint-config` / `@zync/eslint-plugin` (new `prefer-logical-properties` rule).
- **Libraries:** Tailwind v4 logical utilities + custom `tailwindcss/plugin` for any gaps; `Intl.NumberFormat` / `Intl.DateTimeFormat` (built-in) for currency/date formatting; i18next (already wired by `system-i18n`).
- **Cloudflare bindings:** none new — presentation only.
- **No DB / Drizzle changes.** No new migrations.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — tokens & utilities | 1, 2 | `packages/config/tailwind.preset.ts`, `packages/ui/src/styles/rtl.css` | Tasks 1 & 2 parallel |
| B — helpers & primitives | 3, 4, 5 | `packages/ui/src/lib/lang.tsx`, `packages/ui/src/lib/format.ts`, icon registry, `Input` | 3,4 parallel; 5 after 3 |
| C — app wiring | 6, 7 | `apps/zync-app/src/root.tsx`, app-shell `Sidebar`, `apps/zync-www` layout | 6 & 7 parallel |
| D — enforcement & verification | 8, 9 | `@zync/eslint-plugin`, component RTL audit | after A–C |

## Tasks

### Task 1: Logical-property Tailwind utilities (preset plugin)
**Blocks:** 5, 6, 7, 9  ·  **Blocked by:** —
**Files:**
- Modify: `packages/config/tailwind.preset.ts`
**Steps:**
- [ ] Import `tailwindcss/plugin` in the preset.
- [ ] Add a `plugin(({ addUtilities }) => { /* register utilities */ })` entry that registers the full logical-utility set for margin, padding, border, and inset on the 8px grid (only grid-legal values: 8/16/24/32/48/64/96px), so authors never need physical properties.
- [ ] Register `.ms-auto` / `.me-auto` (margin-inline auto) for flex alignment.
- [ ] Register `.text-start` / `.text-end` if Tailwind v4 logical text-align utilities are not already present.
- [ ] Keep all values referencing existing spacing tokens; do not introduce off-grid values (`no-hardcoded-spacing` still applies).
**Schema / Interfaces:**
```ts
// packages/config/tailwind.preset.ts
import plugin from 'tailwindcss/plugin'

export const logicalUtilitiesPlugin = plugin(({ addUtilities }) => {
  addUtilities({
    '.ms-auto': { 'margin-inline-start': 'auto' },
    '.me-auto': { 'margin-inline-end': 'auto' },
    '.ms-2': { 'margin-inline-start': '0.5rem' },  // 8px
    '.me-2': { 'margin-inline-end': '0.5rem' },
    '.ms-4': { 'margin-inline-start': '1rem' },    // 16px
    '.me-4': { 'margin-inline-end': '1rem' },
    '.ms-6': { 'margin-inline-start': '1.5rem' },  // 24px
    '.me-6': { 'margin-inline-end': '1.5rem' },
    '.ms-8': { 'margin-inline-start': '2rem' },    // 32px
    '.me-8': { 'margin-inline-end': '2rem' },
    '.ps-2': { 'padding-inline-start': '0.5rem' },
    '.pe-2': { 'padding-inline-end': '0.5rem' },
    '.ps-4': { 'padding-inline-start': '1rem' },
    '.pe-4': { 'padding-inline-end': '1rem' },
    '.ps-6': { 'padding-inline-start': '1.5rem' },
    '.pe-6': { 'padding-inline-end': '1.5rem' },
    '.ps-8': { 'padding-inline-start': '2rem' },
    '.pe-8': { 'padding-inline-end': '2rem' },
    '.border-s': { 'border-inline-start-width': '1px' },
    '.border-e': { 'border-inline-end-width': '1px' },
    '.inset-s-0': { 'inset-inline-start': '0' },
    '.inset-e-0': { 'inset-inline-end': '0' },
    '.text-start': { 'text-align': 'start' },
    '.text-end': { 'text-align': 'end' },
    '.float-start': { float: 'inline-start' },
    '.float-end': { float: 'inline-end' },
  })
})

export default {
  // existing preset keys (colors, spacing, typography) retained above
  plugins: [logicalUtilitiesPlugin],  // append to the existing plugins array
}
```
**Acceptance:**
- [ ] `ps-4`, `pe-4`, `ms-auto`, `me-auto`, `inset-s-0`, `inset-e-0`, `text-start`, `text-end` resolve to logical CSS in built output.
- [ ] No off-grid spacing value is introduced; `no-hardcoded-spacing` lint still passes on the preset.

### Task 2: RTL CSS (icon mirroring + numeric alignment)
**Blocks:** 5, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/ui/src/styles/rtl.css`
- Modify: `packages/ui/src/styles/index.css` (import `rtl.css`)
**Steps:**
- [ ] Define the `.mirror-rtl` rule that flips horizontally only when the document is RTL.
- [ ] Define a `.num` (numeric) helper that forces `text-align: end` and `font-variant-numeric: tabular-nums` for amount/percent/count columns — applied regardless of direction (numbers read LTR in Hebrew documents).
- [ ] Honor `prefers-reduced-motion`: the mirror transform is static (no animation), so no motion guard is needed, but ensure any sidebar collapse transition that pairs with mirroring is wrapped by the existing reduced-motion guard (do not add new always-on transitions).
- [ ] Ensure the file uses no hardcoded colors (OKLCH-only rule still applies — this file sets no colors).
**Schema / Interfaces:**
```css
/* packages/ui/src/styles/rtl.css */
[dir="rtl"] .mirror-rtl { transform: scaleX(-1); }

/* Numeric cells/inputs: always LTR reading order, end-aligned. */
.num {
  text-align: end;
  font-variant-numeric: tabular-nums;
}
```
**Acceptance:**
- [ ] Under `<html dir="rtl">`, an element with `.mirror-rtl` is horizontally flipped; under `ltr` it is not.
- [ ] `.num` cells are end-aligned in both directions.

### Task 3: Language-direction helpers (`isHebrew`, `<AutoText>`)
**Blocks:** 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/ui/src/lib/lang.tsx`
- Modify: `packages/ui/src/index.ts` (export `isHebrew`, `AutoText`)
**Steps:**
- [ ] Implement `isHebrew(str)` detecting Hebrew Unicode (U+0590–U+05FF and presentation forms) as a majority share of characters.
- [ ] Implement `<AutoText>` that wraps a user-generated string in a `<span dir="auto" lang={isHebrew(value) ? 'he' : 'en'}>` — for entity names (customer/vendor/project) whose language is uncertain.
- [ ] Document (JSDoc) that `AutoText` must NOT be used for fixed translated strings (status labels, category names, formatted dates) nor for structured data (invoice numbers, tax IDs, amounts → use `dir="ltr"` instead).
**Schema / Interfaces:**
```tsx
// packages/ui/src/lib/lang.tsx
export function isHebrew(str: string): boolean {
  if (!str) return false
  const heChars = str.match(/[֐-׿יִ-ﭏ]/g)?.length ?? 0
  return heChars / str.length > 0.5
}

export interface AutoTextProps {
  children: string
  className?: string
  as?: 'span' | 'div'
}

/** Wrap user-generated entity names whose language is uncertain.
 *  dir="auto" lets the browser pick direction from the first strong char;
 *  lang enables correct screen-reader pronunciation.
 *  Do NOT use for translated/fixed strings or structured numeric data. */
export function AutoText({ children, className, as = 'span' }: AutoTextProps) {
  const Tag = as
  return (
    <Tag dir="auto" lang={isHebrew(children) ? 'he' : 'en'} className={className}>
      {children}
    </Tag>
  )
}
```
**Acceptance:**
- [ ] `isHebrew('דוד כהן')` is `true`; `isHebrew('Acme Ltd')` is `false`.
- [ ] `<AutoText>דוד</AutoText>` renders `<span dir="auto" lang="he">דוד</span>`.

### Task 4: Locale-aware number, currency & date formatters
**Blocks:** 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/ui/src/lib/format.ts`
- Modify: `packages/ui/src/index.ts` (export `formatCurrencyILS`, `formatNumber`, `formatDate`)
**Steps:**
- [ ] Implement `formatCurrencyILS(amount, locale)` using `Intl.NumberFormat` with `style: 'currency'`, `currency: 'ILS'`, `minimumFractionDigits: 0` (Intl keeps digits LTR even in RTL context).
- [ ] Implement `formatNumber(value, locale, opts?)` and `formatDate(date, locale)` thin wrappers over `Intl.NumberFormat` / `Intl.DateTimeFormat`.
- [ ] Accept the i18n `Locale` type (`'he' | 'en'`) and map to BCP-47 (`he-IL` / `en-US`) internally for `Intl`.
- [ ] Do NOT render the Hebrew calendar here — that is owned by spec `hebrew-locale-dates` (117); default Gregorian via `Intl.DateTimeFormat`.
**Schema / Interfaces:**
```ts
// packages/ui/src/lib/format.ts
import type { Locale } from '@zync/types' // 'he' | 'en'

const BCP47: Record<Locale, string> = { he: 'he-IL', en: 'en-US' }

export function formatCurrencyILS(amount: number, locale: Locale): string {
  return new Intl.NumberFormat(BCP47[locale], {
    style: 'currency',
    currency: 'ILS',
    minimumFractionDigits: 0,
  }).format(amount)
}

export function formatNumber(
  value: number,
  locale: Locale,
  opts?: Intl.NumberFormatOptions,
): string {
  return new Intl.NumberFormat(BCP47[locale], opts).format(value)
}

export function formatDate(date: Date | string | number, locale: Locale): string {
  return new Intl.DateTimeFormat(BCP47[locale]).format(new Date(date))
}
```
**Acceptance:**
- [ ] `formatCurrencyILS(12500, 'he')` returns the `₪12,500` glyph sequence (LTR digits).
- [ ] `formatNumber`/`formatDate` produce locale-correct output for both `he` and `en`.

### Task 5: RTL-correct numeric `Input` & directional `Sidebar` icon
**Blocks:** 9  ·  **Blocked by:** 1, 2, 3
**Files:**
- Modify: `packages/ui/src/components/Input.tsx`
- Modify: `packages/ui/src/icons/` (icon registry) — add `mirror-rtl` to directional icons
**Steps:**
- [ ] Add a `numeric?: boolean` prop to `Input`. When `numeric`, set `dir="ltr"`, `inputMode="decimal"`, keep `type="text"` (avoid `type="number"`), and apply the `.num` end-alignment class. Placeholder stays LTR (`"0.00"`, `"1234567890"`).
- [ ] Ensure the document-direction label, helper text, and error message remain in document direction (RTL for Hebrew) — only the field value/placeholder is forced LTR.
- [ ] Position input affordances (clear button, password eye toggle) at `inset-inline-end` using the logical utilities from Task 1.
- [ ] In the icon registry, add the `mirror-rtl` class to directional icons (arrow left/right, chevron left/right, back arrow, send/forward). Do NOT mirror: hamburger menu, notification bell, search magnifier, calendar, shekel ₪.
- [ ] Confirm the app-shell `Sidebar` collapse chevron uses a mirrored directional icon and `inset-inline-start: 0` (logical) for its edge anchor — coordinate with Task 6.
**Schema / Interfaces:**
```tsx
// packages/ui/src/components/Input.tsx (additions)
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  numeric?: boolean   // amounts/percent/hours/invoice-no/phone/tax-id → forced LTR
}

// when numeric:
//   <input type="text" dir="ltr" inputMode="decimal" className={cn('num', className)} {...rest} />
// else: inherits document direction; text-align: start.
```
Icon mirroring map (apply `mirror-rtl` only to the YES set):
| Icon | mirror-rtl |
|------|-----------|
| arrow-left / arrow-right | yes |
| chevron-left / chevron-right | yes |
| back-arrow | yes |
| send / forward | yes |
| menu (hamburger) | no |
| bell | no |
| search | no |
| calendar | no |
| shekel (₪) | no |
**Acceptance:**
- [ ] `<Input numeric />` renders `dir="ltr" inputMode="decimal" type="text"` and end-aligns its value.
- [ ] In RTL, an arrow/chevron icon is visually flipped; bell/search/menu/calendar/shekel are not.

### Task 6: App root `dir`/`lang` binding (`zync-app`)
**Blocks:** 9  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-app/src/root.tsx`
**Steps:**
- [ ] Read the user locale from session claims (`user_preferences.locale`, surfaced by `foundation-auth-rbac`); fall back to tenant default then `en`.
- [ ] Render `<html lang={locale} dir={locale === 'he' ? 'rtl' : 'ltr'}>` so the first server-rendered paint is already correct (no LTR flash before hydration).
- [ ] Wire the runtime `useDirection` hook (from i18n) so a locale switch updates `document.documentElement.dir`/`lang` without reload; ensure the static attribute and the runtime hook agree (single source = locale).
- [ ] Verify the app-shell `Sidebar` anchors with `inset-inline-start: 0` so it sits on the right in RTL and left in LTR via logical CSS (no `[dir="rtl"]` layout override duplication).
**Schema / Interfaces:**
```tsx
// apps/zync-app/src/root.tsx
const dir = locale === 'he' ? 'rtl' : 'ltr'
return (
  <html lang={locale} dir={dir}>
    {/* head + <LocaleProvider> + <Outlet /> */}
  </html>
)
```
**Acceptance:**
- [ ] Loading the app with `user_preferences.locale = 'he'` renders `<html dir="rtl" lang="he">` on first paint.
- [ ] Switching locale to English at runtime flips `<html>` to `dir="ltr" lang="en"` without a reload.
- [ ] The sidebar renders on the right in RTL and the left in LTR.

### Task 7: Marketing site `dir`/`lang` binding (`zync-www`)
**Blocks:** 9  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-www/src/layouts/BaseLayout.astro` (the shared Astro layout `<html>` element)
**Steps:**
- [ ] Resolve the request locale (URL/locale param or `Accept-Language`, consistent with `system-i18n`).
- [ ] Set `<html lang={locale} dir={isHebrew ? 'rtl' : 'ltr'}>` where `isHebrew = locale === 'he'`.
- [ ] Ensure the Astro layout pulls the same Tailwind preset (logical utilities) and the Heebo font face for Hebrew, matching the app.
**Schema / Interfaces:**
```astro
---
const locale = /* resolved from request */;
const isHebrew = locale === 'he';
---
<html lang={locale} dir={isHebrew ? 'rtl' : 'ltr'}>
  <!-- head + slot -->
</html>
```
**Acceptance:**
- [ ] A Hebrew marketing page renders `<html dir="rtl" lang="he">`; English renders `ltr`/`en`.
- [ ] Logical-property layout on the marketing site flips correctly under RTL with no physical `left`/`right` overrides.

### Task 8: ESLint rule `prefer-logical-properties`
**Blocks:** 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/eslint-plugin/src/rules/prefer-logical-properties.ts`
- Modify: `packages/eslint-plugin/src/index.ts` (register rule)
- Modify: `packages/eslint-config/index.js` (enable rule for `apps/**` and `packages/ui/**`)
**Steps:**
- [ ] Implement an ESLint rule that flags banned physical directional CSS in JSX `className`/`style`, CSS-in-JS object literals, and (where lintable) `.css` via the existing CSS lint path: `margin-left`, `margin-right`, `padding-left`, `padding-right`, `border-left`, `border-right`, positioned `left:`/`right:`, `text-align: left`, `text-align: right`, `float: left`/`float: right`, plus Tailwind physical classes `ml-*`, `mr-*`, `pl-*`, `pr-*`, `left-*`, `right-*`, `text-left`, `text-right`.
- [ ] Provide the required replacement in the message (e.g. `margin-left → margin-inline-start`, `pl-4 → ps-4`, `text-right → text-end`).
- [ ] **Exception:** allow `text-align: end` / `text-end` on numeric columns; do NOT flag `dir="ltr"` on numeric inputs. The rule targets layout direction, not the explicit numeric LTR override.
- [ ] Wire the rule into the shared config at `error` severity for `apps/**` and `packages/ui/**`, matching how `no-hardcoded-colors` / `no-hardcoded-spacing` are scoped.
**Schema / Interfaces:**
```ts
// packages/eslint-plugin/src/rules/prefer-logical-properties.ts
// Rule id: 'prefer-logical-properties'
// Reports banned physical CSS / Tailwind classes; suggests the logical equivalent.
const PHYSICAL_TO_LOGICAL: Record<string, string> = {
  'margin-left': 'margin-inline-start',
  'margin-right': 'margin-inline-end',
  'padding-left': 'padding-inline-start',
  'padding-right': 'padding-inline-end',
  'border-left': 'border-inline-start',
  'border-right': 'border-inline-end',
  'text-align: left': 'text-align: start',
  'text-align: right': 'text-align: end',
  'float: left': 'float: inline-start',
  'float: right': 'float: inline-end',
  // Tailwind classes:
  'ml-': 'ms-', 'mr-': 'me-', 'pl-': 'ps-', 'pr-': 'pe-',
  'left-': 'inset-s-', 'right-': 'inset-e-',
  'text-left': 'text-start', 'text-right': 'text-end',
}
```
**Acceptance:**
- [ ] Linting a file containing `className="pl-4"` or `style={{ marginLeft: 8 }}` errors with the logical-property suggestion.
- [ ] `text-end` / `.num` and `dir="ltr"` on numeric inputs do NOT trigger the rule.
- [ ] Rule is enabled at `error` in `apps/**` and `packages/ui/**`.

### Task 9: Component RTL audit & verification pass
**Blocks:** —  ·  **Blocked by:** 1, 2, 5, 6, 7, 8
**Files:**
- Modify: any `packages/ui/**` / `apps/**` component flagged by the new lint rule
**Steps:**
- [ ] Run the `prefer-logical-properties` lint across the repo and convert every flagged physical property/class to its logical equivalent.
- [ ] Apply the Component RTL Checklist to shared primitives and app-shell chrome: no physical `left`/`right` in source; flex rows use `gap` (not inter-item margins) so RTL flip is automatic; absolute overlays use `inset-inline-*`; directional icons carry `mirror-rtl`; numeric columns use `.num` / `text-end` (never `text-right`).
- [ ] Verify each interactive primitive (menu, dropdown, popover, dialog, toast, sidebar) renders and is operable under `<html dir="rtl">`.
- [ ] Confirm focus rings, scrollbars, and form-control mirroring follow from the single `<html dir>` (no per-component direction overrides).
- [ ] Preserve a11y: `AutoText` wraps uncertain user-generated entity names with `lang`; ARIA roles and labels on chrome are unchanged and correct in both directions.
**Acceptance:**
- [ ] `prefer-logical-properties` lint passes repo-wide with zero violations.
- [ ] Every shared primitive renders correctly with sidebar on the right, mirrored directional icons, and end-aligned numeric columns under `dir="rtl"`.
- [ ] No `[dir="rtl"]` layout-duplication overrides exist (only the single `.mirror-rtl` and `.num` helpers).
