feat(direction): el eslabon de contexto — la composicion hereda por fisica

La cadena gana el eslabon que el usuario dejo aparcado y ahora ratifica
(2026-08-05): prop -> afirmacion del ancestro (DirectionContext) -> prefs.
Cada activeDir PUBLICA su afirmacion (prop ?? heredada) y consulta la del
ancestro antes de caer a prefs.

Por que: la cadena por-componente metia prefs en la ruta del ATRIBUTO, y el
boot por defecto SIEMPRE tiene la dimension direction (deriva del idioma,
fallback ltr) — asi que "nadie afirmo" era inalcanzable en la practica y cada
hijo del canon estampaba ltr dentro de un subarbol afirmado rtl, cortando la
herencia. La otra sesion lo midio dos veces en el campo: un menu compuesto en
sitio aterrizaba en la preferencia global (LTR chrome dentro de un player RTL),
y el panel portalizado resolvia por su cuenta aunque arreglaras lo primero. Un
solo eslabon cierra los dos agujeros, porque la capa flotante ya lleva el
opts.dir del dueno — que ahora incorpora el contexto.

Con la fisica, las convenciones se borran:

- los 3 reenvios a mano de 4.3 (natural-time-picker-panel, emoji-picker-content
  x3, color-field-format-select) — el contexto los hace
