|
|
---
|
|
|
title: Eidos Theming — Authoring guides
|
|
|
type: guide
|
|
|
audience: human + agent
|
|
|
status: current
|
|
|
source: migrated from src/uix/eidos/THEMING_GUIDE.md (2026-07-02, docs-book F7.3; originally extracted from THEMING §8 + §9)
|
|
|
---
|
|
|
|
|
|
# Eidos Theming — Authoring guides
|
|
|
|
|
|
> The two theming how-tos: add a new component and define a theme. Extracted
|
|
|
> from [`THEMING.md`](./reference.md) (the E1 reference) to
|
|
|
> live as an E4 guide. The token contract holding all of this together is
|
|
|
> [`canon/tsc.md`](../canon/tsc.md); the transversal systems every recipe
|
|
|
> must consume are [`canon/recipe-contract.md`](../canon/recipe-contract.md).
|
|
|
|
|
|
---
|
|
|
|
|
|
## How to add a new component
|
|
|
|
|
|
Assuming you already have the morfo, the soma and the eidos wrapper
|
|
|
scaffolding (`src/uix/eidos/components/{name}/`):
|
|
|
|
|
|
### Step 1 — Decide which tokens you need
|
|
|
|
|
|
Look at similar components (`button`, `toggle`, `switch`). Identify which
|
|
|
dimensions your component exposes:
|
|
|
|
|
|
- Does it have `data-color`? → palette tokens.
|
|
|
- Does it have `data-variant`? → variant tokens.
|
|
|
- Does it have `data-size`? → size tokens.
|
|
|
- How many parts does it have? → per-part tokens.
|
|
|
|
|
|
### Step 2 — Add the recipe in `lib/recipes/base.ts`
|
|
|
|
|
|
```ts
|
|
|
// Within THEME_BASE_RECIPE_TOKENS:
|
|
|
'my-component': {
|
|
|
// size tokens — scope 'root' (stable)
|
|
|
'height-md': '36px',
|
|
|
'padding-inline-md': 'var(--space-3)',
|
|
|
'gap': 'var(--space-2)',
|
|
|
'radius': 'var(--radius-md)',
|
|
|
|
|
|
// per-color literal definitions — scope 'root'
|
|
|
'primary-solid': 'var(--color-primary-solid)',
|
|
|
'affirm-solid': 'var(--color-affirm-solid)',
|
|
|
'threat-solid': 'var(--color-threat-solid)',
|
|
|
|
|
|
// dynamic palette — 'host' default + per-color overrides
|
|
|
'palette-solid': {
|
|
|
declarations: [
|
|
|
{ value: 'var(--my-component-primary-solid)', scope: 'host' },
|
|
|
{ value: 'var(--my-component-affirm-solid)', scope: 'color:affirm' },
|
|
|
{ value: 'var(--my-component-threat-solid)', scope: 'color:threat' }
|
|
|
]
|
|
|
},
|
|
|
|
|
|
// derived tokens — scope 'host' (deps inferred)
|
|
|
'solid-bg': {
|
|
|
value: 'var(--my-component-palette-solid)',
|
|
|
scope: 'host'
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### Step 3 — Regenerate
|
|
|
|
|
|
```bash
|
|
|
npm run generate:eidos-css
|
|
|
```
|
|
|
|
|
|
If your config violates the TSC, the regeneration tells you:
|
|
|
|
|
|
```
|
|
|
Eidos recipe scope contract violations:
|
|
|
- my-component.solid-bg (scope root): dependency 'palette-solid' is only
|
|
|
declared at scopes [host], none of which is reachable from the
|
|
|
consumer's scope.
|
|
|
```
|
|
|
|
|
|
### Step 4 — Write the recipe CSS
|
|
|
|
|
|
`src/uix/eidos/components/my-component/my-component.css`:
|
|
|
|
|
|
```css
|
|
|
[data-my-component] {
|
|
|
/* Private tokens — only this recipe reads them */
|
|
|
--_my-component-bg: var(--my-component-solid-bg);
|
|
|
--_my-component-radius: var(--my-component-radius);
|
|
|
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
padding-inline: var(--my-component-padding-inline-md);
|
|
|
height: var(--my-component-height-md);
|
|
|
border-radius: var(--_my-component-radius);
|
|
|
background: var(--_my-component-bg);
|
|
|
gap: var(--my-component-gap);
|
|
|
}
|
|
|
|
|
|
[data-my-component][data-disabled] {
|
|
|
opacity: var(--opacity-disabled);
|
|
|
pointer-events: none;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### Step 5 — Wire the CSS
|
|
|
|
|
|
Code-split components import their own CSS from the wrapper `.svelte`;
|
|
|
layout primitives and shared visuals aggregate in `index.css`:
|
|
|
|
|
|
```css
|
|
|
@import './components/my-component/my-component.css';
|
|
|
```
|
|
|
|
|
|
### Step 6 — Verify
|
|
|
|
|
|
```bash
|
|
|
npm run generate:eidos-css
|
|
|
npm test -- src/uix/eidos
|
|
|
npm run morfo:check
|
|
|
node --import tsx/esm scripts/eidos-lint.ts my-component
|
|
|
```
|
|
|
|
|
|
### Common anti-pattern: declaring composed tokens at `:root`
|
|
|
|
|
|
```ts
|
|
|
// ❌ WRONG — the TSC will fail
|
|
|
'my-component': {
|
|
|
'palette-solid': { value: 'var(--color-neutral-solid)', scope: 'host' },
|
|
|
'solid-bg': 'var(--my-component-palette-solid)' // ← implicit 'root' scope, deps live in 'host'
|
|
|
}
|
|
|
|
|
|
// ✓ CORRECT
|
|
|
'my-component': {
|
|
|
'palette-solid': { value: 'var(--color-neutral-solid)', scope: 'host' },
|
|
|
'solid-bg': {
|
|
|
value: 'var(--my-component-palette-solid)',
|
|
|
scope: 'host'
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## How to define a theme
|
|
|
|
|
|
Every theme declares **what it is** — `appearance: 'light' | 'dark'`. It is
|
|
|
mandatory, never inferred: it governs `color-scheme`, the first declaration
|
|
|
of the theme block, which rules the surface the UA paints and eidos cannot
|
|
|
style (the native `<select>` option popup, form controls, scrollbars).
|
|
|
|
|
|
Three words, three meanings, no overlap:
|
|
|
|
|
|
| Word | Means | Lives in |
|
|
|
| ------------ | --------------------------- | ---------------------------- |
|
|
|
| `mode` | the PREFERENCE the user set | `data-mode` |
|
|
|
| `theme` | what is PAINTED | `data-theme` |
|
|
|
| `appearance` | what the theme IS | `ThemeDefinition.appearance` |
|
|
|
|
|
|
A `-light` / `-dark` suffix on a theme id is the default resolver's **lookup**
|
|
|
convention (`${theme}-${mode}`), not a source of truth; the config validator
|
|
|
refuses a theme whose suffix contradicts its declared `appearance`. (The name
|
|
|
`colorScheme` is taken in eidos by the seed-derived palette,
|
|
|
`applyColorScheme` — hence `appearance`.)
|
|
|
|
|
|
Eidos supports **3 modes** of theme definition:
|
|
|
|
|
|
### Mode 1 — A patch over the base theme (recommended)
|
|
|
|
|
|
Change only what you need; everything else follows the base:
|
|
|
|
|
|
```ts
|
|
|
import { ActiveEidos } from '$uix/eidos';
|
|
|
|
|
|
ActiveEidos.create({
|
|
|
themeBase: {
|
|
|
semantics: {
|
|
|
color: {
|
|
|
roles: {
|
|
|
primary: 'blue', // primary uses the Radix blue scale
|
|
|
secondary: 'plum'
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
primitives: {
|
|
|
typography: {
|
|
|
families: {
|
|
|
primary: { family: 'Inter' }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
applyDom: true
|
|
|
});
|
|
|
```
|
|
|
|
|
|
### Mode 2 — A full config
|
|
|
|
|
|
For authoring from scratch:
|
|
|
|
|
|
```ts
|
|
|
import { ActiveEidos, defineEidosConfig } from '$uix/eidos';
|
|
|
|
|
|
const config = defineEidosConfig({
|
|
|
primitives: {
|
|
|
/* … */
|
|
|
},
|
|
|
semantics: {
|
|
|
color: {
|
|
|
/* … */
|
|
|
}
|
|
|
},
|
|
|
themes: {
|
|
|
'acme-light': {
|
|
|
appearance: 'light',
|
|
|
color: {
|
|
|
/* … */
|
|
|
}
|
|
|
},
|
|
|
'acme-dark': {
|
|
|
appearance: 'dark',
|
|
|
color: {
|
|
|
/* … */
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
|
|
|
ActiveEidos.create({ config, applyDom: true });
|
|
|
```
|
|
|
|
|
|
### Mode 3 — A CSS-only theme (no TypeScript)
|
|
|
|
|
|
Eidos publishes the contract as empty CSS for externals to fill:
|
|
|
|
|
|
```ts
|
|
|
const contract = activeEidos.renderContractCss({
|
|
|
themeSelector: "[data-theme='acme-light']"
|
|
|
});
|
|
|
// Output:
|
|
|
// [data-theme='acme-light'] {
|
|
|
// --scale-blue-9: ;
|
|
|
// --color-primary-solid: ;
|
|
|
// --size-md-control-height: ;
|
|
|
// ...
|
|
|
// }
|
|
|
```
|
|
|
|
|
|
The consumer fills in the values:
|
|
|
|
|
|
```css
|
|
|
[data-theme='acme-light'] {
|
|
|
--scale-blue-9: #006adc;
|
|
|
--color-primary-solid: var(--scale-blue-9);
|
|
|
--size-md-control-height: 38px;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
And loads that CSS alongside Eidos's. With `themeSource: 'css'`,
|
|
|
`ActiveEidos` generates no theme of its own.
|
|
|
|
|
|
### Versioned persistence
|
|
|
|
|
|
```ts
|
|
|
const document = activeEidos.toDocument();
|
|
|
// → { kind: 'uix.eidos-config', version: 2, options: {...} }
|
|
|
|
|
|
const json = activeEidos.serialize();
|
|
|
localStorage.setItem('user-theme', json);
|
|
|
|
|
|
// Later
|
|
|
const hydrated = createActiveEidos({
|
|
|
config: JSON.parse(localStorage.getItem('user-theme')!),
|
|
|
prefs,
|
|
|
dom
|
|
|
});
|
|
|
```
|
|
|
|
|
|
The document envelope carries a `version` for future migrations. A v1
|
|
|
document (written before `ThemeDefinition.appearance` became mandatory) is
|
|
|
rejected, not migrated — nobody can guess whether an old theme was light or
|
|
|
dark.
|
|
|
|
|
|
---
|
|
|
|
|
|
## How to kill the dark-mode flash
|
|
|
|
|
|
`data-theme` / `data-mode` and the rest of the preference attributes are
|
|
|
written by `ActiveEidos.apply()` and by the prefs DOM projection — that is,
|
|
|
after hydration. On a static build (no server, no hooks, `src/app.html` with
|
|
|
no script) that is several frames after first paint, and a dark-mode user
|
|
|
watches a light page turn dark.
|
|
|
|
|
|
The fix is a tiny script in `<head>` that stamps the SAME attributes before
|
|
|
paint. It must not be hand-written: a second, hand-rolled copy of the
|
|
|
resolution cascade drifts from the real one on the first rename. UIX compiles
|
|
|
it instead, from the runtime's own modules.
|
|
|
|
|
|
### Step 1 — Generate the artifact (once, and after touching the schema)
|
|
|
|
|
|
```bash
|
|
|
npm run generate:boot
|
|
|
```
|
|
|
|
|
|
Writes `src/uix/active-uix/generated/boot.js`, checked in. It is not a bundle
|
|
|
you have to place: it is two constants — `UIX_BOOT_SCRIPT`, the exact text that
|
|
|
goes inside the `<script>` tag, and `UIX_BOOT_CSP_HASH`, the `script-src` hash
|
|
|
of that text. A vitest keeps both in sync with a live compile, so a stale
|
|
|
artifact fails the gate rather than shipping.
|
|
|
|
|
|
The body never carries your parameters — they ride a `data-uix-boot` attribute
|
|
|
of the same tag — so the hash is constant per framework version. That is what
|
|
|
makes step 4 possible.
|
|
|
|
|
|
### Step 2 — A placeholder in `src/app.html`
|
|
|
|
|
|
```html
|
|
|
<head>
|
|
|
%sveltekit.head% %uix.boot%
|
|
|
</head>
|
|
|
```
|
|
|
|
|
|
**Below `%sveltekit.head%`**, and the order is load-bearing. On a prerendered
|
|
|
site Kit publishes the policy as a `<meta http-equiv="content-security-policy">`
|
|
|
inside `%sveltekit.head%`, and a `<meta>` policy governs only what comes after
|
|
|
it. A boot placed above it is outside the policy — which is evading the CSP,
|
|
|
not satisfying it — and the trick buys nothing the moment the same policy
|
|
|
arrives as a header.
|
|
|
|
|
|
### Step 3 — Three lines in `src/hooks.server.ts`
|
|
|
|
|
|
```ts
|
|
|
import { renderUixBootScript } from '$active-uix/boot/render';
|
|
|
|
|
|
export const handle = ({ event, resolve }) =>
|
|
|
resolve(event, {
|
|
|
transformPageChunk: ({ html }) =>
|
|
|
html.replace('%uix.boot%', () =>
|
|
|
renderUixBootScript({
|
|
|
defaultLocale: 'es',
|
|
|
themeIds: eidos.listThemes()
|
|
|
})
|
|
|
)
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`renderUixBootScript` returns a string and nothing else — the framework does
|
|
|
not own `app.html` and does not install a hook.
|
|
|
|
|
|
**The replacement is a FUNCTION, always.** Given a string,
|
|
|
`String.prototype.replace` expands `$&`, `` $` `` and `$'` inside it, and those
|
|
|
two characters can appear in a theme id or a storage key: `$'` splices in
|
|
|
everything that followed the placeholder and the `<script>` ends early. A
|
|
|
function replacement is inserted verbatim, which is the only form that keeps
|
|
|
the guarantee the tag's own tests make.
|
|
|
|
|
|
One hook covers a fully static build as well: `adapter-static` renders its
|
|
|
prerendered pages and its `200.html` fallback through `server.respond`, so the
|
|
|
transform runs for both.
|
|
|
|
|
|
### Step 4 — Name the hash in `svelte.config.js`
|
|
|
|
|
|
```js
|
|
|
import { UIX_BOOT_CSP_HASH } from './src/uix/active-uix/generated/boot.js';
|
|
|
|
|
|
const config = {
|
|
|
kit: {
|
|
|
csp: {
|
|
|
mode: 'hash',
|
|
|
directives: { 'script-src': ['self', UIX_BOOT_CSP_HASH] }
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
```
|
|
|
|
|
|
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
|
|
|
to quote it: `script-src 'self' 'sha256-…'`. Unquoted, the token is not a valid
|
|
|
source expression at all — the browser ignores it and blocks the script, in
|
|
|
every engine.
|
|
|
|
|
|
> **Adding a hash to a `script-src` that already carries `'unsafe-inline'`
|
|
|
> makes the browser IGNORE `'unsafe-inline'` and block every other inline
|
|
|
> script on the site, including Kit's own start-up.** That is how CSP Level 2
|
|
|
> and later are specified, not a UIX rule, and it is the one thing here that
|
|
|
> can take a page down while it fixes a flash. If your policy relies on
|
|
|
> `'unsafe-inline'` today, move every inline script to a hash (or a nonce) in
|
|
|
> the same change, or do not add this one.
|
|
|
|
|
|
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 });
|
|
|
});
|
|
|
```
|
|
|
|
|
|
> **An app consumes UIX as source, from a workspace in this repository — not as
|
|
|
> a package.** Every `<uix>` above is the path from the app to the repository
|
|
|
> root (`../..` for `apps/<name>`), the compiler runs its `.ts` entry with `tsx`,
|
|
|
> and the aliases your schema module writes (`$prefs/standard`, `$libs/prefs`)
|
|
|
> resolve through `uix.aliases.js`, the one table every tool imports. There is
|
|
|
> no published `bin` and no npm package: the contract is
|
|
|
> [`docs/consuming.md`](../consuming.md).
|
|
|
|
|
|
### The nonce — an emergency exit, not the recipe
|
|
|
|
|
|
`renderUixBootScript({ …, nonce })` exists for a site that runs its OWN policy
|
|
|
and issues its own nonces. That site writes the header by hand, so if it stays
|
|
|
with the hash it also writes the quotes by hand — `'sha256-…'`, step 4. Two
|
|
|
things to know before reaching for the nonce:
|
|
|
|
|
|
- **`event.locals.nonce` does not exist.** Kit starts `locals` as `{}` and
|
|
|
never writes a nonce into it. Passing it renders no attribute at all, the
|
|
|
browser blocks the script, and the flash comes back — silently. The only
|
|
|
nonce Kit exposes is the `%sveltekit.nonce%` substitution, which happens
|
|
|
across the whole template BEFORE `transformPageChunk`; a site that wants it
|
|
|
carries it through a placeholder of its own (`%uix.boot:%sveltekit.nonce%%`
|
|
|
in `app.html`, a regex replacement in the hook).
|
|
|
- **A prerendered page has no nonce at all.** Kit rejects `%sveltekit.nonce%`
|
|
|
in the template and `mode: 'nonce'` when prerendering, because the value
|
|
|
would be baked into a file served to everyone. On `adapter-static` the hash
|
|
|
is the only option.
|
|
|
|
|
|
The nonce must fit the CSP Level 3 nonce grammar (base64 or base64url
|
|
|
characters, at most two trailing `=`), and Kit's always does: it is base64.
|
|
|
Outside that grammar the value is not a valid `'nonce-…'` source — engines that
|
|
|
implement the grammar never match it, and the tolerance some engines show for a
|
|
|
stray `=` is nothing a portable policy can rely on — so `renderUixBootScript`
|
|
|
throws `ActiveUixInvalidBootNonceError` instead of writing it into the tag.
|
|
|
|
|
|
### The three parameters, and why they are yours
|
|
|
|
|
|
- `defaultLocale` — the locale you passed to `createActiveUix({ langs })`. The
|
|
|
preference schema is built around it, so a boot given a different one
|
|
|
resolves a different `lang` than the runtime will.
|
|
|
- `themeIds` — `eidos.listThemes()`. It feeds the "is this family already a
|
|
|
complete theme id?" branch; without it every family gets a `-light` /
|
|
|
`-dark` suffix appended and `data-theme` disagrees with the runtime.
|
|
|
- `pins` — the axes your ROOT `ActiveEidos` nailed, if any
|
|
|
(`ActiveEidos.create({ mode: 'dark' })` is a nailed instance, not a
|
|
|
preference). A pin wins over the resolved preference on both sides, so a
|
|
|
site that nails an axis and does not repeat it here paints the user's
|
|
|
preference before hydration and swaps to the pin after it — this section's
|
|
|
flash, inverted. Nothing pinned, nothing to pass.
|
|
|
|
|
|
```ts
|
|
|
renderUixBootScript({
|
|
|
defaultLocale: 'es',
|
|
|
themeIds: eidos.listThemes(),
|
|
|
pins: { mode: 'dark' }
|
|
|
});
|
|
|
```
|
|
|
|
|
|
Pass `storageKey` as well if you replaced the canonical persistence adapter
|
|
|
(`prefs: { storage }`) with one that writes somewhere else. Key and adapter
|
|
|
move together or the two readers stop agreeing.
|
|
|
|
|
|
`nonce` has its own section above, and `artifact` is for a site that compiles a
|
|
|
narrower boot of its own: it replaces the framework's `{ script, hash }` pair,
|
|
|
through the same function, so the hash in your CSP and the body in your page can
|
|
|
never come from two places.
|
|
|
|
|
|
**In development, the root tells you when the two disagree.** `createActiveUix`
|
|
|
reads what the boot stamped on `<html>` before its own projection writes, reads
|
|
|
it again when the turn is over, and names the axis that moved through the
|
|
|
logger. A forgotten `pin`, a `themeIds` missing a family, a `storageKey` that
|
|
|
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 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.
|