You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/canon/direction-contract.md

476 lines
24 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
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 `<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:
```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<P[K], undefined>`), which would destroy exactly the
> distinction §1 is built on. A component using that helper lists `dir` in the
> `Preserve` parameter — `OptsFromProps<P, Managed, StateKey, 'dir'>` — so
> `Active<Direction | undefined>` 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<M>`; 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 `<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 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: <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-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
`<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 root SEEDS the environment from the
AUTHOR's `<html dir>` — the document template's or the server's
(`readPrefsEnvironmentFromDom`) — so the projection adopts that assertion
instead of rewriting it with the language-derived value — precedence
`intent > environment seed > derive(language) > default`.
**That is the one `dir` read back from the DOM** (§1: the attribute is a
projection, never a source). Everything UIX writes to `<html dir>` — the
pre-hydration boot and every runtime projection — carries the ownership mark
`data-dir-projected` (`PREFS_DIR_PROJECTED_ATTR`), and the seed skips a marked
`dir` by presence; the author's `dir`, adopted, is never marked. A script that
writes over a marked `dir` is not a source either: at run time a direction is
asserted with `prefs.setIntent('direction', …)` or `options.prefs.environment`,
both of which beat the seed. The mark's value names its owner, and a
projection's `dispose` retires its attributes only while the mark still names
it — the next root is created before the old one is destroyed.
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`](../../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 `<svg>` 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 `<svg dir="ltr">` under a `dir="rtl"`
> ancestor still computes `rtl`. Stamp the chart's HTML wrapper (`<figure>`,
> `<div data-chart-frame>`) 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?

Powered by TurnKey Linux.