- el enlace manual del submenu (dir ?? parentMenu?.opts.dir.current) — idem
- los 3 canarios dir= de media-player que la otra sesion dejo puestos a
  proposito ("si al quitarlos el panel vuelve a salir LTR, el mecanismo no
  llego al portal")

El canario canta: su repro exacto (media-player, manual parts, direction rtl,
float de subtitulos) da panel rtl SIN el dir= — y el volume float igual.
Medido ademas en Chrome: islas 0 en emoji-picker, natural-time-picker y
color-picker sin ningun reenvio; sliders de canal en espejo exacto (hue 210 a
0.417 del borde fisico); submenu de dropdown con data-side=left en RTL
derivado del contexto.

Regla de colocacion (documentada en direction.ts y el contrato): activeDir lee
y publica contexto de Svelte, asi que corre SIEMPRE en la init del wrapper —
nunca en constructores de provider. Los tests de provider construyen directo y
no se enteran; los 91 mocks de Soma.require() sobreviven porque getOr(undefined)
cae a traves de prefs.

Test nuevo (direction.svelte.test.ts + harnesses reales, 5/5): el hijo hereda
la afirmacion, su prop la pisa, sin afirmacion queda undefined (nunca un
default), sin ancestro cae a prefs, y la publicacion es REACTIVA.

check 77 = linea base · 71/71 en los 9 ambitos tocados · rtl:check 1
(palabras, preexistente) · docs:check 0/566. El contrato §1 pasa a cuatro
eslabones y §1 "Composition carries the assertion" documenta la fisica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 1d442c59dc
commit 9158d07964

@ -37,23 +37,32 @@ The answers are deliberately different types.
## 1. The chain
```text
prop dir → soma.prefs.getDir() → 'ltr'
prop dir → ancestor assertion (DirectionContext) → 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 |
A component with no soma provider runs the same first link through
**Four links, ratified 2026-08-05.** The context link is the "implicit
inheritance" phase left open when the chain was first fixed: every `activeDir`
call **publishes** its assertion (`prop ?? inherited`) into `DirectionContext`
and consults the nearest ancestor's before falling back to prefs. Composition
carries the assertion by physics — a canon component mounted inside an asserted
subtree, in place or through a portal (the Portal forwards Svelte context),
resolves the ancestor's assertion without anyone writing `dir=` at the call
site.
What remains true: a component still does **not read the DOM**. The DOM `dir`
attribute is a _projection_ of the assertion/preference, never a source.
| Link | Who runs it | Where |
| --------------- | ------------------------- | -------------------------------------------------- |
| `prop → ctx` | `activeDir(getter, soma)` | the **wrapper** `.svelte`, at `Provider.create(…)` |
| `ctx → prefs` | `activeDir(getter, soma)` | same call — the read must run at component init |
| `→ 'ltr'` | `resolvedDir` | the **provider**, once |
A component with no soma provider runs the same first links through
`activeEidosDir` — see §7.
`activeDir` (`src/uix/soma/direction.ts`) returns
`Active<Direction | undefined>` — it deliberately stops after the second link.
`Active<Direction | undefined>` — it deliberately stops before the default.
The `'ltr'` tail lives per-provider:
```ts
@ -61,73 +70,45 @@ 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.
neither the consumer via the prop, nor an ancestor via its own assertion, 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)
});
```
### Composition carries the assertion
### Composition carries the assertion — by physics
The chain is per-component, so a component that MOUNTS another canon component
starts a second chain inside the first. Left alone that second chain skips the
prop, lands on prefs, and stamps a value of its own — and a stamped `dir`
**cuts inheritance for everything below it**. The parent asserted `rtl`, the
child stamps `ltr`, and the subtree splits.
Hence the rule, and it has no exceptions:
> **A component that mounts another canon component hands it the RAW assertion
> it holds** — `opts.dir.current`, never `resolvedDir`. Raw, so "nobody
> asserted" stays distinct and the child's own chain still runs its prefs tail.
One rule, two vehicles, chosen by geometry rather than by taste:
| the assertion has to cross… | who carries it |
| --------------------------- | -------------- |
| **a portal** | the floating layer, once for every overlay |
| **an in-place composition** | the `dir` prop, at the call site |
**Through a portal.** A portalled panel is rendered outside its owner's subtree,
so inheritance cannot reach it at all. Leaving that to each consumer produced
eleven hand-written links, several of them simply missing, so
`soma/layers/floating` composes it once:
starts a second chain inside the first. Before the context link that second
chain skipped the prop, landed on prefs, and stamped a value of its own — and a
stamped `dir` **cuts inheritance for everything below it**: the parent asserted
`rtl`, the child stamped `ltr`, the subtree split. The framework carried the
assertion by convention (a "forward `dir=` at the call site" rule with
hand-written forwards) until 2026-08-05; the convention kept being missed in
the field, which is why it is now the resolver's job.
**In place** nothing needs writing: the child's `activeDir` consults
`DirectionContext` before prefs, so it resolves — and stamps — the ancestor's
assertion. A submenu inheriting from its parent menu is the same mechanism,
not a special case.
**Through a portal** the panel is rendered outside its owner's subtree, so DOM
inheritance cannot reach it; the Svelte context still does (the Portal forwards
contexts), and `soma/layers/floating` additionally composes the owner link once
for every overlay:
```text
the surface's own dir prop → the owning provider's asserted dir → omit
```
`FloatingProviderOpts.dir` is where the owner hands its assertion down (already
through `prop → prefs`, so the tail is intact); `FloatingContent.assertedDir` is
where the two meet. The consequence for anyone writing an overlay:
`FloatingProviderOpts.dir` is where the owner hands its assertion down;
`FloatingContent.assertedDir` is where the two meet. The consequence for anyone
writing an overlay:
> A `Content` wrapper passes its `dir` prop **raw** — `readableActive(() => dir)`,
> never `activeDir`. Running the chain there makes the value always concrete, and
> the owner's assertion could then never win the fallback.
**In place.** Nothing structural blocks inheritance here — the child would have
inherited correctly had it not stamped. So the fix is the prop, written where the
composition is:
```svelte
<!-- eidos panel, owner provider already in hand -->
<Slider.Provider dir={owner.opts.dir.current} … />
```
A component that COMPOSES an overlay rather than being one — every picker wraps
a `Popover` — is the same rule reaching a provider instead of a markup child:
`PopoverProvider.create({ …, dir })`.
### The public prop
Every component that has any direction-dependent behaviour or paint declares:
@ -423,9 +404,10 @@ When you touch a component's direction behaviour:
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? And does every canon component it MOUNTS receive the raw
assertion — the composed overlay's provider, and every in-place child?
Otherwise the prop moves one half and leaves the other behind.
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?
5. Does any block pair a logical anchor with a physical displacement?
(`npm run rtl:check`)

@ -1,6 +1,5 @@
<script lang="ts">
import * as ColorField from '$soma/components/color-field';
import { ColorFieldProvider } from '$soma/components/color-field/color-field-provider.svelte';
import { Select } from '../select';
import type { ColorFieldFormatSelectProps } from './types';
@ -10,11 +9,6 @@
// render the Select wired to that state.
let { 'aria-label': ariaLabel = 'Color format', ...rest }: ColorFieldFormatSelectProps =
$props();
// The mounted Select runs its own chain, so without the field's RAW assertion
// it lands on prefs and stamps a `dir` that cuts inheritance for the whole
// switcher (direction contract §1, "Composition carries the assertion").
const field = ColorFieldProvider.require();
</script>
<ColorField.FormatSelect aria-label={ariaLabel} {...rest}>
@ -25,7 +19,6 @@
<Select
data-color-field-format-select
size="sm"
dir={field.opts.dir.current}
aria-label={ariaLabel}
value={[value]}
onValueChange={(v) => v[0] && setValue(v[0] as typeof value)}

@ -57,7 +57,6 @@
>
<EmojiPickerSoma.Content>
<CommandSoma.Provider
dir={provider.opts.dir.current}
search={provider.search}
onSearchChange={(v) => provider.setSearch(v)}
columns={provider.opts.columns.current}
@ -69,7 +68,6 @@
<div data-emoji-picker-header="">
<Command.Input placeholder={provider.searchLabel} />
<ToggleGroup
dir={provider.opts.dir.current}
selectionMode="single"
deselectable={false}
variant="ghost"
@ -110,7 +108,6 @@
{#if provider.opts.showSkinTones.current}
<ToggleGroup
dir={provider.opts.dir.current}
selectionMode="single"
deselectable={false}
variant="ghost"

@ -63,11 +63,7 @@
const trackName = (t: TextTrack, i: number) => t.label || t.language || `${i + 1}`;
</script>
<!-- `dir` BEFORE the spread: the resolver has no parent step, so a menu
mounted in place starts its own chain and would land on the app-global
preference — LTR chrome inside an RTL player. The consumer's own `dir`
still wins because it rides `rest`. Contract: docs/canon/direction-contract.md §1. -->
<DropdownMenu dir={provider.opts.dir?.current} {...rest}>
<DropdownMenu {...rest}>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<CaptionButton

@ -36,11 +36,7 @@
const value = $derived(String(provider.playbackRate));
</script>
<!-- `dir` BEFORE the spread: the resolver has no parent step, so a menu
mounted in place starts its own chain and would land on the app-global
preference — LTR chrome inside an RTL player. The consumer's own `dir`
still wins because it rides `rest`. Contract: docs/canon/direction-contract.md §1. -->
<DropdownMenu dir={provider.opts.dir?.current} {...rest}>
<DropdownMenu {...rest}>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<RateButton

@ -35,8 +35,7 @@
const provider = MediaPlayerProvider.require();
</script>
<!-- `dir` BEFORE the spread — see the note in the sibling floats. -->
<Popover dir={provider.opts.dir?.current} {...rest}>
<Popover {...rest}>
<Popover.Trigger openOnHover {openDelay} {closeDelay}>
{#snippet child({ props })}
<MuteButton {...props} />

@ -149,7 +149,6 @@
step={1}
{disabled}
aria-label={bandLabel}
dir={natTP.opts.dir.current}
>
<Slider.Range />
<Slider.Thumb>

@ -5,12 +5,11 @@
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
import { MenuSubContentProvider, MenuProvider } from '../dropdown-menu-provider.svelte';
import { MenuSubContentProvider } from '../dropdown-menu-provider.svelte';
import type { MenuSubContentProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
const parentMenu = MenuProvider.get();
let {
ref = $bindable(null),
@ -32,12 +31,13 @@
}: MenuSubContentProps = $props();
// The MATHS half, and the only reason this file still runs the chain: the
// default side is a concrete left/right, so it needs a resolved value, and a
// submenu consults its parent Menu before prefs. Submenus default to the
// logical "end" side, which in RTL means the visual left; an explicit `side`
// prop always wins. The ATTRIBUTE half is not here — the floating layer
// composes the parent's assertion for the portalled panel.
const resolvedSide = activeDir(() => dir ?? parentMenu?.opts.dir.current, soma);
// default side is a concrete left/right, so it needs a resolved value.
// Submenus default to the logical "end" side, which in RTL means the visual
// left; an explicit `side` prop always wins. The parent menu's assertion
// arrives through the DirectionContext link — no hand-composed step. The
// ATTRIBUTE half is not here — the floating layer composes the owner's
// assertion for the portalled panel.
const resolvedSide = activeDir(() => dir, soma);
const effectiveSide = $derived(side ?? (resolvedSide.current === 'rtl' ? 'left' : 'right'));
const state = MenuSubContentProvider.create({

@ -0,0 +1,128 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import type { Active } from '$libs/reactive';
import type { Direction } from './types';
import DirectionHarness from './test/direction-harness.svelte';
import DirectionHarnessChild from './test/direction-harness-child.svelte';
/**
* The context link of the chain (`prop → ancestor assertion → prefs`): a canon
* component mounted inside an asserted subtree resolves the ancestor's
* assertion without any hand-written `dir=` forward. Ratified 2026-08-05 —
* the "implicit inheritance" phase of the direction contract.
*
* `*.svelte.test.ts` because `activeDir` is rune-backed ($derived via
* readableActive); the server vitest project compiles `$state` away and would
* pass regardless of the code.
*/
function mountHarness(props: {
dir?: Direction;
childDir?: Direction;
}): {
parent: () => Active<Direction | undefined>;
child: () => Active<Direction | undefined>;
dispose: () => void;
} {
const target = document.createElement('div');
document.body.appendChild(target);
let parent: Active<Direction | undefined> | undefined;
let child: Active<Direction | undefined> | undefined;
const component = mount(DirectionHarness, {
target,
props: {
...props,
report: (resolved: Active<Direction | undefined>) => (parent = resolved),
reportChild: (resolved: Active<Direction | undefined>) => (child = resolved)
}
});
flushSync();
return {
parent: () => {
if (!parent) throw new Error('parent harness did not report');
return parent;
},
child: () => {
if (!child) throw new Error('child harness did not report');
return child;
},
dispose: () => {
void unmount(component);
target.remove();
}
};
}
describe('activeDir — the DirectionContext link', () => {
it('a child with no prop resolves the ancestor assertion', () => {
const h = mountHarness({ dir: 'rtl' });
expect(h.parent().current).toBe('rtl');
expect(h.child().current).toBe('rtl');
h.dispose();
});
it('the child own prop beats the ancestor assertion', () => {
const h = mountHarness({ dir: 'rtl', childDir: 'ltr' });
expect(h.child().current).toBe('ltr');
h.dispose();
});
it('nobody asserted anywhere → undefined, never a default', () => {
const h = mountHarness({});
expect(h.parent().current).toBeUndefined();
expect(h.child().current).toBeUndefined();
h.dispose();
});
it('a lone component with no ancestor stays on its own chain', () => {
const target = document.createElement('div');
document.body.appendChild(target);
let resolved: Active<Direction | undefined> | undefined;
const component = mount(DirectionHarnessChild, {
target,
props: {
dir: 'rtl',
report: (r: Active<Direction | undefined>) => (resolved = r)
}
});
flushSync();
expect(resolved?.current).toBe('rtl');
void unmount(component);
target.remove();
});
it('the assertion is REACTIVE: flipping the ancestor prop reaches the child', () => {
const props = $state<{
dir?: Direction;
childDir?: Direction;
}>({ dir: undefined });
const target = document.createElement('div');
document.body.appendChild(target);
let child: Active<Direction | undefined> | undefined;
const component = mount(DirectionHarness, {
target,
props: {
get dir() {
return props.dir;
},
report: () => {},
reportChild: (r: Active<Direction | undefined>) => (child = r)
}
});
flushSync();
expect(child?.current).toBeUndefined();
props.dir = 'rtl';
flushSync();
expect(child?.current).toBe('rtl');
props.dir = undefined;
flushSync();
expect(child?.current).toBeUndefined();
void unmount(component);
target.remove();
});
});

@ -1,7 +1,23 @@
import { readableActive, type Active } from '$libs/reactive';
import { Context, readableActive, type Active } from '$libs/reactive';
import type { Soma } from './core/soma.svelte';
import type { Direction } from './types';
/**
* The direction ASSERTED by the nearest ancestor component — reactive, raw.
*
* Every `activeDir` call publishes its assertion here and consults the one
* above it, which is what makes composition carry the assertion by PHYSICS
* instead of by convention: a canon component mounted inside an asserted
* subtree — in place or through a portal (the Portal forwards Svelte context) —
* resolves the ancestor's assertion before falling back to prefs, without
* anyone writing `dir=` at the call site.
*
* The value is the raw assertion (`prop ?? inherited`), never a resolved
* default — `undefined` still means "nobody asserted", which is the value the
* stamping contract needs to distinguish (direction contract §2).
*/
export const DirectionContext = new Context<Active<Direction | undefined>>('SomaDirection');
/**
* The ONE way a component resolves its text direction.
*
@ -12,12 +28,24 @@ import type { Direction } from './types';
* framework that sells homogeneity cannot afford ten shapes for its most
* cross-cutting axis.
*
* ## The chain
*
* ```text
* prop dir → ancestor assertion (DirectionContext) → soma.prefs.getDir()
* ```
*
* The context link is what closes the two holes measured in the field: a menu
* composed inside an RTL player used to start its own chain and land on the
* app-global preference (in place), and its portalled panel resolved on its
* own even when the owner was fixed (portal). One published assertion covers
* both, because the floating layer already carries the owner's `opts.dir`.
*
* ## Why the result can be `undefined`
*
* `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 as `'ltr'`, and collapsing the two is the bug this signature
* exists to prevent:
* `undefined` means **nobody asserted a direction** — not the consumer via the
* prop, not an ancestor via its own assertion, not the app via a registered
* `direction` preference. That is NOT the same as `'ltr'`, and collapsing the
* two is the bug this signature exists to prevent:
*
* - A component's own math (arrow keys, pointer sign, placement flip) wants a
* concrete value. Providers default it once, in `resolvedDir`.
@ -26,18 +54,19 @@ import type { Direction } from './types';
* component force its subtree back to LTR inside an `<html dir="rtl">` page.
* An omitted attribute inherits — for free, with no DOM read.
*
* ## Extra steps are declared, not buried
* ## Placement rule
*
* A component that legitimately consults something else first passes it in the
* getter, where it is visible at the call site:
*
* ```ts
* dir: activeDir(() => dir ?? parentMenu?.opts.dir.current, soma)
* ```
* `activeDir` reads AND publishes Svelte context, so it must run during
* component initialisation — the wrapper `.svelte`, never a provider
* constructor (provider tests construct providers directly, outside any
* component). Every wrapper already calls it at init; keep it that way.
*/
export function activeDir(
dir: () => Direction | undefined,
soma: Soma | undefined
): Active<Direction | undefined> {
return readableActive(() => dir() ?? soma?.prefs.getDir());
const inherited = DirectionContext.getOr(undefined);
const asserted = readableActive(() => dir() ?? inherited?.current);
DirectionContext.set(asserted);
return readableActive(() => asserted.current ?? soma?.prefs.getDir());
}

