feat(prefs): el `lang` del documento sigue al idioma, como ya hacia el `dir`

`src/app.html` trae `<html lang="en">` hardcodeado y NADIE lo actualizaba: con
arabe seleccionado la pagina quedaba correctamente espejada (`dir="rtl"`, que
la proyeccion si escribe) pero anunciada en el idioma equivocado. El `lang`
gobierna seleccion de fuentes, corte de palabras, glifos de comilla y lo que
lee CUALQUIER lector de pantalla.

`lang` viaja con `dir` porque responden a la misma pregunta sobre el documento
y el navegador lee LOS DOS del DOM. La dimension `language` ya estaba en el
esquema —es de donde `prefs` deriva la direccion— asi que el slot ya estaba
disponible: el valor efectivo es un tag BCP-47, que es justo lo que el atributo
toma.

Un esquema SIN la dimension `language` no produce slot y no se proyecta nada,
asi que una app que gobierne el `lang` desde el servidor (i18n por routing)
queda intacta.

Cambia el contrato declarado:
`prefsDomProjection.ownsAttrs` pasa a `['dir','lang','data-motion','data-sound','data-haptic']`,
con su guard en `contracts.test.ts` y el README de prefs.

Medido en Chrome por el camino real (el selector de idioma de la shell):
ar -> lang=ar/dir=rtl · en -> lang=en/dir=ltr · es -> lang=es/dir=ltr. Un solo
`language.set` mueve los dos: el `dir` por derivacion, el `lang` directo — y
eso queda fijado en `dom-projection.test.ts`.

