packages/cli/skills/pikku-rtl/SKILL.md
Make a Pikku frontend work in both English (LTR) and Arabic / right-to-left languages. Direction is derived from the active locale, applied once at the document root, and the layout mirrors itself — but only if styling is written flow-relative (margin-inline-start, text-align: start, Mantine ms/me) instead of left/right. TRIGGER when: adding Arabic (or Hebrew/Farsi/Urdu), asked to "support RTL / right-to-left / bidi / mirror the layout", or writing layout styles in an app that may run RTL. Builds on pikku-i18n (an RTL language is just another locale file). DO NOT TRIGGER for backend functions or for LTR-only copy changes.
npx skillsauth add pikkujs/pikku pikku-rtlInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill sits on top of pikku-i18n. That skill maps a locale to t()
tokens; this one adds the second axis: a locale also has a direction.
Arabic is not special-cased — it is just another locale file (ar.json,
registered satisfies typeof en) plus the document being told it is rtl.
Set dir once at the document root from the active locale, then let the
browser and Mantine mirror everything — provided every custom style is written
flow-relative (start/end), never physical (left/right). Get those two
things right and Arabic, Hebrew, Farsi and Urdu all work with zero per-component
RTL code.
t() token via
pikku-i18n. Arabic copy goes in i18n/ar.json, mirroring en.json's keys,
registered with satisfies typeof en so a missing key is a compile error.const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
}
(The bundled templates already ship this helper — use it, don't reinvent it.)dir + lang at the root, once, from the active locale — pick the
recipe for your framework below.tsc, then load ?i18n-debug / set dir and
eyeball that the layout mirrors and nothing is stuck on the wrong edge.Use the inline-axis logical property; never the physical one:
| Don't (physical) | Do (flow-relative) |
| ---------------------------- | -------------------------------------------- |
| margin-left / marginLeft | margin-inline-start / marginInlineStart |
| margin-right | margin-inline-end / marginInlineEnd |
| padding-left/right | padding-inline-start/end |
| left: 0 / right: 0 | inset-inline-start: 0 / inset-inline-end |
| text-align: left/right | text-align: start / end |
| border-top-left-radius | border-start-start-radius |
| float: left/right | float: inline-start / inline-end |
In Mantine, use the logical style props — they emit the logical CSS above:
| Don't | Do |
| ----------- | ----------- |
| ml / mr | ms / me |
| pl / pr | ps / pe |
Mantine's own components already use logical properties internally, so once the direction is set they mirror automatically — you only have to be disciplined in your styles.
Leave flexbox and grid alone. display:flex already follows dir:
justify-content: flex-start resolves to the right edge under RTL on its own.
Never "fix" RTL by swapping to flex-direction: row-reverse or reordering DOM —
that double-flips and breaks the moment direction changes. The DOM order is
logical order; let dir handle the visual order.
Mantine ships first-class RTL: wrap the tree in DirectionProvider and set the
matching dir on <html>.
import { DirectionProvider, MantineProvider } from '@mantine/core'
import i18n, { detectLocale, localeDir } from './i18n/config'
const locale =
typeof window !== 'undefined' ? detectLocale(window.location.pathname) : 'en'
const dir = localeDir(locale)
if (typeof document !== 'undefined') {
document.documentElement.lang = locale
document.documentElement.dir = dir // Mantine + browser read this
}
root.render(
<DirectionProvider initialDirection={dir}>
<MantineProvider theme={theme} defaultColorScheme="dark">
{/* …app… */}
</MantineProvider>
</DirectionProvider>
)
To flip direction live (a language switcher) call
document.documentElement.setAttribute('dir', localeDir(next)) and Mantine's
useDirection().setDirection(dir); both read the same value.
No Mantine — just put dir/lang on <html> at bootstrap, after the locale is
detected (the same detectLocale the i18n config uses):
import { detectLocale, localeDir } from './i18n/config'
const locale = detectLocale(window.location.pathname)
document.documentElement.lang = locale
document.documentElement.dir = localeDir(locale)
Everything below inherits dir from <html>; logical CSS does the mirroring.
The worker renders the full HTML, so set lang/dir on the server <html>
from the URL locale (the client inherits it on hydration — no flash):
import { detectLocale, localeDir } from './i18n/config'
const locale = detectLocale(new URL(request.url).pathname)
const dir = localeDir(locale)
const html = `<!doctype html>
<html lang="${locale}" dir="${dir}">
…
</html>`
i18next's active language must match: call i18n.changeLanguage(locale) before
renderToString so the SSR'd text and dir agree.
Set it on the <html> in app/layout.tsx. With locale-prefixed routes the
segment gives the locale; for a single-locale build it's a constant:
import { localeDir, defaultLocale } from './i18n/config'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const locale = defaultLocale // or the [lang] route segment / params
return (
<html lang={locale} dir={localeDir(locale)}>
<body>{children}</body>
</html>
)
}
For output: 'export' with /ar prefixes, derive locale from the route
segment so each statically-exported tree carries the right dir.
Logical properties mirror box layout, not glyphs. An icon that points
somewhere (chevron, back/next arrow, send, undo) must flip under RTL; a
non-directional icon (search, settings, avatar) must not. Flip with the
:dir() selector — no JS, no per-locale branching:
:dir(rtl) .icon-directional {
transform: scaleX(-1);
}
Or in CSS-in-JS / inline, gate on the resolved direction:
transform: localeDir(locale) === 'rtl' ? 'scaleX(-1)' : undefined.
Prefer logical icon components if your icon set ships them.
font-family so both scripts look intentional.Intl.NumberFormat/Intl.DateTimeFormat (or i18next formatters) given the
active locale, so Western vs Arabic-Indic digits follow the locale choice.line-height
on Arabic body text avoids clipping. Keep it locale-scoped, not global.i18n/ar.json mirroring en.json; register
ar: { translation: ar satisfies typeof en } and add 'ar' to
supportedLocales. (Type-complete or it won't compile — the deploy blocks.)localeDir helper includes ar (it does by default).dir from the locale (recipe above).left/right, ml/mr, text-align: left with the flow-relative equivalent; revert any manual row-reverse.tsc, then load the Arabic route and verify the whole layout mirrors —
sidebar on the right, text right-aligned, arrows pointing the other way.left/right (or ml/mr) in any new layout style — even
in an English-only app. Writing logical from the start is the seam Arabic
slots into, exactly like tokens are for copy.flex-direction: row-reverse, reversed DOM order, or
per-locale if (rtl) layout branches. Set dir once; let layout follow.dir on individual components — it belongs on <html> so the whole
document (and Mantine) agrees.t() token system; an RTL language is
a normal locale, governed by pikku-i18n.documentation
Deprecated — use pikku-middleware instead. Tag middleware (addTagMiddleware) is now documented as a section within the pikku-middleware skill, alongside global HTTP middleware, execution order, and the service-to-service bearer auth pattern.
testing
Use when adding authorization checks to Pikku functions or routes — pikkuPermission, pikkuAuth, per-function permissions, pattern-based permissions, or understanding OR/AND permission logic. TRIGGER when: user wants to restrict who can call a function, check resource ownership, add role-based access, or understand where permission checks belong. DO NOT TRIGGER when: user asks about middleware or request interception (use pikku-middleware), authentication strategies (use pikku-security), or session management.
testing
Use when adding any middleware to a Pikku app — global HTTP middleware, tag-scoped middleware (including service-to-service bearer auth), per-route middleware, session-setting middleware, or understanding middleware execution order and priority. TRIGGER when: user wants middleware on some or all routes, machine-to-machine auth, tag-scoped cross-cutting concerns, global interceptors, or middleware priority/order questions. DO NOT TRIGGER when: user asks about permissions/authorization checks (use pikku-permissions), auth strategies like authBearer/authCookie (use pikku-security), or deployment.
documentation
Standard cleanup to run right after a Pikku template is cloned or scaffolded into a new project. TRIGGER when: a Pikku template was just cloned/scaffolded (via `pikku create`, `git clone <template>`, or the user says "I cloned the kanban template / starter / template"), or the working tree still looks like an untouched template (template README, placeholder `@project/*` name in package.json). DO NOT TRIGGER when: working in an established project mid-feature, or editing the template repo itself.