@ -0,0 +1,21 @@
<script lang="ts">
// Test harness — a bare canon-shaped child: runs `activeDir` at init exactly
// like a component wrapper does and hands the resulting Active back to the
// test. No soma on purpose: these fixtures exercise the CONTEXT link; the
// prefs link is covered by `active-uix/test/prefs-view.svelte.test.ts`.
import { activeDir } from '../direction';
import type { Active } from '$libs/reactive';
import type { Direction } from '../types';
let {
dir = undefined,
report
}: {
dir?: Direction;
report: (resolved: Active<Direction | undefined>) => void;
} = $props();
// svelte-ignore state_referenced_locally — the harness deliberately hands
// the Active out once at init; `report` is not expected to be reactive.
report(activeDir(() => dir, undefined));
</script>

@ -0,0 +1,29 @@
<script lang="ts">
// Test harness — an asserting ancestor with a canon-shaped child mounted in
// place. The parent runs `activeDir` (publishing its assertion into
// DirectionContext) exactly like a component wrapper does; the child runs its
// own chain with no prop. What the test asserts: the child resolves the
// ancestor's assertion without any hand-written `dir=` forward.
import DirectionHarnessChild from './direction-harness-child.svelte';
import { activeDir } from '../direction';
import type { Active } from '$libs/reactive';
import type { Direction } from '../types';
let {
dir = undefined,
childDir = undefined,
report,
reportChild
}: {
dir?: Direction;
childDir?: Direction;
report: (resolved: Active<Direction | undefined>) => void;
reportChild: (resolved: Active<Direction | undefined>) => void;
} = $props();
// svelte-ignore state_referenced_locally — the harness deliberately hands
// the Active out once at init; `report` is not expected to be reactive.
report(activeDir(() => dir, undefined));
</script>
<DirectionHarnessChild dir={childDir} report={reportChild} />
Loading…
Cancel
Save

Powered by TurnKey Linux.