Sin tocar el `<main lang="en">` de las demos: NO es el defecto, es correcto —
la prosa de las demos esta en ingles (decision de 6.4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 23d2e94783
commit 41e1f28306

@ -17,8 +17,13 @@ Current decisions:
- `prefs.direction` resolves the effective direction.
- `prefs.motion`, `prefs.sound` and `prefs.haptic` are cross-cutting
perception/interaction preferences.
- `createActivePrefsDomProjection(...)` projects only `dir`, `data-motion`,
`data-sound` and `data-haptic`.
- `createActivePrefsDomProjection(...)` projects only `dir`, `lang`,
`data-motion`, `data-sound` and `data-haptic`. `lang` travels with `dir`
because they answer the same question about the document and the browser
reads BOTH from the DOM — fonts, hyphenation, quote glyphs and every screen
reader take the language from there. A schema without the `language`
dimension yields no slot, so nothing is projected and an app that owns `lang`
server-side (i18n by routing) is untouched.
- Visual `theme`, `mode` and `density` belong to `ActiveEidos`, not to the core
preset of `prefs`, `ActiveApp` or `ActiveUix`.
- There is no `themeDimension(...)` or `densityDimension(...)` in the public
@ -41,7 +46,7 @@ create another compensatory preferences instance.
ActiveApp/createActiveUix -> ActivePrefs
langs -> prefs.language
format -> prefs.locale, currency, timezone, unitSystem
ActivePrefsDomProjection -> direction, motion, sound, haptic
ActivePrefsDomProjection -> direction, language, motion, sound, haptic
ActiveEidos -> its own visual theme/mode/density
```

@ -2,6 +2,7 @@ import { untrack } from 'svelte';
import type { ActiveDom } from '$adom';
import type { Direction } from '$libs/direction';
import type { DomAttrValue } from '$adom';
import type { Locale } from '$libs/locale';
import type { HapticEffective } from '$libs/haptic';
import type { MotionEffective } from '$libs/motion';
import type { SoundEffective } from '$libs/sound';
@ -23,6 +24,12 @@ export interface ActivePrefsDomProjection {
export const PREFS_DOM_ATTRS = {
DIR: 'dir',
// `lang` travels with `dir` because they answer the same question about the
// document and the browser reads BOTH from the DOM: font selection,
// hyphenation, quote glyphs and every screen reader take the language from
// here. Projecting one without the other left `<html lang='en'>` under
// Arabic — a page correctly mirrored but announced in the wrong tongue.
LANG: 'lang',
MOTION: 'data-motion',
SOUND: 'data-sound',
HAPTIC: 'data-haptic'
@ -33,6 +40,10 @@ export function createActivePrefsDomProjection(
): ActivePrefsDomProjection {
const slots = {
direction: readActivePrefsSlot<Direction>(options.prefs, 'direction'),
// The dimension's effective value is a BCP-47 tag, which is exactly what
// the attribute takes. Absent from the schema → no slot → not projected,
// so an app that owns `lang` server-side (i18n by routing) is untouched.
language: readActivePrefsSlot<Locale>(options.prefs, 'language'),
motion: readActivePrefsSlot<MotionEffective>(options.prefs, 'motion'),
sound: readActivePrefsSlot<SoundEffective>(options.prefs, 'sound'),
haptic: readActivePrefsSlot<HapticEffective>(options.prefs, 'haptic')
@ -56,6 +67,7 @@ export function createActivePrefsDomProjection(
// wrote, so every preference change would run it twice.
untrack(() => {
readSlotIntoAttr(slots.direction, attrs, PREFS_DOM_ATTRS.DIR, managedAttrs);
readSlotIntoAttr(slots.language, attrs, PREFS_DOM_ATTRS.LANG, managedAttrs);
readSlotIntoAttr(slots.motion, attrs, PREFS_DOM_ATTRS.MOTION, managedAttrs);
readSlotIntoAttr(slots.sound, attrs, PREFS_DOM_ATTRS.SOUND, managedAttrs);
readSlotIntoAttr(slots.haptic, attrs, PREFS_DOM_ATTRS.HAPTIC, managedAttrs);
@ -71,6 +83,7 @@ export function createActivePrefsDomProjection(
? []
: [
slots.direction?.onChange(apply),
slots.language?.onChange(apply),
slots.motion?.onChange(apply),
slots.sound?.onChange(apply),
slots.haptic?.onChange(apply)

@ -28,6 +28,9 @@ describe('ActivePrefs DOM projection', () => {
try {
expect(target.getAttribute('dir')).toBe('ltr');
// `lang` rides with `dir`: same question about the document, and the
// browser reads both from the DOM (fonts, hyphenation, announcement).
expect(target.getAttribute('lang')).toBe('es-ES');
expect(target.getAttribute('data-motion')).toBe('allow');
expect(target.getAttribute('data-sound')).toBe('allow');
expect(target.getAttribute('data-haptic')).toBe('allow');
@ -40,7 +43,9 @@ describe('ActivePrefs DOM projection', () => {
prefs.haptic.set('reduce');
prefs.language.set('ar-EG');
// One `language.set` moves BOTH: `dir` by derivation, `lang` directly.
expect(target.getAttribute('dir')).toBe('rtl');
expect(target.getAttribute('lang')).toBe('ar-EG');
expect(target.getAttribute('data-motion')).toBe('reduce');
expect(target.getAttribute('data-sound')).toBe('reduce');
expect(target.getAttribute('data-haptic')).toBe('reduce');
@ -51,6 +56,7 @@ describe('ActivePrefs DOM projection', () => {
}
expect(target.hasAttribute('dir')).toBe(false);
expect(target.hasAttribute('lang')).toBe(false);
expect(target.hasAttribute('data-motion')).toBe(false);
expect(target.hasAttribute('data-sound')).toBe(false);
expect(target.hasAttribute('data-haptic')).toBe(false);

@ -492,6 +492,7 @@ describe('UIX layer contracts', () => {
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.requires).toEqual(['prefs', 'dom']);
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.ownsAttrs).toEqual([
'dir',
'lang',
'data-motion',
'data-sound',
'data-haptic'

@ -71,7 +71,7 @@ export interface UixLayerContractTable {
readonly role: 'cross-modal-prefs-projection';
readonly implementation: '$prefs.createActivePrefsDomProjection';
readonly requires: readonly ['prefs', 'dom'];
readonly ownsAttrs: readonly ['dir', 'data-motion', 'data-sound', 'data-haptic'];
readonly ownsAttrs: readonly ['dir', 'lang', 'data-motion', 'data-sound', 'data-haptic'];
readonly forbiddenAttrs: readonly ['data-theme', 'data-mode', 'data-density'];
readonly createsServices: readonly [];
};
@ -186,7 +186,7 @@ export const UIX_LAYER_CONTRACTS = {
role: 'cross-modal-prefs-projection',
implementation: '$prefs.createActivePrefsDomProjection',
requires: ['prefs', 'dom'],
ownsAttrs: ['dir', 'data-motion', 'data-sound', 'data-haptic'],
ownsAttrs: ['dir', 'lang', 'data-motion', 'data-sound', 'data-haptic'],
forbiddenAttrs: ['data-theme', 'data-mode', 'data-density'],
createsServices: []
},

Loading…
Cancel
Save

Powered by TurnKey Linux.