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

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.
resolver guard prefs architecture guide checklist svg
src/uix/soma/direction.ts (activeDir, and why it can return undefined) src/uix/eidos/rtl-lint.ts · scripts/rtl-check.ts (RTL-1 · RTL-2) src/arts/prefs/README.md (the app-global preference and its DOM projection) docs/architecture/active-architecture.md (where prefs sits in the runtime) docs/guides/component-guide.md (the RTL rows of the build contract) docs/guides/completion-checklist.md (the acceptance rules) 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

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 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:

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:

// 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 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.

⚠️ 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 §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.