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

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 is canon/tsc.md; the transversal systems every recipe must consume are 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

// 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:

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-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, §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 $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:

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

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

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 — the ids eidos registers, derived on the server with listEidosThemes(config) from the config module the root also imports (the hook has no ActiveEidos to ask). 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.
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.

Powered by TurnKey Linux.