feat(active-uix)!: el boot lo compila el build del SITIO con su propio esquema, y lo que UIX escribe en <html dir> lleva marca de propiedad (fila 2 del boot, F1 del cierre)

F1 del plan de cierre del framework. Cierra el último trabajo del eje boot.

EL DEFECTO. El boot por defecto compilaba un catálogo de preferencias de UN
solo idioma (createDefaultUixPrefsSchema), y la raíz sembraba su entorno
leyendo el <html dir> que el boot acababa de escribir: un usuario árabe de
una app multiidioma no veía un parpadeo, se quedaba en LTR TODA la sesión,
porque el runtime tomaba la salida del boot como si la hubiera declarado la
página.

EL COMPILADOR POR SITIO. Una sola fusión, composeUixPrefsSchema, la consumen
createActiveUix y el boot: no puede derivar. El generador acepta --schema y
--out (el especificador boot/boot-schema.ts apunta al esquema del sitio o a
boot/default-schema.ts); los guards (sin runas, techo de tamaño, ASCII, sin
</script, valores string por atributo) son errores del COMPILADOR con mensajes
para el consumidor, porque ahora el artefacto lo produce el sitio. Un esquema
que importa el barrel $prefs se rechaza nombrándolo. scripts/uix-boot-check.ts
es el guard de rancidez: plugin de Vite que tumba el build con un artefacto
viejo (probado con vite build real) y función para CI. Delta cero por esquema:
por defecto, multiidioma con árabe, ejes redefinidos y esquema parcial.

LA MARCA DE PROPIEDAD, diseño firmado por el autor. El canon de dirección dice
que <html dir> es una proyección y nunca una fuente, con una sola excepción: el
dir del AUTOR en la plantilla o el servidor. El boot rompió la premisa de esa
excepción. Todo lo que UIX escribe en <html dir> lleva ahora
PREFS_DIR_PROJECTED_ATTR (el boot con valor «boot», cada proyección del runtime
con un token de instancia); la semilla solo adopta un dir SIN marca. Un dir
escrito por script no es una fuente: en ejecución la dirección se afirma con
prefs.setIntent('direction') o options.prefs.environment, que ganan a la
semilla. Se descartaron, midiendo, la marca con valor (cierra el script y
congela la dirección al navegar entre layouts) y la inferencia por valor.
dispose solo retira lo que todavía es SUYO: SvelteKit crea la raíz del layout
nuevo ANTES de destruir la vieja (medido), y un retiro a ciegas dejaba el
<html> de la raíz nueva sin dir, lang ni data-motion/sound/haptic (medido hoy:
los nueve atributos a null). Mismo principio que el unstamp de sema, que
comprueba data-event-id.

esbuild se declara como devDependency EXACTA 0.27.4: los bytes del boot y su
hash dependen del minificador, y una subida dentro de un rango rompería la
sincronía. Boot 15 198 B, sha256-hsqdGYcrRu3oEc0Q3G/A67ApQT3q9c/vT9zMDgxROg8=
(el hash se mueve: firmado por el autor).

VERIFICACIÓN. Constructor Opus en cuatro rondas y adversarial Opus en dos
pasadas independientes, con sus reproducciones repetidas tras cada cierre:
en Chromium, entrar en árabe y pasar a inglés y a español sigue al idioma, y al
revés también; el dir de plantilla gana antes y después de hidratar y en una
raíz recreada; una raíz recreada sobre una viva sigue al idioma con y sin boot;
tras create b → destroy a, <html> conserva lo que proyectó b. Diez defectos
declarados por el adversarial, cerrados (D1–D10): marca de propiedad, esquema
parcial que estampaba "undefined", vigilante de dev mudo, plugin sin test,
receta de CI que no cargaba en jsdom, cifras y prosa, y un vite build real que
se cae cuando el boot no compila. Mutaciones en rojo, restauradas byte a byte:
la proyección no marca · dispose sin guard de dueño (también repetida por el
coordinador: 3 rojos) · la semilla compara valor en vez de presencia · marcar
el dir del autor. Suite entera 463 ficheros / 5 437 tests, exit 0 · check 0 errores en src/ y en scripts/ (89 en web/, ledger intacto) ·
docs:check 0/0 · generate:boot dos veces byte-idéntico.

LO QUE NO CIERRA (al ledger de cierre): (1) PREEXISTENTE — en la misma
navegación entre layouts, el ActiveEidos.dispose de la raíz vieja sigue
retirando data-theme/mode/density/scaling de la raíz nueva; exige cambiar el
dispose de eidos, otra capa. (2) La propiedad de los atributos proyectados
cuelga de la marca de dir: si la plantilla declara la dirección, dispose deja
lang y data-motion puestos cuando la raíz se desmonta sin sustituta
(benigno). (3) Sin boot, un script que escribe dir antes de la primera raíz es
indistinguible de la plantilla y se adopta. (4) El vigilante de dev no
reacciona a ficheros nuevos ni a inputs fuera del root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 3 weeks ago
parent 086422e637
commit 29e581c721

@ -380,10 +380,22 @@ screen-reader announcement.
components stamp only ASSERTED directions, so the page must reflect the
ambient preference or nothing does. `projectPrefs: false` opts out when the
app owns `<html>` (i18n by routing, its own projection); in `attachActiveUix`
it is opt-IN for the same reason. And the boot SEEDS the environment from a
hand-set `<html dir>` (`readPrefsEnvironmentFromDom`) so the projection adopts
the page's own assertion instead of rewriting it with the language-derived
value — precedence `intent > environment seed > derive(language) > default`.
it is opt-IN for the same reason. And the root SEEDS the environment from the
AUTHOR's `<html dir>` — the document template's or the server's
(`readPrefsEnvironmentFromDom`) — so the projection adopts that assertion
instead of rewriting it with the language-derived value — precedence
`intent > environment seed > derive(language) > default`.
**That is the one `dir` read back from the DOM** (§1: the attribute is a
projection, never a source). Everything UIX writes to `<html dir>` — the
pre-hydration boot and every runtime projection — carries the ownership mark
`data-dir-projected` (`PREFS_DIR_PROJECTED_ATTR`), and the seed skips a marked
`dir` by presence; the author's `dir`, adopted, is never marked. A script that
writes over a marked `dir` is not a source either: at run time a direction is
asserted with `prefs.setIntent('direction', …)` or `options.prefs.environment`,
both of which beat the seed. The mark's value names its owner, and a
projection's `dispose` retires its attributes only while the mark still names
it — the next root is created before the old one is destroyed.
That projection is the reason the common case needs no per-component
assertion at all: the page declares its direction once at the root, every

@ -3224,8 +3224,190 @@ ejes → **1**.
---
**Última revisión**: 2026-09-16 (§64 un cuerpo constante, los parámetros en un atributo, un
hash constante: el boot se entrega sin leer el disco y la receta de CSP pasa a ser por hash).
Anterior: 2026-09-16 (§63 el nonce del boot se valida contra la gramática de CSP y los tests
ejecutan el tag real). Si algo en este doc no coincide con el código, el código gana — pero abre
un issue para que actualicemos el doc.
## 65. El boot lo compila el BUILD DEL SITIO, con su propio esquema (2026-09-16)
**El defecto, y era peor que un parpadeo.** `boot.ts` resolvía con
`createDefaultUixPrefsSchema(defaultLocale)` — un catálogo de UN SOLO idioma — mientras
`createActiveUix` fusionaba el esquema DEL APP sobre los cuatro ejes visuales. Dos escrituras de la
misma composición, y no decían lo mismo. Medido en un sitio de tres idiomas (`es`/`ar`/`en`) con un
navegador que pide árabe:
| lectura | `dir` | `lang` |
| --- | --- | --- |
| el boot | `ltr` | `es` |
| el runtime, sobre ese documento | `ltr` | `ar` |
| el runtime, sobre documento LIMPIO | **`rtl`** | `ar` |
La fila del medio era el daño. El runtime **no podía** corregir la dirección: sembraba su entorno
con `readPrefsEnvironmentFromDom()`, que leía el `<html dir>` que el boot acababa de escribir como
si lo hubiera declarado la página, y `directionDimension.derive` devuelve `env.direction` antes de
derivar del idioma (`src/arts/prefs/dimensions/direction.ts:42`). El usuario árabe navegaba en LTR
TODA LA SESIÓN: no un parpadeo que el boot no evitó, sino una respuesta equivocada que el boot
INTRODUCÍA. Y por la misma puerta, con el boot ya correcto, un cambio de idioma en sesión dejaba la
dirección clavada (`ar → en` quedaba `lang=en dir=rtl`, medido en Chromium). La fila cierra la
carga (§1-§5) y el cambio de idioma (§8).
### 1. Una extracción, una fusión, dos consumidores
`composeUixPrefsSchema(appSchema, defaultLocale)` nace en `prefs-schema.ts` y hace exactamente lo
que `active-uix.svelte.ts` tenía escrito en línea: `{ ...uixVisualPrefsDimensions(), ...(appSchema
?? createDefaultUixPrefsSchema(defaultLocale)) }` — el esquema del app ENCIMA, que es lo que permite
redefinir un eje sin poder omitirlo. La consumen los DOS lados, la raíz y `resolveBootAttrs`. Una
fusión no puede divergir de sí misma.
### 2. El especificador sustituible
`boot.ts` importa `bootPrefsSchema` de `./boot-schema.ts` — fijo, relativo, nada dinámico: la
pureza del boot (sin Svelte, sin `$app/*`, sin runas, un cuerpo cuyos bytes son un hash de CSP) es
load-bearing. La sustitución ocurre al COMPILAR: sin banderas el seam responde con
`./default-schema.ts` (el de hoy), con `--schema <módulo>` el compilador resuelve el del sitio.
El redirect NO va por el `alias` de esbuild: **medido**, esbuild rechaza una clave relativa
(`Invalid alias name: "./boot-schema.ts"`). Va por un `onResolve` sobre el especificador exacto. La
alternativa —darle al seam un especificador con forma de alias— habría metido una entrada de
detalle del boot en la tabla que `vite.config.ts` y `svelte.config.js` llevan las dos.
### 3. La CLI, y los guards como errores DEL COMPILADOR
`--schema <módulo>` y `--out <ruta>`. Sin banderas el artefacto es el de siempre en el sitio de
siempre. Una bandera desconocida es un ERROR con su `usage`, nunca un encogimiento de hombros: un
compilador que ignora `--schema` le entrega al sitio el boot POR DEFECTO mientras el sitio cree
haber compilado el suyo — el mismo fallo mudo que este eje entero existe para quitar.
Los guards (ASCII, `</script`, `<!--`, runas, techo de 32 KB) pasan a hablarle a un CONSUMIDOR. El
caso que de verdad ocurre: un módulo de esquema que importa el barrel `$prefs`. **Medido**: trae
`$state(` al bundle y lo lleva de 15 198 a 56 005 bytes. El mensaje nombra el barrel y los módulos
profundos que hay que usar en su lugar.
**Y el compilador EJECUTA lo que escribe** (`assertBootRuns`): corre el artefacto una vez en un
documento sintético y exige que ESTAMPE. Los guards de texto son estáticos y un boot muerto los
pasa todos — **medido**: un `bootPrefsSchema` que lanza, y otro exportado como objeto en vez de
función, se escribían con exit 0, hash válido y una página que no estampaba nada ni decía nada
(el tag se traga sus fallos por diseño). Eso es PEOR que el defecto que `--schema` cierra: el boot
equivocado al menos pintaba. Los errores de línea de comandos salen además como MENSAJE —un
compilador que le habla a un consumidor no entrega un volcado de Node con seis marcos de pila— y
un `--schema` que no existe nombra el directorio contra el que se resolvió, que es la trampa de la
propia receta.
**Y exige que lo estampado sean STRINGS.** Contar atributos dejaba pasar un esquema sin las
dimensiones estándar, que estampaba el TEXTO `undefined` en `dir`, `lang`, `data-motion`,
`data-sound` y `data-haptic` — medido en Chromium, para toda la sesión. El boot deja ahora fuera,
como el runtime, un eje de prefs que el esquema no declara (medido: el runtime no escribe nada para
él); el guard rechaza lo que queda —un atributo que se estamparía con algo que no es string, como un
eje visual redefinido sin default— con un mensaje que nombra el atributo y el valor. Lo comprueba en
UNA corrida (`defaultLocale` `'en'`, sin `navigator`, `matchMedia` ni `localStorage`): una
dimensión que da string ahí y nada para otro entorno —un navegador árabe— compila, y ese navegador
recibe el texto `undefined` (medido en `vm`).
### 4. El guard de rancidez rompe el BUILD
`scripts/uix-boot-check.ts`: plugin de Vite en `scripts/` —fuera de `src/uix`, porque importa
esbuild y el tipo de plugin de Vite, que son herramientas del ANFITRIÓN— que recompila en
`buildStart` con la configuración del sitio y **tumba el build** si el fichero en disco difiere. En
dev registra cada módulo que la compilación LEYÓ (con un `onLoad` de esbuild, NO con `metafile`: el
porqué, abajo), no sólo la entrada: la rancidez casi nunca empieza en `entry.ts`, empieza en una
dimensión. El mismo chequeo es una función importable (`checkUixBootArtifact`) para un consumidor
sin Vite. En un problema donde TODO falla callado, el único guard que vale es el que rompe el build.
**Y el que NO tumba el dev server.** La lista de inputs no se pide ya por `metafile`: el cliente de
esbuild hace `JSON.parse` sobre un metafile VACÍO cuando el build falla, y lo hace dentro de un
manejador de socket, así que un error de sintaxis en el esquema del sitio llegaba como excepción no
capturada —ni un `catch` ni un `.catch()` podían pararla— y **mataba el proceso**. Medido. Los
inputs los recoge ahora un `onLoad` (mismo conjunto, 58 y 58, cero diferencia) y un build fallido
vuelve a ser un rechazo ordinario que en dev es un aviso. La doctrina escrita del guard —en dev se
avisa— por fin la cumple el código.
**Ni se queda sordo tras un fallo.** El conjunto contra el que el watcher decide pertenencia se
guardaba sólo tras una compilación CORRECTA: un dev server arrancado con el esquema a medio escribir
se quedaba con un conjunto vacío y no volvía a avisar en toda la sesión, y un módulo importado ya
roto nunca entraba en él. Ahora se guarda lo que la compilación LEYÓ también cuando falla (el
`onLoad` corre para el fichero que luego no parsea). Comprobar CADA guardado mientras está roto se
midió y se descartó: diez avisos duplicados por las escrituras de `.svelte-kit/` de un arranque de
Kit. La mitad Vite del guard —`buildStart`, build/dev, watcher— no tenía ningún test; ahora tiene
cinco, con Vite real, entre ellos el `vite build` que se cae cuando el boot ni siquiera compila.
### 5. La delta se parametriza POR ESQUEMA
Un boot se compila alrededor de un esquema, así que «boot == runtime» es una afirmación sobre un
PAR. Cuatro fixtures: el esquema por defecto (el artefacto que se publica), un sitio multiidioma con
`ar`, un sitio que redefine `density` y `scaling`, y uno que no declara ningún eje de prefs opcional
salvo `motion`. Los tres últimos compilan su artefacto **con la CLI** (proceso hijo: esbuild no
carga dentro del entorno jsdom del fichero, medido) y componen `createActiveUix` con el MISMO módulo
de esquema. Sin el eje, la fixture multiidioma es ROJA.
### 6. Lo que la puerta de render NO comprueba (decisión firmada)
`renderUixBootScript` no verifica que `artifact.hash` describa a `artifact.script`. La razón es
`node:crypto` en `render.ts` —node-only otra vez, una fila después de dejar de serlo— y NO el
coste, que se midió y no sostiene nada: SHA-256 + base64 sobre el cuerpo real son 51,45 µs por
render contra los 60,68 µs que `assertBootScript` ya gasta en esa misma llamada. Un argumento
medido y falso se retira aunque apoye la decisión correcta. El par se garantiza DONDE NACE: el
compilador emite las dos constantes de un mismo texto, el guard de rancidez compara el artefacto
escrito contra un compilado vivo y `render.test.ts` digiere el texto PARSEADO del tag real.
Lo que eso deja fuera es un sitio con DOS artefactos válidos —el del framework y el suyo— que se
lleva el cuerpo de uno y el hash del otro: todos los guards pasan y el navegador bloquea el tag.
No existía antes de esta fila, porque con un solo artefacto no había dos hashes que confundir. Se
cierra en la GUÍA, en el paso 4 y otra vez donde el sitio compila el suyo: un artefacto, nombrado
en los dos sitios.
### 7. El límite declarado se ESTRECHA
No es «un `themeResolver` propio». `ActiveEidosThemeResolver` es `(context, active) => string` y
`ActiveEidos` lo llama siempre con la instancia viva (`#themeResolver(themeContext, this)`,
`active-eidos.svelte.ts:539/1056`). Un resolver que lee sólo `context` es una función pura de datos
planos y PODRÍA viajar al boot como ahora viaja el esquema; uno que toca `active` no, porque ahí no
hay instancia que tocar. El seam que llevaría al primero no está construido. Y el modo **attach**
NO lo cierra esta fila: ahí el app es dueño de su motor de prefs y el boot no puede saber con qué
esquema se construyó.
### 8. `<html dir>` es PROYECCIÓN: la marca de propiedad de UIX (firma del autor)
La doctrina es la del [contrato de dirección](../canon/direction-contract.md), §1 y §6; aquí sólo
lo que hace el código. Todo `dir` que UIX escribe lleva la marca `data-dir-projected`
(`PREFS_DIR_PROJECTED_ATTR`, `src/arts/prefs/dom-attrs.ts`): el boot con el valor `boot`, cada
proyección del runtime con un token propio (`projection-<n>`). `readPrefsEnvironmentFromDom` la
mira por PRESENCIA: un `dir` marcado no es una afirmación. La única que se lee del DOM es el `dir`
del AUTOR —plantilla o servidor—: la raíz lo adopta y nadie lo marca, tampoco la proyección que lo
reescribe con el mismo valor; el boot se siembra como la raíz (navegador +
`readPrefsEnvironmentFromDom()`) para no pisarlo antes de que nadie lo lea. Un `dir` escrito por
script NO es fuente: en ejecución se afirma con `prefs.setIntent('direction', …)` o
`options.prefs.environment`, que ganan a la semilla.
**`dispose` sólo retira lo suyo.** SvelteKit crea el layout nuevo ANTES de destruir el viejo
(medido en Chromium: `create b → destroy a`), así que la raíz nueva ya ha proyectado cuando la vieja
se desecha. La proyección retira sus atributos y la marca sólo si la marca todavía la nombra —el
mismo principio que el `unstamp` de sema con `data-event-id`—. Medido antes del arreglo: tras
`destroy a`, los nueve atributos de `<html>` a `null` con la raíz nueva viva; después, `dir`,
`lang`, `data-motion`, `data-sound`, `data-haptic` y la marca de B intactos. **Queda abierto** el
lado de eidos: `ActiveEidos.dispose` retira `data-theme/mode/density/scaling` cuando su VALOR
coincide, y B escribió los mismos valores; cerrarlo es tocar eidos, fuera de esta firma.
Medido en Chromium con el diseño final: los dos recorridos de idioma (`ar-EG` en → es → ar ⇒ `ltr`
→ `ltr` → `rtl`; `es-ES` ar → en → es ⇒ `rtl` → `ltr` → `ltr`) · una raíz creada sobre otra viva
tras cambiar de idioma sigue al idioma con boot y sin boot · plantilla `dir="rtl"`: `rtl` antes y
después de hidratar y en la raíz recreada, sin marca · un script que pone `dir="rtl"` tras el boot
no gana (`ltr`, `prefs.direction=ltr`). Y un boot compilado con el esquema EQUIVOCADO ya no deja la
página en LTR toda la sesión: la hidratación la corrige y el audit dev nombra `dir`.
**Cifras.** El artefacto por defecto pasa de 14 666 a **15 198 bytes** y el hash se MUEVE a
`sha256-hsqdGYcrRu3oEc0Q3G/A67ApQT3q9c/vT9zMDgxROg8=`: componer los ejes visuales en el lado del
boot, dejar fuera los ejes de prefs que el esquema no declara, leer el `dir` de la página y marcar
el suyo es código real, y el hash es constante por VERSIÓN del framework, no entre versiones — un
sitio que siguió la receta de §64 actualiza el valor al actualizar UIX, como dice el paso 4.
Generación determinista (2 corridas byte-idénticas). Suite del boot **7 ficheros / 78 tests**
(HEAD: 5 / 42); `src/uix/active-uix` **11 ficheros / 129 tests**; `src/arts/prefs` **9 / 54**;
suite entera **463 ficheros / 5 437 tests**, exit 0 (HEAD: 461 ficheros — los dos nuevos son
`boot-check` y `boot-compiler`); `npm run check` 0 errores bajo `src/` y bajo `scripts/` (89 en
`web/`, como en HEAD); `docs:check` 0/0 sobre 819 docs. El guard de rancidez, medido en un
`vite build` REAL: con el artefacto rancio **exit 1**; sin el plugin, exit 0 y el artefacto rancio
se publica.
---
**Última revisión**: 2026-09-16 (§65 el boot lo compila el build del sitio, con su propio esquema:
`--schema`, guard de rancidez que tumba el build, delta parametrizada por esquema y el `dir` del
boot marcado como suyo).
Anterior: 2026-09-16 (§64 un cuerpo constante, los parámetros en un atributo, un hash constante: el
boot se entrega sin leer el disco y la receta de CSP pasa a ser por hash). Si algo en este doc no
coincide con el código, el código gana — pero abre un issue para que actualicemos el doc.

