23 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 → ancestor assertion (DirectionContext) → soma.prefs.getDir() → 'ltr'
Four links, ratified 2026-08-05. The context link is the "implicit
inheritance" phase left open when the chain was first fixed: every activeDir
call publishes its assertion (prop ?? inherited) into DirectionContext
and consults the nearest ancestor's before falling back to prefs. Composition
carries the assertion by physics — a canon component mounted inside an asserted
subtree, in place or through a portal (the Portal forwards Svelte context),
resolves the ancestor's assertion without anyone writing dir= at the call
site.
What remains true: a component still does not read the DOM. The DOM dir
attribute is a projection of the assertion/preference, never a source.
The chain is split at the assertion. activeDir returns the ASSERTION
(prop → ctx) and stops there — that is what the attribute stamps and what
crosses portals. The preference is deliberately NOT part of it: prefs reach
the page once, through the boot's automatic DOM projection on <html>, and
everything below inherits. Only the MATHS still needs the preference as a
value (it cannot read the DOM), and that tail lives in one helper:
| Link | Who runs it | Where |
|---|---|---|
prop → ctx |
activeDir(getter) |
the wrapper .svelte, at Provider.create(…) |
→ prefs → 'ltr' |
resolveDir(dir, soma) |
the provider, once, in resolvedDir |
A component with no soma provider runs the same first links through
activeEidosDir — see §7.
activeDir (src/uix/soma/direction.ts) returns
Active<Direction | undefined> — the raw assertion. The maths tail lives
per-provider:
readonly resolvedDir = $derived.by(() => resolveDir(this.opts.dir, this.soma));
Why the chain is split. undefined means nobody asserted a direction —
neither the consumer via the prop, nor an ancestor via its own assertion, 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.
Composition carries the assertion — by physics
The chain is per-component, so a component that MOUNTS another canon component
starts a second chain inside the first. Before the context link that second
chain skipped the prop, landed on prefs, and stamped a value of its own — and a
stamped dir cuts inheritance for everything below it: the parent asserted
rtl, the child stamped ltr, the subtree split. The framework carried the
assertion by convention (a "forward dir= at the call site" rule with
hand-written forwards) until 2026-08-05; the convention kept being missed in
the field, which is why it is now the resolver's job.
In place nothing needs writing: the child's activeDir consults
DirectionContext before prefs, so it resolves — and stamps — the ancestor's
assertion. A submenu inheriting from its parent menu is the same mechanism,
not a special case.
Through a portal the panel is rendered outside its owner's subtree, so DOM
inheritance cannot reach it; the Svelte context still does (the Portal forwards
contexts), and soma/layers/floating additionally composes the owner link once
for every overlay:
the surface's own dir prop → the owning provider's asserted dir → omit
FloatingProviderOpts.dir is where the owner hands its assertion down;
FloatingContent.assertedDir is where the two meet. The consequence for anyone
writing an overlay:
A
Contentwrapper passes itsdirprop raw —readableActive(() => dir), neveractiveDir. Running the chain there makes the value always concrete, and the owner's assertion could then never win the fallback.
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 by default (Exclude<P[K], undefined>), which would destroy exactly the distinction §1 is built on. A component using that helper listsdirin thePreserveparameter —OptsFromProps<P, Managed, StateKey, 'dir'>— soActive<Direction | undefined>survives, and the wrapper passes the box straight through the bag:dir: activeDir(() => dir)is a validbindPropsentry (Active pass-through), keeping the call — and the context it publishes — visible in the wrapper init.
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
A component that accepts
dirMUST stamp it on the element that carries the direction-dependent paint.
The attribute is the ONLY way the paint learns what the prop asserted. Miss it
and the component 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.
:dir() is not the only reader, and reading it as the trigger for this rule is
too narrow. Everything below resolves against the element's own computed
direction:
:dir()in the recipe;- logical properties —
margin-inline-start,inset-inline-end,text-align: start, andflex-direction: row, which reverses; - SVG
text-anchor, which is logical (§7).
Which is to say: in practice a component with any horizontal layout at all is
direction-dependent, and the honest reading of the rule is that accepting dir
implies stamping it.
The morfo declares it, the type demands the wire, the runtime stamps it
The rule above used to be a convention: one hand-written line per provider, 49 copies of it, and 20 of 55 components that had simply forgotten. A convention repeated 49 times that a third of the catalogue breaks is not a convention — it is a missing abstraction.
So the mechanism lives where contracts live. The MORFO declares that the
component has a direction (and which parts carry it); the requirement is
COMPUTED from that declaration through soma.runtime<M>; the runtime writes
the attribute:
// morfo — the declaration
direction: {}, // stamps on 'provider'
direction: { parts: ['trigger'] }, // a root that renders no element
// provider — the wire the type now demands
this.runtime = this.soma.runtime(xMorfo, {
dir: this.opts.dir, // the raw ASSERTION, never resolved
// …
});
A morfo that declares direction makes dir required — forgetting the
wire does not compile. A morfo that doesn't makes it forbidden — a
component with no reading direction can never gain a stray attribute, and the
~75 direction-less runtimes never mention the axis at all. direction.parts
is validated against the declared part tree when the morfo compiles: an
unknown name throws instead of silently stamping nothing. A provider must
not write dir into its render props — the runtime already put it there.
A secondary runtime of the SAME morfo (an item, a cell, a sentinel) meets the
same requirement and passes the same owner assertion; the stamp only lands on
direction.parts, which those runtimes never register.
The census guard (src/uix/morfo/direction-census.test.ts) closes the one
edge the type cannot see — the public prop and the morfo live in different
files — by crossing dir?: Direction in each component's props with the
morfo declaration, in both directions, with the signed exceptions listed
inline (field-langs, waveform, and the three pure overlays whose stamp
belongs to the floating layer).
The open question is still WHICH element, not whether, and now it is
answered in the declaration: direction.parts names the parts that carry the
stamp, defaulting to ['provider']. It is the element the paint sits on:
- Ordinary components — the provider root, which is the default.
- A root that renders no element — the stamp goes to the part that IS the
in-place half (
dropdown-menu→parts: ['trigger']). - A field that paints on both its shell and its input —
parts: ['provider', 'input']. - Portalled surfaces — the floating wrapper, which the floating layer
stamps from
assertedDir(§1). It has to: portalled to<body>, it is outside the component's subtree and cannot inherit from the trigger. A component whose only direction-dependent paint is portalled names that part (drawer/float-panel→parts: ['content']); one that merely composes an overlay is covered provided it hands that overlay itsdir. - A component with both halves — a trigger in place and a menu portalled — needs both.
- Nothing painted from CSS at all — geometry computed and applied by JS —
needs no attribute, and adding one only enlarges the DOM: the morfo simply
declares no
direction.
Ask what the paint reads and where it lives, then name that part.
The exception: :dir() translating an already-logical prop
Not every :dir() rule is about asserting a direction. Some components expose
the axis themselves, as a logical prop — a badge at top-start or
top-end, an image anchored at start or end. There the consumer has already
said which side they mean, in logical terms; :dir() is only supplying the
physical form CSS lacks: the sign of a translate, or object-position: left
where no object-position: start exists.
Those components must NOT take a dir prop. It would be a second knob for
an outcome the first knob already decides, and the two would fight rather than
compose — the logical anchor resolves from the ambient direction while the prop
claims to set it. The :dir() rule needs the direction that actually laid the
element out, which is the inherited one, by design and not by omission.
The test is whether the component has its own logical vocabulary for the axis.
A Switch has none — its thumb travels one way and direction is the only input,
so asserting dir on it is meaningful. An Avatar.Badge has position, so it
is not.
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 — or the environment
seed — 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.
The projection is automatic in standalone boot (ratified 2026-08-05):
components stamp only ASSERTED directions, so the page must reflect the
ambient preference or nothing does. projectPrefs: false opts out when the
app owns <html> (i18n by routing, its own projection); in attachActiveUix
it is opt-IN for the same reason. And the boot SEEDS the environment from a
hand-set <html dir> (readPrefsEnvironmentFromDom) so the projection adopts
the page's own assertion instead of rewriting it with the language-derived
value — precedence intent > environment seed > derive(language) > default.
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.
Server rendering owns the first paint
The projection runs in the client. The <html dir> of the FIRST render
therefore belongs to app.html or the server (the react-aria model): an app
whose default direction is RTL sets <html dir="rtl" lang="ar"> at the
document template — the seed adopts it, the projection keeps it, and there is
no LTR flash. Components ship no dir in SSR output unless the consumer
asserted one, which is the same rule they follow in the client.
Detail of the preference and its projection: src/arts/prefs/README.md.
7. Components with no soma provider
A few components are eidos-only: no provider, no morfo, no Soma. The chain
still applies to them — only the entry points differ, because the maths tail
is handed a Soma in one layer and an ActivePrefs in the other:
| Layer | Assertion | Maths tail |
|---|---|---|
| soma | activeDir(getter) |
resolveDir(dir, soma) |
| eidos | activeEidosDir(getter) |
resolveEidosDir(dir, eidos.prefs) |
Both assertion entry points publish into the SAME DirectionContext — shared
on purpose, so an eidos-only chart inside an asserted soma subtree (or the
reverse) resolves the same fact. Both return Direction | undefined; the raw
stamp is the consumer's, exactly as in §1 and §2. The adapter in the maths
tail is the whole difference: ActiveEidos.prefs is the raw arts service,
which has no getDir() — that method lives on the soma-facing view.
The chart family is the worked example. It resolved by reading
getComputedStyle(node).direction off its own element until the entry point
existed, because the chain was genuinely not runnable from eidos.
⚠️
diron an<svg>does nothing. The attribute is mapped to thedirectionproperty by the HTML UA stylesheet, which does not reach SVG elements — measured in Chrome, an<svg dir="ltr">under adir="rtl"ancestor still computesrtl. Stamp the chart's HTML wrapper (<figure>,<div data-chart-frame>) instead. This matters more than it looks: SVGtext-anchoris LOGICAL, so it resolves against the inherited direction. Get the stamp wrong and the maths mirrors while the labels do not, which is §2's split-in-half failure wearing a different hat.
The mirroring rules a graphic must obey are worked out per chart 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)? (A portalledContentis the exception — it passes the prop raw; §1.) - Does the MORFO declare
direction(withpartswhen the paint is not on the provider root), and does the provider wiredir: this.opts.dir— the raw assertion? (Either half missing does not compile; the census guard crosses prop and morfo.) Does the provider default once, inresolvedDir = resolveDir(this.opts.dir, this.soma), and never elsewhere? In-place children need nothing —DirectionContextcarries the assertion — but a PROVIDER a component creates directly (every picker'sPopoverProvider.create) still receives itsdiropt: provider creation is not a component boundary, so no context is published in between. - 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?