10 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 bundle (once, and after touching the schema)
npm run generate:boot
Writes src/uix/active-uix/generated/boot.js, checked in. A vitest keeps it
in sync with a live compile, so a stale artifact fails the gate rather than
shipping.
Step 2 — A placeholder in src/app.html
<head>
%uix.boot%
%sveltekit.head%
</head>
Step 3 — Three lines in src/hooks.server.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(),
nonce: event.locals.nonce
})
)
});
renderUixBootScript returns a string and nothing else — the framework does
not own app.html and does not install a hook. Drop nonce when the site has
no CSP; under Kit's CSP pass the nonce Kit issued or the browser refuses the
inline script and you are back to the flash.
The 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—eidos.listThemes(). 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: 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.
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.