26 KiB
| title | type | audience | status | source |
|---|---|---|---|---|
| Eidos Theming — Authoring guides | guide | human + agent | current | 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(the E1 reference) to live as an E4 guide. The token contract holding all of this together iscanon/tsc.md; the transversal systems every recipe must consume arecanon/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
// 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
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:
[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:
@import './components/my-component/my-component.css';
Step 6 — Verify
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
// ❌ 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:
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:
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:
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:
[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
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)
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
<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
import { renderUixBootScript } from '$active-uix/boot/render';
import { THEME_IDS } from './lib/eidos-config';
export const handle = ({ event, resolve }) =>
resolve(event, {
transformPageChunk: ({ html }) =>
html.replace('%uix.boot%', () =>
renderUixBootScript({
defaultLocale: 'es',
themeIds: THEME_IDS
})
)
});
renderUixBootScript returns a string and nothing else — the framework does
not own app.html and does not install a hook.
themeIds has no instance to ask. The hook runs on the server, where no
ActiveEidos exists, so it cannot call eidos.listThemes(). Keep the config in
ONE module both sides import — the root passes it to
ActiveEidos.create({ config }), the hook derives the list with
listEidosThemes(config) ($uix/eidos/lib/config) — and the list the boot
receives is the list eidos registers, by construction. Worked example:
consuming.md §6.
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
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-srcthat 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, §6.
What you write: one pure module, imported twice.
// 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:
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$prefsbarrel: 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:
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:
{
"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
renderUixBootScriptnever readsartifact.hashat 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, thendir=rtl lang=ararriving 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:
import { UIX_BOOT_SCRIPT, UIX_BOOT_CSP_HASH } from './generated/boot.js';
renderUixBootScript({
defaultLocale: 'es',
themeIds: THEME_IDS,
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.
// 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.
// @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 (../..forapps/<name>), the compiler runs its.tsentry withtsx, and the aliases your schema module writes ($prefs/standard,$libs/prefs) resolve throughuix.aliases.js, the one table every tool imports. There is no publishedbinand no npm package: the contract isdocs/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.noncedoes not exist. Kit startslocalsas{}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 BEFOREtransformPageChunk; a site that wants it carries it through a placeholder of its own (%uix.boot:%sveltekit.nonce%%inapp.html, a regex replacement in the hook).- A prerendered page has no nonce at all. Kit rejects
%sveltekit.nonce%in the template andmode: 'nonce'when prerendering, because the value would be baked into a file served to everyone. Onadapter-staticthe 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 tocreateActiveUix({ langs }). The preference schema is built around it, so a boot given a different one resolves a differentlangthan the runtime will.themeIds— the ids eidos registers, derived on the server withlistEidosThemes(config)from the config module the root also imports (the hook has noActiveEidosto ask). It feeds the "is this family already a complete theme id?" branch; without it every family gets a-light/-darksuffix appended anddata-themedisagrees with the runtime.pins— the axes your ROOTActiveEidosnailed, 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.
renderUixBootScript({
defaultLocale: 'es',
themeIds: THEME_IDS,
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.