--- title: Direction Contract — how a component obtains, asserts and paints its reading direction type: canon audience: human + agent authority: canonical — the resolution chain, which attribute carries it, and which selector form may read it status: current enforcement: §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. related: resolver: src/uix/soma/direction.ts (activeDir, and why it can return undefined) guard: src/uix/eidos/rtl-lint.ts · scripts/rtl-check.ts (RTL-1 · RTL-2) prefs: src/arts/prefs/README.md (the app-global preference and its DOM projection) architecture: docs/architecture/active-architecture.md (where prefs sits in the runtime) guide: docs/guides/component-guide.md (the RTL rows of the build contract) checklist: docs/guides/completion-checklist.md (the acceptance rules) svg: src/uix/eidos/components/chart/README.md (§Direction — mirroring a graphic) --- # 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: 1. **What direction does this component compute with?** — a concrete value, used for arrow keys, pointer sign, placement flips, scroll maths. 2. **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 ```text 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 ``, 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` — the raw assertion. The maths tail lives per-provider: ```ts 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: ```text 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 `Content` wrapper passes its `dir` prop **raw** — `readableActive(() => dir)`, > never `activeDir`. 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: ```ts 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. > ⚠️ `OptsFromProps` **strips the `undefined`** from an optional public prop by > default (`Exclude`), which would destroy exactly the > distinction §1 is built on. A component using that helper lists `dir` in the > `Preserve` parameter — `OptsFromProps` — so > `Active` survives, and the wrapper passes the box > straight through the bag: `dir: activeDir(() => dir)` is a valid > `bindProps` entry (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 `dir` MUST 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`, and `flex-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`; the runtime writes the attribute: ```ts // 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 ``, 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 its `dir`. - 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: ` 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: `, 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-anchor` is logical: leave it alone when the composition mirrors, force the physical value when it does not. - **Inline `transform` written by a component** — beat it with the independent `rotate` / `translate` / `scale` properties, which compose with `transform` instead 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 `` — together with ``, 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 `` (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 `` (`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 `` of the FIRST render therefore belongs to `app.html` or the server (the react-aria model): an app whose default direction is RTL sets `` 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`](../../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. > ⚠️ **`dir` on an `` does nothing.** The attribute is mapped to the > `direction` property by the HTML UA stylesheet, which does not reach SVG > elements — measured in Chrome, an `` under a `dir="rtl"` > ancestor still computes `rtl`. Stamp the chart's HTML wrapper (`
`, > `
`) instead. This matters more than it looks: SVG > `text-anchor` is 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`](../../src/uix/eidos/components/chart/README.md) §Direction. --- ## 8. Checklist When you touch a component's direction behaviour: 1. Is `dir?: Direction` declared, using the alias? 2. Does the **wrapper** run `activeDir(() => dir, soma)`? (A portalled `Content` is the exception — it passes the prop raw; §1.) 3. Does the MORFO declare `direction` (with `parts` when the paint is not on the provider root), and does the provider wire `dir: this.opts.dir` — the raw assertion? (Either half missing does not compile; the census guard crosses prop and morfo.) Does the provider default once, in `resolvedDir = resolveDir(this.opts.dir, this.soma)`, and never elsewhere? In-place children need nothing — `DirectionContext` carries the assertion — but a PROVIDER a component creates directly (every picker's `PopoverProvider.create`) still receives its `dir` opt: provider creation is not a component boundary, so no context is published in between. 4. Does the recipe branch with `:dir()`? Then is the raw `dir` stamped? 5. Does any block pair a logical anchor with a physical displacement? (`npm run rtl:check`) 6. Are the SVG, the inline transforms and the animation presets checked **by eye, in RTL** — the three places the guard cannot see?