@ -362,6 +362,12 @@ This is the canonical recipe. Kit computes hashes for the scripts IT emits and
fixes the policy before `transformPageChunk` runs, so the boot's own hash is
yours to declare — and it never changes until you upgrade UIX.
**The import must point at the artifact you actually SHIP.** The path above is
the framework's own, which is the right one as long as you ship the framework's
own body. Compile a boot of your own (next section) and this line has to move
with it: a hash that does not describe the body in the tag is a blocked script,
and nothing in the build will tell you.
**The constant carries no quotes, because Kit adds them.** It recognises a
`sha256-…` source and writes `'sha256-…'` into the policy itself. A site that
sends its own `Content-Security-Policy` header instead of using `kit.csp` has
@ -379,6 +385,209 @@ every engine.
Skip this step entirely when the site has no CSP.
### Your schema, your boot — the per-site compiler
Steps 1–4 ship the artifact UIX compiles for itself, and it is built around
UIX's DEFAULT preference schema: **one language**, one currency, the four
visual axes. If your app passes a schema of its own to
`createActiveUix({ prefs: { schema } })`, that boot is resolving somebody
else's preferences — and on a multi-language site the page paints in the wrong
language and the wrong direction.
Measured in Chromium, on a three-language site (`es` / `ar` / `en`) and a
browser asking for Arabic:
| reading | `dir` | `lang` |
| --- | --- | --- |
| first paint, with the boot compiled around the default schema | `ltr` | `es` |
| the same page after hydration, about half a second later | `rtl` | `ar` |
| first paint, with the boot compiled around the SITE's schema | **`rtl`** | **`ar`** |
The first row is what the user sees: an Arabic page laid out left to right
and announced in Spanish, until the framework hydrates and the whole page
flips. The dev audit names it (`the pre-hydration boot disagrees with the
runtime on dir, lang`); nothing in a production build does.
**Every `dir` UIX writes is marked as UIX's.** The runtime reads `<html dir>`
as an assertion only when it is YOURS — written in your page template or by
your server — and then it beats the language, before paint and after it. The
`dir` the boot derives, and every `dir` a root projects, carries
`data-dir-projected`, so a root skips it and keeps deriving: a language the
user picks later still moves the direction, on this root and on the next one a
layout mounts. A script that writes over it is not a source; at run time,
assert a direction with `prefs.setIntent('direction', …)` or
`prefs.environment`. The rule is the
[direction contract's](../canon/direction-contract.md), §6.
**What you write: one pure module, imported twice.**
```ts
// src/prefs-schema.ts
import { standardPrefsDimensions } from '$prefs/standard';
import type { SupportedLocale } from '$libs/langs';
import type { PrefsSchema } from '$libs/prefs';
export function bootPrefsSchema(defaultLocale: SupportedLocale): PrefsSchema {
return standardPrefsDimensions({
languages: [defaultLocale, 'ar', 'en'],
locales: [defaultLocale, 'ar', 'en'],
currencies: ['EUR', 'USD'],
defaults: { language: defaultLocale, locale: defaultLocale, currency: 'EUR' }
});
}
```
The export name is the contract — the compiler resolves `bootPrefsSchema` by
name. The same function is what your root composes:
```ts
createActiveUix({
langs: { schema: strings, defaultLocale: 'es' },
prefs: { schema: bootPrefsSchema('es') }
});
```
One module, two consumers. The merge with the four visual axes happens inside
both of them (`composeUixPrefsSchema`), so do not add the axes yourself: they
travel with the root and your schema goes ON TOP — it can redefine an axis,
never omit one.
**The same locale on both sides.** `defaultLocale` in the tag is the argument
your schema function receives before paint, and the one your root builds its
own schema around. Two different values are two different catalogues, which is
this section's defect with extra steps.
> **The module must be PURE, and it must import DEEP modules.** No runes, no
> `$app/*`, and never the `$prefs` barrel: the barrel re-exports a
> `.svelte.ts`, so it puts `$state(` in a plain `<script>` and the boot dies
> on line one — measured, and it takes the artifact from 15 KB to 56 KB on
> the way. The compiler refuses to write that artifact and says why.
**Compile it** — from YOUR directory, so the paths mean what they look like.
Until the `bin` entry below exists, the compiler is a script inside the
framework and you name it by path; `tsx` is what runs a `.ts` entry, so add it
to your own devDependencies:
```bash
node --import tsx/esm <uix>/scripts/generate-boot.ts \
--schema src/prefs-schema.ts --out src/generated/boot.js
```
**`--schema` and `--out` are resolved against the CURRENT directory**, and that
is the one thing to get right. Run the framework's own `npm run generate:boot`
and the current directory is the FRAMEWORK's, so `src/prefs-schema.ts` means a
file in UIX and `--out src/generated/boot.js` drops your artifact inside UIX's
checkout. From a framework checkout, pass both as absolute paths. A `--schema`
that resolves to nothing is an error that names the directory it tried.
`--out` decides where YOUR artifact lands; with neither flag the command
reproduces the framework's own, byte for byte. An unknown flag is an error,
never a shrug: a compiler that ignored `--schema` would hand you the default
boot while you believed you had compiled your own. And the compiler RUNS what it
wrote before writing it: a schema that throws, or that exports `bootPrefsSchema`
as anything but a function, fails the compile instead of producing an artifact
that stamps nothing in silence — and so does an attribute the boot would stamp
with something other than a string IN THAT RUN, which a browser would write on
`<html>` as the word `undefined`. It is one run: `defaultLocale` `'en'`, and no
`navigator`, `matchMedia` or `localStorage`. A `density` that answers
`comfortable` there and nothing for an Arabic browser compiles, and that browser
gets `data-density="undefined"`. A preference your schema does not declare at
all (no `language`, say), or one of `dir`, `lang`, `data-motion`, `data-sound`
and `data-haptic` that resolves to nothing, is left off `<html>`, as the runtime
leaves it.
Give it a name in your own `package.json`, because you will run it again on
every schema change and on every UIX upgrade:
```json
{
"scripts": {
"boot": "node --import tsx/esm <uix>/scripts/generate-boot.ts --schema src/prefs-schema.ts --out src/generated/boot.js"
}
}
```
Then step 4 imports `UIX_BOOT_CSP_HASH` from **your** file instead of the
framework's — your body ships, so your hash is the one the policy must name.
> **Move that import, or the browser blocks your boot.** The two artifacts are
> both legitimate and nothing downstream compares them: the staleness guard
> below checks that YOUR artifact is fresh, and `renderUixBootScript` never
> reads `artifact.hash` at all. Ship your body under the framework's hash and
> the page is worse than it was before you started — the tag is blocked, the
> boot writes nothing, and hydration does the whole flash. Measured in
> Chromium: `Executing inline script violates … Content-Security-Policy`, then
> `dir=rtl lang=ar` arriving 400 ms late. One import, one artifact, in both
> places.
**The placeholder, the hook and the tag do not change.** The artifact enters
through the door `renderUixBootScript` already has:
```ts
import { UIX_BOOT_SCRIPT, UIX_BOOT_CSP_HASH } from './generated/boot.js';
renderUixBootScript({
defaultLocale: 'es',
themeIds: eidos.listThemes(),
artifact: { script: UIX_BOOT_SCRIPT, hash: UIX_BOOT_CSP_HASH }
});
```
### The staleness guard — not optional
A compiled artifact goes stale the moment you edit your schema, edit a
dimension, or upgrade UIX, and **every symptom of a stale boot is silent**:
the tag is wrapped in a mute `try`/`catch` by design, the values it stamps
are merely old rather than invalid, and nothing in the page says so. The only
guard worth having is one that stops the build.
```js
// vite.config.js
import { uixBootCheck } from '<uix>/scripts/uix-boot-check.ts';
export default {
plugins: [uixBootCheck({ schema: 'src/prefs-schema.ts', out: 'src/generated/boot.js' })]
};
```
It recompiles in `buildStart` with your own configuration and fails the build
on any difference. In dev it WARNS instead — a dev server that refuses to
start over a stale cosmetic artifact helps nobody — and it registers every
module the compile READ, your schema and the dimensions included, so editing
one re-runs the check instead of waiting for a deploy. A schema module you are
halfway through typing is a warning too, and the next save of any module that
compile read runs the check again: the guard never takes the dev server
down with it.
With no Vite, the same check is a promise and a CI test is the whole wiring. The
first line is part of the recipe: the check runs esbuild, and esbuild refuses to
load in a `jsdom` environment (`Invariant violation: "new
TextEncoder().encode("") instanceof Uint8Array" is incorrectly false`), so in a
project whose tests default to `jsdom` the file fails before its first test.
```ts
// @vitest-environment node
import { expect, it } from 'vitest';
import { checkUixBootArtifact } from '<uix>/scripts/uix-boot-check.ts';
it('the compiled boot is not stale', async () => {
const result = await checkUixBootArtifact({
schema: 'src/prefs-schema.ts',
out: 'src/generated/boot.js'
});
expect(result).toMatchObject({ ok: true });
});
```
> **Packaging is not done yet, and this doc will not pretend otherwise.**
> Every `<uix>` above is a path into a checkout of the framework that you
> substitute by hand, the compiler needs `tsx` to run its `.ts` entry, and the
> aliases your schema module writes (`$prefs/standard`, `$libs/prefs`) are
> resolved from the framework's own `vite.config.ts`. The `bin` entry that
> would make it `uix-boot --schema …` from a site's own `package.json`, and a
> published path for the plugin, are an open row. What exists today is the compiler,
> its flags and the guard.
### The nonce — an emergency exit, not the recipe
`renderUixBootScript({ …, nonce })` exists for a site that runs its OWN policy
@ -445,11 +654,25 @@ does not match the adapter, an artifact that lags the schema and a tag the CSP
blocked all produce the same mute flash otherwise. It measures and changes
nothing, and it is silent on a site that never added the tag.
### The declared limit
The boot reproduces the DEFAULT theme resolution (`resolveThemeId`: registered
id → as written; already mode-qualified → as written; otherwise
`${theme}-${mode}`). An app that passes its own `themeResolver` to
`ActiveEidos` has replaced that function, and the compiled boot cannot know
it: `data-theme` will differ until hydration. Such an app owns its own
pre-paint stamp.
### The declared limits
**The theme resolver.** The boot reproduces the DEFAULT theme resolution
(`resolveThemeId`: registered id → as written; already mode-qualified → as
written; otherwise `${theme}-${mode}`). An app that passes its own
`themeResolver` to `ActiveEidos` has replaced that function and the compiled
boot does not know it, so `data-theme` differs until hydration.
The line is narrower than "a custom resolver". `ActiveEidosThemeResolver` is
`(context, active) => string` and `ActiveEidos` always calls it with the live
instance (`#themeResolver(themeContext, this)`). A resolver that reads only
`context` is a pure function of plain data, so it COULD travel into the boot
the way the schema now does; one that touches `active` cannot, because at
that moment there is no instance to touch — no `ActiveEidos`, no prefs
engine, nothing but a `<script>` in `<head>`. The seam that would carry the
first kind is not built: only the schema has one today.
**Attach mode.** `attachActiveUix(app)` is NOT covered. The app owns its prefs
engine there, so the framework merges nothing for it and the boot has no way
to know which schema that engine was built with. An app on attach composes
`uixVisualPrefsDimensions()` into its own schema and owns its pre-paint stamp;
the row that closes attach properly is open.

65
package-lock.json generated

@ -21,6 +21,7 @@
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.5.0",
"@vitest/browser-playwright": "^4.1.0",
"esbuild": "0.27.4",
"jsdom": "^29.0.2",
"playwright": "^1.58.2",
"prettier": "^3.8.1",
@ -1478,6 +1479,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",

@ -50,6 +50,7 @@
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.5.0",
"@vitest/browser-playwright": "^4.1.0",
"esbuild": "0.27.4",
"jsdom": "^29.0.2",
"playwright": "^1.58.2",
"prettier": "^3.8.1",

@ -39,23 +39,56 @@
* the artifact a site hands the `artifact` door, and it could not import
* a module that pulls esbuild in behind it.
*
* esbuild is SCAFFOLDING, not a shipped dependency: it is already in
* `node_modules` (vite's own bundler) and nothing at runtime imports
* it. Same posture as `generate-eidos-css.ts` and `tsx`. `node:crypto`
* is scaffolding for the same reason — it computes the hash here and
* never ships.
* esbuild is SCAFFOLDING, not a shipped dependency: nothing at runtime
* imports it. Same posture as `generate-eidos-css.ts` and `tsx`. It is
* declared in `devDependencies` and pinned EXACT (`0.27.4`, no range) on
* purpose: the body is esbuild's minified output and `UIX_BOOT_CSP_HASH`
* is a digest of those bytes, so another esbuild version can emit other
* bytes from the same source — and an upgrade inside a `^` range would
* put the checked-in artifact out of sync with a fresh compile (the sync
* test and the staleness guard go red) without a line of source moving.
* `node:crypto` is scaffolding too — it computes the hash here and never
* ships.
*
* Aliases are EXTRACTED from `vite.config.ts` rather than copied — the
* same rule `docs-check.ts` invariant 8 follows, for the same reason: a
* copied table drifts and takes the guard's credibility with it.
*
* **And it RUNS what it writes.** The text guards are static, and a boot
* that throws on line one passes every one of them: measured, this
* compiler used to write a dead artifact with exit 0 and a valid hash,
* and the page it produced stamped nothing while reporting nothing
* (`assertBootRuns`). A compiler whose output does not run is not a
* compiler.
*
* **It is a COMPILER, and the site is a caller.** `--schema <module>`
* builds the boot around the site's own preference schema instead of
* UIX's default, and `--out <path>` writes the site's own artifact. Not a
* convenience: a boot compiled around the default schema knows ONE
* language, so on a multi-language site it stamps `dir="ltr"` for an
* Arabic user and the page paints in the wrong direction until hydration
* corrects it (`prefs-schema.ts`, `composeUixPrefsSchema`). Because the
* site is the caller, the guards
* below are COMPILER ERRORS with messages aimed at whoever wrote the
* schema, not assertions about framework code.
*
* **A schema module must import DEEP modules, never the `$prefs`
* barrel.** `$prefs/index.ts` re-exports `createActivePrefs` from a
* `.svelte.ts`, so the barrel drags `$state(` into a plain `<script>` and
* the boot dies on line one — before it stamps a single attribute, and
* silently, because the tag is wrapped in a mute `try`/`catch` by design.
* `src/uix/active-uix/prefs-schema.ts` is the worked example: it imports
* `$prefs/standard` and `$prefs/dimensions/*` one by one for exactly this
* reason. The rune guard is what turns that mistake into a failed build.
*/
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import { build } from 'esbuild';
import { build, type Plugin } from 'esbuild';
import { assertBootScript } from '../src/uix/active-uix/boot/script-guard.ts';
@ -80,13 +113,82 @@ export function readViteAliases(): Record<string, string> {
return aliases;
}
export interface BuildBootScriptOptions {
/**
* Absolute path of the module that exports `bootPrefsSchema` — the
* site's schema. Omitted, `./boot-schema.ts` resolves normally and the
* framework's default answers.
*/
readonly schema?: string;
}
/**
* Redirect the seam — `./boot-schema.ts`, the specifier `boot.ts` imports
* its preference schema through, fixed and relative because nothing in the
* boot may be dynamic. This is the one thing the compiler substitutes.
*
* esbuild's own `alias` option cannot do it: measured, a relative key is
* rejected outright (`Invalid alias name: "./boot-schema.ts"`). The
* alternative — giving the seam an alias-shaped specifier — would put a
* boot-only entry in the alias table that `vite.config.ts` and
* `svelte.config.js` both have to carry. An `onResolve` on the exact
* specifier costs less and says what it does.
*/
function schemaRedirect(schema: string): Plugin {
return {
name: 'uix-boot-schema',
setup(build) {
build.onResolve({ filter: /^\.\/boot-schema\.ts$/ }, () => ({ path: schema }));
}
};
}
/**
* Record every file the bundle READ. `uix-boot-check` watches these in
* dev — the artifact goes stale when any of them moves, not only when the
* entry does.
*
* Why a plugin and not `metafile: true`, which reports the same set:
* esbuild's own client does `if (response.metafile) parseJSON(…)` on the
* way back, and a FAILED build answers with an empty metafile buffer that
* is still truthy — so `JSON.parse('')` throws `SyntaxError: Unexpected
* end of JSON input` from inside a socket data handler, where it is an
* UNCAUGHT EXCEPTION and not a rejection of the build promise. Measured:
* with `metafile`, a schema module with a syntax error kills the process
* and no `try`/`catch` or `.catch()` around the compile can stop it —
* which is a dead dev server for the most ordinary mistake there is.
* Without it, the same build rejects with `Build failed with 1 error`
* and the caller decides. The two input sets were compared file by file:
* 58 and 58, zero difference.
*/
function inputCollector(into: Set<string>): Plugin {
return {
name: 'uix-boot-inputs',
setup(build) {
build.onLoad({ filter: /.*/ }, (args) => {
into.add(resolve(args.path));
return undefined;
});
}
};
}
/**
* Compile the boot entry and return the text that goes inside the tag.
* Used by the sync test, which compares it against the checked-in
* constant. Normalised to LF: the hash is over these bytes, and a
* checkout with `core.autocrlf` must not change it.
* Compile the boot entry: the text that goes inside the tag, plus every
* source file that text was built from.
*
* `inputs` fills as esbuild READS, so a caller that passes its own set
* still holds it when the build REJECTS: a failed compile read files too,
* the one that broke it among them. That is what `uix-boot-check` watches
* until a compile succeeds again.
*
* Normalised to LF: the hash is over these bytes, and a checkout with
* `core.autocrlf` must not change it.
*/
export async function buildBootScript(): Promise<string> {
export async function compileBoot(
options: BuildBootScriptOptions = {},
inputs = new Set<string>()
): Promise<{ script: string; inputs: readonly string[] }> {
const result = await build({
entryPoints: [BOOT_ENTRY],
bundle: true,
@ -96,10 +198,22 @@ export async function buildBootScript(): Promise<string> {
target: 'es2020',
minify: true,
legalComments: 'none',
alias: readViteAliases()
alias: readViteAliases(),
plugins:
options.schema === undefined
? [inputCollector(inputs)]
: [schemaRedirect(options.schema), inputCollector(inputs)]
});
return result.outputFiles[0].text.replace(/\r\n/g, '\n');
return { script: result.outputFiles[0].text.replace(/\r\n/g, '\n'), inputs: [...inputs] };
}
/**
* The text that goes inside the tag. Used by the sync test, which
* compares it against the checked-in constant.
*/
export async function buildBootScript(options: BuildBootScriptOptions = {}): Promise<string> {
return (await compileBoot(options)).script;
}
/** The `script-src` source a site adds to its CSP for this exact text. */
@ -123,17 +237,178 @@ export function renderBootModule(script: string): string {
].join('\n');
}
const USAGE = `usage: generate-boot [--schema <module>] [--out <file>]
--schema <module> module exporting \`bootPrefsSchema(defaultLocale)\` — the
SAME schema the app passes to createActiveUix. Omitted,
the boot is compiled around UIX's default schema, which
knows one language.
--out <file> where to write the artifact (default: the framework's own,
${BOOT_OUTPUT}).`;
/**
* Flags are parsed strictly and an unknown one is an error. A compiler
* that IGNORES a flag it does not know hands a site the default boot
* while the site believes it compiled its own — the same silent wrong
* answer this whole axis exists to remove.
*/
export function parseGenerateBootArgs(argv: readonly string[]): {
schema?: string;
out: string;
} {
let schema: string | undefined;
let out = BOOT_OUTPUT;
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
const value = argv[i + 1];
if (flag !== '--schema' && flag !== '--out') {
throw new Error(`unknown argument \`${flag}\`\n\n${USAGE}`);
}
if (value === undefined || value.startsWith('--')) {
throw new Error(`\`${flag}\` needs a value\n\n${USAGE}`);
}
if (flag === '--schema') schema = resolve(process.cwd(), value);
else out = resolve(process.cwd(), value);
i += 1;
}
return schema === undefined ? { out } : { schema, out };
}
/**
* The guards are the site's compiler errors, so they have to say what a
* SITE can act on. `assertBootScript` states the fact; this states what
* put it there, and the rune case is the one that actually happens — a
* schema module that reached for the `$prefs` barrel.
*/
function guardHint(message: string): string | undefined {
if (message.includes('$state(') || message.includes('$derived(')) {
return 'a Svelte rune reached the boot bundle. A schema module must import DEEP modules (`$prefs/standard`, `$prefs/dimensions/*`), never the `$prefs` barrel: it re-exports a `.svelte.ts` and the boot would die before stamping a single attribute.';
}
if (message.includes('svelte/internal')) {
return 'the boot bundle pulled in the Svelte runtime. Nothing the schema imports may be a `.svelte`/`.svelte.ts` module.';
}
if (message.includes('ceiling')) {
return 'the boot blocks the parser by design, so its size is latency every cold load pays. Narrow the schema, or import fewer modules from it.';
}
if (message.includes('not pure ASCII')) {
return 'the CSP hash is over bytes, so the tag must not depend on the document charset. A non-ASCII literal in the schema (a language name, a currency symbol) is the usual source.';
}
return undefined;
}
/**
* The locale the smoke below compiles against. Any one would do: a
* schema module's declared type is `(defaultLocale: SupportedLocale) =>
* PrefsSchema`, so a module that only answers for one hard-coded locale
* is lying about its own signature. The value is named in the error for
* exactly that reason.
*/
const SMOKE_LOCALE = 'en';
/**
* RUN what was just compiled, once, in a synthetic document, and require
* it to STAMP something — and nothing but strings.
*
* Every other guard here is static — it reads the text. None of them can
* tell a boot that works from one that dies on line one, and a boot that
* dies does it in SILENCE by design: `entry.ts` and `boot()` each wrap
* themselves in a mute `try`/`catch` because this runs in `<head>`, where
* nothing can report. Measured on the two mistakes a site actually makes
* — a `bootPrefsSchema` that throws, and one exported as an object
* instead of a function — the compiler wrote both with exit 0, a valid
* hash and a page that stamped nothing. That is WORSE than the defect
* `--schema` exists to close: the default boot at least painted something
* wrong, a dead one paints nothing and brings the whole flash back.
*
* The document is the smallest one the boot touches: the tag it reads its
* parameters from, and the root it reads the page's own `dir` from and
* writes to — a root that declares nothing. `navigator`, `matchMedia`
* and `localStorage` are absent on purpose — each probe behind them is
* already best-effort, so absence exercises the fallback path instead of
* asserting against a half-built fake of a browser.
*/
function assertBootRuns(script: string): void {
const stamped: Array<[name: string, value: unknown]> = [];
const document = {
currentScript: {
getAttribute: () => JSON.stringify({ defaultLocale: SMOKE_LOCALE, themeIds: [] })
},
documentElement: {
getAttribute: () => null,
setAttribute: (name: string, value: unknown) => {
stamped.push([name, value]);
}
}
};
runInNewContext(script, { document });
if (stamped.length === 0) {
throw new Error(
`it stamped NOTHING when it was run with defaultLocale '${SMOKE_LOCALE}'. The tag swallows its own failures by design, so a browser would say exactly this much: nothing. The usual causes are a \`bootPrefsSchema\` that throws, or one exported as something other than a function.`
);
}
// Counting attributes is not enough: a real browser turns ANY value into
// text, so a dimension that resolves to nothing reaches `<html>` as the
// word `undefined` and the page keeps it for the whole session. The boot
// already leaves out a prefs axis the schema does not declare, exactly
// as the runtime does; what is left here is a DECLARED dimension with no
// string to give — a redefined axis without a default, a dimension that
// resolves to a number or a boolean.
const invalid = stamped.filter(([, value]) => typeof value !== 'string');
if (invalid.length === 0) return;
const listed = invalid.map(([name, value]) => `\`${name}\` = ${String(value)}`).join(', ');
throw new Error(
`it would stamp ${listed} when it was run with defaultLocale '${SMOKE_LOCALE}'. Every attribute the boot stamps must be a string, and the dimension behind each of these resolves to something else. Give it a default, or make it resolve to one of its catalogue's strings.`
);
}
async function main(): Promise<void> {
const script = await buildBootScript();
const { schema, out } = parseGenerateBootArgs(process.argv.slice(2));
// Paths are resolved against the CURRENT DIRECTORY, which is the
// framework's when the command runs through `npm run generate:boot`.
// Said here rather than left to esbuild, whose answer is a resolve
// failure against `boot.ts` and reads like a framework bug.
if (schema !== undefined && !existsSync(schema)) {
throw new Error(
`no schema module at ${schema}\n \`--schema\` is resolved against the current directory (${process.cwd()}). Pass an absolute path, or run the compiler from the directory that path is relative to.`
);
}
const { script } = await compileBoot({ schema });
try {
assertBootScript(script);
assertBootRuns(script);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const hint = guardHint(message);
throw new Error(
[
`boot compile refused the artifact: ${message}`,
schema === undefined ? undefined : `schema module: ${schema}`,
hint
]
.filter((line) => line !== undefined)
.join('\n ')
);
}
const module = renderBootModule(script);
mkdirSync(dirname(BOOT_OUTPUT), { recursive: true });
writeFileSync(BOOT_OUTPUT, module);
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, module);
console.log(
`Generated ${BOOT_OUTPUT} — script ${Buffer.byteLength(script, 'utf8')} bytes, ${bootCspHash(script)}`
`Generated ${out} — script ${Buffer.byteLength(script, 'utf8')} bytes, ${bootCspHash(script)}`
);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await main();
} catch (error) {
// A compiler talks to a CONSUMER. Node's default handler prints the
// message under a source banner and over six frames of the
// framework's own stack, which reads like a crash IN UIX rather than
// an error in the site's schema — and buries the `usage:` block it
// was carrying. The message is the whole product here.
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

@ -0,0 +1,183 @@
/**
* uix-boot-check — the guard that breaks the build when the compiled boot
* on disk no longer describes the source it came from.
*
* The whole boot axis fails SILENTLY by construction. The tag is wrapped
* in a mute `try`/`catch` (it runs in `<head>`, before anything can
* report), a stale artifact stamps values that are merely OLD rather than
* invalid, and a browser that blocks the script leaves no trace in the
* page. So the only guard worth having is one that stops the build: a
* site that edits its preference schema and forgets to recompile must
* learn it from `vite build`, not from a user in Cairo reading an LTR
* page. (`createActiveUix`'s dev audit is the other half — it names the
* axis that moved, in the browser, after the fact.)
*
* It lives in `scripts/` and not under `src/uix` on purpose: it imports
* esbuild and Vite's plugin type, which are the HOST's build tools. The
* framework does not ship its consumer's bundler.
*
* Two shapes, one check:
* - `uixBootCheck()` — a Vite plugin. `buildStart` recompiles with the
* site's own configuration and fails the build on any difference. In
* dev it WARNS instead (a dev server that refuses to start over a
* stale cosmetic artifact helps nobody) and registers every input the
* compile read, so editing a dimension re-runs the check there and
* then instead of waiting for a deploy. That doctrine covers a compile
* that FAILS outright too — a half-typed schema module is the most
* ordinary state a watched file is ever in, and it must cost a line in
* the log, not the session.
* - `checkUixBootArtifact()` — the same check as a promise, for a site
* with no Vite (or a CI job that prefers a test over a build).
*/
import { readFileSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import type { Plugin } from 'vite';
import { BOOT_OUTPUT, compileBoot, renderBootModule } from './generate-boot.ts';
export interface UixBootCheckOptions {
/**
* The module exporting `bootPrefsSchema` the artifact was compiled
* with — the same path passed to `generate-boot --schema`. Omitted,
* the framework's default schema.
*/
readonly schema?: string;
/** The artifact on disk. Defaults to the framework's own. */
readonly out?: string;
}
export interface UixBootCheckResult {
readonly ok: boolean;
/** Why it is stale, in the words a site can act on. Absent when `ok`. */
readonly reason?: string;
/** Every source file the compile read — what dev watches. */
readonly inputs: readonly string[];
}
/**
* Recompile and compare. The comparison is over the artifact's TEXT, not
* over the hash alone: the hash lives in the same file, and a check that
* only compared hashes would pass on a file where someone edited the
* script and left the hash — the exact pair that makes a browser block
* the tag.
*/
export async function checkUixBootArtifact(
options: UixBootCheckOptions = {},
// Filled as the compile reads, and still filled when it throws — see
// `compileBoot`.
read = new Set<string>()
): Promise<UixBootCheckResult> {
const out = options.out === undefined ? BOOT_OUTPUT : resolve(options.out);
const schema = options.schema === undefined ? undefined : resolve(options.schema);
const { script, inputs } = await compileBoot({ schema }, read);
const expected = renderBootModule(script);
let actual: string;
try {
// LF: the artifact is written with LF and a checkout with
// `core.autocrlf` must not be read as a stale one.
actual = readFileSync(out, 'utf8').replace(/\r\n/g, '\n');
} catch {
return { ok: false, reason: `no compiled boot at ${out} — run the boot compiler`, inputs };
}
if (actual === expected) return { ok: true, inputs };
return {
ok: false,
reason: `the compiled boot at ${out} is stale — it no longer matches ${
schema === undefined ? 'the framework schema' : relative(process.cwd(), schema)
} and the modules it imports. Recompile it.`,
inputs
};
}
/**
* A compile that never produced a result — almost always a syntax error
* the site is halfway through typing into its schema module. It is a
* different event from a stale artifact and gets its own sentence,
* because the artifact on disk may well be fine.
*/
function compileFailure(error: unknown): string {
return `could not compile the boot: ${error instanceof Error ? error.message : String(error)}`;
}
export function uixBootCheck(options: UixBootCheckOptions = {}): Plugin {
let serve = false;
// The files the last compile READ. A file that is NOT one of them cannot
// make the artifact stale, and a file that becomes one can only do it
// through an edit to a file that already is — so membership is enough to
// decide whether a save is worth a recompile.
//
// That holds for a compile that FAILED too, as long as the set is the one
// it read before it broke: the file that broke it is in there, and so is
// every file that imports it. Measured, twice: a set kept only from
// SUCCESSFUL compiles was empty on a dev server that started on a
// half-typed schema, and never held a module the schema imported broken
// for the first time — either way the fix was discarded as "not an input"
// and the guard stayed silent. Checking every save instead while broken
// was measured too: ten duplicate warnings from a SvelteKit start-up,
// which writes its own files.
let inputs = new Set<string>();
return {
name: 'uix-boot-check',
configResolved(config) {
serve = config.command === 'serve';
},
async buildStart() {
const read = new Set<string>();
let result: UixBootCheckResult | undefined;
let failure: unknown;
try {
result = await checkUixBootArtifact(options, read);
} catch (error) {
// In dev this is a warning like any other: a guard over a
// cosmetic artifact must never be the reason a dev server
// refuses to start, and the site is about to see the same
// syntax error from Vite itself. On a build there is nothing
// to recover to.
if (!serve) throw error;
failure = error;
}
inputs = read;
// In dev the input files are registered so a later edit re-runs
// the check; on a build there is no "later", so a difference is
// the end of the build.
if (serve) for (const input of read) this.addWatchFile(input);
if (result === undefined) {
this.warn(compileFailure(failure));
return;
}
if (result.ok) return;
if (serve) this.warn(result.reason!);
else this.error(result.reason!);
},
configureServer(server) {
server.watcher.on('change', (file) => {
if (!inputs.has(resolve(file))) return;
const read = new Set<string>();
void checkUixBootArtifact(options, read)
.then((result) => {
inputs = new Set(result.inputs);
if (result.ok) return;
server.config.logger.warn(`[uix-boot-check] ${result.reason}`);
})
// Without this the rejection is unhandled and Node 24 ENDS
// THE PROCESS: one typo in a schema module and the session
// is over. (The compile itself no longer throws from
// outside a promise — see `inputCollector` in
// `generate-boot.ts` — but a guard that watches a file the
// user is editing has to survive every state that file
// passes through.)
.catch((error: unknown) => {
inputs = read;
server.config.logger.warn(`[uix-boot-check] ${compileFailure(error)}`);
});
});
}
};
}

@ -294,8 +294,11 @@ const prefsProjection = createActivePrefsDomProjection({
});
```
The projector is idempotent, subscribes to the available slots and clears the
attributes it manages on `dispose()`.
The projector is idempotent and subscribes to the available slots. Every `dir`
it writes carries UIX's ownership mark (`PREFS_DIR_PROJECTED_ATTR`, whose value
names this instance), and `dispose()` clears the attributes it manages only
while that mark still names it. The doctrine is the
[direction contract's](../../../docs/canon/direction-contract.md), §6.
Attribute contract:

@ -1,5 +1,6 @@
import type { PrefsEnvironment } from '$libs/prefs';
import { DIRECTIONS, type Direction } from '$libs/direction';
import { PREFS_DIR_PROJECTED_ATTR } from '../dom-attrs.ts';
export interface DomEnvironmentOverrides {
/** Injectable for tests / non-global documents. Defaults to `globalThis.document`. */
@ -18,7 +19,10 @@ export interface DomEnvironmentOverrides {
* (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.
* only the two concrete directions are assertions. So does a `dir` carrying
* `PREFS_DIR_PROJECTED_ATTR`, whoever the mark names: UIX wrote it (the boot
* or a projection), and adopting its own output as the page's would freeze
* the direction.
*
* SSR-safe: with no `document` it returns `{}` and the dimension falls through
* to language derivation.
@ -28,6 +32,7 @@ export function readPrefsEnvironmentFromDom(
): PrefsEnvironment {
const doc = overrides.document ?? globalThis.document;
if (doc === undefined) return {};
if (doc.documentElement?.getAttribute(PREFS_DIR_PROJECTED_ATTR) != null) return {};
const raw = doc.documentElement?.getAttribute('dir');
if (typeof raw !== 'string') return {};

@ -26,3 +26,30 @@ export const PREFS_DOM_ATTRS = {
SOUND: 'data-sound',
HAPTIC: 'data-haptic'
} as const;
/**
* UIX's OWNERSHIP mark on the element whose `dir` it writes. The doctrine
* is the direction contract's (`docs/canon/direction-contract.md` §1, §6):
* `<html dir>` is a projection, never a source, and the one `dir` read back
* from the DOM as an assertion is the AUTHOR's — the document template's or
* the server's, which never carries this mark.
*
* Its PRESENCE is all `readPrefsEnvironmentFromDom` reads: a marked `dir` is
* UIX's own output and seeds nothing, so the direction keeps following the
* preferences on this root and on any root created over the same document.
* A script that writes over a marked `dir` is not a source either — at run
* time a direction is asserted with `prefs.setIntent('direction', …)` or
* `options.prefs.environment`, both of which beat the seed.
*
* Its VALUE names the owner: `PREFS_DIR_BOOT_OWNER` for the pre-hydration
* boot, a token per instance for each runtime projection. A projection
* retires its attributes on `dispose` only while the mark still names it:
* the root that replaces it is created BEFORE the old one is destroyed
* (measured on SvelteKit layout navigation), and a blind retire left the
* new root's `<html>` bare. Sema's unstamp checks `data-event-id` for the
* same reason.
*/
export const PREFS_DIR_PROJECTED_ATTR = 'data-dir-projected';
/** The mark's value when the `dir` was written by the pre-hydration boot. */
export const PREFS_DIR_BOOT_OWNER = 'boot';

@ -7,7 +7,7 @@ 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 { PREFS_DOM_ATTRS } from './dom-attrs.ts';
import { PREFS_DIR_PROJECTED_ATTR, PREFS_DOM_ATTRS } from './dom-attrs.ts';
export type ActivePrefsDomTarget = HTMLElement | (() => HTMLElement | null | undefined);
@ -23,9 +23,13 @@ export interface ActivePrefsDomProjection {
dispose(): void;
}
let projections = 0;
export function createActivePrefsDomProjection(
options: ActivePrefsDomProjectionOptions
): ActivePrefsDomProjection {
// This instance's name in the ownership mark (`PREFS_DIR_PROJECTED_ATTR`).
const owner = `projection-${++projections}`;
const slots = {
direction: readActivePrefsSlot<Direction>(options.prefs, 'direction'),
// The dimension's effective value is a BCP-47 tag, which is exactly what
@ -63,6 +67,7 @@ export function createActivePrefsDomProjection(
if (Object.keys(attrs).length === 0) return;
claimDir(target, attrs, owner);
options.dom.apply({ target, attrs });
}
@ -86,9 +91,14 @@ export function createActivePrefsDomProjection(
const target = resolveTarget(options.dom, options.target);
if (!target || managedAttrs.size === 0) return;
// Only what is still OURS. A root created over this one — SvelteKit
// mounts the new layout before it destroys the old — has already
// written these attributes and claimed the mark; retiring them here
// would leave its page without `dir`, `lang` and the rest.
if (target.getAttribute(PREFS_DIR_PROJECTED_ATTR) !== owner) return;
const attrs = Object.fromEntries(
[...managedAttrs].map((attr) => [attr, undefined])
[...managedAttrs, PREFS_DIR_PROJECTED_ATTR].map((attr) => [attr, undefined])
) as Record<string, DomAttrValue>;
options.dom.apply({ target, attrs });
managedAttrs.clear();
@ -99,6 +109,23 @@ export function createActivePrefsDomProjection(
return projection;
}
/**
* Mark the `dir` this write carries as this projection's. Everything UIX
* writes to `dir` is marked, with one exception: the AUTHOR's `dir` — no
* mark, and already saying what the preferences resolve to, because the
* root adopted it as its seed. Claiming it would turn the author's
* assertion into UIX's output, and the next root created over the page
* would stop reading it.
*/
function claimDir(target: HTMLElement, attrs: Record<string, DomAttrValue>, owner: string): void {
const dir = attrs[PREFS_DOM_ATTRS.DIR];
if (dir === undefined || dir === null) return;
const authored =
target.getAttribute(PREFS_DIR_PROJECTED_ATTR) === null &&
target.getAttribute(PREFS_DOM_ATTRS.DIR) === String(dir);
if (!authored) attrs[PREFS_DIR_PROJECTED_ATTR] = owner;
}
function readSlotIntoAttr<T extends DomAttrValue>(
slot: ActivePrefsSlot<T> | undefined,
attrs: Record<string, DomAttrValue>,

@ -1,11 +1,16 @@
import { describe, expect, it } from 'vitest';
import { createActivePrefs, directionDimension, localeDimension } from '$prefs';
import { readPrefsEnvironmentFromDom } from '../adapters/dom-environment.ts';
import { PREFS_DIR_PROJECTED_ATTR } from '../dom-attrs.ts';
function fakeDocument(dir: string | null): Document {
function fakeDocument(dir: string | null, projected = false): Document {
const attrs: Record<string, string | null> = {
dir,
[PREFS_DIR_PROJECTED_ATTR]: projected ? '' : null
};
return {
documentElement: {
getAttribute: (name: string) => (name === 'dir' ? dir : null)
getAttribute: (name: string) => attrs[name] ?? null
}
} as unknown as Document;
}
@ -26,6 +31,16 @@ describe('readPrefsEnvironmentFromDom', () => {
expect(readPrefsEnvironmentFromDom({ document: fakeDocument('auto') })).toEqual({});
});
it('skips a `dir` the preferences projected, and still reads one the page declared', () => {
// UIX marks every `dir` it writes (the boot's, a projection's). Read as
// the page's assertion, that value froze the direction for the whole
// session: a language chosen later could no longer move it.
expect(readPrefsEnvironmentFromDom({ document: fakeDocument('rtl', true) })).toEqual({});
expect(readPrefsEnvironmentFromDom({ document: fakeDocument('rtl', false) })).toEqual({
direction: 'rtl'
});
});
it('is SSR-safe: no document → {}', () => {
expect(readPrefsEnvironmentFromDom({ document: undefined })).toEqual({});
});

@ -61,7 +61,7 @@ import { auditUixBootDelta } from './boot/audit';
import { UIX_REQUIRED_SERVICES, type UixRequiredService } from './services';
import type { ActiveUix, ActiveUixOptions, AttachActiveUixOptions, EngineColor } from './types';
import { connectLangsToPrefs, createLocaleSourceFromPrefs } from './prefs';
import { createDefaultUixPrefsSchema, uixVisualPrefsDimensions } from './prefs-schema';
import { composeUixPrefsSchema } from './prefs-schema';
import { hydratePrefsIntentSync, resolveDefaultPrefsIntentStorage } from './prefs-storage';
import {
ActiveUixDomDisabledError,
@ -124,14 +124,16 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
// app schema SUBSTITUTED the default one whole, which left eidos reading
// four absent slots and degrading to `light` on a dark-mode machine —
// the docs site, dark shell and light ink, measured in Chrome.
schema: {
...uixVisualPrefsDimensions(),
...(options.prefs?.schema ?? createDefaultUixPrefsSchema(defaultLocale))
},
// The merge itself lives in `prefs-schema.ts`: the pre-hydration boot
// runs the SAME one, and two spellings of it drifted into a wrong `dir`
// on multi-language sites.
schema: composeUixPrefsSchema(options.prefs?.schema, defaultLocale),
// 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.
// the app passes explicitly wins over the seed. A `dir` UIX wrote — the
// pre-hydration boot's or an earlier root's projection — carries the
// ownership mark `PREFS_DIR_PROJECTED_ATTR` and is not a declaration.
environment: {
...(inBrowser ? detectBrowserEnvironment() : {}),
...readPrefsEnvironmentFromDom(),

@ -0,0 +1,289 @@
/**
* The staleness guard, judged on the three states a site can be in: in
* sync, compiled from another schema, and with no artifact at all.
*
* The first case is also the framework's own gate — `npm run test` fails
* when `src/uix/active-uix/generated/boot.js` no longer describes the
* modules it was compiled from. It overlaps `generated-boot.test.ts` on
* purpose and does not duplicate it: that suite compares the two
* CONSTANTS a site imports, this one compares the FILE the compiler
* writes, which is what a build recompiles and diffs.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { build, createLogger, createServer, type Logger, type ViteDevServer } from 'vite';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { compileBoot, renderBootModule } from '../../../../scripts/generate-boot.ts';
import { checkUixBootArtifact, uixBootCheck } from '../../../../scripts/uix-boot-check.ts';
const temp = mkdtempSync(join(tmpdir(), 'uix-boot-check-'));
afterAll(() => {
rmSync(temp, { recursive: true, force: true });
});
const MULTILANG_SCHEMA = 'src/uix/active-uix/test/boot-schema-multilang.ts';
/** Compile through the CLI, so the file under test is the one a site writes. */
function compileTo(out: string, schema?: string): void {
execFileSync(
process.execPath,
[
'--import',
'tsx/esm',
'scripts/generate-boot.ts',
...(schema === undefined ? [] : ['--schema', schema]),
'--out',
out
],
{ stdio: 'pipe' }
);
}
describe('uix-boot-check', () => {
it('passes on the artifact this repo ships', async () => {
const result = await checkUixBootArtifact();
expect(result.reason).toBeUndefined();
expect(result.ok).toBe(true);
}, 60_000);
it('watches every module the boot was compiled from, not just the entry', async () => {
// Staleness almost never starts at the entry: it starts at a
// dimension, or at the schema. A guard that watched `entry.ts` alone
// would sit quiet through exactly the edits that break the artifact.
const inputs = (await checkUixBootArtifact()).inputs.map((input) => resolve(input));
expect(inputs).toContain(resolve('src/uix/active-uix/boot/boot.ts'));
expect(inputs).toContain(resolve('src/uix/active-uix/prefs-schema.ts'));
expect(inputs).toContain(resolve('src/arts/prefs/dimensions/direction.ts'));
}, 60_000);
it('fails when the artifact on disk was compiled from another schema', async () => {
const out = join(temp, 'site-boot.js');
compileTo(out, MULTILANG_SCHEMA);
expect((await checkUixBootArtifact({ schema: MULTILANG_SCHEMA, out })).ok).toBe(true);
// The same file, checked against the FRAMEWORK schema: a site that
// recompiled with a flag and then dropped it, or a CI job wired to
// the wrong module. Nothing about the file itself says which schema
// built it — only a recompile does.
const mismatched = await checkUixBootArtifact({ out });
expect(mismatched.ok).toBe(false);
expect(mismatched.reason).toContain('stale');
}, 60_000);
it('sees a body edited under a hash that was left alone', async () => {
// THE pair that makes a browser block the tag: the script moved, the
// hash did not. It is the whole reason this guard compares the
// artifact's TEXT instead of its hash — and the reason that decision
// needs a test and not only the comment that argues it. Degrade the
// comparison to the hash line and everything else here stays green.
const out = join(temp, 'edited-boot.js');
compileTo(out);
const original = readFileSync(out, 'utf8');
writeFileSync(out, original.replace('UIX_BOOT_SCRIPT = "', 'UIX_BOOT_SCRIPT = " '));
const edited = readFileSync(out, 'utf8');
const hashLine = (source: string) =>
source.split('\n').find((line) => line.startsWith('export const UIX_BOOT_CSP_HASH'));
expect(hashLine(edited)).toBe(hashLine(original));
expect(edited).not.toBe(original);
const result = await checkUixBootArtifact({ out });
expect(result.ok).toBe(false);
expect(result.reason).toContain('stale');
}, 60_000);
it('fails when there is no artifact at all', async () => {
const result = await checkUixBootArtifact({ out: join(temp, 'absent.js') });
expect(result.ok).toBe(false);
expect(result.reason).toContain('no compiled boot');
}, 60_000);
});
/**
* The plugin half, inside a REAL Vite: `buildStart`, the build/serve split
* and the dev watcher all run in Vite's own plugin container. The one
* thing synthesised is the file-system event — the test writes a file and
* then emits the `change` chokidar would, with real watching switched off
* so no second, late event can race the assertions.
*
* Every dev assertion WAITS for a warning to appear. The failure mode of
* this guard is a warning that never comes, so the red of each case is a
* timeout, not a wrong value.
*/
describe('uixBootCheck — the Vite plugin', () => {
const schemaWith = (value: string, imports = '') =>
[
"import { enumDimension } from '$prefs/dimensions/primitive';",
imports,
'export function bootPrefsSchema() {',
`\treturn { contrast: enumDimension(['normal', 'more'], { default: ${value} }) };`,
'}',
''
].join('\n');
const HALF_TYPED = 'export function bootPrefsSchema() { return {{{ ;\n';
const IMPORTS_EXTRA = "import { EXTRA } from './extra.ts';";
function site(name: string) {
const root = join(temp, name);
mkdirSync(root, { recursive: true });
const schema = join(root, 'prefs-schema.ts');
const out = join(root, 'boot.js');
return {
root,
schema,
out,
extra: join(root, 'extra.ts'),
async compile() {
writeFileSync(out, renderBootModule((await compileBoot({ schema })).script));
}
};
}
function recordingLogger(): { logger: Logger; warnings: string[] } {
const warnings: string[] = [];
const logger = createLogger('silent');
logger.warn = (message) => void warnings.push(message);
logger.warnOnce = (message) => void warnings.push(message);
return { logger, warnings };
}
function devServer(where: ReturnType<typeof site>, logger: Logger): Promise<ViteDevServer> {
return createServer({
root: where.root,
configFile: false,
appType: 'custom',
customLogger: logger,
optimizeDeps: { noDiscovery: true, include: [] },
server: { middlewareMode: true, watch: { ignored: ['**/*'] } },
plugins: [uixBootCheck({ schema: where.schema, out: where.out })]
});
}
const stale = (warnings: string[]) => warnings.filter((line) => line.includes('is stale'));
const failed = (warnings: string[]) =>
warnings.filter((line) => line.includes('could not compile the boot'));
function edit(server: ViteDevServer, file: string, text: string): void {
writeFileSync(file, text);
server.watcher.emit('change', file);
}
it('fails a BUILD over a stale artifact', async () => {
const where = site('build-stale');
writeFileSync(where.schema, schemaWith("'normal'"));
await where.compile();
writeFileSync(where.schema, schemaWith("'more'"));
const entry = join(where.root, 'main.js');
writeFileSync(entry, 'export {};\n');
await expect(
build({
root: where.root,
configFile: false,
logLevel: 'silent',
build: { write: false, rollupOptions: { input: entry } },
plugins: [uixBootCheck({ schema: where.schema, out: where.out })]
})
).rejects.toThrow('is stale');
}, 60_000);
it('fails a BUILD over a boot that does not compile at all', async () => {
// Dev turns a failed compile into a warning; a build has nothing to
// recover to. Without this case, turning the build's failure into a
// warning too left every other test here green.
const where = site('build-broken');
writeFileSync(where.schema, schemaWith("'normal'"));
await where.compile();
writeFileSync(where.schema, HALF_TYPED);
const entry = join(where.root, 'main.js');
writeFileSync(entry, 'export {};\n');
await expect(
build({
root: where.root,
configFile: false,
logLevel: 'silent',
build: { write: false, rollupOptions: { input: entry } },
plugins: [uixBootCheck({ schema: where.schema, out: where.out })]
})
).rejects.toThrow('Build failed');
}, 60_000);
it('keeps watching after a dev server STARTED on a schema that does not compile', async () => {
// A half-typed module is the state a watched file is most often in.
// Measured: a server that started there kept an EMPTY input set, so
// every later edit was discarded as "not an input" and the guard said
// nothing for the rest of the session.
const where = site('dev-start-broken');
writeFileSync(where.schema, schemaWith("'normal'"));
await where.compile();
writeFileSync(where.schema, HALF_TYPED);
const { logger, warnings } = recordingLogger();
const server = await devServer(where, logger);
try {
expect(failed(warnings)).toHaveLength(1);
edit(server, where.schema, schemaWith("'more'"));
await vi.waitFor(() => expect(stale(warnings)).toHaveLength(1), { timeout: 20_000 });
} finally {
await server.close();
}
}, 60_000);
it('watches a module the schema STARTS importing, from the next compile on', async () => {
// Membership is decided against the inputs of the LAST compile, so
// those inputs have to move with every compile. Frozen at start-up,
// an edit to a module the schema imported later is never checked.
const where = site('dev-new-input');
writeFileSync(where.extra, "export const EXTRA = 'normal';\n");
writeFileSync(where.schema, schemaWith("'normal'"));
await where.compile();
const { logger, warnings } = recordingLogger();
const server = await devServer(where, logger);
try {
expect(warnings).toHaveLength(0);
edit(server, where.schema, schemaWith('EXTRA', IMPORTS_EXTRA));
await vi.waitFor(() => expect(stale(warnings)).toHaveLength(1), { timeout: 20_000 });
await where.compile();
edit(server, where.extra, "export const EXTRA = 'more';\n");
await vi.waitFor(() => expect(stale(warnings)).toHaveLength(2), { timeout: 20_000 });
} finally {
await server.close();
}
}, 60_000);
it('rechecks after a failed compile, whichever file the fix lands in', async () => {
// A failed compile reports no inputs, so the last good set says
// nothing about the file that broke it — here a module the schema
// imports for the first time, broken as it is imported. The fix lands
// in THAT module, which no successful compile ever read.
const where = site('dev-broken-new-input');
writeFileSync(where.schema, schemaWith("'normal'"));
await where.compile();
const { logger, warnings } = recordingLogger();
const server = await devServer(where, logger);
try {
writeFileSync(where.extra, 'export const EXTRA = ;\n');
edit(server, where.schema, schemaWith('EXTRA', IMPORTS_EXTRA));
await vi.waitFor(() => expect(failed(warnings)).toHaveLength(1), { timeout: 20_000 });
edit(server, where.extra, "export const EXTRA = 'more';\n");
await vi.waitFor(() => expect(stale(warnings)).toHaveLength(1), { timeout: 20_000 });
} finally {
await server.close();
}
}, 60_000);
});

@ -0,0 +1,214 @@
/**
* The boot is a COMPILER a site runs, so its command line is a contract:
* `--schema` decides which preference schema the boot resolves with and
* `--out` decides where the site's artifact lands. This suite drives the
* real command line — a child process, the same one the recipe in
* `docs/theming/guide.md` tells a site to put in its `package.json`.
*
* The failure it is really about is the mute one. A flag that is accepted
* and ignored, or a schema module that quietly drags the Svelte runtime
* into a plain `<script>`, both end the same way: a page that looks fine
* to whoever shipped it and is wrong for the user it was compiled for.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, describe, expect, it } from 'vitest';
import { BOOT_OUTPUT } from '../../../../scripts/generate-boot.ts';
const temp = mkdtempSync(join(tmpdir(), 'uix-boot-cli-'));
afterAll(() => {
rmSync(temp, { recursive: true, force: true });
});
interface CliResult {
readonly status: number;
readonly stderr: string;
}
function runCompiler(args: readonly string[]): CliResult {
try {
execFileSync(process.execPath, ['--import', 'tsx/esm', 'scripts/generate-boot.ts', ...args], {
stdio: 'pipe'
});
return { status: 0, stderr: '' };
} catch (error) {
const failure = error as { status?: number; stderr?: Buffer };
return { status: failure.status ?? -1, stderr: String(failure.stderr ?? '') };
}
}
describe('the boot compiler command line', () => {
it('writes the framework artifact, byte for byte, when no schema is named', () => {
const out = join(temp, 'default-boot.js');
expect(runCompiler(['--out', out]).status).toBe(0);
// `--out` moves the file and changes NOTHING else: the checked-in
// artifact is reproducible from the command line.
expect(readFileSync(out, 'utf8')).toBe(readFileSync(BOOT_OUTPUT, 'utf8'));
});
it('compiles a DIFFERENT boot for a site that names its own schema', () => {
const base = join(temp, 'base-boot.js');
const site = join(temp, 'site-boot.js');
expect(runCompiler(['--out', base]).status).toBe(0);
expect(
runCompiler(['--schema', 'src/uix/active-uix/test/boot-schema-multilang.ts', '--out', site])
.status
).toBe(0);
// Same entry, same output path shape, one flag of difference: the
// only thing that can have moved is the schema the boot resolves
// with. A `--schema` that was accepted and ignored dies here.
expect(readFileSync(site, 'utf8')).not.toBe(readFileSync(base, 'utf8'));
});
it('refuses an argument it does not understand instead of ignoring it', () => {
// A compiler that ignores `--schemaa` hands the site the DEFAULT boot
// while the site believes it compiled its own — the silent wrong
// answer this axis exists to remove.
const result = runCompiler(['--schemaa', 'src/uix/active-uix/test/boot-schema-axes.ts']);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('unknown argument');
expect(result.stderr).toContain('usage: generate-boot');
// And it says so as a MESSAGE. Node's default handler buries the
// usage block under a source banner and six frames of framework
// stack, which reads like a crash in UIX rather than a typo.
expect(result.stderr).not.toContain('Node.js v');
expect(result.stderr).not.toContain('at parseGenerateBootArgs');
});
it('refuses an artifact that does not RUN, however well-formed its text is', () => {
// The text guards are static and a dead boot passes all of them.
// Measured before this check existed: both of these compiled with
// exit 0, a valid CSP hash and a page that stamped nothing — and
// nothing anywhere said so, because the tag swallows its own
// failures by design. Worse than the defect `--schema` closes: the
// wrong boot at least painted, a dead one brings the flash back
// whole.
const dead = {
'throws-schema.ts': [
'export function bootPrefsSchema(defaultLocale: string) {',
'\tthrow new Error(`no schema for ${defaultLocale}`);',
'}',
''
],
'object-schema.ts': ["export const bootPrefsSchema = { language: 'not a function' };", '']
};
for (const [name, lines] of Object.entries(dead)) {
const schema = join(temp, name);
writeFileSync(schema, lines.join('\n'));
const out = join(temp, `${name}.js`);
const result = runCompiler(['--schema', schema, '--out', out]);
expect(result.status, name).not.toBe(0);
expect(result.stderr, name).toContain('stamped NOTHING');
// And it writes nothing: a refused compile must not leave a file
// a build could pick up.
expect(existsSync(out), name).toBe(false);
}
});
it('refuses a declared dimension with no string to stamp', () => {
// The run above used to COUNT attributes, and a browser turns any
// value into text: measured, a schema whose dimensions resolved to
// nothing compiled with exit 0 and put `lang="undefined"` on `<html>`
// for the whole session. A redefined axis without a default is the
// same mistake on an axis the boot always stamps.
const schema = join(temp, 'no-default-schema.ts');
writeFileSync(
schema,
[
"import { enumDimension } from '$prefs/dimensions/primitive';",
'',
'export function bootPrefsSchema() {',
"\treturn { density: enumDimension(['compact', 'comfortable'], {}) };",
'}',
''
].join('\n')
);
const out = join(temp, 'no-default-boot.js');
const result = runCompiler(['--schema', schema, '--out', out]);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('`data-density` = undefined');
expect(result.stderr).toContain('must be a string');
expect(existsSync(out)).toBe(false);
});
it('reports a syntax error in the schema instead of dying on an empty metafile', () => {
// `metafile: true` makes esbuild's own client run `JSON.parse('')` on
// a FAILED build, from inside a socket handler — an uncaught
// exception, not a rejection, so no `catch` upstream can hold it.
// Measured: that is what killed a site's dev server over one typo.
// The input list comes from an `onLoad` plugin instead, and a failed
// compile is an ordinary rejection again.
const schema = join(temp, 'broken-schema.ts');
writeFileSync(schema, 'export function bootPrefsSchema(l) { return {{{ ;\n');
const result = runCompiler(['--schema', schema, '--out', join(temp, 'broken-boot.js')]);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Expected identifier');
expect(result.stderr).not.toContain('Unexpected end of JSON input');
});
it('names the directory it resolved a missing `--schema` against', () => {
// The recipe's own trap: `npm run generate:boot` pins the working
// directory to the framework, so a path relative to the SITE lands
// nowhere. esbuild's answer points at `boot.ts` and reads like a
// framework bug; this one points at the site's mistake.
const result = runCompiler(['--schema', 'src/prefs-schema.ts']);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('no schema module at');
expect(result.stderr).toContain('resolved against the current directory');
});
it('refuses a flag left without a value', () => {
const result = runCompiler(['--schema']);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('needs a value');
});
it('fails the compile when a schema module reaches for the `$prefs` barrel', () => {
// Measured: importing `standardPrefsDimensions` from the barrel pulls
// `$state(` into the bundle (and takes it from 15 KB to 56 KB). In a
// plain `<script>` that throws on line one, before a single attribute
// is stamped, and the tag swallows it by design — so the compiler is
// the only place it can be caught.
const schema = join(temp, 'barrel-schema.ts');
writeFileSync(
schema,
[
"import { standardPrefsDimensions } from '$prefs';",
'',
'export function bootPrefsSchema(defaultLocale: string) {',
'\treturn standardPrefsDimensions({',
'\t\tlanguages: [defaultLocale],',
'\t\tlocales: [defaultLocale],',
"\t\tcurrencies: ['USD']",
'\t});',
'}',
''
].join('\n')
);
const result = runCompiler(['--schema', schema, '--out', join(temp, 'barrel-boot.js')]);
expect(result.status).not.toBe(0);
// The message is for whoever wrote the schema, not for the framework.
expect(result.stderr).toContain('$state(');
expect(result.stderr).toContain('`$prefs` barrel');
expect(result.stderr).toContain(schema);
});
});

@ -13,23 +13,41 @@
* instead of a flash a user sees and nobody can reproduce.
*
* Real instances throughout: no fake prefs engine, no fake eidos. The
* only doubles are ENVIRONMENT (`matchMedia`, `localStorage`), which is
* what a double is for — precedent in
* only doubles are ENVIRONMENT (`matchMedia`, `localStorage`,
* `navigator`), which is what a double is for — precedent in
* `src/arts/prefs/test/browser-environment.test.ts`.
*
* **Parameterised by SCHEMA.** A boot is compiled around a preference
* schema, so "boot == runtime" is a claim about a PAIR. Four fixtures:
* the framework default (the shipped artifact), a multi-language site,
* a site that redefines two visual axes, and a site that declares none of
* the optional prefs axes but `motion`. The last three compile their own
* artifact through the boot compiler and hand the runtime the SAME schema
* module — which is the recipe a site follows, run as a test.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createActiveUix } from '../active-uix.svelte';
import { createWebStoragePrefsIntentStorage } from '../prefs-storage';
import { runBootScriptTag } from '../test/boot-script-tag';
import { renderUixBootScript } from './render';
import { bootPrefsSchema as multilangSchema } from '../test/boot-schema-multilang';
import { bootPrefsSchema as axesSchema } from '../test/boot-schema-axes';
import { bootPrefsSchema as partialSchema } from '../test/boot-schema-partial';
import { renderUixBootScript, type UixBootArtifact } from './render';
import { createActiveEidos } from '$uix/eidos';
import type { VisualPreferencePins } from '$uix/eidos/lib/visual-preference';
import {
createPrefsIntentDocument,
PREFS_INTENT_DOCUMENT_KIND,
PREFS_STORAGE_KEY
PREFS_STORAGE_KEY,
type PrefsSchema
} from '$libs/prefs';
import { PREFS_DIR_PROJECTED_ATTR } from '$prefs/dom-attrs';
const BOOT_ATTRS = [
'dir',
@ -94,36 +112,108 @@ function installGlobal(name: string, value: unknown): void {
});
}
beforeEach(() => {
/**
* Not in `BOOT_ATTRS`: the ownership mark is not a preference, and its value
* changes hands by design (the boot's, then the root's projection), so the
* three delta readings stay over the nine attributes that paint or announce.
* It is cleared between cases all the same — the document is shared by the
* whole file.
*/
function clearHtml(): void {
for (const name of BOOT_ATTRS) document.documentElement.removeAttribute(name);
});
document.documentElement.removeAttribute(PREFS_DIR_PROJECTED_ATTR);
}
beforeEach(clearHtml);
afterEach(() => {
for (const restore of restoreGlobals.reverse()) restore();
restoreGlobals = [];
for (const name of BOOT_ATTRS) document.documentElement.removeAttribute(name);
clearHtml();
});
/**
* A site, as the two halves that must come from one schema: the artifact
* its boot compiler produced, and the schema its root composes. `{}` is
* the framework's own default — the shipped artifact, the default schema.
*/
interface BootFixture {
readonly artifact?: UixBootArtifact;
readonly appSchema?: PrefsSchema;
}
const DEFAULT_FIXTURE: BootFixture = {};
const compiled = mkdtempSync(join(tmpdir(), 'uix-boot-fixture-'));
afterAll(() => {
rmSync(compiled, { recursive: true, force: true });
});
/**
* Compile a site's boot THROUGH THE CLI — a child process, not the
* generator's exported function. Two reasons, and the first is not a
* preference: esbuild refuses to load inside this file's jsdom
* environment (`new TextEncoder().encode("") instanceof Uint8Array` is
* false across realms, measured). The second is that the recipe a site
* follows IS the command line, so the fixture exercises the flags.
*/
function compileFixture(module: string, appSchema: PrefsSchema): BootFixture {
const out = join(compiled, `${module}.js`);
execFileSync(
process.execPath,
[
'--import',
'tsx/esm',
'scripts/generate-boot.ts',
'--schema',
`src/uix/active-uix/test/${module}.ts`,
'--out',
out
],
{ stdio: 'pipe' }
);
return { artifact: readBootArtifact(out), appSchema };
}
/**
* Read the two constants out of a written artifact. Not `import()`: the
* file is outside the project root and the module runner will not resolve
* it. A site imports it for real — `generated-boot.test.ts` is the guard
* that the framework's own artifact is importable and in sync.
*/
function readBootArtifact(file: string): UixBootArtifact {
const source = readFileSync(file, 'utf8');
const script = source.match(/^export const UIX_BOOT_SCRIPT = (".*");$/m);
const hash = source.match(/^export const UIX_BOOT_CSP_HASH = (".*");$/m);
if (script === null || hash === null) {
throw new Error(`the compiler wrote an artifact this test cannot read: ${file}`);
}
return { script: JSON.parse(script[1]) as string, hash: JSON.parse(hash[1]) as string };
}
/**
* The criterion, in one helper: **boot == hydration == runtime on a CLEAN
* document**.
*
* The second reading is hydration as a user lives it — the runtime coming up
* over the attributes the boot just wrote. The third exists because one of
* those attributes FEEDS BACK into the resolution: the root seeds its
* those attributes can FEED BACK into the resolution: the root seeds its
* environment with `readPrefsEnvironmentFromDom()`, which reads `<html dir>`,
* and `directionDimension` returns `env.direction` before deriving anything
* from the language. Over a sealed document the `dir` column therefore
* confirms itself, and would keep confirming itself if the two sides derived
* direction differently. On a document that carries nothing, the runtime has
* to reach the boot's answer by the boot's route.
* from the language. The boot marks the `dir` it derived so the root skips
* it; lose the mark and the `dir` column over a stamped document confirms
* itself, and keeps confirming itself if the two sides derive direction
* differently. On a document that carries nothing, the runtime has to reach
* the boot's answer by the boot's route.
*/
function expectRuntimeAgrees(
fixture: BootFixture,
afterBoot: Record<string, string | null>,
storage: ReturnType<typeof createMemoryStorage>,
pins: VisualPreferencePins = {}
): void {
const hydrated = bootRuntime(storage, pins);
const hydrated = bootRuntime(fixture, storage, pins);
try {
expect(snapshotHtmlAttrs()).toEqual(afterBoot);
} finally {
@ -131,7 +221,7 @@ function expectRuntimeAgrees(
}
for (const name of BOOT_ATTRS) document.documentElement.removeAttribute(name);
const clean = bootRuntime(storage, pins);
const clean = bootRuntime(fixture, storage, pins);
try {
expect(snapshotHtmlAttrs()).toEqual(afterBoot);
} finally {
@ -141,12 +231,13 @@ function expectRuntimeAgrees(
/** Boot the real runtime over the same storage + environment the script saw. */
function bootRuntime(
fixture: BootFixture,
storage: ReturnType<typeof createMemoryStorage>,
pins: VisualPreferencePins = {}
) {
const uix = createActiveUix({
langs: { schema: {}, defaultLocale: DEFAULT_LOCALE },
prefs: { storage: createWebStoragePrefsIntentStorage(storage) },
prefs: { schema: fixture.appSchema, storage: createWebStoragePrefsIntentStorage(storage) },
projectPrefs: true,
events: false
});
@ -161,7 +252,7 @@ function bootRuntime(
};
}
describe('pre-hydration boot ↔ runtime delta', () => {
describe('pre-hydration boot ↔ runtime delta — the framework default schema', () => {
it('reproduces a fully persisted intent, attribute for attribute', () => {
const storage = createMemoryStorage({
[PREFS_STORAGE_KEY]: JSON.stringify(
@ -192,7 +283,7 @@ describe('pre-hydration boot ↔ runtime delta', () => {
'data-motion': 'reduce'
});
expectRuntimeAgrees(afterBoot, storage);
expectRuntimeAgrees(DEFAULT_FIXTURE, afterBoot, storage);
});
it('agrees on a bare environment: prefers-color-scheme dark and nothing stored', () => {
@ -206,7 +297,7 @@ describe('pre-hydration boot ↔ runtime delta', () => {
expect(afterBoot['data-mode']).toBe('dark');
expect(afterBoot['data-theme']).toBe('base-dark');
expectRuntimeAgrees(afterBoot, storage);
expectRuntimeAgrees(DEFAULT_FIXTURE, afterBoot, storage);
});
it('agrees on a NAILED instance: the pin beats the stored preference', () => {
@ -230,7 +321,7 @@ describe('pre-hydration boot ↔ runtime delta', () => {
// The instance is nailed the same way the script was told. It stays
// SUBSCRIBED to prefs — `apply()` runs and finds the axis unmoved.
expectRuntimeAgrees(afterBoot, storage, { mode: 'dark' });
expectRuntimeAgrees(DEFAULT_FIXTURE, afterBoot, storage, { mode: 'dark' });
});
it('agrees on a nailed FAMILY: the pin goes through resolveThemeId', () => {
@ -249,7 +340,42 @@ describe('pre-hydration boot ↔ runtime delta', () => {
// tiers ran on the same side of the hydration line.
expect(afterBoot).toMatchObject({ 'data-theme': 'acme-dark', 'data-mode': 'dark' });
expectRuntimeAgrees(afterBoot, storage, { theme: 'acme' });
expectRuntimeAgrees(DEFAULT_FIXTURE, afterBoot, storage, { theme: 'acme' });
});
it("keeps a direction the PAGE declared, and does not claim it as the boot's", () => {
// `<html dir="rtl">` in the document template, on a Spanish site: an
// assertion, and the root adopts it over the language. The boot has to
// read it the same way — the tag runs after the template is parsed —
// and must not mark it, or the root would derive `ltr` over it.
const storage = createMemoryStorage();
installGlobal('localStorage', storage);
installGlobal('matchMedia', fakeMatchMedia({}));
const root = document.documentElement;
root.setAttribute('dir', 'rtl');
runBootScriptTag(renderUixBootScript({ defaultLocale: DEFAULT_LOCALE, themeIds: [] }));
const afterBoot = snapshotHtmlAttrs();
expect(afterBoot).toMatchObject({ dir: 'rtl', lang: DEFAULT_LOCALE });
expect(root.hasAttribute(PREFS_DIR_PROJECTED_ATTR)).toBe(false);
const hydrated = bootRuntime(DEFAULT_FIXTURE, storage);
try {
expect(snapshotHtmlAttrs()).toEqual(afterBoot);
} finally {
hydrated.dispose();
}
// The page as its template wrote it, with no boot at all.
clearHtml();
root.setAttribute('dir', 'rtl');
const clean = bootRuntime(DEFAULT_FIXTURE, storage);
try {
expect(snapshotHtmlAttrs()).toEqual(afterBoot);
} finally {
clean.dispose();
}
});
it('agrees on an unreadable envelope: both sides ignore it', () => {
@ -270,6 +396,355 @@ describe('pre-hydration boot ↔ runtime delta', () => {
expect(afterBoot['data-mode']).toBe('light');
expect(afterBoot['data-density']).toBe('comfortable');
expectRuntimeAgrees(afterBoot, storage);
expectRuntimeAgrees(DEFAULT_FIXTURE, afterBoot, storage);
});
});
/**
* THE FIXTURE THIS ROW EXISTS FOR.
*
* A multi-language site and a browser asking for Arabic. Against a boot
* compiled around the framework's default schema — a catalogue of ONE
* language — the three readings were `ltr/es`, `ltr/ar`, `rtl/ar`,
* measured before the boot marked its own `dir`: the root adopted the
* boot's `dir` as the page's declaration and the Arabic user read an LTR
* page for the whole session. With the mark they are `ltr/es`, `rtl/ar`,
* `rtl/ar` — hydration flips a page that painted LTR. Compiled with the
* site's schema, the boot answers `rtl/ar` before paint.
*/
describe('pre-hydration boot ↔ runtime delta — a multi-language site', () => {
let fixture: BootFixture;
beforeAll(() => {
fixture = compileFixture('boot-schema-multilang', multilangSchema(DEFAULT_LOCALE));
}, 60_000);
it('answers Arabic to an Arabic browser, direction included', () => {
const storage = createMemoryStorage();
installGlobal('localStorage', storage);
installGlobal('matchMedia', fakeMatchMedia({}));
installGlobal('navigator', { languages: ['ar-EG', 'ar'] });
runBootScriptTag(
renderUixBootScript({
defaultLocale: DEFAULT_LOCALE,
themeIds: [],
artifact: fixture.artifact
})
);
const afterBoot = snapshotHtmlAttrs();
expect(afterBoot).toMatchObject({ lang: 'ar', dir: 'rtl' });
expectRuntimeAgrees(fixture, afterBoot, storage);
});
it('still answers the default language to a browser that asks for none', () => {
const storage = createMemoryStorage();
installGlobal('localStorage', storage);
installGlobal('matchMedia', fakeMatchMedia({}));
installGlobal('navigator', { languages: [] });
runBootScriptTag(
renderUixBootScript({
defaultLocale: DEFAULT_LOCALE,
themeIds: [],
artifact: fixture.artifact
})
);
const afterBoot = snapshotHtmlAttrs();
expect(afterBoot).toMatchObject({ lang: DEFAULT_LOCALE, dir: 'ltr' });
expectRuntimeAgrees(fixture, afterBoot, storage);
});
/**
* The session after the load. The runtime comes up over the `dir` the
* boot stamped, and it used to read that attribute as the PAGE's own
* declaration — which `directionDimension` honours before deriving from
* the language — so the direction the boot chose stayed for the whole
* session whatever language the user picked next. Measured in Chromium:
* `ar → en` left `lang=en dir=rtl`, `es → ar` left `lang=ar dir=ltr`.
*/
for (const [from, browser, to, expected] of [
['ar', ['ar-EG', 'ar'], 'en', 'ltr'],
[DEFAULT_LOCALE, ['es-ES'], 'ar', 'rtl']
] as const) {
it(`moves the direction with the language in the session: ${from} → ${to}`, () => {
const storage = createMemoryStorage();
installGlobal('localStorage', storage);
installGlobal('matchMedia', fakeMatchMedia({}));
installGlobal('navigator', { languages: browser });
runBootScriptTag(
renderUixBootScript({
defaultLocale: DEFAULT_LOCALE,
themeIds: [],
artifact: fixture.artifact
})
);
expect(snapshotHtmlAttrs()).toMatchObject({ lang: from });
// The page declared no direction, so the one on `<html>` is the boot's.
expect(document.documentElement.hasAttribute(PREFS_DIR_PROJECTED_ATTR)).toBe(true);
const runtime = bootRuntime(fixture, storage);
try {
runtime.uix.prefs.setIntent('language', to);
expect(snapshotHtmlAttrs()).toMatchObject({ lang: to, dir: expected });
} finally {
runtime.dispose();
}
});
}
});
/**
* A site that redefines two visual axes. The axes are what the MERGE
* ORDER decides: they travel with the root and the app's redefinition
* goes ON TOP, so `composeUixPrefsSchema` spreads the app's schema last.
* Invert it and the site's `compact` / `110` disappear behind the
* framework's `comfortable` / `100` — on BOTH sides, because it is one
* function: the delta stays zero and the page is simply not the site's.
* Measured, with the merge inverted: this case goes red on the first
* assertion, before the delta is even read.
*/
/**
* `<html dir>` is a projection, never a source — the direction contract
* (`docs/canon/direction-contract.md` §1, §6). What UIX writes to `dir`
* carries its ownership mark; the author's `dir` (template, server) is the
* one assertion read back from the DOM; at run time a direction is asserted
* through prefs. Roots are exercised in the order SvelteKit mounts layouts:
* the new one is created while the old one is still alive, and the old one
* is disposed afterwards.
*/
describe('ownership of <html dir> — one site, several roots', () => {
let fixture: BootFixture;
beforeAll(() => {
fixture = compileFixture('boot-schema-multilang', multilangSchema(DEFAULT_LOCALE));
}, 60_000);
const PROJECTED = ['dir', 'lang', 'data-motion', 'data-sound', 'data-haptic'] as const;
function root(
storage: ReturnType<typeof createMemoryStorage>,
options: { eidos?: boolean; environment?: { direction: 'ltr' | 'rtl' } } = {}
) {
const uix = createActiveUix({
langs: { schema: {}, defaultLocale: DEFAULT_LOCALE },
prefs: {
schema: fixture.appSchema,
storage: createWebStoragePrefsIntentStorage(storage),
environment: options.environment
},
projectPrefs: true,
events: false
});
const eidos = options.eidos === false ? undefined : createActiveEidos({ uix, applyDom: true });
let disposed = false;
return {
uix,
dispose() {
if (disposed) return;
disposed = true;
eidos?.dispose();
uix.dispose();
}
};
}
function site(browser: readonly string[], boot: boolean) {
const storage = createMemoryStorage();
installGlobal('localStorage', storage);
installGlobal('matchMedia', fakeMatchMedia({}));
installGlobal('navigator', { languages: browser });
if (boot) {
runBootScriptTag(
renderUixBootScript({
defaultLocale: DEFAULT_LOCALE,
themeIds: [],
artifact: fixture.artifact
})
);
}
return storage;
}
const html = () => document.documentElement;
it("(a) the template's dir wins before hydration, after it, and on a root created over a live one", () => {
html().setAttribute('dir', 'rtl');
const storage = site(['es-ES'], true);
expect(html().getAttribute('dir')).toBe('rtl');
const a = root(storage);
const b = { dispose() {} };
try {
expect(html().getAttribute('dir')).toBe('rtl');
expect(a.uix.prefs.effective().direction).toBe('rtl');
// Adopted, so still the author's: UIX does not claim it.
expect(html().hasAttribute(PREFS_DIR_PROJECTED_ATTR)).toBe(false);
const again = root(storage);
b.dispose = again.dispose;
expect(again.uix.prefs.effective().direction).toBe('rtl');
a.dispose();
expect(html().getAttribute('dir')).toBe('rtl');
} finally {
b.dispose();
a.dispose();
}
});
it('(b) a script is not a source; setIntent and prefs.environment are', () => {
const storage = site(['es-ES'], true);
expect(html().getAttribute('dir')).toBe('ltr');
html().setAttribute('dir', 'rtl');
const scripted = root(storage);
try {
expect(html().getAttribute('dir')).toBe('ltr');
expect(scripted.uix.prefs.effective().direction).toBe('ltr');
scripted.uix.prefs.setIntent('direction', 'rtl');
expect(html().getAttribute('dir')).toBe('rtl');
expect(scripted.uix.prefs.effective().direction).toBe('rtl');
} finally {
scripted.dispose();
}
const fresh = site(['es-ES'], true);
const asserted = root(fresh, { environment: { direction: 'rtl' } });
try {
expect(html().getAttribute('dir')).toBe('rtl');
expect(asserted.uix.prefs.effective().direction).toBe('rtl');
} finally {
asserted.dispose();
}
});
for (const boot of [true, false]) {
it(`(c) a root created over a live one keeps following the language (${boot ? 'with' : 'without'} boot)`, () => {
const storage = site(['ar-EG', 'ar'], boot);
const a = root(storage);
const b = { dispose() {} };
try {
a.uix.prefs.setIntent('language', 'en');
expect(html().getAttribute('dir')).toBe('ltr');
const again = root(storage);
b.dispose = again.dispose;
again.uix.prefs.setIntent('language', 'ar');
expect(html().getAttribute('dir')).toBe('rtl');
again.uix.prefs.setIntent('language', 'en');
expect(html().getAttribute('dir')).toBe('ltr');
} finally {
b.dispose();
a.dispose();
}
});
}
for (const boot of [true, false]) {
it(`(d) create b, then destroy a: <html> keeps what b projected (${boot ? 'with' : 'without'} boot)`, () => {
// The projection's attributes only: eidos retires its own four by
// VALUE and is a different owner (measured, reported, not changed).
const storage = site(['ar-EG', 'ar'], boot);
const a = root(storage, { eidos: false });
const b = root(storage, { eidos: false });
try {
const projectedByB = Object.fromEntries(
PROJECTED.map((name) => [name, html().getAttribute(name)])
);
expect(projectedByB.dir).not.toBeNull();
a.dispose();
expect(
Object.fromEntries(PROJECTED.map((name) => [name, html().getAttribute(name)]))
).toEqual(projectedByB);
expect(html().getAttribute(PREFS_DIR_PROJECTED_ATTR)).not.toBeNull();
} finally {
b.dispose();
a.dispose();
}
// And b, the owner, retires everything when it goes.
for (const name of [...PROJECTED, PREFS_DIR_PROJECTED_ATTR]) {
expect(html().hasAttribute(name), name).toBe(false);
}
});
}
});
describe('pre-hydration boot ↔ runtime delta — a site that redefines axes', () => {
let fixture: BootFixture;
beforeAll(() => {
fixture = compileFixture('boot-schema-axes', axesSchema(DEFAULT_LOCALE));
}, 60_000);
it("paints the site's own density and scaling defaults", () => {
const storage = createMemoryStorage();
installGlobal('localStorage', storage);
installGlobal('matchMedia', fakeMatchMedia({}));
runBootScriptTag(
renderUixBootScript({
defaultLocale: DEFAULT_LOCALE,
themeIds: [],
artifact: fixture.artifact
})
);
const afterBoot = snapshotHtmlAttrs();
// Neither is the framework's default (`comfortable` / `100`).
expect(afterBoot).toMatchObject({ 'data-density': 'compact', 'data-scaling': '110' });
expectRuntimeAgrees(fixture, afterBoot, storage);
});
});
/**
* A site whose schema declares NONE of `language`, `direction`, `sound`
* and `haptic`. The runtime leaves those four attributes alone — measured:
* `null` on a clean document, with or without an Arabic browser. A boot
* that stamped them anyway wrote the TEXT `undefined` into `dir` and `lang`,
* measured in Chromium, and the runtime never removed it: nothing projects
* an attribute for a dimension that does not exist. `motion` is declared,
* so the other half of the rule is pinned too: absence is per axis, not
* all-or-nothing.
*/
describe('pre-hydration boot ↔ runtime delta — a site without the optional prefs axes', () => {
let fixture: BootFixture;
beforeAll(() => {
fixture = compileFixture('boot-schema-partial', partialSchema());
}, 60_000);
it('leaves alone every attribute the runtime leaves alone', () => {
const storage = createMemoryStorage();
installGlobal('localStorage', storage);
installGlobal('matchMedia', fakeMatchMedia({}));
installGlobal('navigator', { languages: ['ar-EG', 'ar'] });
runBootScriptTag(
renderUixBootScript({
defaultLocale: DEFAULT_LOCALE,
themeIds: [],
artifact: fixture.artifact
})
);
const afterBoot = snapshotHtmlAttrs();
expect(afterBoot).toMatchObject({
dir: null,
lang: null,
'data-motion': 'allow',
'data-sound': null,
'data-haptic': null
});
expectRuntimeAgrees(fixture, afterBoot, storage);
});
});

@ -0,0 +1,28 @@
/**
* THE SEAM: the one module `scripts/generate-boot.ts` swaps.
*
* `boot.ts` imports `bootPrefsSchema` from HERE and from nowhere else,
* with a fixed, relative specifier. Nothing is dynamic — the boot's
* purity (no Svelte, no `$app/*`, no runes, one `<script>` body whose
* bytes are a CSP hash) is load-bearing and a runtime lookup would cost
* all of it. The substitution happens at COMPILE time: with no flags the
* generator resolves this file, which answers with the framework's
* default (`./default-schema.ts`); with `--schema <module>` it resolves
* the site's module instead, and the boot is compiled around the site's
* catalogues.
*
* The reason a site ever needs that: the schema decides which languages,
* which currencies and which axes exist. A boot built around the
* framework default knows ONE language, so on a multi-language site it
* stamps `dir="ltr"` for an Arabic user and the page paints left to right
* until hydration flips it. Compiling the boot with the site's schema is
* what closes that.
*
* A site's module must export exactly this shape, and must stay as pure
* as this one: deep imports (`$prefs/standard`, `$prefs/dimensions/*`),
* never the `$prefs` barrel, which re-exports a `.svelte.ts` and would
* put `$state(` in a plain `<script>`. The generator fails the build with
* that message when it happens.
*/
export type { BootPrefsSchema } from './default-schema.ts';
export { bootPrefsSchema } from './default-schema.ts';

@ -20,13 +20,15 @@
*
* Purity is therefore load-bearing: no Svelte, no `$app/*`, nothing
* that needs a compiler. Everything it reaches (`resolvePrefs`,
* `createDefaultUixPrefsSchema`, `detectBrowserEnvironment`,
* `readPrefsIntentDocument`, `resolveThemeId`, the attribute
* constants) is a plain function over plain data.
* `bootPrefsSchema`, `detectBrowserEnvironment`,
* `readPrefsEnvironmentFromDom`, `readPrefsIntentDocument`,
* `resolveThemeId`, the attribute constants) is a plain function over
* plain data.
*/
import { PREFS_DOM_ATTRS } from '$prefs/dom-attrs';
import { PREFS_DIR_BOOT_OWNER, PREFS_DIR_PROJECTED_ATTR, PREFS_DOM_ATTRS } from '$prefs/dom-attrs';
import { detectBrowserEnvironment } from '$prefs/adapters/browser-environment';
import { readPrefsEnvironmentFromDom } from '$prefs/adapters/dom-environment';
import {
readPrefsIntentDocument,
resolvePrefs,
@ -48,7 +50,8 @@ import {
type VisualPreferencePins
} from '$uix/eidos/lib/visual-preference';
import type { ScalingKey } from '$uix/eidos/lib/config-types';
import { createDefaultUixPrefsSchema } from '../prefs-schema';
import { composeUixPrefsSchema } from '../prefs-schema';
import { bootPrefsSchema } from './boot-schema.ts';
export interface UixBootParams {
/**
@ -95,13 +98,26 @@ export function resolveBootAttrs(
environment: PrefsEnvironment,
intent: Record<string, unknown> = {}
): UixBootAttrs {
const schema = createDefaultUixPrefsSchema(params.defaultLocale);
// The SITE's schema, merged the way the runtime merges it — one
// function, two consumers (`composeUixPrefsSchema`). `bootPrefsSchema`
// comes through the seam the generator swaps: the framework default
// with no flags, the site's own module under `--schema`. A boot built
// around a catalogue the app does not have resolves a language and a
// direction the runtime will not, and the page paints both wrong until
// hydration corrects them.
const schema = composeUixPrefsSchema(bootPrefsSchema(params.defaultLocale), params.defaultLocale);
// The five prefs axes are OPTIONAL: a site's schema may declare none of
// them (`lang` and `dir` owned by the server, say), and then they resolve
// to nothing. The four visual axes are not — `composeUixPrefsSchema`
// puts them under every schema. That a declared value is a STRING is
// not something a type can promise over a site's module: the compiler
// runs the artifact and refuses one that stamps anything else.
const effective = resolvePrefs({ schema, environment, intent }) as unknown as {
direction: string;
language: string;
motion: string;
sound: string;
haptic: string;
direction?: string;
language?: string;
motion?: string;
sound?: string;
haptic?: string;
mode: ThemeEffective;
theme: string;
density: Density;
@ -116,13 +132,27 @@ export function resolveBootAttrs(
const theme = resolveVisualPreference(pins.theme, effective.theme);
const mode = resolveVisualPreference(pins.mode, effective.mode);
const themeIds = params.themeIds;
return {
// What the runtime writes for a prefs axis, the boot writes: the
// projection reads no slot for a dimension the schema does not declare,
// and removes the attribute on a nullish value — so on a clean document
// the attribute is ABSENT, and here it is left out. Measured: stamping
// it anyway put `dir="undefined" lang="undefined"` on `<html>` for the
// whole session.
const prefsAttrs = {
[PREFS_DOM_ATTRS.DIR]: effective.direction,
[PREFS_DOM_ATTRS.LANG]: effective.language,
[PREFS_DOM_ATTRS.MOTION]: effective.motion,
[PREFS_DOM_ATTRS.SOUND]: effective.sound,
[PREFS_DOM_ATTRS.HAPTIC]: effective.haptic,
[PREFS_DOM_ATTRS.HAPTIC]: effective.haptic
};
const attrs: UixBootAttrs = {};
for (const [name, value] of Object.entries(prefsAttrs)) {
if (value != null) attrs[name] = value;
}
const themeIds = params.themeIds;
return {
...attrs,
[EIDOS_THEME_ATTR]: resolveThemeId(theme, mode, (id) => themeIds.includes(id)),
[EIDOS_MODE_ATTR]: mode,
[EIDOS_DENSITY_ATTR]: resolveVisualPreference(pins.density, effective.density),
@ -143,11 +173,27 @@ export function resolveBootAttrs(
*/
export function boot(params: UixBootParams): void {
try {
const attrs = resolveBootAttrs(params, detectBrowserEnvironment(), readStoredIntent(params));
// Seeded the way the root seeds its engine: the browser, then what the
// PAGE declares (`<html dir>` in the document template). Without the
// second half the boot overwrote a hand-set direction with the
// language-derived one before the runtime could read it.
const page = readPrefsEnvironmentFromDom();
const attrs = resolveBootAttrs(
params,
{ ...detectBrowserEnvironment(), ...page },
readStoredIntent(params)
);
const root = document.documentElement;
for (const name of Object.keys(attrs)) {
root.setAttribute(name, attrs[name]);
}
// A `dir` the page did not declare is UIX's output, and says so (the
// ownership mark, `PREFS_DIR_PROJECTED_ATTR`): the root does not read it
// as an assertion and keeps deriving, so a language chosen later in the
// session still moves it. The author's `dir` stays unmarked.
if (page.direction === undefined && attrs[PREFS_DOM_ATTRS.DIR] !== undefined) {
root.setAttribute(PREFS_DIR_PROJECTED_ATTR, PREFS_DIR_BOOT_OWNER);
}
} catch {
// Cosmetic pass. Never block the parser over it.
}

@ -0,0 +1,22 @@
/**
* What the seam (`./boot-schema.ts`) answers when a site compiles the
* boot with no `--schema`: UIX's own default schema, the one
* `createActiveUix` falls back to when the app passes none. The two
* consumers agree by construction because it is literally the same
* function.
*/
import type { SupportedLocale } from '$libs/langs';
import type { PrefsSchema } from '$libs/prefs';
import { createDefaultUixPrefsSchema } from '../prefs-schema.ts';
/**
* The shape a site's `--schema` module must export: the app's preference
* schema, built around the locale the app composed UIX with. It is the
* SAME object the app passes to `createActiveUix({ prefs: { schema } })`
* — that is the whole point, and `composeUixPrefsSchema` puts the four
* visual axes under it on both sides.
*/
export type BootPrefsSchema = (defaultLocale: SupportedLocale) => PrefsSchema;
export const bootPrefsSchema: BootPrefsSchema = createDefaultUixPrefsSchema;

@ -71,6 +71,28 @@ export interface RenderUixBootScriptParams extends UixBootParams {
* 76,35 us with it. That is the price of never shipping a body the CSP
* hash cannot describe; a site that finds it too high hoists the render
* out of the request, which is what a compiled boot invites anyway.
*
* **What this door does NOT check, and why (signed decision).** It does
* not verify that `hash` describes `script`. The reason is `node:crypto`
* in this module — node-only again, one row after it stopped being
* node-only — and NOT the cost, which was measured and is not an
* argument: SHA-256 plus base64 over the real 14 739-byte body is
* 51,45 us per render against the 60,68 us `assertBootScript` already
* spends on the same call. The pair is guaranteed where it is BORN
* instead: the compiler emits the two constants together from one text
* (`renderBootModule`), the staleness guard
* (`scripts/uix-boot-check.ts`) compares the written artifact against a
* live compile, `generated-boot.test.ts` checks both constants against a
* live compile, and `render.test.ts` digests the PARSED text of the tag
* this returns and asserts it equals the constant a site puts in its
* policy.
*
* What all of that leaves out is a site holding TWO valid artifacts —
* the framework's and its own — and taking the body from one and the
* hash from the other. Every guard passes (both files are fresh, both
* pairs are internally consistent) and the browser blocks the tag.
* `docs/theming/guide.md` is where that is closed, in step 4 and again
* where the site compiles its own: ONE artifact, named in both places.
*/
readonly artifact?: UixBootArtifact;
}

File diff suppressed because one or more lines are too long

@ -10,6 +10,7 @@ import { enumDimension, stringDimension } from '$prefs/dimensions/primitive';
import { standardPrefsDimensions } from '$prefs/standard';
import { DENSITIES } from '$libs/density';
import type { SupportedLocale } from '$libs/langs';
import type { PrefsSchema } from '$libs/prefs';
import { DEFAULT_SCALING, SCALING_KEYS } from '$uix/eidos/lib/config-types';
const DEFAULT_THEME = 'base';
@ -82,3 +83,35 @@ export function createDefaultUixPrefsSchema(defaultLocale: SupportedLocale) {
}
export type DefaultUixPrefsSchema = ReturnType<typeof createDefaultUixPrefsSchema>;
/**
* THE composition: the schema a UIX composition resolves preferences
* with, whether the resolution happens before paint or after hydration.
*
* One merge, two consumers — `createActiveUix` and the boot's
* `resolveBootAttrs`. Written as a function for exactly that reason: the
* two used to spell it out separately, and they did not say the same
* thing. The boot built `createDefaultUixPrefsSchema(defaultLocale)`, a
* catalogue of ONE language, while the root merged the app's schema over
* the four visual axes. On a multi-language site the boot stamps
* `dir="ltr" lang="es"` for an Arabic user and the page paints that way
* until hydration flips it. (It used to stay LTR for the whole session:
* the root adopted the boot's `dir` as the page's own declaration, until
* UIX started marking the `dir` it writes — `PREFS_DIR_PROJECTED_ATTR`.)
* The boot has to resolve with the SITE's schema, and a site's schema
* reaches it through `scripts/generate-boot.ts --schema`.
*
* The app's schema goes ON TOP: it may redefine an axis (another `theme`
* pattern, a narrower `scaling` catalog) but never omit one — eidos reads
* `mode` / `theme` / `density` / `scaling` through these slots and a root
* without them answers `light` to a dark-mode user.
*/
export function composeUixPrefsSchema(
appSchema: PrefsSchema | undefined,
defaultLocale: SupportedLocale
) {
return {
...uixVisualPrefsDimensions(),
...(appSchema ?? createDefaultUixPrefsSchema(defaultLocale))
};
}

@ -0,0 +1,26 @@
/**
* A site that REDEFINES two of the visual axes: a narrower `density`
* catalogue and a narrower `scaling` one, both with a default of its own.
*
* The axes are the case the merge order decides. They travel with the
* root, so the app's redefinition has to sit ON TOP of
* `uixVisualPrefsDimensions()` — invert the merge and the site's
* defaults vanish behind the framework's, before paint and after
* hydration alike.
*
* Deep imports, same rule as its sibling: this module is compiled into a
* plain `<script>` body.
*/
import { enumDimension } from '$prefs/dimensions/primitive';
import type { SupportedLocale } from '$libs/langs';
import type { PrefsSchema } from '$libs/prefs';
import { createDefaultUixPrefsSchema } from '../prefs-schema.ts';
export function bootPrefsSchema(defaultLocale: SupportedLocale): PrefsSchema {
return {
...createDefaultUixPrefsSchema(defaultLocale),
density: enumDimension(['compact', 'comfortable'] as const, { default: 'compact' }),
scaling: enumDimension(['100', '110'] as const, { default: '110' })
};
}

@ -0,0 +1,27 @@
/**
* A multi-language site's preference schema — the fixture that proves
* the boot resolves with the SITE's catalogues.
*
* It is what a real app composes: the standard preset around its own
* languages, and NOT the four visual axes — those travel with the root
* (`composeUixPrefsSchema` puts them underneath, on both sides of the
* hydration line).
*
* Deep imports on purpose. This module is compiled INTO a `<script>`
* body by `scripts/generate-boot.ts --schema`, where nothing runs the
* Svelte compiler: the `$prefs` barrel re-exports a `.svelte.ts` and
* would put `$state(` in the tag. Every site schema module lives under
* the same rule, and the generator fails the build when it is broken.
*/
import { standardPrefsDimensions } from '$prefs/standard';
import type { SupportedLocale } from '$libs/langs';
import type { PrefsSchema } from '$libs/prefs';
export function bootPrefsSchema(defaultLocale: SupportedLocale): PrefsSchema {
return standardPrefsDimensions({
languages: [defaultLocale, 'ar', 'en'],
locales: [defaultLocale, 'ar', 'en'],
currencies: ['EUR', 'USD'],
defaults: { language: defaultLocale, locale: defaultLocale, currency: 'EUR' }
});
}

@ -0,0 +1,24 @@
/**
* A site that declares only the preferences it uses: `motion`, and one
* dimension of its own. No `language` and no `direction` — `lang` and
* `dir` are the server's (i18n by routing) — and no `sound` or `haptic`.
*
* The runtime tolerates that by design: the prefs projection reads one
* slot per dimension and writes nothing for a dimension the schema does
* not declare. The boot has to answer the same way — an attribute the
* runtime leaves alone is an attribute the boot leaves alone, never one
* stamped with the text `undefined`.
*
* Deep imports, same rule as its siblings: this module is compiled into a
* plain `<script>` body.
*/
import { motionDimension } from '$prefs/dimensions/motion';
import { enumDimension } from '$prefs/dimensions/primitive';
import type { PrefsSchema } from '$libs/prefs';
export function bootPrefsSchema(): PrefsSchema {
return {
motion: motionDimension(),
contrast: enumDimension(['normal', 'more'] as const, { default: 'normal' })
};
}

@ -51,6 +51,13 @@ export interface ActiveUixPrefsOptions<S extends PrefsSchema = PrefsSchema> {
* An app on `attachActiveUix` owns its prefs engine, so nothing can be
* merged for it — it composes `uixVisualPrefsDimensions()` into its own
* schema, or eidos warns and falls back.
*
* **A schema here is also the boot's.** The pre-hydration script resolves
* with a schema COMPILED INTO IT, so a site that passes one must compile
* its own boot (`generate-boot --schema`) or the script answers with
* UIX's default — one language, which on a multi-language site paints
* `dir="ltr"` until hydration corrects it. Recipe and guard:
* `docs/theming/guide.md`.
*/
readonly schema?: S;
readonly environment?: PrefsEnvironment;

Loading…
Cancel
Save

Powered by TurnKey Linux.