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

276 lines
13 KiB

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?

Powered by TurnKey Linux.