Skip to content

Make the theme direction-aware so an RTL locale is possible #13

Closed

Description

@TitusKirch

Before you start

  • I searched existing issues and didn't find a duplicate

What problem are you trying to solve?

The layer ships five languages and seven locales, all left-to-right. Adding Arabic, Hebrew or Persian is not a locale entry — the theme would render mirrored-wrong, because its spacing, borders and positioning are written in physical directions rather than logical ones.

Measured over the <template> blocks and the stylesheets, comments excluded:

  • 124 direction-dependent utilities against 5 logical ones, spread across 56 of 190 .vue files.
  • By family: ml- 34, pl- 22, left- 21, right- 19, translate-x- 11, pr- 8, border-l- 5, rounded-r- 1.

The plumbing is further along than that suggests, which is why this is worth doing as its own change rather than discovering it inside a locale PR:

  • app/app.vue already sets dir on <html>, read from the current locale's own dir field. No locale declares one, so it resolves to ltr every time — the wire is in place and carries nothing.
  • app/error.vue sets lang but not dir, so an error page would stay LTR even once the rest works.

Until this lands, "add an RTL language" is a request the layer has to decline, and it declines it silently — nothing fails, the site just renders wrong.

Proposed solution

Make direction a property of the theme rather than an assumption in it, in three parts:

  1. Convert the physical utilities to logical ones where direction actually matters — ml-/mr- → ms-/me-, pl-/pr- → ps-/pe-, left-/right- → start-/end-, border-l-/border-r- → border-s-/border-e-, text-left/text-right → text-start/text-end. Not every occurrence is a candidate: an icon that must sit on the visual left regardless of script, and anything driven by a chevron's rotation, stays physical on purpose. The distinction has to be made per site, which is what makes this a review rather than a search-and-replace.
  2. Handle what logical properties do not reach — translate-x-, the transforms behind the sheet and the sidebar, and any origin-/scroll- anchoring that moves with the writing direction.
  3. Give error.vue the same dir the app shell already sets.

The definition of done is that a locale declaring dir: 'rtl' renders correctly without further per-component work — not that any RTL language ships. Whether one does is a separate decision.

Alternatives considered

  • Do it as part of adding the first RTL language. Rejected: it would bury a 56-file theme change inside a translation PR, and the review would be about the wrong thing. The conversion is also useful on its own — logical properties are the correct spelling regardless of whether an RTL locale ever exists.
  • A CSS-level flip ([dir="rtl"] overrides or a postcss mirror plugin). Rejected: it puts a second, invisible source of truth beside the utility classes, and every future component has to remember it exists. Tailwind's logical utilities are the mechanism the framework already provides.
  • Leave it and declare the layer LTR-only. Defensible while the project is private and has no RTL consumer, but it should be a stated position rather than an accident of how the CSS was typed.

