feat(direction): eidos comparte el contexto, y el corpus registra el endgame

La entrada eidos se parte igual que la de soma: `activeEidosDir(dir)` devuelve
la AFIRMACION (prop -> ancestro) y publica en el MISMO DirectionContext — un
chart eidos-only dentro de un subarbol soma afirmado (o al reves) resuelve el
mismo hecho. La cola matematica va a `resolveEidosDir(dir, eidos.prefs)`, con
la vista de prefs cacheada por instancia (WeakMap): construirla dentro de una
funcion pura llamada por $derived era una alocacion por pasada.

createChartRtl consume las dos mitades por su lado: `attr` estampa la
afirmacion cruda (ausente hereda del <html> proyectado), `current`/`anchor`
resuelven la cola.

Corpus:

- direction-contract.md §2 pasa del campo obligatorio de 4.1 al mecanismo del
  morfo (declaracion -> tipo condicional -> estampado; secundarios del mismo
  morfo; el censo como guard), §7 documenta el contexto compartido y la
  tabla de entradas partidas, y el checklist §8.3 pide morfo + cable.
- docs/decisions.md registra la ratificacion D1-D4 del 2026-08-05 con las
  cuatro decisiones y su porque.
- CONTINUE-direction-runtime.md §11 cierra el handoff: tabla de fases con
  commits, verificacion final medida, la cola pospuesta por D4 y las trampas
  nuevas (pathspec SIEMPRE en rama compartida; el detector de huerfanos y la
  ruta del import; la vista en $derived).

check 77 = linea base · morfo+direction suites 131/131 · docs:check 0/566.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent fc84305c22
commit 8d1909b166

@ -183,31 +183,52 @@ 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 runtime stamps it, and the type makes you say so
### 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 stamp is the runtime's. A provider hands `soma.runtime(morfo, …)` its
direction and the runtime writes the attribute:
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: { get: () => this.opts.dir.current }, // RAW, never `resolvedDir`
dir: this.opts.dir, // the raw ASSERTION, never resolved
// …
});
```
`SomaRuntimeSources.dir` is **required**, so a component that forgets does not
compile. `null` is the only way out, and it says something true: this runtime
stamps no direction. A provider must not write `dir` into its render props any
more — the runtime already put it there.
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 same place: `dir.parts` names the parts that carry the stamp,
defaulting to `['provider']`. It is the element the paint sits on:
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
@ -221,18 +242,13 @@ defaulting to `['provider']`. It is the element the paint sits on:
(`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. The two agree today only because both descend from `<html dir>`;
assert by prop and they part company.
needs both.
- **Nothing painted from CSS at all** — geometry computed and applied by JS —
needs no attribute, and adding one only enlarges the DOM: `dir: null`.
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.
> ⚠️ One consequence worth knowing: the stamp ships through the runtime's part
> props, so it is present at runtime but **absent from the static type** of a
> provider's `props`. A test that asserts on it reads through
> `(props as Record<string, unknown>).dir`.
### The exception: `:dir()` translating an already-logical prop
Not every `:dir()` rule is about asserting a direction. Some components expose
@ -388,18 +404,20 @@ 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 point differs, because `activeDir` is
handed a `Soma` and they hold an `ActiveEidos`:
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 | Entry point | Service |
| ----- | ------------------------------------- | ------------- |
| soma | `activeDir(getter, soma)` | `Soma` |
| eidos | `activeEidosDir(getter, eidos.prefs)` | `ActivePrefs` |
| Layer | Assertion | Maths tail |
| ----- | ------------------------ | ----------------------------------- |
| soma | `activeDir(getter)` | `resolveDir(dir, soma)` |
| eidos | `activeEidosDir(getter)` | `resolveEidosDir(dir, eidos.prefs)` |
Both run the same two links and return `Direction | undefined`; the `'ltr'` tail
and the raw stamp are the consumer's, exactly as in §1 and §2. The adapter
between them is the whole difference: `ActiveEidos.prefs` is the raw arts
service, which has no `getDir()` — that method lives on the soma-facing view.
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
@ -426,11 +444,13 @@ 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 provider hand the runtime `dir: { get: () => this.opts.dir.current }`
— raw, with `parts` when the paint is not on the provider root? (It will not
compile otherwise.) Does it default once, in `resolvedDir`, and never
elsewhere? In-place children need nothing — `DirectionContext` carries the
assertion — but a PROVIDER a component creates directly (every picker's
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?

@ -87,4 +87,5 @@ Design records for component families whose doctrine spans several components
| [`LIBRO_VARIACIONES_Y_EXTENSIONES.md`](../src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md) | The running log of where the implementation deviates from (or extends) the book's editorial canon — verbs, adoptions, clusters, and the D.x architectural decisions. The seed for a consolidated decision-log. |
| [`GESTURES.md`](../src/uix/soma/layers/gesture/GESTURES.md) | The soma gesture layer design: `Gesture.base`/`drag`/`resize`, velocity ring-buffer, axis lock, deferred pointer capture. |
| [`architecture/active-architecture.md` §7 + `arts/adom`/`arts/perf` READMEs](./architecture/active-architecture.md) | **Sec-dom — read-timing & token-resolution (2026-06-29).** The framework governs layout READS like it governs writes: `dom.measure` (coalesced post-layout reads), `eidos.resolveToken` (token→colour in JS, no `getComputedStyle` probe), the discoverable `uix.color`/`uix.perf` surfaces. Decided: **reject** a static grep-guard (too noisy across ~120 legit reads, and it can't catch the sync-read-after-write _ordering_ nor cover routes) — the dev `uix.perf` detector (Long Animation Frames) is the runtime safety net instead. |
| [`canon/direction-contract.md`](./canon/direction-contract.md) §1, §2, §6 | **Direction endgame — physics over convention (ratified 2026-08-05).** Four decisions signed at once: (D1) the chain gains the CONTEXT link — every `activeDir` publishes its assertion and descendants consult it before prefs, the "implicit inheritance" phase reopened and ratified, closing both field-measured holes (in-place and portal) with one mechanism; (D2) the attribute stamps ONLY the assertion — prefs leave the per-component chain and reach the page once, through the now-AUTOMATIC boot projection (opt-out in standalone, opt-in in attach), with the environment SEED (`readPrefsEnvironmentFromDom`) adopting a hand-set `<html dir>` at precedence `intent > env > derive(language) > default`; (D3) the stamping mechanism belongs to the MORFO — `direction: { parts }` declares it, `soma.runtime<M>` computes the requirement from the declaration (required when declared, forbidden when not), `compileMorfo` validates part names fail-closed, and the census guard crosses prop ↔ morfo; (D4) the API census (7 components without the prop + chat-log) stays POSTPONED by explicit decision. |
| [`canon/direction-contract.md`](./canon/direction-contract.md) | **Direction — resolution, assertion and paint.** One chain resolves a component's reading direction, one native attribute asserts it to the DOM, one selector form reads it back. Decided: the resolver stops **before** the default and returns `undefined`, because _nobody asserted a direction_ is a different fact from the default; stamping `dir` is **conditional** on who reads the direction — mandatory when the recipe branches with `:dir()`, needless when the dependence is pure JavaScript; and the static guard (`RTL-1`, `npm run rtl:check`) is deliberately scoped to the one trap CSS text can reveal — a logical inline anchor paired with a physical inline translate — leaving the chain, the attribute and the selector form to review. |

@ -434,3 +434,57 @@ no es un hallazgo — repite la medida antes de perseguirlo.
⚠️ Las 5 demos tocadas **ya fallaban `prettier --check` en HEAD**, así que no se
formatearon (§9.4).
---
## 11. ENDGAME ejecutado — 2026-08-05 (P1–P5, decisiones D1–D4 firmadas)
El plan completo vive en la sesión que lo ejecutó; lo durable está en el canon
(`direction-contract.md` §1/§2/§6) y en `docs/decisions.md`. Resumen operativo:
| fase | commit | qué |
| --- | --- | --- |
| P1 | `9158d0796` | **DirectionContext dentro de `activeDir`** — la cadena gana el eslabón del ancestro; los 3 reenvíos de 4.3, el enlace manual del submenú y los 3 canarios de media-player se BORRAN. El repro de la otra sesión pasa sin ellos. |
| P2 | `973d2c886` | **Proyección automática + semilla** — `createActiveUix` proyecta por defecto (`projectPrefs:false` opt-out; attach opt-in); `readPrefsEnvironmentFromDom` siembra `<html dir>` puesto a mano (`intent > env > derive > default`). Los 3 cableados manuales borrados en el MISMO commit. |
| P3 | `01cc13d41` | **El flip** — `activeDir(dir)` devuelve la AFIRMACIÓN (prop ?? ctx), sin prefs y sin parámetro soma; la matemática vive en `resolveDir(dir, soma)`. En `auto` la página entera lleva UN `dir` (el `<html>`). SSR: cero `dir` en el payload. |
| P4 | `fc84305c2` | **El morfo declara** — `direction: { parts }` en 51 morfos; `soma.runtime<M>` computa el requisito del tipo (declarado ⇒ requerido; no ⇒ prohibido); `compileMorfo` valida parts fail-closed; censo prop↔morfo como guard (`direction-census.test.ts`) con las excepciones firmadas (field-langs, waveform, popover/tooltip/link-preview). La ceremonia de 4.1 (67+47 `dir: null`, 49 getters) MUERE. |
| P5 | (este commit) | `activeEidosDir` gana el contexto (compartido con soma) y se parte igual (`resolveEidosDir`); charts al día; corpus + decisiones + memoria. |
**El estado final del eje**: nadie escribe `dir=` a mano; ausente hereda de
verdad; olvidar el cable no compila; el ambiente llega al DOM una vez. Los tres
`dir=` de media-player eran los canarios y CANTARON (panel rtl sin ellos).
### Verificación final medida
- `auto`: **1** `[dir]` en toda la página (el `<html>` proyectado) — antes ~cada raíz.
- prefs rtl: select y su panel portalizado SIN atributo, computan rtl por herencia.
- prop rtl: trigger del dropdown estampa, el portal cruza, submenú `data-side=left`, 0 islas.
- matemática: slider sin atributo con página rtl — click 25% físico → 75, `ArrowRight` baja.
- SSR (curl): cero `dir=` en select y accordion.
- `check` 77 = línea base en todas las fases · `rtl:check` 1 (palabras) · `docs:check` 0/566 · suites 1401/1402 (el 1: `soma-attr-audit`, flaky bajo carga, pasa aislado).
### La cola que QUEDA (pospuesta por decisión D4 o ajena)
- **Censo de API**: `dir?: Direction` para gradient-picker, picker, grid-list,
tag-group, virtual-grid, virtual-list (drawer/float-panel/tree-grid ya la
tienen); y `chat-log` (2 errores de línea base: compone Feed/VirtualList sin
`dir` en OPTS — ojo, esto es la opt del provider, no el estampado).
- **Opts canónicas transversales** (bindProps v2) — eje propio; el censo
completo está en el reporte del agente de diseño de esta sesión.
- Deuda ajena intacta: 91 tests que falsean `Soma.require()` · cero tests de
charts · `smoke`/`perm:check` sin correr.
### Trampas nuevas de esta pasada, para no repetir
- `git commit` SIN pathspec en rama compartida se llevó por delante el índice
de la otra sesión una vez (b41669c43). Desde entonces: SIEMPRE `git commit --
<rutas>`.
- El detector de huérfanos por identificador se tropezó con la RUTA del import
(`core/soma.svelte` contiene "soma") — excluir líneas de import antes de
buscar usos.
- `readableActive`/vista de prefs construida DENTRO de una función pura llamada
por `$derived` = una alocación por pasada — cachear por instancia (WeakMap en
`resolveEidosDir`).
- El gate de P4 cazó a `command` (estampado inline en el assert que el barrido
4.1 no vio) y a 5 runtimes secundarios del mismo morfo — un tipo condicional
bien puesto encuentra lo que los barridos no.

@ -1,6 +1,6 @@
import type { ActiveEidos } from '$uix/eidos';
import type { Direction } from '$soma/types';
import { activeEidosDir } from '../../direction';
import { activeEidosDir, resolveEidosDir } from '../../direction';
/**
* Direction helpers shared by the chart family.
@ -26,7 +26,8 @@ export function physicalAnchor(
/**
* The chart family's direction, resolved by the canonical chain.
*
* `prop dir → prefs → 'ltr'`, run through `activeEidosDir` — the eidos entry
* Assertion via `activeEidosDir` (prop → ancestor context), maths tail via
* `resolveEidosDir` (→ prefs → 'ltr') — the eidos entry
* point, because a chart has no soma provider to hand `activeDir` a `Soma`.
* This used to read `getComputedStyle(node).direction` off the element, which
* the contract forbids: the DOM `dir` is a projection of the preference, never
@ -48,20 +49,20 @@ export function physicalAnchor(
* Contract: `docs/canon/direction-contract.md` §1 and §2.
*/
export function createChartRtl(eidos: ActiveEidos, dir: () => Direction | undefined) {
const resolved = activeEidosDir(dir, eidos.prefs);
const asserted = activeEidosDir(dir);
return {
get current(): boolean {
return resolved.current === 'rtl';
return resolveEidosDir(asserted, eidos.prefs) === 'rtl';
},
/** Spread onto the `<svg>`: the raw assertion, absent when there is none. */
/** Spread onto the wrapper: the raw assertion, absent when there is none. */
get attr(): { dir?: Direction } {
const value = resolved.current;
const value = asserted.current;
return value ? { dir: value } : {};
},
/** `text-anchor` that lands on the intended physical side. */
anchor(a: 'start' | 'middle' | 'end') {
return physicalAnchor(a, resolved.current === 'rtl');
return physicalAnchor(a, resolveEidosDir(asserted, eidos.prefs) === 'rtl');
}
};
}

@ -1,35 +1,56 @@
import { readableActive, type Active } from '$libs/reactive';
import { createActiveUixPrefsView } from '$active-uix/prefs';
import { DirectionContext } from '$soma/direction';
import type { ActivePrefs } from '$prefs';
import type { Direction } from '$soma/types';
/**
* The direction chain's eidos entry point — the same two links `activeDir` runs
* The direction chain's eidos entry point — the same links `activeDir` runs
* for soma, handed the service an eidos-only component actually holds.
*
* Most of the catalogue resolves direction in a soma provider, so
* `activeDir(getter, soma)` covers it. A few components are eidos-only: they
* have no provider, no morfo and no `Soma`, only an `ActiveEidos`. Before this
* existed the chain was simply not runnable there, and the workaround was to
* read the resolved direction off the element — which the contract forbids,
* because the DOM `dir` is a projection of the preference, never a source.
* `activeDir(getter)` covers it. A few components are eidos-only: they have no
* provider, no morfo and no `Soma`, only an `ActiveEidos`. Before this existed
* the chain was simply not runnable there, and the workaround was to read the
* resolved direction off the element — which the contract forbids, because the
* DOM `dir` is a projection of the assertion, never a source.
*
* `ActiveEidos.prefs` is an `ActivePrefs` (the raw arts service), which has no
* `getDir()`; that method lives on the soma-facing view. Adapting the one to
* the other is the whole of the difference between the two entry points.
*
* Returns `Direction | undefined` for the same reason `activeDir` does:
* `undefined` means NOBODY asserted a direction, which is not `'ltr'`. The
* consumer defaults it once for its own maths and stamps the RAW value, so the
* attribute stays absent when nothing was asserted.
* Returns the ASSERTION (`prop → ancestor via DirectionContext`) and publishes
* it, exactly like `activeDir` — the context is soma's, shared on purpose, so
* an eidos-only chart inside an asserted soma subtree (or the reverse)
* resolves the same fact. `undefined` means NOBODY asserted, which is not
* `'ltr'`: the consumer stamps the RAW value (absent inherits from the
* projected page) and resolves its maths through {@link resolveEidosDir}.
*
* Contract: `docs/canon/direction-contract.md` §1.
* Contract: `docs/canon/direction-contract.md` §1 and §7.
*/
export function activeEidosDir(dir: () => Direction | undefined): Active<Direction | undefined> {
const inherited = DirectionContext.getOr(undefined);
const asserted = readableActive(() => dir() ?? inherited?.current);
DirectionContext.set(asserted);
return asserted;
}
/**
* The maths tail for eidos-only components: `assertion → prefs → 'ltr'`.
* `ActiveEidos.prefs` is an `ActivePrefs` (the raw arts service), which has no
* `getDir()` — that method lives on the soma-facing view; adapting the one to
* the other is the whole of the difference from soma's `resolveDir`.
*/
export function activeEidosDir(
dir: () => Direction | undefined,
const PREFS_VIEWS = new WeakMap<ActivePrefs, ReturnType<typeof createActiveUixPrefsView>>();
export function resolveEidosDir(
dir: Active<Direction | undefined>,
prefs: ActivePrefs | undefined
): Active<Direction | undefined> {
// Built once, not per read: the view allocates closures over the slot.
const view = prefs ? createActiveUixPrefsView(prefs) : undefined;
return readableActive(() => dir() ?? view?.getDir());
): Direction {
if (dir.current !== undefined) return dir.current;
if (!prefs) return 'ltr';
// The view allocates closures over the slot — cached per prefs instance so
// a maths tail read inside a $derived does not rebuild it every pass.
let view = PREFS_VIEWS.get(prefs);
if (!view) {
view = createActiveUixPrefsView(prefs);
PREFS_VIEWS.set(prefs, view);
}
return view.getDir() ?? 'ltr';
}

Loading…
Cancel
Save

Powered by TurnKey Linux.