feat(direction): la proyeccion prefs->DOM es del boot, y el DOM la siembra

La proyeccion cross-modal (dir · lang · data-motion/sound/haptic sobre <html>)
era cableado manual de cada app: solo 4 de ~13 boots de la demo la tenian, y el
estampado per-componente del valor de prefs actuaba de SUSTITUTO de la
proyeccion que faltaba. Para que el flip de P3 pueda soltar prefs del atributo,
el ambiente tiene que llegar al DOM una vez y siempre:

- createActiveUix la crea por defecto (opt-out `projectPrefs: false` cuando la
  app posee <html>: i18n por routing, proyeccion propia). En attach es opt-IN —
  el inverso — porque el host puede gobernar el documento.
- Se dispone la PRIMERA en dispose(), antes de que prefs/dom mueran debajo.
- Los 3 cableados manuales commiteables se borran EN ESTE MISMO commit (uix,
  blocks, BootUix); el de web/routes/alpha se edita en arbol pero no se
  commitea nunca (regla de la rama). Borrarlos junto a la automatica evita el
  agujero medido del doble montaje: dispose() BORRA los attrs gestionados sin
  restaurar.

Y LA SEMILLA, que es lo que hace la automatica segura: sin ella, el boot
REESCRIBIRIA un <html dir="rtl"> puesto a mano (el escenario 013ceac57) con el
valor derivado del idioma. `readPrefsEnvironmentFromDom` lee <html dir> UNA vez
al boot (SSR-safe: {} sin document; auto/vacio = "la pagina no declaro") y la
dimension direction la honra con la precedencia:

    intent del usuario > semilla del entorno > derivacion(idioma) > default

Una declaracion a nivel de pagina es una AFIRMACION explicita; el enlace por
idioma es una heuristica — la afirmacion gana. Lo que pase la app en
options.prefs.environment pisa la semilla.

Un guard viejo fijaba el comportamiento anterior ("does not auto-project…") —
es exactamente lo que la decision D2 (firmada 2026-08-05) revierte. Pasa a
fijar el nuevo contrato: se proyectan SOLO los attrs cross-modales; data-theme/
mode/density siguen prohibidos (son de eidos).

Tests: semilla y precedencia 6/6 (dom-environment.test.ts) · proyeccion
automatica, opt-out, adopcion del dir a mano y limpieza en dispose (4 nuevos en
active-uix.svelte.test.ts) · suites active-uix + prefs 79/79.

Verificado en Chrome por el camino real: el toggle del topbar mueve <html dir>
via prefs->proyeccion AUTOMATICA (ltr -> rtl -> auto) con el cableado manual ya
borrado, en /uix y en /blocks; <html lang> viaja con el.

check 77 = linea base · rtl:check 1 (palabras) · docs:check 0/566.

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

@ -0,0 +1,38 @@
import type { PrefsEnvironment } from '$libs/prefs';
import { DIRECTIONS, type Direction } from '$libs/direction';
export interface DomEnvironmentOverrides {
/** Injectable for tests / non-global documents. Defaults to `globalThis.document`. */
readonly document?: Document;
}
/**
* Reads the environment facts the PAGE itself declares — today, the reading
* direction of `<html dir>`.
*
* One-shot, at boot, BEFORE the DOM projection first applies: this is the seed
* that stops an automatic projection from rewriting a hand-set
* `<html dir="rtl">` with the language-derived value. It is not a live source —
* direction stays one-way (prefs → DOM) after boot; re-running the read would
* only echo the projection's own write, which is idempotent by construction
* (the dimension resolves `env.direction` to the same value it projected).
*
* `dir="auto"`, empty and missing all read as "the page declared nothing" —
* only the two concrete directions are assertions.
*
* SSR-safe: with no `document` it returns `{}` and the dimension falls through
* to language derivation.
*/
export function readPrefsEnvironmentFromDom(
overrides: DomEnvironmentOverrides = {}
): PrefsEnvironment {
const doc = overrides.document ?? globalThis.document;
if (doc === undefined) return {};
const raw = doc.documentElement?.getAttribute('dir');
if (typeof raw !== 'string') return {};
const value = raw.toLowerCase();
if (!DIRECTIONS.includes(value as Direction)) return {};
return { direction: value as Direction };
}

