docs(direction): el contrato pasa a ser canon, y el corpus deja de contradecirlo
El eje estaba cerrado en CODIGO y no en DOCUMENTACION. El contrato vivia solo en
`CONTINUE-direction.md`, un handoff que `docs/README.md` declara «never a source
of truth». Un capitulo E2 lo fija ahora: `docs/canon/direction-contract.md`.
Va a canon y no a arquitectura por la misma razon que `recipe-contract.md`: es
normativo y tiene guard ejecutable. Cubre la cadena y donde corre cada eslabon;
por que `undefined` no es `'ltr'`; las DOS atributos —`dir` crudo (nativo, lo que
`:dir()` mira) y `data-dir` resuelto (opt-in, para recetas que necesitan un hook
incondicional)—; cuando el estampado es OBLIGATORIO; la doctrina de selector; las
trampas que el guard no ve; que espeja y que no; y la mitad global de prefs.
EL CORPUS SE CONTRADECIA en cuatro sitios, y dos de ellos ENSENABAN mal:
- `component-guide.md:372` daba la cadena como `prefs → 'ltr'`, DOS eslabones,
mandando al wrapper a leer prefs directamente. Eso excluye la prop.
- `soma-architecture.md` §3.4 presentaba el resolutor a pelo (`soma.prefs.getDir()`
dentro del provider) como LA forma de obtener la direccion — justo lo que el eje
retiro del catalogo.
- `html.ts` ensenaba en su JSDoc `dir?: 'ltr' | 'rtl'`, el union a mano que la
regla E-2.5 ahora prohibe. El mecanismo que ensena (extender para estrechar una
clave HTML) es correcto y sobrevive; solo cambia el ejemplo.
- `active-architecture.md:416` no listaba `lang` en la proyeccion, contra
`contracts.ts` y contra su propio test de frontera. Igual `prefs/README.md`.
Y no lo mencionaba en absoluto: el glosario (`activeDir`, `resolvedDir`,
`data-dir`, RTL-1, el escape `rtl-physical:`), `eidos.md` (RTL-1 es el TERCER
guard de deriva y faltaba junto al builder tipado y eidos-lint), `soma.md`,
`building-a-component.md` (§Known traps es exactamente donde va «la prop mueve la
matematica y deja la pintura atras»), y la tabla de atributos de la arquitectura,
que afirma «todo lo que viaja entre capas viaja por atributos» y omitia `dir`.
El checklist gana cuatro reglas y el guard que faltaba (`rtl:check` no estaba en
la matriz de aceptacion pese a que la tabla canon lo nombra), mas un recorte en
E-3.5: «todas las props visuales mapean a `data-{prop}`» leia como mandato de
estampar `data-dir`, que `:dir()` no puede ver.
DIVERGENCIA DECLARADA (§7): la familia `chart` no tiene prop ni provider y
resuelve leyendo `getComputedStyle(node).direction`. Es el unico sitio del
catalogo que hace lo que §1 prohibe. Se registra en vez de esconderse.
Tres verificadores adversariales sobre el barrido; sus hallazgos, corregidos:
RTL-1 estaba anunciado como guard de TODO el capitulo (solo cubre §4) · «nunca
lee al padre» era absoluto y borraba la composicion sancionada en el punto de
llamada · el estampado se afirmaba incondicional en un sitio y condicional en
otro · las cuatro filas nuevas usaban una aplicabilidad que ni la leyenda ni
`component-audit.ts` conocen.
`docs:check` 0 errores sobre 564 docs. `check` 77 = la linea base.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
|
|
|
|
---
|
|
|
|
|
|
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 (rule RTL-1, `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)
|
|
|
|
|
|
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 → 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:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
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:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
Provider.create({
|
|
|
|
|
|
// …
|
|
|
|
|
|
dir: activeDir(() => dir ?? parentMenu?.opts.dir.current, soma)
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 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
|
|
|
|
|
|
> (`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 `Active`s.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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.
|
|
|
|
|
|
|
|
|
|
|
|
If a `:dir()` block contains nothing but `inset-inline-*`, `margin-inline-*`,
|
|
|
|
|
|
`padding-inline-*` or `border-inline-*`, it is almost certainly cancelling a
|
|
|
|
|
|
mirror rather than creating one. Delete it and check the base rule instead.
|
|
|
|
|
|
RTL-1 does not see this shape — it looks for a _physical_ displacement.
|
|
|
|
|
|
|
|
|
|
|
|
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`](../../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`](../../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?
|