diff --git a/src/arts/prefs/README.md b/src/arts/prefs/README.md index f27c372fb..1ec7d0b2e 100644 --- a/src/arts/prefs/README.md +++ b/src/arts/prefs/README.md @@ -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 ``` diff --git a/src/arts/prefs/dom-projection.ts b/src/arts/prefs/dom-projection.ts index 7245dde49..cb1553201 100644 --- a/src/arts/prefs/dom-projection.ts +++ b/src/arts/prefs/dom-projection.ts @@ -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 `` 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(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(options.prefs, 'language'), motion: readActivePrefsSlot(options.prefs, 'motion'), sound: readActivePrefsSlot(options.prefs, 'sound'), haptic: readActivePrefsSlot(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) diff --git a/src/arts/prefs/test/dom-projection.test.ts b/src/arts/prefs/test/dom-projection.test.ts index aad8ad071..5c1e4e74c 100644 --- a/src/arts/prefs/test/dom-projection.test.ts +++ b/src/arts/prefs/test/dom-projection.test.ts @@ -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); diff --git a/src/uix/contracts.test.ts b/src/uix/contracts.test.ts index 266c66c33..25a151477 100644 --- a/src/uix/contracts.test.ts +++ b/src/uix/contracts.test.ts @@ -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' diff --git a/src/uix/contracts.ts b/src/uix/contracts.ts index 418113b45..9f1c394d0 100644 --- a/src/uix/contracts.ts +++ b/src/uix/contracts.ts @@ -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: [] },