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

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

⚠️ OptsFromProps strips the undefined from an optional public prop (Exclude<P[K], undefined>), yielding Active<'ltr' | 'rtl'>. That destroys exactly the distinction §1 is built on. A component using that helper declares dir separately — ActiveProps<{ dir: Direction | undefined }> — and wires it outside bindProps, which takes getters rather than Actives.


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 stamp dir.

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

  1. Is dir?: Direction declared, using the alias?
  2. Does the wrapper run activeDir(() => dir, soma)?
  3. Does the provider default once, in resolvedDir, and never elsewhere?
  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.