13 KiB
| title | type | audience | authority | status | enforcement | related | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Direction Contract — how a component obtains, asserts and paints its reading direction | canon | human + agent | canonical — the resolution chain, which attribute carries it, and which selector form may read it | current | §4 only — scripts/rtl-check.ts (rules RTL-1 and RTL-2, `error`) · `npm run rtl:check`. §1–§3 and §5 have no mechanical guard; they are held by review and by the acceptance rules. |
|
Direction Contract
Reading direction is the one cross-cutting fact that every layer touches: the consumer may assert it, the app derives it, soma computes with it, the DOM carries it, and eidos paints from it. This chapter fixes where each of those happens and, more importantly, where they must not.
There are two separate questions, and conflating them is the source of every direction bug this framework has shipped:
- What direction does this component compute with? — a concrete value, used for arrow keys, pointer sign, placement flips, scroll maths.
- What direction does this component assert to the DOM? — an attribute that must be absent when nobody asserted anything.
The answers are deliberately different types.
1. The chain
prop dir → soma.prefs.getDir() → 'ltr'
Three links, no fourth. There is no parent step: a component does not
read its ancestor provider's direction, and it does not read the DOM. The DOM
dir attribute is a projection of the preference, never a source.
| Link | Who runs it | Where |
|---|---|---|
prop → prefs |
activeDir(getter, soma) |
the wrapper .svelte, at Provider.create(…) |
→ 'ltr' |
resolvedDir |
the provider, once |
activeDir (src/uix/soma/direction.ts) returns
Active<Direction | undefined> — it deliberately stops after the second link.
The 'ltr' tail lives per-provider:
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
Why the chain is split. undefined means nobody asserted a direction —
neither the consumer via the prop nor the app via a registered direction
preference. That is not the same fact as 'ltr', and every === 'rtl' test in
the codebase erases the difference. So the provider keeps both: resolvedDir
for its own maths, and the raw opts.dir.current for the DOM.
A component that legitimately needs a fourth link — a submenu inheriting from its parent menu — composes it at the call site, not by adding a step to the resolver:
Provider.create({
// …
dir: activeDir(() => dir ?? parentMenu?.opts.dir.current, soma)
});
The public prop
Every component that has any direction-dependent behaviour or paint declares:
dir?: Direction;
Direction is the alias, never the union written by hand. Writing
dir?: 'ltr' | 'rtl' compiles identically and leaves the contract outside the
alias — widening or renaming Direction would not reach it, and the drift would
not break at type level, which is the whole point of having the alias.
⚠️
OptsFromPropsstrips theundefinedfrom an optional public prop (Exclude<P[K], undefined>), yieldingActive<'ltr' | 'rtl'>. That destroys exactly the distinction §1 is built on. A component using that helper declaresdirseparately —ActiveProps<{ dir: Direction | undefined }>— and wires it outsidebindProps, which takes getters rather thanActives.
2. The two attributes
| Attribute | Value | Present when | Read by |
|---|---|---|---|
dir |
raw opts.dir.current |
someone asserted a direction | the browser (:dir(), bidi, UA sheet) |
data-dir |
resolved resolvedDir |
the component chose to stamp it | recipes that need an unconditional hook |
dir is a native HTML attribute with real platform semantics: it drives the
bidi algorithm, isolates its subtree, and is what :dir() matches against. It
is stamped raw. Stamping a defaulted 'ltr' is what makes a component force
its own subtree back to LTR inside an RTL page; an omitted attribute inherits,
for free, with no DOM read.
data-dir is the component's own attribute, carrying the resolved value — so
where a component stamps it, it is always present, unlike dir. It exists for
the case where a recipe needs a selector that always matches: a glyph that must
be flipped to agree with maths the component already did. Using it is not a
violation of §3; it is a different attribute answering a different question.
But it is opt-in, not routine. The acceptance rule that visual props map to
data-{prop} does not apply here — dir is native, :dir() cannot see
data-dir, and a component that stamps data-dir without a recipe rule reading
it has only enlarged the DOM. Stamp it when a recipe needs it, and not before.
When stamping is mandatory
If the recipe branches with
:dir(), the provider MUST stampdir.
:dir() matches the element's resolved direction, which without an attribute
is whatever it inherited. A component that accepts the prop, runs the chain, and
does not stamp is split in half: the prop moves the maths and leaves the
paint behind. Consumer asserts dir="rtl" on an LTR page, the arrow keys
flip, and nothing mirrors.
The inverse is also true — a component whose direction-dependence is purely JavaScript does not need the attribute, and adding it only enlarges the DOM. Ask who reads the direction, then stamp accordingly.
3. Selector doctrine
| Form | Verdict |
|---|---|
:dir(rtl) |
the canon |
[dir='rtl'] … |
forbidden |
[data-dir='rtl'] |
legitimate — a different attribute (§2) |
[dir='rtl'] fails twice. The attribute is not defaulted, so the common case is
an element with no dir at all and the selector never matches; and as a
descendant selector it ignores any nearer dir that re-declares the axis.
:dir() reads the resolved direction and has neither failure.
4. The trap the guard exists for
A logical anchor paired with a physical displacement. inset-inline-start
flips with the direction; transform: translateX(-50%) does not. Put them in
the same block and the element lands a full offset off-axis in RTL — the anchor
moved, the transform did not.
RTL-1 (src/uix/eidos/rtl-lint.ts, run by npm run rtl:check) is the guard.
It reads CSS text and flags an inline-axis translate inside a block that anchors
on inset-inline* / margin-inline*. The sanctioned escape is a
rtl-physical: <reason> comment when the geometry genuinely is physical.
Know its blind spot before trusting a green run: it sees a pairing only when
both halves are CSS declarations. An anchor in the recipe plus a transform
written inline by JS is the same defect and is invisible to it — as is the same
pairing living in a shared motion preset rather than in a recipe.
The double flip
The opposite mistake, and the harder one to see: a :dir(rtl) rule whose body
only re-assigns logical properties. A logical property has already mirrored
by the time that rule matches, so moving it from inline-start to inline-end
sends it back to where it started — and, since the sibling declarations were
left alone, it lands on the opposite edge from the thing it belongs to. An
indent on one side with its rail on the other is the signature.
The tell is the pair: inside a :dir() block, the same logical family declared
on both faces, one turned off (0, auto, none) and the other repainted.
Delete the rule and check the base declaration instead — it had already
mirrored.
RTL-2 guards this shape, in the same file and the same run as RTL-1. Its
escape hatch is rtl-mirror: <reason>, a different marker from RTL-1's
rtl-physical: because it declares a different thing. Reach for it only when a
design breaks the mirror on purpose; the fix is almost always the deletion.
Two further physical forms the guard cannot reach, both of which must be checked by eye in RTL:
- SVG geometry and
text-anchor— a graphic with a reading axis mirrors; a radial one does not.text-anchoris logical: leave it alone when the composition mirrors, force the physical value when it does not. - Inline
transformwritten by a component — beat it with the independentrotate/translate/scaleproperties, which compose withtransforminstead of replacing it, rather than escalating to!important.
5. Behaviour that mirrors, and behaviour that does not
Mirroring is decided by whether the interaction runs along a reading axis, not by whether it is horizontal.
- Mirrors — anything the user reads or traverses in sequence: arrow-key
navigation (via
getDirectionalKeys), carousel travel, a slider's value ramp, a colour channel spread across the inline dimension, a heat-map's columns, a calendar's months. - Does not mirror — geometry that is intrinsically physical: radial controls (a colour wheel, a dial), compass-named resize handles, a freely positioned panel's coordinates, an image being panned inside a viewport.
The distinction is the same one the references draw, and it is why a colour area mirrors while a colour wheel does not.
When a component's own geometry is physical but its handles are placed with
logical insets, translate the logical edge into a physical one once, at the
boundary, rather than sprinkling === 'rtl' through the handlers.
Scrolling
In RTL the CSSOM scroll model runs negative: scrollLeft rests at 0 at
the start of the content and decreases to -(scrollWidth - clientWidth).
Distance along the reading axis and distance from the physical left edge are
therefore two different quantities, and a component that needs both must name
both.
6. The app-global half
prefs.direction is the single source of the app's effective direction: it
derives from prefs.language unless an explicit intent overrides it.
ActivePrefsDomProjection writes that value to <html dir> — together with
<html lang>, which travels with it because the browser reads both from the DOM
for font selection, hyphenation and screen-reader announcement.
That projection is the reason the common case needs no per-component
assertion at all: the page declares its direction once at the root, every
component inherits it, and :dir() resolves correctly with no attribute in
between. The per-component prop exists for the other case — asserting a
direction for one subtree against the page's.
Detail of the preference and its projection: src/arts/prefs/README.md.
7. Declared divergence — the chart family
The chart components are eidos-only: they have no soma provider, no dir prop,
and therefore no chain to run. They resolve direction by reading
getComputedStyle(node).direction off their own element and re-reading it on a
MutationObserver for <html dir>. That is the one place in the catalogue that
does what §1 forbids.
It is recorded here rather than hidden because the workaround has a real cause —
the resolved direction is available on the element even where no direction
preference is registered — and because the fix is known: charts should accept
dir like everything else, at which point the DOM read goes away. Until then, a
chart mirrors with the page and cannot be flipped for one subtree.
The mirroring rules a chart must obey are the same ones as everywhere else, and
are worked out per graphic in
src/uix/eidos/components/chart/README.md §Direction.
8. Checklist
When you touch a component's direction behaviour:
- Is
dir?: Directiondeclared, using the alias? - Does the wrapper run
activeDir(() => dir, soma)? - Does the provider default once, in
resolvedDir, and never elsewhere? - Does the recipe branch with
:dir()? Then is the rawdirstamped? - Does any block pair a logical anchor with a physical displacement?
(
npm run rtl:check) - Are the SVG, the inline transforms and the animation presets checked by eye, in RTL — the three places the guard cannot see?