Repository navigation
Make the theme direction-aware so an RTL locale is possible #13
Description
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
.vuefiles. - 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.vuealready setsdiron<html>, read from the current locale's owndirfield. No locale declares one, so it resolves toltrevery time — the wire is in place and carries nothing.app/error.vuesetslangbut notdir, 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:
- 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. - Handle what logical properties do not reach —
translate-x-, the transforms behind the sheet and the sidebar, and anyorigin-/scroll-anchoring that moves with the writing direction. - Give
error.vuethe samedirthe 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 andapp/assets/css/*.cssonly, 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
feat(theme): make direction a property of the theme
TitusKirch commentedon Sep 11, 2026
Implemented on
devas8fd818a(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:
app/utils/direction.ts), becauseapp.vueanderror.vueare two shells and the error page replaces the app rather than nesting inside it — which is exactly how it came to setlangand neverdir. Seven cases intests/direction.test.ts, red on a missing module first.tests/direction-utilities.test.ts): no physical direction-dependent utility inapp/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 familycontrast.test.tsandi18n-ownership.test.tsbelong to —check:a11yruns axe in jsdom, which has no layout engine, and no RTL locale ships, so nothing else in this repo can see apl-typed tomorrow.ConfigProvider— reka'suseDirectioninjects that context and falls back toltrwithout 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 revertingerror.vueto 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.
UiSheetContentandUiSidebargainedstart/endbesideleft/right, never instead of them.Two things worth a reviewer's eye, neither a defect:
-ml-1.5 first:ml-0→-ms-1.5 first:ms-0). Both intents are intact — the avatars, ring, alt text and "+N" badge are untouched; only the spelling changed. Same for Center and reorganize the mobile footer #41's footer, which had deliberately used only logical or axis-relative utilities, and for thev-if="controls.copyPage"gate Show contributors in release history #67's frontmatter work added.app/assets/css/duxt.css,app/utils/direction.ts, and one test name intests/direction.test.ts). Prose only, and I chose not to double a held integration lock re-gating for it.What is verified, and what cannot be.
pnpm checkgreen on the exact pushed tree: 3944 tests, lint, format, both typechecks, policy parity, devtools previews, SSR build (38 routes prerendered, zerocreateImage 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 declaringdir: '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.15 remaining items
fix(theme): see the direction in a declaration, not only in a utility
feat(sheet): default the sheet's side to the reader's end
TitusKirch commentedon Sep 12, 2026
Re-work round (round 2 feedback), pushed to
devasa27a9d8and0f0146e(shared-branch mode, no PR). All three findings addressed.1. The three physical declarations (blocking) — fixed
app/assets/css/duxt.css, the.duxt-releaselist:padding-left: 0andpadding-left: 1.05remare nowpadding-inline-start, and the bullet'sleft: 0isinset-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.tsnow matches two more pattern sets beside the utility tokens, against the comment-blanked source of every.vue,.tsand.cssfile it already walked:(margin|padding|border|scroll-margin|scroll-padding)-(left|right)with the optional-width/-style/-colortail, the four physicalborder-*-radiuscorners, and a bareleft:/right:. The bare branch needs a boundary in front of it so--duxt-sidebar-left:is not read as one;inset:,inset-block-*,top/bottomand the vertical radii are absent on purpose, the same reasoninset-x-*is absent from the utility set.text-align,float,clear.Findings are normalised so the allowlist survives a value being tuned:
padding-left:drops its value (an indent moving from1.05remto1.2remis not a direction decision), whiletext-align: rightkeeps 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_PURPOSEmap, 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, insidepre/code, which theunicode-bidi: isolaterule 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:marginInlineStartappears nowhere insatori@0.33.4's dist (onlymarginLeftand yoga'sEDGE_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 nodir.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 totypeset.css— the file the old guard could not see at all — failed asassets/css/typeset.css padding-left:andassets/css/typeset.css text-align: left, and was reverted. The hole is closed for both stylesheets, for@applychains, and for astyle=""attribute in a template.3.
UiSheetContent's default side (non-blocking) — answered by moving itMoved to
end, which is the answerSidebarProps.sidealready 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 —endresolves torightin every locale this layer ships, and both call sites here (DuxtHeader'sside="start",Sidebar's:side="resolvedSide") name their side explicitly.leftandrightkeep meaning the screen's edges, and the prop doc now says so and says why the default is not one of them.Gate
pnpm checkgreen on the exact pushed tree (0f0146e), run in a clean worktree offorigin/devwith a freshpnpm install --frozen-lockfile: 82 test files / 4003 tests, lint, format, both typechecks, policy parity, devtools previews, SSR build (38 routes prerendered, zerocreateImage 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:appthere dies withdefineMcpTool is not defined, andtests/shortcuts.test.tsandtests/nuxt-process-guard.test.tsfail there too — all three identically with and without this change, i.e. on pristineorigin/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.TitusKirch commentedon Sep 12, 2026
AI review — accepted (round 3 of 3, commits
a27a9d8and0f0146eondev, measured againstorigin/dev).All three of round 1's findings, re-stated unchanged in round 2, are now closed. This is the last round before the
maxRoundscap, 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-releaselist now reserves its indent withpadding-inline-start(both rules) and draws its bullet atinset-inline-start: 0. Verified by reading the file rather than the commit message: a grep for a property or value naming a side acrossapp/assets/css/*.cssreturns 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.tsnow 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 samePHYSICAL_ON_PURPOSEallowlist. Three things about the implementation are right and worth recording:left:/right:branch carries a(?:^|[\s;{}])boundary, so--duxt-sidebar-left:is not read as a declaration.padding-left:drops its value (an indent moving is not a direction decision),text-align: rightkeeps it (there the value is the finding). That is what lets the allowlist survive a tuned value without going blind.pre/code, which theunicode-bidi: isolaterule pins LTR, and satori'smargin-leftin 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 itwithDefaults(..., { side: 'end' }), matchingSidebarProps.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 insheetVariants' class chain (inset-y-0 end-0 … border-splus theltr:/rtl:slide pair),left/rightkeep their screen-edge meaning, and both in-repo call sites name a side explicitly (DuxtHeaderside="start",Sidebar:side="resolvedSide"). Nothing moves in any locale this layer ships; what changes is the consumer who writes a bare<UiSheetContent>and later declaresdir: '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 ondevwithdefineMcpTool is not definedsince703412e: it did not reproduce here, andnuxt devinwww/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 ofdev.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 istests/nuxt-process-guard.test.ts > keeps serving when a .env change restarts Nuxt dev, which spawns a realnuxt devand polls for HTTP 200 with a 90 s budget. On this machine a coldnuxt devinwww/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.vueL118/L120 setpaddingLeftandpaddingRightin the CodeMirrorEditorView.themeobject. 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 inPHYSICAL_ON_PURPOSEwhen 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:a11ycannot 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 declaringdir: '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 setdirand mount reka'sConfigProvider, and the two components that take a side offer logical values and default to them.