You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/theming/guide.md

694 lines
26 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
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.

Powered by TurnKey Linux.