diff --git a/src/arts/prefs/active-prefs.svelte.ts b/src/arts/prefs/active-prefs.svelte.ts index 7ede07aed..8f0b5cba1 100644 --- a/src/arts/prefs/active-prefs.svelte.ts +++ b/src/arts/prefs/active-prefs.svelte.ts @@ -185,8 +185,30 @@ export function createActivePrefs( const pendingCell = $state(false); const lastErrorCell = $state(null); + // Svelte only re-runs a reaction that has READ a `$state`. `get()` reads a + // plain object off the engine, so without this nothing is ever tracked and + // every reader freezes at the value it saw first. Each dimension owns a + // counter, bumped when that dimension actually changes; `get()` reads its + // own counter so the caller subscribes to THAT dimension and no other. + // The number itself means nothing — reading it is the whole point. + // + // Bumped from `effectiveDiff`, which the engine computes by diffing the + // resolved `effective` views. So a dimension that changed by derivation + // (direction following language) bumps too, not only one set by intent. + // + // One counter per key, not one shared cell: a shared cell would wake every + // dimension's readers on any commit — a `motion` change would re-run every + // `direction` reader in the app. The engine stays the only source of the + // VALUE; the counter carries only the "something changed" signal. + const changeCounts = $state>( + Object.fromEntries(Object.keys(options.schema).map((key) => [key, 0])) + ); + const detachCommit = engine.subscribe((event) => { snapshotCell = event.next; + for (const key of Object.keys(event.effectiveDiff)) { + changeCounts[key] = (changeCounts[key] ?? 0) + 1; + } }); const state: ActivePrefsState = { @@ -215,7 +237,7 @@ export function createActivePrefs( const dimensionMembers: Record> = {}; for (const key of Object.keys(options.schema)) { - dimensionMembers[key] = makeDimension(engine, key); + dimensionMembers[key] = makeDimension(engine, key, changeCounts); } const base = { @@ -243,10 +265,15 @@ export function createActivePrefs( function makeDimension( engine: ReturnType, - key: string + key: string, + changeCounts: Record ): ActivePrefsDimension { return { get() { + // Read the counter first so the caller subscribes to this dimension; + // the value still comes from the engine. Outside a reaction this is + // a plain property read and costs nothing. + void changeCounts[key]; return (engine.snapshot().effective as Record)[key]; }, set(value: unknown) { diff --git a/src/arts/prefs/dom-projection.ts b/src/arts/prefs/dom-projection.ts index c78a12765..7245dde49 100644 --- a/src/arts/prefs/dom-projection.ts +++ b/src/arts/prefs/dom-projection.ts @@ -1,14 +1,11 @@ +import { untrack } from 'svelte'; import type { ActiveDom } from '$adom'; import type { Direction } from '$libs/direction'; import type { DomAttrValue } from '$adom'; import type { HapticEffective } from '$libs/haptic'; import type { MotionEffective } from '$libs/motion'; import type { SoundEffective } from '$libs/sound'; -import { - readActivePrefsSlot, - type ActivePrefs, - type ActivePrefsSlot -} from './active-prefs.svelte'; +import { readActivePrefsSlot, type ActivePrefs, type ActivePrefsSlot } from './active-prefs.svelte'; export type ActivePrefsDomTarget = HTMLElement | (() => HTMLElement | null | undefined); @@ -50,10 +47,19 @@ export function createActivePrefsDomProjection( if (!target) return; const attrs: Record = {}; - readSlotIntoAttr(slots.direction, attrs, PREFS_DOM_ATTRS.DIR, 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); + // Reading a dimension subscribes the reader to it. This projection is + // already driven by its own `onChange` subscriptions below, so it must + // not ALSO become a reactive dependency of whoever happens to be on the + // stack: `apply()` runs synchronously inside the engine commit, and the + // app triggers that commit from its own `$effect` (setIntent). Tracking + // here would leave that effect subscribed to the very cell it just + // wrote, so every preference change would run it twice. + untrack(() => { + readSlotIntoAttr(slots.direction, attrs, PREFS_DOM_ATTRS.DIR, 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); + }); if (Object.keys(attrs).length === 0) return; diff --git a/src/arts/prefs/test/active-prefs-reactivity.svelte.test.ts b/src/arts/prefs/test/active-prefs-reactivity.svelte.test.ts new file mode 100644 index 000000000..a5353bf4e --- /dev/null +++ b/src/arts/prefs/test/active-prefs-reactivity.svelte.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { flushSync } from 'svelte'; +import { directionDimension, enumDimension, localeDimension } from '$prefs'; +import { createActivePrefs } from '../active-prefs.svelte.ts'; + +/** + * Reactivity contract of the dimension surface (`prefs..get()`). + * + * This file MUST stay `*.svelte.test.ts`: it only runs under the `client` + * project (vite.config.ts:93). The `server` project compiles `$state` away + * entirely — Svelte's server transform rewrites `let x = $state(v)` to a plain + * `let x = v` — so a `.test.ts` here would pass no matter what the code does. + * + * Why this exists: `getDir()` (src/uix/active-uix/prefs.ts:39) reads a + * dimension through this surface, and ~64 soma component wrappers resolve + * their direction as `dir ?? soma?.prefs.getDir() ?? 'ltr'`. If that read is + * untracked, every one of them freezes its direction at mount. + */ + +const schema = { + language: localeDimension({ catalog: ['es-ES', 'ar-EG'], default: 'es-ES' }), + direction: directionDimension(), + accent: enumDimension(['blue', 'green'] as const, { default: 'blue' }) +}; + +describe('createActivePrefs — dimension reads are reactive', () => { + it('re-runs a reaction when the dimension it reads commits', () => { + const prefs = createActivePrefs({ schema }); + const seen: string[] = []; + + const stop = $effect.root(() => { + $effect(() => { + seen.push(prefs.direction.get() as string); + }); + flushSync(); + + prefs.direction.set('rtl'); + flushSync(); + }); + + expect(seen).toEqual(['ltr', 'rtl']); + + stop(); + prefs.dispose(); + }); + + it('does NOT re-run a reaction when an unrelated dimension commits', () => { + const prefs = createActivePrefs({ schema }); + let runs = 0; + + const stop = $effect.root(() => { + $effect(() => { + void prefs.direction.get(); + runs++; + }); + flushSync(); + + prefs.accent.set('green'); + flushSync(); + }); + + // Tracking must be per-dimension: a commit on `accent` shares the same + // engine snapshot, so a single global cell would wake every direction + // reader in the app (~64 component roots). + expect(runs).toBe(1); + + stop(); + prefs.dispose(); + }); + + it('re-runs when the dimension changes by DERIVATION, not only by intent', () => { + const prefs = createActivePrefs({ schema }); + const seen: string[] = []; + + const stop = $effect.root(() => { + $effect(() => { + seen.push(prefs.direction.get() as string); + }); + flushSync(); + + // `direction` derives from `language` (dimensions/direction.ts:36-40); + // no intent is ever set on `direction` itself here. + prefs.language.set('ar-EG'); + flushSync(); + }); + + // The derivation itself is sound — the engine rebuilds `effective` + // through the derive hooks on every commit. What is missing is the + // propagation: nothing re-runs the reaction that read it. + expect(prefs.direction.get()).toBe('rtl'); + expect(seen).toEqual(['ltr', 'rtl']); + + stop(); + prefs.dispose(); + }); +}); diff --git a/src/uix/active-uix/prefs.ts b/src/uix/active-uix/prefs.ts index 4562db496..e5703293a 100644 --- a/src/uix/active-uix/prefs.ts +++ b/src/uix/active-uix/prefs.ts @@ -3,12 +3,24 @@ import type { Direction } from '$libs/direction'; import type { LocaleSource } from '$libs/locale'; export interface ActiveUixPrefsView { - getDir(): Direction; + /** + * Effective direction, or `undefined` when the app registered no + * `direction` dimension. + * + * The `undefined` is load-bearing, not laziness. A component uses this + * for two different purposes: its own logic (arrow keys, pointer sign, + * placement flip) needs a concrete value and defaults to `'ltr'`; the + * `dir` ATTRIBUTE it writes to the DOM must only appear when someone + * actually asserted a direction. Collapsing the two here — returning + * `'ltr'` for "nobody said" — is what makes a component stamp + * `dir="ltr"` onto a page whose `` says `rtl`, flipping its own + * subtree back. Absent attribute means "inherit", which is the right + * answer and costs no DOM read. + */ + getDir(): Direction | undefined; onPreferenceChange(handler: () => void): () => void; } -const DEFAULT_DIR: Direction = 'ltr'; - export const readActiveUixPrefsSlot = readActivePrefsSlot as ( prefs: unknown, key: string @@ -36,8 +48,8 @@ export function connectLangsToPrefs( export function createActiveUixPrefsView(prefs: ActivePrefs): ActiveUixPrefsView { return { - getDir(): Direction { - return readActiveUixPrefsSlot(prefs, 'direction')?.get() ?? DEFAULT_DIR; + getDir(): Direction | undefined { + return readActiveUixPrefsSlot(prefs, 'direction')?.get(); }, onPreferenceChange(handler: () => void): () => void { const detachers = [ diff --git a/src/uix/active-uix/test/prefs-view.svelte.test.ts b/src/uix/active-uix/test/prefs-view.svelte.test.ts new file mode 100644 index 000000000..deeaff33e --- /dev/null +++ b/src/uix/active-uix/test/prefs-view.svelte.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { booleanDimension, createActivePrefs, directionDimension, localeDimension } from '$prefs'; +import { createActiveUixPrefsView } from '../prefs.ts'; + +/** + * `getDir()` distinguishes "nobody asserted a direction" (`undefined`) from + * "the direction is ltr" (`'ltr'`). Components collapse it to a concrete value + * for their own math, but the `dir` ATTRIBUTE they write must only appear in + * the first case — otherwise a component dropped into a page whose `` + * says `rtl` stamps `dir="ltr"` on its own root and flips its subtree back. + * + * `*.svelte.test.ts` because `createActivePrefs` is rune-backed: the `server` + * vitest project compiles `$state` away and would pass regardless. + */ + +describe('createActiveUixPrefsView — getDir distinguishes absent from ltr', () => { + it('returns undefined when the app registered no `direction` dimension', () => { + const prefs = createActivePrefs({ + schema: { sidebarCollapsed: booleanDimension({ default: false }) } + }); + + expect(createActiveUixPrefsView(prefs).getDir()).toBeUndefined(); + + prefs.dispose(); + }); + + it('returns the effective value when the dimension IS registered', () => { + const prefs = createActivePrefs({ + schema: { + language: localeDimension({ catalog: ['es-ES', 'ar-EG'], default: 'es-ES' }), + direction: directionDimension() + } + }); + const view = createActiveUixPrefsView(prefs); + + expect(view.getDir()).toBe('ltr'); + + prefs.direction.set('rtl'); + expect(view.getDir()).toBe('rtl'); + + // Derivation counts as an assertion too: the app declared a language, + // and direction follows from it. + prefs.direction.clear(); + prefs.language.set('ar-EG'); + expect(view.getDir()).toBe('rtl'); + + prefs.dispose(); + }); +}); diff --git a/src/uix/eidos/components/accordion/accordion.css b/src/uix/eidos/components/accordion/accordion.css index dbdcad1c2..1d75b9bd5 100644 --- a/src/uix/eidos/components/accordion/accordion.css +++ b/src/uix/eidos/components/accordion/accordion.css @@ -112,7 +112,10 @@ font-size: var(--_accordion-trigger-font-size); font-weight: var(--accordion-trigger-font-weight); line-height: var(--accordion-trigger-line-height); - text-align: left; + /* Logical, not `left`: a button defaults to `center`, so this rule exists to + pin the label to the reading start. `left` does not flip under RTL — the + bidi reordering happens but the block stays against the wrong edge. */ + text-align: start; cursor: pointer; transition: background var(--accordion-trigger-transition-duration) var(--accordion-trigger-transition-ease), diff --git a/src/uix/eidos/components/slider/slider.css b/src/uix/eidos/components/slider/slider.css index 082a68561..fd7ba451d 100644 --- a/src/uix/eidos/components/slider/slider.css +++ b/src/uix/eidos/components/slider/slider.css @@ -92,11 +92,15 @@ transform: translateY(-50%); } +/* Centring on the inline axis is a LOGICAL offset (`margin-inline-start`), never + `translateX(-50%)`: transforms are physical, so pairing one with + `inset-inline-start` centres in LTR and lands a full track-width off-axis in + RTL. Same idiom the Tick already used below. */ [data-slider][data-orientation='vertical']::before { inset-block: 0; inset-inline-start: 50%; inline-size: var(--_slider-track-size); - transform: translateX(-50%); + margin-inline-start: calc(var(--_slider-track-size) / -2); } [data-slider-range] { @@ -114,7 +118,7 @@ [data-slider-range][data-orientation='vertical'] { inset-inline-start: 50%; inline-size: var(--_slider-track-size); - transform: translateX(-50%); + margin-inline-start: calc(var(--_slider-track-size) / -2); } /* Secondary track (buffer / preloaded region) — same geometry as the range, @@ -134,7 +138,7 @@ [data-slider-secondary-range][data-orientation='vertical'] { inset-inline-start: 50%; inline-size: var(--_slider-track-size); - transform: translateX(-50%); + margin-inline-start: calc(var(--_slider-track-size) / -2); } [data-slider-thumb] { diff --git a/src/uix/soma/components/accordion/accordion-provider.svelte.ts b/src/uix/soma/components/accordion/accordion-provider.svelte.ts index e2fd50306..27d5dba3c 100644 --- a/src/uix/soma/components/accordion/accordion-provider.svelte.ts +++ b/src/uix/soma/components/accordion/accordion-provider.svelte.ts @@ -26,11 +26,21 @@ interface AccordionOpts disabled: boolean; orientation: Orientation; loop: boolean; - dir: Direction; + /** `undefined` = nobody asserted a direction. See `resolvedDir`. */ + dir: Direction | undefined; region: boolean; }> {} export class AccordionProvider { + /** + * Direction for this component's OWN math (arrow keys). Always concrete — + * `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test. + * The DOM attribute is the other half: `props` stamps `opts.dir.current` + * raw, so it is omitted when nobody asserted a direction and the element + * inherits instead of forcing `ltr` onto an RTL page. + */ + readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr'); + readonly opts: AccordionOpts; readonly runtimePart: SomaRuntimePart; readonly soma = Soma.require(); @@ -332,7 +342,7 @@ export class AccordionTriggerProvider { const root = this.item.provider; const orientation = root.opts.orientation.current; - const dir = root.opts.dir.current; + const dir = root.resolvedDir; const { nextKey, prevKey } = getDirectionalKeys(dir, orientation); const triggers = root.getTriggers(); const currentIndex = triggers.indexOf(e.currentTarget as HTMLButtonElement); diff --git a/src/uix/soma/components/accordion/components/accordion.svelte b/src/uix/soma/components/accordion/components/accordion.svelte index 56946fb5c..e65ae53cd 100644 --- a/src/uix/soma/components/accordion/components/accordion.svelte +++ b/src/uix/soma/components/accordion/components/accordion.svelte @@ -1,15 +1,12 @@ diff --git a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts index 83d7f490c..e7db01a47 100644 --- a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts +++ b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts @@ -46,11 +46,20 @@ interface ContextMenuOpts ProviderOpts, StateProps<{ open: boolean }>, ActiveProps<{ - dir: Direction; + dir: Direction | undefined; onOpenChangeComplete: OnChangeFn; }> {} export class ContextMenuProvider { + /** + * Direction for this part's OWN math (arrow keys, placement flip). Always + * concrete — `undefined` and `'ltr'` are indistinguishable to every + * `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped + * is the raw `opts.dir.current`, so it is omitted when nobody asserted a + * direction and the element inherits instead of forcing `ltr`. + */ + readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr'); + readonly opts: ContextMenuOpts; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; @@ -228,11 +237,20 @@ interface ContextMenuContentOpts onInteractOutside: (e: PointerEvent) => void; interactOutsideBehavior: DismissalBehavior; onFocusOutside: (e: FocusEvent) => void; - dir: Direction; + dir: Direction | undefined; style: StyleProperties | null | undefined | string; }> {} export class ContextMenuContentProvider { + /** + * Direction for this part's OWN math (arrow keys, placement flip). Always + * concrete — `undefined` and `'ltr'` are indistinguishable to every + * `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped + * is the raw `opts.dir.current`, so it is omitted when nobody asserted a + * direction and the element inherits instead of forcing `ltr`. + */ + readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr'); + readonly opts: ContextMenuContentOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ContextMenuContentOpts) { @@ -338,7 +356,7 @@ export class ContextMenuContentProvider { const items = this.provider.getItems(container); const active = this.provider.soma.dom.activeElement(container) as HTMLElement | null; const currentIndex = active ? items.indexOf(active) : -1; - const dir = this.provider.opts.dir.current; + const dir = this.provider.resolvedDir; const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical'); let targetIndex = -1; @@ -962,11 +980,20 @@ interface ContextMenuSubContentOpts collisionBoundary: Arrayable; collisionPadding: number | Partial>; loop: boolean; - dir: Direction; + dir: Direction | undefined; style: StyleProperties | null | undefined | string; }> {} export class ContextMenuSubContentProvider { + /** + * Direction for this part's OWN math (arrow keys, placement flip). Always + * concrete — `undefined` and `'ltr'` are indistinguishable to every + * `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped + * is the raw `opts.dir.current`, so it is omitted when nobody asserted a + * direction and the element inherits instead of forcing `ltr`. + */ + readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr'); + readonly opts: ContextMenuSubContentOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ContextMenuSubContentOpts) { diff --git a/src/uix/soma/components/css-field/css-field-provider.svelte.ts b/src/uix/soma/components/css-field/css-field-provider.svelte.ts index 88cf2631e..c42b0abd3 100644 --- a/src/uix/soma/components/css-field/css-field-provider.svelte.ts +++ b/src/uix/soma/components/css-field/css-field-provider.svelte.ts @@ -135,6 +135,7 @@ interface CssFieldOpts }> {} export class CssFieldProvider { + readonly opts: CssFieldOpts; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; diff --git a/src/uix/soma/components/date-field/components/date-field.svelte b/src/uix/soma/components/date-field/components/date-field.svelte index 6cd8b111b..9e66f1366 100644 --- a/src/uix/soma/components/date-field/components/date-field.svelte +++ b/src/uix/soma/components/date-field/components/date-field.svelte @@ -1,20 +1,13 @@ diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts index f095e6b57..4f0f32c4f 100644 --- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts +++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts @@ -51,12 +51,21 @@ interface MenuOpts ProviderOpts, StateProps<{ open: boolean }>, ActiveProps<{ - dir: Direction; + dir: Direction | undefined; accessibleWhenDisabled: boolean; onOpenChangeComplete: OnChangeFn; }> {} export class MenuProvider { + /** + * Direction for this part's OWN math (arrow keys, placement flip). Always + * concrete — `undefined` and `'ltr'` are indistinguishable to every + * `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped + * is the raw `opts.dir.current`, so it is omitted when nobody asserted a + * direction and the element inherits instead of forcing `ltr`. + */ + readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr'); + readonly opts: MenuOpts; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; @@ -284,12 +293,21 @@ interface MenuContentOpts onInteractOutside: (e: PointerEvent) => void; interactOutsideBehavior: DismissalBehavior | undefined; onFocusOutside: (e: FocusEvent) => void; - dir: Direction; + dir: Direction | undefined; style: StyleProperties | null | undefined | string; customAnchor: HTMLElement | null; }> {} export class MenuContentProvider { + /** + * Direction for this part's OWN math (arrow keys, placement flip). Always + * concrete — `undefined` and `'ltr'` are indistinguishable to every + * `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped + * is the raw `opts.dir.current`, so it is omitted when nobody asserted a + * direction and the element inherits instead of forcing `ltr`. + */ + readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr'); + readonly opts: MenuContentOpts; readonly runtimePart: SomaRuntimePart; static create(opts: MenuContentOpts) { @@ -439,7 +457,7 @@ export class MenuContentProvider { const items = this.provider.getItems(container); const active = this.provider.soma.dom.activeElement(container) as HTMLElement | null; const currentIndex = active ? items.indexOf(active) : -1; - const dir = this.provider.opts.dir.current; + const dir = this.provider.resolvedDir; const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical'); let targetIndex = -1; @@ -1192,11 +1210,20 @@ interface MenuSubContentOpts collisionBoundary: Arrayable; collisionPadding: number | Partial>; loop: boolean; - dir: Direction; + dir: Direction | undefined; style: StyleProperties | null | undefined | string; }> {} export class MenuSubContentProvider { + /** + * Direction for this part's OWN math (arrow keys, placement flip). Always + * concrete — `undefined` and `'ltr'` are indistinguishable to every + * `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped + * is the raw `opts.dir.current`, so it is omitted when nobody asserted a + * direction and the element inherits instead of forcing `ltr`. + */ + readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr'); + readonly opts: MenuSubContentOpts; readonly runtimePart: SomaRuntimePart; static create(opts: MenuSubContentOpts) { diff --git a/src/uix/soma/components/emoji-picker/components/emoji-picker.svelte b/src/uix/soma/components/emoji-picker/components/emoji-picker.svelte index 40756cfb3..93244625c 100644 --- a/src/uix/soma/components/emoji-picker/components/emoji-picker.svelte +++ b/src/uix/soma/components/emoji-picker/components/emoji-picker.svelte @@ -1,5 +1,6 @@