@ -14,10 +14,15 @@ export interface DirectionDimensionOptions {
}
/**
* Built-in direction dimension. Derived from the resolved language
* (RTL languages → `'rtl'`, everything else → `'ltr'`). User intent is
* still allowed to override — the resolver checks intent first, then
* the derive hook, then the default.
* Built-in direction dimension. Precedence: user intent → environment seed
* (`env.direction`, the `<html dir>` the page already declared at boot) →
* derivation from the resolved language (RTL languages → `'rtl'`) → default.
*
* The environment link matters when the projection is automatic: an app that
* hand-set `<html dir="rtl">` without registering any preference would
* otherwise get its page rewritten to the language-derived value on boot. A
* page-level declaration is an explicit assertion; the language link is a
* heuristic — the assertion wins.
*/
export function directionDimension(
options: DirectionDimensionOptions = {}
@ -33,7 +38,8 @@ export function directionDimension(
}
return { ok: true, value: value as Direction };
},
derive(effective) {
derive(effective, env) {
if (env?.direction !== undefined) return env.direction;
const resolvedLanguage = effective[langKey];
if (typeof resolvedLanguage !== 'string') return fallback;
return directionFromLanguage(resolvedLanguage as Locale);

@ -69,6 +69,9 @@ export type { BrowserEnvironmentOverrides } from './adapters/browser-environment
export { detectServerEnvironment, parseAcceptLanguage } from './adapters/server-environment.ts';
export type { ServerEnvironmentInput } from './adapters/server-environment.ts';
export { readPrefsEnvironmentFromDom } from './adapters/dom-environment.ts';
export type { DomEnvironmentOverrides } from './adapters/dom-environment.ts';
export { createPrefsStorageBridge } from './adapters/storage-bridge.ts';
export type {
PrefsIntentStorage,

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { createActivePrefs, directionDimension, localeDimension } from '$prefs';
import { readPrefsEnvironmentFromDom } from '../adapters/dom-environment.ts';
function fakeDocument(dir: string | null): Document {
return {
documentElement: {
getAttribute: (name: string) => (name === 'dir' ? dir : null)
}
} as unknown as Document;
}
describe('readPrefsEnvironmentFromDom', () => {
it('reads a concrete <html dir> into env.direction', () => {
expect(readPrefsEnvironmentFromDom({ document: fakeDocument('rtl') })).toEqual({
direction: 'rtl'
});
expect(readPrefsEnvironmentFromDom({ document: fakeDocument('LTR') })).toEqual({
direction: 'ltr'
});
});
it('missing, empty and auto all read as "the page declared nothing"', () => {
expect(readPrefsEnvironmentFromDom({ document: fakeDocument(null) })).toEqual({});
expect(readPrefsEnvironmentFromDom({ document: fakeDocument('') })).toEqual({});
expect(readPrefsEnvironmentFromDom({ document: fakeDocument('auto') })).toEqual({});
});
it('is SSR-safe: no document → {}', () => {
expect(readPrefsEnvironmentFromDom({ document: undefined })).toEqual({});
});
});
describe('directionDimension — environment seed precedence', () => {
const schema = {
language: localeDimension({ catalog: ['es-ES', 'ar-EG'], default: 'es-ES' }),
direction: directionDimension()
};
it('the seed beats language derivation (the page asserted, the language is a heuristic)', () => {
const prefs = createActivePrefs({
schema,
environment: { direction: 'rtl' }
});
// language resolves es-ES → derivation would say ltr; the seed wins.
expect(prefs.direction.get()).toBe('rtl');
prefs.dispose();
});
it('user intent beats the seed', () => {
const prefs = createActivePrefs({
schema,
environment: { direction: 'rtl' }
});
prefs.direction.set('ltr');
expect(prefs.direction.get()).toBe('ltr');
// Clearing the intent falls back to the seed, not to the derivation.
prefs.direction.clear();
expect(prefs.direction.get()).toBe('rtl');
prefs.dispose();
});
it('without a seed the derivation still runs', () => {
const prefs = createActivePrefs({ schema, environment: {} });
expect(prefs.direction.get()).toBe('ltr');
prefs.language.set('ar-EG');
expect(prefs.direction.get()).toBe('rtl');
prefs.dispose();
});
});

@ -23,6 +23,13 @@ export interface PrefsEnvironment {
readonly unitSystem?: UnitSystem;
readonly colorScheme?: 'light' | 'dark';
readonly reducedMotion?: boolean;
/**
* Reading direction the page already declared (`<html dir>`) when the
* engine booted. Seeded so the DOM projection does not overwrite an
* app's hand-set direction with a language-derived one: the dimension
* honours it AFTER user intent but BEFORE derivation.
*/
readonly direction?: 'ltr' | 'rtl';
/**
* Origin of the detection so consumers / tests can branch on it.
* `'mixed'` is for SSR + browser hydrate paths where the engine

@ -113,6 +113,45 @@ describe('createActiveUix (standalone)', () => {
}
});
it('projects the cross-modal prefs onto <html> automatically', () => {
// The default schema includes `direction` (derived from language) and
// `language`; the projection must reflect both without any app wiring —
// components stamp only ASSERTED directions, so the page must carry the
// ambient one or nothing does.
const uix = createActiveUix({ langs: minimalLang });
try {
expect(document.documentElement.getAttribute('dir')).toBe('ltr');
expect(document.documentElement.getAttribute('lang')).toBe('es');
} finally {
uix.dispose();
}
// Its dispose removes the managed attrs (documented behaviour).
expect(document.documentElement.hasAttribute('dir')).toBe(false);
});
it('projectPrefs: false leaves <html> untouched', () => {
const uix = createActiveUix({ langs: minimalLang, projectPrefs: false });
try {
expect(document.documentElement.hasAttribute('dir')).toBe(false);
} finally {
uix.dispose();
}
});
it('seeds the direction from a hand-set <html dir> instead of rewriting it', () => {
// The 013ceac57 scenario: an app that sets the page direction by hand and
// registers no explicit preference. The automatic projection must ADOPT
// that assertion, not overwrite it with the language-derived value.
document.documentElement.setAttribute('dir', 'rtl');
const uix = createActiveUix({ langs: minimalLang });
try {
expect(document.documentElement.getAttribute('dir')).toBe('rtl');
} finally {
uix.dispose();
document.documentElement.removeAttribute('dir');
}
});
it('opts out of format when format=false', () => {
const uix = createActiveUix({ langs: minimalLang, format: false });
try {
@ -154,21 +193,21 @@ describe('createActiveUix (standalone)', () => {
}
});
it('does not auto-project prefs attrs; composition roots wire prefs projection explicitly', () => {
it('auto-projects ONLY the cross-modal attrs — theme/mode/density stay forbidden', () => {
// Ratified 2026-08-05 (the direction endgame): standalone boot owns the
// projection, but its surface is still the prefsDomProjection contract —
// dir/lang/data-motion/sound/haptic and NOTHING visual. data-theme,
// data-mode and data-density belong to eidos and must never appear here.
const root = document.documentElement;
root.removeAttribute('dir');
root.removeAttribute('data-motion');
root.removeAttribute('data-sound');
root.removeAttribute('data-haptic');
root.removeAttribute('data-mode');
root.removeAttribute('data-density');
root.removeAttribute('data-theme');
const uix = createActiveUix({ langs: minimalLang });
try {
expect(root.hasAttribute('dir')).toBe(false);
expect(root.hasAttribute('data-motion')).toBe(false);
expect(root.hasAttribute('data-sound')).toBe(false);
expect(root.hasAttribute('data-haptic')).toBe(false);
expect(root.hasAttribute('dir')).toBe(true);
expect(root.hasAttribute('data-motion')).toBe(true);
expect(root.hasAttribute('data-sound')).toBe(true);
expect(root.hasAttribute('data-haptic')).toBe(true);
expect(root.hasAttribute('data-mode')).toBe(false);
expect(root.hasAttribute('data-density')).toBe(false);
expect(root.hasAttribute('data-theme')).toBe(false);

@ -22,7 +22,14 @@ import type { ActiveApp } from '$active-app';
import { createEngineLogger, type EngineLogger } from '$logger';
import { createSvelteEngineBus, type EngineBus } from '$bus';
import { createActiveTimers, type ActiveTimers } from '$timer';
import { createActivePrefs, standardPrefsDimensions, type ActivePrefs } from '$prefs';
import {
createActivePrefs,
createActivePrefsDomProjection,
readPrefsEnvironmentFromDom,
standardPrefsDimensions,
type ActivePrefs,
type ActivePrefsDomProjection
} from '$prefs';
import { readableActive } from '$reactive';
import { createActiveLangs } from '$langs/active-langs.svelte';
import type { ActiveLangs } from '$langs';
@ -76,7 +83,11 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
const defaultLocale = options.langs.defaultLocale ?? 'es';
const prefs = createActivePrefs({
schema: options.prefs?.schema ?? createDefaultUixPrefsSchema(defaultLocale),
environment: options.prefs?.environment,
// The page's own declarations (`<html dir>`) seed the environment so the
// automatic projection below never REWRITES a hand-set direction with the
// language-derived one. One-shot, SSR-safe ({} on the server); anything
// the app passes explicitly wins over the seed.
environment: { ...readPrefsEnvironmentFromDom(), ...options.prefs?.environment },
intent: options.prefs?.intent
});
@ -157,6 +168,17 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
// Dev forced-reflow detector — opt-in (typically gated on import.meta.env.DEV).
const perf = options.reflowDetector ? createActivePerf() : undefined;
// Cross-modal DOM projection (dir · lang · data-motion/sound/haptic on
// `<html>`) — automatic in standalone boot, because the components stopped
// stamping the ambient direction themselves: the page MUST reflect prefs or
// nothing does. Opt out with `projectPrefs: false` when the app owns
// `<html>` (i18n by routing, its own projection). No-op with dom disabled
// and on the server (the projection resolves its target lazily).
const prefsProjection =
(options.projectPrefs ?? true) && dom
? createActivePrefsDomProjection({ prefs, dom })
: undefined;
if (options.registerDefaultLangs ?? true) {
langs.extend('common', commonLangs);
langs.extend('components', componentLangs);
@ -178,6 +200,7 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
scene,
sound,
perf,
prefsProjection,
portal: options.portal,
detachLangsPrefs
});
@ -217,6 +240,12 @@ export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions
const ownsSound = appSound === undefined;
const perf = options.reflowDetector ? createActivePerf() : undefined;
// Attach: the HOST may own `<html>` (i18n by routing, its own projection),
// so the cross-modal projection is opt-in here — the inverse of standalone.
const prefsProjection = options.projectPrefs
? createActivePrefsDomProjection({ prefs: app.prefs as ActivePrefs, dom: appDom })
: undefined;
if (options.registerDefaultLangs ?? true) {
(app.langs as ActiveLangs).extend('common', commonLangs);
(app.langs as ActiveLangs).extend('components', componentLangs);
@ -234,6 +263,7 @@ export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions
sound,
ownsSound,
perf,
prefsProjection,
portal: options.portal,
detachLangsPrefs: undefined
});
@ -257,6 +287,8 @@ interface StandaloneInit {
scene: EngineScene | undefined;
sound: EngineSound;
perf: ActivePerf | undefined;
/** Cross-modal DOM projection — created unless `projectPrefs: false`. */
prefsProjection: ActivePrefsDomProjection | undefined;
portal: string | HTMLElement | undefined;
detachLangsPrefs: (() => void) | undefined;
}
@ -276,6 +308,8 @@ interface AttachInit {
/** active-uix created the sound engine as a fallback (the app didn't declare one). */
ownsSound: boolean;
perf: ActivePerf | undefined;
/** Cross-modal DOM projection — opt-in in attach (`projectPrefs: true`). */
prefsProjection: ActivePrefsDomProjection | undefined;
portal: string | HTMLElement | undefined;
detachLangsPrefs: (() => void) | undefined;
}
@ -580,6 +614,10 @@ class ActiveUixImpl implements ActiveUix {
this.liveRegionElements.clear();
}
this.init.detachLangsPrefs?.();
// The cross-modal projection goes FIRST (before prefs/dom die under it)
// and is ours in both boot modes when it exists. Its dispose REMOVES the
// managed attrs rather than restoring them — documented behaviour.
this.init.prefsProjection?.dispose();
// The forced-reflow detector (if enabled) is ours in both boot modes.
this.init.perf?.dispose();
// Standalone: tear down every service we instantiated. Reverse

@ -51,6 +51,16 @@ export interface ActiveUixOptions {
/** Preference engine options. Omit to get UIX's neutral standard prefs schema. */
readonly prefs?: ActiveUixPrefsOptions;
/**
* Project the cross-modal prefs (`dir` · `lang` · `data-motion/sound/haptic`)
* onto `<html>` automatically. On by default in standalone boot: components
* stamp only ASSERTED directions, so the page must reflect the ambient
* preference or nothing does. Set `false` when the app owns `<html>`
* (i18n by routing, its own projection).
* @default true
*/
readonly projectPrefs?: boolean;
/** Format service options. `false` opts out (no number/currency/date formatting). */
readonly format?: ActiveFormatOptions | false;
@ -95,6 +105,13 @@ export interface AttachActiveUixOptions {
readonly registerDefaultLangs?: boolean;
/** Enable the dev forced-reflow detector (`uix.perf`). @default false */
readonly reflowDetector?: boolean;
/**
* Project the cross-modal prefs onto `<html>`. Opt-IN here — the inverse of
* standalone — because the host app may own the document (i18n by routing,
* its own projection).
* @default false
*/
readonly projectPrefs?: boolean;
}
/**

@ -13,7 +13,7 @@
import { onDestroy } from 'svelte';
import { page } from '$app/state';
import { createActiveUix, setActiveUix } from '$active-uix';
import { createActivePrefsDomProjection, standardPrefsDimensions } from '$prefs';
import { standardPrefsDimensions } from '$prefs';
import { Soma } from '$soma/core/soma.svelte';
import { siumLangs } from '$sium/langs/langs';
import { secsLangs } from '$libs/secs';
@ -63,7 +63,6 @@
});
setActiveUix(uix);
Soma.create();
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
// ── Axes ──────────────────────────────────────────────────────────────
let mode = $state<'light' | 'dark'>('light');
@ -138,7 +137,6 @@
const currentSlug = $derived(page.url.pathname.replace(/^\/blocks\/?/, ''));
onDestroy(() => {
prefsProjection.dispose();
activeEidos.dispose();
uix.dispose();
});

@ -11,7 +11,7 @@
import './reset.css';
import { onDestroy } from 'svelte';
import { createActiveUix, setActiveUix } from '$active-uix';
import { createActivePrefsDomProjection, standardPrefsDimensions } from '$prefs';
import { standardPrefsDimensions } from '$prefs';
import { Soma } from '$soma/core/soma.svelte';
import { siumLangs } from '$sium/langs/langs';
import { secsLangs } from '$libs/secs';
@ -64,7 +64,6 @@
});
setActiveUix(uix);
Soma.create();
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
const modeListeners = new Set<(value: 'light' | 'dark') => void>();
const activeEidos = ActiveEidos.create({
@ -98,7 +97,6 @@
});
onDestroy(() => {
prefsProjection.dispose();
activeEidos.dispose();
uix.dispose();
});

@ -2,7 +2,6 @@
import { onDestroy } from 'svelte';
import { createActiveUix, setActiveUix } from '$active-uix';
import {
createActivePrefsDomProjection,
readActivePrefsSlot,
standardPrefsDimensions
} from '$prefs';
@ -200,7 +199,6 @@
setActiveUix(uix);
Soma.create();
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
type Section =
| { kind: 'top'; slug: string; label: string }
@ -528,7 +526,6 @@
activeEidos.applyGradients({ brand: brandGradient, spectrum: spectrumGradient });
onDestroy(() => {
prefsProjection.dispose();
activeEidos.dispose();
uix.dispose();
});

Loading…
Cancel
Save

Powered by TurnKey Linux.