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

361 lines
10 KiB

---
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 bundle (once, and after touching the schema)
```bash
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`
```html
<head>
%uix.boot%
%sveltekit.head%
</head>
```
### 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(),
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 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.
### 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.

Powered by TurnKey Linux.