feat(eidos)!: un escalar es una instancia CLAVADA — mueren el throw de la puerta 2 y las fuentes por eje (c')

Cierra el estado INTERINO de e3c0899dd. `resolvePreferences` lanzaba con `uix` +
escalar y `ActiveEidos.create()` inyecta `uix` SIEMPRE, así que la regla no decía
«no mezcles dos motores»: decía «ningún escalar, nunca», y toda demo que clava un
panel en oscuro arrancaba con excepción. Forma firmada por el autor (c'):

- Pines SÍ: `theme` / `mode` / `density` / `scaling` en `ActiveEidosOptions` son
  PINES — el eje queda clavado en la instancia y gana sobre la fuente, prefs
  incluido (precedente en el mismo constructor: `options.dom ?? options.uix?.dom`).
  Un eje clavado sigue suscrito: `apply()` corre y lo encuentra quieto.
- Sources por eje FUERA sin shim: `modeSource` / `densitySource` / `scalingSource`
  eran la API del segundo motor. La puerta de sustitución ENTERA sigue siendo
  `preferences`. Mueren `createComposedPreferenceSource`, `createStaticValueSource`
  y `PREFERENCE_OPTION_KEYS`; nace `createStandalonePreferenceSource(dom)`:
  sin primer motor no hay segundo — standalone sigue al SO EN VIVO para `mode`.
- Sin throw. UNA precedencia por UN envoltorio (`createPinnedPreferenceSource`)
  sobre la puerta que responda (`preferences` · `uix.prefs` · standalone):
  `pin ?? fuente ?? fallback` en las tres.
- Una función pura, dos lectores: `src/uix/eidos/lib/visual-preference.ts`
  (`VisualPreferencePins` + `resolveVisualPreference(pin, value)`), importable por
  el boot compilado como `lib/theme-id.ts`. `ActiveEidosOptions extends
  VisualPreferencePins`; `UixBootParams.pins` lo toma entero;
  `renderUixBootScript({ pins })` los embarca. DOS parámetros y no tres: el
  fallback no es compartido (el boot resuelve siempre los cuatro ejes; en runtime
  lo aporta cada fuente) — acta en changelog §60.
- Delta cero a CINCO casos: instancia clavada a dark con sobre en light (ni un
  attr se mueve al hidratar) + familia clavada `acme` con modo de prefs
  (`acme-dark` a los dos lados: el pin atraviesa `resolveThemeId`). Mutaciones
  probadas: sin pin en el boot → 2 rojos; sin pin en el envoltorio → 2 rojos;
  restauración por sha256.
- `contracts.test.ts`: retirado el guard «guards UIX docs shell from writing
  visual prefs through ActivePrefs» — codificaba la doctrina REVOCADA el
  2026-09-14 (escribir `theme` en prefs es el camino canónico); no se invierte:
  un guard sobre un árbol congelado que se reconstruye no mide nada. Acta en §60.
- Coste en el árbol congelado: diez demos pasan `*Source` y pierden el tipo →
  ledger `check-debt.ts` +22 (8 entradas nuevas, 2 subidas), cada una con causa
  fechada (excepción firmada 2026-09-13). `src/` a CERO.
- Docs: eidos.md §preferencias · prefs README §Eidos Boundary · guide.md
  (`pins`, tercer parámetro del boot) · changelog §60 · active-uix / overview /
  active-architecture / blocks (snippets con `modeSource` al flujo canónico).

Verificación: vitest eidos+active-uix+prefs+contracts+value-channels 59/640 ·
check 95 = 73 + 22 exacto, src/ 0 · check:gate OK · docs:check 819 OK ·
generate:boot 15104 bytes con sync verde · adversarial Opus independiente
(informe en el handoff). Constructor + adversarial Opus 5; la sesión coordina.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
alpha-0.1-background
dev 3 weeks ago
parent 9c465a8445
commit f875f60b97

@ -316,9 +316,9 @@ src/uix/eidos/
`ActiveEidos` is the source of truth for theming: primitives (color + alpha
scales, size map, spaces, control height, radius, border, opacity, z-index,
focus ring, layout, typography, shadow, motion, icon), semantic roles and
themes. It also resolves the active theme from its visual sources (`theme`,
`modeSource`, `densitySource` or defaults) and injects runtime CSS when the
app doesn't precompile it. External themes can come from CSS alone if they
themes. It also resolves the active theme from `uix.prefs` (or from the axes
the instance pinned) and injects runtime CSS when the app doesn't precompile
it. External themes can come from CSS alone if they
honor the custom-property contract
(`themeSource: 'auto' | 'config' | 'css'`); `getCssContract()` publishes that
contract as typed data and `renderContractCss()` materializes it as empty CSS
@ -447,16 +447,12 @@ When a UIX shell wants runtime visual mode, the canonical flow is:
```ts
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
const eidos = ActiveEidos.create({
theme: 'base',
modeSource,
applyDom: true
});
const eidos = ActiveEidos.create({ applyDom: true });
```
`prefsProjection` and `eidos` are disposed with the shell. Light/dark mode is
not written into `prefs.theme`; it is passed to `ActiveEidos` as a visual
source.
`prefsProjection` and `eidos` are disposed with the shell. Light/dark mode
goes to `uix.prefs.setIntent('mode', …)` and eidos follows it; a scalar on
`ActiveEidos` is a pin (a nailed instance), not the app's preference.
---

@ -101,15 +101,11 @@ const prefsProjection = createActivePrefsDomProjection({
dom: uix.dom
});
const eidos = ActiveEidos.create({
theme: 'base',
modeSource,
applyDom: true
});
const eidos = ActiveEidos.create({ applyDom: true });
```
`prefsProjection` owns `dir`, `data-motion`, `data-sound` and `data-haptic`.
`ActiveEidos` owns `data-theme`, `data-mode` and `data-density`. Light/dark
mode goes to `ActiveEidos.modeSource`, not to `prefs.theme` — `theme` is not
part of UIX's core prefs preset and writing it raises
`prefs::unknown_dimension`.
mode is a prefs dimension like the rest (2026-09-14): the toggle writes
`uix.prefs.setIntent('mode', 'dark')` and eidos, which reads the slot,
re-applies the attributes.

@ -159,8 +159,8 @@ asserts its own detectors against inline fixtures on every run).
the grid, the scroll model, the landmark set, and the state that its own
regions share (a collapsed nav, a folded aside). What a shell does NOT own,
and this is the line that matters: **the composition root**. Creating
`ActiveApp`, attaching `ActiveUix`, mounting `<Uix>`, wiring `modeSource` /
`densitySource` into `ActiveEidos`, projecting prefs onto the document,
`ActiveApp`, attaching `ActiveUix`, mounting `<Uix>`, creating
`ActiveEidos`, projecting prefs onto the document,
persisting them — all of that is the application's, exactly as it is for every
other block (§Services above; only composition roots create services). A shell
that read `App.session` to draw a user menu would be a composition root

@ -407,7 +407,9 @@ document through `ActiveDom`.
### Where the four preferences come from
`resolvePreferences` has three doors and takes the first that answers:
ONE door answers, and the PINS are applied over what it answered.
The door, in this order:
1. an explicit `preferences: ActiveEidosPreferenceSource` — the app owns the
question outright;
@ -415,34 +417,40 @@ document through `ActiveDom`.
`density` and `scaling` are prefs dimensions (2026-09-14; see
[`src/arts/prefs/README.md`](../../src/arts/prefs/README.md) §"One engine"),
so eidos reads the slots and subscribes to them;
3. the per-option sources — only when there is no `uix` at all: bare CSS
serialization, SSR renders, unit tests.
3. standalone eidos — no `uix`, no `prefs`, no `preferences`: `mode` follows
the OS **live** through `prefers-color-scheme` (fallback `light`) and the
other three stand on eidos' defaults (`base`, `comfortable`, `100`).
Without a first engine there is no second one to hand the question to;
bare CSS serialization, SSR renders and unit tests boot exactly here.
Then the pins. An explicit `theme` / `mode` / `density` / `scaling` **nails**
that axis on this instance and wins over the source, prefs included — a
preview panel, a hero that stays dark whatever the reader prefers. It is one
wrapper over whichever door answered, so the rule reads the same at all three:
`pin ?? source ?? fallback`. A nailed axis stays SUBSCRIBED: a preference
change still runs `apply()`, and `apply()` finds that axis unmoved.
```ts
// Inside a UIX tree — no visual options at all.
// Inside a UIX tree, nothing pinned — the user's preference drives the page.
const eidos = ActiveEidos.create({ applyDom: true });
uix.prefs.setIntent('mode', 'dark'); // eidos re-applies data-mode + data-theme
// Standalone eidos — no uix, so the per-option sources are the way in.
const headless = createActiveEidos({
applyDom: false,
theme: 'base',
mode: 'dark',
density: 'compact'
});
// A NAILED instance: dark whatever prefs says. `density` still follows the user.
const preview = createActiveEidos({ uix, applyDom: true, mode: 'dark' });
// Standalone eidos — no uix at all.
const headless = createActiveEidos({ applyDom: false, theme: 'base', density: 'compact' });
```
Doors 2 and 3 are mutually exclusive **by throw**, not by precedence: passing
`theme`, `mode`, `density`, `scaling`, `modeSource`, `densitySource` or
`scalingSource` together with a `uix` raises `ActiveEidosConfigError`
(`preferences live in uix.prefs — set the intent there`). Silently preferring
one of the two would leave a caller believing the scalar still drives the page
while prefs quietly does — the same failure mode `resolveEidosConfig` already
refuses for `config` + `themeBase`.
Standalone defaults are unchanged: no `modeSource` → `prefers-color-scheme`
with a `light` fallback; no `densitySource` → `comfortable`; no
`scalingSource` → `100`.
Substituting the resolution WHOLE is `preferences` — the one door that
replaces the engine instead of nailing an axis of it. There are no per-axis
source options: `modeSource` / `densitySource` / `scalingSource` were the
second engine's API and left with it
([changelog §60](../theming/changelog.md)).
A pinned ROOT instance has to teach the same pins to the pre-hydration boot
(`renderUixBootScript({ pins })`), or the page paints the preference before
hydration and the pin after it.
Reading the system does not stop at boot. Inside a UIX tree the OS hints
(`prefers-color-scheme`, `prefers-reduced-motion`) seed the prefs environment at

@ -220,15 +220,12 @@ setActiveUix(uix);
Soma.create();
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
const eidos = ActiveEidos.create({
theme: 'base',
modeSource,
applyDom: true
});
const eidos = ActiveEidos.create({ applyDom: true });
```
The light/dark toggle feeds `ActiveEidos.modeSource`, not `uix.prefs.theme`.
`prefs.theme` is not part of UIX's core preset.
The light/dark toggle feeds `uix.prefs.setIntent('mode', …)`; eidos reads the
slot and re-applies `data-mode` + `data-theme` (2026-09-14: `mode`, `theme`,
`density` and `scaling` are prefs dimensions).
In standalone mode, `createActiveUix()` instantiates `ActivePrefs` with the
standard UIX preset and creates the configured services. In attach mode,

@ -2692,9 +2692,119 @@ hasta hidratar.
---
**Última revisión**: 2026-09-14 (§59 un motor, un sobre, un código; `prefs`
resuelve `mode`/`theme`/`density`/`scaling` y el boot precompilado los estampa
antes del primer pintado). Anterior: 2026-09-13 (§58 `appearance` obligatorio
en `ThemeDefinition`; el sufijo del id baja a convención de búsqueda). Si algo en
este doc no coincide con el código, el código gana — pero abre un issue para que
actualicemos el doc.
## 60. Un escalar es una INSTANCIA CLAVADA — mueren el throw y las fuentes por eje (2026-09-14)
**El defecto.** §59 cerró la puerta 2 con un `throw`: pasar `theme`, `mode`,
`density`, `scaling`, `modeSource`, `densitySource` o `scalingSource` junto a un
`uix` era un error de configuración. Pero `ActiveEidos.create()` inyecta `uix`
SIEMPRE, así que la regla no decía «no mezcles dos motores»: decía «ningún
escalar, nunca». Toda demo que clava un panel en oscuro
(`ActiveEidos.create({ applyDom: true, mode: 'dark' })`) arrancaba con excepción.
El throw trataba como error una figura legítima —una instancia clavada— y no
dejaba ninguna forma de expresarla.
**La distinción que faltaba.** Son dos preguntas distintas y §59 las fundió:
```text
La preferencia del USUARIO: uix.prefs.setIntent('mode', 'dark')
Una instancia CLAVADA: createActiveEidos({ uix, applyDom: true, mode: 'dark' })
```
La primera mueve la app entera y se persiste. La segunda no es una preferencia
de nadie: es un panel de previsualización, un hero que se queda oscuro lea quien
lea. Confundirlas costaba las dos.
### 1. Pines — lo explícito gana, y no lanza
Un escalar en `ActiveEidosOptions` es un **pin**: ese eje queda clavado en esa
instancia y gana sobre la fuente, prefs incluido. El precedente estaba a una
pantalla de distancia en el mismo constructor: `options.dom ?? options.uix?.dom`
—lo que escribió quien llama gana, sin throw—.
La precedencia es UNA y se aplica por UN envoltorio sobre la puerta que haya
respondido (`preferences` explícita · `uix.prefs` · standalone), así que la regla
se lee igual en las tres: `pin ?? fuente ?? fallback`. Un eje clavado **sigue
suscrito** a su fuente: un cambio de preferencia sigue corriendo `apply()`, que
encuentra ese eje quieto. Eso es lo que prueban los casos nuevos de delta cero.
### 2. Las fuentes por eje se van enteras
`modeSource` / `densitySource` / `scalingSource` eran la API del SEGUNDO motor:
un hueco por eje para que la app enchufase su propia resolución cuando eidos aún
resolvía por su cuenta. Con `prefs` resolviendo los cuatro ejes ya no describen
nada, y mantenerlas sería ofrecer tres puertas traseras a un motor que ya no
existe. Se retiran sin shim. La puerta de sustitución **entera** sigue siendo
`preferences: ActiveEidosPreferenceSource` — se reemplaza el motor, no se clava
un eje de él.
Con ellas mueren `createComposedPreferenceSource` y `createStaticValueSource`
(una fuente estática era la forma de decir «este eje no se mueve», que es
exactamente lo que ahora dice un pin). Queda
`createStandalonePreferenceSource(dom)`: **sin primer motor no hay segundo**, así
que un eidos sin `uix` sigue al SO EN VIVO para `mode`
(`createSystemColorSchemeSource`, que se queda) y planta los otros tres en sus
defaults. Los pines se aplican encima, igual que en las otras dos puertas.
### 3. Una función pura, dos lectores
El pin lo leen el runtime y el script de pre-hidratación, y si los dos lo
interpretan por su cuenta vuelven a ser dos implementaciones de una cascada. La
regla vive en un módulo PURO —cero Svelte, cero `$app/*`, importable por el boot
compilado, el mismo sitio y el mismo motivo que `lib/theme-id.ts`—:
```text
src/uix/eidos/lib/visual-preference.ts
VisualPreferencePins { theme?, mode?, density?, scaling? }
resolveVisualPreference(pin, value) -> pin ?? value
```
`ActiveEidosOptions extends VisualPreferencePins` y `UixBootParams.pins` lo toma
entero: los cuatro ejes se DECLARAN una sola vez, así que un quinto no puede
aparecer en un lado y faltar en el otro. `renderUixBootScript({ pins })` los
embarca en el JSON del script (ya hacía spread de sus parámetros).
**Dos parámetros y no tres.** La firma que se firmó era
`resolveVisualPreference(pin, value, fallback)`. El tercer escalón NO es
compartido: el boot resuelve contra `createDefaultUixPrefsSchema`, que responde
siempre a los cuatro ejes, y en el runtime el fallback ya lo pone cada fuente (el
adaptador de prefs degrada a los defaults de eidos cuando un esquema omite una
dimensión; la puerta standalone planta `DEFAULT_*`). Con tres parámetros el boot
tendría que pasar un argumento muerto cuatro veces. La función comparte
exactamente lo que los dos lectores no pueden derivar: **el pin gana**. La
precedencia de punta a punta sigue siendo `pin ?? fuente ?? fallback`.
**El delta cero crece a cinco casos.** `boot-delta.test.ts` añade una instancia
clavada a `dark` con el sobre persistido en `light` (el boot estampa el pin, el
runtime hidrata y no se mueve un atributo) y una FAMILIA clavada (`acme`) con el
modo viniendo de prefs: `acme-dark` a los dos lados, que es la prueba de que el
pin de familia atraviesa `resolveThemeId` y no se queda en el escalón anterior.
El coste de olvidarlo está escrito en el JSDoc de `renderUixBootScript`: una app
que clava la raíz y no se lo enseña al boot pinta la preferencia antes de
hidratar y el pin después — el flash de §59, invertido.
**Lo que cuesta.** El árbol congelado (`web/routes/**`) pasa `modeSource` en diez
ficheros y pierde el tipo: el ledger de `check-debt.ts` sube **+22 errores** en
diez entradas (dos subidas, `alpha/+layout@.svelte` 3 → 7 y `temas/tema/+page.svelte`
1 → 3; ocho entradas nuevas), cada una con su causa escrita. Son la propiedad en
exceso más los `onChange` que pierden su tipo contextual con ella. Es la excepción
que la cabecera de `check-debt.ts` ya adjudicó: el framework se mueve, las demos
congeladas no pueden seguirle. `src/` queda a cero. A cambio, los dos call sites
que §59 dejó lanzando en runtime vuelven a arrancar cuando ese árbol se
reconstruya, con el escalar significando lo que dice.
**El acta del guard retirado.** `src/uix/contracts.test.ts` tenía un `it` —«guards
UIX docs shell from writing visual prefs through ActivePrefs»— que prohibía
`prefs.theme` y `setIntent('theme')` bajo `web/routes/uix`. Codificaba la doctrina
que §59 REVOCÓ: escribir `theme` en prefs es hoy el camino canónico. Se retira, no
se invierte. Un guard positivo sobre un árbol congelado que se va a reconstruir no
mide nada, y **el guard vive donde NACE el valor, no donde se observa**. `grepSources`
conserva siete usos y `REPO_ROOT` once: la retirada no deja huérfanos.
---
**Última revisión**: 2026-09-14 (§60 un escalar es una instancia clavada; mueren
el throw de la puerta 2 y las fuentes por eje). Anterior: 2026-09-14 (§59 un
motor, un sobre, un código; `prefs` resuelve `mode`/`theme`/`density`/`scaling` y
el boot precompilado los estampa antes del primer pintado). Si algo en este doc no
coincide con el código, el código gana — pero abre un issue para que actualicemos
el doc.

@ -323,7 +323,7 @@ not own `app.html` and does not install a hook. Drop `nonce` when the site has
no CSP; under Kit's CSP pass the nonce Kit issued or the browser refuses the
inline script and you are back to the flash.
### The two parameters, and why they are yours
### The three parameters, and why they are yours
- `defaultLocale` — the locale you passed to `createActiveUix({ langs })`. The
preference schema is built around it, so a boot given a different one
@ -331,6 +331,20 @@ inline script and you are back to the flash.
- `themeIds` — `eidos.listThemes()`. It feeds the "is this family already a
complete theme id?" branch; without it every family gets a `-light` /
`-dark` suffix appended and `data-theme` disagrees with the runtime.
- `pins` — the axes your ROOT `ActiveEidos` nailed, if any
(`ActiveEidos.create({ mode: 'dark' })` is a nailed instance, not a
preference). A pin wins over the resolved preference on both sides, so a
site that nails an axis and does not repeat it here paints the user's
preference before hydration and swaps to the pin after it — this section's
flash, inverted. Nothing pinned, nothing to pass.
```ts
renderUixBootScript({
defaultLocale: 'es',
themeIds: eidos.listThemes(),
pins: { mode: 'dark' }
});
```
Pass `storageKey` as well if you replaced the canonical persistence adapter
(`prefs: { storage }`) with one that writes somewhere else. Key and adapter

@ -17,7 +17,14 @@
* Populated by fase A from the svelte-check run on alpha-0.1-background.
*/
export const CHECK_DEBT: Record<string, number> = {
'web/routes/alpha/+layout@.svelte': 3,
// 0 → 1 (2026-09-14): batch (c′) retired `modeSource` / `densitySource` /
// `scalingSource` from `ActiveEidosOptions` — the whole substitution door is
// `preferences` now, and a scalar is a PIN. The frozen tree cannot follow.
'web/routes/active/docs/agnt/+page.svelte': 1,
// 3 → 7 (2026-09-14): the same retired `*Source` options — the excess
// property, plus the three `onChange` handlers that lost their contextual
// type with it. The other 3 (ToggleGroup `"type"`) are the old entry.
'web/routes/alpha/+layout@.svelte': 7,
'web/routes/alpha/components/button/+page.svelte': 5,
'web/routes/alpha/lib/Playground.svelte': 3,
// 0 → 2 (2026-09-13): `ThemeDefinition.appearance` became mandatory and
@ -27,7 +34,13 @@ export const CHECK_DEBT: Record<string, number> = {
// `content.subtle` error per literal and suppresses the outer one. Fixing the
// inner error surfaces `appearance` in the same count; this cannot reach 0.
'web/routes/alpha/lib/theme-variants.ts': 4,
// 0 → 2 (2026-09-14): retired `*Source` options — excess property + handler.
'web/routes/alpha/sheet/[variant]/+layout@.svelte': 2,
'web/routes/alpha/sheet/[variant]/+page.svelte': 1,
// 0 → 2 (2026-09-14): retired `*Source` options — excess property + handler.
'web/routes/blocks/+layout@.svelte': 2,
// 0 → 2 (2026-09-14): retired `*Source` options — excess property + handler.
'web/routes/blocks/_lib/BootUix.svelte': 2,
'web/routes/demos/animations/background/balastro/+page.svelte': 1,
'web/routes/demos/animations/background/beam/beam.svelte': 2,
'web/routes/demos/animations/background/bends/+page.svelte': 1,
@ -54,9 +67,21 @@ export const CHECK_DEBT: Record<string, number> = {
// shadow error TS reports per literal; fixing it surfaces `appearance`, same count.
'web/routes/temas/_lib/grafito.ts': 2,
'web/routes/temas/animations/panel-cascade/+page.svelte': 1,
// 0 → 1 (2026-09-14): retired `*Source` options (the handler is named here,
// so only the excess property errors).
'web/routes/temas/estudio/+page.svelte': 1,
// 0 → 2 (2026-09-14): retired `*Source` options — excess property + handler.
'web/routes/temas/gradientes/+page.svelte': 2,
// 0 → 4 (2026-09-14): retired `*Source` options — excess property + the three
// handlers that lost their contextual type.
'web/routes/temas/grafito/+layout@.svelte': 4,
'web/routes/temas/sema/_lib/audition.ts': 1,
'web/routes/temas/sema/+page.svelte': 1,
'web/routes/temas/tema/+page.svelte': 1,
// 1 → 3 (2026-09-14): retired `*Source` options — excess property + handler.
// The old 1 is the unrelated `"scoop"` comparison.
'web/routes/temas/tema/+page.svelte': 3,
// 0 → 2 (2026-09-14): retired `*Source` options — excess property + handler.
'web/routes/uix/+layout@.svelte': 2,
'web/routes/uix/components/aura/+page.svelte': 1,
'web/routes/uix/components/slider/+page.svelte': 1
};

@ -314,14 +314,21 @@ const eidos = ActiveEidos.create({ applyDom: true });
Practical rule:
```text
YES: uix.prefs.setIntent('mode', 'dark')
NO: ActiveEidos.create({ mode: 'dark' }) inside a uix tree — it THROWS
The USER's preference: uix.prefs.setIntent('mode', 'dark')
A NAILED instance: ActiveEidos.create({ mode: 'dark' })
```
`ActiveEidos` takes `theme` / `mode` / `density` / `scaling` / `modeSource` /
`densitySource` / `scalingSource` only when it has no `uix` (standalone CSS
serialization, SSR, tests). Passing one WITH a `uix` raises
`ActiveEidosConfigError` rather than picking a winner in silence.
They are different questions. `setIntent` moves the preference for the whole
app and persists it. A scalar on `ActiveEidos` is a **pin**: that axis is
nailed on that instance and wins over prefs — a preview panel, a hero that
stays dark whatever the reader prefers. Everything not pinned keeps following
prefs, and the pinned axis stays subscribed, so a preference change still
re-applies and still finds it unmoved.
Per-axis source options (`modeSource` / `densitySource` / `scalingSource`) no
longer exist: they were the second engine's API. Replacing the resolution
whole is `preferences`, and the pre-hydration boot takes the same pins
(`renderUixBootScript({ pins })`) so both readers agree before hydration.
## Storage

@ -24,8 +24,8 @@
* `effect` names a REGISTERED effect — import its module first
* (`import '$packs/ambient/effects/mesh'`); tree-shaking is by import.
* Color params accept eidos token names (P-4) resolved via
* `eidos.resolveToken`; live re-tint follows the eidos mode when the
* shell's `modeSource` is reactive (see the pack README).
* `eidos.resolveToken`; live re-tint follows the eidos effective mode,
* which comes from `uix.prefs` (see the pack README).
*/
import { untrack, type Snippet } from 'svelte';
import { ActiveEidos } from '$uix/eidos';

@ -22,6 +22,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createActiveUix } from '../active-uix.svelte';
import { createWebStoragePrefsIntentStorage } from '../prefs-storage';
import { createActiveEidos } from '$uix/eidos';
import type { VisualPreferencePins } from '$uix/eidos/lib/visual-preference';
import {
createPrefsIntentDocument,
PREFS_INTENT_DOCUMENT_KIND,
@ -73,7 +74,11 @@ function fakeMatchMedia(matches: Record<string, boolean>) {
* Run the generated IIFE the way `renderUixBootScript` does: inside a
* function so its `var __uixBoot` stays function-scoped.
*/
function runGeneratedBoot(params: { defaultLocale: string; themeIds: readonly string[] }): void {
function runGeneratedBoot(params: {
defaultLocale: string;
themeIds: readonly string[];
pins?: VisualPreferencePins;
}): void {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const run = new Function('p', `${BOOT_SOURCE}\nreturn __uixBoot.boot(p)`) as (p: unknown) => void;
run(params);
@ -114,14 +119,17 @@ afterEach(() => {
});
/** Boot the real runtime over the same storage + environment the script saw. */
function bootRuntime(storage: ReturnType<typeof createMemoryStorage>) {
function bootRuntime(
storage: ReturnType<typeof createMemoryStorage>,
pins: VisualPreferencePins = {}
) {
const uix = createActiveUix({
langs: { schema: {}, defaultLocale: DEFAULT_LOCALE },
prefs: { storage: createWebStoragePrefsIntentStorage(storage) },
projectPrefs: true,
events: false
});
const eidos = createActiveEidos({ uix, applyDom: true });
const eidos = createActiveEidos({ uix, applyDom: true, ...pins });
return {
uix,
eidos,
@ -188,6 +196,55 @@ describe('pre-hydration boot ↔ runtime delta', () => {
}
});
it('agrees on a NAILED instance: the pin beats the stored preference', () => {
const storage = createMemoryStorage({
[PREFS_STORAGE_KEY]: JSON.stringify(createPrefsIntentDocument({ mode: 'light' }))
});
installGlobal('localStorage', storage);
installGlobal('matchMedia', fakeMatchMedia({}));
runGeneratedBoot({
defaultLocale: DEFAULT_LOCALE,
themeIds: ['base-light', 'base-dark'],
pins: { mode: 'dark' }
});
const afterBoot = snapshotHtmlAttrs();
// The stored preference says light. What painted is the pin.
expect(afterBoot).toMatchObject({ 'data-mode': 'dark', 'data-theme': 'base-dark' });
// The instance is nailed the same way the script was told. It stays
// SUBSCRIBED to prefs — `apply()` runs and finds the axis unmoved.
const runtime = bootRuntime(storage, { mode: 'dark' });
try {
expect(snapshotHtmlAttrs()).toEqual(afterBoot);
} finally {
runtime.dispose();
}
});
it('agrees on a nailed FAMILY: the pin goes through resolveThemeId', () => {
const storage = createMemoryStorage({
[PREFS_STORAGE_KEY]: JSON.stringify(createPrefsIntentDocument({ mode: 'dark' }))
});
installGlobal('localStorage', storage);
installGlobal('matchMedia', fakeMatchMedia({}));
runGeneratedBoot({ defaultLocale: DEFAULT_LOCALE, themeIds: [], pins: { theme: 'acme' } });
const afterBoot = snapshotHtmlAttrs();
// The family is pinned, the mode is NOT: the composed id proves both
// tiers ran on the same side of the hydration line.
expect(afterBoot).toMatchObject({ 'data-theme': 'acme-dark', 'data-mode': 'dark' });
const runtime = bootRuntime(storage, { theme: 'acme' });
try {
expect(snapshotHtmlAttrs()).toEqual(afterBoot);
} finally {
runtime.dispose();
}
});
it('agrees on an unreadable envelope: both sides ignore it', () => {
const storage = createMemoryStorage({
[PREFS_STORAGE_KEY]: JSON.stringify({

@ -42,6 +42,10 @@ import {
EIDOS_THEME_ATTR
} from '$uix/eidos/lib/attrs';
import { resolveThemeId } from '$uix/eidos/lib/theme-id';
import {
resolveVisualPreference,
type VisualPreferencePins
} from '$uix/eidos/lib/visual-preference';
import type { ScalingKey } from '$uix/eidos/lib/config-types';
import { createDefaultUixPrefsSchema } from '../prefs-schema';
@ -61,6 +65,14 @@ export interface UixBootParams {
readonly themeIds: readonly string[];
/** Storage key, when the app persists intent somewhere other than the canonical one. */
readonly storageKey?: string;
/**
* Axes the app NAILED on its root `ActiveEidos`
* (`ActiveEidos.create({ mode: 'dark' })`). A pin wins over the
* resolved preference on both sides, so a site that nails one and
* does not say so here paints the preference before hydration and the
* pin after it — the flash this module exists to remove, inverted.
*/
readonly pins?: VisualPreferencePins;
}
/**
@ -95,6 +107,14 @@ export function resolveBootAttrs(
scaling: ScalingKey;
};
// The pins run over the resolved values with the function the runtime
// uses, so a nailed axis reaches `<html>` before paint exactly as the
// instance will re-state it after hydration. A pinned family still
// goes through `resolveThemeId`: `acme` + dark is `acme-dark` here too.
const pins = params.pins ?? {};
const theme = resolveVisualPreference(pins.theme, effective.theme);
const mode = resolveVisualPreference(pins.mode, effective.mode);
const themeIds = params.themeIds;
return {
[PREFS_DOM_ATTRS.DIR]: effective.direction,
@ -102,12 +122,10 @@ export function resolveBootAttrs(
[PREFS_DOM_ATTRS.MOTION]: effective.motion,
[PREFS_DOM_ATTRS.SOUND]: effective.sound,
[PREFS_DOM_ATTRS.HAPTIC]: effective.haptic,
[EIDOS_THEME_ATTR]: resolveThemeId(effective.theme, effective.mode, (id) =>
themeIds.includes(id)
),
[EIDOS_MODE_ATTR]: effective.mode,
[EIDOS_DENSITY_ATTR]: effective.density,
[EIDOS_SCALING_ATTR]: effective.scaling
[EIDOS_THEME_ATTR]: resolveThemeId(theme, mode, (id) => themeIds.includes(id)),
[EIDOS_MODE_ATTR]: mode,
[EIDOS_DENSITY_ATTR]: resolveVisualPreference(pins.density, effective.density),
[EIDOS_SCALING_ATTR]: resolveVisualPreference(pins.scaling, effective.scaling)
};
}

@ -13,9 +13,12 @@ import type { UixBootParams } from './boot.ts';
* **Kit-agnostic on purpose.** The framework hands back a string; the
* site decides where it goes. The recipe (`docs/theming/guide.md`) is a
* `%uix.boot%` placeholder in `app.html` plus a `transformPageChunk` in
* `hooks.server.ts` — three lines the app owns, because the two
* parameters below are the app's facts: which locale it composed UIX
* with, and which themes its eidos config registers.
* `hooks.server.ts` — three lines the app owns, because the parameters
* below are the app's facts: which locale it composed UIX with, which
* themes its eidos config registers, and which axes it NAILED on its
* root eidos (`pins`). The third is the one a site forgets: pin `mode`
* on the instance without telling the boot and the page paints the
* user's preference, then swaps to the pin at hydration.
*/
export interface RenderUixBootScriptParams extends UixBootParams {
/** CSP nonce. Under Kit's CSP this is `event.locals.nonce` / the `%sveltekit.nonce%` value. */

File diff suppressed because one or more lines are too long

@ -1267,14 +1267,6 @@ describe('UIX layer contracts', () => {
expect(violations).toEqual([]);
});
it('guards UIX docs shell from writing visual prefs through ActivePrefs', () => {
const violations = grepSources(
join(REPO_ROOT, 'web', 'routes', 'uix'),
/prefs\.theme|setIntent\(['"]theme['"]/
);
expect(violations).toEqual([]);
});
it('Morfo declares text slots as absolute idlangrefs (texts:) — catalog lives outside the morfo', () => {
_resetMorfoRegistryForTesting();

@ -12,8 +12,8 @@ import { createActiveEidos } from './active-eidos.svelte';
/**
* Booting eidos where there is no `window` must DEGRADE, never throw.
*
* The system colour-scheme source is the default `modeSource` (no `mode`, no
* `modeSource`, no `preferences` — the shape `ActiveEidos.create({ applyDom:
* The system colour-scheme source is the standalone door's `mode` (nothing
* pinned, no `preferences` — the shape `ActiveEidos.create({ applyDom:
* false })` produces, since `create` always injects `uix.dom`). It used to
* read `globalThis.matchMedia` and degrade to the fallback mode when absent.
* Routing it through the injected dom (E2) made it call `dom.getWindow()`,

@ -41,6 +41,7 @@ import {
EIDOS_THEME_ATTR
} from './lib/attrs';
import { resolveThemeId } from './lib/theme-id';
import { resolveVisualPreference, type VisualPreferencePins } from './lib/visual-preference';
import { createPrefsPreferenceSource } from './prefs-source';
import {
renderContractCss as renderEidosContractCss,
@ -126,7 +127,8 @@ export interface ActiveEidosPreferenceSource {
onPreferenceChange(handler: () => void): () => void;
}
export interface ActiveEidosValueSource<T> {
/** Internal shape of `createSystemColorSchemeSource` — the one live axis a standalone eidos has. */
interface ActiveEidosValueSource<T> {
get(): T;
onChange(handler: (value: T) => void): () => void;
}
@ -296,7 +298,16 @@ export interface ApplyThemeResult {
readonly gradient?: BuildGradientResult;
}
export interface ActiveEidosOptions {
/**
* `theme` / `mode` / `density` / `scaling` arrive through
* {@link VisualPreferencePins} and are PINS, not defaults: an axis given
* here is NAILED on this instance and wins over every source, prefs
* included — a preview panel, a hero that stays dark whatever the reader
* prefers. Moving the USER's preference is `uix.prefs.setIntent(...)`;
* substituting the whole resolution is `preferences`. See
* `resolvePreferences`.
*/
export interface ActiveEidosOptions extends VisualPreferencePins {
readonly config?: EidosConfig | EidosConfigDocument;
readonly themeBase?: EidosConfigPatch;
readonly uix?: ActiveUix;
@ -304,13 +315,6 @@ export interface ActiveEidosOptions {
readonly langs?: ActiveLangs;
readonly format?: ActiveFormat;
readonly preferences?: ActiveEidosPreferenceSource;
readonly theme?: string;
readonly mode?: ThemeEffective;
readonly density?: Density;
readonly scaling?: ScalingKey;
readonly modeSource?: ActiveEidosValueSource<ThemeEffective>;
readonly densitySource?: ActiveEidosValueSource<Density>;
readonly scalingSource?: ActiveEidosValueSource<ScalingKey>;
readonly dom?: ActiveDom;
readonly applyDom?: boolean;
readonly styleId?: string;
@ -333,21 +337,6 @@ const DEFAULT_MODE: ThemeEffective = 'light';
const DEFAULT_DENSITY: Density = 'comfortable';
const _ctx = new Context<ActiveEidos>('ActiveEidos');
/**
* Options that describe a preference eidos now READS from `uix.prefs`.
* Passing one together with a `uix` is a configuration error, not a
* precedence question — see `resolvePreferences`.
*/
const PREFERENCE_OPTION_KEYS = [
'theme',
'mode',
'density',
'scaling',
'modeSource',
'densitySource',
'scalingSource'
] as const;
export class ActiveEidos {
readonly #config: EidosConfig;
readonly #uix: ActiveUix | undefined;
@ -1369,36 +1358,47 @@ function resolveEidosConfig(
}
/**
* Where the four visual preferences come from. Three doors, and only
* three, checked in this order:
* Where the four visual preferences come from: ONE door answers, and
* the PINS are applied over whatever it answered.
*
* The door, in this order:
*
* 1. an explicit `preferences` source — the app owns the question;
* 2. `uix.prefs` — the ONE engine. Since 2026-09-14 mode / theme /
* density / scaling are prefs dimensions like motion or sound, so
* a uix-hosted eidos reads them there instead of running a second,
* parallel resolution;
* 3. the per-option sources — standalone eidos (no uix at all):
* CSS serialization, SSR renders, tests.
* 3. standalone eidos (no uix, no prefs, no `preferences`): the OS
* answers `mode` LIVE through `prefers-color-scheme` and the other
* three take eidos' defaults. Without a first engine there is no
* second one to hand the question to — CSS serialization, SSR
* renders and unit tests boot exactly here.
*
* The pins are the same rule at every door: an explicit `theme` / `mode`
* / `density` / `scaling` NAILS that axis on this instance and wins over
* the source, prefs included. It is not a second resolution — the
* precedent is `options.dom ?? options.uix?.dom` one screen up: what the
* caller wrote wins, and nothing throws. A nailed axis stays SUBSCRIBED
* to its source, so a preference change still runs `apply()` and still
* finds that axis unmoved.
*
* Door 2 and door 3 are mutually exclusive BY THROW rather than by
* precedence. A `uix` plus a `mode` is not a caller asking for an
* override: it is a caller who believes the scalar still drives the
* page while prefs quietly does. Two silent paths to one attribute is
* precisely the shape this batch exists to remove — the precedent is
* `resolveEidosConfig`, which refuses `config` + `themeBase` instead of
* picking one.
* Substituting the resolution WHOLE is `preferences`, the one door that
* replaces the engine rather than nailing an axis of it.
*/
function resolvePreferences(options: ActiveEidosOptions): ActiveEidosPreferenceSource {
return createPinnedPreferenceSource(resolvePreferenceSource(options), {
theme: options.theme,
mode: options.mode,
density: options.density,
scaling: options.scaling
});
}
function resolvePreferenceSource(options: ActiveEidosOptions): ActiveEidosPreferenceSource {
if (options.preferences) return options.preferences;
const prefs = options.prefs ?? options.uix?.prefs;
if (prefs) {
const conflicting = PREFERENCE_OPTION_KEYS.filter((key) => options[key] !== undefined);
if (conflicting.length > 0) {
throw new ActiveEidosConfigError(
`preferences live in uix.prefs — set the intent there, not through ${conflicting.join(', ')}`
);
}
return createPrefsPreferenceSource(prefs, {
theme: DEFAULT_THEME,
mode: DEFAULT_MODE,
@ -1407,48 +1407,35 @@ function resolvePreferences(options: ActiveEidosOptions): ActiveEidosPreferenceS
});
}
return createComposedPreferenceSource({
theme: options.theme,
modeSource:
options.modeSource ??
(options.mode
? createStaticValueSource(options.mode)
: createSystemColorSchemeSource(options.dom ?? options.uix?.dom)),
densitySource:
options.densitySource ?? createStaticValueSource(options.density ?? DEFAULT_DENSITY),
scalingSource:
options.scalingSource ?? createStaticValueSource(options.scaling ?? DEFAULT_SCALING)
});
return createStandalonePreferenceSource(options.dom ?? options.uix?.dom);
}
function createComposedPreferenceSource(options: {
readonly theme?: string;
readonly modeSource: ActiveEidosValueSource<ThemeEffective>;
readonly densitySource: ActiveEidosValueSource<Density>;
readonly scalingSource: ActiveEidosValueSource<ScalingKey>;
}): ActiveEidosPreferenceSource {
function createPinnedPreferenceSource(
source: ActiveEidosPreferenceSource,
pins: VisualPreferencePins
): ActiveEidosPreferenceSource {
return {
getTheme: () => options.theme ?? DEFAULT_THEME,
getMode: () => options.modeSource.get(),
getDensity: () => options.densitySource.get(),
getScaling: () => options.scalingSource.get(),
onPreferenceChange(handler) {
const detachers = [
options.modeSource.onChange(handler),
options.densitySource.onChange(handler),
options.scalingSource.onChange(handler)
];
return () => {
for (const detach of detachers) detach();
};
}
getTheme: () => resolveVisualPreference(pins.theme, source.getTheme()),
getMode: () => resolveVisualPreference(pins.mode, source.getMode()),
getDensity: () => resolveVisualPreference(pins.density, source.getDensity()),
getScaling: () => resolveVisualPreference(pins.scaling, source.getScaling()),
onPreferenceChange: (handler) => source.onPreferenceChange(handler)
};
}
function createStaticValueSource<T>(value: T): ActiveEidosValueSource<T> {
/**
* The door of last resort: `mode` follows the OS live, the other three
* axes stand on eidos' defaults. Nothing here is static by choice — the
* system scheme is the only preference a bare eidos can actually read.
*/
function createStandalonePreferenceSource(dom?: ActiveDom): ActiveEidosPreferenceSource {
const mode = createSystemColorSchemeSource(dom);
return {
get: () => value,
onChange: () => () => {}
getTheme: () => DEFAULT_THEME,
getMode: () => mode.get(),
getDensity: () => DEFAULT_DENSITY,
getScaling: () => DEFAULT_SCALING,
onPreferenceChange: (handler) => mode.onChange(handler)
};
}
@ -1486,9 +1473,9 @@ function getColorSchemeMedia(dom?: ActiveDom): MediaQueryList | undefined {
// there is no window at all: `ActiveEidos.create` always injects
// `uix.dom`, so under SSR every boot took this branch and crashed where
// the old global-`matchMedia` read simply degraded. This source is the
// DEFAULT `modeSource` — a `mode`-less boot is the common shape, not an
// exotic one. Absence of a window is not an error here: it is the
// server, and the fallback mode is the right answer.
// standalone door's `mode` — a boot with nothing pinned is the common
// shape, not an exotic one. Absence of a window is not an error here:
// it is the server, and the fallback mode is the right answer.
try {
const win = dom.getWindow();
return typeof win.matchMedia === 'function'

@ -634,8 +634,8 @@ describe('ActiveEidos system mode source goes through ActiveDom (E2)', () => {
const dom = createActiveDom({ targetWindow: stubWin });
const listenSpy = vi.spyOn(dom, 'listen');
// No `mode`, no `modeSource`, no `preferences` — the boot falls to the
// system color-scheme source, which must consult the injected dom.
// Nothing pinned, no `preferences` — the boot falls to the standalone
// door's system color-scheme source, which must consult the injected dom.
createActiveEidos({ dom, styleHost: document.head, styleId: 'e2-probe' });
const scheme = mqls.get('(prefers-color-scheme: dark)');

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { resolveVisualPreference } from './visual-preference.ts';
describe('resolveVisualPreference', () => {
it('lets a pinned axis win over what the source resolved', () => {
expect(resolveVisualPreference('dark', 'light')).toBe('dark');
});
it('keeps the source value when the axis is not pinned', () => {
expect(resolveVisualPreference(undefined, 'light')).toBe('light');
});
it('pins on DEFINEDNESS, not on truthiness', () => {
// `||` would hand the source back here. A theme family is a plain
// string and an empty one is still a caller's explicit answer.
expect(resolveVisualPreference('', 'base')).toBe('');
});
});

@ -0,0 +1,36 @@
import type { Density } from '$libs/density';
import type { ThemeEffective } from '$libs/theme';
import type { ScalingKey } from './config-types';
/**
* The four visual axes an instance may PIN. A pinned axis is a nailed
* instance — a preview panel, a hero that is dark whatever the user
* prefers — not a default and not a user preference.
*
* One declaration for two readers: `ActiveEidosOptions` spreads it as
* its four scalars and `UixBootParams.pins` takes it whole, so the
* pre-hydration script and the runtime cannot disagree about which axes
* can be nailed.
*/
export interface VisualPreferencePins {
readonly theme?: string;
readonly mode?: ThemeEffective;
readonly density?: Density;
readonly scaling?: ScalingKey;
}
/**
* The ONE precedence rule for a visual axis: a pin wins over whatever
* the source resolved. End to end that reads `pin ?? source ?? fallback`
* — the last step belongs to each source (the prefs adapter degrades to
* eidos' defaults when a schema omits a dimension; the boot resolves
* against the default schema, which never does), so what has to stay
* identical between the two readers, and lives here, is this one.
*
* Pure and Svelte-free on purpose: `boot.ts` is compiled into the
* pre-hydration bundle and may not reach a `.svelte.ts` module. Same
* reason `resolveThemeId` lives next door.
*/
export function resolveVisualPreference<T>(pin: T | undefined, value: T): T {
return pin ?? value;
}

@ -8,7 +8,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createActiveUix, type ActiveUix } from '$active-uix';
import { ActiveEidosConfigError, createActiveEidos } from './active-eidos.svelte';
import { createActiveEidos } from './active-eidos.svelte';
import { createPrefsPreferenceSource } from './prefs-source';
const THEME_ATTRS = ['data-theme', 'data-mode', 'data-density', 'data-scaling'] as const;
@ -81,22 +81,24 @@ describe('eidos over uix.prefs', () => {
void eidos;
});
it('refuses uix + a scalar instead of silently ignoring one of them', () => {
it('lets a pinned scalar win over prefs, and only that axis', () => {
const uix = bootUix();
uix.prefs.setIntent('density', 'compact');
expect(() => createActiveEidos({ uix, applyDom: false, mode: 'dark' })).toThrow(
ActiveEidosConfigError
);
expect(() => createActiveEidos({ uix, applyDom: false, theme: 'acme' })).toThrow(
/preferences live in uix\.prefs/
);
expect(() =>
createActiveEidos({
uix,
applyDom: false,
modeSource: { get: () => 'dark' as const, onChange: () => () => {} }
})
).toThrow(/modeSource/);
// prefs says `light` (nothing stored, no dark OS hint); the instance
// is NAILED to dark. A nailed axis is not a conflict to refuse — it is
// a preview panel, a hero that stays dark whatever the reader prefers.
const eidos = track(createActiveEidos({ uix, applyDom: true, mode: 'dark' }));
expect(htmlAttrs()).toMatchObject({ 'data-mode': 'dark', 'data-theme': 'base-dark' });
expect(eidos.getThemeContext().density).toBe('compact');
// The user moves both axes: the nailed one does not follow, the other does.
uix.prefs.setIntent('mode', 'light');
uix.prefs.setIntent('density', 'spacious');
expect(htmlAttrs()).toMatchObject({ 'data-mode': 'dark', 'data-theme': 'base-dark' });
expect(eidos.getThemeContext().density).toBe('spacious');
});
it('lets an explicit preferences source win over prefs', () => {

Loading…
Cancel
Save

Powered by TurnKey Linux.