Additional context

  • Counted with the <template> bodies and app/assets/css/*.css only, HTML and CSS comments stripped, so prose containing the words "left" or "right" is not included.
  • Related: the language evaluation this came out of concluded that Latin- and Cyrillic-script languages cost only the layer's 153 interface keys, that CJK is cheaper than expected because the search is Fuse.js fuzzy matching rather than a whitespace tokenizer, and that RTL is the one script class blocked by the theme itself. This issue is that blocker.
  • No RTL language is requested by anyone yet. This is deliberately about capability, not about shipping a locale.

Decided (refinement, 2026-09-11):

  • Technical content remains explicitly LTR inside an RTL interface. This includes code blocks and inline code, shell commands, URLs, file paths, API endpoints, version strings, and request/response examples. Direction-sensitive theme chrome mirrors; technical syntax does not.
  • This settles which physical direction-dependent rules are intentional rather than migration omissions.

Activity

  1. added
    featureNew feature or capability
    needs triageNot yet triaged by a maintainer
    ai: readyScoped + approved for an AI agent to pick up
    and removed
    needs triageNot yet triaged by a maintainer
    on Sep 8, 2026
  2. added
    ai: workingAn AI agent is implementing (incl. committed but not yet pushed)
    and removed
    ai: readyScoped + approved for an AI agent to pick up
    on Sep 11, 2026
  3. self-assigned this
    on Sep 11, 2026
  4. TitusKirch commented on Sep 11, 2026

    @TitusKirch
    MemberAuthor

    Implemented on dev as 8fd818a (shared-branch mode, no PR).

    Test discipline. Seams taken from this issue's body, which is the approval that stands in for a human here, and driven red-green:

    1. A pure locale → direction resolver (app/utils/direction.ts), because app.vue and error.vue are two shells and the error page replaces the app rather than nesting inside it — which is exactly how it came to set lang and never dir. Seven cases in tests/direction.test.ts, red on a missing module first.
    2. A source invariant (tests/direction-utilities.test.ts): no physical direction-dependent utility in app/ outside a declared, reasoned allowlist, and no declaration its file no longer carries. Written with an empty allowlist, red over 162 findings, then driven to green. This is the family contrast.test.ts and i18n-ownership.test.ts belong to — check:a11y runs axe in jsdom, which has no layout engine, and no RTL locale ships, so nothing else in this repo can see a pl- typed tomorrow.
    3. Both shells declare direction and both mount reka's ConfigProvider — reka's useDirection injects that context and falls back to ltr without ever consulting the document, so <html dir> alone would leave every menu, select, tooltip and scroll area the other way round. Proven to bite by reverting error.vue to its old shape: 2 failed, then restored.

    Scope note for the reviewer. 131 utilities across 58 files converted; 39 remain physical, each declared with a reason, in four kinds: centring, a side floating-ui already resolved, geometry after a rotation, and a prop that names a screen edge. UiSheetContent and UiSidebar gained start/end beside left/right, never instead of them.

    Two things worth a reviewer's eye, neither a defect:

    What is verified, and what cannot be. pnpm check green on the exact pushed tree: 3944 tests, lint, format, both typechecks, policy parity, devtools previews, SSR build (38 routes prerendered, zero createImage timeout), a11y over 15 pages, SEO, images. Nothing rendered right-to-left was ever observed — jsdom has no layout, and no RTL locale ships, so the guard proves the spellings and not the result. The definition of done is a claim about what a locale declaring dir: 'rtl' would do, and that claim rests on review, not on a gate.

    Deliberately out of scope: a docs/6.conventions/3.direction.md. I wrote one and removed it — the repo translates 152/152 docs pages and mine would have been the only gap, and this repo documents once a feature is settled rather than when implementation ends. The stated rule lives in the guard test's header, where it is enforced rather than merely written. It is the natural follow-up, with its four translations, once this clears review.

  5. 15 remaining items

  6. added
    ai: changes requestedAI review requested changes - back to the implement loop
    ai: workingAn AI agent is implementing (incl. committed but not yet pushed)
    and removed
    ai: reviewingAn AI agent is reviewing the pushed work
    ai: changes requestedAI review requested changes - back to the implement loop
    on Sep 12, 2026
  7. TitusKirch commented on Sep 12, 2026

    @TitusKirch
    MemberAuthor

    Re-work round (round 2 feedback), pushed to dev as a27a9d8 and 0f0146e (shared-branch mode, no PR). All three findings addressed.

    1. The three physical declarations (blocking) — fixed

    app/assets/css/duxt.css, the .duxt-release list: padding-left: 0 and padding-left: 1.05rem are now padding-inline-start, and the bullet's left: 0 is inset-inline-start: 0. Nothing else in that block named a side.

    2. The guard could not see declaration-level CSS (blocking) — closed, and driven red first

    tests/direction-utilities.test.ts now matches two more pattern sets beside the utility tokens, against the comment-blanked source of every .vue, .ts and .css file it already walked:

    • A property that names a side by its own name — (margin|padding|border|scroll-margin|scroll-padding)-(left|right) with the optional -width/-style/-color tail, the four physical border-*-radius corners, and a bare left:/right:. The bare branch needs a boundary in front of it so --duxt-sidebar-left: is not read as one; inset:, inset-block-*, top/bottom and the vertical radii are absent on purpose, the same reason inset-x-* is absent from the utility set.
    • A neutral property whose value names one — text-align, float, clear.

    Findings are normalised so the allowlist survives a value being tuned: padding-left: drops its value (an indent moving from 1.05rem to 1.2rem is not a direction decision), while text-align: right keeps it (there the value is the finding). Both carry the shape of a declaration, so a failure line distinguishes itself from a utility without explaining itself.

    They feed the same PHYSICAL_ON_PURPOSE map, so the two intentional rules are now recorded rather than silently exempt:

    • assets/css/duxt.css → margin-right:, text-align: right — the opt-in line numbers, inside pre/code, which the unicode-bidi: isolate rule at the top of the file pins left-to-right. A gutter that followed the reader would land on the far side of the run it numbers. The same reason is now also a comment on the rule itself, since it read identically to the three above it.
    • components/OgImage/Duxt.satori.vue → margin-left: — a finding the review did not name, surfaced by the new patterns. satori is not a browser: marginInlineStart appears nowhere in satori@0.33.4's dist (only marginLeft and yoga's EDGE_LEFT/EDGE_START), so a logical spelling would be dropped and the gap would vanish rather than mirror. The image is also a fixed composition carrying no dir.

    Red before green, twice. The new patterns went in first and failed on exactly the three findings (assets/css/duxt.css left:, assets/css/duxt.css padding-left:) with the two intentional ones already declared and passing; the fix turned it green. Then a throwaway .probe { padding-left: 1rem; text-align: left } appended to typeset.css — the file the old guard could not see at all — failed as assets/css/typeset.css padding-left: and assets/css/typeset.css text-align: left, and was reverted. The hole is closed for both stylesheets, for @apply chains, and for a style="" attribute in a template.

    3. UiSheetContent's default side (non-blocking) — answered by moving it

    Moved to end, which is the answer SidebarProps.side already gave; I could not argue the asymmetry. A sheet opened with no side named is not one somebody pinned to an edge, it is one nobody thought about, and the theme's default for that should follow the reader. Nothing moves today — end resolves to right in every locale this layer ships, and both call sites here (DuxtHeader's side="start", Sidebar's :side="resolvedSide") name their side explicitly. left and right keep meaning the screen's edges, and the prop doc now says so and says why the default is not one of them.

    Gate

    pnpm check green on the exact pushed tree (0f0146e), run in a clean worktree off origin/dev with a fresh pnpm install --frozen-lockfile: 82 test files / 4003 tests, lint, format, both typechecks, policy parity, devtools previews, SSR build (38 routes prerendered, zero createImage timeout), a11y over 15 pages, SEO, images.

    One note for whoever verifies this independently: a detached worktree placed inside .git/ does not build. pnpm build:app there dies with defineMcpTool is not defined, and tests/shortcuts.test.ts and tests/nuxt-process-guard.test.ts fail there too — all three identically with and without this change, i.e. on pristine origin/dev. Something in the module scan ignores paths containing .git. A worktree outside the repo is green. Worth knowing before a red run there gets attributed to a diff.

    What is still not verified, unchanged from round 1

    Nothing was observed rendering right-to-left. jsdom has no layout and no RTL locale ships, so the guard proves the spellings and not the result; the definition of done remains a claim about what a locale declaring dir: 'rtl' would do, resting on review rather than on a gate.

  8. added
    ai: reviewingAn AI agent is reviewing the pushed work
    and removed
    ai: workingAn AI agent is implementing (incl. committed but not yet pushed)
    on Sep 12, 2026
  9. TitusKirch commented on Sep 12, 2026

    @TitusKirch
    MemberAuthor

    AI review — accepted (round 3 of 3, commits a27a9d8 and 0f0146e on dev, measured against origin/dev).

    All three of round 1's findings, re-stated unchanged in round 2, are now closed. This is the last round before the maxRounds cap, and it passes on its merits rather than by running out of rounds.

    1. The three physical CSS declarations (blocking) — fixed

    app/assets/css/duxt.css: the .duxt-release list now reserves its indent with padding-inline-start (both rules) and draws its bullet at inset-inline-start: 0. Verified by reading the file rather than the commit message: a grep for a property or value naming a side across app/assets/css/*.css returns exactly two hits, and both are the opt-in line-number gutter — the pair that is physical on purpose. Nothing else in either stylesheet names a side any more.

    2. The guard could not see declaration-level CSS (blocking) — closed, and the exemptions became decisions

    tests/direction-utilities.test.ts now matches two pattern sets beside the utility tokens — a property that names a side by its own name, and a neutral property whose value does — against the same comment-blanked source and, crucially, the same PHYSICAL_ON_PURPOSE allowlist. Three things about the implementation are right and worth recording:

    • The bare left:/right: branch carries a (?:^|[\s;{}]) boundary, so --duxt-sidebar-left: is not read as a declaration.
    • Normalisation is asymmetric on purpose and correctly so: padding-left: drops its value (an indent moving is not a direction decision), text-align: right keeps it (there the value is the finding). That is what lets the allowlist survive a tuned value without going blind.
    • The two previously-silent exemptions are now written down with a reason — the line-number gutter inside pre/code, which the unicode-bidi: isolate rule pins LTR, and satori's margin-left in the OG template, which has no logical spelling to move to. The second was surfaced by the new patterns rather than by a human, which is the guard doing its job on its first run. The reason is also now a comment on the CSS rule itself, as asked.

    I re-ran the two direction suites against the pushed tree: 16 tests, green.

    3. UiSheetContent's default side (non-blocking) — answered by moving it

    withDefaults(..., { side: 'end' }), matching SidebarProps.side, with the rationale in the prop's doc comment rather than only in the commit. Checked that the move is inert here and safe there: 'end' is a real branch in sheetVariants' class chain (inset-y-0 end-0 … border-s plus the ltr:/rtl: slide pair), left/right keep their screen-edge meaning, and both in-repo call sites name a side explicitly (DuxtHeader side="start", Sidebar :side="resolvedSide"). Nothing moves in any locale this layer ships; what changes is the consumer who writes a bare <UiSheetContent> and later declares dir: 'rtl'.

    Gate

    Run against the pushed head (0f0146e), which is what the working tree holds, clean:

    • pnpm lint, pnpm format, pnpm typecheck, pnpm typecheck:app, pnpm check:policy — green.
    • pnpm build:app — green, a full SSR build. Worth stating plainly, because the build was reported as failing on dev with defineMcpTool is not defined since 703412e: it did not reproduce here, and nuxt dev in www/ also starts and serves. Whatever produced that failure is environment-bound (the earlier note about a worktree placed inside .git/ points the same way), not a property of dev.
    • pnpm check:previews (10), check:a11y (15 pages), check:seo (4 pages), check:images (6 images) — green.
    • pnpm test — 4002 of 4003 passed. The one failure is tests/nuxt-process-guard.test.ts > keeps serving when a .env change restarts Nuxt dev, which spawns a real nuxt dev and polls for HTTP 200 with a 90 s budget. On this machine a cold nuxt dev in www/ takes longer than that on its own (the icon client bundle alone compiles in ~29 s), so the poll expires before the server is ready. It is a timeout against machine speed, and a change consisting of three CSS declarations, a test file and one prop default cannot reach it. Not attributed to this issue — but it is a real flake with a real trigger, and it will fire for anyone on a slow runner.

    One new observation, non-blocking, not a condition of this accept

    app/components/DuxtJsonEditor.vue L118/L120 set paddingLeft and paddingRight in the CodeMirror EditorView.theme object. The rules themselves are correct as they stand — the editor holds a JSON request body, which the refinement decision pins explicitly LTR alongside code blocks and shell commands, exactly like the line-number gutter that is already declared. But they are CSS-in-JS in camelCase, which neither the utility patterns nor the new declaration patterns can see, so they are silently exempt rather than declared — the same shape as finding #2, one notation narrower. If the guard's header is to keep claiming that a physical rule typed tomorrow is caught, camelCase style objects are the remaining notation, and this one entry is what belongs in PHYSICAL_ON_PURPOSE when it is. Worth a follow-up issue, not another round.

    What remains unverified, unchanged since round 1 and stated rather than hidden

    Nothing was observed rendering right-to-left. jsdom has no layout, check:a11y cannot see it, and no RTL locale ships — so the guard proves the spellings, not the result. The definition of done is a claim about what a locale declaring dir: 'rtl' would do, and it rests on this review. I read it as met: every side-naming rule in the layer is now either logical or declared with a reason, both shells set dir and mount reka's ConfigProvider, and the two components that take a side offer logical values and default to them.

  10. added
    ai: doneAI-reviewed and accepted
    and removed
    ai: reviewingAn AI agent is reviewing the pushed work
    on Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions