diff --git a/docs/canon/direction-contract.md b/docs/canon/direction-contract.md index 8cad53e15..53721f984 100644 --- a/docs/canon/direction-contract.md +++ b/docs/canon/direction-contract.md @@ -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`; 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 ``; - 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).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? diff --git a/docs/decisions.md b/docs/decisions.md index 556fbe3df..0215002aa 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 `` at precedence `intent > env > derive(language) > default`; (D3) the stamping mechanism belongs to the MORFO — `direction: { parts }` declares it, `soma.runtime` 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. | diff --git a/docs/process/CONTINUE-direction-runtime.md b/docs/process/CONTINUE-direction-runtime.md index 1dfa1f014..c920a3c6a 100644 --- a/docs/process/CONTINUE-direction-runtime.md +++ b/docs/process/CONTINUE-direction-runtime.md @@ -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 `` 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 ``). SSR: cero `dir` en el payload. | +| P4 | `fc84305c2` | **El morfo declara** — `direction: { parts }` en 51 morfos; `soma.runtime` 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 `` 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 -- + `. +- 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. diff --git a/src/uix/eidos/components/chart/rtl.svelte.ts b/src/uix/eidos/components/chart/rtl.svelte.ts index 16ddef6b3..964fc3430 100644 --- a/src/uix/eidos/components/chart/rtl.svelte.ts +++ b/src/uix/eidos/components/chart/rtl.svelte.ts @@ -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 ``: 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'); } }; } diff --git a/src/uix/eidos/direction.ts b/src/uix/eidos/direction.ts index 53d1bec8e..04adedc5d 100644 --- a/src/uix/eidos/direction.ts +++ b/src/uix/eidos/direction.ts @@ -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 { + 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>(); + +export function resolveEidosDir( + dir: Active, prefs: ActivePrefs | undefined -): Active { - // 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'; }